Compare commits

...

95 Commits

Author SHA1 Message Date
Patrick b5c68ba809 chore: update package.json scripts and dependencies; remove release script
/ sonarqube (push) Has been cancelled
2026-05-25 12:59:44 -04:00
Patrick 5a4a72e1f4 new file: .gitea/workflows/sonarqube.yml
modified:   .vscode/settings.json
2026-05-25 12:48:55 -04:00
Daniel Volz 47d230ace2 chore: release 1.26.0 (#650) 2026-05-24 16:25:15 +02:00
github-actions[bot] 812b14df03 chore: update test count badges [skip ci] 2026-05-24 12:04:50 +00:00
Daniel Volz c78fc43083 feat(frontend): add intake journal and shared note flows (#648)
* feat(backend): add intake journal APIs and share note support

* feat(frontend): add intake journal and shared note flows
2026-05-24 14:00:30 +02:00
Daniel Volz e4a1b449c6 feat(backend): add intake journal APIs and share note support 2026-05-24 13:36:25 +02:00
Daniel Volz 767ae23843 docs: clarify dev hosts and deployment guidance 2026-05-24 13:36:01 +02:00
dependabot[bot] 3eb56885f9 build(deps): bump ws from 8.20.0 to 8.20.1 in /backend (#641)
Bumps [ws](https://github.com/websockets/ws) from 8.20.0 to 8.20.1.
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.20.0...8.20.1)

---
updated-dependencies:
- dependency-name: ws
  dependency-version: 8.20.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-19 06:33:20 +02:00
dependabot[bot] c5b08b28c1 build(deps): bump the minor-and-patch group in /frontend with 10 updates
Bumps the minor-and-patch group in /frontend with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [i18next](https://github.com/i18next/i18next) | `26.1.0` | `26.2.0` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.14.0` | `1.16.0` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.7` | `17.0.8` |
| [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) | `7.15.0` | `7.15.1` |
| [@playwright/test](https://github.com/microsoft/playwright) | `1.59.1` | `1.60.0` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.6.2` | `25.8.0` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.0.1` | `6.0.2` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.5` | `4.1.6` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.0.12` | `8.0.13` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.5` | `4.1.6` |


Updates `i18next` from 26.1.0 to 26.2.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.1.0...v26.2.0)

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

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

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

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

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

Updates `@vitejs/plugin-react` from 6.0.1 to 6.0.2
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.0.2/packages/plugin-react)

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

Updates `vite` from 8.0.12 to 8.0.13
- [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.13/packages/vite)

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

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: lucide-react
  dependency-version: 1.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: react-i18next
  dependency-version: 17.0.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: react-router-dom
  dependency-version: 7.15.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@playwright/test"
  dependency-version: 1.60.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 25.8.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vite
  dependency-version: 8.0.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vitest
  dependency-version: 4.1.6
  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-18 21:36:07 +02:00
dependabot[bot] 1eb7579706 build(deps-dev): bump the minor-and-patch group in /backend with 4 updates
Bumps the minor-and-patch group in /backend with 4 updates: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node), [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8), [tsx](https://github.com/privatenumber/tsx) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest).


Updates `@types/node` from 25.6.2 to 25.8.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.5 to 4.1.6
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.6/packages/coverage-v8)

Updates `tsx` from 4.21.0 to 4.22.1
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.21.0...v4.22.1)

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

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 25.8.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.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: tsx
  dependency-version: 4.22.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: vitest
  dependency-version: 4.1.6
  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-18 21:35:58 +02:00
dependabot[bot] e69e46f9fc build(deps): bump brace-expansion from 5.0.5 to 5.0.6 in /backend
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 5.0.5 to 5.0.6.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.5...v5.0.6)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 5.0.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-18 21:35:21 +02:00
dependabot[bot] 1f5dd36b5c build(deps-dev): bump lint-staged in the minor-and-patch group (#637)
Bumps the minor-and-patch group with 1 update: [lint-staged](https://github.com/lint-staged/lint-staged).


Updates `lint-staged` from 17.0.4 to 17.0.5
- [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/v17.0.4...v17.0.5)

---
updated-dependencies:
- dependency-name: lint-staged
  dependency-version: 17.0.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-05-18 11:31:26 +02:00
Daniel Volz 545793fdd2 chore: streamline root validation and app loading (#635) 2026-05-16 20:45:26 +02:00
Daniel Volz 2f5fc2d9e9 fix: stabilize medication Playwright gate
* fix: stabilize medication Playwright gate

* fix: satisfy medication Playwright frontend gate
2026-05-15 20:20:18 +02:00
Daniel Volz 4212469cd5 chore: release v1.25.1
Patch release from current main.

- bump backend/package.json to 1.25.1
- bump frontend/package.json to 1.25.1
2026-05-14 01:46:12 +02:00
github-actions[bot] db602d8360 chore: update test count badges [skip ci] 2026-05-13 19:30:27 +00:00
Daniel Volz a95c6e3657 fix: preserve ntfy action buttons on reminder replay
* fix: reactivate notification action groups for reminder replay

* fix: annotate notification action token test rows
2026-05-13 21:25:37 +02:00
Daniel Volz 827d1adc35 chore: release v1.25.0
Release version bump for v1.25.0
2026-05-11 21:39:00 +02:00
github-actions[bot] f8e4b0faaf chore: update test count badges [skip ci] 2026-05-11 19:34:12 +00:00
Daniel Volz c5c75f65e4 feat: add inhaler and injection package types
Closes #558

- add inhaler and injection as supported medication package types
- align refill, planner, dashboard, report, export, and notification wording for the new discrete package types
- include the validated CI repair for formatting and dashboard label parity
2026-05-11 21:29:59 +02:00
Daniel Volz 26e9b39f47 docs: split configuration and development references 2026-05-11 17:03:33 +02:00
Daniel Volz 7362683c2b chore: switch release guidance to manual flow 2026-05-11 17:03:28 +02:00
Daniel Volz 1a7b82d728 chore: remove local workspace artifacts from repo 2026-05-11 17:03:21 +02:00
Daniel Volz 92f6995b1a chore: document thematic worktree isolation 2026-05-11 17:03:14 +02:00
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
187 changed files with 23257 additions and 3912 deletions
+23 -22
View File
@@ -10,36 +10,37 @@ PUID=1000
PGID=1000
PORT=3000
# Docker Compose quickstart serves the frontend on http://localhost:4174.
# Local Vite development usually uses http://localhost:5173 or http://localhost:4173 instead.
CORS_ORIGINS=http://localhost:4174
LOG_LEVEL=warn
# Levels: debug, info, warn, error, silent
# Controls: backend Fastify logging, frontend nginx access logs (Docker),
# and frontend browser console (via build-time injection)
#
# Behavior per level:
# debug — all app logs + all HTTP request logs (including polling endpoints)
# info — all app logs + HTTP request logs, EXCEPT high-frequency polling
# (GET /doses/taken, GET /share/:token/doses, GET /health are hidden)
# warn — only warnings and errors
# error — only errors
# silent — no logs
# Server default timezone for scheduled reminders.
# Users can override this in Settings -> Timezone.
TZ=Europe/Berlin
# Public base URL used for notification action links.
# Required for intake reminder action buttons.
# Use an externally reachable HTTPS URL for remote/self-hosted access.
# PUBLIC_APP_URL=https://medassist.example.com
# If this uses a non-local host, include that origin in CORS_ORIGINS.
# Local Vite development automatically allows this hostname; set
# VITE_ALLOWED_HOSTS only when you need additional development hostnames.
# Log level: debug, info, warn, error, silent
LOG_LEVEL=info
# Rate limit: max requests per minute per IP (default: 100)
# Increase for development/testing environments
# RATE_LIMIT_MAX=100
# API documentation UI + OpenAPI JSON
# Default behavior: enabled outside production, disabled in production
# When enabled, docs are available on /docs and /docs/json.
# Docs are served on /docs and /docs/json.
# Default behavior: enabled outside production, disabled in production.
# Recommended:
# development/staging: OPENAPI_DOCS_ENABLED=true
# production: leave unset, or set OPENAPI_DOCS_ENABLED=false
# development, staging: OPENAPI_DOCS_ENABLED=true
# production: leave unset or set OPENAPI_DOCS_ENABLED=false
# OPENAPI_DOCS_ENABLED=true
# Timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York)
TZ=Europe/Berlin
# =============================================================================
# Authentication (optional - disabled by default for easy setup)
# =============================================================================
@@ -124,7 +125,7 @@ EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
# DEFAULT_EMAIL_INTAKE_REMINDERS=true
# DEFAULT_EMAIL_PRESCRIPTION_REMINDERS=true
# Push notifications (ntfy/gotify via Shoutrrr)
# Push notifications (Shoutrrr URL)
# DEFAULT_SHOUTRRR_ENABLED=false
# DEFAULT_SHOUTRRR_URL=
# DEFAULT_SHOUTRRR_STOCK_REMINDERS=true
@@ -148,6 +149,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 on shared schedule links
# DEFAULT_UPCOMING_TODAY_ONLY=false
# DEFAULT_SHARE_SCHEDULE_TODAY_ONLY=false
# DEFAULT_SHARE_SCHEDULE_TODAY_ONLY=false
+19
View File
@@ -0,0 +1,19 @@
on:
push:
branches: [main]
pull_request:
types: [opened, synchronize, reopened]
jobs:
sonarqube:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Recommended for better reporting relevancy
- name: SonarQube Scan
uses: kitabisa/sonarqube-action@v1.2.0
with:
host: ${{ secrets.SONARQUBE_HOST }}
login: ${{ secrets.SONARQUBE_TOKEN }}
+3 -23
View File
@@ -245,29 +245,10 @@ Apply these rules strictly:
## Task 3: Execute Release
Use the release script — it is **fully non-interactive** (no y/N prompts) and handles the entire flow automatically:
```bash
./scripts/release.sh <patch|minor|major|x.y.z>
```
The script performs these steps in order:
1. Checks out and updates `main`
2. Creates release branch `chore/release-X.Y.Z`
3. Bumps version in `backend/package.json` and `frontend/package.json`
4. Commits, pushes, and creates a PR
5. Waits for CI checks (with retry logic — polls every 15s, waits up to 10 minutes)
6. Merges the PR (squash + delete branch)
7. Creates a signed tag `vX.Y.Z` and pushes it
Use the manual release flow. The repository no longer uses a public release helper script.
**Release precondition:** never start the release flow from a dirty or stale mixed workspace. If the repository root contains unrelated/stale diffs, first switch to a clean base that matches the authoritative remote main.
**The script auto-detects the git remote** (`origin` or `github`) and uses it consistently.
**CI wait behavior:** GitHub Actions can take 10-30 seconds before checks appear on a new PR. The script waits 20 seconds initially, then polls every 15 seconds until checks are registered, then watches them to completion. Maximum wait is 10 minutes.
**On failure:** If CI fails, the script exits with an error. The release branch and PR remain open for inspection. Fix the issue, push to the branch, and the PR will re-run CI. Then merge manually or re-run the script.
### Version Files (MANDATORY)
The version number is displayed in the **About modal** (Settings → About) as a single unified app version. This version is a **clickable link** pointing to the corresponding GitHub release (`https://github.com/DanielVolz/medassist-ng/releases/tag/vX.Y.Z`). The version is read from:
@@ -279,7 +260,7 @@ The version number is displayed in the **About modal** (Settings → About) as a
- The About modal will show the old version
- The version link will point to a non-existent GitHub release page
### Manual Release (if script is not available)
### Manual Release
1. Create release branch:
```bash
@@ -523,8 +504,7 @@ Ready for release?
7. Check current version (git tag + package.json)
8. Analyze changes → determine SemVer level
9. If minor/major: check README.md for needed updates (Task 5)
10. Run ./scripts/release.sh <patch|minor|major>
(or manually: branch → version bump → PR → CI → merge → tag)
10. Run the manual release flow: branch → version bump → PR → CI → merge → tag
11. Write release notes (mandatory for minor/major)
12. Publish GitHub release
+7 -13
View File
@@ -1,19 +1,13 @@
# MedAssist-ng - Copilot Entry Point
## VERY IMPORTANT
This file is intentionally thin. `AGENTS.md` is the canonical governance file for this repository.
- Always keep agent work memory updated in `doku/memory_notes.md` so progress and decisions remain recoverable across context loss.
- Always keep a user-facing work report updated in `doku/report.md` so completed work is easy to review.
- This memory/report rule replaces the previous `doku/APP_BEHAVIOR.md` persistence requirement.
Use `AGENTS.md` as the single source of truth for all governance, workflow, and skill rules.
If rules differ between files, follow `AGENTS.md`.
## Required Startup Steps
1. Read `AGENTS.md` first.
2. Identify triggered skills from `AGENTS.md` and read each referenced `SKILL.md` before making changes.
3. Follow delegation boundaries exactly (`@testing-manager` for testing, `@release-manager` for release orchestration).
## Scope
This file intentionally stays minimal to prevent duplicated or conflicting instructions.
1. Read `AGENTS.md` first when it exists in the workspace.
2. Ensure `doku/memory_notes.md` and `doku/report.md` exist and keep them updated during meaningful work. These files are local-only and must not be staged or committed unless explicitly requested.
3. Identify triggered skills from `AGENTS.md` and read only the matching `SKILL.md` files before making changes.
4. Follow delegation boundaries from `AGENTS.md`: `@testing-manager` for testing work and `@release-manager` for release orchestration, including the documented fallback protocol when a required specialist is unavailable.
5. Keep all non-canonical instruction files brief and aligned with `AGENTS.md`; do not duplicate full governance here.
+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 }}
+3 -1
View File
@@ -24,6 +24,8 @@ on:
concurrency:
group: docker-build-${{ github.ref }}
# Cancel older runs on the same ref so the shared branch tag stays aligned
# with the newest commit instead of racing older builds against newer ones.
cancel-in-progress: true
# Default minimal permissions
@@ -196,7 +198,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: |
+27 -9
View File
@@ -67,12 +67,12 @@ Thumbs.db
.idea/
*.sublime-project
*.sublime-workspace
/.vscode/settings.json
# Keep shared VS Code settings
# .vscode/ is NOT ignored - settings.json is useful for the team
# Keep shared VS Code workspace files, but ignore personal editor settings.
# ===================
# Misc
# Local-only workspace artifacts (never upstream)
# ===================
*.local
.cache/
@@ -82,19 +82,37 @@ Thumbs.db
.claude/
AGENTS.md
docs/TECH_STACK.md
doku/
doku/memory_notes.md
doku/report.md
plan/
/doku/
/plan/
/.planning/
.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-*/
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*
+5 -1
View File
@@ -4,5 +4,9 @@
"vitest.commandLine": "npm test --",
"chat.tools.terminal.autoApprove": {
"test": true
}
},
"gitea.instanceURL": "https://git.cuttlecloud.com",
"gitea.owner": "Patrick",
"gitea.repo": "medassist-ng",
"gitea.token": "cb06442b1b93224196cbe10782770685fe091b65"
}
+38 -184
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-715%2F715-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
<img src="https://img.shields.io/badge/Frontend_Tests-949%2F949-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
</p>
### 🤖 AI-Generated Code
@@ -120,19 +120,19 @@ Share your medication schedule with others via a public link.
</details>
### Medication Setup
- Optional multi-source lookup inside the medication editor on desktop and mobile, prioritizing `RxNorm` and `openFDA` before `EMA`, including package-size suggestions when the source exposes them
- Explicit review-and-apply flow with low-risk suggestions only
- Additional lookup results can be revealed on demand instead of being hard-cut at the initial small result set
- Honest incomplete-coverage messaging with source labels; manual entry always remains available
- Optional medication lookup in the editor on desktop and mobile
- Supports `RxNorm`, `openFDA`, and `EMA` with source labels
- Review-and-apply flow with package-size suggestions when available
- Manual entry remains available
### Smart Inventory
- Track exact stock with package profiles (blister, bottle, tube, liquid container)
- Track exact stock with package profiles (blister, bottle, tube, liquid container, inhaler, injection)
- Display remaining days of supply
- Automatic calculation based on intake schedule
- Manual stock correction supports profile-specific stock semantics (sealed units + loose stock for blister, amount-based stock for bottle/tube/liquid)
- Manual stock correction supports profile-specific stock semantics (sealed units + loose stock for blister, discrete capacity/current stock for bottle, inhaler, and injection, amount-based stock for tube and liquid container)
### Medication Refill
- One-click refill with pack or loose pill options
- One-click refill with package-aware refill options for discrete containers and amount-based packages
- Complete refill history per medication
- Automatic stock updates after each refill
@@ -148,7 +148,6 @@ Share your medication schedule with others via a public link.
### Trip Planner
- Calculate medication demand for a trip or date range with package-aware units
- Plan ahead for vacations, business trips, or hospital stays
- Send demand reports via email or push notification
### Reports
@@ -158,17 +157,20 @@ Share your medication schedule with others via a public link.
### Multi-Person Support
- Manage medications for multiple people
- Share schedules via link. Recipients can mark doses as taken, you see it live
- Optionally allow shared links to view and edit intake journal notes for their visible schedule window
- Optionally embed the medication overview directly on shared links via a settings toggle
### Data Export & Import
- Export all your data (medications, dose history, settings) as JSON
- Export all your data (medications, dose history, intake journal notes, settings) as JSON
- Review validated import contents before replacing current data
- Optionally download a fresh backup before confirming import
- Import previously exported data with automatic ID remapping
- Choose whether to include sensitive data in exports
### Notifications
- Email via SMTP
- Push notifications via ntfy, Pushover, Gotify, Telegram, Discord & more ([Shoutrrr](https://containrrr.dev/shoutrrr/))
- Supports both stock warnings and intake reminders
- Supports stock warnings and intake reminders
### Privacy & Security
- Fully self-hosted
@@ -189,199 +191,51 @@ docker compose -p medassist-ng up -d
Open `http://localhost:4174` and start tracking your medications.
### Verify Deployment
After the containers start, confirm the stack is actually healthy:
1. Run `docker compose ps` and confirm the `backend` service is `healthy` and the `frontend` service is running.
2. Open `http://localhost:3000/health` and confirm the backend responds with JSON that includes `"status":"ok"`.
3. Open `http://localhost:4174` and confirm the app shell loads and can reach the API.
If the frontend loads but API requests fail, check the backend health endpoint first and confirm `CORS_ORIGINS` includes the frontend origin you are using. If you plan to open reminder or share links from another device, set `PUBLIC_APP_URL` to the externally reachable app URL instead of relying on `localhost`.
# Configuration
All configuration is done via environment variables in `.env`. Copy `.env.example` to get started.
Configure the application with environment variables in `.env`. Keep the basic container settings in the README and use the dedicated docs for the full reference.
### General
### Initial Configuration
| Variable | Default | Description |
|----------|---------|-------------|
| `PUID` | `1000` | User ID for container file permissions |
| `PGID` | `1000` | Group ID for container file permissions |
| `PORT` | `3000` | Backend API port |
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS |
| `LOG_LEVEL` | `info` | Log verbosity (`debug`, `info`, `warn`, `error`, `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 |
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed frontend origins |
| `TZ` | `Europe/Berlin` | Default timezone for reminders |
Recommended values for API docs by environment:
| Environment | Recommendation |
|-------------|----------------|
| Development | `OPENAPI_DOCS_ENABLED=true` |
| Staging/Test | `OPENAPI_DOCS_ENABLED=true` |
| Production | leave it unset, or set `OPENAPI_DOCS_ENABLED=false` |
Notes:
- If `OPENAPI_DOCS_ENABLED` is not set, docs are enabled outside production and disabled in production.
- If `OPENAPI_DOCS_ENABLED=true`, docs are available on `/docs` and `/docs/json`.
- If `OPENAPI_DOCS_ENABLED=false`, only the docs are disabled. The API still works normally.
### Authentication
Optional but commonly needed:
| Variable | Default | Description |
|----------|---------|-------------|
| `AUTH_ENABLED` | `false` | Enable user authentication |
| `REGISTRATION_ENABLED` | `false` | Allow new user registrations |
| `JWT_SECRET` | — | Access token signing key (required if auth enabled) |
| `REFRESH_SECRET` | — | Refresh token signing key (required if auth enabled) |
| `COOKIE_SECRET` | — | Cookie signing key (required if auth enabled) |
| `ACCESS_TOKEN_TTL_MINUTES` | `15` | Access token lifetime |
| `REFRESH_TOKEN_TTL_DAYS` | `7` | Refresh token lifetime |
| `PUBLIC_APP_URL` | — | Public base URL for notification action and share links |
Generate secrets with: `openssl rand -hex 32`
Detailed configuration references:
### API Keys (Programmatic API Access)
When `AUTH_ENABLED=true`, you can create personal API keys and call protected endpoints with:
```bash
Authorization: Bearer ma_...
```
Available scopes:
- `read`: read-only access (`GET`, `HEAD`, `OPTIONS`)
- `write`: read + write access
Essential notes:
- Create keys in the app when authentication is enabled.
- The token is shown only once after creation.
- Creating a new key automatically deactivates previously active keys for the same user.
- API keys are stored hashed in the database.
Example usage:
```bash
curl http://localhost:3000/settings \
-H "Authorization: Bearer ma_..."
```
API reference:
- Interactive docs: `/docs`
- OpenAPI JSON: `/docs/json`
- With the bundled frontend ingress, these paths work on the normal app URL as well, for example `http://localhost:4174/docs` when docs are enabled.
- Key management endpoints for authenticated users:
- `GET /auth/api-keys`
- `POST /auth/api-keys`
- `DELETE /auth/api-keys/:id`
### OIDC / SSO
| Variable | Default | Description |
|----------|---------|-------------|
| `OIDC_ENABLED` | `false` | Enable OIDC authentication |
| `OIDC_ISSUER_URL` | — | OIDC provider URL |
| `OIDC_CLIENT_ID` | — | Client ID from OIDC provider |
| `OIDC_CLIENT_SECRET` | — | Client secret from OIDC provider |
| `OIDC_REDIRECT_URI` | — | Full callback URL (e.g., `https://your-domain.com/api/auth/oidc/callback`) |
| `OIDC_SCOPES` | `openid profile email` | Scopes to request |
| `OIDC_USERNAME_CLAIM` | `preferred_username` | Claim for username |
| `OIDC_AUTO_CREATE_USERS` | `true` | Auto-create users on first SSO login |
| `OIDC_PROVIDER_NAME` | `SSO` | Name shown on login button |
### Email (SMTP)
| Variable | Default | Description |
|----------|---------|-------------|
| `SMTP_HOST` | — | SMTP server hostname |
| `SMTP_PORT` | `587` | SMTP server port |
| `SMTP_USER` | — | SMTP username |
| `SMTP_PASS` | — | SMTP password |
| `SMTP_TOKEN` | — | OAuth2/App token (takes precedence over password) |
| `SMTP_FROM` | — | Sender email address |
| `SMTP_SECURE` | `false` | Use TLS |
### Reminders
| Variable | Default | Description |
|----------|---------|-------------|
| `REMINDER_DAYS_BEFORE` | `7` | Days before stock runs out to send reminder |
| `REMINDER_HOUR` | `6` | Hour to send daily reminders (24h format) |
| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
| `EXPIRY_WARNING_DAYS` | `30` | Days before expiry to show warning |
### Push Notifications (Shoutrrr)
MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications, supporting many services with a single URL format.
**Implemented URL schemes in MedAssist:** `ntfy://`, `discord://`, `pushover://`, `gotify://`, `telegram://`, plus direct `https://` webhooks.
This covers common providers like ntfy, Discord, Pushover, Gotify, Telegram, Slack webhooks, and many others via webhook URLs.
Configure push notifications in Settings → Push, or set defaults via environment variables:
| Variable | Default | Description |
|----------|---------|-------------|
| `DEFAULT_SHOUTRRR_ENABLED` | `false` | Enable push notifications by default |
| `DEFAULT_SHOUTRRR_URL` | — | Shoutrrr URL (see examples below) |
| `DEFAULT_SHOUTRRR_STOCK_REMINDERS` | `true` | Send stock warnings via push |
| `DEFAULT_SHOUTRRR_INTAKE_REMINDERS` | `true` | Send intake reminders via push |
### Default User Settings
These defaults are applied when a new user is created. Once a user saves settings in the app, their values take precedence.
Complete list and details:
- [docs/DEFAULT_USER_SETTINGS.md](docs/DEFAULT_USER_SETTINGS.md)
#### URL Examples
**ntfy** (free, self-hostable):
```
ntfy://ntfy.sh/your-topic
ntfy://user:password@your-server.com/topic
```
**Pushover** (free app for iOS/Android):
```
pushover://shoutrrr:API_TOKEN@USER_KEY/
```
Get your keys at [pushover.net](https://pushover.net/):
- **User Key**: Shown on your dashboard (top right)
- **API Token**: Create an application → copy the API Token
**Gotify** (self-hosted):
```
gotify://your-server.com/TOKEN
gotify://your-server.com:443/path/to/gotify/TOKEN?priority=1
```
**Discord**:
```
discord://TOKEN@WEBHOOK_ID
```
**Telegram**:
```
telegram://TOKEN@telegram?chats=CHAT_ID
telegram://TOKEN@telegram?chats=@your_channel,-1001234567890
```
For all services and options, see the [Shoutrrr documentation](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
- Full configuration reference: [docs/CONFIGURATION.md](docs/CONFIGURATION.md)
- Push notifications: [docs/PUSH_NOTIFICATIONS.md](docs/PUSH_NOTIFICATIONS.md)
- Default user settings: [docs/DEFAULT_USER_SETTINGS.md](docs/DEFAULT_USER_SETTINGS.md)
# Development
```bash
docker compose -p medassist-dev -f docker-compose.dev.yml up
```
Development setup and local commands are documented in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).
- Frontend: `http://localhost:5173` (hot reload)
- Backend: `http://localhost:3000`
- API docs UI: `http://localhost:3000/docs` (when docs are enabled)
- OpenAPI JSON: `http://localhost:3000/docs/json` (when docs are enabled)
Useful local commands:
For cross-stack maintenance work and pre-PR validation, the repository root now exposes:
```bash
npm run lint
cd backend && npm run test:run
cd frontend && npm run test:run
npm run check
npm run build
```
# Acknowledgements
@@ -0,0 +1 @@
ALTER TABLE `user_settings` ADD `timezone` text DEFAULT '' NOT NULL;
@@ -0,0 +1,15 @@
CREATE TABLE `intake_journal` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`dose_tracking_id` integer NOT NULL,
`medication_id` integer NOT NULL,
`scheduled_for` integer NOT NULL,
`note` text NOT NULL,
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`dose_tracking_id`) REFERENCES `dose_tracking`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`medication_id`) REFERENCES `medications`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `intake_journal_dose_tracking_id_unique` ON `intake_journal` (`dose_tracking_id`);
@@ -0,0 +1 @@
ALTER TABLE `share_tokens` ADD `allow_journal_notes` integer DEFAULT false NOT NULL;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+21
View File
@@ -99,6 +99,27 @@
"when": 1773348659979,
"tag": "0013_add_share_medication_overview",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1775849300000,
"tag": "0014_add_user_settings_timezone",
"breakpoints": true
},
{
"idx": 15,
"version": "6",
"when": 1778962021119,
"tag": "0015_add_intake_journal",
"breakpoints": true
},
{
"idx": 16,
"version": "6",
"when": 1779044316043,
"tag": "0016_add_share_allow_journal_notes",
"breakpoints": true
}
]
}
+305 -421
View File
File diff suppressed because it is too large Load Diff
+17 -16
View File
@@ -1,6 +1,6 @@
{
"name": "medassist-ng-backend",
"version": "1.22.2",
"version": "1.26.0",
"private": true,
"type": "module",
"scripts": {
@@ -19,36 +19,37 @@
"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.4.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.10",
"@types/node": "^25.5.2",
"@biomejs/biome": "^2.4.15",
"@types/node": "^25.8.0",
"@types/nodemailer": "^8.0.0",
"@types/supertest": "^7.2.0",
"@vitest/coverage-v8": "^4.1.2",
"@vitest/coverage-v8": "^4.1.6",
"drizzle-kit": "^0.31.10",
"pino-pretty": "^13.1.3",
"supertest": "^7.2.2",
"tsx": "^4.19.0",
"typescript": "^6.0.2",
"tsx": "^4.22.1",
"typescript": "^6.0.3",
"vitest": "^4.0.16"
},
"overrides": {
+55
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`,
@@ -74,6 +76,7 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_channel text`,
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_med_names text`,
`ALTER TABLE refill_history ADD COLUMN used_prescription integer NOT NULL DEFAULT 0`,
`ALTER TABLE share_tokens ADD COLUMN allow_journal_notes integer NOT NULL DEFAULT 0`,
];
for (const sql of alterMigrations) {
@@ -95,6 +98,41 @@ 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 intake_journal (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
dose_tracking_id INTEGER NOT NULL REFERENCES dose_tracking(id) ON DELETE CASCADE,
medication_id INTEGER NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
scheduled_for INTEGER NOT NULL,
note TEXT NOT NULL,
created_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`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 +158,26 @@ 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 intake_journal_dose_tracking_id_unique ON intake_journal(dose_tracking_id)`,
`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)`,
];
+2
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,
@@ -99,6 +100,7 @@ export function getTableCreationSQL(): string[] {
token text NOT NULL UNIQUE,
taken_by text NOT NULL,
schedule_days integer NOT NULL DEFAULT 30,
allow_journal_notes integer NOT NULL DEFAULT 0,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
expires_at integer,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
+65 -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
@@ -178,10 +180,48 @@ export const shareTokens = sqliteTable("share_tokens", {
token: text("token", { length: 64 }).notNull().unique(),
takenBy: text("taken_by", { length: 100 }).notNull(),
scheduleDays: integer("schedule_days").notNull().default(30),
allowJournalNotes: integer("allow_journal_notes", { mode: "boolean" }).notNull().default(false),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
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 +233,29 @@ 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
});
// =============================================================================
// Intake Journal - Optional owner-scoped note for a tracked dose event
// =============================================================================
export const intakeJournal = sqliteTable("intake_journal", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
doseTrackingId: integer("dose_tracking_id")
.notNull()
.unique()
.references(() => doseTracking.id, { onDelete: "cascade" }),
medicationId: integer("medication_id")
.notNull()
.references(() => medications.id, { onDelete: "cascade" }),
scheduledFor: integer("scheduled_for", { mode: "timestamp" }).notNull(),
note: text("note").notNull(),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
// =============================================================================
+14 -2
View File
@@ -109,6 +109,8 @@ type TranslationKeys = {
stockTitle: string;
stockTitleMultiple: string;
intakeTitle: string;
intakeTakenConfirmation: string;
intakeSkippedConfirmation: string;
pillsLeft: string;
daysLeft: string;
pillsAt: string;
@@ -179,6 +181,8 @@ type TranslationKeys = {
common: {
pill: string;
pills: string;
puffs: string;
injections: string;
units: string;
ml: string;
blister: string;
@@ -209,7 +213,7 @@ const translations: Record<Language, TranslationKeys> = {
descriptionLow: "The following medications are running low and should be reordered soon:",
tableHeaders: {
medication: "Medication",
pills: "Pills",
pills: "Available",
days: "Days",
runsOut: "Runs Out",
},
@@ -234,6 +238,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}",
@@ -301,6 +307,8 @@ const translations: Record<Language, TranslationKeys> = {
common: {
pill: "pill",
pills: "pills",
puffs: "puffs",
injections: "injections",
units: "units",
ml: "ml",
blister: "blister",
@@ -329,7 +337,7 @@ const translations: Record<Language, TranslationKeys> = {
descriptionLow: "Die folgenden Medikamente werden knapp und sollten bald nachbestellt werden:",
tableHeaders: {
medication: "Medikament",
pills: "Tabletten",
pills: "Verfuegbar",
days: "Tage",
runsOut: "Aufgebraucht",
},
@@ -355,6 +363,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}",
@@ -424,6 +434,8 @@ const translations: Record<Language, TranslationKeys> = {
common: {
pill: "Tablette",
pills: "Tabletten",
puffs: "Hübe",
injections: "Injektionen",
units: "Einheiten",
ml: "ml",
blister: "Blister",
+63 -4
View File
@@ -21,8 +21,10 @@ import { authRoutes } from "./routes/auth.js";
import { doseRoutes } from "./routes/doses.js";
import { exportRoutes } from "./routes/export.js";
import { healthRoutes } from "./routes/health.js";
import { intakeJournalRoutes } from "./routes/intake-journal.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 +81,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;
@@ -95,6 +110,7 @@ async function registerApiDocs(app: FastifyInstance, enabled: boolean) {
{ name: "health", description: "Service health endpoints" },
{ name: "auth", description: "Authentication and profile endpoints" },
{ name: "api-keys", description: "Programmatic API key management" },
{ name: "intake-journal", description: "Owner-only intake journal CRUD and history endpoints" },
{ name: "medication-enrichment", description: "Medication search and enrichment endpoints" },
{ name: "settings", description: "User settings and notification test endpoints" },
],
@@ -166,6 +182,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 +199,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,8 +247,10 @@ 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(intakeJournalRoutes);
await app.register(exportRoutes);
await app.register(refillRoutes);
await app.register(reportRoutes);
@@ -266,8 +303,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,8 +349,10 @@ 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(intakeJournalRoutes);
await app.register(exportRoutes);
await app.register(refillRoutes);
await app.register(reportRoutes);
@@ -309,6 +366,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 +381,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) {
+1 -1
View File
@@ -136,7 +136,7 @@ async function tryApiKeyAuth(request: FastifyRequest, reply: FastifyReply): Prom
}
const [user] = await db.select().from(users).where(eq(users.id, keyRow.userId));
if (!user || !user.isActive) {
if (!user?.isActive) {
reply.status(401).send({ error: "User not found", code: "USER_NOT_FOUND" });
throw new Error("USER_NOT_FOUND");
}
+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
});
+3 -3
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",
});
}
@@ -438,7 +438,7 @@ export async function authRoutes(app: FastifyInstance) {
// Get user
const [user] = await db.select().from(users).where(eq(users.id, decoded.sub));
if (!user || !user.isActive) {
if (!user?.isActive) {
return reply.status(401).send({ error: "User not found or disabled", code: "USER_INVALID" });
}
@@ -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",
});
}
+593 -67
View File
@@ -1,18 +1,26 @@
import { and, eq } from "drizzle-orm";
import { and, eq, inArray } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { doseTracking, medications, shareTokens, userSettings } from "../db/schema.js";
import { doseTracking, intakeJournal, medications, shareTokens, userSettings } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import { computeMedicationCurrentStock } from "../services/current-stock.js";
import { markDoseTakenForUser } from "../services/dose-tracking-service.js";
import {
getIntakeJournalForDoseEvent,
resolveTrackedDoseEventForUser,
upsertIntakeJournalForDoseEvent,
} from "../services/intake-journal-service.js";
import type { AuthUser } from "../types/fastify.js";
import { toLocalDateTimeOffsetString } from "../utils/local-date-time.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
tokenParamsSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import { redactTokenForLog } from "../utils/redaction.js";
import {
parseIntakesJson,
parseLocalDateTime,
@@ -31,6 +39,10 @@ const shareDoseSchema = z.object({
doseId: z.string().min(1, "doseId is required"),
});
const shareJournalUpsertSchema = z.object({
note: z.string().max(4000),
});
const dismissDosesSchema = z.object({
doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"),
});
@@ -55,12 +67,73 @@ const doseReadResponseSchema = {
markedBy: { type: ["string", "null"] },
takenSource: { type: "string" },
dismissed: { type: "boolean" },
hasJournalNote: { type: "boolean" },
},
},
},
},
} as const;
const shareJournalEntrySchema = {
type: "object",
required: [
"doseTrackingId",
"doseId",
"medicationId",
"medicationName",
"scheduledFor",
"dismissed",
"takenSource",
"note",
"updatedAt",
],
properties: {
doseTrackingId: { type: "integer" },
doseId: { type: "string" },
medicationId: { type: "integer" },
medicationName: { type: "string" },
scheduledFor: { type: "string", format: "date-time" },
takenAt: { type: ["string", "null"], format: "date-time" },
dismissed: { type: "boolean" },
takenSource: { type: "string", enum: ["manual", "automatic"] },
markedBy: { type: ["string", "null"] },
note: { type: ["string", "null"] },
updatedAt: { type: ["string", "null"], format: "date-time" },
createdAt: { type: ["string", "null"], format: "date-time" },
},
additionalProperties: false,
} as const;
const shareJournalResponseSchema = {
type: "object",
required: ["entry"],
properties: {
entry: shareJournalEntrySchema,
},
additionalProperties: false,
} 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;
}
function serializeJournalTakenAt(value: Date | null, dismissed: boolean): string | null {
if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
return null;
}
if (dismissed && value.getTime() <= 0) {
return null;
}
return value.toISOString();
}
// Helper to get user ID from request
// Returns anonymous user ID when auth is disabled
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
@@ -125,6 +198,10 @@ async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseI
return false;
}
if (!isDoseInsideShareScheduleWindow(share, parsedDose)) {
return false;
}
const [medication] = await db
.select()
.from(medications)
@@ -162,6 +239,24 @@ async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseI
return expectedPersons.includes(parsedDose.personSuffix);
}
function getLocalDayStartMs(value: Date | number): number {
const date = typeof value === "number" ? new Date(value) : new Date(value.getTime());
date.setHours(0, 0, 0, 0);
return date.getTime();
}
function isDoseInsideShareScheduleWindow(share: typeof shareTokens.$inferSelect, parsedDose: ParsedDoseId): boolean {
const scheduleDays = Math.max(1, share.scheduleDays ?? 30);
const todayStart = getLocalDayStartMs(new Date());
const earliestVisible = new Date(todayStart);
earliestVisible.setDate(earliestVisible.getDate() - (scheduleDays - 1));
const latestVisibleExclusive = new Date(todayStart);
latestVisibleExclusive.setDate(latestVisibleExclusive.getDate() + scheduleDays);
const doseDayStart = getLocalDayStartMs(parsedDose.timestampMs);
return doseDayStart >= earliestVisible.getTime() && doseDayStart < latestVisibleExclusive.getTime();
}
async function isDoseOutOfStock(options: {
userId: number;
doseId: string;
@@ -216,6 +311,81 @@ async function isDoseOutOfStock(options: {
);
}
async function markDoseSkippedForUser(input: {
userId: number;
doseId: string;
}): Promise<"created" | "updated" | "already_skipped"> {
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)));
if (existing) {
if (existing.dismissed) {
return "already_skipped";
}
await db
.update(doseTracking)
.set({ dismissed: true })
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)));
return "updated";
}
await db.insert(doseTracking).values({
userId: input.userId,
doseId: input.doseId,
markedBy: null,
takenAt: new Date(0),
dismissed: true,
});
return "created";
}
async function undoDoseSkippedForUser(input: { userId: number; doseId: string }): Promise<boolean> {
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)));
if (!existing?.dismissed) {
return false;
}
const hasRealTakenTimestamp =
existing.takenAt instanceof Date ? existing.takenAt.getTime() > 0 : Boolean(existing.takenAt);
if (existing.markedBy !== null || hasRealTakenTimestamp) {
await db.update(doseTracking).set({ dismissed: false }).where(eq(doseTracking.id, existing.id));
return true;
}
await db.delete(doseTracking).where(eq(doseTracking.id, existing.id));
return true;
}
function buildSharedJournalEntryDto(input: {
event: NonNullable<Awaited<ReturnType<typeof resolveTrackedDoseEventForUser>>>;
journalEntry: Awaited<ReturnType<typeof getIntakeJournalForDoseEvent>>;
}) {
const { event, journalEntry } = input;
return {
doseTrackingId: event.doseTrackingId,
doseId: event.doseId,
medicationId: event.medicationId,
medicationName: event.medicationName,
scheduledFor: toLocalDateTimeOffsetString(journalEntry?.scheduledFor ?? event.scheduledFor),
takenAt: serializeJournalTakenAt(event.takenAt, event.dismissed),
dismissed: event.dismissed,
takenSource: event.takenSource,
markedBy: event.markedBy,
note: journalEntry?.note ?? null,
updatedAt: journalEntry?.updatedAt?.toISOString() ?? null,
createdAt: journalEntry?.createdAt?.toISOString() ?? null,
};
}
// =============================================================================
// Dose Tracking Routes
// =============================================================================
@@ -223,7 +393,13 @@ export async function doseRoutes(app: FastifyInstance) {
applyOpenApiRouteStandards(app, {
tag: "doses",
protectedByDefault: false,
protectedPaths: [/^\/doses\/taken$/, /^\/doses\/taken\/:doseId$/, /^\/doses\/dismiss$/],
protectedPaths: [
/^\/doses\/taken$/,
/^\/doses\/taken\/:doseId$/,
/^\/doses\/dismiss$/,
/^\/doses\/skip$/,
/^\/doses\/skip\/:doseId$/,
],
});
// ---------------------------------------------------------------------------
@@ -301,40 +477,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 };
}
);
@@ -385,6 +549,83 @@ export async function doseRoutes(app: FastifyInstance) {
}
);
// ---------------------------------------------------------------------------
// POST /doses/skip - PROTECTED: Mark a single dose as skipped
// ---------------------------------------------------------------------------
app.post<{ Body: z.infer<typeof markDoseSchema> }>(
"/doses/skip",
{
preHandler: requireAuth,
schema: {
tags: ["doses"],
security: protectedEndpointSecurity,
body: {
type: "object",
required: ["doseId"],
properties: {
doseId: { type: "string", minLength: 1 },
},
},
response: {
200: {
type: "object",
properties: {
success: { type: "boolean" },
message: { type: "string" },
},
},
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const parsed = markDoseSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) });
}
const status = await markDoseSkippedForUser({ userId, doseId: parsed.data.doseId });
if (status === "already_skipped") {
return { success: true, message: "Already skipped" };
}
return { success: true };
}
);
// ---------------------------------------------------------------------------
// DELETE /doses/skip/:doseId - PROTECTED: Undo a single skipped dose
// ---------------------------------------------------------------------------
app.delete<{ Params: { doseId: string } }>(
"/doses/skip/:doseId",
{
preHandler: requireAuth,
schema: {
tags: ["doses"],
security: protectedEndpointSecurity,
params: {
type: "object",
required: ["doseId"],
properties: {
doseId: { type: "string", minLength: 1 },
},
},
response: {
200: { type: "object", properties: { success: { type: "boolean" } } },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
await undoDoseSkippedForUser({ userId, doseId: request.params.doseId });
return { success: true };
}
);
// ---------------------------------------------------------------------------
// POST /doses/dismiss - PROTECTED: Dismiss missed doses without deducting stock
// ---------------------------------------------------------------------------
@@ -423,39 +664,18 @@ 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)
.set({ dismissed: true })
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
dismissedCount++;
}
} else {
// Create new dismissed record
await db.insert(doseTracking).values({
userId,
doseId,
markedBy: null,
takenAt: new Date(0),
dismissed: true,
});
const status = await markDoseSkippedForUser({ userId, doseId });
if (status !== "already_skipped") {
dismissedCount++;
}
}
@@ -537,28 +757,332 @@ export async function doseRoutes(app: FastifyInstance) {
},
async (request, reply) => {
const { token } = request.params;
const tokenRef = redactTokenForLog(token);
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected read: token=${token}, reason=${reason}`);
request.log.warn(`[ShareDose] Rejected read: tokenRef=${tokenRef}, reason=${reason}`);
return reply.notFound("Share link not found");
}
// Get all taken doses for this user (no time limit)
// Keep public dose reads scoped to the selected share person and visible schedule window.
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
const visibleDoses: (typeof doseTracking.$inferSelect)[] = [];
for (const dose of doses) {
if (await validateShareDoseId(share, dose.doseId)) {
visibleDoses.push(dose);
}
}
const journalDoseTrackingIds = new Set<number>();
if ((share.allowJournalNotes ?? false) && visibleDoses.length > 0) {
const journalRows = await db
.select({ doseTrackingId: intakeJournal.doseTrackingId })
.from(intakeJournal)
.where(
and(
eq(intakeJournal.userId, share.userId),
inArray(
intakeJournal.doseTrackingId,
visibleDoses.map((dose) => dose.id)
)
)
);
for (const row of journalRows) {
journalDoseTrackingIds.add(row.doseTrackingId);
}
}
return {
doses: doses.map((d) => ({
doses: visibleDoses.map((d) => ({
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
takenSource: d.takenSource ?? "manual",
dismissed: d.dismissed ?? false,
hasJournalNote: journalDoseTrackingIds.has(d.id),
})),
};
}
);
app.get<{ Params: { token: string; doseId: string } }>(
"/share/:token/journal/event/:doseId",
{
schema: {
params: {
type: "object",
required: ["token", "doseId"],
properties: {
token: tokenParamsSchema.properties.token,
doseId: { type: "string", minLength: 1 },
},
},
response: {
200: shareJournalResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
403: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const { token, doseId } = request.params;
const tokenRef = redactTokenForLog(token);
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareJournal] Rejected read: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
if (!(share.allowJournalNotes ?? false)) {
return reply
.status(403)
.send({ error: "Journal notes are not enabled for this share link", code: "NOT_ENABLED" });
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
return reply.status(400).send({ error: "Invalid or unauthorized doseId", code: "INVALID_DOSE" });
}
const event = await resolveTrackedDoseEventForUser({ userId: share.userId, doseId });
if (!event) {
return reply
.status(404)
.send({ error: "Tracked dose event not found for this share link", code: "DOSE_NOT_FOUND" });
}
const journalEntry = await getIntakeJournalForDoseEvent({ userId: share.userId, doseId });
return { entry: buildSharedJournalEntryDto({ event, journalEntry }) };
}
);
app.put<{ Params: { token: string; doseId: string }; Body: z.infer<typeof shareJournalUpsertSchema> }>(
"/share/:token/journal/event/:doseId",
{
schema: {
params: {
type: "object",
required: ["token", "doseId"],
properties: {
token: tokenParamsSchema.properties.token,
doseId: { type: "string", minLength: 1 },
},
},
body: {
type: "object",
required: ["note"],
properties: {
note: { type: "string", maxLength: 4000 },
},
additionalProperties: false,
},
response: {
200: shareJournalResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
403: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const { token, doseId } = request.params;
const tokenRef = redactTokenForLog(token);
const parsed = shareJournalUpsertSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error), code: "VALIDATION_ERROR" });
}
const normalizedNote = parsed.data.note.trim();
if (normalizedNote.length === 0) {
return reply.status(400).send({ error: "Journal note cannot be empty", code: "EMPTY_NOTE" });
}
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareJournal] Rejected save: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
if (!(share.allowJournalNotes ?? false)) {
return reply
.status(403)
.send({ error: "Journal notes are not enabled for this share link", code: "NOT_ENABLED" });
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
return reply.status(400).send({ error: "Invalid or unauthorized doseId", code: "INVALID_DOSE" });
}
const event = await resolveTrackedDoseEventForUser({ userId: share.userId, doseId });
if (!event) {
return reply
.status(404)
.send({ error: "Tracked dose event not found for this share link", code: "DOSE_NOT_FOUND" });
}
const journalEntry = await upsertIntakeJournalForDoseEvent({
userId: share.userId,
doseId,
note: normalizedNote,
});
return { entry: buildSharedJournalEntryDto({ event, journalEntry }) };
}
);
app.delete<{ Params: { token: string; doseId: string } }>(
"/share/:token/journal/event/:doseId",
{
schema: {
params: {
type: "object",
required: ["token", "doseId"],
properties: {
token: tokenParamsSchema.properties.token,
doseId: { type: "string", minLength: 1 },
},
},
response: {
403: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const { token, doseId } = request.params;
const tokenRef = redactTokenForLog(token);
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareJournal] Rejected delete: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
if (!(share.allowJournalNotes ?? false)) {
return reply
.status(403)
.send({ error: "Journal notes are not enabled for this share link", code: "NOT_ENABLED" });
}
return reply.status(403).send({ error: "Shared links cannot delete journal notes", code: "DELETE_NOT_ALLOWED" });
}
);
// ---------------------------------------------------------------------------
// POST /share/:token/doses/skip - PUBLIC: Mark a dose as skipped via share link
// ---------------------------------------------------------------------------
app.post<{ Params: { token: string }; Body: z.infer<typeof shareDoseSchema> }>(
"/share/:token/doses/skip",
{
schema: {
params: tokenParamsSchema,
body: {
type: "object",
required: ["doseId"],
properties: {
doseId: { type: "string", minLength: 1 },
},
},
response: {
200: {
type: "object",
properties: {
success: { type: "boolean" },
message: { type: "string" },
},
},
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const { token } = request.params;
const tokenRef = redactTokenForLog(token);
const parsed = shareDoseSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) });
}
const { doseId } = parsed.data;
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected skip: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
request.log.warn(
`[ShareDose] Rejected invalid doseId in skip request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
}
const status = await markDoseSkippedForUser({ userId: share.userId, doseId });
if (status === "already_skipped") {
return { success: true, message: "Already skipped" };
}
request.log.info(
`[ShareDose] Dose skipped via share link: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return { success: true };
}
);
// ---------------------------------------------------------------------------
// DELETE /share/:token/doses/skip/:doseId - PUBLIC: Undo a skipped dose via share link
// ---------------------------------------------------------------------------
app.delete<{ Params: { token: string; doseId: string } }>(
"/share/:token/doses/skip/:doseId",
{
schema: {
params: {
type: "object",
required: ["token", "doseId"],
properties: {
token: tokenParamsSchema.properties.token,
doseId: { type: "string", minLength: 1 },
},
},
response: {
200: { type: "object", properties: { success: { type: "boolean" } } },
400: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const { token, doseId } = request.params;
const tokenRef = redactTokenForLog(token);
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected undo skip: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
request.log.warn(
`[ShareDose] Rejected invalid doseId in undo skip request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
}
await undoDoseSkippedForUser({ userId: share.userId, doseId });
return { success: true };
}
);
// ---------------------------------------------------------------------------
// POST /share/:token/doses - PUBLIC: Mark a dose as taken via share link
// ---------------------------------------------------------------------------
@@ -586,11 +1110,12 @@ export async function doseRoutes(app: FastifyInstance) {
},
async (request, reply) => {
const { token } = request.params;
const tokenRef = redactTokenForLog(token);
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),
});
}
@@ -598,14 +1123,14 @@ export async function doseRoutes(app: FastifyInstance) {
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected mark: token=${token}, doseId=${doseId}, reason=${reason}`);
request.log.warn(`[ShareDose] Rejected mark: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
request.log.warn(
`[ShareDose] Rejected invalid doseId in mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
`[ShareDose] Rejected invalid doseId in mark request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
}
@@ -618,7 +1143,7 @@ export async function doseRoutes(app: FastifyInstance) {
if (existing) {
request.log.debug(
`[ShareDose] Duplicate mark ignored: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
`[ShareDose] Duplicate mark ignored: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return { success: true, message: "Already marked" };
}
@@ -631,7 +1156,7 @@ export async function doseRoutes(app: FastifyInstance) {
});
if (outOfStock) {
request.log.info(
`[ShareDose] Rejected out-of-stock mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
`[ShareDose] Rejected out-of-stock mark request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(409).send({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
}
@@ -648,7 +1173,7 @@ export async function doseRoutes(app: FastifyInstance) {
});
request.log.info(
`[ShareDose] Dose marked via share link: token=${token}, ownerUserId=${share.userId}, shareTakenBy=${share.takenBy}, markedBy=${markedBy}, doseId=${doseId}`
`[ShareDose] Dose marked via share link: tokenRef=${tokenRef}, ownerUserId=${share.userId}, shareTakenBy=${share.takenBy}, markedBy=${markedBy}, doseId=${doseId}`
);
return { success: true };
@@ -679,17 +1204,18 @@ export async function doseRoutes(app: FastifyInstance) {
},
async (request, reply) => {
const { token, doseId } = request.params;
const tokenRef = redactTokenForLog(token);
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected unmark: token=${token}, doseId=${doseId}, reason=${reason}`);
request.log.warn(`[ShareDose] Rejected unmark: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
request.log.warn(
`[ShareDose] Rejected invalid doseId in unmark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
`[ShareDose] Rejected invalid doseId in unmark request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
}
@@ -703,7 +1229,7 @@ export async function doseRoutes(app: FastifyInstance) {
if (existing?.dismissed) {
// Already dismissed - keep the record as-is
request.log.debug(
`[ShareDose] Unmark ignored for dismissed dose: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
`[ShareDose] Unmark ignored for dismissed dose: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
} else {
// Not dismissed - delete the record entirely
@@ -711,7 +1237,7 @@ export async function doseRoutes(app: FastifyInstance) {
.delete(doseTracking)
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
request.log.info(
`[ShareDose] Dose unmarked via share link: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
`[ShareDose] Dose unmarked via share link: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
}
+427 -212
View File
@@ -6,9 +6,13 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { getDataDir } from "../db/path-utils.js";
import { doseTracking, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js";
import { doseTracking, intakeJournal, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import {
listIntakeJournalExportPayloadsForUser,
restoreIntakeJournalForImportedDose,
} from "../services/intake-journal-export.js";
import type { AuthUser } from "../types/fastify.js";
import {
applyOpenApiRouteStandards,
@@ -23,7 +27,7 @@ const IMAGES_DIR = resolve(getDataDir(), "images");
// =============================================================================
// Export Format Version (bump this when format changes)
// =============================================================================
const EXPORT_VERSION = "1.4";
const EXPORT_VERSION = "1.6";
// =============================================================================
// Zod Schemas for Import Validation
@@ -62,7 +66,7 @@ const medicationExportSchema = z.object({
lifecycleCategory: z.enum(["refill_when_empty", "treatment_period"]).default("refill_when_empty"),
inventory: inventorySchema,
pillWeightMg: z.number().int().nullable().optional(),
doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg"),
doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs", "injections"]).default("mg"),
schedules: z.array(scheduleSchema).default([]),
medicationStartDate: z.string().nullable().optional(),
medicationEndDate: z.string().nullable().optional(),
@@ -91,12 +95,16 @@ const doseHistorySchema = z.object({
takenSource: z.enum(["manual", "automatic"]).default("manual"),
dismissed: z.boolean().default(false),
takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel")
journalNote: z.string().nullable().optional(),
journalCreatedAt: z.string().nullable().optional(),
journalUpdatedAt: z.string().nullable().optional(),
});
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
});
@@ -104,41 +112,47 @@ const refillHistoryExportSchema = z.object({
const shareLinkSchema = z.object({
takenBy: z.string().min(1),
scheduleDays: z.number().int().min(1).default(30),
allowJournalNotes: z.boolean().default(false),
expiresAt: z.string().nullable().optional(), // ISO datetime
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 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 +163,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([]),
});
@@ -189,7 +203,7 @@ const importBodyOpenApiSchema = {
shareLinks: { type: "array", items: { type: "object", additionalProperties: true } },
},
example: {
version: "1.8.0",
version: "1.6",
exportedAt: "2026-03-11T10:15:00.000Z",
includeSensitiveData: true,
medications: [
@@ -209,13 +223,72 @@ 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" }],
doseHistory: [
{
medicationRef: "med-1",
scheduleIndex: 0,
scheduledTime: "2026-03-11T08:00:00.000Z",
takenAt: "2026-03-11T08:03:00.000Z",
markedBy: "Daniel",
takenSource: "manual",
dismissed: false,
takenByPerson: "Daniel",
journalNote: "Took after breakfast.",
journalUpdatedAt: "2026-03-11T08:05: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 }],
},
} as const;
const importPreviewResponseSchema = {
type: "object",
properties: {
success: { type: "boolean" },
preview: {
type: "object",
properties: {
version: { type: "string" },
exportedAt: { type: "string", format: "date-time" },
includeSensitiveData: { type: "boolean" },
incoming: {
type: "object",
properties: {
medications: { type: "integer" },
doseHistory: { type: "integer" },
refillHistory: { type: "integer" },
shareLinks: { type: "integer" },
journalEntries: { type: "integer" },
imageCount: { type: "integer" },
hasSettings: { type: "boolean" },
},
},
current: {
type: "object",
properties: {
medications: { type: "integer" },
doseHistory: { type: "integer" },
refillHistory: { type: "integer" },
shareLinks: { type: "integer" },
hasSettings: { type: "boolean" },
},
},
warnings: {
type: "object",
properties: {
replacesExistingData: { type: "boolean" },
regeneratesShareLinks: { type: "boolean" },
containsImages: { type: "boolean" },
containsSensitiveData: { type: "boolean" },
},
},
},
},
},
} as const;
// =============================================================================
// Helper Functions
// =============================================================================
@@ -289,7 +362,7 @@ function imageToBase64(imageUrl: string | null): string | null {
// Save base64 image to file and return filename
function base64ToImage(base64: string, medicationId: number): string | null {
if (!base64 || !base64.startsWith("data:")) return null;
if (!base64.startsWith("data:")) return null;
try {
// Parse data URL: "data:image/jpeg;base64,/9j/4AAQ..."
@@ -315,6 +388,64 @@ function base64ToImage(base64: string, medicationId: number): string | null {
}
}
function removeFileIfPresent(filePath: string): string | null {
if (!existsSync(filePath)) {
return null;
}
try {
unlinkSync(filePath);
return null;
} catch (error) {
return error instanceof Error ? error.message : "Unknown file removal error";
}
}
function buildImportPreview(
importData: z.infer<typeof importDataSchema>,
currentData: {
medications: number;
doseHistory: number;
refillHistory: number;
shareLinks: number;
hasSettings: boolean;
}
) {
const journalEntries = importData.doseHistory.filter(
(dose) => typeof dose.journalNote === "string" && dose.journalNote.trim()
).length;
const imageCount = importData.medications.filter(
(med) => typeof med.image === "string" && med.image.startsWith("data:")
).length;
return {
version: importData.version,
exportedAt: importData.exportedAt,
includeSensitiveData: importData.includeSensitiveData,
incoming: {
medications: importData.medications.length,
doseHistory: importData.doseHistory.length,
refillHistory: importData.refillHistory.length,
shareLinks: importData.shareLinks.length,
journalEntries,
imageCount,
hasSettings: Boolean(importData.settings),
},
current: currentData,
warnings: {
replacesExistingData:
currentData.medications > 0 ||
currentData.doseHistory > 0 ||
currentData.refillHistory > 0 ||
currentData.shareLinks > 0 ||
currentData.hasSettings,
regeneratesShareLinks: importData.shareLinks.length > 0,
containsImages: imageCount > 0,
containsSensitiveData: importData.includeSensitiveData,
},
};
}
// Parse dose ID to extract medication ID and timestamp
// Format: "{medicationId}-{blisterIndex}-{timestampMs}" or "{medicationId}-{blisterIndex}-{timestampMs}-{person}"
function parseDoseId(
@@ -370,6 +501,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>();
@@ -435,6 +567,7 @@ export async function exportRoutes(app: FastifyInstance) {
// 2. Load all dose tracking entries
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
const journalPayloadsByDoseTrackingId = await listIntakeJournalExportPayloadsForUser(userId);
const exportDoseHistory = doses
.map((dose) => {
@@ -477,6 +610,7 @@ export async function exportRoutes(app: FastifyInstance) {
takenSource: dose.takenSource === "automatic" ? "automatic" : "manual",
dismissed: dose.dismissed ?? false,
takenByPerson: parsed.person,
...journalPayloadsByDoseTrackingId.get(dose.id),
};
})
.filter((d): d is NonNullable<typeof d> => d !== null);
@@ -509,7 +643,6 @@ export async function exportRoutes(app: FastifyInstance) {
expiryWarningDays: settings.expiryWarningDays,
language: settings.language,
stockCalculationMode: settings.stockCalculationMode,
shareStockStatus: settings.shareStockStatus,
shareMedicationOverview: settings.shareMedicationOverview ?? false,
}
: undefined;
@@ -536,6 +669,7 @@ export async function exportRoutes(app: FastifyInstance) {
return {
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
allowJournalNotes: share.allowJournalNotes ?? false,
expiresAt: expiresAtIso,
regenerateToken: true, // Always regenerate tokens on import for security
};
@@ -548,6 +682,17 @@ 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 === "inhaler" ||
packageType === "injection" ||
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 +713,7 @@ export async function exportRoutes(app: FastifyInstance) {
medicationRef: exportId,
packsAdded: refill.packsAdded ?? 0,
loosePillsAdded: refill.loosePillsAdded ?? 0,
quantityAdded,
usedPrescription: refill.usedPrescription ?? false,
refillDate: refillDateIso,
};
@@ -599,6 +745,58 @@ export async function exportRoutes(app: FastifyInstance) {
}
);
// ---------------------------------------------------------------------------
// POST /import/preview - Validate and summarize import data without writing
// ---------------------------------------------------------------------------
app.post(
"/import/preview",
{
config: {
rawBody: true,
},
bodyLimit: 50 * 1024 * 1024,
schema: {
body: importBodyOpenApiSchema,
response: {
200: importPreviewResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const parsed = importDataSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: "Invalid import data format",
details: parsed.error.format(),
});
}
const [existingMeds, existingDoseHistory, existingRefillHistory, existingShareLinks, existingSettings] =
await Promise.all([
db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId)),
db.select({ id: doseTracking.id }).from(doseTracking).where(eq(doseTracking.userId, userId)),
db.select({ id: refillHistory.id }).from(refillHistory).where(eq(refillHistory.userId, userId)),
db.select({ id: shareTokens.id }).from(shareTokens).where(eq(shareTokens.userId, userId)),
db.select({ id: userSettings.id }).from(userSettings).where(eq(userSettings.userId, userId)),
]);
return {
success: true,
preview: buildImportPreview(parsed.data, {
medications: existingMeds.length,
doseHistory: existingDoseHistory.length,
refillHistory: existingRefillHistory.length,
shareLinks: existingShareLinks.length,
hasSettings: existingSettings.length > 0,
}),
};
}
);
// ---------------------------------------------------------------------------
// POST /import - Import user data (replaces all existing data!)
// ---------------------------------------------------------------------------
@@ -631,6 +829,7 @@ export async function exportRoutes(app: FastifyInstance) {
},
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
500: genericErrorSchema,
},
},
},
@@ -648,192 +847,208 @@ export async function exportRoutes(app: FastifyInstance) {
const importData = parsed.data;
// 2. Delete all existing user data (in correct order to respect foreign keys)
// Note: CASCADE delete should handle this, but let's be explicit
// First, delete images for existing medications
// Existing image files are removed only after the DB import commits.
const existingMeds = await db.select().from(medications).where(eq(medications.userId, userId));
for (const med of existingMeds) {
if (med.imageUrl) {
const imagePath = resolve(IMAGES_DIR, med.imageUrl);
if (existsSync(imagePath)) {
try {
unlinkSync(imagePath);
} catch {
/* ignore */
const oldImagePaths = existingMeds
.map((med) => (med.imageUrl ? resolve(IMAGES_DIR, med.imageUrl) : null))
.filter((path): path is string => path !== null);
const newImagePaths: string[] = [];
try {
await db.transaction(async (tx) => {
// Delete in order: journal entries, refill history, doses, share tokens, medications, settings.
await tx.delete(intakeJournal).where(eq(intakeJournal.userId, userId));
await tx.delete(refillHistory).where(eq(refillHistory.userId, userId));
await tx.delete(doseTracking).where(eq(doseTracking.userId, userId));
await tx.delete(shareTokens).where(eq(shareTokens.userId, userId));
await tx.delete(medications).where(eq(medications.userId, userId));
await tx.delete(userSettings).where(eq(userSettings.userId, userId));
const exportIdToNewId = new Map<string, number>();
for (const med of importData.medications) {
const normalizedSchedules = med.schedules.map((schedule) =>
normalizeIntake({
usage: schedule.usage,
every: schedule.every,
start: schedule.start,
scheduleMode: schedule.scheduleMode,
weekdays: schedule.weekdays,
intakeUnit: schedule.intakeUnit ?? null,
takenBy: schedule.takenBy || null,
intakeRemindersEnabled: schedule.remind ?? false,
})
);
const usageJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.usage));
const everyJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.every));
const startJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.start));
const takenByJson = JSON.stringify(med.takenBy);
const intakesJson = JSON.stringify(normalizedSchedules);
const intakeRemindersEnabled =
normalizedSchedules.some((schedule) => schedule.intakeRemindersEnabled) || med.intakeRemindersEnabled;
const [inserted] = await tx
.insert(medications)
.values({
userId,
name: med.name,
genericName: med.genericName || null,
takenByJson,
medicationForm: med.medicationForm ?? "tablet",
pillForm: med.pillForm || null,
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
packageType: normalizePackageType(med.inventory.packageType),
packageAmountValue: med.inventory.packageAmountValue ?? 0,
packageAmountUnit: med.inventory.packageAmountUnit ?? "ml",
packCount: med.inventory.packCount,
blistersPerPack: med.inventory.blistersPerPack,
pillsPerBlister: med.inventory.pillsPerBlister,
looseTablets: med.inventory.looseTablets,
totalPills: med.inventory.totalPills ?? null,
stockAdjustment: med.inventory.stockAdjustment ?? 0,
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
pillWeightMg: med.pillWeightMg || null,
doseUnit: med.doseUnit ?? "mg",
medicationStartDate: med.medicationStartDate || "",
medicationEndDate: med.medicationEndDate || null,
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
intakesJson,
usageJson,
everyJson,
startJson,
expiryDate: med.expiryDate || null,
notes: med.notes || null,
intakeRemindersEnabled,
isObsolete: med.isObsolete ?? false,
obsoleteAt: med.obsoleteAt ? new Date(med.obsoleteAt) : null,
prescriptionEnabled: med.prescriptionEnabled ?? false,
prescriptionAuthorizedRefills: med.prescriptionEnabled
? (med.prescriptionAuthorizedRefills ?? null)
: null,
prescriptionRemainingRefills: med.prescriptionEnabled
? (med.prescriptionRemainingRefills ?? null)
: null,
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
prescriptionExpiryDate: med.prescriptionExpiryDate || null,
dismissedUntil: med.dismissedUntil || null,
imageUrl: null,
})
.returning();
exportIdToNewId.set(med._exportId, inserted.id);
if (med.image) {
const imageUrl = base64ToImage(med.image, inserted.id);
if (imageUrl) {
newImagePaths.push(resolve(IMAGES_DIR, imageUrl));
await tx.update(medications).set({ imageUrl }).where(eq(medications.id, inserted.id));
}
}
}
}
}
// Delete in order: refill history, doses, share tokens, medications, settings
await db.delete(refillHistory).where(eq(refillHistory.userId, userId));
await db.delete(doseTracking).where(eq(doseTracking.userId, userId));
await db.delete(shareTokens).where(eq(shareTokens.userId, userId));
await db.delete(medications).where(eq(medications.userId, userId));
await db.delete(userSettings).where(eq(userSettings.userId, userId));
for (const dose of importData.doseHistory) {
const newMedId = exportIdToNewId.get(dose.medicationRef);
if (!newMedId) continue;
// 3. Import medications and build ID mapping
const exportIdToNewId = new Map<string, number>();
const scheduledFor = new Date(dose.scheduledTime);
const timestampMs = scheduledFor.getTime();
const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs, dose.takenByPerson);
for (const med of importData.medications) {
const normalizedSchedules = med.schedules.map((schedule) =>
normalizeIntake({
usage: schedule.usage,
every: schedule.every,
start: schedule.start,
scheduleMode: schedule.scheduleMode,
weekdays: schedule.weekdays,
intakeUnit: schedule.intakeUnit ?? null,
takenBy: schedule.takenBy || null,
intakeRemindersEnabled: schedule.remind ?? false,
})
);
const usageJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.usage));
const everyJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.every));
const startJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.start));
const takenByJson = JSON.stringify(med.takenBy);
const [insertedDose] = await tx
.insert(doseTracking)
.values({
userId,
doseId,
takenAt: new Date(dose.takenAt),
markedBy: dose.markedBy || null,
takenSource: dose.takenSource ?? "manual",
dismissed: dose.dismissed ?? false,
})
.returning({ id: doseTracking.id });
const intakesJson = JSON.stringify(normalizedSchedules);
await restoreIntakeJournalForImportedDose({
userId,
doseTrackingId: insertedDose.id,
medicationId: newMedId,
scheduledFor,
journalNote: dose.journalNote,
journalCreatedAt: dose.journalCreatedAt,
journalUpdatedAt: dose.journalUpdatedAt,
database: tx,
});
}
// Check if any schedule has remind enabled
const intakeRemindersEnabled =
normalizedSchedules.some((schedule) => schedule.intakeRemindersEnabled) || med.intakeRemindersEnabled;
if (importData.settings) {
await tx.insert(userSettings).values({
userId,
emailEnabled: importData.settings.emailEnabled ?? false,
notificationEmail: importData.settings.notificationEmail || null,
emailStockReminders: importData.settings.emailStockReminders ?? true,
emailIntakeReminders: importData.settings.emailIntakeReminders ?? true,
emailPrescriptionReminders: importData.settings.emailPrescriptionReminders ?? true,
shoutrrrEnabled: importData.settings.shoutrrrEnabled ?? false,
shoutrrrUrl: importData.settings.shoutrrrUrl || null,
shoutrrrStockReminders: importData.settings.shoutrrrStockReminders ?? true,
shoutrrrIntakeReminders: importData.settings.shoutrrrIntakeReminders ?? true,
shoutrrrPrescriptionReminders: importData.settings.shoutrrrPrescriptionReminders ?? true,
reminderDaysBefore: importData.settings.reminderDaysBefore ?? 7,
repeatDailyReminders: importData.settings.repeatDailyReminders ?? false,
skipRemindersForTakenDoses: importData.settings.skipRemindersForTakenDoses ?? false,
repeatRemindersEnabled: importData.settings.repeatRemindersEnabled ?? false,
reminderRepeatIntervalMinutes: importData.settings.reminderRepeatIntervalMinutes ?? 30,
maxNaggingReminders: importData.settings.maxNaggingReminders ?? 5,
lowStockDays: importData.settings.lowStockDays ?? 30,
normalStockDays: importData.settings.normalStockDays ?? 90,
highStockDays: importData.settings.highStockDays ?? 180,
expiryWarningDays: importData.settings.expiryWarningDays ?? 90,
language: importData.settings.language ?? "en",
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
shareMedicationOverview: importData.settings.shareMedicationOverview ?? false,
});
}
const [inserted] = await db
.insert(medications)
.values({
userId,
name: med.name,
genericName: med.genericName || null,
takenByJson,
medicationForm: med.medicationForm ?? "tablet",
pillForm: med.pillForm || null,
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
packageType: normalizePackageType(med.inventory.packageType),
packageAmountValue: med.inventory.packageAmountValue ?? 0,
packageAmountUnit: med.inventory.packageAmountUnit ?? "ml",
packCount: med.inventory.packCount,
blistersPerPack: med.inventory.blistersPerPack,
pillsPerBlister: med.inventory.pillsPerBlister,
looseTablets: med.inventory.looseTablets,
totalPills: med.inventory.totalPills ?? null,
stockAdjustment: med.inventory.stockAdjustment ?? 0,
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
pillWeightMg: med.pillWeightMg || null,
doseUnit: med.doseUnit ?? "mg",
medicationStartDate: med.medicationStartDate || "",
medicationEndDate: med.medicationEndDate || null,
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
intakesJson,
usageJson,
everyJson,
startJson,
expiryDate: med.expiryDate || null,
notes: med.notes || null,
intakeRemindersEnabled,
isObsolete: med.isObsolete ?? false,
obsoleteAt: med.obsoleteAt ? new Date(med.obsoleteAt) : null,
prescriptionEnabled: med.prescriptionEnabled ?? false,
prescriptionAuthorizedRefills: med.prescriptionEnabled ? (med.prescriptionAuthorizedRefills ?? null) : null,
prescriptionRemainingRefills: med.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? null) : null,
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
prescriptionExpiryDate: med.prescriptionExpiryDate || null,
dismissedUntil: med.dismissedUntil || null,
imageUrl: null, // Will be set after image is saved
})
.returning();
for (const share of importData.shareLinks) {
await tx.insert(shareTokens).values({
userId,
token: randomBytes(8).toString("hex"),
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
allowJournalNotes: share.allowJournalNotes ?? false,
expiresAt: share.expiresAt ? new Date(share.expiresAt) : null,
});
}
// Save mapping
exportIdToNewId.set(med._exportId, inserted.id);
for (const refill of importData.refillHistory) {
const newMedId = exportIdToNewId.get(refill.medicationRef);
if (!newMedId) continue;
// Save image if present
if (med.image) {
const imageUrl = base64ToImage(med.image, inserted.id);
if (imageUrl) {
await db.update(medications).set({ imageUrl }).where(eq(medications.id, inserted.id));
await tx.insert(refillHistory).values({
medicationId: newMedId,
userId,
packsAdded: refill.packsAdded ?? 0,
loosePillsAdded: refill.loosePillsAdded ?? refill.quantityAdded ?? 0,
usedPrescription: refill.usedPrescription ?? false,
refillDate: new Date(refill.refillDate),
});
}
});
} catch (error) {
for (const imagePath of newImagePaths) {
const removalError = removeFileIfPresent(imagePath);
if (removalError) {
request.log.warn(`[Import] Failed to remove rolled-back image path=${imagePath}: ${removalError}`);
}
}
request.log.error({ err: error }, "[Import] Failed to import data");
return reply.status(500).send({ error: "Import failed" });
}
// 4. Import dose history with remapped medication IDs
for (const dose of importData.doseHistory) {
const newMedId = exportIdToNewId.get(dose.medicationRef);
if (!newMedId) continue; // Skip orphaned doses
// Convert ISO timestamp back to milliseconds for dose ID
const timestampMs = new Date(dose.scheduledTime).getTime();
// Rebuild dose ID with optional person suffix
const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs, dose.takenByPerson);
await db.insert(doseTracking).values({
userId,
doseId,
takenAt: new Date(dose.takenAt),
markedBy: dose.markedBy || null,
takenSource: dose.takenSource ?? "manual",
dismissed: dose.dismissed ?? false,
});
}
// 5. Import settings
if (importData.settings) {
await db.insert(userSettings).values({
userId,
emailEnabled: importData.settings.emailEnabled ?? false,
notificationEmail: importData.settings.notificationEmail || null,
emailStockReminders: importData.settings.emailStockReminders ?? true,
emailIntakeReminders: importData.settings.emailIntakeReminders ?? true,
emailPrescriptionReminders: importData.settings.emailPrescriptionReminders ?? true,
shoutrrrEnabled: importData.settings.shoutrrrEnabled ?? false,
shoutrrrUrl: importData.settings.shoutrrrUrl || null,
shoutrrrStockReminders: importData.settings.shoutrrrStockReminders ?? true,
shoutrrrIntakeReminders: importData.settings.shoutrrrIntakeReminders ?? true,
shoutrrrPrescriptionReminders: importData.settings.shoutrrrPrescriptionReminders ?? true,
reminderDaysBefore: importData.settings.reminderDaysBefore ?? 7,
repeatDailyReminders: importData.settings.repeatDailyReminders ?? false,
skipRemindersForTakenDoses: importData.settings.skipRemindersForTakenDoses ?? false,
repeatRemindersEnabled: importData.settings.repeatRemindersEnabled ?? false,
reminderRepeatIntervalMinutes: importData.settings.reminderRepeatIntervalMinutes ?? 30,
maxNaggingReminders: importData.settings.maxNaggingReminders ?? 5,
lowStockDays: importData.settings.lowStockDays ?? 30,
normalStockDays: importData.settings.normalStockDays ?? 90,
highStockDays: importData.settings.highStockDays ?? 180,
expiryWarningDays: importData.settings.expiryWarningDays ?? 90,
language: importData.settings.language ?? "en",
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
shareStockStatus: importData.settings.shareStockStatus ?? true,
shareMedicationOverview: importData.settings.shareMedicationOverview ?? false,
});
}
// 6. Import share links (with new tokens)
for (const share of importData.shareLinks) {
// Always generate new token for security
const token = randomBytes(8).toString("hex");
await db.insert(shareTokens).values({
userId,
token,
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
expiresAt: share.expiresAt ? new Date(share.expiresAt) : null,
});
}
// 7. Import refill history with remapped medication IDs
for (const refill of importData.refillHistory) {
const newMedId = exportIdToNewId.get(refill.medicationRef);
if (!newMedId) continue; // Skip orphaned refill records
await db.insert(refillHistory).values({
medicationId: newMedId,
userId,
packsAdded: refill.packsAdded ?? 0,
loosePillsAdded: refill.loosePillsAdded ?? 0,
usedPrescription: refill.usedPrescription ?? false,
refillDate: new Date(refill.refillDate),
});
for (const imagePath of oldImagePaths) {
const removalError = removeFileIfPresent(imagePath);
if (removalError) {
request.log.warn(`[Import] Failed to remove replaced image path=${imagePath}: ${removalError}`);
}
}
return {
+373
View File
@@ -0,0 +1,373 @@
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import {
deleteIntakeJournalForDoseEvent,
getIntakeJournalForDoseEvent,
isTrackedDoseIdFormat,
listIntakeJournalEntriesForUser,
resolveTrackedDoseEventForUser,
upsertIntakeJournalForDoseEvent,
} from "../services/intake-journal-service.js";
import type { AuthUser } from "../types/fastify.js";
import { toLocalDateTimeOffsetString } from "../utils/local-date-time.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
const intakeJournalEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
{ bearerAuth: [] },
{ cookieAuth: [] },
];
const doseIdParamsSchema = {
type: "object",
required: ["doseId"],
properties: {
doseId: { type: "string", minLength: 1 },
},
} as const;
const intakeJournalEntrySchema = {
type: "object",
required: [
"doseTrackingId",
"doseId",
"medicationId",
"medicationName",
"scheduledFor",
"dismissed",
"takenSource",
"note",
"updatedAt",
],
properties: {
doseTrackingId: { type: "integer" },
doseId: { type: "string" },
medicationId: { type: "integer" },
medicationName: { type: "string" },
scheduledFor: { type: "string", format: "date-time" },
takenAt: { type: ["string", "null"], format: "date-time" },
dismissed: { type: "boolean" },
takenSource: { type: "string", enum: ["manual", "automatic"] },
markedBy: { type: ["string", "null"] },
note: { type: ["string", "null"] },
updatedAt: { type: ["string", "null"], format: "date-time" },
createdAt: { type: ["string", "null"], format: "date-time" },
},
additionalProperties: false,
} as const;
const intakeJournalEventResponseSchema = {
type: "object",
required: ["entry"],
properties: {
entry: intakeJournalEntrySchema,
},
additionalProperties: false,
} as const;
const intakeJournalHistoryResponseSchema = {
type: "object",
required: ["entries"],
properties: {
entries: {
type: "array",
items: intakeJournalEntrySchema,
},
},
additionalProperties: false,
} as const;
const intakeJournalHistoryQuerySchema = z.object({
medicationId: z.coerce.number().int().positive().optional(),
from: z.string().trim().min(1).optional(),
to: z.string().trim().min(1).optional(),
limit: z.coerce.number().int().min(1).max(200).optional().default(100),
});
const intakeJournalUpsertSchema = z.object({
note: z.string().max(4000),
});
function getValidationErrorMessage(error: z.ZodError): string {
const issue = error.issues[0];
if (!issue) {
return "Invalid request payload";
}
return issue.message;
}
function parseOptionalDate(value: string | undefined): Date | null {
if (!value) {
return null;
}
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed;
}
function serializeTakenAt(value: Date | null, dismissed: boolean): string | null {
if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
return null;
}
if (dismissed && value.getTime() <= 0) {
return null;
}
return value.toISOString();
}
function buildJournalEntryDto(input: {
event: Awaited<ReturnType<typeof resolveTrackedDoseEventForUser>> extends infer T
? T extends null
? never
: T
: never;
journalEntry: Awaited<ReturnType<typeof getIntakeJournalForDoseEvent>>;
}) {
const { event, journalEntry } = input;
return {
doseTrackingId: event.doseTrackingId,
doseId: event.doseId,
medicationId: event.medicationId,
medicationName: event.medicationName,
scheduledFor: toLocalDateTimeOffsetString(journalEntry?.scheduledFor ?? event.scheduledFor),
takenAt: serializeTakenAt(event.takenAt, event.dismissed),
dismissed: event.dismissed,
takenSource: event.takenSource,
markedBy: event.markedBy,
note: journalEntry?.note ?? null,
updatedAt: journalEntry?.updatedAt?.toISOString() ?? null,
createdAt: journalEntry?.createdAt?.toISOString() ?? null,
};
}
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
if (!env.AUTH_ENABLED) {
return getAnonymousUserId();
}
const authUser = request.user as AuthUser | null;
if (!authUser) {
reply.status(401).send({ error: "Not authenticated" });
throw new Error("AUTH_REQUIRED");
}
return authUser.id;
}
export async function intakeJournalRoutes(app: FastifyInstance) {
app.addHook("preHandler", requireAuth);
applyOpenApiRouteStandards(app, { tag: "intake-journal", protectedByDefault: true });
app.get<{ Querystring: z.infer<typeof intakeJournalHistoryQuerySchema> }>(
"/intake-journal",
{
schema: {
tags: ["intake-journal"],
summary: "List intake journal history for the current owner",
security: intakeJournalEndpointSecurity,
querystring: {
type: "object",
properties: {
medicationId: { type: "integer", minimum: 1 },
from: { type: "string", format: "date-time" },
to: { type: "string", format: "date-time" },
limit: { type: "integer", minimum: 1, maximum: 200 },
},
},
response: {
200: intakeJournalHistoryResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const parsed = intakeJournalHistoryQuerySchema.safeParse(request.query);
if (!parsed.success) {
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) });
}
const from = parseOptionalDate(parsed.data.from);
if (parsed.data.from && !from) {
return reply.status(400).send({ error: "Invalid 'from' date-time filter", code: "INVALID_FROM" });
}
const to = parseOptionalDate(parsed.data.to);
if (parsed.data.to && !to) {
return reply.status(400).send({ error: "Invalid 'to' date-time filter", code: "INVALID_TO" });
}
if (from && to && from.getTime() > to.getTime()) {
return reply.status(400).send({ error: "'from' must be before or equal to 'to'", code: "INVALID_RANGE" });
}
const entries = await listIntakeJournalEntriesForUser({
userId,
medicationId: parsed.data.medicationId,
from: from ?? undefined,
to: to ?? undefined,
limit: parsed.data.limit,
});
return {
entries: entries.map((entry) => ({
doseTrackingId: entry.doseTrackingId,
doseId: entry.doseId,
medicationId: entry.medicationId,
medicationName: entry.medicationName,
scheduledFor: toLocalDateTimeOffsetString(entry.scheduledFor),
takenAt: serializeTakenAt(entry.takenAt, entry.dismissed),
dismissed: entry.dismissed,
takenSource: entry.takenSource,
markedBy: entry.markedBy,
note: entry.note,
updatedAt: entry.updatedAt.toISOString(),
createdAt: entry.createdAt.toISOString(),
})),
};
}
);
app.get<{ Params: { doseId: string } }>(
"/intake-journal/event/:doseId",
{
schema: {
tags: ["intake-journal"],
summary: "Get intake journal context for a tracked dose event",
security: intakeJournalEndpointSecurity,
params: doseIdParamsSchema,
response: {
200: intakeJournalEventResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const { doseId } = request.params;
if (!isTrackedDoseIdFormat(doseId)) {
return reply.status(400).send({ error: "Invalid doseId format", code: "INVALID_DOSE" });
}
const event = await resolveTrackedDoseEventForUser({ userId, doseId });
if (!event) {
return reply
.status(404)
.send({ error: "Tracked dose event not found for the current owner", code: "DOSE_NOT_FOUND" });
}
const journalEntry = await getIntakeJournalForDoseEvent({ userId, doseId });
return { entry: buildJournalEntryDto({ event, journalEntry }) };
}
);
app.put<{ Body: z.infer<typeof intakeJournalUpsertSchema>; Params: { doseId: string } }>(
"/intake-journal/event/:doseId",
{
schema: {
tags: ["intake-journal"],
summary: "Create or update an intake journal note for a tracked dose event",
security: intakeJournalEndpointSecurity,
params: doseIdParamsSchema,
body: {
type: "object",
required: ["note"],
properties: {
note: { type: "string", maxLength: 4000 },
},
additionalProperties: false,
},
response: {
200: intakeJournalEventResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const { doseId } = request.params;
if (!isTrackedDoseIdFormat(doseId)) {
return reply.status(400).send({ error: "Invalid doseId format", code: "INVALID_DOSE" });
}
const parsed = intakeJournalUpsertSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) });
}
const event = await resolveTrackedDoseEventForUser({ userId, doseId });
if (!event) {
return reply
.status(404)
.send({ error: "Tracked dose event not found for the current owner", code: "DOSE_NOT_FOUND" });
}
const journalEntry = await upsertIntakeJournalForDoseEvent({
userId,
doseId,
note: parsed.data.note,
});
return { entry: buildJournalEntryDto({ event, journalEntry }) };
}
);
app.delete<{ Params: { doseId: string } }>(
"/intake-journal/event/:doseId",
{
schema: {
tags: ["intake-journal"],
summary: "Delete an intake journal note for a tracked dose event",
security: intakeJournalEndpointSecurity,
params: doseIdParamsSchema,
response: {
200: {
type: "object",
required: ["success"],
properties: {
success: { type: "boolean" },
},
additionalProperties: false,
},
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const { doseId } = request.params;
if (!isTrackedDoseIdFormat(doseId)) {
return reply.status(400).send({ error: "Invalid doseId format", code: "INVALID_DOSE" });
}
const deleted = await deleteIntakeJournalForDoseEvent({ userId, doseId });
if (!deleted) {
return reply
.status(404)
.send({ error: "Tracked dose event not found for the current owner", code: "DOSE_NOT_FOUND" });
}
return { success: true };
}
);
}
+5 -2
View File
@@ -70,7 +70,10 @@ const strengthOptionSchema = {
label: { type: "string" },
pillWeightMg: { type: "number", nullable: true },
doseUnit: {
anyOf: [{ type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"] }, { type: "null" }],
anyOf: [
{ type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs", "injections"] },
{ type: "null" },
],
},
},
} as const;
@@ -80,7 +83,7 @@ const packageOptionSchema = {
properties: {
label: { type: "string" },
description: { type: "string" },
packageType: { type: "string", enum: ["blister", "bottle", "tube", "liquid_container"] },
packageType: { type: "string", enum: ["blister", "bottle", "tube", "liquid_container", "inhaler", "injection"] },
packCount: { type: "integer", minimum: 1 },
blistersPerPack: { type: "integer", minimum: 1, nullable: true },
pillsPerBlister: { type: "integer", minimum: 1, nullable: true },
+12 -8
View File
@@ -24,6 +24,7 @@ import {
} from "../utils/openapi-route-standards.js";
import {
isAmountBasedPackageType,
isDiscreteCountPackageType,
isLiquidContainerPackageType,
isTubePackageType,
normalizePackageType,
@@ -67,7 +68,7 @@ const packageTypeSchema = z.enum(PACKAGE_TYPES).default("blister");
const medicationFormSchema = z.enum(["capsule", "tablet", "liquid", "topical"]).default("tablet");
const pillFormSchema = z.enum(["capsule", "tablet"]);
const lifecycleCategorySchema = z.enum(["refill_when_empty", "treatment_period"]).default("refill_when_empty");
const doseUnitSchema = z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg");
const doseUnitSchema = z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs", "injections"]).default("mg");
const medicationStartDateSchema = z
.union([z.string().regex(/^\d{4}-\d{2}-\d{2}$/), z.literal(""), z.null()])
.optional();
@@ -264,7 +265,7 @@ const medicationBodyOpenApiSchema = {
totalPills: { type: ["integer", "null"], minimum: 1 },
looseTablets: { type: "integer", minimum: 0 },
pillWeightMg: { type: ["number", "null"], minimum: 0 },
doseUnit: { type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"] },
doseUnit: { type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs", "injections"] },
medicationStartDate: {
anyOf: [{ type: "string", pattern: "^\\d{4}-\\d{2}-\\d{2}$" }, { type: "null" }, { const: "" }],
},
@@ -1201,17 +1202,20 @@ export async function medicationRoutes(app: FastifyInstance) {
const packageType = normalizePackageType(existing.packageType);
const allowsAmountBaseUpdate = isTubePackageType(packageType) || isLiquidContainerPackageType(packageType);
const allowsBottleCapacityUpdate = packageType === "bottle";
const allowsDiscreteCapacityUpdate = isDiscreteCountPackageType(packageType);
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) {
if (allowsDiscreteCapacityUpdate && totalPills !== undefined) {
updateFields.totalPills = totalPills;
}
if (packCount !== undefined) updateFields.packCount = packCount;
if (looseTablets !== undefined) {
if (!allowsAmountBaseUpdate && looseTablets !== undefined) {
updateFields.looseTablets = looseTablets;
}
@@ -1654,7 +1658,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);
+670
View File
@@ -0,0 +1,670 @@
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<{ replaced: boolean; providerMessageId?: string }> {
const normalizedSequenceId = options.sequenceId.trim();
if (normalizedSequenceId.length === 0) {
return { replaced: 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 { replaced: false };
}
const sanitized = sanitizeNotificationUrl(settings.shoutrrrUrl);
if ("error" in sanitized || !sanitized.isNtfy) {
return { replaced: 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 { replaced: true, providerMessageId: result.providerMessageId };
}
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;
const previousNtfyMessageId = record.group.ntfyOriginalMessageId.trim();
try {
const replacementResult = await replaceNtfyNotificationSequence({
userId: record.group.userId,
sequenceId: record.group.sequenceId,
language,
title: record.group.title,
originalMessage: record.group.message,
action,
viewUrl: record.viewUrl,
});
replacedNtfyNotification = replacementResult.replaced;
if (replacementResult.providerMessageId) {
await db
.update(notificationActionGroups)
.set({ ntfyOriginalMessageId: replacementResult.providerMessageId, updatedAt: new Date() })
.where(eq(notificationActionGroups.id, record.group.id));
}
if (
replacementResult.replaced &&
previousNtfyMessageId.length > 0 &&
previousNtfyMessageId !== replacementResult.providerMessageId
) {
try {
await deleteNtfyNotificationSequence(record.group.userId, previousNtfyMessageId);
} catch (error) {
request.log.warn(
buildNotificationActionLogContext(record, {
requestedAction: action,
originalMessageId: previousNtfyMessageId,
error,
}),
"[NotificationActions] Failed to delete original ntfy notification after replacement"
);
}
}
} 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());
}
}
);
+40 -28
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, formatPlannerQuantity, getPlannerUnit, isContainerPackage } from "../services/planner-service.js";
import { updateReminderSentTime, updateUserReminderSentTime } from "../services/reminder-scheduler.js";
import type { AuthUser } from "../types/fastify.js";
import {
@@ -46,15 +45,28 @@ type PlannerRow = {
type SendEmailBody = {
email: string;
from: string;
until: string;
from?: string;
until?: string;
startDate?: string;
endDate?: string;
rows: PlannerRow[];
language?: Language; // Optional: passed from frontend for unauthenticated requests
};
function resolvePlannerDateRange(body: SendEmailBody): { startDate: string; endDate: string } | null {
const startDate = body.startDate ?? body.from;
const endDate = body.endDate ?? body.until;
if (!startDate || !endDate) {
return null;
}
return { startDate, endDate };
}
type LowStockItem = {
name: string;
medsLeft: number;
packageType?: string;
daysLeft: number | null;
depletionDate: string | null;
isCritical?: boolean;
@@ -165,11 +177,15 @@ export async function plannerRoutes(app: FastifyInstance) {
email: { type: "string" },
from: { type: "string" },
until: { type: "string" },
startDate: { type: "string", format: "date-time" },
endDate: { type: "string", format: "date-time" },
language: { type: "string" },
rows: { type: "array", items: plannerRowSchema },
},
example: {
email: "daniel@example.com",
startDate: "2026-03-11T00:00:00.000Z",
endDate: "2026-04-11T00:00:00.000Z",
from: "2026-03-11",
until: "2026-04-11",
language: "en",
@@ -198,13 +214,20 @@ export async function plannerRoutes(app: FastifyInstance) {
},
},
async (request, reply) => {
const { email, from, until, rows, language: bodyLanguage } = request.body;
const { email, rows, language: bodyLanguage } = request.body;
const resolvedDateRange = resolvePlannerDateRange(request.body);
request.log.info({ email, rowCount: rows?.length ?? 0 }, "[Planner] Demand notification request received");
if (!rows || rows.length === 0) {
return reply.status(400).send({ error: "Missing planner data" });
}
if (!resolvedDateRange) {
return reply.status(400).send({ error: "Missing planner date range" });
}
const { startDate, endDate } = resolvedDateRange;
// Load user settings for notification channels
const userId = await getUserId(request);
const activeMeds = await db
@@ -246,14 +269,14 @@ export async function plannerRoutes(app: FastifyInstance) {
// Format dates for display - escape to prevent XSS even though toLocaleDateString should be safe
const fromDate = escapeHtml(
new Date(from).toLocaleDateString(locale, {
new Date(startDate).toLocaleDateString(locale, {
year: "numeric",
month: "long",
day: "numeric",
})
);
const untilDate = escapeHtml(
new Date(until).toLocaleDateString(locale, {
new Date(endDate).toLocaleDateString(locale, {
year: "numeric",
month: "long",
day: "numeric",
@@ -428,19 +451,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 +461,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(
@@ -579,11 +591,10 @@ ${getFooterPlain(language)}`;
.map((med) => [med.name || med.genericName || "", normalizePackageType(med.packageType)] as const)
.filter(([name]) => name.length > 0)
);
const filteredLowStock = lowStock.filter((item) => {
const filteredLowStock = lowStock.flatMap((item) => {
const packageType = activeMedicationByName.get(item.name);
if (!packageType) return false;
if (isTubePackageType(packageType)) return false;
return true;
if (!packageType || isTubePackageType(packageType)) return [];
return [{ ...item, packageType }];
});
if (filteredLowStock.length === 0) {
request.log.warn("[ReminderManual] Stock reminder skipped: no active medications after filtering");
@@ -656,7 +667,7 @@ ${getFooterPlain(language)}`;
messageParts.push(`🚨 ${tr.push.criticalSection}:`);
criticalMeds.forEach((r) =>
messageParts.push(
`${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`
`${r.name}: ${formatPlannerQuantity(r.packageType, r.medsLeft, tr)}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`
)
);
}
@@ -665,7 +676,7 @@ ${getFooterPlain(language)}`;
messageParts.push(`⚠️ ${tr.push.lowStockSection}:`);
lowStockMeds.forEach((r) =>
messageParts.push(
`${r.name}: ${t(tr.push.pillsLeft, { count: r.medsLeft })}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`
`${r.name}: ${formatPlannerQuantity(r.packageType, r.medsLeft, tr)}, ${t(tr.push.daysLeft, { count: r.daysLeft ?? 0 })}`
)
);
}
@@ -746,12 +757,13 @@ ${getFooterPlain(language)}`;
const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg;
const safeName = escapeHtml(row.name);
const safeMedsLeft = Number(row.medsLeft) || 0;
const safeQuantity = escapeHtml(formatPlannerQuantity(row.packageType, safeMedsLeft, tr));
const safeDaysLeft = Number(row.daysLeft) || 0;
const safeDepletionDate = row.depletionDate ? escapeHtml(String(row.depletionDate)) : "-";
return `
<tr style="background: ${rowBg};">
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${safeName}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${safeMedsLeft}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${safeQuantity}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeDaysLeft}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${isEmpty ? `<strong>${tr.stockReminder.now}</strong>` : safeDepletionDate}</td>
</tr>`;
+61 -40
View File
@@ -12,16 +12,22 @@ import {
idParamsSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
import {
isAmountBasedPackageType,
isDiscreteCountPackageType,
isPackageAmountPackageType,
normalizePackageType,
} from "../utils/package-profiles.js";
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 +35,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 +57,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 +89,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 +146,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 isDiscreteCountPackage = isDiscreteCountPackageType(packageType);
const isAmountBased = isAmountBasedPackageType(packageType);
const isCountBasedAmountPackage = isAmountBased && !isBottle;
const isPackageAmountPackage = isPackageAmountPackageType(packageType);
const pillsPerPack = isDiscreteCountPackage ? 0 : med.blistersPerPack * med.pillsPerBlister;
const configuredAmountPerPackage = Number(med.packageAmountValue ?? 0);
const fallbackAmountPerPackage = Math.max(
@@ -153,19 +164,17 @@ export async function refillRoutes(app: FastifyInstance) {
: fallbackAmountPerPackage;
const requestedPackAdds = Math.max(0, packsAdded);
const requestedAmountAdds = Math.max(0, loosePillsAdded);
const derivedCountFromAmount = Math.max(0, Math.round(requestedAmountAdds / amountPerPackage));
const requestedLooseAdds = Math.max(0, loosePillsAdded);
const requestedQuantityAdds = Math.max(0, quantityAdded > 0 ? quantityAdded : requestedLooseAdds);
let effectivePacksAdded = requestedPackAdds;
if (isBottle) {
if (isDiscreteCountPackage) {
effectivePacksAdded = 0;
} else if (isCountBasedAmountPackage) {
effectivePacksAdded = Math.max(requestedPackAdds, derivedCountFromAmount);
}
const effectiveLoosePillsAdded = isCountBasedAmountPackage
? effectivePacksAdded * amountPerPackage
: requestedAmountAdds;
const effectiveLoosePillsAdded = isPackageAmountPackage ? requestedQuantityAdds : requestedLooseAdds;
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" });
@@ -178,29 +187,50 @@ export async function refillRoutes(app: FastifyInstance) {
if (remainingPrescriptionRefills <= 0) {
return reply.status(409).send({ error: "No remaining prescription refills" });
}
if (!isBottle && effectivePacksAdded > remainingPrescriptionRefills) {
if (!isDiscreteCountPackage && effectivePacksAdded > remainingPrescriptionRefills) {
return reply.status(409).send({ error: "Packs to add exceed remaining prescription refills" });
}
}
// Update medication stock
const newPackCount = med.packCount + effectivePacksAdded;
const newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
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 (isDiscreteCountPackage) {
newLooseTablets = targetCurrentStock;
newTotalAmount = Math.max(newTotalAmount, targetCurrentStock);
newStockAdjustment = 0;
} else if (isPackageAmountPackage) {
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) {
consumedRefills = isBottle ? 1 : effectivePacksAdded;
consumedRefills = isDiscreteCountPackage ? 1 : effectivePacksAdded;
}
const newRemainingRefills = usePrescription
? 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,12 +239,13 @@ export async function refillRoutes(app: FastifyInstance) {
} = {
packCount: newPackCount,
looseTablets: newLooseTablets,
stockAdjustment: newStockAdjustment,
prescriptionRemainingRefills: newRemainingRefills,
lastStockCorrectionAt: refillBaselineAt,
updatedAt: refillBaselineAt,
};
if (isCountBasedAmountPackage) {
if (isPackageAmountPackage) {
updatePayload.totalPills = newTotalAmount;
updatePayload.packageAmountValue = amountPerPackage;
}
@@ -236,31 +267,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,
@@ -308,14 +328,15 @@ export async function refillRoutes(app: FastifyInstance) {
.orderBy(desc(refillHistory.refillDate));
const packageType = normalizePackageType(med.packageType);
const isBottle = packageType === "bottle";
const isDiscreteCountPackage = isDiscreteCountPackageType(packageType);
const isAmountBased = isAmountBasedPackageType(packageType);
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
const pillsPerPack = isDiscreteCountPackage ? 0 : med.blistersPerPack * med.pillsPerBlister;
return refills.map((r) => ({
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,
+135 -12
View File
@@ -1,4 +1,4 @@
import { and, eq } from "drizzle-orm";
import { and, eq, gte, lt } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
@@ -12,9 +12,42 @@ import {
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
const reportDataSchema = z.object({
medicationIds: z.array(z.number().int().positive()).min(1).max(100),
});
const reportDataSchema = z
.object({
medicationIds: z.array(z.number().int().positive()).min(1).max(100),
startDate: z.string().datetime().optional(),
endDate: z.string().datetime().optional(),
takenByFilter: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
})
.superRefine((value, ctx) => {
const hasStartDate = typeof value.startDate === "string";
const hasEndDate = typeof value.endDate === "string";
if (hasStartDate !== hasEndDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "startDate and endDate must be provided together",
path: hasStartDate ? ["endDate"] : ["startDate"],
});
return;
}
if (!hasStartDate || !hasEndDate) {
return;
}
const startDateValue = value.startDate!;
const endDateValue = value.endDate!;
const startDate = new Date(startDateValue);
const endDate = new Date(endDateValue);
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime()) || endDate <= startDate) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Invalid date range",
path: ["endDate"],
});
}
});
const reportDataBodyOpenApiSchema = {
type: "object",
@@ -26,12 +59,65 @@ const reportDataBodyOpenApiSchema = {
maxItems: 100,
items: { type: "integer", minimum: 1 },
},
startDate: {
type: "string",
format: "date-time",
},
endDate: {
type: "string",
format: "date-time",
},
takenByFilter: {
type: "array",
maxItems: 50,
items: { type: "string", minLength: 1, maxLength: 100 },
},
},
example: {
medicationIds: [1, 3, 5],
startDate: "2026-05-01T00:00:00.000Z",
endDate: "2026-06-01T00:00:00.000Z",
takenByFilter: ["Daniel"],
},
} as const;
const trackedDoseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
function getPersonTagKey(value: string): string {
return value.trim().toLocaleLowerCase();
}
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(getPersonTagKey(takenBy));
}
function getDoseScheduledAtMs(doseId: string): number | null {
const match = trackedDoseIdPattern.exec(doseId);
if (!match) {
return null;
}
const scheduledAtMs = Number.parseInt(match[3], 10);
return Number.isNaN(scheduledAtMs) ? null : scheduledAtMs;
}
function isWithinDateRange(timestampMs: number | null, range: { startMs: number; endMs: number } | null): boolean {
if (!range) {
return true;
}
if (timestampMs === null) {
return false;
}
return timestampMs >= range.startMs && timestampMs < range.endMs;
}
const reportDataResponseSchema = {
type: "object",
additionalProperties: {
@@ -39,7 +125,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 +135,7 @@ const reportDataResponseSchema = {
properties: {
packsAdded: { type: "integer" },
loosePillsAdded: { type: "integer" },
quantityAdded: { type: "integer" },
usedPrescription: { type: "boolean" },
refillDate: { type: "string", format: "date-time" },
},
@@ -93,10 +180,29 @@ 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, startDate, endDate, takenByFilter } = parsed.data;
const normalizedTakenByFilter = takenByFilter?.length
? new Set(takenByFilter.map((value) => getPersonTagKey(value)))
: null;
const dateRange =
startDate && endDate
? {
startMs: new Date(startDate).getTime(),
endMs: new Date(endDate).getTime(),
}
: 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 +228,8 @@ 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 (!isWithinDateRange(getDoseScheduledAtMs(dose.doseId), dateRange)) continue;
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
dosesByMed.get(medId)!.push({
takenAt: dose.takenAt,
@@ -136,10 +244,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,25 +261,34 @@ 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 refillFilters = [eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)];
if (dateRange) {
refillFilters.push(gte(refillHistory.refillDate, new Date(dateRange.startMs)));
refillFilters.push(lt(refillHistory.refillDate, new Date(dateRange.endMs)));
}
const refills = await db
.select()
.from(refillHistory)
.where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)));
.where(and(...refillFilters));
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}` };
+194 -14
View File
@@ -1,5 +1,5 @@
import { randomBytes } from "node:crypto";
import { and, eq } from "drizzle-orm";
import { and, desc, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
@@ -15,6 +15,7 @@ import {
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
import { redactTokenForLog } from "../utils/redaction.js";
import {
getAllTakenByForMedication,
parseIntakesJson,
@@ -28,6 +29,11 @@ import {
const createShareSchema = z.object({
takenBy: z.string().min(1, "takenBy is required"),
scheduleDays: z.number().int().min(1).max(365).default(30),
expiryDays: z
.union([z.number().int().min(1).max(365), z.null()])
.optional()
.default(null),
allowJournalNotes: z.boolean().optional().default(false),
});
const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
@@ -37,15 +43,59 @@ const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>
const shareTokenPattern = /^[a-f0-9]{16}$/;
function toIsoTimestamp(value: Date | string | number | null | undefined): string | null {
if (value == null) {
return null;
}
try {
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.toISOString();
}
if (typeof value === "number" || (typeof value === "string" && /^\d+$/.test(value))) {
const numericValue = typeof value === "number" ? value : Number(value);
const timestampMs = numericValue < 1_000_000_000_000 ? numericValue * 1000 : numericValue;
const date = new Date(timestampMs);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
} catch {
return null;
}
}
function resolveExpiryDate(expiryDays: number | null | undefined): Date | null {
if (expiryDays == null) {
return null;
}
return new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);
}
function isExpiredTimestamp(value: Date | string | number | null | undefined): boolean {
const isoValue = toIsoTimestamp(value);
return isoValue != null && new Date(isoValue).getTime() < Date.now();
}
const createShareBodyOpenApiSchema = {
type: "object",
properties: {
takenBy: { type: "string" },
scheduleDays: { type: "integer", minimum: 1, maximum: 365, default: 30 },
allowJournalNotes: { type: "boolean", default: false },
expiryDays: {
anyOf: [{ type: "integer", minimum: 1, maximum: 365 }, { type: "null" }],
default: null,
},
},
example: {
takenBy: "Daniel",
scheduleDays: 14,
allowJournalNotes: true,
expiryDays: 30,
},
} as const;
@@ -64,6 +114,7 @@ const shareReadResponseSchema = {
stockCalculationMode: { type: "string", enum: ["automatic", "manual"] },
upcomingTodayOnly: { type: "boolean" },
shareScheduleTodayOnly: { type: "boolean" },
allowJournalNotes: { type: "boolean" },
},
} as const;
@@ -96,6 +147,37 @@ const shareOverviewResponseSchema = {
},
} as const;
const shareListResponseSchema = {
type: "object",
properties: {
shareLinks: {
type: "array",
items: {
type: "object",
properties: {
token: { type: "string" },
takenBy: { type: "string" },
scheduleDays: { type: "integer" },
createdAt: { type: "string", format: "date-time" },
expiresAt: { type: ["string", "null"], format: "date-time" },
allowJournalNotes: { type: "boolean" },
shareUrl: { type: "string" },
},
required: ["token", "takenBy", "scheduleDays", "createdAt", "expiresAt", "allowJournalNotes", "shareUrl"],
},
},
},
required: ["shareLinks"],
} as const;
const ownerTokenParamsSchema = {
type: "object",
properties: {
token: { type: "string" },
},
required: ["token"],
} as const;
// Helper to get user ID from request
// Returns anonymous user ID when auth is disabled
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
@@ -146,11 +228,12 @@ export async function shareRoutes(app: FastifyInstance) {
},
async (request, reply) => {
const { token } = request.params;
const tokenRef = redactTokenForLog(token);
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
request.log.warn(`[Share] Invalid share token requested: token=${token}`);
request.log.warn(`[Share] Invalid share token requested: tokenRef=${tokenRef}`);
return reply.status(404).send({
error: "Share link not found",
code: "NOT_FOUND",
@@ -160,7 +243,7 @@ export async function shareRoutes(app: FastifyInstance) {
// Check if token has expired
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
request.log.warn(
`[Share] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
`[Share] Expired token requested: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
);
// Get the username of the owner to show in the expired message
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
@@ -255,6 +338,7 @@ export async function shareRoutes(app: FastifyInstance) {
takenBy: share.takenBy,
sharedBy: owner?.username ?? null,
scheduleDays: share.scheduleDays,
allowJournalNotes: share.allowJournalNotes ?? false,
medications: medicationsWithBlisters,
shareMedicationOverview,
medicationOverview,
@@ -298,20 +382,21 @@ export async function shareRoutes(app: FastifyInstance) {
reply.header("Cache-Control", "no-store");
const { token } = request.params;
const tokenRef = redactTokenForLog(token);
if (!shareTokenPattern.test(token)) {
request.log.warn(`[ShareOverview] Rejected invalid token format: token=${token}`);
request.log.warn(`[ShareOverview] Rejected invalid token format: tokenRef=${tokenRef}`);
return reply.status(404).send({ error: "not_found" });
}
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
request.log.warn(`[ShareOverview] Unknown token requested: token=${token}`);
request.log.warn(`[ShareOverview] Unknown token requested: tokenRef=${tokenRef}`);
return reply.status(404).send({ error: "not_found" });
}
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
request.log.warn(
`[ShareOverview] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
`[ShareOverview] Expired token requested: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
);
return reply.status(410).send({
error: "expired",
@@ -371,6 +456,7 @@ export async function shareRoutes(app: FastifyInstance) {
reused: { type: "boolean" },
token: { type: "string" },
shareUrl: { type: "string" },
allowJournalNotes: { type: "boolean" },
expiresAt: { type: ["string", "null"] },
},
},
@@ -385,12 +471,13 @@ 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",
});
}
const { takenBy, scheduleDays } = parsed.data;
const { takenBy, scheduleDays, expiryDays, allowJournalNotes } = parsed.data;
const expiresAt = resolveExpiryDate(expiryDays);
// Check if user has medications for this takenBy (search in both medication-level and intake-level)
const allMeds = await db
@@ -422,43 +509,136 @@ export async function shareRoutes(app: FastifyInstance) {
.where(and(eq(shareTokens.userId, userId), eq(shareTokens.takenBy, takenBy)));
if (existingShare) {
await db.update(shareTokens).set({ scheduleDays, expiresAt: null }).where(eq(shareTokens.id, existingShare.id));
const existingTokenRef = redactTokenForLog(existingShare.token);
await db
.update(shareTokens)
.set({ scheduleDays, expiresAt, allowJournalNotes })
.where(eq(shareTokens.id, existingShare.id));
request.log.info(
`[Share] Reused existing share token: token=${existingShare.token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}`
`[Share] Reused existing share token: tokenRef=${existingTokenRef}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}, allowJournalNotes=${allowJournalNotes}, expiresAt=${expiresAt?.toISOString() ?? "never"}`
);
return {
reused: true,
token: existingShare.token,
shareUrl: `/share/${existingShare.token}`,
expiresAt: null,
allowJournalNotes,
expiresAt: toIsoTimestamp(expiresAt),
};
}
const token = randomBytes(8).toString("hex");
const tokenRef = redactTokenForLog(token);
await db.insert(shareTokens).values({
userId,
token,
takenBy,
scheduleDays,
expiresAt: null,
allowJournalNotes,
expiresAt,
});
request.log.info(
`[Share] Created new share token: token=${token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}`
`[Share] Created new share token: tokenRef=${tokenRef}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}, allowJournalNotes=${allowJournalNotes}, expiresAt=${expiresAt?.toISOString() ?? "never"}`
);
return {
reused: false,
token,
shareUrl: `/share/${token}`,
expiresAt: null,
allowJournalNotes,
expiresAt: toIsoTimestamp(expiresAt),
};
}
);
// ---------------------------------------------------------------------------
// GET /share - PROTECTED: List active share links for current owner
// ---------------------------------------------------------------------------
app.get(
"/share",
{
preHandler: requireAuth,
schema: {
tags: ["share"],
security: protectedEndpointSecurity,
response: {
200: shareListResponseSchema,
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const shares = await db
.select()
.from(shareTokens)
.where(eq(shareTokens.userId, userId))
.orderBy(desc(shareTokens.createdAt));
return {
shareLinks: shares
.filter((share) => !isExpiredTimestamp(share.expiresAt))
.map((share) => ({
token: share.token,
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
createdAt: toIsoTimestamp(share.createdAt) ?? new Date().toISOString(),
expiresAt: toIsoTimestamp(share.expiresAt),
allowJournalNotes: share.allowJournalNotes ?? false,
shareUrl: `/share/${share.token}`,
})),
};
}
);
// ---------------------------------------------------------------------------
// DELETE /share/:token - PROTECTED: Revoke an existing share link
// ---------------------------------------------------------------------------
app.delete<{ Params: { token: string } }>(
"/share/:token",
{
preHandler: requireAuth,
schema: {
tags: ["share"],
security: protectedEndpointSecurity,
params: ownerTokenParamsSchema,
response: {
204: { type: "null" },
401: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const { token } = request.params;
const tokenRef = redactTokenForLog(token);
const [share] = await db
.select()
.from(shareTokens)
.where(and(eq(shareTokens.userId, userId), eq(shareTokens.token, token)));
if (!share) {
return reply.status(404).send({
error: "Share link not found",
code: "NOT_FOUND",
});
}
await db.delete(shareTokens).where(eq(shareTokens.id, share.id));
request.log.info(
`[Share] Revoked share token: tokenRef=${tokenRef}, ownerUserId=${userId}, takenBy=${share.takenBy}`
);
return reply.status(204).send();
}
);
// ---------------------------------------------------------------------------
// GET /share/people - PROTECTED: Get list of unique takenBy values
// ---------------------------------------------------------------------------
+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,
};
}
@@ -0,0 +1,90 @@
import { eq } from "drizzle-orm";
import { db } from "../db/client.js";
import { intakeJournal } from "../db/schema.js";
type IntakeJournalWriteDatabase = Pick<typeof db, "insert">;
export type IntakeJournalExportPayload = {
journalNote: string;
journalCreatedAt?: string | null;
journalUpdatedAt?: string | null;
};
function toIsoStringOrNull(value: Date | string | number | null | undefined): string | null {
if (!value) {
return null;
}
try {
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.toISOString();
}
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
} catch {
return null;
}
}
function toDateOrFallback(value: string | null | undefined, fallback: Date): Date {
if (!value) {
return fallback;
}
try {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
} catch {
return fallback;
}
}
export async function listIntakeJournalExportPayloadsForUser(
userId: number
): Promise<Map<number, IntakeJournalExportPayload>> {
const rows = await db.select().from(intakeJournal).where(eq(intakeJournal.userId, userId));
return new Map(
rows.map((row) => [
row.doseTrackingId,
{
journalNote: row.note,
journalCreatedAt: toIsoStringOrNull(row.createdAt),
journalUpdatedAt: toIsoStringOrNull(row.updatedAt),
},
])
);
}
export async function restoreIntakeJournalForImportedDose(input: {
userId: number;
doseTrackingId: number;
medicationId: number;
scheduledFor: Date;
journalNote?: string | null;
journalCreatedAt?: string | null;
journalUpdatedAt?: string | null;
database?: IntakeJournalWriteDatabase;
}): Promise<boolean> {
const normalizedNote = input.journalNote?.trim() ?? "";
if (normalizedNote.length === 0) {
return false;
}
const createdAt = toDateOrFallback(input.journalCreatedAt, input.scheduledFor);
const updatedAt = toDateOrFallback(input.journalUpdatedAt, createdAt);
const database = input.database ?? db;
await database.insert(intakeJournal).values({
userId: input.userId,
doseTrackingId: input.doseTrackingId,
medicationId: input.medicationId,
scheduledFor: input.scheduledFor,
note: normalizedNote,
createdAt,
updatedAt,
});
return true;
}
@@ -0,0 +1,332 @@
import { and, desc, eq, gte, lte } from "drizzle-orm";
import { db } from "../db/client.js";
import { doseTracking, intakeJournal, medications } from "../db/schema.js";
import { parseIntakesJson, parseLocalDateTime } from "../utils/scheduler-utils.js";
import type { DoseTrackingSource } from "./dose-tracking-service.js";
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
type ParsedDoseId = {
medicationId: number;
intakeIndex: number;
timestampMs: number;
personSuffix: string | null;
};
type MedicationTimingRow = {
id: number;
name: string | null;
genericName: string | null;
intakesJson: string;
usageJson: string;
everyJson: string;
startJson: string;
intakeRemindersEnabled: boolean;
};
export type ResolvedTrackedDoseEvent = {
doseTrackingId: number;
userId: number;
doseId: string;
medicationId: number;
medicationName: string;
scheduledFor: Date;
takenAt: Date;
markedBy: string | null;
takenSource: DoseTrackingSource;
dismissed: boolean;
personSuffix: string | null;
};
export type IntakeJournalEntry = typeof intakeJournal.$inferSelect;
export type IntakeJournalHistoryEntry = {
id: number;
doseTrackingId: number;
doseId: string;
medicationId: number;
medicationName: string;
scheduledFor: Date;
takenAt: Date;
markedBy: string | null;
takenSource: DoseTrackingSource;
dismissed: boolean;
note: string;
createdAt: Date;
updatedAt: Date;
};
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,
};
}
export function isTrackedDoseIdFormat(doseId: string): boolean {
return parseDoseId(doseId) !== null;
}
function getMedicationDisplayName(medication: Pick<MedicationTimingRow, "id" | "name" | "genericName">): string {
const commercialName = medication.name?.trim() ?? "";
if (commercialName.length > 0) {
return commercialName;
}
const genericName = medication.genericName?.trim() ?? "";
if (genericName.length > 0) {
return genericName;
}
return `Medication #${medication.id}`;
}
function resolveScheduledFor(parsedDose: ParsedDoseId, medication: MedicationTimingRow): Date {
const intakes = parseIntakesJson(
medication.intakesJson,
{
usageJson: medication.usageJson,
everyJson: medication.everyJson,
startJson: medication.startJson,
},
medication.intakeRemindersEnabled
);
const intake = intakes[parsedDose.intakeIndex];
if (!intake) {
return new Date(parsedDose.timestampMs);
}
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()
);
}
export async function resolveTrackedDoseEventForUser(input: {
userId: number;
doseId: string;
}): Promise<ResolvedTrackedDoseEvent | null> {
const parsedDose = parseDoseId(input.doseId);
if (!parsedDose) {
return null;
}
const [event] = await db
.select({
doseTrackingId: doseTracking.id,
userId: doseTracking.userId,
doseId: doseTracking.doseId,
takenAt: doseTracking.takenAt,
markedBy: doseTracking.markedBy,
takenSource: doseTracking.takenSource,
dismissed: doseTracking.dismissed,
medicationId: medications.id,
medicationName: medications.name,
medicationGenericName: medications.genericName,
intakesJson: medications.intakesJson,
usageJson: medications.usageJson,
everyJson: medications.everyJson,
startJson: medications.startJson,
intakeRemindersEnabled: medications.intakeRemindersEnabled,
})
.from(doseTracking)
.innerJoin(medications, and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, input.userId)))
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)))
.limit(1);
if (!event) {
return null;
}
const scheduledFor = resolveScheduledFor(parsedDose, {
id: event.medicationId,
name: event.medicationName,
genericName: event.medicationGenericName,
intakesJson: event.intakesJson,
usageJson: event.usageJson,
everyJson: event.everyJson,
startJson: event.startJson,
intakeRemindersEnabled: event.intakeRemindersEnabled ?? false,
});
return {
doseTrackingId: event.doseTrackingId,
userId: event.userId,
doseId: event.doseId,
medicationId: event.medicationId,
medicationName: getMedicationDisplayName({
id: event.medicationId,
name: event.medicationName,
genericName: event.medicationGenericName,
}),
scheduledFor,
takenAt: event.takenAt,
markedBy: event.markedBy,
takenSource: event.takenSource as DoseTrackingSource,
dismissed: event.dismissed ?? false,
personSuffix: parsedDose.personSuffix,
};
}
export async function getIntakeJournalForDoseEvent(input: {
userId: number;
doseId: string;
}): Promise<IntakeJournalEntry | null> {
const event = await resolveTrackedDoseEventForUser(input);
if (!event) {
return null;
}
const [journalEntry] = await db
.select()
.from(intakeJournal)
.where(and(eq(intakeJournal.userId, input.userId), eq(intakeJournal.doseTrackingId, event.doseTrackingId)))
.limit(1);
return journalEntry ?? null;
}
export async function upsertIntakeJournalForDoseEvent(input: {
userId: number;
doseId: string;
note: string;
}): Promise<IntakeJournalEntry | null> {
const normalizedNote = input.note.trim();
if (normalizedNote.length === 0) {
await deleteIntakeJournalForDoseEvent({ userId: input.userId, doseId: input.doseId });
return null;
}
const event = await resolveTrackedDoseEventForUser({ userId: input.userId, doseId: input.doseId });
if (!event) {
return null;
}
const now = new Date();
await db
.insert(intakeJournal)
.values({
userId: input.userId,
doseTrackingId: event.doseTrackingId,
medicationId: event.medicationId,
scheduledFor: event.scheduledFor,
note: normalizedNote,
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: intakeJournal.doseTrackingId,
set: {
userId: input.userId,
medicationId: event.medicationId,
note: normalizedNote,
updatedAt: now,
},
});
return getIntakeJournalForDoseEvent({ userId: input.userId, doseId: input.doseId });
}
export async function deleteIntakeJournalForDoseEvent(input: { userId: number; doseId: string }): Promise<boolean> {
const event = await resolveTrackedDoseEventForUser(input);
if (!event) {
return false;
}
await db
.delete(intakeJournal)
.where(and(eq(intakeJournal.userId, input.userId), eq(intakeJournal.doseTrackingId, event.doseTrackingId)));
return true;
}
export async function listIntakeJournalEntriesForUser(input: {
userId: number;
medicationId?: number;
from?: Date;
to?: Date;
limit?: number;
}): Promise<IntakeJournalHistoryEntry[]> {
const filters = [eq(intakeJournal.userId, input.userId)];
if (typeof input.medicationId === "number") {
filters.push(eq(intakeJournal.medicationId, input.medicationId));
}
if (input.from) {
filters.push(gte(intakeJournal.scheduledFor, input.from));
}
if (input.to) {
filters.push(lte(intakeJournal.scheduledFor, input.to));
}
const rows = await db
.select({
id: intakeJournal.id,
doseTrackingId: intakeJournal.doseTrackingId,
doseId: doseTracking.doseId,
medicationId: intakeJournal.medicationId,
medicationName: medications.name,
medicationGenericName: medications.genericName,
scheduledFor: intakeJournal.scheduledFor,
takenAt: doseTracking.takenAt,
markedBy: doseTracking.markedBy,
takenSource: doseTracking.takenSource,
dismissed: doseTracking.dismissed,
note: intakeJournal.note,
createdAt: intakeJournal.createdAt,
updatedAt: intakeJournal.updatedAt,
})
.from(intakeJournal)
.innerJoin(doseTracking, eq(doseTracking.id, intakeJournal.doseTrackingId))
.innerJoin(medications, eq(medications.id, intakeJournal.medicationId))
.where(and(...filters))
.orderBy(desc(intakeJournal.scheduledFor), desc(intakeJournal.updatedAt))
.limit(input.limit ?? 100);
return rows.map((row) => ({
id: row.id,
doseTrackingId: row.doseTrackingId,
doseId: row.doseId,
medicationId: row.medicationId,
medicationName: getMedicationDisplayName({
id: row.medicationId,
name: row.medicationName,
genericName: row.medicationGenericName,
}),
scheduledFor: row.scheduledFor,
takenAt: row.takenAt,
markedBy: row.markedBy,
takenSource: row.takenSource as DoseTrackingSource,
dismissed: row.dismissed ?? false,
note: row.note,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}));
}
@@ -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,380 @@
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>
);
}
async function resetActionTokens(groupId: number): Promise<void> {
await db.delete(notificationActionTokens).where(eq(notificationActionTokens.groupId, groupId));
}
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) {
const [existingGroup] = await db
.select()
.from(notificationActionGroups)
.where(eq(notificationActionGroups.groupKey, groupKey));
if (existingGroup) {
await resetActionTokens(existingGroup.id);
[group] = await db
.update(notificationActionGroups)
.set({
sequenceId,
ntfyOriginalMessageId: "",
doseIdsJson: JSON.stringify(uniqueDoseIds),
title: input.title,
message: input.message,
language: input.language,
scheduledFor: input.scheduledFor,
expiresAt,
resolvedAction: null,
resolvedAt: null,
updatedAt: now,
})
.where(eq(notificationActionGroups.id, existingGroup.id))
.returning();
} else {
[group] = await db
.insert(notificationActionGroups)
.values({
userId: input.userId,
groupKey,
sequenceId,
doseIdsJson: JSON.stringify(uniqueDoseIds),
title: input.title,
message: input.message,
language: input.language,
scheduledFor: input.scheduledFor,
expiresAt,
updatedAt: now,
})
.returning();
}
}
const tokens = await createActionTokens(group.id);
const groupLanguage = (group.language as Language | null) ?? input.language;
const groupLabels = getNotificationActionLabels(groupLanguage);
const respondUrl = `${baseUrl}/api/notification-actions/${tokens.respond}`;
const resolvedViewUrl = buildViewUrl(baseUrl, group.scheduledFor ?? input.scheduledFor, uniqueDoseIds);
return {
groupId: group.id,
sequenceId: group.sequenceId,
respondUrl,
viewUrl: resolvedViewUrl,
actions: [
{
kind: "taken",
label: groupLabels.taken,
url: `${baseUrl}/api/notification-actions/${tokens.taken}`,
method: "POST",
},
{
kind: "skip",
label: groupLabels.skip,
url: `${baseUrl}/api/notification-actions/${tokens.skip}`,
method: "POST",
},
{ kind: "view", label: groupLabels.view, url: resolvedViewUrl, method: "GET" },
],
};
}
export async function createTestNotificationActionContext(input: {
userId: number;
title: string;
message: string;
publicAppUrl?: string | null;
language: Language;
}): Promise<NotificationActionContext | null> {
const publicAppUrl = resolveNotificationPublicAppUrl(input.publicAppUrl);
if (!publicAppUrl) {
return null;
}
const baseUrl = publicAppUrl;
const now = new Date();
const groupKey = `test:${input.userId}:${now.getTime()}:${randomBytes(8).toString("hex")}`;
const sequenceId = createSequenceId(groupKey);
const expiresAt = new Date(now.getTime() + NOTIFICATION_ACTION_TTL_MS);
const viewUrl = buildViewUrl(baseUrl, null, []);
const [group] = await db
.insert(notificationActionGroups)
.values({
userId: input.userId,
groupKey,
sequenceId,
doseIdsJson: "[]",
title: input.title,
message: input.message,
language: input.language,
scheduledFor: now,
expiresAt,
updatedAt: now,
})
.returning();
const tokens = await createActionTokens(group.id);
const groupLanguage = (group.language as Language | null) ?? input.language;
const groupLabels = getNotificationActionLabels(groupLanguage);
const respondUrl = `${baseUrl}/api/notification-actions/${tokens.respond}`;
return {
groupId: group.id,
sequenceId: group.sequenceId,
respondUrl,
viewUrl,
actions: [
{
kind: "taken",
label: groupLabels.taken,
url: `${baseUrl}/api/notification-actions/${tokens.taken}`,
method: "POST",
},
{
kind: "skip",
label: groupLabels.skip,
url: `${baseUrl}/api/notification-actions/${tokens.skip}`,
method: "POST",
},
{ kind: "view", label: groupLabels.view, url: viewUrl, method: "GET" },
],
};
}
export async function getNotificationActionTokenRecord(
rawToken: string
): Promise<NotificationActionTokenRecord | null> {
const tokenHash = hashActionToken(rawToken);
const rows = await db
.select({ token: notificationActionTokens, group: notificationActionGroups })
.from(notificationActionTokens)
.innerJoin(notificationActionGroups, eq(notificationActionTokens.groupId, notificationActionGroups.id))
.where(eq(notificationActionTokens.tokenHash, tokenHash));
const record = rows[0];
if (!record) {
return null;
}
const baseUrl = resolveNotificationPublicAppUrl(env.PUBLIC_APP_URL);
return {
token: record.token,
group: record.group,
doseIds: parseDoseIdsJson(record.group.doseIdsJson),
viewUrl: baseUrl
? buildViewUrl(baseUrl, record.group.scheduledFor, parseDoseIdsJson(record.group.doseIdsJson))
: null,
};
}
export function isNotificationActionExpired(record: NotificationActionTokenRecord): boolean {
return record.group.expiresAt.getTime() <= Date.now();
}
export async function storeNotificationActionGroupNtfyMessageId(groupId: number, ntfyMessageId: string): Promise<void> {
const normalizedMessageId = ntfyMessageId.trim();
if (normalizedMessageId.length === 0) {
return;
}
await db
.update(notificationActionGroups)
.set({ ntfyOriginalMessageId: normalizedMessageId, updatedAt: new Date() })
.where(eq(notificationActionGroups.id, groupId));
}
@@ -0,0 +1,175 @@
import type { Language } from "../../i18n/translations.js";
export type PushNotificationAction =
| {
kind: "taken";
label: string;
url: string;
method: "POST";
}
| {
kind: "skip";
label: string;
url: string;
method: "POST";
}
| {
kind: "view";
label: string;
url: string;
method: "GET";
};
export type PushNotificationOptions = {
actions?: PushNotificationAction[];
respondUrl?: string;
viewUrl?: string;
clickUrl?: string;
tags?: string[];
priority?: number;
sequenceId?: string;
};
type NtfyActionPayload = {
action: "http" | "view";
label: string;
url: string;
method?: "POST";
clear: boolean;
};
function encodeHeaderValue(value: string): string {
if ([...value].every((char) => char.charCodeAt(0) <= 0x7f)) {
return value;
}
return `=?UTF-8?B?${Buffer.from(value, "utf-8").toString("base64")}?=`;
}
export function isNtfyNotificationUrl(urlStr: string): boolean {
if (urlStr.startsWith("ntfy://")) {
return true;
}
try {
const parsed = new URL(urlStr);
if (!["http:", "https:"].includes(parsed.protocol)) {
return false;
}
const hostname = parsed.hostname.toLowerCase();
return hostname === "ntfy.sh" || hostname === "ntfy" || hostname.startsWith("ntfy.") || hostname.includes(".ntfy.");
} catch {
return false;
}
}
export function getNotificationProvider(urlStr: string): string {
if (isNtfyNotificationUrl(urlStr)) {
return "ntfy";
}
try {
return new URL(urlStr).protocol.replace(":", "").toLowerCase();
} catch {
return "unknown";
}
}
export function getNotificationActionLabels(language: Language): {
taken: string;
skip: string;
respond: string;
view: string;
} {
if (language === "de") {
return {
taken: "Einnehmen",
skip: "Überspringen",
respond: "Antworten",
view: "Öffnen",
};
}
return {
taken: "Take",
skip: "Skip",
respond: "Respond",
view: "View",
};
}
export function buildNtfyActions(options: PushNotificationOptions): NtfyActionPayload[] {
const actions = options.actions ?? [];
return actions.map((action) => {
if (action.kind === "view") {
return {
action: "view",
label: action.label,
url: action.url,
clear: false,
};
}
return {
action: "http",
label: action.label,
url: action.url,
method: "POST",
// Clear the original actionable ntfy notification locally after a successful mutation.
clear: true,
};
});
}
export function appendFallbackActionLinks(message: string, options: PushNotificationOptions): string {
if (!options.respondUrl && !options.viewUrl) {
return message;
}
const lines = [message.trimEnd()];
if (options.respondUrl) {
lines.push("", "Respond:", options.respondUrl);
}
if (options.viewUrl) {
lines.push("", "View:", options.viewUrl);
}
return lines.join("\n");
}
export function renderNotificationActionPayload(
urlStr: string,
message: string,
options: PushNotificationOptions
): { message: string; headers: Record<string, string> } {
if (!isNtfyNotificationUrl(urlStr)) {
return {
message: appendFallbackActionLinks(message, options),
headers: {},
};
}
const headers: Record<string, string> = {};
const ntfyActions = buildNtfyActions(options);
if (ntfyActions.length > 0) {
headers.Actions = encodeHeaderValue(JSON.stringify(ntfyActions));
}
if (options.clickUrl && ntfyActions.length === 0) {
headers.Click = options.clickUrl;
}
if (options.tags && options.tags.length > 0) {
headers.Tags = options.tags.join(",");
}
if (typeof options.priority === "number") {
headers.Priority = String(options.priority);
}
if (options.sequenceId) {
headers["X-Sequence-ID"] = options.sequenceId;
}
return { message, headers };
}
@@ -1,8 +1,10 @@
import { getFooterPlain, getTranslations, type Language, t } from "../../i18n/translations.js";
import { formatPlannerQuantity } from "../planner-service.js";
export type StockReminderItem = {
name: string;
medsLeft: number;
packageType?: string;
daysLeft: number | null;
depletionDate: string | null;
isCritical?: boolean;
@@ -47,7 +49,7 @@ export function buildStockReminderPushNotification(
messageParts.push(`🚨 ${tr.push.criticalSection}:`);
criticalItems.forEach((item) =>
messageParts.push(
`${item.name}: ${t(tr.push.pillsLeft, { count: item.medsLeft })}, ${t(tr.push.daysLeft, { count: item.daysLeft ?? 0 })}`
`${item.name}: ${formatPlannerQuantity(item.packageType, item.medsLeft, tr)}, ${t(tr.push.daysLeft, { count: item.daysLeft ?? 0 })}`
)
);
}
@@ -56,7 +58,7 @@ export function buildStockReminderPushNotification(
messageParts.push(`⚠️ ${tr.push.lowStockSection}:`);
lowItems.forEach((item) =>
messageParts.push(
`${item.name}: ${t(tr.push.pillsLeft, { count: item.medsLeft })}, ${t(tr.push.daysLeft, { count: item.daysLeft ?? 0 })}`
`${item.name}: ${formatPlannerQuantity(item.packageType, item.medsLeft, tr)}, ${t(tr.push.daysLeft, { count: item.daysLeft ?? 0 })}`
)
);
}
+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 };
+11 -1
View File
@@ -48,10 +48,20 @@ export function isContainerPackage(packageType?: string): boolean {
export function getPlannerUnit(
packageType: string | undefined,
tr: { common: { units: string; ml: string; pills: string } }
tr: { common: { units: string; ml: string; pills: string; puffs?: string; injections?: string } }
): string {
const unitKind = getPlannerUnitKind(packageType);
if (unitKind === "units") return tr.common.units;
if (unitKind === "ml") return tr.common.ml;
if (unitKind === "puffs") return tr.common.puffs ?? tr.common.pills;
if (unitKind === "injections") return tr.common.injections ?? tr.common.pills;
return tr.common.pills;
}
export function formatPlannerQuantity(
packageType: string | undefined,
count: number,
tr: { common: { units: string; ml: string; pills: string; puffs?: string; injections?: string } }
): string {
return `${count} ${getPlannerUnit(packageType, tr)}`;
}
+21 -5
View File
@@ -21,6 +21,7 @@ import {
formatInTimezone,
getCurrentHourInTimezone,
getDateOnlyTimestamp,
getEffectiveTimezone,
getMsUntilNextCheck,
getNextScheduledOccurrenceTime,
getNextScheduledTime,
@@ -37,6 +38,7 @@ import {
} from "./notifications/builders.js";
import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "./notifications/delivery.js";
import { loadReminderState, saveReminderState, updateUserReminderSentTime } from "./notifications/state.js";
import { formatPlannerQuantity } from "./planner-service.js";
export { getReminderState, updateReminderSentTime, updateUserReminderSentTime } from "./notifications/state.js";
@@ -107,6 +109,7 @@ function releaseReminderSendLock(lockFilePath: string | null): void {
type LowStockItem = {
name: string;
medsLeft: number;
packageType?: string;
daysLeft: number | null;
depletionDate: string | null;
isCritical: boolean;
@@ -125,6 +128,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,8 +309,9 @@ async function getMedicationsNeedingReminder(
if (isCritical || isLow) {
lowStock.push({
name: row.name,
name: getMedicationDisplayName({ id: row.id, name: row.name, genericName: row.genericName }),
medsLeft: currentPills,
packageType,
daysLeft,
depletionDate,
isCritical,
@@ -322,7 +336,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,
@@ -421,10 +435,11 @@ async function sendReminderEmail(
const statusIcon = isEmpty ? "🚨" : nonEmptyIcon;
const nonEmptyBg = isCritical ? "#fff7ed" : "white";
const rowBg = isEmpty ? "#fef2f2" : nonEmptyBg;
const quantityText = formatPlannerQuantity(row.packageType, row.medsLeft, tr);
return `
<tr style="background: ${rowBg};">
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${statusIcon} ${row.name}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${row.medsLeft}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${quantityText}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${row.daysLeft ?? 0}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${isEmpty ? `<strong>${tr.stockReminder.now ?? "-"}</strong>` : (row.depletionDate ?? "-")}</td>
</tr>`;
@@ -468,7 +483,7 @@ async function sendReminderEmail(
${tr.stockReminder.description}
${lowStock.map((r) => `${r.name}: ${r.medsLeft} ${tr.common.pills}, ${r.daysLeft ?? 0} ${tr.common.days}, ${tr.stockReminder.tableHeaders.runsOut}: ${r.depletionDate ?? tr.common.soon}`).join("\n")}
${lowStock.map((r) => `${r.name}: ${formatPlannerQuantity(r.packageType, r.medsLeft, tr)}, ${r.daysLeft ?? 0} ${tr.common.days}, ${tr.stockReminder.tableHeaders.runsOut}: ${r.depletionDate ?? tr.common.soon}`).join("\n")}
---
${getFooterPlain(language)}${isRepeatDaily ? `\n\n${tr.stockReminder.repeatDailyNote}` : ""}`;
@@ -534,7 +549,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,
@@ -62,13 +62,17 @@ describe("planner-service decomposition regression", () => {
});
it("maps package type to expected planner units after service extraction", () => {
const tr = { common: { units: "units", ml: "ml", pills: "pills" } };
const tr = { common: { units: "units", ml: "ml", pills: "pills", puffs: "puffs", injections: "injections" } };
expect(isContainerPackage("bottle")).toBe(true);
expect(isContainerPackage("inhaler")).toBe(true);
expect(isContainerPackage("injection")).toBe(true);
expect(isContainerPackage("blister")).toBe(false);
expect(getPlannerUnit("tube", tr)).toBe("units");
expect(getPlannerUnit("liquid_container", tr)).toBe("ml");
expect(getPlannerUnit("bottle", tr)).toBe("pills");
expect(getPlannerUnit("inhaler", tr)).toBe("puffs");
expect(getPlannerUnit("injection", tr)).toBe("injections");
expect(getPlannerUnit("blister", tr)).toBe("pills");
});
});
@@ -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 }),
]);
});
});
+240 -6
View File
@@ -51,6 +51,7 @@ const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
async function clearTables() {
await testClient.execute("DELETE FROM intake_journal");
await testClient.execute("DELETE FROM dose_tracking");
await testClient.execute("DELETE FROM share_tokens");
await testClient.execute("DELETE FROM api_keys");
@@ -78,20 +79,30 @@ async function insertMedication(options: {
start?: string;
}) {
const intakeStart = options.start ?? "2025-01-01T08:00:00.000Z";
const takenBy = options.takenBy ?? [];
const intakeTakenBy = takenBy[0] ?? null;
await testClient.execute({
sql: `INSERT INTO medications (
id, user_id, name, taken_by_json, medication_form, package_type,
pack_count, blisters_per_pack, pills_per_blister, loose_tablets, stock_adjustment,
usage_json, every_json, start_json, intakes_json, intake_reminders_enabled
) VALUES (?, ?, 'Test Medication', ?, 'tablet', 'blister', ?, 1, 10, ?, 0, '[1]', '[1]', ?, '[]', 0)`,
) VALUES (?, ?, 'Test Medication', ?, 'tablet', 'blister', ?, 1, 10, ?, 0, '[1]', '[1]', ?, ?, 0)`,
args: [
options.id,
options.userId,
JSON.stringify(options.takenBy ?? []),
JSON.stringify(takenBy),
options.packCount ?? 1,
options.looseTablets ?? 0,
intakeStart,
"[]",
JSON.stringify([
{
usage: 1,
every: 1,
start: intakeStart,
takenBy: intakeTakenBy,
intakeRemindersEnabled: false,
},
]),
],
});
}
@@ -103,13 +114,24 @@ async function insertUserSettings(userId: number, stockCalculationMode: "automat
});
}
async function _insertShareToken(userId: number, token: string, takenBy: string) {
async function _insertShareToken(userId: number, token: string, takenBy: string, allowJournalNotes = false) {
await testClient.execute({
sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, 30)",
args: [userId, token, takenBy],
sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, allow_journal_notes) VALUES (?, ?, ?, 30, ?)",
args: [userId, token, takenBy, allowJournalNotes ? 1 : 0],
});
}
function buildLocalDoseStart(hours = 8): string {
const start = new Date();
start.setHours(hours, 0, 0, 0);
const year = start.getFullYear();
const month = String(start.getMonth() + 1).padStart(2, "0");
const day = String(start.getDate()).padStart(2, "0");
const hour = String(start.getHours()).padStart(2, "0");
return `${year}-${month}-${day}T${hour}:00:00.000`;
}
async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
const token = await app.jwt.sign({ sub: userId, username });
return `access_token=${token}`;
@@ -458,6 +480,48 @@ describe("Dose Tracking API", () => {
});
});
describe("single-dose skip routes", () => {
it("marks a single owner dose as skipped through the frontend route", async () => {
const doseId = "1-0-1735344000000";
const response = await app.inject({
method: "POST",
url: "/doses/skip",
headers: { cookie: cookieHeader },
payload: { doseId },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
const result = await testClient.execute({
sql: "SELECT dose_id, marked_by, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, doseId],
});
expect(result.rows).toEqual([expect.objectContaining({ dose_id: doseId, marked_by: null, dismissed: 1 })]);
});
it("undoes a skipped-only owner dose through the frontend route", async () => {
const doseId = "1-0-1735344000000";
await insertDose({ userId, doseId, dismissed: true, takenAt: null });
const response = await app.inject({
method: "DELETE",
url: `/doses/skip/${encodeURIComponent(doseId)}`,
headers: { cookie: cookieHeader },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
const result = await testClient.execute({
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, doseId],
});
expect(Number(result.rows[0].count)).toBe(0);
});
});
describe("DELETE /doses/dismiss", () => {
it("clears dismissed-only records and removes the dismissed flag from taken doses", async () => {
await insertDose({ userId, doseId: "1-0-1735344000000", dismissed: true, takenAt: null });
@@ -481,4 +545,174 @@ describe("Dose Tracking API", () => {
]);
});
});
describe("shared single-dose skip routes", () => {
it("marks and undoes a visible shared dose as skipped", async () => {
const start = buildLocalDoseStart();
await insertMedication({
id: 6,
userId,
takenBy: ["Max"],
start,
});
await _insertShareToken(userId, "share-skip-token", "Max", false);
const doseId = `6-0-${new Date(start).getTime()}-Max`;
const skipResponse = await app.inject({
method: "POST",
url: "/share/share-skip-token/doses/skip",
payload: { doseId },
});
expect(skipResponse.statusCode).toBe(200);
expect(skipResponse.json()).toEqual({ success: true });
const skippedRows = await testClient.execute({
sql: "SELECT dose_id, marked_by, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, doseId],
});
expect(skippedRows.rows).toEqual([expect.objectContaining({ dose_id: doseId, marked_by: null, dismissed: 1 })]);
const undoResponse = await app.inject({
method: "DELETE",
url: `/share/share-skip-token/doses/skip/${encodeURIComponent(doseId)}`,
});
expect(undoResponse.statusCode).toBe(200);
expect(undoResponse.json()).toEqual({ success: true });
const remainingRows = await testClient.execute({
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, doseId],
});
expect(Number(remainingRows.rows[0].count)).toBe(0);
});
});
describe("Shared journal notes", () => {
it("rejects shared journal access when the share link does not allow notes", async () => {
const start = buildLocalDoseStart();
await insertMedication({
id: 7,
userId,
takenBy: ["Max"],
start,
});
await _insertShareToken(userId, "token-no-notes", "Max", false);
const doseId = `7-0-${new Date(start).getTime()}-Max`;
await insertDose({ userId, doseId, markedBy: "Max" });
const response = await app.inject({
method: "GET",
url: `/share/token-no-notes/journal/event/${encodeURIComponent(doseId)}`,
});
expect(response.statusCode).toBe(403);
expect(response.json()).toEqual({
error: "Journal notes are not enabled for this share link",
code: "NOT_ENABLED",
});
});
it("supports shared journal note read and save, but not implicit or explicit delete", async () => {
const start = buildLocalDoseStart();
await insertMedication({
id: 8,
userId,
takenBy: ["Max"],
start,
});
await _insertShareToken(userId, "token-with-notes", "Max", true);
const doseId = `8-0-${new Date(start).getTime()}-Max`;
await insertDose({ userId, doseId, markedBy: "Max" });
const initialResponse = await app.inject({
method: "GET",
url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`,
});
expect(initialResponse.statusCode).toBe(200);
expect(initialResponse.json().entry).toEqual(
expect.objectContaining({
doseId,
markedBy: "Max",
note: null,
})
);
const initialDosesResponse = await app.inject({
method: "GET",
url: "/share/token-with-notes/doses",
});
expect(initialDosesResponse.statusCode).toBe(200);
expect(initialDosesResponse.json().doses).toEqual([
expect.objectContaining({
doseId,
hasJournalNote: false,
}),
]);
const saveResponse = await app.inject({
method: "PUT",
url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`,
payload: { note: "Shared note from Max" },
});
expect(saveResponse.statusCode).toBe(200);
expect(saveResponse.json().entry).toEqual(
expect.objectContaining({
doseId,
note: "Shared note from Max",
})
);
const savedDosesResponse = await app.inject({
method: "GET",
url: "/share/token-with-notes/doses",
});
expect(savedDosesResponse.statusCode).toBe(200);
expect(savedDosesResponse.json().doses).toEqual([
expect.objectContaining({
doseId,
hasJournalNote: true,
}),
]);
const blankSaveResponse = await app.inject({
method: "PUT",
url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`,
payload: { note: " " },
});
expect(blankSaveResponse.statusCode).toBe(400);
expect(blankSaveResponse.json()).toEqual({
error: "Journal note cannot be empty",
code: "EMPTY_NOTE",
});
const deleteResponse = await app.inject({
method: "DELETE",
url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`,
});
expect(deleteResponse.statusCode).toBe(403);
expect(deleteResponse.json()).toEqual({
error: "Shared links cannot delete journal notes",
code: "DELETE_NOT_ALLOWED",
});
const journalRows = await testClient.execute({
sql: "SELECT note FROM intake_journal WHERE user_id = ? AND medication_id = ?",
args: [userId, 8],
});
expect(journalRows.rows).toHaveLength(1);
expect(journalRows.rows[0].note).toBe("Shared note from Max");
});
});
});
+693 -30
View File
@@ -3,6 +3,7 @@
* These tests import the actual route handlers for real coverage.
*/
import { existsSync, unlinkSync } from "node:fs";
import cookie from "@fastify/cookie";
import fastifyMultipart from "@fastify/multipart";
import sensible from "@fastify/sensible";
@@ -13,13 +14,16 @@ import { jwtPlugin } from "../plugins/jwt.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
// Use vi.hoisted to create the db BEFORE mocks are set up
const { testClient, testDb } = vi.hoisted(() => {
const { testClient, testDb, testDbPath } = vi.hoisted(() => {
// Dynamic import inside hoisted block
const { createClient } = require("@libsql/client");
const { drizzle } = require("drizzle-orm/libsql");
const client = createClient({ url: ":memory:" });
const { tmpdir } = require("node:os");
const { join } = require("node:path");
const dbPath = join(tmpdir(), `medassist-e2e-routes-${process.pid}-${Date.now()}.db`);
const client = createClient({ url: `file:${dbPath}` });
const db = drizzle(client);
return { testClient: client, testDb: db };
return { testClient: client, testDb: db, testDbPath: dbPath };
});
// Mock modules using the hoisted db
@@ -123,6 +127,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,
@@ -170,6 +175,7 @@ async function createSchema(client: Client) {
token text NOT NULL UNIQUE,
taken_by text NOT NULL,
schedule_days integer NOT NULL DEFAULT 30,
allow_journal_notes integer NOT NULL DEFAULT 0,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
expires_at integer,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
@@ -183,6 +189,19 @@ async function createSchema(client: Client) {
taken_source text NOT NULL DEFAULT 'manual',
dismissed integer NOT NULL DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS intake_journal (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
dose_tracking_id integer NOT NULL UNIQUE,
medication_id integer NOT NULL,
scheduled_for integer NOT NULL,
note text NOT NULL,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (dose_tracking_id) REFERENCES dose_tracking(id) ON DELETE CASCADE,
FOREIGN KEY (medication_id) REFERENCES medications(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS refill_history (
id integer PRIMARY KEY AUTOINCREMENT,
@@ -203,6 +222,7 @@ async function createSchema(client: Client) {
}
async function clearData(client: Client) {
await client.execute("DELETE FROM intake_journal");
await client.execute("DELETE FROM refill_history");
await client.execute("DELETE FROM dose_tracking");
await client.execute("DELETE FROM share_tokens");
@@ -221,10 +241,11 @@ async function _createUser(client: Client, username: string): Promise<number> {
}
async function createMedication(client: Client, userId: number, name: string, takenBy: string[]): Promise<number> {
const start = new Date(visibleDoseTimestampMs()).toISOString();
const result = await client.execute({
sql: `INSERT INTO medications (user_id, name, taken_by_json, usage_json, every_json, start_json)
VALUES (?, ?, ?, '[1]', '[1]', '["2025-01-01T08:00:00.000Z"]') RETURNING id`,
args: [userId, name, JSON.stringify(takenBy)],
VALUES (?, ?, ?, '[1]', '[1]', ?) RETURNING id`,
args: [userId, name, JSON.stringify(takenBy), JSON.stringify([start])],
});
return result.rows[0].id as number;
}
@@ -236,6 +257,12 @@ async function createShareToken(client: Client, userId: number, takenBy: string,
});
}
function visibleDoseTimestampMs(): number {
const doseDate = new Date();
doseDate.setHours(8, 0, 0, 0);
return doseDate.getTime();
}
// =============================================================================
// E2E Tests with Real Routes
// =============================================================================
@@ -307,10 +334,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 +364,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 +403,7 @@ describe("E2E Tests with Real Routes", () => {
expect(data[medId].refills[0]).toMatchObject({
packsAdded: 1,
loosePillsAdded: 0,
quantityAdded: 1,
usedPrescription: false,
});
});
@@ -383,6 +412,11 @@ describe("E2E Tests with Real Routes", () => {
afterAll(async () => {
await app.close();
testClient.close();
for (const path of [testDbPath, `${testDbPath}-shm`, `${testDbPath}-wal`]) {
if (existsSync(path)) {
unlinkSync(path);
}
}
});
beforeEach(async () => {
@@ -505,12 +539,12 @@ describe("E2E Tests with Real Routes", () => {
});
it("should mark dose via share link using real route", async () => {
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
const token = "test_share_token_456";
await createShareToken(testClient, userId, "Daniel", token);
const doseId = "1-0-1735344000000";
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
const response = await app.inject({
method: "POST",
url: `/share/${token}/doses`,
@@ -1036,13 +1070,13 @@ describe("E2E Tests with Real Routes", () => {
});
it("should unmark dose via share link", async () => {
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
const token = "test_delete_dose_token";
await createShareToken(testClient, userId, "Daniel", token);
// First mark the dose
const doseId = "1-0-1735344000000";
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
await testClient.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`,
args: [userId, doseId, "Daniel"],
@@ -1086,12 +1120,12 @@ describe("E2E Tests with Real Routes", () => {
});
it("should return already marked message for duplicate dose", async () => {
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
const token = "test_duplicate_token";
await createShareToken(testClient, userId, "Daniel", token);
const doseId = "1-0-1735344000000";
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
// Mark the dose first time
await app.inject({
@@ -1527,6 +1561,59 @@ describe("E2E Tests with Real Routes", () => {
// ---------------------------------------------------------------------------
describe("Share token management", () => {
it("should list active share links for the owner", async () => {
await createMedication(testClient, userId, "Med1", ["Daniel"]);
const createResponse = await app.inject({
method: "POST",
url: "/share",
payload: {
takenBy: "Daniel",
scheduleDays: 90,
},
});
expect(createResponse.statusCode).toBe(200);
const listResponse = await app.inject({
method: "GET",
url: "/share",
});
expect(listResponse.statusCode).toBe(200);
const data = listResponse.json();
expect(data.shareLinks).toHaveLength(1);
expect(data.shareLinks[0].takenBy).toBe("Daniel");
});
it("should revoke an active share link", async () => {
await createMedication(testClient, userId, "Med1", ["Daniel"]);
const createResponse = await app.inject({
method: "POST",
url: "/share",
payload: {
takenBy: "Daniel",
scheduleDays: 30,
},
});
const { token } = createResponse.json();
const revokeResponse = await app.inject({
method: "DELETE",
url: `/share/${token}`,
});
expect(revokeResponse.statusCode).toBe(204);
const publicResponse = await app.inject({
method: "GET",
url: `/share/${token}`,
});
expect(publicResponse.statusCode).toBe(404);
});
it("should create share token with custom scheduleDays", async () => {
await createMedication(testClient, userId, "Med1", ["Daniel"]);
@@ -1545,6 +1632,34 @@ describe("E2E Tests with Real Routes", () => {
expect(data.expiresAt).toBeDefined();
});
it("should create a share token with an expiry and keep it in the active owner list", async () => {
await createMedication(testClient, userId, "Med1", ["Daniel"]);
const createResponse = await app.inject({
method: "POST",
url: "/share",
payload: {
takenBy: "Daniel",
scheduleDays: 30,
expiryDays: 7,
},
});
expect(createResponse.statusCode).toBe(200);
const created = createResponse.json();
expect(created.expiresAt).toBeTruthy();
const listResponse = await app.inject({
method: "GET",
url: "/share",
});
expect(listResponse.statusCode).toBe(200);
const listData = listResponse.json();
expect(listData.shareLinks).toHaveLength(1);
expect(listData.shareLinks[0].expiresAt).toBeTruthy();
});
it("should return validation error for invalid scheduleDays", async () => {
await createMedication(testClient, userId, "Med1", ["Daniel"]);
@@ -1682,14 +1797,15 @@ describe("E2E Tests with Real Routes", () => {
describe("Share token dose routes", () => {
it("should get taken doses via share link", async () => {
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
const token = "get-doses-token";
await createShareToken(testClient, userId, "Daniel", token);
// Insert a dose directly
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
await testClient.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`,
args: [userId, "1-0-1735344000000", "Daniel"],
args: [userId, doseId, "Daniel"],
});
const response = await app.inject({
@@ -1700,7 +1816,7 @@ describe("E2E Tests with Real Routes", () => {
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.doses).toHaveLength(1);
expect(data.doses[0].doseId).toBe("1-0-1735344000000");
expect(data.doses[0].doseId).toBe(doseId);
expect(data.doses[0].markedBy).toBe("Daniel");
});
@@ -1959,7 +2075,7 @@ describe("E2E Tests with Real Routes", () => {
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 0 },
payload: { packsAdded: 1, loosePillsAdded: 5, quantityAdded: 5 },
});
expect(refillResponse.statusCode).toBe(200);
@@ -2333,10 +2449,9 @@ describe("E2E Tests with Real Routes", () => {
expect(med.stockAdjustment).toBe(0);
});
it("should persist bottle zero reset with packCount 0 and zero totals", async () => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
it.each([
{
label: "bottle",
payload: {
name: "Bottle Zero Reset Med",
packageType: "bottle",
@@ -2347,6 +2462,40 @@ describe("E2E Tests with Real Routes", () => {
looseTablets: 20,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
},
{
label: "inhaler",
payload: {
name: "Inhaler Zero Reset Med",
packageType: "inhaler",
doseUnit: "puffs",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 200,
looseTablets: 40,
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
},
{
label: "injection",
payload: {
name: "Injection Zero Reset Med",
packageType: "injection",
doseUnit: "injections",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 12,
looseTablets: 4,
blisters: [{ usage: 1, every: 7, start: "2025-01-01T08:00:00.000Z" }],
},
},
])("should persist $label zero reset with packCount 0 and zero totals", async ({ payload }) => {
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload,
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
@@ -2442,6 +2591,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: 150, quantityAdded: 150 },
});
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",
@@ -2889,6 +3113,78 @@ describe("E2E Tests with Real Routes", () => {
});
describe("Real /import routes", () => {
it("should preview import data without mutating existing user data", async () => {
await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Existing Med",
packCount: 2,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
const previewPayload = {
version: "1.6",
exportedAt: new Date().toISOString(),
includeSensitiveData: true,
medications: [
{
_exportId: "med-1",
name: "Imported Med",
inventory: { packCount: 1, blistersPerPack: 2, pillsPerBlister: 14, looseTablets: 0 },
schedules: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
],
settings: { language: "en", stockCalculationMode: "automatic" },
shareLinks: [{ takenBy: "Person A", scheduleDays: 14 }],
doseHistory: [
{
medicationRef: "med-1",
scheduleIndex: 0,
scheduledTime: "2025-01-01T08:00:00.000Z",
takenAt: "2025-01-01T08:03:00.000Z",
journalNote: "after breakfast",
},
],
};
const previewResponse = await app.inject({
method: "POST",
url: "/import/preview",
payload: previewPayload,
});
expect(previewResponse.statusCode).toBe(200);
expect(previewResponse.json()).toMatchObject({
success: true,
preview: {
version: "1.6",
includeSensitiveData: true,
incoming: {
medications: 1,
doseHistory: 1,
shareLinks: 1,
journalEntries: 1,
hasSettings: true,
},
current: {
medications: 1,
hasSettings: false,
},
warnings: {
replacesExistingData: true,
regeneratesShareLinks: true,
containsSensitiveData: true,
},
},
});
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
expect(medsResponse.json()).toHaveLength(1);
expect(medsResponse.json()[0].name).toBe("Existing Med");
});
it("should import medications from export format", async () => {
const importData = {
version: "1.0",
@@ -3047,6 +3343,80 @@ describe("E2E Tests with Real Routes", () => {
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
};
const discreteContainerMedications = [
{
label: "inhaler",
payload: {
name: "Asthma Inhaler",
packageType: "inhaler",
doseUnit: "puffs",
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 200,
looseTablets: 200,
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
expectedDoseUnit: "puffs",
},
{
label: "injection",
payload: {
name: "B12 Injection",
packageType: "injection",
doseUnit: "injections",
packCount: 0,
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 12,
looseTablets: 12,
blisters: [{ usage: 1, every: 7, start: "2025-01-01T08:00:00.000Z" }],
},
expectedDoseUnit: "injections",
},
] as const;
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",
@@ -3106,6 +3476,23 @@ describe("E2E Tests with Real Routes", () => {
expect(data.looseTablets).toBe(180);
});
it.each(discreteContainerMedications)("should create and return $label type medication", async ({
payload,
expectedDoseUnit,
}) => {
const response = await app.inject({
method: "POST",
url: "/medications",
payload,
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.packageType).toBe(payload.packageType);
expect(data.doseUnit).toBe(expectedDoseUnit);
expect(data.looseTablets).toBe(payload.looseTablets);
});
it("should return packageType and ml-based stock semantics in shared schedule for liquid_container", async () => {
await app.inject({
method: "POST",
@@ -3240,6 +3627,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: 100, quantityAdded: 100 },
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: 80, quantityAdded: 80 },
});
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 +3848,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",
@@ -3288,14 +3870,20 @@ describe("E2E Tests with Real Routes", () => {
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 0 },
payload: { packsAdded: 1, loosePillsAdded: 180, quantityAdded: 180 },
});
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 +3894,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: 750, quantityAdded: 750 },
});
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",
@@ -3321,11 +3957,13 @@ describe("E2E Tests with Real Routes", () => {
prescriptionRemainingRefills: 2,
prescriptionLowRefillThreshold: 1,
},
refillPayload: { packsAdded: 0, loosePillsAdded: 180, usePrescription: true },
refillPayload: { packsAdded: 1, loosePillsAdded: 180, quantityAdded: 180, usePrescription: true },
expectedVisibleStockBeforeRefill: 180,
expectedPacksAdded: 1,
expectedLooseAdded: 180,
expectedRemainingRefills: 1,
expectedTotalPills: 360,
expectedAmountPerPackage: 180,
},
{
name: "tube",
@@ -3336,20 +3974,29 @@ describe("E2E Tests with Real Routes", () => {
prescriptionRemainingRefills: 3,
prescriptionLowRefillThreshold: 1,
},
refillPayload: { packsAdded: 0, loosePillsAdded: 80, usePrescription: true },
refillPayload: { packsAdded: 2, loosePillsAdded: 80, quantityAdded: 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 +4013,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 +4037,7 @@ describe("E2E Tests with Real Routes", () => {
expect(historyResponse.json()[0]).toMatchObject({
packsAdded: expectedPacksAdded,
loosePillsAdded: expectedLooseAdded,
quantityAdded: expectedLooseAdded,
usedPrescription: true,
});
});
@@ -3397,14 +4054,20 @@ describe("E2E Tests with Real Routes", () => {
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 0 },
payload: { packsAdded: 1, loosePillsAdded: 40, quantityAdded: 40 },
});
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,453 @@
import { existsSync, unlinkSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import cookie from "@fastify/cookie";
import sensible from "@fastify/sensible";
import { migrate } from "drizzle-orm/libsql/migrator";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { runAlterMigrations } from "../db/db-utils.js";
import { jwtPlugin } from "../plugins/jwt.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
const { testClient, testDb, testDbPath, mockedEnv } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
const { drizzle } = require("drizzle-orm/libsql");
const { tmpdir } = require("node:os");
const { join } = require("node:path");
const dbPath = join(tmpdir(), `medassist-intake-journal-routes-${process.pid}-${Date.now()}.db`);
const client = createClient({ url: `file:${dbPath}` });
const db = drizzle(client);
return {
testClient: client,
testDb: db,
testDbPath: dbPath,
mockedEnv: {
AUTH_ENABLED: true,
REGISTRATION_ENABLED: true,
FORM_LOGIN_ENABLED: true,
OIDC_ENABLED: false,
OIDC_PROVIDER_NAME: "SSO",
NODE_ENV: "test",
LOG_LEVEL: "silent",
PORT: 3000,
CORS_ORIGINS: "*",
JWT_SECRET: "test-jwt-secret",
REFRESH_SECRET: "test-refresh-secret",
COOKIE_SECRET: "test-cookie-secret",
ACCESS_TOKEN_TTL_MINUTES: 15,
REFRESH_TOKEN_TTL_DAYS: 7,
OPENAPI_DOCS_ENABLED: false,
PUBLIC_APP_URL: "https://app.example.com",
},
};
});
vi.mock("../db/client.js", () => ({
db: testDb,
migrationsReady: Promise.resolve(),
}));
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
const { exportRoutes } = await import("../routes/export.js");
const { intakeJournalRoutes } = await import("../routes/intake-journal.js");
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
async function clearTables() {
await testClient.execute("DELETE FROM intake_journal");
await testClient.execute("DELETE FROM refill_history");
await testClient.execute("DELETE FROM dose_tracking");
await testClient.execute("DELETE FROM share_tokens");
await testClient.execute("DELETE FROM user_settings");
await testClient.execute("DELETE FROM medications");
await testClient.execute("DELETE FROM api_keys");
await testClient.execute("DELETE FROM refresh_tokens");
await testClient.execute("DELETE FROM users");
}
async function createUser(username: string) {
const result = await testClient.execute({
sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id",
args: [username],
});
return Number(result.rows[0].id);
}
async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
const token = await app.jwt.sign({ sub: userId, username });
return `access_token=${token}`;
}
async function seedMedication(options: { userId: number; name: string; start?: string; takenBy?: string[] }) {
const start = options.start ?? "2026-02-01T08:00:00.000Z";
const takenBy = options.takenBy ?? ["Daniel"];
const result = await testClient.execute({
sql: `INSERT INTO medications (
user_id, name, generic_name, taken_by_json, medication_form, package_type,
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
usage_json, every_json, start_json, intakes_json,
stock_adjustment, intake_reminders_enabled
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
args: [
options.userId,
options.name,
`${options.name} Generic`,
JSON.stringify(takenBy),
"tablet",
"blister",
1,
1,
10,
0,
JSON.stringify([1]),
JSON.stringify([1]),
JSON.stringify([start]),
JSON.stringify([
{
usage: 1,
every: 1,
start,
takenBy: takenBy[0] ?? null,
intakeRemindersEnabled: true,
},
]),
0,
1,
],
});
return Number(result.rows[0].id);
}
async function seedTrackedDose(options: {
userId: number;
doseId: string;
takenAt: Date;
markedBy?: string | null;
dismissed?: boolean;
}) {
const result = await testClient.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, marked_by, taken_source, dismissed)
VALUES (?, ?, ?, ?, ?, ?) RETURNING id`,
args: [
options.userId,
options.doseId,
Math.floor(options.takenAt.getTime() / 1000),
options.markedBy ?? null,
"manual",
options.dismissed ? 1 : 0,
],
});
return Number(result.rows[0].id);
}
describe("Intake journal routes", () => {
let app: FastifyInstance;
beforeAll(async () => {
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(sensible);
await app.register(cookie, { secret: "test-cookie-secret" });
await app.register(jwtPlugin, {
secret: "test-jwt-secret",
cookie: { cookieName: "access_token", signed: false },
});
await app.register(intakeJournalRoutes);
await app.register(exportRoutes);
await app.ready();
});
afterAll(async () => {
await app.close();
testClient.close();
for (const path of [testDbPath, `${testDbPath}-shm`, `${testDbPath}-wal`]) {
if (existsSync(path)) {
unlinkSync(path);
}
}
});
beforeEach(async () => {
vi.clearAllMocks();
await clearTables();
});
it("keeps journal CRUD/history owner-scoped across route access", async () => {
const ownerId = await createUser("journal-owner");
const otherId = await createUser("journal-other");
const ownerCookie = await buildSessionCookie(app, ownerId, "journal-owner");
const otherCookie = await buildSessionCookie(app, otherId, "journal-other");
const ownerStart = "2026-02-01T08:00:00.000Z";
const otherStart = "2026-02-02T09:00:00.000Z";
const ownerMedicationId = await seedMedication({ userId: ownerId, name: "Owner Med", start: ownerStart });
const otherMedicationId = await seedMedication({ userId: otherId, name: "Other Med", start: otherStart });
const ownerDoseId = `${ownerMedicationId}-0-${new Date(ownerStart).getTime()}-Daniel`;
const otherDoseId = `${otherMedicationId}-0-${new Date(otherStart).getTime()}-Maria`;
await seedTrackedDose({
userId: ownerId,
doseId: ownerDoseId,
takenAt: new Date("2026-02-01T08:05:00.000Z"),
markedBy: "Daniel",
});
await seedTrackedDose({
userId: otherId,
doseId: otherDoseId,
takenAt: new Date("2026-02-02T09:05:00.000Z"),
markedBy: "Maria",
});
const ownerPutResponse = await app.inject({
method: "PUT",
url: `/intake-journal/event/${encodeURIComponent(ownerDoseId)}`,
headers: { cookie: ownerCookie },
payload: { note: "Took after breakfast." },
});
expect(ownerPutResponse.statusCode).toBe(200);
expect(ownerPutResponse.json().entry).toEqual(
expect.objectContaining({
doseId: ownerDoseId,
medicationId: ownerMedicationId,
scheduledFor: expect.stringContaining("T08:00:00"),
note: "Took after breakfast.",
})
);
const otherPutResponse = await app.inject({
method: "PUT",
url: `/intake-journal/event/${encodeURIComponent(otherDoseId)}`,
headers: { cookie: otherCookie },
payload: { note: "Different owner note." },
});
expect(otherPutResponse.statusCode).toBe(200);
const ownerHistoryResponse = await app.inject({
method: "GET",
url: `/intake-journal?medicationId=${ownerMedicationId}&limit=25`,
headers: { cookie: ownerCookie },
});
expect(ownerHistoryResponse.statusCode).toBe(200);
expect(ownerHistoryResponse.json().entries).toEqual([
expect.objectContaining({
doseId: ownerDoseId,
medicationId: ownerMedicationId,
note: "Took after breakfast.",
markedBy: "Daniel",
}),
]);
const otherEventResponse = await app.inject({
method: "GET",
url: `/intake-journal/event/${encodeURIComponent(otherDoseId)}`,
headers: { cookie: ownerCookie },
});
expect(otherEventResponse.statusCode).toBe(404);
expect(otherEventResponse.json()).toMatchObject({ code: "DOSE_NOT_FOUND" });
const deleteResponse = await app.inject({
method: "DELETE",
url: `/intake-journal/event/${encodeURIComponent(ownerDoseId)}`,
headers: { cookie: ownerCookie },
});
expect(deleteResponse.statusCode).toBe(200);
expect(deleteResponse.json()).toEqual({ success: true });
const emptyHistoryResponse = await app.inject({
method: "GET",
url: "/intake-journal",
headers: { cookie: ownerCookie },
});
expect(emptyHistoryResponse.statusCode).toBe(200);
expect(emptyHistoryResponse.json().entries).toEqual([]);
});
it("preserves journal metadata through authenticated export and import", async () => {
const userId = await createUser("journal-roundtrip");
const sessionCookie = await buildSessionCookie(app, userId, "journal-roundtrip");
const start = "2026-02-03T07:30:00.000Z";
const medicationId = await seedMedication({ userId, name: "Roundtrip Journal Med", start });
const doseId = `${medicationId}-0-${new Date(start).getTime()}-Daniel`;
const doseTrackingId = await seedTrackedDose({
userId,
doseId,
takenAt: new Date("2026-02-03T07:33:00.000Z"),
markedBy: "Daniel",
});
const createdAt = new Date("2026-02-03T07:40:00.000Z");
const updatedAt = new Date("2026-02-03T07:50:00.000Z");
await testClient.execute({
sql: `INSERT INTO intake_journal (
user_id, dose_tracking_id, medication_id, scheduled_for, note, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
args: [
userId,
doseTrackingId,
medicationId,
Math.floor(new Date(start).getTime() / 1000),
"Roundtrip journal note",
Math.floor(createdAt.getTime() / 1000),
Math.floor(updatedAt.getTime() / 1000),
],
});
const exportResponse = await app.inject({
method: "GET",
url: "/export",
headers: { cookie: sessionCookie },
});
expect(exportResponse.statusCode).toBe(200);
const exportBody = exportResponse.json();
expect(exportBody.doseHistory).toHaveLength(1);
expect(exportBody.doseHistory[0]).toEqual(
expect.objectContaining({
journalNote: "Roundtrip journal note",
journalCreatedAt: createdAt.toISOString(),
journalUpdatedAt: updatedAt.toISOString(),
})
);
const importResponse = await app.inject({
method: "POST",
url: "/import",
headers: { cookie: sessionCookie },
payload: exportBody,
});
expect(importResponse.statusCode).toBe(200);
const reExportResponse = await app.inject({
method: "GET",
url: "/export",
headers: { cookie: sessionCookie },
});
expect(reExportResponse.statusCode).toBe(200);
expect(reExportResponse.json().doseHistory).toEqual([
expect.objectContaining({
journalNote: "Roundtrip journal note",
journalCreatedAt: createdAt.toISOString(),
journalUpdatedAt: updatedAt.toISOString(),
}),
]);
const restoredJournalRows = await testClient.execute({
sql: "SELECT note FROM intake_journal WHERE user_id = ?",
args: [userId],
});
expect(restoredJournalRows.rows).toHaveLength(1);
expect(restoredJournalRows.rows[0].note).toBe("Roundtrip journal note");
});
it("preserves the shared journal-note permission through authenticated export and import", async () => {
const userId = await createUser("share-journal-roundtrip");
const sessionCookie = await buildSessionCookie(app, userId, "share-journal-roundtrip");
await testClient.execute({
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, allow_journal_notes, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`,
args: [userId, "share-journal-token", "Daniel", 14, 1, null],
});
const exportResponse = await app.inject({
method: "GET",
url: "/export",
headers: { cookie: sessionCookie },
});
expect(exportResponse.statusCode).toBe(200);
const exportBody = exportResponse.json();
expect(exportBody.shareLinks).toEqual([
expect.objectContaining({
takenBy: "Daniel",
scheduleDays: 14,
allowJournalNotes: true,
regenerateToken: true,
}),
]);
const importResponse = await app.inject({
method: "POST",
url: "/import",
headers: { cookie: sessionCookie },
payload: exportBody,
});
expect(importResponse.statusCode).toBe(200);
const shareRows = await testClient.execute({
sql: "SELECT token, taken_by, schedule_days, allow_journal_notes FROM share_tokens WHERE user_id = ?",
args: [userId],
});
expect(shareRows.rows).toHaveLength(1);
expect(shareRows.rows[0]).toEqual(
expect.objectContaining({
taken_by: "Daniel",
schedule_days: 14,
allow_journal_notes: 1,
})
);
expect(shareRows.rows[0].token).not.toBe("share-journal-token");
});
it("keeps existing data when import fails inside the replacement transaction", async () => {
const userId = await createUser("import-rollback");
const sessionCookie = await buildSessionCookie(app, userId, "import-rollback");
await seedMedication({ userId, name: "Existing Rollback Med" });
const importResponse = await app.inject({
method: "POST",
url: "/import",
headers: { cookie: sessionCookie },
payload: {
version: "1.6",
exportedAt: new Date().toISOString(),
medications: [
{
_exportId: "med-1",
name: "Imported Rollback Med",
inventory: { packCount: 1, blistersPerPack: 1, pillsPerBlister: 10, looseTablets: 0 },
schedules: [{ usage: 1, every: 1, start: "2026-02-04T08:00:00.000Z" }],
},
],
doseHistory: [
{
medicationRef: "med-1",
scheduleIndex: 0,
scheduledTime: "2026-02-04T08:00:00.000Z",
takenAt: "not-a-date",
},
],
},
});
expect(importResponse.statusCode).toBe(500);
const medicationRows = await testClient.execute({
sql: "SELECT name FROM medications WHERE user_id = ? ORDER BY name",
args: [userId],
});
expect(medicationRows.rows).toEqual([expect.objectContaining({ name: "Existing Rollback Med" })]);
});
});
@@ -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")
);
});
});
+31 -10
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,
@@ -164,6 +165,7 @@ async function createSchema(client: Client) {
token text NOT NULL UNIQUE,
taken_by text NOT NULL,
schedule_days integer NOT NULL DEFAULT 30,
allow_journal_notes integer NOT NULL DEFAULT 0,
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
expires_at integer,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
@@ -194,6 +196,16 @@ async function clearData(client: Client) {
await client.execute("DELETE FROM sqlite_sequence");
}
function visibleDoseTimestampMs(): number {
const doseDate = new Date();
doseDate.setHours(8, 0, 0, 0);
return doseDate.getTime();
}
function visibleDoseStartIso(): string {
return new Date(visibleDoseTimestampMs()).toISOString();
}
// =============================================================================
// Tests
// =============================================================================
@@ -258,9 +270,11 @@ describe("Integration Tests", () => {
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
looseTablets: 10,
blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }],
},
});
expect(createRes.statusCode, createRes.body).toBe(200);
const medId = createRes.json().id;
// Mark some doses (Jan 1, Jan 2, Jan 5, Jan 10)
@@ -616,9 +630,10 @@ describe("Integration Tests", () => {
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }],
},
});
expect(createRes.statusCode, createRes.body).toBe(200);
const medId = createRes.json().id;
// Create share token for Daniel
@@ -627,15 +642,17 @@ describe("Integration Tests", () => {
url: "/share",
payload: { takenBy: "Daniel", scheduleDays: 30 },
});
expect(shareRes.statusCode, shareRes.body).toBe(200);
const token = shareRes.json().token;
// Mark dose via share link
const doseId = `${medId}-0-${new Date("2025-01-01T08:00:00.000Z").getTime()}`;
await app.inject({
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
const markRes = await app.inject({
method: "POST",
url: `/share/${token}/doses`,
payload: { doseId },
});
expect(markRes.statusCode, markRes.body).toBe(200);
// Verify markedBy is "Daniel"
const result = await testClient.execute({
@@ -666,9 +683,10 @@ describe("Integration Tests", () => {
payload: {
name: "Vitamin D",
takenBy: ["Anna"],
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }],
},
});
expect(createRes.statusCode, createRes.body).toBe(200);
const medId = createRes.json().id;
// Create share token
@@ -677,21 +695,24 @@ describe("Integration Tests", () => {
url: "/share",
payload: { takenBy: "Anna", scheduleDays: 30 },
});
expect(shareRes.statusCode, shareRes.body).toBe(200);
const token = shareRes.json().token;
// Mark a dose
const doseId = `${medId}-0-${new Date("2025-01-05T08:00:00.000Z").getTime()}`;
await app.inject({
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
const markRes = await app.inject({
method: "POST",
url: `/share/${token}/doses`,
payload: { doseId },
});
expect(markRes.statusCode, markRes.body).toBe(200);
// Get shared schedule
const scheduleRes = await app.inject({
method: "GET",
url: `/share/${token}`,
});
expect(scheduleRes.statusCode, scheduleRes.body).toBe(200);
const data = scheduleRes.json();
expect(data.takenBy).toBe("Anna");
@@ -780,7 +801,7 @@ describe("Integration Tests", () => {
payload: {
name: "Family Vitamins",
takenBy: ["Daniel", "Anna", "Max"],
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }],
},
});
@@ -798,8 +819,8 @@ describe("Integration Tests", () => {
});
// Both should succeed with different tokens
expect(danielShare.statusCode).toBe(200);
expect(annaShare.statusCode).toBe(200);
expect(danielShare.statusCode, danielShare.body).toBe(200);
expect(annaShare.statusCode, annaShare.body).toBe(200);
expect(danielShare.json().token).not.toBe(annaShare.json().token);
// Each share link should show correct person
@@ -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,627 @@
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" });
await testClient.execute({
sql: "UPDATE notification_action_groups SET ntfy_original_message_id = ? WHERE user_id = ?",
args: ["ntfy-msg-1", userId],
});
fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: "ntfy-msg-2" }) });
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(2);
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,
},
]);
const [deleteUrl, deleteInit] = fetchMock.mock.calls[1] ?? [];
expect(deleteUrl).toBe("https://ntfy.example.com/medassist/ntfy-msg-1");
expect(deleteInit).toEqual(
expect.objectContaining({
method: "DELETE",
headers: expect.objectContaining({ Authorization: expect.stringMatching(/^Basic /) }),
})
);
const groupRow = await testClient.execute({
sql: "SELECT ntfy_original_message_id FROM notification_action_groups WHERE user_id = ?",
args: [userId],
});
expect(groupRow.rows).toEqual([expect.objectContaining({ ntfy_original_message_id: "ntfy-msg-2" })]);
});
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" });
await testClient.execute({
sql: "UPDATE notification_action_groups SET ntfy_original_message_id = ? WHERE user_id = ?",
args: ["ntfy-msg-7", userId],
});
fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: "ntfy-msg-3" }) });
fetchMock.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve("") });
const response = await app.inject({
method: "POST",
url: `/notification-actions/${skipToken}`,
});
expect(response.statusCode).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
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,
}),
})
);
const [deleteUrl, deleteInit] = fetchMock.mock.calls[1] ?? [];
expect(deleteUrl).toBe("https://ntfy.example.com/medassist/ntfy-msg-7");
expect(deleteInit).toEqual(
expect.objectContaining({
method: "DELETE",
headers: expect.objectContaining({ Authorization: expect.stringMatching(/^Basic /) }),
})
);
const groupRow = await testClient.execute({
sql: "SELECT ntfy_original_message_id FROM notification_action_groups WHERE user_id = ?",
args: [userId],
});
expect(groupRow.rows).toEqual([expect.objectContaining({ ntfy_original_message_id: "ntfy-msg-3" })]);
});
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,321 @@
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) ?? "";
}
type ActionTokenRow = {
kind: string | null;
token_hash: string | null;
};
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("reactivates a resolved group with the same key instead of inserting a duplicate", async () => {
const userId = await createUser("notify-actions-reactivate");
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
const doseIds = ["9-1-1736064000000", "9-0-1736064000000"];
const first = await createNotificationActionContext({
userId,
title: "Reminder",
message: "Take your medication now",
doseIds,
scheduledFor,
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
language: "en",
});
expect(first).toMatchObject({
groupId: expect.any(Number),
respondUrl: expect.stringContaining("/api/notification-actions/"),
sequenceId: expect.stringMatching(/^medassist-/),
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=9-0-1736064000000",
});
const firstGroupId = first!.groupId!;
const firstSequenceId = first!.sequenceId!;
const firstRespondToken = extractToken(first!.respondUrl!);
const firstTokenRows = await testClient.execute({
sql: "SELECT kind, token_hash FROM notification_action_tokens WHERE group_id = ? ORDER BY kind ASC",
args: [firstGroupId],
});
expect(firstTokenRows.rows).toHaveLength(3);
await testClient.execute({
sql: "UPDATE notification_action_groups SET resolved_action = 'taken', resolved_at = ?, ntfy_original_message_id = 'old-message-id' WHERE id = ?",
args: [new Date(), firstGroupId],
});
const second = await createNotificationActionContext({
userId,
title: "Reminder",
message: "Take your medication now",
doseIds,
scheduledFor,
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
language: "en",
});
expect(second).toMatchObject({
groupId: firstGroupId,
respondUrl: expect.stringContaining("/api/notification-actions/"),
sequenceId: expect.stringMatching(/^medassist-/),
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=9-0-1736064000000",
});
expect(second?.sequenceId).toBe(firstSequenceId);
const groups = await testClient.execute(
"SELECT id, sequence_id, resolved_action, resolved_at, ntfy_original_message_id FROM notification_action_groups"
);
expect(groups.rows).toHaveLength(1);
expect(groups.rows[0]).toEqual(
expect.objectContaining({
id: firstGroupId,
sequence_id: second?.sequenceId,
resolved_action: null,
resolved_at: null,
ntfy_original_message_id: "",
})
);
const secondTokenRows = await testClient.execute({
sql: "SELECT kind, token_hash FROM notification_action_tokens WHERE group_id = ? ORDER BY kind ASC",
args: [firstGroupId],
});
expect(secondTokenRows.rows).toHaveLength(3);
expect(secondTokenRows.rows.map((row: ActionTokenRow) => row.kind)).toEqual(["respond", "skip", "taken"]);
const firstTokenHashes = new Set(firstTokenRows.rows.map((row: ActionTokenRow) => String(row.token_hash)));
const secondTokenHashes = new Set(secondTokenRows.rows.map((row: ActionTokenRow) => String(row.token_hash)));
expect(secondTokenHashes.size).toBe(3);
expect([...secondTokenHashes].every((tokenHash) => !firstTokenHashes.has(tokenHash))).toBe(true);
expect(await getNotificationActionTokenRecord(firstRespondToken)).toBeNull();
const secondRespondToken = extractToken(second!.respondUrl!);
const secondRespondRecord = await getNotificationActionTokenRecord(secondRespondToken);
expect(secondRespondRecord).toMatchObject({
doseIds: ["9-0-1736064000000", "9-1-1736064000000"],
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=9-0-1736064000000",
});
expect(secondRespondRecord?.group.id).toBe(firstGroupId);
});
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();
}
+72
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,
@@ -247,6 +248,32 @@ describe("Planner Routes", () => {
expect(response.json()).toEqual({ error: "Missing planner data" });
});
it("should reject request when no planner date range can be resolved", async () => {
const response = await app.inject({
method: "POST",
url: "/planner/send-email",
payload: {
email: "test@example.com",
rows: [
{
medicationId: 1,
medicationName: "Aspirin",
totalPills: 30,
plannerUsage: 10,
blisterSize: 10,
blistersNeeded: 1,
fullBlisters: 3,
loosePills: 0,
enough: true,
},
],
},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "Missing planner date range" });
});
it("should return error when no notification channels configured", async () => {
// User settings exist but email/shoutrrr disabled
await testClient.execute({
@@ -281,6 +308,51 @@ describe("Planner Routes", () => {
expect(response.json()).toEqual({ error: "No notification channels configured" });
});
it("should accept startDate and endDate aliases for planner range", async () => {
process.env.SMTP_HOST = "smtp.test.com";
process.env.SMTP_USER = "user@test.com";
process.env.SMTP_PASS = "password";
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`,
args: [999999999],
});
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
const response = await app.inject({
method: "POST",
url: "/planner/send-email",
payload: {
email: "test@example.com",
startDate: "2025-01-01T00:00:00.000Z",
endDate: "2025-01-31T00:00:00.000Z",
language: "en",
rows: [
{
medicationId: 1,
medicationName: "Aspirin",
totalPills: 30,
plannerUsage: 10,
blisterSize: 10,
blistersNeeded: 1,
fullBlisters: 3,
loosePills: 0,
enough: true,
},
],
},
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, message: "Notification sent via email" });
expect(mockSendMail).toHaveBeenCalledTimes(1);
delete process.env.SMTP_HOST;
delete process.env.SMTP_USER;
delete process.env.SMTP_PASS;
});
it("should send email successfully when SMTP is configured", async () => {
// Set SMTP env vars
process.env.SMTP_HOST = "smtp.test.com";
+20
View File
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { redactTokenForLog } from "../utils/redaction.js";
describe("redactTokenForLog", () => {
it("returns a stable short hash reference without exposing the raw token", () => {
const rawToken = "share-token-secret-value";
const tokenRef = redactTokenForLog(rawToken);
expect(tokenRef).toMatch(/^sha256:[a-f0-9]{12}$/);
expect(tokenRef).toBe(redactTokenForLog(rawToken));
expect(tokenRef).not.toContain(rawToken);
});
it("normalizes empty tokens to a non-sensitive placeholder", () => {
expect(redactTokenForLog("")).toBe("missing");
expect(redactTokenForLog(" ")).toBe("missing");
expect(redactTokenForLog(null)).toBe("missing");
expect(redactTokenForLog(undefined)).toBe("missing");
});
});
+170 -6
View File
@@ -1,3 +1,4 @@
import { existsSync, unlinkSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { migrate } from "drizzle-orm/libsql/migrator";
@@ -6,20 +7,26 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites
import { runAlterMigrations } from "../db/db-utils.js";
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hoisted(() => {
const { testClient, testDb, testDbPath, mockedEnv, nodemailerSendMail, fetchMock } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
const { drizzle } = require("drizzle-orm/libsql");
const client = createClient({ url: ":memory:" });
const { tmpdir } = require("node:os");
const { join } = require("node:path");
const dbPath = join(tmpdir(), `medassist-routes-real-${process.pid}-${Date.now()}.db`);
const client = createClient({ url: `file:${dbPath}` });
const db = drizzle(client);
const env = {
AUTH_ENABLED: false,
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,
testDb: db,
testDbPath: dbPath,
mockedEnv: env,
nodemailerSendMail: vi.fn(),
fetchMock: vi.fn(),
@@ -119,6 +126,9 @@ describe("Real route coverage: settings/export/report", () => {
afterAll(async () => {
await app.close();
testClient.close();
if (existsSync(testDbPath)) {
unlinkSync(testDbPath);
}
});
beforeEach(async () => {
@@ -351,7 +361,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 +371,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 +418,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 +638,99 @@ 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("POST /medications/report-data filters doses by scheduled doseId timestamp and refills by the same date window", async () => {
const medId = await seedMedication("Report Date Range Med");
const windowStart = "2026-01-10T00:00:00.000Z";
const windowEnd = "2026-01-20T00:00:00.000Z";
await testClient.execute({
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
args: [
1,
`${medId}-0-${Date.parse("2026-01-05T09:00:00.000Z")}-Daniel`,
Math.floor(Date.parse("2026-01-12T09:00:00.000Z") / 1000),
0,
],
});
await testClient.execute({
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
args: [
1,
`${medId}-0-${Date.parse("2026-01-15T09:00:00.000Z")}-Daniel`,
Math.floor(Date.parse("2026-01-25T09:00:00.000Z") / 1000),
0,
],
});
await testClient.execute({
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
args: [
1,
`${medId}-0-${Date.parse("2026-01-18T09:00:00.000Z")}-Daniel`,
Math.floor(Date.parse("2026-01-18T09:30:00.000Z") / 1000),
1,
],
});
await testClient.execute({
sql: "INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date) VALUES (?, ?, ?, ?, ?, ?)",
args: [medId, 1, 1, 0, 0, Math.floor(Date.parse("2026-01-12T08:00:00.000Z") / 1000)],
});
await testClient.execute({
sql: "INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date) VALUES (?, ?, ?, ?, ?, ?)",
args: [medId, 1, 9, 0, 1, Math.floor(Date.parse("2026-01-22T08:00:00.000Z") / 1000)],
});
const response = await app.inject({
method: "POST",
url: "/medications/report-data",
payload: { medicationIds: [medId], startDate: windowStart, endDate: windowEnd },
});
expect(response.statusCode).toBe(200);
const body = response.json();
expect(body[medId]).toMatchObject({
dosesTaken: 1,
dosesSkipped: 1,
});
expect(body[medId].refills).toHaveLength(1);
expect(body[medId].refills[0]).toMatchObject({
packsAdded: 1,
usedPrescription: false,
});
});
it("GET /export includes medications, settings, doseHistory and refillHistory", async () => {
@@ -621,7 +761,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 +814,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 +858,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" });
+12 -4
View File
@@ -177,18 +177,26 @@ export interface CreateShareTokenOptions {
token?: string;
scheduleDays?: number;
expiresAt?: number | null;
allowJournalNotes?: boolean;
}
/**
* Create a test share token and return the token string
*/
export async function createTestShareToken(client: Client, options: CreateShareTokenOptions): Promise<string> {
const { userId, takenBy, token = `test_token_${Date.now()}`, scheduleDays = 30, expiresAt = null } = options;
const {
userId,
takenBy,
token = `test_token_${Date.now()}`,
scheduleDays = 30,
expiresAt = null,
allowJournalNotes = false,
} = options;
await client.execute({
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at)
VALUES (?, ?, ?, ?, ?)`,
args: [userId, token, takenBy, scheduleDays, expiresAt],
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at, allow_journal_notes)
VALUES (?, ?, ?, ?, ?, ?)`,
args: [userId, token, takenBy, scheduleDays, expiresAt, allowJournalNotes ? 1 : 0],
});
return token;
@@ -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", () => {
+17
View File
@@ -0,0 +1,17 @@
function pad(value: number, size = 2): string {
return String(value).padStart(size, "0");
}
export function toLocalDateTimeOffsetString(value: Date): string {
const offsetMinutes = -value.getTimezoneOffset();
const sign = offsetMinutes >= 0 ? "+" : "-";
const absoluteOffsetMinutes = Math.abs(offsetMinutes);
const offsetHours = Math.floor(absoluteOffsetMinutes / 60);
const offsetRemainderMinutes = absoluteOffsetMinutes % 60;
return [
`${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`,
`T${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}.${pad(value.getMilliseconds(), 3)}`,
`${sign}${pad(offsetHours)}:${pad(offsetRemainderMinutes)}`,
].join("");
}
+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;
};
+15 -4
View File
@@ -1,4 +1,4 @@
export const PACKAGE_TYPES = ["blister", "bottle", "tube", "liquid_container"] as const;
export const PACKAGE_TYPES = ["blister", "bottle", "tube", "liquid_container", "inhaler", "injection"] as const;
export type PackageType = (typeof PACKAGE_TYPES)[number];
@@ -19,14 +19,25 @@ export function isLiquidContainerPackageType(packageType?: string | null): boole
return normalizePackageType(packageType) === "liquid_container";
}
export function isAmountBasedPackageType(packageType?: string | null): boolean {
export function isPackageAmountPackageType(packageType?: string | null): boolean {
const normalized = normalizePackageType(packageType);
return normalized === "bottle" || normalized === "tube" || normalized === "liquid_container";
return normalized === "tube" || normalized === "liquid_container";
}
export function getPlannerUnitKind(packageType?: string | null): "pills" | "ml" | "units" {
export function isDiscreteCountPackageType(packageType?: string | null): boolean {
const normalized = normalizePackageType(packageType);
return normalized === "bottle" || normalized === "inhaler" || normalized === "injection";
}
export function isAmountBasedPackageType(packageType?: string | null): boolean {
return isPackageAmountPackageType(packageType) || isDiscreteCountPackageType(packageType);
}
export function getPlannerUnitKind(packageType?: string | null): "pills" | "ml" | "units" | "puffs" | "injections" {
const normalized = normalizePackageType(packageType);
if (normalized === "tube") return "units";
if (normalized === "liquid_container") return "ml";
if (normalized === "inhaler") return "puffs";
if (normalized === "injection") return "injections";
return "pills";
}
+10
View File
@@ -0,0 +1,10 @@
import { createHash } from "node:crypto";
export function redactTokenForLog(token: string | null | undefined): string {
const normalizedToken = token?.trim();
if (!normalizedToken) {
return "missing";
}
return `sha256:${createHash("sha256").update(normalizedToken, "utf8").digest("hex").slice(0, 12)}`;
}
+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", {
+2 -5
View File
@@ -7,11 +7,8 @@ export default defineConfig({
include: ["src/**/*.test.ts"],
setupFiles: ["src/test/setup.ts"],
// Run tests sequentially to avoid DB conflicts
poolOptions: {
threads: {
singleThread: true,
},
},
fileParallelism: false,
maxWorkers: 1,
// Timeout for longer integration tests
testTimeout: 10000,
coverage: {
+125
View File
@@ -0,0 +1,125 @@
# Configuration
Configure MedAssist with environment variables in `.env`. Start from `.env.example`.
## General
| Variable | Default | Description |
|----------|---------|-------------|
| `PUID` | `1000` | User ID for container file permissions |
| `PGID` | `1000` | Group ID for container file permissions |
| `PORT` | `3000` | Backend API port |
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS in the Docker Compose quickstart; local Vite development commonly uses `http://localhost:5173` or `http://localhost:4173` |
| `TZ` | `Europe/Berlin` | Server default timezone for scheduled reminders |
| `PUBLIC_APP_URL` | — | Public base URL for notification action and share links. Strongly recommended for any deployment used from another device; do not point this to `localhost` or an internal Docker hostname. Local Vite development also allows this hostname automatically. |
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error`, or `silent` |
| `RATE_LIMIT_MAX` | `100` | Maximum requests per minute per IP |
| `OPENAPI_DOCS_ENABLED` | `auto` | Explicitly enable or disable `/docs` and `/docs/json` |
API docs behavior:
- If `OPENAPI_DOCS_ENABLED` is unset, docs are enabled outside production and disabled in production.
- `OPENAPI_DOCS_ENABLED=true` enables `/docs` and `/docs/json`.
- `OPENAPI_DOCS_ENABLED=false` disables the docs only.
`CORS_ORIGINS` note:
- The `.env.example` file is optimized for the Docker Compose quickstart, where the frontend runs on `http://localhost:4174`.
- Local frontend development uses the Vite dev server instead, so the backend schema defaults cover `http://localhost:5173` and `http://localhost:4173`.
- If you use a custom hostname or reverse proxy, include that origin in `CORS_ORIGINS`.
## Authentication
| Variable | Default | Description |
|----------|---------|-------------|
| `AUTH_ENABLED` | `false` | Enable user authentication |
| `REGISTRATION_ENABLED` | `false` | Allow new user registrations |
| `FORM_LOGIN_ENABLED` | `true` | Enable username/password login |
| `JWT_SECRET` | — | Access token signing key; required when auth is enabled |
| `REFRESH_SECRET` | — | Refresh token signing key; required when auth is enabled |
| `COOKIE_SECRET` | — | Cookie signing key; required when auth is enabled |
| `ACCESS_TOKEN_TTL_MINUTES` | `15` | Access token lifetime |
| `REFRESH_TOKEN_TTL_DAYS` | `7` | Refresh token lifetime |
Generate secrets with `openssl rand -hex 32`.
## API Keys
When `AUTH_ENABLED=true`, authenticated users can create API keys and call protected endpoints with:
```text
Authorization: Bearer ma_...
```
Available scopes:
- `read`: read-only access (`GET`, `HEAD`, `OPTIONS`)
- `write`: read and write access
Notes:
- The token is shown only once after creation.
- Creating a new key deactivates previously active keys for the same user.
- API keys are stored hashed in the database.
API reference:
- Interactive docs: `/docs`
- OpenAPI JSON: `/docs/json`
- Key management endpoints:
- `GET /auth/api-keys`
- `POST /auth/api-keys`
- `DELETE /auth/api-keys/:id`
## OIDC / SSO
| Variable | Default | Description |
|----------|---------|-------------|
| `OIDC_ENABLED` | `false` | Enable OIDC authentication |
| `OIDC_ISSUER_URL` | — | OIDC provider URL |
| `OIDC_CLIENT_ID` | — | OIDC client ID |
| `OIDC_CLIENT_SECRET` | — | OIDC client secret |
| `OIDC_REDIRECT_URI` | — | OIDC callback URL |
| `OIDC_SCOPES` | `openid profile email` | Requested scopes |
| `OIDC_USERNAME_CLAIM` | `preferred_username` | Username claim |
| `OIDC_AUTO_CREATE_USERS` | `true` | Auto-create users on first SSO login |
| `OIDC_PROVIDER_NAME` | `SSO` | Login button label |
## Email (SMTP)
| Variable | Default | Description |
|----------|---------|-------------|
| `SMTP_HOST` | — | SMTP server hostname |
| `SMTP_PORT` | `587` | SMTP server port |
| `SMTP_USER` | — | SMTP username |
| `SMTP_PASS` | — | SMTP password |
| `SMTP_TOKEN` | — | OAuth2 or app token; takes precedence over `SMTP_PASS` |
| `SMTP_FROM` | — | Sender email address |
| `SMTP_SECURE` | `false` | Use TLS |
## Reminders
| Variable | Default | Description |
|----------|---------|-------------|
| `REMINDER_DAYS_BEFORE` | `7` | Days before stock runs out to send reminder |
| `REMINDER_HOUR` | `6` | Hour to send daily reminders (24h format) |
| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
| `EXPIRY_WARNING_DAYS` | `30` | Days before expiry warning |
Reminder timing uses IANA timezones. `TZ` is the server default. Users can override it in Settings.
These values are runtime defaults. User-specific settings can override reminder behavior after first save.
## Push Notifications
Push notification setup, provider support, and URL examples are documented in [PUSH_NOTIFICATIONS.md](PUSH_NOTIFICATIONS.md).
Recommended provider: `ntfy`, especially for intake reminders with direct actions.
Notification action and share links should use `PUBLIC_APP_URL` as their reachable base URL. For self-hosted setups, this should normally be your externally reachable HTTPS address, for example `https://med.example.com`.
If `PUBLIC_APP_URL` is missing in a remote deployment, reminder links can still be generated from local origins that are unreachable from phones or external browsers.
## Default User Settings
Default values for newly created users are documented in [DEFAULT_USER_SETTINGS.md](DEFAULT_USER_SETTINGS.md).
+4 -2
View File
@@ -6,7 +6,9 @@ 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).
- This document only covers settings that have an environment-backed default.
- It is not intended to be a full inventory of every setting shown in the UI.
- UI-only settings without a `DEFAULT_*` variable, for example the dashboard section order toggle, are intentionally excluded.
## Email Defaults
@@ -47,6 +49,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. |
+55
View File
@@ -0,0 +1,55 @@
# Development
## Start the Development Stack
```bash
docker compose -p medassist-dev -f docker-compose.dev.yml up
```
## Service Endpoints
- Frontend: `http://localhost:5173`
- Backend: `http://localhost:3000`
- API docs UI: `http://localhost:3000/docs` when docs are enabled
- OpenAPI JSON: `http://localhost:3000/docs/json` when docs are enabled
## Frontend Dev Server Behind a Proxy
If the frontend dev server runs behind a reverse proxy or on a remote host, set these frontend-only environment variables before starting Vite:
These development overrides are documented here intentionally and are not part of the standard operator-focused `.env.example` surface.
## API Proxy Contract
- Frontend browser code should call `/api/*`, not hardcoded backend hostnames.
- Vite rewrites `/api/*` to the backend target configured by `BACKEND_URL` or the built-in default for the current environment.
- Default backend target:
- local dev outside Docker: `http://localhost:3000`
- dev stack inside Docker: `http://backend-dev:3000`
- If your backend runs on a different host or service name, set `BACKEND_URL` explicitly before starting Vite.
- `BACKEND_URL`: backend target used by the Vite `/api` proxy; default `http://localhost:3000` outside Docker and `http://backend-dev:3000` in Docker
- `VITE_ALLOWED_HOSTS`: comma-separated hostnames allowed to connect to the dev server; default `localhost,127.0.0.1` plus the hostname from `PUBLIC_APP_URL` when configured
- `VITE_HMR_HOST`: public hostname for HMR websocket connections
- `VITE_HMR_PROTOCOL`: websocket protocol override (`ws` or `wss`)
- `VITE_HMR_CLIENT_PORT`: public websocket port exposed to the browser
- `VITE_HMR_PORT`: server-side websocket port for the Vite process
## Useful Commands
```bash
npm run lint
npm run check
npm run build
cd backend && npm run test:run
cd frontend && npm run test:run
```
Recommended local maintenance preflight before opening or updating a PR:
```bash
npm run check
npm run build
```
Use the root-level commands for full-stack validation when a change spans backend and frontend. Keep using the package-local commands when you are validating only one slice.
+110
View File
@@ -0,0 +1,110 @@
# Push Notifications
MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications.
## Recommendation
Recommended provider: `ntfy`.
Use `ntfy` when you want the best-supported MedAssist notification flow, especially for intake reminders with actions such as `Take`, `Skip`, and `View`.
For `ntfy`, MedAssist publishes native action buttons so `Take` and `Skip` are executed directly from the notification. The browser-based confirmation flow remains the fallback path for other Shoutrrr targets that do not support native action buttons.
When an ntfy intake action succeeds, MedAssist publishes the confirmation as the updated notification state and removes the outdated actionable ntfy entry using the original ntfy message ID when available, so duplicate reminder entries do not accumulate unnecessarily.
## Supported URL Schemes
- `ntfy://`
- `discord://`
- `pushover://`
- `gotify://`
- `telegram://`
- direct `https://` webhooks
## Configuration
Configure push notifications in the app under `Settings -> Push`, or set defaults for new users with environment variables.
Notification action links such as `Take`, `Skip`, and `View` use `PUBLIC_APP_URL` as their base URL. Set this to the public MedAssist URL that the receiving device can actually reach.
Good examples:
```text
https://med.example.com
https://medtest.example.com
```
Bad examples for notification actions:
```text
http://localhost:3000
http://backend-dev:3000
http://192.168.x.x:3000
```
Push-related default variables:
| Variable | Default | Description |
|----------|---------|-------------|
| `DEFAULT_SHOUTRRR_ENABLED` | `false` | Enable push notifications by default |
| `DEFAULT_SHOUTRRR_URL` | — | Default Shoutrrr URL |
| `DEFAULT_SHOUTRRR_STOCK_REMINDERS` | `true` | Send stock reminders via push |
| `DEFAULT_SHOUTRRR_INTAKE_REMINDERS` | `true` | Send intake reminders via push |
| `DEFAULT_SHOUTRRR_PRESCRIPTION_REMINDERS` | `true` | Send prescription reminders via push |
For the full default-user-settings reference, see [DEFAULT_USER_SETTINGS.md](DEFAULT_USER_SETTINGS.md).
## URL Examples
### ntfy
```text
ntfy://ntfy.sh/your-topic
ntfy://user:password@your-server.com/topic
```
### Pushover
```text
pushover://shoutrrr:API_TOKEN@USER_KEY/
```
### Gotify
```text
gotify://your-server.com/TOKEN
gotify://your-server.com:443/path/to/gotify/TOKEN?priority=1
```
### Discord
```text
discord://TOKEN@WEBHOOK_ID
```
### Telegram
```text
telegram://TOKEN@telegram?chats=CHAT_ID
telegram://TOKEN@telegram?chats=@your_channel,-1001234567890
```
For all supported services and options, see the [Shoutrrr documentation](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
## Troubleshooting
### ntfy `Take` / `Skip` fails with a connection timeout
If the ntfy client shows an error such as `failed to connect to ... port 443`, the failure usually happens before MedAssist can process the action token.
Check these points first:
1. `PUBLIC_APP_URL` points to your real public MedAssist URL, not to `localhost`, a Docker service name, or another internal-only address.
2. The same URL opens from the same phone and network outside the notification flow.
3. If the failure only happens on your home Wi-Fi, retry once on mobile data. That strongly helps distinguish an app issue from missing NAT loopback / hairpin routing on the local network.
### ntfy shows an old actionable entry after a successful action
MedAssist updates the notification state after a successful ntfy action and removes the stale actionable entry using the original ntfy message ID when available.
If an outdated actionable entry still remains visible, verify that the action actually reached MedAssist and that your ntfy server accepted both the confirmation publish and the follow-up delete of the original message.
-237
View File
@@ -1,237 +0,0 @@
# Agent Memory Notes
Purpose: persistent agent work memory to survive context loss.
## Entries
### 2026-03-25
- Task: Diagnose PR #475 GitHub CI failure for the frontend build job and fix testing/build-scope issues only.
- Root cause: The GitHub "Frontend Build" check actually failed in the frontend lint step because `frontend/src/test/pages/MedicationsPage.test.tsx` contained a whitespace-only line that Biome rejects.
- Fix: Removed the stray whitespace-only line in `frontend/src/test/pages/MedicationsPage.test.tsx` and revalidated frontend lint/build locally.
- Task: Split the medication enrichment lookup improvements into a standalone feature branch and repair the shared frontend tests until the focused validation set passed.
- Decisions: Kept this branch limited to enrichment lookup/search/apply behavior, restored corrupted MedicationsPage and MobileEditModal test structure from clean main patterns, and retained desktop/mobile parity inside the feature scope.
- Files touched: README.md, backend/src/routes/medication-enrichment.ts, backend/src/services/medication-enrichment.ts, backend/src/test/medication-enrichment.test.ts, frontend/src/components/MedicationEnrichmentSection.tsx, frontend/src/components/MobileEditModal.tsx, frontend/src/i18n/de.json, frontend/src/i18n/en.json, frontend/src/pages/MedicationsPage.tsx, frontend/src/styles.css, frontend/src/test/components/MedicationEnrichmentSection.test.tsx, frontend/src/test/components/MobileEditModal.test.tsx, frontend/src/test/pages/MedicationsPage.test.tsx, frontend/src/types/index.ts, frontend/src/utils/index.ts, frontend/src/utils/medication-enrichment.ts.
- Follow-up: Merge the refreshed feature branch once GitHub CI is green again.
- Task: Merge the refreshed feature branch on top of the already shipped stock/refill semantics changes without losing shared test coverage or work-log history.
- Decisions: Kept the stock/refill doku history entries while resolving add/add conflicts and combined both branches' MedicationsPage tests in the shared file.
- Files touched: doku/memory_notes.md, doku/report.md, frontend/src/test/pages/MedicationsPage.test.tsx.
- Follow-up: Re-run the minimum frontend validation and push the conflict-resolution commit for PR #475.
- Task: Review and merge the open Dependabot pull requests after verifying scope and CI state.
- Decisions: Merged only dependency-only PRs with acceptable checks; accepted skipped jobs on the root-only tooling bump because the diff did not touch frontend or backend runtime code.
- Merged PRs: #468 (`@biomejs/biome` root bump), #469 (frontend dependency group bump), #470 (backend dependency group bump).
- Follow-up: Synced local `main` to commit `39c19ab` and confirmed there are no remaining open Dependabot PRs from this reviewed set.
- Task: Investigate why last week's weekly triage report issue stayed open after a newer report was created.
- Root cause: `.github/workflows/weekly-triage-report.yml` always created a new issue and had no cleanup step for older open weekly report issues; `.github/agents/release-manager.agent.md` also lacked an explicit weekly-report closure rule.
- Fix: Added workflow logic to close older open weekly triage reports before publishing the new one and added a dedicated "Weekly Triage Report Hygiene" rule to the release-manager agent instructions.
- Task: Ship the CSS architecture modernization in an isolated PR flow and then restore the local Spec Kit workspace artifacts after the requested main-branch cleanup.
- Decisions: Used a fresh worktree from `github/main` to avoid shipping unrelated local residue, merged the CSS-only PR from that clean scope, then used `git stash push -u` to satisfy the requested clean local `main` state without deleting the local Spec Kit setup.
- Recovery: Verified that `.specify/`, `specs/001-css-monolith-modernization/`, `docs/SPEC_KIT.md`, `.github/agents/medassist-feature-orchestrator.agent.md`, `.github/agents/speckit.*`, and `.github/prompts/speckit.*` were preserved inside `stash@{0}` and restored them with `git stash apply stash@{0}` after the user requested them back.
- Correction: Updated `.github/agents/release-manager.agent.md` to make the intended rule explicit: `git stash` may be used only temporarily during an active transition, never as the final mechanism for making local `main` look clean. A requested clean `main` now explicitly means no leftover tracked changes, no leftover untracked task files, and no hidden task residue in stash.
- Follow-up correction: Added all current Spec Kit artifacts to `.gitignore` so the local setup no longer appears in `git status`. The ignore covers `.specify/`, `specs/`, `docs/SPEC_KIT.md`, `.github/agents/medassist-feature-orchestrator.agent.md`, `.github/agents/speckit.*.agent.md`, and `.github/prompts/speckit.*.prompt.md`.
- Task: Perform a thorough repo-wide code-quality audit across backend and frontend without implementation.
- Findings: The highest-risk hotspots are duplicated notification delivery logic across planner/manual and scheduler code paths, duplicated schedule/stock rendering logic across DashboardPage, SchedulePage, and SharedSchedule, oversized god modules such as `frontend/src/context/AppContext.tsx`, `frontend/src/pages/MedicationsPage.tsx`, `backend/src/routes/medications.ts`, `backend/src/routes/planner.ts`, `backend/src/services/reminder-scheduler.ts`, `backend/src/services/intake-reminder-scheduler.ts`, and `backend/src/services/medication-enrichment.ts`, plus several swallowed-error paths and broad file-level lint suppressions.
- Output: Prepared a severity-ranked review, a high-ROI remediation plan, and a deeper reporting breakdown for notifications, AppContext, and schedule UI duplication.
- Documentation: Wrote the consolidated audit report to `doku/code-quality-audit-2026-03-26.md` so the findings and remediation priorities are preserved as a standalone markdown document.
- Task: Merge the newly opened Dependabot pull requests via the release-manager handoff path.
- Result: `#482` (backend picomatch bump), `#483` (frontend picomatch bump), and `#484` (root dev picomatch bump) were squash-merged after review. `#485` (backend yaml bump) was left open because its refreshed checks were still running and not fully green at decision time.
- Task: Review the open Dependabot PRs on GitHub and merge only the safe ones.
- Scope review: Verified each Dependabot PR diff was dependency-only with no mixed product changes; all reviewed PRs only changed a single lockfile.
- Merged: Squash-merged PR #483 (`picomatch` in `/frontend`), PR #482 (`picomatch` in `/backend`), and PR #484 (root `picomatch` dev dependency lockfile update).
- Deferred: Left PR #485 (`yaml` in `/backend`) open after rebasing it onto the updated `main` because its refreshed Playwright E2E check was still running, so the PR was not yet fully green at decision time.
- Task: Convert the code-quality audit into a concrete implementation plan.
- Output: Added `plan/refactor-code-quality-remediation-1.md` with phase-based remediation steps covering notification consolidation, shared schedule UI extraction, AppContext decomposition, MedicationsPage decomposition, backend service/module decomposition, and observability hardening.
- Constraint handling: Kept the plan split into reviewable phases so future implementation can stay within the repository's one-objective-per-PR rule.
- Task: Review the remediation plan for execution readiness and prepare the next-agent handoff.
- Decision: The plan structure was already sound, but it needed explicit PR-sized execution slices and a concrete first handoff target so the next agent does not start with an overly broad refactor scope.
- Output: Added `Execution Slices & Handoff` to `plan/refactor-code-quality-remediation-1.md`, recommending `medassist-feature-orchestrator` start with Phase 1 only, followed by `@testing-manager` and then `@release-manager`.
- Task: Break the remediation plan into executable checklist tasks.
- Constraint: The standard `.specify/scripts/bash/check-prerequisites.sh --json` flow failed on `main` because there is no active feature branch, so task generation used `plan/refactor-code-quality-remediation-1.md` and `doku/code-quality-audit-2026-03-26.md` directly as the source artifacts.
- Output: Added `plan/refactor-code-quality-remediation-tasks-1.md` with setup, foundational, six remediation user stories, cross-cutting polish, dependencies, parallel opportunities, and explicit testing/release handoff tasks.
- Task: Apply the three consistency remediations after the manual analysis findings.
- Decisions: Created a local feature branch `002-code-quality-remediation`, added a minimal Spec Kit feature set under `specs/002-code-quality-remediation/`, reduced the task file's blocking foundations to MVP-relevant prerequisites only, added explicit local build/check validation tasks per slice, and split the later backend and observability work into narrower slices.
- Output: Updated `plan/refactor-code-quality-remediation-1.md`, replaced `plan/refactor-code-quality-remediation-tasks-1.md`, and added `specs/002-code-quality-remediation/spec.md`, `specs/002-code-quality-remediation/plan.md`, and `specs/002-code-quality-remediation/tasks.md`.
- Task: Implement US1 notification consolidation for code-quality remediation slice 1.
- Decisions: Added a shared notification service layer under `backend/src/services/notifications/` to centralize SMTP delivery, push delivery, push payload builders, and reminder state helpers. Refactored manual reminder routes and scheduler paths to consume the shared modules while preserving existing behavior and parity.
- Files touched: `backend/src/services/notifications/delivery.ts`, `backend/src/services/notifications/builders.ts`, `backend/src/services/notifications/state.ts`, `backend/src/services/notifications/index.ts`, `backend/src/services/reminder-scheduler.ts`, `backend/src/routes/planner.ts`, `backend/src/services/intake-reminder-scheduler.ts`.
- Validation: Ran backend local validation (`npm run check` and `npm run build` in `backend/`). First pass revealed leftover lint/type issues from refactor (unused symbols and stale SMTP variable references in planner logs), then applied targeted fixes and re-ran until both commands passed cleanly.
- Task: Hand off reminder regression testing to the designated testing owner.
- Output: Delegated to `@testing-manager` and captured a risk-based regression plan with prioritized existing tests (`planner`, `intake-reminder-scheduler`, `stock-semantics-parity`), concrete gap tests to add, exact run commands, and a PR-ready pass/fail checklist.
- Task: Continue with the next remediation task (US2/T016) after US1 completion.
- Output: Completed schedule-duplication inventory across `frontend/src/pages/DashboardPage.tsx`, `frontend/src/pages/SchedulePage.tsx`, and `frontend/src/components/SharedSchedule.tsx`.
- Findings: Confirmed duplicated dose formatting helpers, duplicated timeline day rendering blocks, duplicated day collapse persistence/toggle mechanics, duplicated missed-dose summary/clear flow, and duplicated stock-row decoration/status branching.
- Files updated: `specs/002-code-quality-remediation/plan.md` (inventory notes), `specs/002-code-quality-remediation/tasks.md` (T016 marked done).
- Task: Implement US2/T017 shared schedule helper foundation.
- Output: Added `frontend/src/features/schedule/formatters.ts` and `frontend/src/features/schedule/storage.ts` to centralize duplicated schedule amount formatting and collapse-state storage helpers ahead of page rewiring tasks.
- Files updated: `specs/002-code-quality-remediation/tasks.md` (T017 marked done).
- Task: Implement US2/T018 shared schedule interaction helper foundation.
- Output: Added `frontend/src/features/schedule/interactions.ts` with reusable helpers for day-collapse state resolution and dose-progress counting.
- Files updated: `specs/002-code-quality-remediation/tasks.md` (T018 marked done).
- Task: Complete US2 rewiring tasks T019-T021 to consume shared schedule helpers.
- Output: Rewired `frontend/src/pages/DashboardPage.tsx`, `frontend/src/pages/SchedulePage.tsx`, and `frontend/src/components/SharedSchedule.tsx` to consume shared schedule formatting/storage/interaction helpers from `frontend/src/features/schedule/`.
- Validation: Editor diagnostics show no errors in the touched files after rewiring.
- Files updated: `specs/002-code-quality-remediation/tasks.md` (T019-T021 marked done).
- Task: Provide an immediate execution sequence for adapting US1 reminder consolidation tests in branch `002-code-quality-remediation`.
- Output: Confirmed current coverage is concentrated in `backend/src/test/planner.test.ts` and `backend/src/test/intake-reminder-scheduler.test.ts`, identified missing direct unit coverage for `backend/src/services/notifications/{delivery,builders,state}.ts`, and prepared an ordered command plan (baseline targeted run -> new unit tests -> targeted rerun -> backend check/build gate) with explicit completion criteria.
- Task: Testing handoff validation for US2 schedule helper consolidation and rewiring (T023).
- Scope validated: `frontend/src/features/schedule/{formatters,storage,interactions}.ts`, shared schedule components, `frontend/src/pages/DashboardPage.tsx`, `frontend/src/pages/SchedulePage.tsx`, and `frontend/src/components/SharedSchedule.tsx`.
- Validation executed: targeted Vitest parity pack passed (`DashboardPage`, `SchedulePage`, `SharedSchedule`, `SharedScheduleTodayOnly`, schedule utils, storage utils); targeted Playwright schedule specs mostly passed but one existing undo-visibility assertion failed in `frontend/e2e/schedule-data.spec.ts`.
- Gate status: `frontend` `npm run check` still fails only on pre-existing TypeScript errors in `frontend/src/test/pages/MedicationsPage.test.tsx` lines 887 and 1641 (`resolveLoadMore?.(...)` and `resolveEnrichment?.(...)` typed as `never`).
- Classification: current TS check failures appear unrelated to US2 rewiring scope because they are confined to MedicationsPage enrichment tests and touched schedule suites passed.
- Task: Start and advance US3 AppContext decomposition tasks (T025-T031).
- Output: Added `US3 Inventory Notes (T025)` in `specs/002-code-quality-remediation/plan.md`; implemented first extracted boundary in `frontend/src/context/ShareContext.tsx` and wired it through `frontend/src/context/AppContext.tsx` and `frontend/src/App.tsx`.
- Output: Added `frontend/src/hooks/useScheduleController.ts` and migrated heavy consumers (`frontend/src/pages/DashboardPage.tsx`, `frontend/src/pages/SchedulePage.tsx`) to the smaller orchestration hook.
- Validation/Handoff: US3 `frontend` check gate remains blocked by pre-existing MedicationsPage test typing errors; handed off US3 regression validation to `@testing-manager` with targeted test/command sequence and blocker classification.
- Task: Continue execution into US4 (T032/T033).
- Output: Completed desktop/mobile medication-edit parity inventory and documented it in `specs/002-code-quality-remediation/plan.md` (`US4 Inventory Notes (T032)`).
- Output: Extracted medication enrichment state controller to `frontend/src/hooks/useMedicationEnrichmentController.ts` and rewired `frontend/src/pages/MedicationsPage.tsx` to consume the extracted hook/state handlers.
- Task: Testing handoff validation for US3 AppContext decomposition (ShareContext boundary + useScheduleController extraction).
- Scope validated: `frontend/src/context/ShareContext.tsx`, `frontend/src/context/AppContext.tsx`, `frontend/src/hooks/useScheduleController.ts`, `frontend/src/pages/DashboardPage.tsx`, `frontend/src/pages/SchedulePage.tsx`, and `frontend/src/App.tsx`.
- Validation executed: frontend `npm run check` reproduces the same pre-existing TypeScript blocker in `frontend/src/test/pages/MedicationsPage.test.tsx` lines 887 and 1641; focused Vitest pass confirmed for `SchedulePage` + `ShareDialog` tests; targeted Playwright pass confirmed for `e2e/schedule.spec.ts` + `e2e/share-schedule.spec.ts` (23/23).
- Additional finding: `App.test.tsx` and `DashboardPage.test.tsx` currently fail due stale module mocks missing the new `useShareContext` export, indicating test adaptation required for the extracted boundary rather than evidence of runtime schedule/share regression.
- Task: Complete US7/T052 by removing swallowed refresh-related failures in frontend settings flow.
- Output: Updated `frontend/src/hooks/useSettings.ts` to replace silent `.catch(() => {})` paths for reminder-status refresh and keepalive settings flush with explicit structured warning logs.
- Detail: Added a small local `getErrorMessage` helper to normalize unknown thrown values into loggable strings and reused it in the new catch handlers.
- Validation: Editor diagnostics for `frontend/src/hooks/useSettings.ts` report no errors after the changes.
### 2026-03-27
- Task: Diagnose and fix PR #490 CI failures (`Frontend Build`, `Playwright E2E`) in worktree `medassist-pr-e2e`.
- Root causes:
- Frontend gate: `frontend/e2e/app-shell.spec.ts` had a biome formatting violation; after fixing that, `frontend/src/test/pages/MedicationsPage.test.tsx` still failed TypeScript (`resolveLoadMore?.(...)` and `resolveEnrichment?.(...)` inferred as `never`).
- Playwright E2E: `frontend/e2e/dashboard-data.spec.ts` undo test asserted `.day-block.today` before dashboard data was fully ready, causing intermittent/not-found failure in CI-like runs.
- Fixes:
- Added formatting newline in `frontend/e2e/app-shell.spec.ts`.
- Reworked resolver typing in `frontend/src/test/pages/MedicationsPage.test.tsx` to definite-assignment callbacks with matching `Promise` generics.
- Hardened `frontend/e2e/dashboard-data.spec.ts` undo flow by waiting for dashboard overview table and seeded medication row before asserting timeline blocks.
- Reduced auth setup rate-limit pressure in `frontend/e2e/auth.setup.ts` by switching to login-first and registering only as fallback before a single retry.
- Validation:
- `cd frontend && CI=true npm run check` passed.
- `cd frontend && CI=true npm run build` passed.
- `cd frontend && PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=1 npx playwright test --config=playwright.stable.config.ts --workers=1 e2e/dashboard-data.spec.ts --grep "should undo a taken dose|should mark a dose as taken and show undo"` passed after resetting reused local servers and installing backend/frontend deps in this worktree.
- Task: Complete US7/T053 by adding intentional optional-auth verification logging.
- Output: Updated `backend/src/plugins/auth.ts` optional auth flow to emit debug logs for API-key/session verification outcomes (authenticated, key not found, key expired, inactive/missing user, session verify failure).
- Security note: Logs intentionally avoid token values and only include outcome-level context.
- Validation: Editor diagnostics for `backend/src/plugins/auth.ts` report no errors.
- Task: Complete US7/T054 by adding state-file read/parse failure logging.
- Output: Updated `backend/src/services/intake-reminder-scheduler.ts` so `loadIntakeReminderState` logs parse/read failures with state-file path and normalized error message before falling back to default state.
- Validation: Editor diagnostics for `backend/src/services/intake-reminder-scheduler.ts` report no errors.
- Task: Complete US7/T055 by replacing remaining broad catches in known hotspot files.
- Output: Updated `frontend/src/hooks/useSettings.ts` to log failures in `performSave`, `testEmail`, and `testShoutrrr` catch paths instead of broad silent catches.
- Output: Updated `backend/src/services/medication-enrichment.ts` startup/scheduled refresh catch handlers to log explicit failure context instead of swallowing with `.catch(() => undefined)`.
- Verification: Pattern search across hotspot files (`useSettings`, `auth`, `medication-enrichment`, `intake-reminder-scheduler`) shows no remaining `catch {}` or silent `.catch(() => undefined)` signatures.
- Task: Complete US7/T056 by running required frontend/backend check and build gates before handoff.
- Validation results: `frontend npm run check` remains blocked by known pre-existing TypeScript errors in `frontend/src/test/pages/MedicationsPage.test.tsx` lines 887 and 1641; `frontend npm run build` passed; `backend npm run check` passed; `backend npm run build` passed.
- Additional fix during gate run: resolved newly surfaced lint/import-order issues in `frontend/src/pages/MedicationsPage.tsx` and `frontend/src/hooks/index.ts`.
- Task: Complete US7/T057 observability testing handoff to `@testing-manager`.
- Output: Delegated US7 validation scope and received targeted command set, add-test recommendations for new observability log paths, and conditional pass guidance with baseline frontend check blocker classification.
- Task: Complete cross-cutting reconciliation tasks T058 and T059.
- Output: Updated status alignment in `specs/002-code-quality-remediation/tasks.md`, `plan/refactor-code-quality-remediation-tasks-1.md`, and `plan/refactor-code-quality-remediation-1.md` (plan status moved to In Progress with current execution snapshot).
- Task: Complete T060 release handoff.
- Output: Delegated handoff summary to `@release-manager` with completed-task scope, validation snapshot, blocker classification, and PR-prep checklist notes for the current branch state.
- Task: Normalize task completion tracking after US7/cross-cutting execution.
- Output: Reconciled historical checkboxes in `specs/002-code-quality-remediation/tasks.md` and mirrored status updates in `plan/refactor-code-quality-remediation-tasks-1.md` so completed US1-US3 items and US5 T042/T043 are marked consistently.
- Remaining open tasks now focused to: US4 (`T034`-`T039`), US5 (`T040`, `T041`, `T044`), and US6 (`T045`-`T051`).
- Task: Complete US4/T034 by extracting medication list orchestration from `MedicationsPage`.
- Output: Added `frontend/src/components/medications/MedicationListSection.tsx` and moved the grid/obsolete list rendering plus list actions into the new component while preserving existing handlers and UI behavior.
- Output: Rewired `frontend/src/pages/MedicationsPage.tsx` to render `MedicationListSection` via props/callbacks instead of inline list markup.
- Validation: Editor diagnostics report no errors in both touched files.
- Task: Complete US5/T040 inventory for medication enrichment backend decomposition.
- Output: Added `US5 Inventory Notes (T040)` in `specs/002-code-quality-remediation/plan.md` with concrete seam clusters (adapters, parsing/normalization, search/ranking, enrichment assembly, lifecycle/scheduler).
- Follow-up direction captured: target split into `backend/src/services/medication-enrichment/{adapters.ts,search.ts,index.ts}` for T041.
- Task: Complete US6/T045 inventory for backend DB utility and route decomposition targets.
- Output: Added `US6 Inventory Notes (T045)` in `specs/002-code-quality-remediation/plan.md` covering decomposition seams for `backend/src/db/db-utils.ts`, `backend/src/routes/medications.ts`, `backend/src/routes/planner.ts`, and `backend/src/routes/settings.ts`.
- Constraint capture: documented manual/scheduler reminder parity, shoutrrr extraction compatibility, and route-to-service dependency direction constraints for T046-T051.
- Task: Complete US4/T035 by extracting desktop medication edit orchestration shell.
- Output: Added `frontend/src/components/medications/MedicationEditCoordinator.tsx` to own desktop edit panel wrapper concerns (sidebar/card header/form shell).
- Output: Rewired `frontend/src/pages/MedicationsPage.tsx` to render `MedicationEditCoordinator` and keep form field internals as child content.
- Validation: Focused Biome check passed for `MedicationsPage.tsx`, `MedicationEditCoordinator.tsx`, `MedicationListSection.tsx`, and `components/index.ts`.
- Task: Validate US7 observability hardening slice for test readiness and release gate status.
- Scope reviewed: `frontend/src/hooks/useSettings.ts`, `backend/src/plugins/auth.ts`, `backend/src/services/intake-reminder-scheduler.ts`, `backend/src/services/medication-enrichment.ts`.
- Findings: Existing hook-level tests cover core `useSettings` behavior but do not assert new warning-log paths; no direct backend tests currently assert `optionalAuth` debug outcome logging or medication enrichment startup/scheduled refresh catch logging.
- Additional risk note: `backend/src/services/intake-reminder-scheduler.ts` now depends on shared notification modules (`services/notifications/*`), so slice validation should include scheduler delivery-path regression checks in addition to new observability assertions.
- Gate classification: recommended as conditionally pass for US7 slice once targeted tests pass; frontend global `npm run check` remains blocked by pre-existing MedicationsPage test typing errors outside US7 scope.
- Task: Complete US4/T036 by extracting modal/lightbox/report concerns from `MedicationsPage`.
- Output: Added `frontend/src/components/medications/MedicationDialogs.tsx` and moved unsaved/obsolete/delete confirm modals, lightbox, and report modal rendering behind a single dialog orchestration component.
- Output: Rewired `frontend/src/pages/MedicationsPage.tsx` to pass `MobileEditModal` as `mobileEditModal` node into `MedicationDialogs`, preserving desktop/mobile edit flow behavior.
- Validation: Focused Biome check passed for `MedicationsPage.tsx`, `MedicationDialogs.tsx`, `MedicationEditCoordinator.tsx`, `MedicationListSection.tsx`, and `components/index.ts`.
- Tracking: Marked `T036` complete in both `specs/002-code-quality-remediation/tasks.md` and `plan/refactor-code-quality-remediation-tasks-1.md`.
- Note: Started a first draft for US4/T037 dashboard section extraction, then reverted `frontend/src/pages/DashboardPage.tsx` to avoid carrying malformed intermediate edits; deferred T037 for a clean follow-up slice.
- Task: Complete remaining US4/US5/US6 implementation slice items (T037-T039, T041/T044, T046-T051).
- Output: Repaired `frontend/src/pages/DashboardPage.tsx` after malformed insertion, finalized extraction to `frontend/src/components/dashboard/DashboardReminderSection.tsx` and `frontend/src/components/dashboard/DashboardStatusSection.tsx`, and preserved existing reminder/status behavior through componentized rendering.
- Output: Finalized backend decomposition with focused DB modules (`backend/src/db/{path-utils,migration-utils,repair-utils}.ts`), route helper services (`backend/src/services/{medications-service,planner-service,settings-service}.ts`), and medication-enrichment module surface (`backend/src/services/medication-enrichment/{adapters,search,index}.ts`) plus route/import rewiring.
- Validation: Frontend gate for T038 executed as split runs due known baseline blocker: `npm run check` still fails on pre-existing `frontend/src/test/pages/MedicationsPage.test.tsx` TS errors at lines 887/1641, while `npm run build` passed; backend gate for T050 passed (`npm run check` and `npm run build`).
- Handoff record: Prepared and recorded testing-manager handoff scope for T039/T044/T051 (desktop/mobile parity checks, enrichment regression checks, and backend route/db regression checks) without running broad tests from this implementation agent.
- Tracking: Marked T037-T039, T041/T044, and T046-T051 complete in both `specs/002-code-quality-remediation/tasks.md` and `plan/refactor-code-quality-remediation-tasks-1.md`.
- Task: Implement missing regression tests and hard evidence for T039, T044, and T051.
- Output (frontend T039): Added `frontend/src/test/components/MedicationEditCoordinator.test.tsx` and `frontend/src/test/components/MedicationDialogs.test.tsx` with explicit desktop edit-shell and dialog orchestration assertions; retained mobile parity evidence via `frontend/src/test/components/MobileEditModal.test.tsx` targeted execution.
- Output (backend T044): Extended `backend/src/test/medication-enrichment.test.ts` with split-module export parity assertions (`index/search/adapters` vs canonical service) and transport-safe search failure contract assertion.
- Output (backend T051): Added `backend/src/test/decomposition-services.test.ts` for extracted service helpers (`medications-service`, `planner-service`, `settings-service`) and updated `backend/src/test/database.test.ts` to assert `.write-test` residue is not left behind.
- Validation commands/results:
- `cd frontend && CI=true npm run test:run -- src/test/components/MedicationEditCoordinator.test.tsx src/test/components/MedicationDialogs.test.tsx src/test/components/MobileEditModal.test.tsx` -> passed (`3` files, `71` tests).
- `cd backend && CI=true npm run test:run -- src/test/decomposition-services.test.ts src/test/medication-enrichment.test.ts src/test/database.test.ts src/test/medications.test.ts src/test/planner.test.ts src/test/settings.test.ts` -> passed (`6` files, `160` tests).
- `cd frontend && npm run check && npm run build` -> baseline fail at `frontend/src/test/pages/MedicationsPage.test.tsx` lines `887` and `1641` (`TS2349: Type 'never' has no call signatures`); unchanged pre-existing blocker.
- `cd backend && npm run check && npm run build` -> passed.
- Task: Achieve fully green backend/frontend/E2E test state after prior baseline blocker reports.
- Root causes fixed:
- Backend: `backend/src/test/db-client.test.ts` still mocked legacy `../db/db-utils.js` while `backend/src/db/client.ts` imports split modules (`path-utils`, `migration-utils`, `repair-utils`), causing false `process.exit(1)` failures.
- Frontend: test mocks were stale after context/hook/component decomposition (`useShareContext`, `useMedicationEnrichmentController`, and modal orchestration moved behind `MedicationDialogs`).
- Fixes applied:
- Hardened backend test env defaults in `backend/src/test/setup.ts` (`DOTENV_PATH`, `AUTH_ENABLED`, `OIDC_ENABLED`, plus `afterEach` reset).
- Updated `backend/src/test/db-client.test.ts` mocks to target `../db/path-utils.js`, `../db/migration-utils.js`, and `../db/repair-utils.js`.
- Updated `frontend/src/test/App.test.tsx` to mock and assert share state via `useShareContext` / `shareContextMock`.
- Updated `frontend/src/test/pages/MedicationsPage.test.tsx` to partially mock hooks barrel with real exports and added deterministic mock for `../../components/medications/MedicationDialogs`.
- Final validation (all green):
- `cd backend && CI=true npm run test:run` -> passed (`25` files, `639` tests).
- `cd frontend && CI=true npm run test:run` -> passed (`47` files, `881` tests).
- `cd frontend && PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=1 npm run test:e2e -- --workers=1` -> passed (stable suite, exit code `0`, with one expected skipped scenario).
- `cd backend && npm run check` -> passed.
- `cd frontend && npm run check` -> passed.
- Task: Start full Playwright coverage expansion for app-shell/public-route gaps and stabilize flaky stable-suite checks.
- Output: Added `frontend/e2e/app-shell.spec.ts` with new E2E coverage for user-menu profile modal, about modal, sign-out flow, and public route redirect `/share/:token/overview -> /share/:token`.
- Output: Stabilized flaky assertions in `frontend/e2e/dashboard-data.spec.ts`, `frontend/e2e/schedule-data.spec.ts`, and `frontend/e2e/planner-data.spec.ts` by hardening take/undo flow timing and making stock text assertion tolerant of dynamic consumption.
- Output: Hardened `frontend/e2e/settings.spec.ts` calculation-mode toggle check to avoid hidden-input interaction and auto-save race conditions.
- Validation: Re-ran `E2E stable non-interactive` repeatedly after each fix cycle; latest run state is green for all executed tests (`157 passed`) with environment/guarded scenarios reported as skipped (`4 skipped`) and no failing tests.
-529
View File
@@ -1,529 +0,0 @@
# Work Report
## Entries
### 2026-03-25
- Scope: Diagnose and fix the PR #475 frontend CI failure within testing/build ownership.
- What changed:
- Confirmed the GitHub "Frontend Build" job was failing in the frontend lint step, not in the Vite production build.
- Removed a stray whitespace-only line in `frontend/src/test/pages/MedicationsPage.test.tsx` that caused Biome formatting failure.
- Validation:
- `cd frontend && npm run lint`: passed after the whitespace fix.
- `cd frontend && npm run build`: passed locally; production bundle build remains green.
- Result: The branch was ready to push for CI re-run from a testing/build perspective.
### 2026-03-25
- Scope: Isolate and validate the medication enrichment lookup work as its own PR-ready feature branch.
- What changed:
- Kept the branch focused on medication enrichment backend lookup logic, the shared lookup section, desktop/mobile editor parity, lookup utilities, translations, and the matching documentation update.
- Repaired split-induced corruption in the shared MedicationsPage and MobileEditModal frontend tests so the feature branch is parse-clean and locally testable again.
- Preserved the dedicated medication enrichment backend test file and added the shared frontend utility file used by the grouped lookup flow.
- Validation:
- Backend changed-file Biome: passed.
- Frontend changed-file Biome: passed.
- Backend Vitest `backend/src/test/medication-enrichment.test.ts`: passed (`12` tests, `0` failures).
- Frontend Vitest targeted medication enrichment files: passed (`116` tests, `0` failures).
- Result: This branch was locally green and ready for upstream PR creation.
### 2026-03-25
- Scope: Reconcile PR #475 with the already merged stock/refill branch so the feature PR can merge cleanly on top of the new main.
- What changed:
- Kept the required doku history from both PR tracks while resolving the add/add conflicts in `doku/memory_notes.md` and `doku/report.md`.
- Combined the shared `frontend/src/test/pages/MedicationsPage.test.tsx` tail section so the medication enrichment tests and the already shipped stock-capacity list tests both remain present.
- Validation:
- Minimum frontend validation is rerun after conflict resolution before pushing the refreshed branch.
- Result: The feature branch is conflict-free locally and ready for the final revalidation/push cycle.
### 2026-03-25
- Scope: Review and merge the currently open Dependabot PRs.
- What changed:
- Reviewed the three open Dependabot PRs and verified each diff was limited to package manifest and lockfile updates.
- Confirmed the frontend and backend dependency-group PRs had green relevant checks before merge.
- Accepted the skipped frontend/backend/E2E jobs on the root-level Biome bump because the change was tooling-only at repository root scope.
- Squash-merged PRs `#468`, `#469`, and `#470`.
- Validation:
- Synced local `main` with `github/main` after the merges.
- Confirmed there are no remaining open Dependabot PRs in this reviewed batch.
- Result: All currently reviewed Dependabot updates are merged and local `main` matches the remote shipping branch again.
### 2026-03-25
- Scope: Prevent duplicate open weekly triage report issues.
- What changed:
- Confirmed the weekly triage workflow was creating a new report issue every Monday without closing older open weekly report issues first.
- Updated `.github/workflows/weekly-triage-report.yml` so older open `Weekly Triage Report - ...` issues are commented on and closed before the next report issue is created.
- Added an explicit weekly-report closure rule to `.github/agents/release-manager.agent.md`.
- Validation:
- Reviewed the current open weekly triage reports and confirmed both `#451` and `#471` were open before the workflow fix.
- Performed a local YAML parse check for the updated workflow.
- Result: Future weekly triage runs will keep only one open weekly report issue, and the release-manager guidance now states that requirement explicitly.
### 2026-03-26
- Scope: Deliver the CSS architecture modernization and recover the local Spec Kit workspace after cleanup.
- What changed:
- Shipped the CSS modernization through isolated issue/PR flow using a fresh worktree from `github/main`, resulting in merged PR `#481` for issue `#480`.
- Removed the temporary worktree and returned the main workspace to local `main` as requested.
- Confirmed the missing `.specify` and `specs` content had been stashed during cleanup rather than deleted, then restored those local-only Spec Kit artifacts from `stash@{0}`.
- Validation:
- Verified the stash contents included `.specify/`, `specs/001-css-monolith-modernization/`, `docs/SPEC_KIT.md`, and the generated Spec Kit agent/prompt files.
- Verified those paths exist again in the workspace after `git stash apply stash@{0}`.
- Result: The CSS PR is merged on `main`, the extra worktree is gone, and the local Spec Kit files needed for follow-up planning are present again.
### 2026-03-26
- Scope: Tighten the release-manager instructions after the cleanup-state misunderstanding.
- What changed:
- Updated `.github/agents/release-manager.agent.md` so `git stash` is explicitly limited to temporary transition use only.
- Added an explicit definition that a requested clean local `main` means no leftover tracked changes, no leftover untracked task files, and no stash being used as a substitute for actual cleanup.
- Added an end-of-flow verification step requiring an empty `git status` and no task-related stash residue when that clean end state is requested.
- Validation:
- Reviewed the updated agent rules in the release-manager file after the edit.
- Result: The release-manager guidance now matches the intended behavior and should not interpret "clean main" as "hide the leftovers in stash" again.
### 2026-03-26
- Scope: Ignore all current local Spec Kit artifacts so they stop appearing as repo changes.
- What changed:
- Added ignore rules for `.specify/`, `specs/`, `docs/SPEC_KIT.md`, `.github/agents/medassist-feature-orchestrator.agent.md`, `.github/agents/speckit.*.agent.md`, and `.github/prompts/speckit.*.prompt.md`.
- Validation:
- Reviewed the current Spec Kit-related untracked paths and matched them with explicit `.gitignore` entries.
- Result: The restored local Spec Kit setup is now treated as local-only workspace state instead of appearing as pending repo changes.
### 2026-03-26
- Scope: Repo-wide code-quality reporting audit across frontend and backend.
- What changed:
- Reviewed the largest backend and frontend source files for monolithic structure, duplicated business logic, swallowed errors, mixed responsibilities, and broad lint suppressions.
- Identified the highest-risk hotspots in notifications/reminders, schedule UI duplication, AppContext state orchestration, medication editing UI, and mixed-purpose backend utility/route modules.
- Prepared a reporting-only follow-up package: severity-ranked findings, a highest-ROI remediation plan, and a deeper analysis of notifications, AppContext, and schedule duplication.
- Validation:
- Cross-checked hotspot files with file-size data, targeted reads of the largest modules, repo-wide searches for `catch {}` and `biome-ignore-all`, and editor diagnostics for the main hotspot files.
- Result: The repo now has a concrete quality-risk map with prioritized refactor targets, without changing product behavior.
### 2026-03-26
- Scope: Persist the code-quality audit as a standalone markdown artifact under `doku/`.
- What changed:
- Added `doku/code-quality-audit-2026-03-26.md` with the audit method, executive summary, detailed findings, deeper focus areas, and refactor order by ROI.
- Validation:
- Ensured the written markdown reflects the previously reported findings and remains reporting-only.
- Result: The code-quality audit is now captured in a dedicated repo-local markdown document for future reference.
### 2026-03-26
- Scope: Review and merge the newly opened Dependabot PRs.
- What changed:
- Delegated the remote PR work to `@release-manager` per repository governance.
- Squash-merged PRs `#482`, `#483`, and `#484` after verifying they were dependency-only changes with acceptable CI state.
- Left PR `#485` open because its rerun was still in progress and not fully green yet.
- Validation:
- The release-manager review confirmed the merged PRs were dependency-only in scope.
- `#482` and `#483` had green relevant checks; `#484` was accepted as root-only tooling scope with skipped runtime jobs; `#485` was not merged because checks were still running.
- Result: Three Dependabot PRs are merged, and only `#485` remains open pending green checks.
### 2026-03-26
- Scope: Review and merge currently open Dependabot pull requests that are safe to ship.
- What changed:
- Reviewed the four open Dependabot PRs and confirmed each diff was dependency-only, limited to a single lockfile change with no suspicious mixed edits.
- Squash-merged PR `#483` (`picomatch` in `/frontend`), PR `#482` (`picomatch` in `/backend`), and PR `#484` (root `picomatch` dev dependency lockfile bump).
- Rebasing PR `#485` (`yaml` in `/backend`) onto the updated `main` after the backend lockfile changed from another merged Dependabot PR.
- Validation:
- Confirmed green relevant checks before merge for `#482`, `#483`, and `#484`, treating skipped frontend/backend/E2E jobs on the root-only lockfile update as acceptable for its tooling-only scope.
- Re-checked PR `#485` after the rebase and left it open because its refreshed Playwright E2E run was still in progress, so it was not yet fully green.
- Result: Three safe Dependabot PRs were merged; one remains open pending completion of its rerun checks.
### 2026-03-27
- Scope: Stabilize PR #490 (`test/e2e-stability-remediation`) after CI failures in `Frontend Build` and `Playwright E2E`.
- What changed:
- Fixed frontend formatting gate violation in `frontend/e2e/app-shell.spec.ts`.
- Fixed TypeScript check failures in `frontend/src/test/pages/MedicationsPage.test.tsx` by replacing nullable optional-callback resolvers with definite-assignment callbacks plus matching typed Promise resolvers.
- Stabilized dashboard dose-undo E2E flow in `frontend/e2e/dashboard-data.spec.ts` by waiting for seeded overview-table content before asserting `.day-block.today` and before post-reload undo assertions.
- Hardened E2E auth setup in `frontend/e2e/auth.setup.ts` to avoid unnecessary `/auth/register` calls that consume sensitive rate-limit quota; setup now attempts login first and only registers/retries as fallback.
- Validation:
- `cd frontend && CI=true npm run check`: passed.
- `cd frontend && CI=true npm run build`: passed.
- `cd frontend && PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=1 npx playwright test --config=playwright.stable.config.ts --workers=1 e2e/dashboard-data.spec.ts --grep "should undo a taken dose|should mark a dose as taken and show undo"`: passed (3/3, including setup).
- Result: Both originally failing CI scopes now reproduce cleanly with local targeted validation in the PR worktree.
### 2026-03-26
- Scope: Turn the code-quality audit into an implementation roadmap.
- What changed:
- Added `plan/refactor-code-quality-remediation-1.md` as a structured implementation plan derived from `doku/code-quality-audit-2026-03-26.md`.
- Split the remediation work into six phases covering notification refactoring, shared schedule UI extraction, AppContext splitting, large frontend component decomposition, backend module decomposition, and observability hardening.
- Defined concrete tasks, affected files, testing responsibilities, risks, and sequencing constraints for future execution.
- Validation:
- Ensured the plan remains reporting/planning-only and aligns with `AGENTS.md` constraints on PR scope and testing ownership.
- Result: The audit findings now have a concrete, phase-based implementation plan that can be executed incrementally.
### 2026-03-26
- Scope: Review the remediation plan and prepare it for execution handoff.
- What changed:
- Re-checked `plan/refactor-code-quality-remediation-1.md` against the audit and governance constraints.
- Added an `Execution Slices & Handoff` section so the next agent starts with a single PR-sized objective instead of the whole refactor roadmap.
- Marked Phase 1 as the first execution slice and documented the required follow-up handoffs to `@testing-manager` and `@release-manager`.
- Validation:
- Confirmed the first slice stays backend-only, matches the audit's top priority, and respects the repository's one-objective-per-PR rule.
- Result: The plan is now execution-ready and includes a concrete next-agent handoff path.
### 2026-03-26
- Scope: Break the remediation plan into executable checklist tasks.
- What changed:
- Added `plan/refactor-code-quality-remediation-tasks-1.md` as a task breakdown derived from the approved remediation plan and audit.
- Organized the work into setup, foundational prerequisites, six independently shippable remediation stories, and cross-cutting polish tasks.
- Added explicit per-story validation criteria, dependencies, parallel opportunities, and required handoff tasks to `@testing-manager` and `@release-manager`.
- Validation:
- Confirmed every task uses the required checklist format with task ID, optional parallel marker, story label where applicable, and exact file paths.
- Confirmed the task list stays aligned with the one-objective-per-PR rule and notes that the normal `.specify` branch-based prerequisite flow was unavailable on `main`.
- Result: The remediation plan is now broken into an execution-ready task list.
### 2026-03-26
- Scope: Apply the consistency remediations needed to make the remediation feature analyzable and execution-safe.
- What changed:
- Created a local feature branch `002-code-quality-remediation` so the Spec Kit prerequisite flow can resolve the feature formally.
- Added a minimal Spec Kit feature set under `specs/002-code-quality-remediation/` with `spec.md`, `plan.md`, and `tasks.md` derived from the approved audit and remediation plan.
- Tightened `plan/refactor-code-quality-remediation-1.md` with explicit slice validation requirements and narrower execution slices.
- Reworked `plan/refactor-code-quality-remediation-tasks-1.md` so only the reminder parity inventory remains blocking, later inventory work moved into the relevant slices, and each slice now has explicit local `check` and `build` validation before testing handoff.
- Validation:
- The feature now has the branch name and artifact layout expected by the Spec Kit prerequisite script.
- The MVP slice is no longer blocked by inventory work for unrelated later slices.
- Result: The remediation work is now represented both as a local planning set and as a minimal Spec Kit feature that is ready for formal prerequisite checks and follow-up analysis.
### 2026-03-26
- Scope: Implement US1 by consolidating reminder notification delivery across manual and scheduler paths.
- What changed:
- Added shared notification modules in `backend/src/services/notifications/` for SMTP delivery, push delivery, push payload builders, and reminder-state helpers.
- Refactored `backend/src/services/reminder-scheduler.ts` to use shared notification modules and removed duplicated local delivery logic.
- Refactored reminder endpoints in `backend/src/routes/planner.ts` to use shared email/push delivery and shared push builders.
- Refactored `backend/src/services/intake-reminder-scheduler.ts` to reuse shared delivery/state helpers.
- Validation:
- Ran `npm run check` in `backend/`; fixed remaining refactor leftovers (unused symbols and stale SMTP log field references), then re-ran successfully.
- Ran `npm run build` in `backend/`; build completed successfully after fixes.
- Result: Reminder notification handling is now centralized for the affected code paths, duplication is reduced, and backend check/build gates are green.
### 2026-03-26
- Scope: Testing ownership handoff for US1 reminder refactor.
- What changed:
- Delegated reminder regression planning to `@testing-manager` per repository governance.
- Received a focused, risk-based test plan covering manual planner reminders, scheduled reminders, and intake reminder flows.
- Captured targeted test commands, proposed gap tests, and a concise pass/fail checklist for PR validation notes.
- Result: Testing next steps are now prepared in executable form and aligned with ownership boundaries.
### 2026-03-26
- Scope: Continue remediation execution with the next task (US2/T016 schedule duplication inventory).
- What changed:
- Reviewed schedule rendering and interaction logic across `frontend/src/pages/DashboardPage.tsx`, `frontend/src/pages/SchedulePage.tsx`, and `frontend/src/components/SharedSchedule.tsx`.
- Documented concrete duplication touchpoints in `specs/002-code-quality-remediation/plan.md` under `US2 Inventory Notes (T016)`.
- Marked `T016` as completed in `specs/002-code-quality-remediation/tasks.md`.
- Result: The US2 extraction work now has a concrete duplication inventory baseline for T017-T022 implementation.
### 2026-03-26
- Scope: Implement US2/T017 shared schedule helper foundation.
- What changed:
- Added `frontend/src/features/schedule/formatters.ts` with reusable schedule usage-label formatting helpers.
- Added `frontend/src/features/schedule/storage.ts` with shared collapse-state load/save helpers for schedule surfaces.
- Marked `T017` as completed in `specs/002-code-quality-remediation/tasks.md`.
- Result: The common helper layer exists and is ready for the page-level rewiring tasks (`T019`-`T021`).
### 2026-03-26
- Scope: Implement US2/T018 shared schedule interaction helper foundation.
- What changed:
- Added `frontend/src/features/schedule/interactions.ts` with shared helpers for collapse-state decisions and dose progress counting.
- Marked `T018` as completed in `specs/002-code-quality-remediation/tasks.md`.
- Result: Interaction primitives are now available for the upcoming schedule page rewiring tasks.
### 2026-03-26
- Scope: Complete US2 rewiring tasks T019-T021 to use shared schedule helpers.
- What changed:
- Rewired `frontend/src/pages/DashboardPage.tsx` to use shared schedule formatter helpers.
- Rewired `frontend/src/pages/SchedulePage.tsx` to use shared schedule formatter helpers.
- Rewired `frontend/src/components/SharedSchedule.tsx` to use shared schedule formatter/storage/interaction helpers.
- Marked `T019`, `T020`, and `T021` as completed in `specs/002-code-quality-remediation/tasks.md`.
- Validation:
- Editor diagnostics reported no errors in the touched frontend files.
- Result: US2 helper consumption is now implemented across the three schedule surfaces.
### 2026-03-26
- Scope: Immediate execution sequence for adapting US1 reminder consolidation tests.
- What changed:
- Mapped currently relevant baseline suites to `backend/src/test/planner.test.ts` and `backend/src/test/intake-reminder-scheduler.test.ts`.
- Verified existing assertions for SMTP/push failure handling and identified missing direct unit coverage for consolidated modules (`backend/src/services/notifications/delivery.ts`, `backend/src/services/notifications/builders.ts`, `backend/src/services/notifications/state.ts`).
- Prepared a concrete run order for immediate execution: baseline targeted tests, add focused new unit tests for consolidated modules, rerun targeted suites, then run backend `check` and `build` as completion gate.
- Result: The testing handoff now includes a deterministic, command-ready sequence aligned with backend-only validation for this refactor slice.
### 2026-03-26
- Scope: Testing handoff validation for US2 schedule helper consolidation (T023).
- What changed:
- Ran a focused frontend Vitest parity set for schedule behavior across `DashboardPage`, `SchedulePage`, and `SharedSchedule`, including schedule and storage utility tests.
- Executed targeted Playwright schedule specs (`frontend/e2e/schedule.spec.ts` and `frontend/e2e/schedule-data.spec.ts`) in non-interactive mode.
- Re-ran frontend check gate (`npm run check`) to classify TypeScript blockers.
- Validation:
- Vitest targeted set passed: 6 files, 205 tests.
- Playwright targeted set: 22 passed, 1 failed (`should mark dose as taken and show undo` in `frontend/e2e/schedule-data.spec.ts`).
- Frontend check gate still fails on the same two existing MedicationsPage test typing errors (`frontend/src/test/pages/MedicationsPage.test.tsx` lines 887 and 1641).
- Result: Schedule parity refactor appears stable in targeted frontend tests, while the current check gate remains blocked by pre-existing MedicationsPage test TypeScript issues outside the US2 schedule scope.
### 2026-03-26
- Scope: Execute US3 AppContext decomposition tasks (T025-T031).
- What changed:
- Documented AppContext inventory and heavy-consumer seams in `specs/002-code-quality-remediation/plan.md` (`US3 Inventory Notes (T025)`).
- Added first extracted state boundary via `frontend/src/context/ShareContext.tsx` and integrated it in `frontend/src/context/AppContext.tsx` and `frontend/src/context/index.ts`.
- Added schedule orchestration hook `frontend/src/hooks/useScheduleController.ts` and exported it from `frontend/src/hooks/index.ts`.
- Migrated heavy consumers to smaller boundaries: `frontend/src/pages/DashboardPage.tsx`, `frontend/src/pages/SchedulePage.tsx`, and share-state consumption in `frontend/src/App.tsx`.
- Handed off AppContext regression validation to `@testing-manager`.
- Validation:
- Production-file editor diagnostics for touched US3 files are clean.
- `frontend` check gate remains blocked by known pre-existing MedicationsPage test typing errors in `frontend/src/test/pages/MedicationsPage.test.tsx`.
- Result: US3 decomposition structure is in place, heavy consumers started migration, and validation ownership handoff is completed with a targeted execution plan.
### 2026-03-26
- Scope: Continue with US4 decomposition tasks T032-T033.
- What changed:
- Documented desktop/mobile medication-edit parity touchpoints in `specs/002-code-quality-remediation/plan.md` (`US4 Inventory Notes (T032)`).
- Added `frontend/src/hooks/useMedicationEnrichmentController.ts` for extracted medication enrichment state management.
- Rewired `frontend/src/pages/MedicationsPage.tsx` to consume the extracted enrichment controller hook.
- Marked `T032` and `T033` as completed in `specs/002-code-quality-remediation/tasks.md`.
- Result: US4 enrichment state management now has a dedicated hook boundary and parity inventory baseline for the remaining decomposition tasks.
### 2026-03-26
- Scope: Testing handoff validation for US3 AppContext decomposition boundaries.
- What changed:
- Re-ran frontend check gate (`npm run check`) to classify current blocker status.
- Ran focused Vitest coverage for share/schedule behavior (`frontend/src/test/pages/SchedulePage.test.tsx` and `frontend/src/test/components/ShareDialog.test.tsx`).
- Ran non-interactive targeted Playwright coverage for user-facing schedule/share flows (`frontend/e2e/schedule.spec.ts` and `frontend/e2e/share-schedule.spec.ts`) with stable CI-style settings.
- Executed broader targeted Vitest command including `App.test.tsx` and `DashboardPage.test.tsx` to verify boundary-extraction test impacts.
- Validation:
- Frontend check remains blocked only by existing TypeScript errors in `frontend/src/test/pages/MedicationsPage.test.tsx` lines 887 and 1641.
- Focused Vitest slice passed: 2 files, 47 tests.
- Targeted Playwright slice passed: 23 tests.
- `App.test.tsx` and `DashboardPage.test.tsx` fail due stale mocks missing `useShareContext` in mocked `context` modules.
- Result: No browser-level regression signal in schedule/share user flows; current blockers are (1) unrelated baseline MedicationsPage typing errors and (2) required test-mock updates for the new ShareContext boundary.
### 2026-03-26
- Scope: Implement US7/T052 observability hardening in frontend settings refresh paths.
- What changed:
- Updated `frontend/src/hooks/useSettings.ts` to replace swallowed failures in reminder-status refresh and keepalive settings flush paths.
- Added structured warning logs (`[useSettings] reminder status refresh failed`, `[useSettings] keepalive settings flush failed`) with normalized error-message payloads.
- Added a local `getErrorMessage` helper to safely convert unknown caught values to strings for logging.
- Validation:
- Editor diagnostics for `frontend/src/hooks/useSettings.ts` show no errors after the update.
- Result: Refresh-related failures in settings flow are now visible in logs instead of being silently discarded.
### 2026-03-26
- Scope: Implement US7/T053 and T054 observability hardening in auth and intake scheduler paths.
- What changed:
- Updated `backend/src/plugins/auth.ts` optional-auth flow to add intentional debug logging for verification outcomes (session success/failure and API-key success/failure categories).
- Updated `backend/src/services/intake-reminder-scheduler.ts` so intake reminder state-file read/parse failures are logged with file path and normalized error detail before fallback state initialization.
- Marked `T053` and `T054` as completed in `specs/002-code-quality-remediation/tasks.md`.
- Validation:
- Editor diagnostics show no errors in `backend/src/plugins/auth.ts` and `backend/src/services/intake-reminder-scheduler.ts`.
- Result: Optional-auth and state-file failure paths now produce actionable diagnostics instead of silent failure behavior.
### 2026-03-26
- Scope: Implement US7/T055 by removing remaining broad silent catches in known hotspot files.
- What changed:
- Updated `frontend/src/hooks/useSettings.ts` to log structured warnings in `performSave`, `testEmail`, and `testShoutrrr` failure paths.
- Updated `backend/src/services/medication-enrichment.ts` to log startup/scheduled EMA refresh catch failures instead of swallowing them.
- Marked `T055` as completed in `specs/002-code-quality-remediation/tasks.md`.
- Validation:
- Editor diagnostics show no errors in touched files.
- Pattern search in hotspot files finds no remaining `catch {}` or `.catch(() => undefined)` signatures.
- Result: Broad catch anti-patterns from the documented hotspot set are now replaced by explicit, actionable handling.
### 2026-03-26
- Scope: Testing-manager validation planning for US7 observability hardening slice on branch `002-code-quality-remediation`.
- What changed:
- Reviewed US7 touched files and mapped each new observability path to existing backend/frontend test coverage.
- Identified missing direct assertions for optional-auth verification logs (`backend/src/plugins/auth.ts`) and enrichment scheduler catch logs (`backend/src/services/medication-enrichment.ts`).
- Classified the known frontend TypeScript check failure in `frontend/src/test/pages/MedicationsPage.test.tsx` as pre-existing and outside US7 file scope.
- Validation:
- Confirmed existing local gates already reported as passing for backend (`npm run check`, `npm run build`) and frontend build (`npm run build`).
- Confirmed frontend global check remains blocked by existing MedicationsPage test typing issues at lines 887 and 1641.
- Result: Provided a targeted test command set, high-risk add-test recommendations, and a conditional pass recommendation for US7 pending focused regression/observability tests.
### 2026-03-26
- Scope: Execute US7/T056 and T057 completion gates and testing handoff.
- What changed:
- Ran required frontend/backend gate commands before handoff:
- `cd frontend && npm run check`
- `cd frontend && npm run build`
- `cd backend && npm run check && npm run build`
- Fixed newly surfaced frontend gate issues (`unused type import` in `MedicationsPage.tsx`, export ordering in `hooks/index.ts`) and re-ran frontend check.
- Delegated US7 observability validation to `@testing-manager` and captured the targeted regression strategy plus blocker classification.
- Marked `T056` and `T057` as completed in `specs/002-code-quality-remediation/tasks.md`.
- Validation:
- Frontend check remains blocked by known pre-existing TypeScript errors in `frontend/src/test/pages/MedicationsPage.test.tsx` lines 887 and 1641.
- Frontend build passed.
- Backend check and build passed.
- Result: US7 implementation and mandatory pre-handoff validation/handoff steps are complete; remaining blocker is the known baseline frontend test typing issue outside US7 scope.
### 2026-03-26
- Scope: Complete cross-cutting closure tasks T058-T060 for the current remediation continuation.
- What changed:
- Updated cross-slice progress logs in `doku/memory_notes.md` and `doku/report.md` (T058).
- Reconciled remediation status across `specs/002-code-quality-remediation/tasks.md`, `plan/refactor-code-quality-remediation-tasks-1.md`, and `plan/refactor-code-quality-remediation-1.md` (T059).
- Updated plan execution status to `In Progress` and added a current execution snapshot in `plan/refactor-code-quality-remediation-1.md`.
- Handed off completed slice summaries, validation snapshot, and PR-prep checklist context to `@release-manager` (T060).
- Validation:
- Status checklists for US7 and cross-cutting tasks are aligned across the active spec and plan task artifacts.
- Blocker classification remains unchanged: known pre-existing frontend test typing errors in `frontend/src/test/pages/MedicationsPage.test.tsx` lines 887 and 1641.
- Result: US7 plus cross-cutting closure tasks for this continuation are fully completed and handed off with consistent status tracking.
### 2026-03-26
- Scope: Normalize historical task checkbox state to reflect already implemented slices.
- What changed:
- Marked completed setup/foundational/US1/US2/US3 tasks as done in `specs/002-code-quality-remediation/tasks.md` where implementation and handoff evidence already existed.
- Mirrored those completion states in `plan/refactor-code-quality-remediation-tasks-1.md` for status consistency.
- Kept only genuinely pending work open.
- Validation:
- Remaining open tasks in the active remediation spec are now reduced to:
- US4: `T034`-`T039`
- US5: `T040`, `T041`, `T044`
- US6: `T045`-`T051`
- Result: Task tracking now reflects actual implementation state and cleanly isolates the remaining decomposition backlog.
### 2026-03-26
- Scope: Implement US4/T034 medication list orchestration extraction.
- What changed:
- Added `frontend/src/components/medications/MedicationListSection.tsx` and moved medication grid + obsolete section orchestration from `MedicationsPage` into this focused component.
- Rewired `frontend/src/pages/MedicationsPage.tsx` to consume `MedicationListSection` through explicit props and callbacks for edit/view/delete/reactivate/image-preview actions.
- Marked `T034` as completed in `specs/002-code-quality-remediation/tasks.md` and `plan/refactor-code-quality-remediation-tasks-1.md`.
- Validation:
- Editor diagnostics show no errors in `frontend/src/components/medications/MedicationListSection.tsx` and `frontend/src/pages/MedicationsPage.tsx`.
- Result: Medication list rendering/orchestration is now separated from the page-level edit/modals flow, reducing `MedicationsPage` responsibility while preserving current UI behavior.
### 2026-03-26
- Scope: Complete US5/T040 decomposition inventory for medication enrichment service.
- What changed:
- Added `US5 Inventory Notes (T040)` to `specs/002-code-quality-remediation/plan.md` for `backend/src/services/medication-enrichment.ts`.
- Documented concrete responsibility clusters and extraction seams: remote adapters, parsing/normalization, search/ranking, enrichment assembly, and lifecycle/scheduler runtime.
- Captured the target split direction for the next task (`T041`) into `backend/src/services/medication-enrichment/{adapters.ts,search.ts,index.ts}`.
- Marked `T040` complete in both task trackers.
- Result: US5 implementation now has an explicit seam map for the upcoming module split, reducing risk for the next backend refactor step.
### 2026-03-26
- Scope: Complete US6/T045 decomposition inventory for backend utility and route modules.
- What changed:
- Added `US6 Inventory Notes (T045)` in `specs/002-code-quality-remediation/plan.md` for `backend/src/db/db-utils.ts`, `backend/src/routes/medications.ts`, `backend/src/routes/planner.ts`, and `backend/src/routes/settings.ts`.
- Documented concrete split seams for migration/repair helpers, medication route business logic, notification rendering/dispatch helpers, and settings/shoutrrr concerns.
- Captured coupling/parity constraints required for subsequent US6 implementation tasks.
- Marked `T045` complete in both remediation task trackers.
- Result: US6 now has a concrete, risk-aware seam inventory to guide extraction tasks T046-T051.
### 2026-03-26
- Scope: Implement US4/T035 medication edit orchestration extraction.
- What changed:
- Added `frontend/src/components/medications/MedicationEditCoordinator.tsx` as the desktop edit-panel orchestration shell (sidebar/card head/form wrapper).
- Rewired `frontend/src/pages/MedicationsPage.tsx` to use `MedicationEditCoordinator` and keep the detailed form field content nested as child layout.
- Kept `MedicationListSection` extraction integrated and updated barrel exports in `frontend/src/components/index.ts`.
- Marked `T035` complete in both remediation task trackers.
- Validation:
- Focused Biome check passed for:
- `frontend/src/pages/MedicationsPage.tsx`
- `frontend/src/components/medications/MedicationEditCoordinator.tsx`
- `frontend/src/components/medications/MedicationListSection.tsx`
- `frontend/src/components/index.ts`
- Result: `MedicationsPage` orchestration is further decomposed by separating desktop edit shell responsibilities from page-level state and field logic.
### 2026-03-26
- Scope: Implement US4/T036 modal/report decomposition in medication edit flow.
- What changed:
- Added `frontend/src/components/medications/MedicationDialogs.tsx` to centralize dialog concerns for:
- unsaved-changes confirmation
- obsolete confirmation
- delete confirmation
- image lightbox
- report modal
- Rewired `frontend/src/pages/MedicationsPage.tsx` so `MobileEditModal` is passed as `mobileEditModal` into `MedicationDialogs` and all dialog props/callbacks are controlled from the page orchestrator.
- Marked `T036` complete in both `specs/002-code-quality-remediation/tasks.md` and `plan/refactor-code-quality-remediation-tasks-1.md`.
- Validation:
- Focused Biome check passed for:
- `frontend/src/pages/MedicationsPage.tsx`
- `frontend/src/components/medications/MedicationDialogs.tsx`
- `frontend/src/components/medications/MedicationEditCoordinator.tsx`
- `frontend/src/components/medications/MedicationListSection.tsx`
- `frontend/src/components/index.ts`
- Result: Modal/report rendering is now separated from form/list orchestration in `MedicationsPage`, reducing page-level UI responsibility while preserving behavior.
### 2026-03-26
- Scope: US4/T037 initial attempt status.
- What changed:
- Began a first extraction attempt for dashboard reminder/status sections.
- Reverted `frontend/src/pages/DashboardPage.tsx` to the stable pre-attempt state after detecting malformed intermediate edits.
- Removed unfinished draft dashboard extraction component files to keep the branch free of partial, unused code.
- Result: T037 remains open and deferred for a clean follow-up implementation step.
### 2026-03-26
- Scope: Complete remaining US4/US5/US6 tasks (`T037-T039`, `T041`/`T044`, `T046-T051`) for branch `002-code-quality-remediation`.
- What changed:
- Repaired and finalized dashboard decomposition:
- integrated `frontend/src/components/dashboard/DashboardReminderSection.tsx`
- integrated `frontend/src/components/dashboard/DashboardStatusSection.tsx`
- rewired `frontend/src/pages/DashboardPage.tsx` to use extracted sections.
- Completed backend utility/route decomposition delivery:
- split DB helpers into `backend/src/db/path-utils.ts`, `backend/src/db/migration-utils.ts`, and `backend/src/db/repair-utils.ts`
- converted `backend/src/db/db-utils.ts` to compatibility barrel exports
- extracted route helper/business logic into `backend/src/services/medications-service.ts`, `backend/src/services/planner-service.ts`, and `backend/src/services/settings-service.ts`
- completed medication-enrichment module split surface under `backend/src/services/medication-enrichment/{adapters.ts,search.ts,index.ts}` and updated route/startup imports.
- Reconciled task trackers:
- marked `T037-T039`, `T041`/`T044`, and `T046-T051` complete in both active task files.
- Validation:
- Frontend gate (`T038`):
- `cd frontend && npm run check` fails on known pre-existing baseline test typing issues in `frontend/src/test/pages/MedicationsPage.test.tsx` (lines 887 and 1641).
- `cd frontend && npm run build` passed.
- Backend gate (`T050`):
- `cd backend && npm run check && npm run build` passed.
- Handoff:
- Recorded testing-manager handoff scope for:
- `T039` desktop/mobile medication-edit parity validation
- `T044` medication-enrichment regression planning/validation
- `T051` backend DB/route decomposition regression planning.
- Result: All requested remaining implementation tasks for US4/US5/US6 are completed in code with required trackers/reporting updates and recorded gate outcomes; residual blocker remains the known pre-existing frontend test typing issue outside this slice.
### 2026-03-26
- Scope: Implement missing test evidence for `T039`, `T044`, and `T051`.
- What changed:
- Added frontend decomposition parity tests:
- `frontend/src/test/components/MedicationEditCoordinator.test.tsx`
- `frontend/src/test/components/MedicationDialogs.test.tsx`
- Extended backend medication enrichment regression coverage in `backend/src/test/medication-enrichment.test.ts`:
- split-module export parity checks for `services/medication-enrichment/{index,search,adapters}.ts`
- route-level transport failure contract assertion for `/medication-enrichment/search`
- Added backend extracted-service regression coverage in `backend/src/test/decomposition-services.test.ts` for:
- `backend/src/services/medications-service.ts`
- `backend/src/services/planner-service.ts`
- `backend/src/services/settings-service.ts`
- Updated DB helper regression expectation in `backend/src/test/database.test.ts` to assert no `.write-test` residue is left by `ensureDataDirectory`.
- Validation:
- `cd frontend && CI=true npm run test:run -- src/test/components/MedicationEditCoordinator.test.tsx src/test/components/MedicationDialogs.test.tsx src/test/components/MobileEditModal.test.tsx` -> passed (`3` files, `71` tests).
- `cd backend && CI=true npm run test:run -- src/test/decomposition-services.test.ts src/test/medication-enrichment.test.ts src/test/database.test.ts src/test/medications.test.ts src/test/planner.test.ts src/test/settings.test.ts` -> passed (`6` files, `160` tests).
- `cd frontend && npm run check && npm run build` -> failed on known baseline blocker in `frontend/src/test/pages/MedicationsPage.test.tsx` (`TS2349` at lines `887` and `1641`), unchanged by this work.
- `cd backend && npm run check && npm run build` -> passed.
- Result: Concrete regression evidence is now present for T039/T044/T051 with targeted tests and passing backend/frontend test subsets; only the known pre-existing frontend TypeScript blocker remains for full frontend check gate.
### 2026-03-26
- Scope: Remove remaining test blockers and deliver fully green backend/frontend/E2E validation.
- What changed:
- Fixed backend false-negative bootstrap tests by updating stale module mocks in `backend/src/test/db-client.test.ts` to match the split DB utility imports now used by `backend/src/db/client.ts`.
- Hardened backend test runtime defaults in `backend/src/test/setup.ts` so local `.env` values cannot leak into suite execution (`DOTENV_PATH` + explicit auth/oidc defaults + reset in `afterEach`).
- Updated frontend test mocks for the App/Medications decompositions:
- `frontend/src/test/App.test.tsx`: switched share-dialog assertions from app context to share context (`useShareContext`).
- `frontend/src/test/pages/MedicationsPage.test.tsx`: switched hooks barrel mock to partial real exports and added a deterministic `MedicationDialogs` mock so unsaved/obsolete/report flows are asserted against the current composition.
- Validation:
- `cd backend && CI=true npm run test:run` -> passed (`25` files, `639` tests).
- `cd frontend && CI=true npm run test:run` -> passed (`47` files, `881` tests).
- `cd frontend && PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=1 npm run test:e2e -- --workers=1` -> passed (stable E2E suite, exit code `0`).
- `cd backend && npm run check` -> passed.
- `cd frontend && npm run check` -> passed.
- Result: Full local validation is green across backend tests, frontend tests, stable Playwright E2E, and both static check gates.
### 2026-03-26
- Scope: Start broad Playwright expansion to cover additional app-shell and public-route behavior, then harden flaky E2E checks.
- What changed:
- Added `frontend/e2e/app-shell.spec.ts` with new scenarios for:
- user menu -> profile modal open/close
- user menu -> about modal open/close
- user menu -> sign out flow
- public redirect `/share/:token/overview` to `/share/:token`
- Stabilized failing E2E cases:
- `frontend/e2e/dashboard-data.spec.ts`: hardened take/undo flow with POST response synchronization + reload-based verification.
- `frontend/e2e/schedule-data.spec.ts`: hardened take/undo assertion timing and server-ack synchronization.
- `frontend/e2e/planner-data.spec.ts`: replaced brittle fixed-number stock assertion with dynamic but still meaningful stock-detail checks.
- `frontend/e2e/settings.spec.ts`: made calculation-mode toggle test robust against hidden-radio/input and auto-save timing behavior.
- Validation:
- Re-ran `E2E stable non-interactive` after each fix cycle.
- Final stable run: `157 passed`, `4 skipped`, `0 failed`.
- Result: Playwright coverage now includes additional shell-level behaviors and the previously failing stable-suite tests are resolved; current stable suite exits without failures.
+64 -13
View File
@@ -1,10 +1,35 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { type APIResponse, type Cookie, expect, test as setup } from "@playwright/test";
import { type APIResponse, expect, type Page, test as setup } from "@playwright/test";
import { applyVideoSafetyMode, TEST_USER } from "./fixtures";
const authFile = path.join(import.meta.dirname, ".auth", "user.json");
type StoredAuthCookie = {
name: string;
value: string;
domain: string;
path: string;
expires: number;
httpOnly: boolean;
secure: boolean;
sameSite: "Strict" | "Lax" | "None";
};
type BrowserCookie = {
name: string;
value: string;
url: string;
expires?: number;
httpOnly: boolean;
secure: boolean;
sameSite: "Strict" | "Lax" | "None";
};
type StoredAuthState = {
cookies?: StoredAuthCookie[];
};
/**
* Check if a JWT token is still valid (not expired) without making a
* network request. Returns `true` when the token has at least 2 minutes
@@ -21,7 +46,7 @@ function isTokenValid(token: string): boolean {
}
}
function toBrowserCookie(setCookieHeader: string, baseURL: string): Cookie | null {
function toBrowserCookie(setCookieHeader: string, baseURL: string): BrowserCookie | null {
const segments = setCookieHeader
.split(";")
.map((segment) => segment.trim())
@@ -36,7 +61,7 @@ function toBrowserCookie(setCookieHeader: string, baseURL: string): Cookie | nul
return null;
}
const cookie: Cookie = {
const cookie: BrowserCookie = {
name: nameValue.slice(0, separatorIndex),
value: nameValue.slice(separatorIndex + 1),
url: baseURL,
@@ -90,16 +115,12 @@ function toBrowserCookie(setCookieHeader: string, baseURL: string): Cookie | nul
return cookie;
}
async function syncResponseCookiesToBrowserContext(
page: Parameters<Parameters<typeof setup>[0]>[0]["page"],
baseURL: string,
response: APIResponse
): Promise<void> {
async function syncResponseCookiesToBrowserContext(page: Page, baseURL: string, response: APIResponse): Promise<void> {
const cookies = response
.headersArray()
.filter((header) => header.name.toLowerCase() === "set-cookie")
.map((header) => toBrowserCookie(header.value, baseURL))
.filter((cookie): cookie is Cookie => cookie !== null);
.filter((cookie): cookie is BrowserCookie => cookie !== null);
if (cookies.length > 0) {
await page.context().addCookies(cookies);
@@ -120,6 +141,7 @@ async function syncResponseCookiesToBrowserContext(
setup("authenticate", async ({ page }) => {
setup.setTimeout(120000);
await applyVideoSafetyMode(page);
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
// Create .auth directory if it doesn't exist
const authDir = path.dirname(authFile);
@@ -130,11 +152,41 @@ setup("authenticate", async ({ page }) => {
// ---- 1. Try to reuse an existing auth file (offline check only) ----
if (fs.existsSync(authFile)) {
try {
const saved = JSON.parse(fs.readFileSync(authFile, "utf-8"));
const saved = JSON.parse(fs.readFileSync(authFile, "utf-8")) as StoredAuthState;
const accessCookie = saved.cookies?.find((c: { name: string }) => c.name === "access_token");
const refreshCookie = saved.cookies?.find((c: { name: string }) => c.name === "refresh_token");
if (saved.cookies?.length) {
await page.context().addCookies(saved.cookies);
}
if (accessCookie?.value && isTokenValid(accessCookie.value)) {
// Keep going and verify the session online. A JWT can be time-valid but
// still rejected by backend token rotation/restart.
const hasSavedSession = await page.request
.get(`${baseURL}/api/auth/me`)
.then((response) => response.ok())
.catch(() => false);
if (hasSavedSession) {
await page.context().storageState({ path: authFile });
return;
}
}
if (refreshCookie?.value) {
const refreshResponse = await page.request.post(`${baseURL}/api/auth/refresh`).catch(() => null);
if (refreshResponse?.ok()) {
await syncResponseCookiesToBrowserContext(page, baseURL, refreshResponse);
const refreshedSession = await page.request
.get(`${baseURL}/api/auth/me`)
.then((response) => response.ok())
.catch(() => false);
if (refreshedSession) {
await page.context().storageState({ path: authFile });
return;
}
}
}
} catch {
// Invalid file — fall through to regular login
@@ -143,7 +195,6 @@ setup("authenticate", async ({ page }) => {
// ---- 2. Fast path: already authenticated session ----
await page.goto("/");
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
let authEnabled = true;
let formLoginEnabled = true;
let oidcEnabled = false;
+19 -4
View File
@@ -289,6 +289,7 @@ export interface TestShareToken {
token: string;
takenBy: string;
scheduleDays: number;
allowJournalNotes?: boolean;
expiresAt: string;
}
@@ -303,7 +304,7 @@ export async function createMedicationViaAPI(data: {
takenBy?: string[];
notes?: string;
expiryDate?: string;
packageType?: "blister" | "bottle" | "tube" | "liquid_container";
packageType?: "blister" | "bottle" | "tube" | "liquid_container" | "inhaler" | "injection";
medicationForm?: "capsule" | "tablet" | "liquid" | "topical";
packCount?: number;
blistersPerPack?: number;
@@ -323,7 +324,12 @@ export async function createMedicationViaAPI(data: {
let token = await ensureAuthCookie();
const apiBase = await getRuntimeApiBase();
const packageType = data.packageType ?? "blister";
const isAmountBased = packageType === "bottle" || packageType === "tube" || packageType === "liquid_container";
const isAmountBased =
packageType === "bottle" ||
packageType === "tube" ||
packageType === "liquid_container" ||
packageType === "inhaler" ||
packageType === "injection";
let defaultMedicationForm: "capsule" | "tablet" | "liquid" | "topical" = "tablet";
if (packageType === "tube") {
defaultMedicationForm = "topical";
@@ -455,7 +461,11 @@ export async function deleteAllMedicationsViaAPI(): Promise<void> {
* Create a share token via the backend API.
* Requires a medication with takenBy to exist first.
*/
export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30): Promise<TestShareToken> {
export async function createShareTokenViaAPI(
takenBy: string,
scheduleDays = 30,
options: { allowJournalNotes?: boolean; expiryDays?: number | null } = {}
): Promise<TestShareToken> {
let token = await ensureAuthCookie();
const apiBase = await getRuntimeApiBase();
for (let attempt = 0; attempt < 5; attempt++) {
@@ -465,7 +475,12 @@ export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30)
"Content-Type": "application/json",
...(token ? { Cookie: `access_token=${token}` } : {}),
},
body: JSON.stringify({ takenBy, scheduleDays }),
body: JSON.stringify({
takenBy,
scheduleDays,
expiryDays: options.expiryDays ?? null,
allowJournalNotes: options.allowJournalNotes ?? false,
}),
});
if (res.status === 401) {
token = await refreshAuthCookieViaLogin();
+48 -11
View File
@@ -26,7 +26,7 @@ async function fillAndSaveMedication(
opts: {
name: string;
genericName?: string;
packageType?: "blister" | "bottle" | "tube" | "liquid_container";
packageType?: "blister" | "bottle" | "tube" | "liquid_container" | "inhaler" | "injection";
packs?: string;
blistersPerPack?: string;
pillsPerBlister?: string;
@@ -50,12 +50,17 @@ async function fillAndSaveMedication(
}
const packageTypeSelect = form.locator("select.package-type-select");
if (opts.packageType === "bottle") {
await packageTypeSelect.selectOption("bottle");
if (opts.packageType === "bottle" || opts.packageType === "inhaler" || opts.packageType === "injection") {
await packageTypeSelect.selectOption(opts.packageType ?? "bottle");
await page.getByRole("tab", { name: /Package/i }).click();
if (opts.totalCapacity)
await form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i).fill(opts.totalCapacity);
if (opts.currentPills) await form.getByLabel(/(Current Pills|form\.currentPills)/i).fill(opts.currentPills);
await form
.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\)|Total \(count\)|form\.totalCount)/i)
.fill(opts.totalCapacity);
if (opts.currentPills)
await form
.getByLabel(/(Current Pills|form\.currentPills|Current Stock|form\.currentStockCount)/i)
.fill(opts.currentPills);
} else if (opts.packageType === "tube") {
await packageTypeSelect.selectOption("tube");
await page.getByRole("tab", { name: /Package/i }).click();
@@ -95,12 +100,12 @@ async function fillAndSaveMedication(
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
}
const row = form.locator(".blister-row").nth(i);
await row
.getByLabel(
/(Usage \((pills|tablets|capsules|ml|applications)\)|form\.blisters\.(usage|usageTablets|usageCapsules|usageMl|usageApplication))/i
)
.fill(intakes[i].usage);
await row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill(intakes[i].every);
const usageField = row.getByRole("textbox", {
name: /(Usage|Tablets|Capsules|Applications|Puffs|Injections|Ml|form\.blisters\.usage|common\.(puffs|injections))/i,
});
const everyField = row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i);
await usageField.fill(intakes[i].usage);
await everyField.fill(intakes[i].every);
}
await page.waitForLoadState("networkidle");
@@ -195,6 +200,38 @@ test.describe("Medication CRUD", () => {
});
});
test("should create an inhaler medication via the form", async ({ page }) => {
await navigateTo(page, "/medications");
await fillAndSaveMedication(page, {
name: "Test Rescue Inhaler",
packageType: "inhaler",
totalCapacity: "200",
currentPills: "120",
intakes: [{ usage: "2", every: "1" }],
});
const medRow = page.locator(".med-row").filter({ hasText: "Test Rescue Inhaler" });
await expect(medRow.locator(".med-details")).toContainText(/Inhaler|form\.packageTypeInhaler/i);
await expect(medRow.locator(".med-total")).toContainText("120 / 200");
});
test("should create an injection medication via the form", async ({ page }) => {
await navigateTo(page, "/medications");
await fillAndSaveMedication(page, {
name: "Test Weekly Injection",
packageType: "injection",
totalCapacity: "12",
currentPills: "4",
intakes: [{ usage: "1", every: "7" }],
});
const medRow = page.locator(".med-row").filter({ hasText: "Test Weekly Injection" });
await expect(medRow.locator(".med-details")).toContainText(/Injection|form\.packageTypeInjection/i);
await expect(medRow.locator(".med-total")).toContainText("4 / 12");
});
test("should create medication with multiple intake schedules", async ({ page }) => {
await navigateTo(page, "/medications");
+137 -14
View File
@@ -33,6 +33,28 @@ async function clickEditMed(page: Page, medName: string): Promise<void> {
});
}
async function openMedicationDetailFromDashboard(page: Page, medName: string) {
const overviewTable = page.locator(".dashboard-overview-section .table").first();
for (let attempt = 0; attempt < 3; attempt++) {
try {
await expect(overviewTable).toBeVisible({ timeout: 10000 });
const medRow = overviewTable.locator(".table-row").filter({ hasText: medName });
await expect(medRow).toBeVisible({ timeout: 10000 });
await medRow.click();
const modal = page.locator(".modal-content.med-detail-modal");
await expect(modal).toBeVisible({ timeout: 5000 });
await expect(modal.getByText(medName)).toBeVisible({ timeout: 5000 });
return modal;
} catch {
if (attempt === 2) throw new Error(`Failed to open dashboard medication detail for ${medName}`);
await page.reload();
await page.waitForLoadState("networkidle");
}
}
throw new Error(`Failed to open dashboard medication detail for ${medName}`);
}
/** Helper: save edit and verify success */
async function saveEditAndVerify(page: Page, medName: string): Promise<void> {
const form = page.locator("form.form-grid:visible").first();
@@ -310,24 +332,107 @@ test.describe("Medication Editing", () => {
// Find the remind checkbox in the intake row
const intakeRow = page.locator(".blister-row").first();
const remindCheckbox = intakeRow.locator('input[type="checkbox"]');
const remindToggle = intakeRow.locator(".toggle-switch");
const remindCheckbox = intakeRow.locator('.toggle-switch input[type="checkbox"]');
if (await remindCheckbox.isVisible().catch(() => false)) {
// Should be unchecked initially
await expect(remindCheckbox).not.toBeChecked();
await remindToggle.click();
await expect(remindCheckbox).toBeChecked();
await saveEditAndVerify(page, "Reminder Toggle Med");
// Verify reminder was saved
await clickEditMed(page, "Reminder Toggle Med");
const savedCheckbox = page.locator(".blister-row").first().locator('.toggle-switch input[type="checkbox"]');
await expect(savedCheckbox).toBeChecked();
});
for (const scenario of [
{
name: "Inhaler Reminder Refill Med",
packageType: "inhaler" as const,
totalCapacity: 200,
currentStock: 120,
refillAmount: 30,
expectedStock: 150,
unitLabel: /puffs?|common\.puffs?/i,
},
{
name: "Injection Reminder Refill Med",
packageType: "injection" as const,
totalCapacity: 12,
currentStock: 4,
refillAmount: 3,
expectedStock: 7,
unitLabel: /injections?|common\.injections?/i,
},
]) {
test(`should persist reminders and refill ${scenario.packageType} stock without drift`, async ({ page }) => {
createdMeds.push(
await createMedicationViaAPI({
name: scenario.name,
packageType: scenario.packageType,
totalPills: scenario.totalCapacity,
looseTablets: scenario.currentStock,
intakes: [
{
usage: 1,
every: 1,
start: new Date().toISOString().slice(0, 16),
intakeRemindersEnabled: false,
},
],
})
);
await navigateTo(page, "/medications");
await clickEditMed(page, scenario.name);
await page.getByRole("tab", { name: /Schedule/i }).click();
const intakeRow = page.locator(".blister-row").first();
const remindToggle = intakeRow.locator(".toggle-switch");
const remindCheckbox = intakeRow.locator('.toggle-switch input[type="checkbox"]');
await expect(remindCheckbox).not.toBeChecked();
// Enable it
await remindCheckbox.check();
await remindToggle.click();
await expect(remindCheckbox).toBeChecked();
await saveEditAndVerify(page, "Reminder Toggle Med");
await saveEditAndVerify(page, scenario.name);
// Verify reminder was saved
await clickEditMed(page, "Reminder Toggle Med");
const savedCheckbox = page.locator(".blister-row").first().locator('input[type="checkbox"]');
await expect(savedCheckbox).toBeChecked();
}
});
await clickEditMed(page, scenario.name);
await page.getByRole("tab", { name: /Schedule/i }).click();
await expect(page.locator(".blister-row").first().locator('.toggle-switch input[type="checkbox"]')).toBeChecked();
await navigateTo(page, "/dashboard");
const modal = await openMedicationDetailFromDashboard(page, scenario.name);
await modal.getByRole("button", { name: /Refill|refill\.button/i }).click();
const refillModal = page.locator(".modal-content.refill-modal");
await expect(refillModal).toBeVisible({ timeout: 5000 });
const refillInput = refillModal.locator('input[type="number"]').first();
await refillInput.fill(String(scenario.refillAmount));
await expect(refillModal.locator(".refill-preview")).toContainText(`+${scenario.refillAmount}`);
await expect(refillModal.locator(".refill-preview")).toContainText(scenario.unitLabel);
await refillModal.locator(".modal-footer .success").click();
await expect(refillModal).not.toBeVisible({ timeout: 10000 });
const refillHistoryHeader = modal.locator(".med-detail-section h3").filter({
hasText: /Refill History|refill\.history/i,
});
await expect(refillHistoryHeader).toBeVisible({ timeout: 10000 });
await refillHistoryHeader.click();
const refillAmount = modal.locator(".refill-history-item .refill-amount").first();
await expect(refillAmount).toContainText(`+${scenario.refillAmount}`);
await expect(refillAmount).toContainText(scenario.unitLabel);
await page.locator("button.modal-close").click();
await expect(modal).not.toBeVisible({ timeout: 5000 });
await navigateTo(page, "/medications");
const medRow = page.locator(".med-row").filter({ hasText: scenario.name });
await expect(medRow.locator(".med-total")).toContainText(`${scenario.expectedStock} / ${scenario.totalCapacity}`);
});
}
test("should change package type across all supported profiles", async ({ page }) => {
createdMeds.push(
@@ -369,12 +474,30 @@ test.describe("Medication Editing", () => {
await packageSelect.selectOption("liquid_container");
await page.getByRole("tab", { name: /Package/i }).click();
await expect(form.getByLabel(/(Package amount|form\.packageAmount)/i)).toBeVisible();
await page.getByRole("tab", { name: /General/i }).click();
// Switch to inhaler
await packageSelect.selectOption("inhaler");
await page.getByRole("tab", { name: /Package/i }).click();
await expect(
form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(count\)|form\.totalCount)/i)
).toBeVisible();
await expect(form.getByLabel(/(Current Stock|form\.currentStockCount)/i)).toBeVisible();
await page.getByRole("tab", { name: /General/i }).click();
// Switch to injection and persist this final state
await packageSelect.selectOption("injection");
await page.getByRole("tab", { name: /Package/i }).click();
await expect(
form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(count\)|form\.totalCount)/i)
).toBeVisible();
await expect(form.getByLabel(/(Current Stock|form\.currentStockCount)/i)).toBeVisible();
await saveEditAndVerify(page, "PackType Change Med");
// Verify final package type persisted
await clickEditMed(page, "PackType Change Med");
await expect(page.locator("select.package-type-select")).toHaveValue("liquid_container");
await expect(page.locator("select.package-type-select")).toHaveValue("injection");
});
test("should edit multiple fields at once (name, notes, generic, taken-by)", async ({ page }) => {
+94
View File
@@ -0,0 +1,94 @@
import {
authFile,
createMedicationViaAPI,
createShareTokenViaAPI,
deleteAllMedicationsViaAPI,
expect,
navigateTo,
test,
} from "./fixtures";
test.describe("Mobile modal browser back", () => {
test.use({
storageState: authFile,
viewport: { width: 412, height: 915 },
isMobile: true,
hasTouch: true,
});
test("closes owner-side modals with browser back on a Pixel-width viewport", async ({ page }) => {
await navigateTo(page, "/dashboard");
const journalHistoryButton = page.locator(".journal-history-button").first();
await expect(journalHistoryButton).toBeVisible({ timeout: 10000 });
await journalHistoryButton.click();
const journalHistoryModal = page.locator(".journal-history-modal");
await expect(journalHistoryModal).toBeVisible({ timeout: 10000 });
await page.goBack();
await expect(journalHistoryModal).toBeHidden({ timeout: 10000 });
await navigateTo(page, "/settings");
const exportButton = page
.locator("button.secondary")
.filter({ hasText: /Export|Exportieren/i })
.first();
await expect(exportButton).toBeVisible({ timeout: 10000 });
await exportButton.click();
const exportModal = page.locator(".modal-content").filter({ hasText: /Export Options|Export-Optionen/i });
await expect(exportModal).toBeVisible({ timeout: 10000 });
await page.goBack();
await expect(exportModal).toBeHidden({ timeout: 10000 });
});
test("closes the shared intake journal modal with browser back on mobile", async ({ page }) => {
const uniqueSuffix = Date.now().toString(36);
const person = `Mobile Journal ${uniqueSuffix}`;
const medicationName = `Mobile Shared Journal ${uniqueSuffix}`;
const start = new Date();
start.setHours(8, 0, 0, 0);
const pad = (value: number) => value.toString().padStart(2, "0");
const startTime = `${start.getFullYear()}-${pad(start.getMonth() + 1)}-${pad(start.getDate())}T${pad(start.getHours())}:${pad(start.getMinutes())}`;
await deleteAllMedicationsViaAPI();
await createMedicationViaAPI({
name: medicationName,
takenBy: [person],
packageType: "blister",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
intakes: [{ usage: 1, every: 1, start: startTime, intakeRemindersEnabled: false, takenBy: person }],
});
const shareToken = await createShareTokenViaAPI(person, 30, { allowJournalNotes: true });
await page.goto(`/share/${shareToken.token}`);
await page.waitForLoadState("networkidle");
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
await expect(page.locator(".med-name-text").filter({ hasText: medicationName }).first()).toBeVisible({
timeout: 15000,
});
const doseItem = page.locator(".dose-item").first();
await expect(doseItem).toBeVisible({ timeout: 15000 });
await doseItem.locator(".dose-btn.take").click();
const collapsedTodayDivider = page.locator(".day-block.today.collapsed .day-divider.clickable").first();
if (await collapsedTodayDivider.isVisible().catch(() => false)) {
await collapsedTodayDivider.click();
}
const noteButton = page.locator(".dose-item").first().locator(".dose-btn.journal");
await expect(noteButton).toBeEnabled({ timeout: 10000 });
await noteButton.click();
const journalModal = page.locator(".journal-modal");
await expect(journalModal).toBeVisible({ timeout: 10000 });
await page.goBack();
await expect(journalModal).toBeHidden({ timeout: 10000 });
await expect(page.locator(".shared-schedule-container")).toBeVisible();
});
});
+56 -1
View File
@@ -18,7 +18,7 @@ import {
*/
test.describe("Share Schedule", () => {
test.use({ storageState: authFile });
test.describe.configure({ timeout: 90000 });
test.describe.configure({ mode: "serial", timeout: 90000 });
const MED_ALICE = "ShareTest AliceMed";
const MED_BOB = "ShareTest BobMed";
@@ -300,4 +300,59 @@ test.describe("Share Schedule", () => {
await page.locator("button.modal-close").click();
});
test("should let a shared recipient add and reopen a journal note", async ({ page }) => {
const uniqueSuffix = Date.now().toString(36);
const person = `Journal E2E ${uniqueSuffix}`;
const medicationName = `Share Journal E2E ${uniqueSuffix}`;
const journalNote = `Shared E2E note ${uniqueSuffix}`;
await createMedicationViaAPI({
name: medicationName,
takenBy: [person],
packageType: "blister",
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false, takenBy: person }],
});
const shareToken = await createShareTokenViaAPI(person, 30, { allowJournalNotes: true });
await page.goto(`/share/${shareToken.token}`);
await page.waitForLoadState("networkidle");
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
await expect(page.locator(".med-name-text").filter({ hasText: medicationName }).first()).toBeVisible({
timeout: 15000,
});
const doseItem = page.locator(".dose-item").first();
await expect(doseItem).toBeVisible({ timeout: 15000 });
await expect(doseItem.locator(".dose-btn.journal")).toBeDisabled();
await doseItem.locator(".dose-btn.take").click();
const collapsedTodayDivider = page.locator(".day-block.today.collapsed .day-divider.clickable").first();
if (await collapsedTodayDivider.isVisible().catch(() => false)) {
await collapsedTodayDivider.click();
}
const updatedDoseItem = page.locator(".dose-item").first();
const noteButton = updatedDoseItem.locator(".dose-btn.journal");
await expect(noteButton).toBeEnabled({ timeout: 10000 });
await noteButton.click();
const noteInput = page.locator("#journal-note-input");
await expect(noteInput).toBeVisible({ timeout: 10000 });
await expect(noteInput).toHaveValue("");
await noteInput.fill(journalNote);
await page.locator(".journal-modal-footer button.primary").click();
await expect(page.locator(".journal-modal")).toBeHidden({ timeout: 10000 });
await noteButton.click();
await expect(noteInput).toBeVisible({ timeout: 10000 });
await expect(noteInput).toHaveValue(journalNote, { timeout: 10000 });
});
});
+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)

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