Compare commits

...

268 Commits

Author SHA1 Message Date
Daniel Volz de31ac7eb7 feat: add ntfy notification action renderer 2026-05-10 19:01:11 +02:00
Daniel Volz e2ed25059a feat: add ntfy notification action foundation 2026-05-10 19:00:57 +02:00
dependabot[bot] 7554a79898 build(deps): bump fast-uri from 3.1.0 to 3.1.2 in /backend (#564)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.0 to 3.1.2.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.0...v3.1.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-05-10 15:53:03 +02:00
Daniel Volz 70f2392a71 fix: harden dashboard notification focus rendering 2026-05-10 15:03:23 +02:00
Daniel Volz ba789f9794 fix: harden dashboard takenBy rendering 2026-05-10 14:22:33 +02:00
Daniel Volz 277fc3e686 fix(refill): stabilize stock and amount package semantics 2026-05-08 11:03:25 +02:00
dependabot[bot] b838f0e8ea build(deps): bump zod from 3.25.76 to 4.4.3 in /backend
* build(deps): bump zod from 3.25.76 to 4.4.3 in /backend

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

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

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

* fix: adapt backend validation for zod v4

---------

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


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

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

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

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

---
updated-dependencies:
- dependency-name: jose
  dependency-version: 6.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: nodemailer
  dependency-version: 8.0.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: openid-client
  dependency-version: 6.8.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-05-04 10:17:26 +02:00
dependabot[bot] 15a44d4f55 build(deps): bump the minor-and-patch group in /frontend with 5 updates (#560)
Bumps the minor-and-patch group in /frontend with 5 updates:

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


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

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

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

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

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

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: react-i18next
  dependency-version: 17.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: zod
  dependency-version: 4.4.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: jsdom
  dependency-version: 29.1.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-04 10:16:51 +02:00
dependabot[bot] 4de138015d build(deps-dev): bump @biomejs/biome in the minor-and-patch group (#559)
Bumps the minor-and-patch group with 1 update: [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome).


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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-04 10:14:08 +02:00
Daniel Volz 3bb8b93a4c chore: add .planning/codebase map (7 documents, gsd-map-codebase) 2026-04-30 12:37:45 +02:00
dependabot[bot] 3af8a5a704 build(deps): bump the minor-and-patch group in /backend with 7 updates (#554)
Bumps the minor-and-patch group in /backend with 7 updates:

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


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

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

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

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

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

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

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

---
updated-dependencies:
- dependency-name: "@fastify/static"
  dependency-version: 9.1.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@fastify/swagger-ui"
  dependency-version: 5.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@libsql/client"
  dependency-version: 0.17.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: nodemailer
  dependency-version: 8.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vitest
  dependency-version: 4.1.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-04-27 20:56:41 +02:00
dependabot[bot] f301f24182 build(deps-dev): bump @biomejs/biome in the minor-and-patch group (#552)
Bumps the minor-and-patch group with 1 update: [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome).


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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-04-27 20:56:00 +02:00
dependabot[bot] 6dc1e68392 build(deps): bump the minor-and-patch group in /frontend with 8 updates (#553)
Bumps the minor-and-patch group in /frontend with 8 updates:

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


Updates `i18next` from 26.0.6 to 26.0.8
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.0.6...v26.0.8)

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

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

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

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

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

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

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

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.0.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: lucide-react
  dependency-version: 1.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: react-router-dom
  dependency-version: 7.14.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.13
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: jsdom
  dependency-version: 29.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: vite
  dependency-version: 8.0.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vitest
  dependency-version: 4.1.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-27 20:53:37 +02:00
github-actions[bot] e4b1630922 chore: update test count badges [skip ci] 2026-04-21 07:16:32 +00:00
Copilot c7be73786b Improve coverage for image upload and schedule helper logic with focused unit tests (#551)
Agent-Logs-Url: https://github.com/DanielVolz/medassist-ng/sessions/a5af7c91-2dd4-4a79-838e-dbb79fc08f6d

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


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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 18:39:50 +02:00
dependabot[bot] f7da65e7a1 build(deps): bump the minor-and-patch group in /frontend with 6 updates
Bumps the minor-and-patch group in /frontend with 6 updates:

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


Updates `i18next` from 26.0.4 to 26.0.6
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.0.4...v26.0.6)

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

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

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

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

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

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: react-i18next
  dependency-version: 17.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: react-router-dom
  dependency-version: 7.14.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: typescript
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vite
  dependency-version: 8.0.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 18:38:40 +02:00
dependabot[bot] 27e42c0935 build(deps): bump the minor-and-patch group in /backend with 4 updates
Bumps the minor-and-patch group in /backend with 4 updates: [@fastify/static](https://github.com/fastify/fastify-static), [openid-client](https://github.com/panva/openid-client), [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) and [typescript](https://github.com/microsoft/TypeScript).


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

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

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

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

---
updated-dependencies:
- dependency-name: "@fastify/static"
  dependency-version: 9.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: openid-client
  dependency-version: 6.8.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: typescript
  dependency-version: 6.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 18:38:02 +02:00
dependabot[bot] 67ad693b31 build(deps): bump fastify from 5.8.4 to 5.8.5 in /backend
Bumps [fastify](https://github.com/fastify/fastify) from 5.8.4 to 5.8.5.
- [Release notes](https://github.com/fastify/fastify/releases)
- [Commits](https://github.com/fastify/fastify/compare/v5.8.4...v5.8.5)

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

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

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


Updates `i18next` from 26.0.3 to 26.0.4
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.0.3...v26.0.4)

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

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

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

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

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

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

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

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

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

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: lucide-react
  dependency-version: 1.8.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: react
  dependency-version: 19.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: react-dom
  dependency-version: 19.2.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 25.6.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: jsdom
  dependency-version: 29.0.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vite
  dependency-version: 8.0.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vitest
  dependency-version: 4.1.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-13 09:28:08 +02:00
dependabot[bot] cc5141c997 build(deps): bump softprops/action-gh-release from 2 to 3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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


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

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

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

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

* test(backend): add timezone column to in-memory user_settings schemas
2026-04-10 22:33:58 +02:00
Daniel Volz b70fc88921 chore(gitignore): ignore local agent workspace artifacts (#534) 2026-04-10 22:31:54 +02:00
Daniel Volz 95aec8350a fix(settings): stabilize timezone edit UX and tooltip visibility (#535) 2026-04-10 22:31:22 +02:00
Daniel Volz 401228699f Add searchable timezone settings override for reminder scheduling 2026-04-10 21:08:16 +02:00
Daniel Volz 0d2b21199e chore(release): bump app version to 1.22.3
chore(release): bump app version to 1.22.3
2026-04-10 19:01:40 +02:00
Copilot d5b3c5c21f fix: remove upgrade-insecure-requests from CSP — blank homepage on HTTP deployments (#525)
* Initial plan

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

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

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

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

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

---------

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

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 8.0.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-04-07 07:01:11 +02:00
dependabot[bot] 52fec1a4e5 build(deps-dev): bump vite from 8.0.3 to 8.0.5 in /frontend
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 8.0.3 to 8.0.5.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.0.5/packages/vite)

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-04-06 11:00:55 +02:00
dependabot[bot] 51b09dc563 build(deps): bump the minor-and-patch group in /backend with 3 updates
Bumps the minor-and-patch group in /backend with 3 updates: [dotenv](https://github.com/motdotla/dotenv), [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) and [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-04-06 10:54:02 +02:00
dependabot[bot] dbbd9d5ed8 build(deps-dev): bump @biomejs/biome from 2.4.9 to 2.4.10 in the minor-and-patch group
Bumps the minor-and-patch group with 1 update: [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome).


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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-04-06 10:50:19 +02:00
dependabot[bot] 15f1e33aa4 build(deps): bump the minor-and-patch group in /frontend with 6 updates
Bumps the minor-and-patch group in /frontend with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [i18next](https://github.com/i18next/i18next) | `26.0.1` | `26.0.3` |
| [react-i18next](https://github.com/i18next/react-i18next) | `17.0.1` | `17.0.2` |
| [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) | `7.13.2` | `7.14.0` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.9` | `2.4.10` |
| [@playwright/test](https://github.com/microsoft/playwright) | `1.58.2` | `1.59.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `25.5.0` | `25.5.2` |


Updates `i18next` from 26.0.1 to 26.0.3
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v26.0.1...v26.0.3)

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

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

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

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

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

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 26.0.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: react-i18next
  dependency-version: 17.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: react-router-dom
  dependency-version: 7.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@playwright/test"
  dependency-version: 1.59.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 25.5.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-06 10:49:32 +02:00
Daniel Volz 5161949578 chore(release): 1.22.1 (#509)
* chore(release): 1.22.1

* noop

* chore: remove accidental noop artifact

* noop2

* remove accidental noop2

* chore: remove accidental noop2 artifact
2026-04-05 15:18:36 +02:00
github-actions[bot] d721bab01a chore: update test count badges [skip ci] 2026-04-05 12:55:29 +00:00
Daniel Volz eec1653ff4 fix(security): ship isolated JWT decorator hotfix
* fix(security): isolate dependency hotfix from github main

* fix(security): expose hotfix jwt decorators across routes

* test(e2e): restore stable app header selectors

* test(e2e): align planner and app shell checks

* test(e2e): add legacy settings page selectors

* test(e2e): align settings page contracts
2026-04-05 14:49:50 +02:00
Daniel Volz 6bba006e64 test(e2e): refresh smoke selectors for current app hooks 2026-03-30 21:55:32 +02:00
github-actions[bot] 59ffb55dfd chore: update test count badges [skip ci] 2026-03-30 19:13:31 +00:00
Daniel Volz ad48ab6ba7 fix: prefer latest medication data when opening edit 2026-03-30 20:58:35 +02:00
dependabot[bot] f4a5f5112a build(deps): bump the minor-and-patch group in /frontend with 5 updates (#496)
* build(deps): bump the minor-and-patch group in /frontend with 5 updates

Bumps the minor-and-patch group in /frontend with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) | `7.13.1` | `7.13.2` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.8` | `2.4.9` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.0` | `4.1.2` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.0.1` | `8.0.3` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.0` | `4.1.2` |


Updates `react-router-dom` from 7.13.1 to 7.13.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.13.2/packages/react-router-dom)

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

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

Updates `vite` from 8.0.1 to 8.0.3
- [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/create-vite@8.0.3/packages/vite)

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

---
updated-dependencies:
- dependency-name: react-router-dom
  dependency-version: 7.13.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vite
  dependency-version: 8.0.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vitest
  dependency-version: 4.1.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

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

* test(e2e): wait for login submit button before click

* test(e2e): prefer API login in setup with UI fallback

* test(e2e): align selectors with current ui testids

* chore: rerun ci after merge resolution

* chore: trim stale e2e diff from dependency branch

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-03-30 20:36:25 +02:00
dependabot[bot] 98062358be build(deps): bump the minor-and-patch group in /backend with 5 updates (#501)
* build(deps): bump the minor-and-patch group in /backend with 5 updates

Bumps the minor-and-patch group in /backend with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [drizzle-orm](https://github.com/drizzle-team/drizzle-orm) | `0.45.1` | `0.45.2` |
| [fastify](https://github.com/fastify/fastify) | `5.8.3` | `5.8.4` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.4.8` | `2.4.9` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.1.0` | `4.1.2` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.1.0` | `4.1.2` |


Updates `drizzle-orm` from 0.45.1 to 0.45.2
- [Release notes](https://github.com/drizzle-team/drizzle-orm/releases)
- [Commits](https://github.com/drizzle-team/drizzle-orm/compare/0.45.1...0.45.2)

Updates `fastify` from 5.8.3 to 5.8.4
- [Release notes](https://github.com/fastify/fastify/releases)
- [Commits](https://github.com/fastify/fastify/compare/v5.8.3...v5.8.4)

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

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

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

---
updated-dependencies:
- dependency-name: drizzle-orm
  dependency-version: 0.45.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: fastify
  dependency-version: 5.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.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.1.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vitest
  dependency-version: 4.1.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

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

* test(e2e): wait for login submit button before click

* test(e2e): prefer API login in setup with UI fallback

* test(e2e): align selectors with current ui testids

* chore: rerun ci after merge resolution

* chore: trim stale e2e diff from dependency branch

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-03-30 20:35:19 +02:00
dependabot[bot] 4132ba486d build(deps-dev): bump typescript from 5.9.3 to 6.0.2 in /frontend
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-03-30 20:18:16 +02:00
dependabot[bot] 0faad5d28b build(deps): bump i18next from 25.10.4 to 26.0.1 in /frontend
Bumps [i18next](https://github.com/i18next/i18next) from 25.10.4 to 26.0.1.
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v25.10.4...v26.0.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-03-30 20:17:55 +02:00
dependabot[bot] 218b9056fa build(deps): bump lucide-react from 0.577.0 to 1.7.0 in /frontend
Bumps [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) from 0.577.0 to 1.7.0.
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.7.0/packages/lucide-react)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.7.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>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-03-30 20:17:28 +02:00
dependabot[bot] a7bd353f75 build(deps): bump react-i18next from 16.6.1 to 17.0.1 in /frontend
Bumps [react-i18next](https://github.com/i18next/react-i18next) from 16.6.1 to 17.0.1.
- [Changelog](https://github.com/i18next/react-i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/react-i18next/compare/v16.6.1...v17.0.1)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-30 20:16:49 +02:00
dependabot[bot] bd2bfe6972 fix: unblock PR 502 checks
* build(deps-dev): bump typescript from 5.9.3 to 6.0.2 in /backend

Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 6.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v6.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 6.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

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

* fix: unblock PR 502 checks

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-03-30 20:14:29 +02:00
dependabot[bot] 8a9b44ef31 build(deps-dev): bump @biomejs/biome from 2.4.8 to 2.4.9 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.8 to 2.4.9
- [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.9/packages/@biomejs/biome)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.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-03-30 10:03:40 +02:00
dependabot[bot] 026091c5ca build(deps): bump brace-expansion from 5.0.2 to 5.0.5 in /backend
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 5.0.2 to 5.0.5.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.2...v5.0.5)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-27 08:46:22 +01:00
dependabot[bot] 08f75e44ff build(deps): bump nodemailer from 8.0.3 to 8.0.4 in /backend
* build(deps): bump nodemailer from 8.0.3 to 8.0.4 in /backend

Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 8.0.3 to 8.0.4.
- [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.3...v8.0.4)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 8.0.4
  dependency-type: direct:production
...

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

* chore: retrigger ci for dependabot pr 492

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-03-27 08:43:23 +01:00
github-actions[bot] 5e3a10a93c chore: update test count badges [skip ci] 2026-03-27 05:55:17 +00:00
Daniel Volz 7f2ef09df5 test: expand app-shell e2e coverage and stabilize flaky flows
* test: expand e2e app shell coverage and stabilize flaky scenarios

* fix(e2e): stabilize dashboard flow and frontend ci gates
2026-03-27 06:51:04 +01:00
Daniel Volz f46043970f refactor: decompose frontend state and medication dialog flows 2026-03-27 06:50:19 +01:00
Daniel Volz b58c4fe5bb refactor: decompose backend services and routes for maintainability 2026-03-27 06:48:20 +01:00
dependabot[bot] 73a235dd83 build(deps): bump yaml from 2.8.2 to 2.8.3 in /backend (#485)
Bumps [yaml](https://github.com/eemeli/yaml) from 2.8.2 to 2.8.3.
- [Release notes](https://github.com/eemeli/yaml/releases)
- [Commits](https://github.com/eemeli/yaml/compare/v2.8.2...v2.8.3)

---
updated-dependencies:
- dependency-name: yaml
  dependency-version: 2.8.3
  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-03-26 07:56:06 +01:00
dependabot[bot] ce184a6c56 build(deps-dev): bump picomatch from 4.0.3 to 4.0.4
Bumps [picomatch](https://github.com/micromatch/picomatch) from 4.0.3 to 4.0.4.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-26 07:37:23 +01:00
dependabot[bot] 675cb88f3e build(deps): bump picomatch from 4.0.3 to 4.0.4 in /backend
Bumps [picomatch](https://github.com/micromatch/picomatch) from 4.0.3 to 4.0.4.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-26 07:36:55 +01:00
dependabot[bot] 4b8fa10b39 build(deps): bump picomatch from 4.0.3 to 4.0.4 in /frontend
Bumps [picomatch](https://github.com/micromatch/picomatch) from 4.0.3 to 4.0.4.
- [Release notes](https://github.com/micromatch/picomatch/releases)
- [Changelog](https://github.com/micromatch/picomatch/blob/master/CHANGELOG.md)
- [Commits](https://github.com/micromatch/picomatch/compare/4.0.3...4.0.4)

---
updated-dependencies:
- dependency-name: picomatch
  dependency-version: 4.0.4
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-26 07:36:27 +01:00
dependabot[bot] c39b5c9501 build(deps): bump fastify from 5.8.2 to 5.8.3 in /backend (#479)
Bumps [fastify](https://github.com/fastify/fastify) from 5.8.2 to 5.8.3.
- [Release notes](https://github.com/fastify/fastify/releases)
- [Commits](https://github.com/fastify/fastify/compare/v5.8.2...v5.8.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-03-26 07:24:47 +01:00
Daniel Volz a1c7e0e62c refactor: split frontend styles into layered modules
Split the large frontend shared stylesheet into focused layered CSS partials while preserving the stable global entrypoint and existing specialized stylesheet modules.
2026-03-26 05:36:50 +01:00
Daniel Volz f670a6355f chore: release v1.22.0 2026-03-25 11:01:00 +01:00
Daniel Volz 3cdb38055d fix: close stale weekly triage reports before creating a new one 2026-03-25 09:16:16 +01:00
dependabot[bot] 39c19ab2fe build(deps): bump the minor-and-patch group in /backend with 4 updates
Squash merge PR #470
2026-03-25 07:11:44 +01:00
dependabot[bot] 8372b7ec27 build(deps): bump the minor-and-patch group in /frontend with 5 updates
Squash merge PR #469
2026-03-25 07:11:36 +01:00
dependabot[bot] b32ec9b21b build(deps-dev): bump @biomejs/biome from 2.4.7 to 2.4.8 in the minor-and-patch group
Squash merge PR #468
2026-03-25 07:11:28 +01:00
github-actions[bot] 60bef957de chore: update test count badges [skip ci] 2026-03-25 06:07:14 +00:00
Daniel Volz 8e2d7e74d2 feat: improve medication enrichment lookup
Squash merge PR #475
2026-03-25 07:03:08 +01:00
github-actions[bot] 5382669ffe chore: update test count badges [skip ci] 2026-03-25 05:54:07 +00:00
Daniel Volz 7059c25f1c fix: align stock and refill semantics
Squash merge PR #474
2026-03-25 06:49:34 +01:00
Daniel Volz 37fc2b8e66 chore: release v1.21.0 (#467) 2026-03-20 21:02:28 +01:00
github-actions[bot] d434131d02 chore: update test count badges [skip ci] 2026-03-20 19:43:52 +00:00
Daniel Volz b796e03bcb feat: add medication enrichment lookup to the medication editor
* feat: add medication enrichment lookup

* fix: avoid double unescape in enrichment sanitization

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

---------

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

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

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

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

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

* fix: remove array index keys from intake rows

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

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

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

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

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

* fix: align AJV docs plugin typing

* fix: preserve runtime behavior for OpenAPI schemas

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

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

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

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

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

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

* test: stabilize flaky playwright checks for settings and schedule

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

---------

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

* fix: harden forbidden-settings e2e assertion

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


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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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


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

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

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


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

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

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


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

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

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

* chore: align frontend lockfile version and include remaining local changes
2026-03-02 01:33:28 +01:00
Daniel Volz e6a097d81d chore: release v1.18.1 (#366) 2026-03-02 01:16:08 +01:00
Daniel Volz f4723c6f99 chore: release v1.18.0 (#365) 2026-03-02 00:41:20 +01:00
github-actions[bot] aad6b143ef chore: update test count badges [skip ci] 2026-03-01 23:27:20 +00:00
Daniel Volz da004b5c3e fix: align frontend tube/liquid container semantics (#364)
* fix: align frontend tube/liquid container semantics

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

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

* test: stabilize e2e selectors for updated medication semantics

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

* test: align backend test schemas with medication metadata fields

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

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

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

* test: align backend test schemas with medication metadata fields

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

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

* fix: unblock required checks for nginx hotfix

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-27 01:29:04 +01:00
dependabot[bot] 7f26dca7a7 build(deps-dev): bump @types/node (#343)
Bumps the minor-and-patch group in /backend with 1 update: [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node).


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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-27 01:29:00 +01:00
dependabot[bot] 46d768dd4e build(deps): bump the minor-and-patch group in /frontend with 5 updates (#344)
Bumps the minor-and-patch group in /frontend with 5 updates:

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


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

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

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

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

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

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

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

---
updated-dependencies:
- dependency-name: react
  dependency-version: 19.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/react"
  dependency-version: 19.2.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: react-dom
  dependency-version: 19.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 25.3.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/react"
  dependency-version: 19.2.14
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-27 01:28:55 +01:00
dependabot[bot] c62b6d7893 build(deps): bump actions/upload-artifact from 6 to 7 (#345)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-27 01:28:51 +01:00
dependabot[bot] 1668eb935c build(deps-dev): bump @types/supertest from 6.0.3 to 7.2.0 in /backend (#346)
Bumps [@types/supertest](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/supertest) from 6.0.3 to 7.2.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/supertest)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-27 01:28:47 +01:00
Daniel Volz 1ea4919323 chore: release v1.17.0 (#348) 2026-02-27 01:19:39 +01:00
Daniel Volz ba0ab672b9 docs: update memory and report for multi-pr delivery (#347) 2026-02-27 01:15:40 +01:00
Daniel Volz 57c998ba09 chore: update dependabot automation and agent governance (#341)
* chore: update dependabot automation and agent governance

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: update placeholder text and fix FIELD_LIMITS test

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

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

---------

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

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

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

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

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

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

* docs: remove obsolete project setup guide

* fix: restore health config flags for compatibility

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

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


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

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

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

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

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 25.8.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: lucide-react
  dependency-version: 0.575.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: react-router-dom
  dependency-version: 7.13.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-23 19:25:28 +01:00
dependabot[bot] 3e4f1440a9 build(deps-dev): bump the minor-and-patch group (#290)
Bumps the minor-and-patch group in /backend with 3 updates: [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome), [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) and [@types/nodemailer](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/nodemailer).


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

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

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

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@types/node"
  dependency-version: 25.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@types/nodemailer"
  dependency-version: 7.0.11
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-23 19:25:24 +01:00
dependabot[bot] d64a833bda build(deps-dev): bump @biomejs/biome from 2.4.1 to 2.4.4 (#289)
Bumps [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) from 2.4.1 to 2.4.4.
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.4.4/packages/@biomejs/biome)

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

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

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

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

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

* fix: show mobile name error when validation already exists

* fix: restore app-level escape priority handling

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

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

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

Replaces the inline 40-line scroll lock in MobileEditModal with the
shared hook.
2026-02-22 18:40:39 +01:00
Daniel Volz 3238a22fd6 test: add E2E regression tests for MedDetail tooltip visibility (#282)
Guard against tooltip pseudo-elements being clipped by ancestor
overflow:hidden or hidden behind modal overlays. Covers edit,
stock correction, export, and close button tooltips.
2026-02-22 18:07:58 +01:00
Daniel Volz b139660241 chore: release v1.15.1 (#280) 2026-02-22 18:02:32 +01:00
Daniel Volz 259f00e7a0 fix: unify number stepper layout and detail modal padding (#279)
Reorder stepper DOM elements (input first) and apply refill-number-stepper
class to both steppers for consistent CSS order-based layout.
Fix missing bottom padding on .med-detail-body.
2026-02-22 17:57:36 +01:00
github-actions[bot] e9f2760815 chore: update test count badges [skip ci] 2026-02-22 16:55:21 +00:00
Daniel Volz d0e2ee0783 fix: trim whitespace from username on login and registration (#277)
Add .trim() to both loginSchema and registerSchema Zod validators so
leading/trailing spaces are stripped before validation and DB lookup.
Includes 5 new test cases covering trim behavior for both endpoints.
2026-02-22 17:51:41 +01:00
Daniel Volz c620146c4b chore: release v1.15.0 (#275) 2026-02-22 16:54:49 +01:00
Daniel Volz 33c1095e77 feat: add FormNumberStepper to medication edit forms (#274)
Replace plain numeric inputs with a reusable +/− stepper component in
both desktop (MedicationsPage) and mobile (MobileEditModal) edit forms.

Applied to Stock, Schedule, and Prescription tab fields. Reorder tabs
so Schedule appears before Prescription. Add responsive grid overrides
for narrow sidebar and compact schedule rows.

Fix label-hover ghost activation by placing <input> first in DOM
(CSS order restores visual [−] [value] [+] layout).

Closes #273
2026-02-22 16:49:51 +01:00
Daniel Volz 5d657558f7 chore: release v1.14.4 (#272) 2026-02-22 14:00:02 +01:00
Daniel Volz 0c28999c89 chore: release v1.14.3 (#271) 2026-02-22 11:05:09 +01:00
Daniel Volz 2296303236 fix: prevent duplicate scheduler reminder sends (#270) 2026-02-22 10:56:13 +01:00
Daniel Volz 9a2d42b8b9 fix: stabilize dashboard modal and image click behavior (#267)
* feat: make medication names clickable in Dashboard dose schedule

Add click handlers to med-name-stack divs in all three dose schedule
sections (past, current/overdue, future) on DashboardPage, opening the
MedDetail modal on click.

Add early-return guards to all four modal openers in AppContext
(openMedDetail, openImageLightbox, openScheduleLightbox, openUserFilter)
to prevent duplicate pushState entries on double-click, which caused
unexpected navigation to the Medications page.

Closes #266

* fix: stabilize dashboard modal and image click handling

* fix: close medication detail on first backdrop click
2026-02-22 10:50:58 +01:00
Daniel Volz 088a6c1a05 chore: fix all Biome lint warnings and MedDetail intake bell icons (#265)
- Backend: refactor nested ternaries, remove unused imports/any types
- Frontend: fix exhaustive deps, a11y label associations, array index keys,
  empty CSS blocks, unused vars, type annotations
- MedDetail modal: fix intake schedule bell icons not rendering (use unified
  intake source with fallback), place bell inline after person name
- MedDetail modal: revert schedule rows from grid to flexbox layout

Closes #264
2026-02-22 08:52:03 +01:00
Daniel Volz 228fd4cd7e chore: release v1.14.2 (#263) 2026-02-21 20:56:12 +01:00
Daniel Volz e346d60f39 chore: release v1.14.1 (#262) 2026-02-21 20:51:28 +01:00
Daniel Volz afb8e5028c fix: auto-mark intakes at due time and show robot marker (#261)
* fix: auto-mark intakes at due time and show robot marker

* test: add taken_source to integration schema

* test: align e2e route schema with taken_source
2026-02-21 20:45:05 +01:00
Daniel Volz 9ab077a037 chore: release v1.14.0 (#259) 2026-02-21 18:04:20 +01:00
Daniel Volz 976d7356ec feat: improve medication detail modal layout and display (#258)
Widen detail modal on desktop (711px, up from 500px) with max-width
override to beat modals-base.css specificity. Limit fullscreen mode
to actual phones (<=500px) instead of all screens <=900px. Move intake
schedule section before prescription details. Show per-intake takenBy
person and bell icon with proper warning color. Right-align time in
schedule rows. Move notes icon after label text. Replace emoji bell
icons with Lucide Bell component in SchedulePage and MobileEditModal.
Add common.on/common.off i18n keys.

Closes #254
2026-02-21 18:00:23 +01:00
Daniel Volz 943148fb49 feat: close modals with browser back button on mobile (#257)
* feat: close modals with browser back button on mobile

Create reusable useModalHistory hook that pushes history state when a
modal opens and listens for popstate to close it. Apply to ReportModal,
ClearMissedConfirm, ExportModal, ImportConfirm, and all modals using
ConfirmModal/ShareDialog/Auth/ExportModal base components. Escape key
handling was already in place for desktop.

Closes #253

* fix: update tests for renamed button labels and missing useModalHistory mock
2026-02-21 18:00:12 +01:00
Daniel Volz 94bd8bd6e8 feat: improve mobile edit modal swipe gestures and tab navigation (#256)
* feat: improve mobile edit modal swipe gestures and tab navigation

Replace React passive touch handlers with native non-passive
addEventListener via useEffect for reliable horizontal swipe blocking.
Reduce axis-lock threshold from 18-26px to 6px for more responsive
gesture detection. Remove isInteractive() guard so swipe works on
input fields. Add tab strip auto-scroll via scrollIntoView when
active tab changes. Fix vertical scrolling by changing readonly
fieldset from display:block to display:flex.

Closes #252

* fix: guard scrollIntoView for jsdom test compatibility
2026-02-21 18:00:02 +01:00
Daniel Volz 0cf1c5353e fix: notification channel toggles snap back after being enabled (#255)
* fix: notification channel toggles snap back after being enabled

The checked props for email/push notification toggles had redundant
conditions (smtpHost/shoutrrrUrl checks) that forced them to false,
causing immediate visual snap-back. Additionally, performSave()
overwrote emailEnabled/shoutrrrEnabled in local state with effective
values, disabling toggles when no SMTP host or Shoutrrr URL was set.

Remove redundant checked prop conditions (disabled attr already handles
interaction gating) and stop overwriting enabled flags in local state
after save.

Closes #250

* fix: remove leaked useModalHistory import from SettingsPage

* fix: update useSettings tests to match new toggle behavior
2026-02-21 17:59:50 +01:00
github-actions[bot] 98cf1ce1d2 chore: update test count badges [skip ci] 2026-02-21 14:51:05 +00:00
Daniel Volz 75c201cab5 fix: keep med detail stock and package values consistent (#249) 2026-02-21 15:47:44 +01:00
github-actions[bot] 74f079d13e chore: update test count badges [skip ci] 2026-02-21 14:28:27 +00:00
Daniel Volz fd3b770a81 fix: improve mobile edit modal scrolling behavior (#247) 2026-02-21 15:24:57 +01:00
Daniel Volz 612aa007aa fix: unify stock semantics across planner and scheduler (#245)
* fix: unify stock semantics across planner and scheduler

* fix: stabilize dashboard hmr and align stock helper tests
2026-02-21 15:24:53 +01:00
Daniel Volz 02af93ec55 chore: release v1.13.0 (#243) 2026-02-20 19:55:26 +01:00
dependabot[bot] 8f57aa8bc9 build(deps): bump ajv from 8.17.1 to 8.18.0 in /backend (#238)
Bumps [ajv](https://github.com/ajv-validator/ajv) from 8.17.1 to 8.18.0.
- [Release notes](https://github.com/ajv-validator/ajv/releases)
- [Commits](https://github.com/ajv-validator/ajv/compare/v8.17.1...v8.18.0)

---
updated-dependencies:
- dependency-name: ajv
  dependency-version: 8.18.0
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 19:51:41 +01:00
dependabot[bot] f42ed87d94 build(deps): bump minimatch from 10.2.0 to 10.2.2 in /backend (#237)
Bumps [minimatch](https://github.com/isaacs/minimatch) from 10.2.0 to 10.2.2.
- [Changelog](https://github.com/isaacs/minimatch/blob/main/changelog.md)
- [Commits](https://github.com/isaacs/minimatch/compare/v10.2.0...v10.2.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-20 19:51:37 +01:00
Daniel Volz 8de54b9065 docs: sync README feature list with recent app changes (#241) 2026-02-20 19:50:55 +01:00
Daniel Volz b489e1e117 fix: keep mobile med detail actions visible while scrolling (#240) 2026-02-20 19:31:59 +01:00
Daniel Volz 8c97abd3c9 Merge branch 'main' of github.com:DanielVolz/medassist-ng
* 'main' of github.com:DanielVolz/medassist-ng:
  chore: update test count badges [skip ci]
2026-02-20 18:58:44 +01:00
Daniel Volz 2eec415af6 docs: enforce hard PR scope and size splitting rule in copilot instructions 2026-02-20 18:56:55 +01:00
github-actions[bot] 243a46f960 chore: update test count badges [skip ci] 2026-02-20 17:56:42 +00:00
Daniel Volz 052751b2ba feat: reports, timeline toggles, and stock correction improvements (#236)
* refactor(frontend): modularize styles and polish modal/ui interactions

* feat: add report workflow and timeline/settings improvements

* fix: resolve CI failures for backend typing, lint, and playwright config
2026-02-20 18:52:59 +01:00
Daniel Volz 89d565bc9d chore: fix lint errors and reduce warnings across codebase (#234)
* chore: fix lint errors and reduce warnings across codebase

- Fix noExplicitAny catches in backend routes and plugins
- Fix noNestedTernary issues in backend services
- Add keyboard event handlers for useKeyWithClickEvents in frontend
- Disable noImportantStyles rule in biome.json
- Fix formatting errors across all changed files
- Fix test file lint issues

Closes #233

* fix: restore any types in test files for TS compatibility

* fix: revert Auth.tsx dependency array changes that caused infinite re-render

* fix: null-safe user.username access in AppContext dependency array
2026-02-17 05:21:47 +01:00
Daniel Volz 08a18fc14a fix: improve export filename and import confirmation UX (#232)
Export filename:
- Include username for multi-user/instance distinction
- Include timestamp with time (YYYYMMDD-HHMM) instead of date only
- Example: medassist-export-daniel-20260216-2108.json

Import confirmation:
- Show friendly 'Import Data?' dialog on empty instances instead of
  scary 'Replace All Data?' warning with danger button
- Only show destructive warning when there is existing data to replace
- Use primary button style for empty-state import

Closes #231
2026-02-16 22:20:20 +01:00
Daniel Volz e41efdf98b fix: disable nginx temp file buffering for proxied responses (#230)
Replace increased proxy buffer sizes with proxy_max_temp_file_size 0
to stream upstream responses directly to clients instead of buffering
to temp files. Eliminates warnings for large medication images without
increasing per-connection RAM usage.
2026-02-16 22:03:11 +01:00
Daniel Volz cefac8cc4e fix: nginx proxy buffering warnings and LOG_LEVEL propagation (#229)
- Increase proxy buffer sizes to prevent upstream image responses being
  buffered to temporary files (16k header + 8x256k body + 512k busy)
- Add env_file to frontend service in docker-compose.dev.yml for LOG_LEVEL
- Normalize LOG_LEVEL in nginx-entrypoint.sh (case-insensitive, trim whitespace)
- Add startup logging showing LOG_LEVEL → access_log mapping

Closes #226
2026-02-16 21:52:03 +01:00
Daniel Volz 779870960c fix: frontend UI polish — tooltips, planner checkbox, settings layout (#228)
- Fix mobile tooltip positioning (above icon instead of centered)
- Place planner checkbox and send-now button on same row
- Move settings tooltips beside input fields instead of overlapping
- Fix input-with-tooltip layout for narrow screens
- Add daily/everyNDays i18n keys for dose frequency display
- Fix lint formatting in page components

Closes #225
2026-02-16 21:51:51 +01:00
Daniel Volz 871e6066ec fix: export/import missing refill history, prescription, and bottle fields (#227)
- Add refill history export/import with medication reference mapping
- Include totalPills (bottle type capacity) in inventory export
- Include dismissedUntil field for past dose dismissal state
- Add expiryWarningDays and shareStockStatus to settings export
- Bump export version to 1.1
- Add refill count to import result reporting
- Update i18n import success details to include refill count

Closes #224
2026-02-16 21:51:39 +01:00
dependabot[bot] ff100dfea5 build(deps-dev): bump @types/nodemailer in /backend (#223)
Bumps [@types/nodemailer](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/nodemailer) from 6.4.21 to 7.0.10.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/nodemailer)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 19:06:28 +01:00
dependabot[bot] 47581ca7ad build(deps-dev): bump @biomejs/biome (#222)
Bumps the minor-and-patch group in /backend with 1 update: [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome).


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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 19:06:18 +01:00
dependabot[bot] 39e9ebbf28 build(deps): bump the minor-and-patch group in /frontend with 3 updates (#221)
Bumps the minor-and-patch group in /frontend with 3 updates: [i18next](https://github.com/i18next/i18next), [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) and [jsdom](https://github.com/jsdom/jsdom).


Updates `i18next` from 25.8.7 to 25.8.10
- [Release notes](https://github.com/i18next/i18next/releases)
- [Changelog](https://github.com/i18next/i18next/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next/compare/v25.8.7...v25.8.10)

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

Updates `jsdom` from 28.0.0 to 28.1.0
- [Release notes](https://github.com/jsdom/jsdom/releases)
- [Changelog](https://github.com/jsdom/jsdom/blob/main/Changelog.md)
- [Commits](https://github.com/jsdom/jsdom/compare/28.0.0...28.1.0)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 25.8.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.4.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: jsdom
  dependency-version: 28.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 19:06:06 +01:00
dependabot[bot] 41b20bb4e6 build(deps): bump actions/github-script from 7 to 8 (#220)
Bumps [actions/github-script](https://github.com/actions/github-script) from 7 to 8.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v7...v8)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 19:05:55 +01:00
dependabot[bot] f9c51956d5 build(deps-dev): bump @biomejs/biome from 2.3.15 to 2.4.1 (#219)
Bumps [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) from 2.3.15 to 2.4.1.
- [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.1/packages/@biomejs/biome)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-16 19:05:41 +01:00
Daniel Volz 543b42b540 docs: add mandatory PR metadata fields to release-manager agent (#218)
- Add PR Metadata section with assignee, label, and project requirements
- Update gh pr create command template to include --assignee, --label, --project flags
- Add label mapping table for branch prefix to label type
- Update workflow summary to mention metadata fields
2026-02-15 23:52:15 +01:00
Daniel Volz 36a2f7d537 chore: release v1.12.0 (#216) 2026-02-15 23:28:35 +01:00
Daniel Volz 4b697374f6 feat: obsolete medication archiving, start date, and UI improvements (#215)
* feat: obsolete medication archiving, start date, and UI improvements

- Add soft-archive (obsolete) for medications with dedicated section and toggle
- Add medication start date field with date picker and validation
- Add obsolete/reactivate API endpoints with proper auth
- Filter obsolete meds from schedule, coverage, planner, and notifications
- Improve UserFilterModal with intake schedules, stock badges, and click-to-open
- Improve dashboard taken-by badges with per-intake bell icons
- Add Escape key support to ConfirmModal and MobileEditModal
- Fix Lightbox close button positioning near image
- Add read-only mode support for MobileEditModal
- DB migrations: 0008 (is_obsolete, obsolete_at), 0009 (medication_start_date)
- All user-facing text uses i18n keys (en + de)

* test: fix tests for obsolete medications and UI changes

- Backend: add is_obsolete, obsolete_at, medication_start_date columns to test schemas
- Backend: add test medication inserts in planner tests for active-med filtering
- Frontend: update useMedications URL to include includeObsolete param
- Frontend: fix MobileEditModal selectors and validation assertions
- Frontend: add onClearUser prop to UserFilterModal test renders
- Frontend: fix MedicationsPage and DashboardPage test assertions
2026-02-15 23:23:38 +01:00
Daniel Volz c47a35d642 fix: use COPY --chmod instead of RUN chmod in frontend Dockerfile (#214)
The nginx-unprivileged base image runs as non-root, so RUN chmod
on / fails with 'Operation not permitted'. Use COPY --chmod=755
to set the executable bit at build time instead.
2026-02-14 21:12:51 +01:00
Daniel Volz d8d8c4a07e chore: release v1.11.1 (#213) 2026-02-14 21:07:14 +01:00
Daniel Volz 3f041f26aa feat: respect LOG_LEVEL in frontend nginx container (#212)
Add entrypoint wrapper that translates LOG_LEVEL into nginx
access_log control. When LOG_LEVEL is warn or higher, nginx
access logs are suppressed. The frontend container now receives
LOG_LEVEL via env_file (.env) — no new env vars needed.
2026-02-14 21:04:45 +01:00
Daniel Volz 1e043c8bf3 chore: release v1.11.0 (#210) 2026-02-14 20:33:54 +01:00
290 changed files with 59797 additions and 21238 deletions
+37 -3
View File
@@ -11,13 +11,40 @@ PGID=1000
PORT=3000
CORS_ORIGINS=http://localhost:4174
LOG_LEVEL=info
LOG_LEVEL=warn
# Public base URL used for notification action links.
# Required for intake reminder action buttons/links.
# PUBLIC_APP_URL=https://medassist.example.com
# For mobile testing on the same LAN, use your laptop IP instead of localhost,
# e.g. PUBLIC_APP_URL=http://192.168.0.113:5173 and add that origin to CORS_ORIGINS.
# Levels: debug, info, warn, error, silent
# Controls: backend Fastify logging, frontend nginx access logs (Docker),
# and frontend browser console (via build-time injection)
#
# Behavior per level:
# debug — all app logs + all HTTP request logs (including polling endpoints)
# info — all app logs + HTTP request logs, EXCEPT high-frequency polling
# (GET /doses/taken, GET /share/:token/doses, GET /health are hidden)
# warn — only warnings and errors
# error — only errors
# silent — no logs
# Rate limit: max requests per minute per IP (default: 100)
# Increase for development/testing environments
# RATE_LIMIT_MAX=100
# Timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York)
# API documentation UI + OpenAPI JSON
# Default behavior: enabled outside production, disabled in production
# When enabled, docs are available on /docs and /docs/json.
# Recommended:
# development/staging: OPENAPI_DOCS_ENABLED=true
# production: leave unset, or set OPENAPI_DOCS_ENABLED=false
# OPENAPI_DOCS_ENABLED=true
# Server default timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York).
# Users can override this per account in Settings -> Timezone.
TZ=Europe/Berlin
# =============================================================================
@@ -29,6 +56,9 @@ AUTH_ENABLED=false
# Allow new user registrations (auto-enabled when no users exist)
# REGISTRATION_ENABLED=false
# Disable username/password form login (useful for OIDC-only setups)
# FORM_LOGIN_ENABLED=true
# JWT Secrets - REQUIRED when AUTH_ENABLED=true
# Generate with: openssl rand -hex 32
# JWT_SECRET=
@@ -99,12 +129,14 @@ EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
# DEFAULT_NOTIFICATION_EMAIL=
# DEFAULT_EMAIL_STOCK_REMINDERS=true
# DEFAULT_EMAIL_INTAKE_REMINDERS=true
# DEFAULT_EMAIL_PRESCRIPTION_REMINDERS=true
# Push notifications (ntfy/gotify via Shoutrrr)
# DEFAULT_SHOUTRRR_ENABLED=false
# DEFAULT_SHOUTRRR_URL=
# DEFAULT_SHOUTRRR_STOCK_REMINDERS=true
# DEFAULT_SHOUTRRR_INTAKE_REMINDERS=true
# DEFAULT_SHOUTRRR_PRESCRIPTION_REMINDERS=true
# Repeat/nagging reminders for missed doses
# DEFAULT_REPEAT_REMINDERS_ENABLED=false
@@ -123,4 +155,6 @@ EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
# UI defaults
# DEFAULT_LANGUAGE=en # en or de
# DEFAULT_STOCK_CALCULATION_MODE=automatic # automatic or manual
# DEFAULT_SHARE_STOCK_STATUS=true # Show stock status on shared schedule links
# DEFAULT_SHARE_MEDICATION_OVERVIEW=false # Show medication overview section on shared schedule links
# DEFAULT_UPCOMING_TODAY_ONLY=false
# DEFAULT_SHARE_SCHEDULE_TODAY_ONLY=false
+11
View File
@@ -0,0 +1,11 @@
# MedAssist ownership
# This routes review requests automatically to the maintainer.
* @DanielVolz
# Explicit domains for clarity
/backend/ @DanielVolz
/frontend/ @DanielVolz
/.github/ @DanielVolz
/doku/ @DanielVolz
/docs/ @DanielVolz
+20 -1
View File
@@ -1,12 +1,19 @@
name: 🐛 Bug Report
name: Bug Report
description: Report a bug or unexpected behavior
title: "[Bug]: "
labels: ["bug", "triage"]
assignees:
- DanielVolz
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to report a bug! Please fill out the sections below.
Before submitting, please reproduce the issue on the latest released version.
Even better: verify it on the current `main` image/tag.
The issue may already be fixed in newer builds.
- type: textarea
id: description
attributes:
@@ -57,6 +64,18 @@ body:
validations:
required: true
- type: textarea
id: version_info
attributes:
label: Version / Image Information
description: Provide the app version and, if using Docker, the exact image tag you are running.
placeholder: |
App version (Settings -> About): vX.Y.Z
Docker image tag (if applicable): latest or main
Tag guidance: use `latest` for the newest release, or `main` for the newest changes from the main branch (`main` is always as new as or newer than `latest`).
validations:
required: true
- type: input
id: browser
attributes:
-7
View File
@@ -1,8 +1 @@
blank_issues_enabled: true
contact_links:
- name: 💬 Discussions
url: https://github.com/DanielVolz/medassist-ng/discussions
about: Ask questions or share ideas in Discussions
- name: 📖 Documentation
url: https://github.com/DanielVolz/medassist-ng#readme
about: Check the README for setup and usage instructions
+2 -1
View File
@@ -1,5 +1,6 @@
name: Feature Request
name: Feature Request
description: Suggest a new feature or improvement
title: "[Feature]: "
labels: ["enhancement", "triage"]
body:
- type: markdown
@@ -0,0 +1,42 @@
---
description: 'Provide principal-level software engineering guidance with focus on engineering excellence, technical leadership, and pragmatic implementation.'
name: 'Principal software engineer'
tools: ['changes', 'search/codebase', 'edit/editFiles', 'extensions', 'web/fetch', 'findTestFiles', 'githubRepo', 'new', 'openSimpleBrowser', 'problems', 'runCommands', 'runTasks', 'runTests', 'search', 'search/searchResults', 'runCommands/terminalLastCommand', 'runCommands/terminalSelection', 'testFailure', 'usages', 'vscodeAPI', 'github']
---
# Principal software engineer mode instructions
You are in principal software engineer mode. Your task is to provide expert-level engineering guidance that balances craft excellence with pragmatic delivery as if you were Martin Fowler, renowned software engineer and thought leader in software design.
## Core Engineering Principles
You will provide guidance on:
- **Engineering Fundamentals**: Gang of Four design patterns, SOLID principles, DRY, YAGNI, and KISS - applied pragmatically based on context
- **Clean Code Practices**: Readable, maintainable code that tells a story and minimizes cognitive load
- **Test Automation**: Comprehensive testing strategy including unit, integration, and end-to-end tests with clear test pyramid implementation
- **Quality Attributes**: Balancing testability, maintainability, scalability, performance, security, and understandability
- **Technical Leadership**: Clear feedback, improvement recommendations, and mentoring through code reviews
## Implementation Focus
- **Requirements Analysis**: Carefully review requirements, document assumptions explicitly, identify edge cases and assess risks
- **Implementation Excellence**: Implement the best design that meets architectural requirements without over-engineering
- **Pragmatic Craft**: Balance engineering excellence with delivery needs - good over perfect, but never compromising on fundamentals
- **Forward Thinking**: Anticipate future needs, identify improvement opportunities, and proactively address technical debt
## Technical Debt Management
When technical debt is incurred or identified:
- **MUST** offer to create GitHub Issues using the `create_issue` tool to track remediation
- Clearly document consequences and remediation plans
- Regularly recommend GitHub Issues for requirements gaps, quality issues, or design improvements
- Assess long-term impact of untended technical debt
## Deliverables
- Clear, actionable feedback with specific improvement recommendations
- Risk assessments with mitigation strategies
- Edge case identification and testing strategies
- Explicit documentation of assumptions and decisions
- Technical debt remediation plans with GitHub Issue creation
+104 -53
View File
@@ -12,10 +12,20 @@ You are the release manager for **MedAssist-ng**. Your job is to guide code from
## Critical Safety Rules
- **Do EXACTLY what the user asks — nothing more.** If the user says "create a PR and merge to main", do only that. Do NOT also start a release. If the user says "do a release", do only the release. Never chain additional steps the user did not request.
- **NEVER release, tag, push, or create PRs without explicit user confirmation at each step.** Always present your plan and wait for approval.
- **This specialist agent is the only agent allowed to perform remote release operations after explicit confirmation.**
- **Use GitHub MCP for all GitHub remote operations except release publishing.** Issues, PRs, workflow checks/logs, project updates, comments, merges, and branch/PR metadata must go through GitHub MCP tools only.
- **Use `gh` CLI only for GitHub release creation and editing** (`gh release create`, `gh release edit`). GitHub MCP lacks a create/edit release tool, so `gh` CLI is the approved exception for this single operation.
- **NEVER push directly to `main`** — GitHub will reject it (`GH013: Repository rule violations`). All changes go through Pull Requests.
- **NEVER skip CI checks.** Wait for all status checks to pass before merging.
- **Testing ownership belongs to `@testing-manager`**. Do not plan or implement tests in this agent; request/hand off to testing-manager when testing work is required.
- **Pre-PR local quality gate is mandatory**: before creating any PR, require confirmation from `@testing-manager` that lint is clean (no errors and no simple/fixable warnings) and all relevant tests passed locally.
- **No CI-first failures policy**: do not use GitHub CI as first detection for obvious test/lint regressions; those must be reproducible and fixed locally before PR creation.
- **Never trust a dirty local `main` workspace as release truth**: before splitting work, branching, or preparing a PR, fetch the authoritative remote and verify whether the local workspace is ahead/behind/stale relative to `<remote>/main`.
- **If the main workspace is dirty, behind, or contains mixed stale copies of already-merged work, quarantine it**: do not branch from it and do not keep splitting PRs out of it. Create a fresh branch/worktree from the authoritative remote main and transplant only the intended scope.
- **`git stash` is temporary only**: use it only as a short-lived safety mechanism during an active transition. Never use stash as the final way to make a workspace appear clean, and never leave user changes hidden in stash at task completion unless the user explicitly asked for that exact outcome.
- **"Local `main` must be clean" means zero leftover local changes**: when the user asks for a clean local `main`, finish with no uncommitted tracked changes, no leftover untracked files from the completed task, and no hidden task residue parked in stash as a substitute for cleanup.
- **Track all work in the GitHub Project board.** Every PR should reference an issue. Move issues through the board as work progresses.
- **ALWAYS verify Project board status after merge.** The `project-auto-done.yml` workflow moves items to "Done" automatically when issues close or PRs merge. Verify it ran successfully; if it didn't, move items manually via GraphQL (see Task 6).
@@ -44,16 +54,27 @@ This repository intentionally uses only two operational agents for CI/CD handoff
- During active PR/release work, `@release-manager` must keep all relevant current workflows in view until completion.
- If a failing workflow is testing-related (`test.yml` or `e2e.yml`), immediately hand off diagnosis/fix to `@testing-manager`.
## GitHub CLI Safety (Non-Interactive Only)
## GitHub Operations (GitHub MCP + gh CLI Exception)
- Never use `gh` commands that can open an interactive pager and block execution (requiring `q`).
- Always run `gh` commands in non-interactive mode using `GH_PAGER=cat` (or `--no-pager` where supported).
- Do not use these commands in agent flows:
- `gh pr view 155 --json statusCheckRollup --jq '.statusCheckRollup[] | {name:.name,conclusion:.conclusion,detailsUrl:.detailsUrl,workflowName:.workflowName}'`
- `SHA=$(gh pr view 155 --json headRefOid --jq .headRefOid) && gh api repos/DanielVolz/medassist-ng/commits/$SHA/check-runs --jq '.check_runs[] | {name,conclusion,details_url,html_url,app:.app.name}'`
- Use safe variants instead:
- `GH_PAGER=cat gh pr view <PR_NUMBER> --json statusCheckRollup --jq '<jq-filter>'`
- `GH_PAGER=cat gh api repos/<owner>/<repo>/commits/<sha>/check-runs --jq '<jq-filter>'`
- Use GitHub MCP tools for: issue creation/comments, PR creation/view/merge, workflow status/log inspection, project board updates, and branch/PR metadata lookup.
- **Exception — `gh` CLI for releases only**: Use `gh release create` and `gh release edit` for GitHub release publishing and updates. GitHub MCP does not provide a create/edit release tool.
- Never use `gh` CLI for any other GitHub operation (issues, PRs, merges, workflow checks, etc.).
- Prefer structured MCP operations over shell-based GitHub access so remote actions stay explicit, auditable, and non-interactive.
## Workspace Hygiene And Source-Of-Truth Rules
- The authoritative comparison target is the actual remote default branch used for shipping, normally `github/main` or `origin/main`. Determine it first and use the same remote consistently for fetch/diff/pull decisions.
- Before any PR split or branch creation, run a source-of-truth audit:
1. fetch the authoritative remote
2. inspect `git status`
3. compare local `main` against `<remote>/main`
4. compare intended changes against `<remote>/main`, not only against local `HEAD`
- If a dirty workspace contains files that are already present on `<remote>/main`, treat that workspace as stale local state, not as unshipped work.
- When mixed local changes must be split into multiple PRs, do the classification first: `already upstream`, `intended for current PR`, or `unrelated/local-only`.
- If the classification is unclear, stop using the dirty workspace as the source branch and move the intended scope into fresh worktrees from `<remote>/main`.
- After a PR is merged, do not continue future PR extraction from an older dirty workspace unless it has been explicitly re-synced and re-audited against the authoritative remote.
- **Cleanup is mandatory**: after a temporary worktree, scratch branch, or quarantine workspace is no longer needed, remove it promptly. Do not leave obsolete local worktrees hanging around in Source Control after the task is complete.
- If `git stash` was used temporarily during the flow, either restore and resolve it or intentionally discard it before finishing. Do not end the task with a stash that merely hides leftover scope.
---
@@ -89,14 +110,42 @@ PR #141: "fix: planner checkbox layout on single line"
---
## PR Metadata (MANDATORY)
Every Pull Request MUST have the following sidebar fields populated at creation time:
| Field | Value | How |
|-------|-------|-----|
| **Assignee** | `DanielVolz` (repo owner) | `--assignee DanielVolz` |
| **Label** | Match the change type: `enhancement` (feat), `bug` (fix), `documentation` (docs) | `--label <label>` |
| **Project** | `@DanielVolz's MedAssist-ng project` | `--project "@DanielVolz's MedAssist-ng project"` |
**Label mapping for PRs:**
| Branch prefix / commit type | Label |
|---|---|
| `feat/` | `enhancement` |
| `fix/` | `bug` |
| `docs/` | `documentation` |
| `chore/` (non-release) | `enhancement` or `bug` depending on content |
| `chore/release-*` | No label needed (release PRs are automated) |
These fields provide traceability, filtering, and project board integration. **Never leave them empty.**
---
## Task 1: Branch, PR, and Merge Workflow
When code changes (features or bug fixes) are complete:
### Step 1: Verify Readiness
1. Check for uncommitted changes: `git status`
2. Confirm testing has been completed by `@testing-manager` and CI is expected to pass.
1. Identify the authoritative shipping remote for `main` (`github` or `origin`) and fetch it.
2. Check for uncommitted changes: `git status`.
3. Compare local `main` and the current workspace against `<remote>/main` before treating any visible diff as unshipped work.
4. If the workspace is dirty, behind, or contains stale copies of already-merged files, quarantine it and create a fresh worktree/branch from `<remote>/main` for the current PR scope.
5. Confirm testing has been completed by `@testing-manager`.
6. Confirm pre-PR local gate is passed: lint clean (no errors and no simple/fixable warnings) and all relevant tests pass locally.
7. Only after local gate is confirmed and the scope is verified against `<remote>/main`, proceed to push/create PR and then monitor CI.
### Step 2: Create Feature Branch
@@ -104,11 +153,13 @@ When code changes (features or bug fixes) are complete:
- Bug fix: `fix/short-description` (e.g., `fix/stock-correction-consumption`)
- Feature: `feat/short-description` (e.g., `feat/refill-tracking`)
- Chore: `chore/short-description`
2. Create and switch to the branch:
2. Create the branch from a clean base that matches `<remote>/main`. If the main workspace was quarantined, use a fresh worktree instead of branching from the dirty repository root.
3. Create and switch to the branch:
```bash
git checkout -b feat/short-description
```
3. Stage and commit changes with a conventional commit message:
4. Move only the intended scope into that branch/worktree. Never carry over unrelated local residue or stale already-upstream files.
5. Stage and commit changes with a conventional commit message:
```bash
git add .
git commit -m "fix: short description of what was fixed"
@@ -117,32 +168,30 @@ When code changes (features or bug fixes) are complete:
### Step 3: Push and Create PR
1. Push the branch:
1. Re-check local gate status before push/PR creation (lint + relevant local tests green).
2. Push the branch:
```bash
git push -u origin feat/short-description
```
2. Create a Pull Request via GitHub CLI, linking the related issue:
```bash
gh pr create --title "fix: short description" --body "Closes #<ISSUE_NUMBER>
Description of changes"
```
Using `Closes #N` in the PR body ensures the issue is automatically moved to "Done" on merge.
3. **Present the PR URL to the user and wait for confirmation.**
3. Create a Pull Request via GitHub MCP with **all metadata fields populated**.
- Set the title to the conventional change summary (for example `fix: short description`).
- Set the body to include `Closes #<ISSUE_NUMBER>` plus a short description of changes.
- Set assignee to `DanielVolz`.
- Set the label to match the change type (`enhancement`, `bug`, or `documentation`).
- Link the PR to `@DanielVolz's MedAssist-ng project`.
- Using `Closes #N` in the PR body ensures the issue is automatically closed on merge.
- Always add an explicit issue comment with the PR link and short fix summary (do not rely on auto-close event only).
4. **Present the PR URL to the user and wait for confirmation.**
### Step 4: Wait for CI and Merge
1. Monitor CI status:
```bash
gh pr checks <PR_NUMBER> --watch
```
1. Monitor CI status via GitHub MCP until all required checks complete.
Required checks: all repository-required checks must pass.
2. If CI fails: analyze the failure, fix it, push again, and re-check.
3. Once CI is green, **ask the user for merge confirmation**, then:
```bash
gh pr merge <PR_NUMBER> --squash --delete-branch
```
4. Switch back to main and pull:
3. Once CI is green, **ask the user for merge confirmation**, then merge the PR via GitHub MCP using squash merge and branch deletion.
4. Re-sync the authoritative local `main` before using it again as a source of truth for any next PR or release step. Do not continue from a previously dirty workspace without another source-of-truth audit.
5. If the requested end state is a clean local `main`, verify that `git status` is empty and that no task-related stash entry remains as hidden residue.
6. Switch back to main and pull:
```bash
git checkout main
git pull origin main
@@ -211,6 +260,8 @@ The script performs these steps in order:
6. Merges the PR (squash + delete branch)
7. Creates a signed tag `vX.Y.Z` and pushes it
**Release precondition:** never start the release flow from a dirty or stale mixed workspace. If the repository root contains unrelated/stale diffs, first switch to a clean base that matches the authoritative remote main.
**The script auto-detects the git remote** (`origin` or `github`) and uses it consistently.
**CI wait behavior:** GitHub Actions can take 10-30 seconds before checks appear on a new PR. The script waits 20 seconds initially, then polls every 15 seconds until checks are registered, then watches them to completion. Maximum wait is 10 minutes.
@@ -355,11 +406,18 @@ Existing installations need to:
### Step 3: Publish
Present the release notes to the user. They will copy them to the GitHub release page or ask you to publish via:
Publish the release via `gh` CLI:
```bash
gh release create vX.Y.Z --title "vX.Y.Z" --notes "RELEASE_NOTES_HERE"
# Write notes to a temp file first, then:
gh release create vX.Y.Z --title "vX.Y.Z" --notes-file /tmp/release-notes-vX.Y.Z.md
# If the release was already auto-created (e.g. by pushing a tag), update it:
gh release edit vX.Y.Z --title "vX.Y.Z" --notes-file /tmp/release-notes-vX.Y.Z.md
```
**Present the published release URL to the user for verification.**
---
## Task 5: README Update Check (MANDATORY for new features)
@@ -408,30 +466,17 @@ All work is tracked in the [GitHub Project board](https://github.com/users/Danie
### Workflow During PRs
1. **Before creating a PR**: Check if a corresponding issue exists on the Project board. If not, create one:
```bash
gh issue create --title "fix: description" --label bug
```
1. **Before creating a PR**: Check if a corresponding issue exists on the Project board. If not, create one via GitHub MCP with the appropriate label.
Issues with `enhancement`, `bug`, or `triage` labels are **automatically added** to the board.
If you open a new `triage` issue to replace an older triage thread for the same topic, close the old triage issue immediately and add a short comment linking to the new canonical issue so only one active triage issue remains per topic.
2. **When creating a PR**: Always reference the issue with `Closes #N` in the PR body so the issue is automatically **closed** on merge. Note: this does NOT move the Project board status — that must be done manually (see step 3).
Also add a direct issue comment with the PR link and a one-line summary for clear issue-thread traceability.
3. **After merge — verify automation**: The `project-auto-done.yml` workflow automatically moves project items to "Done" when issues close or PRs merge. After merge, verify it ran:
```bash
GH_PAGER=cat gh issue view <ISSUE_NUMBER> --json state,projectItems --jq '{state, projects: [.projectItems[] | {title: .title, status: .status.name}]}'
```
3. **After merge — verify automation**: The `project-auto-done.yml` workflow automatically moves project items to "Done" when issues close or PRs merge. After merge, verify issue/project status via GitHub MCP.
**Manual fallback** — if the workflow fails or the item wasn't moved, use GraphQL:
```bash
GH_PAGER=cat gh api graphql -f query='mutation {
updateProjectV2ItemFieldValue(input: {
projectId: "PVT_kwHOADH82s4BO2OT"
itemId: "<ITEM_ID>"
fieldId: "PVTSSF_lAHOADH82s4BO2OTzg9bdkE"
value: { singleSelectOptionId: "ca45af98" }
}) { projectV2Item { id } }
}'
```
**Manual fallback** — if the workflow fails or the item wasn't moved, use GitHub MCP GraphQL/project mutation support with the project/item/field IDs below.
**Known Project field IDs (Status):**
| Status | Option ID |
@@ -453,6 +498,12 @@ All work is tracked in the [GitHub Project board](https://github.com/users/Danie
All three labels trigger the `add-to-project.yml` workflow, which automatically adds the issue to the Project board.
### Weekly Triage Report Hygiene
- There must never be more than one open `Weekly Triage Report - YYYY-MM-DD` issue at the same time.
- Before a new weekly triage report issue is created, close any older open weekly triage report issue and leave a short closing comment.
- If automation creates a new weekly report without closing the old one first, treat that as workflow drift and fix the workflow or close the stale report immediately.
---
## Complete Workflow Summary
@@ -462,7 +513,7 @@ Code complete & validated by testing-manager
1. Ensure a GitHub issue exists (create if not)
2. Create feature branch (fix/... or feat/...)
3. Commit, push, create PR (with "Closes #N" in body)
3. Commit, push, create PR (with "Closes #N" in body, assignee, label, project)
4. Wait for CI (all required checks)
5. Merge PR to main (squash + delete branch)
6. Verify issue moved to "Done" on Project board (automated by `project-auto-done.yml`; fallback: GraphQL, see Task 6)
+90 -15
View File
@@ -14,9 +14,18 @@ You are the testing manager for **MedAssist-ng**. Your job is to ensure every fe
- **Tests are mandatory**: Every new feature and every bug fix MUST have corresponding tests.
- **Fix bugs, don't test around them**: If behavior is incorrect, fix the implementation first, then write tests for correct behavior.
- **Linting is a hard quality gate**: resolve all lint errors and all simple/fixable warnings before handoff, especially before PR handoff from `@release-manager`.
- **Pre-PR local gate is mandatory**: before any PR is created, all lint errors must be fixed and all relevant tests must pass locally.
- **No CI-first failures**: tests must fail locally when broken and be fixed locally before PR handoff; do not rely on GitHub CI to discover obvious regressions.
- **Run tests non-interactively**: Use `CI=true` where required to avoid watch-mode hangs.
- **Playwright must disable auto-open reports**: Always prefix Playwright runs with `PLAYWRIGHT_HTML_OPEN=never`.
- **Keep CI E2E stable**: Use `PLAYWRIGHT_WORKERS=1` in CI unless a change is explicitly requested.
- **Never start interactive report servers**: Do not run commands that wait for manual input (for example Playwright HTML report server: `Serving HTML report ... Press Ctrl+C to quit`). Always use finite, non-interactive commands and reporters.
- **Use GitHub MCP for all GitHub workflow/PR inspection. Never use `gh` CLI.** When triaging CI, inspect workflow runs, check runs, logs, PR state, and issue context through GitHub MCP tools only.
- **No remote git operations**: Do not push, merge, create PRs, tags, or releases. Hand over to `@release-manager` when ready.
- **Keep scope focused**: Do not fix unrelated failures unless explicitly requested.
- **Tests must be valid and reliable**: no fake-green tests, no assertions that skip core logic, no over-mocking that hides real behavior, and no brittle timing-only assertions.
- **Regression prevention is mandatory**: every fixed bug must get a deterministic regression test that fails before the fix and passes after it.
## CI/CD Ownership Boundary
@@ -26,9 +35,10 @@ You are the testing manager for **MedAssist-ng**. Your job is to ensure every fe
## Test Stack & Locations
- **Backend**: Vitest 2.1 + v8 coverage
- **Frontend unit/integration**: Vitest
- **E2E**: Playwright
- **Backend unit/integration**: Vitest 4 + v8 coverage (`backend/src/test/*.test.ts`)
- **Frontend unit/integration**: Vitest 4 + Testing Library (`frontend/src/test/**`)
- **Frontend E2E**: Playwright (`frontend/e2e/**`) using stable config for CI-like runs
- **Static quality gates**: TypeScript via `tsc --noEmit` and Biome via `npx biome check .`
Primary locations:
@@ -36,39 +46,83 @@ Primary locations:
- Frontend tests: `frontend/src/test/**`
- Playwright E2E: `frontend/e2e/**`
## Testing Strategy Defaults
- **Default to targeted validation, not shotgun runs**: start with the smallest test command that exercises the changed behavior.
- **Do not run every test by default**: broad full-suite runs are reserved for cross-cutting changes, shared infrastructure, release gates, or when focused runs show signal that wider breakage is plausible.
- **Frontend browser behavior must use Playwright when the real browser matters**: routing, auth/session flows, focus behavior, form workflows, responsive behavior, optimistic UI rollbacks, and other end-to-end user journeys should be validated in Playwright instead of only Vitest.
- **Frontend component logic that does not require a real browser stays in Vitest**: hooks, utilities, component state, rendering branches, and request handling should usually be validated with targeted Vitest tests first.
- **Backend changes should usually prove three things separately**: affected Vitest regression scope, backend static gate (`tsc --noEmit` through `npm run check`), and broader backend suite only when the change touches shared route/service behavior.
- **Escalate only when justified**: run full backend/frontend suites or broader Playwright coverage only if the touched area is shared, the failure mode is unclear, CI disproves the focused pass, or release-manager explicitly needs a broader pre-PR gate.
## Required Test Workflow
1. Identify changed behavior and expected outcomes.
2. Add/update tests near the affected feature.
3. Run the smallest relevant subset first.
4. Expand to broader suites if subset passes.
5. Report what was run, what passed, and any remaining known failures.
2. Map the change to the correct layer: backend Vitest, frontend Vitest, or frontend Playwright browser coverage.
3. Add/update tests near the affected feature.
4. Run the smallest relevant subset first.
5. Expand to broader suites only if the change is cross-cutting or the focused run indicates wider risk.
6. Run lint + required local test/build gates before PR handoff.
7. Report what was run, what passed, and why broader suites were or were not needed.
## Lint and Quality Gates
- Run lint as part of every validation cycle when code changed.
- Required before PR creation and before PR-ready handoff from `@release-manager`: no lint errors and no simple/fixable warnings left unresolved.
- If lint fails, fix root causes first, then re-run affected tests.
- Required before PR creation: relevant local tests must pass (`backend`/`frontend` unit tests and relevant Playwright scope when affected).
- If CI fails after a claimed local pass, treat it as a test validity gap and close that gap with deterministic local reproduction.
- Use `tsc` intentionally: backend and frontend type checks are part of the local gate and should be run through the existing `npm run check` scripts unless a narrower `tsc --noEmit` repro is needed during diagnosis.
Recommended commands:
```bash
npm run lint
cd backend && npm run check
cd frontend && npm run check
```
## Commands
### Backend
```bash
cd backend && CI=true npm test
cd backend && npx tsc --version
cd backend && npx vitest --version
cd backend && CI=true npm run test:run -- src/test/doses.test.ts
cd backend && CI=true npm run test:run
cd backend && CI=true npm run test:coverage
cd backend && CI=true npm test -- -t "test name"
cd backend && CI=true npm run test:run -- src/test/doses.test.ts src/test/integration.test.ts
cd backend && CI=true npm run test:run -- -t "test name"
```
### Frontend
```bash
cd frontend && CI=true npm test
cd frontend && npx tsc --version
cd frontend && npx vitest --version
cd frontend && CI=true npm run test:run -- src/test/pages/DashboardPage.test.tsx
cd frontend && CI=true npm run test:run
cd frontend && CI=true npm run test:coverage
cd frontend && CI=true npm run test:run -- src/test/pages/DashboardPage.test.tsx src/test/hooks/useDoses.test.ts
cd frontend && CI=true npm run test:run -- -t "test name"
cd frontend && npm run lint
cd frontend && npm run check
cd frontend && npm run build
```
### Playwright E2E
```bash
cd frontend && npm run test:e2e
cd frontend && npm run test:e2e -- --project=chromium
cd frontend && npm run test:e2e:ui
cd frontend && npm run test:e2e:headed
cd frontend && npx playwright --version
cd frontend && PLAYWRIGHT_HTML_OPEN=never npm run test:e2e -- --grep "schedule"
cd frontend && PLAYWRIGHT_HTML_OPEN=never npm run test:e2e -- frontend/e2e/schedule.spec.ts
cd frontend && PLAYWRIGHT_HTML_OPEN=never npm run test:e2e
cd frontend && PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=1 npm run test:e2e -- --workers=1
cd frontend && PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=4 npm run test:e2e:local
cd frontend && PLAYWRIGHT_HTML_OPEN=never npm run test:e2e -- --project=chromium
# Never use interactive UI/headed/report-server commands in agent runs.
# Do not use: npm run test:e2e:ui, npm run test:e2e:headed, npx playwright show-report
```
## Backend Test Patterns
@@ -77,13 +131,31 @@ cd frontend && npm run test:e2e:headed
- Validate both status codes and response payloads.
- Add regression tests for every fixed bug.
- Keep tests deterministic and isolated.
- Validate observable behavior, not implementation details.
## E2E Test Patterns
- Use stable selectors and explicit assertions.
- Avoid flaky timing assumptions; prefer waiting for concrete UI states.
- For auth-sensitive flows, handle both auth-enabled and auth-disabled environments when applicable.
- For CI triage, inspect failed run logs first, then reproduce locally with targeted specs.
- For CI triage, inspect failed run logs via GitHub MCP first, then reproduce locally with targeted specs.
- Prefer user-meaningful assertions (visible state, persisted effects, API-visible outcomes) over brittle internal hooks.
- Prefer the narrowest browser scenario that covers the changed user path before considering a full stable suite.
## When To Run Broad Suites
- Run the full backend Vitest suite when shared backend services, route helpers, schema-adjacent behavior, or broad scheduling logic changes can affect multiple route families.
- Run the full frontend Vitest suite when shared context/providers, global hooks, router shells, or common rendering utilities change.
- Run broader Playwright coverage when the change spans multiple user journeys, modifies auth/navigation foundations, changes network synchronization behavior, or a targeted browser test is insufficient to prove safety.
- For small isolated fixes, a narrow Vitest file, a narrow Playwright spec, and the relevant `check` command are usually enough.
## Test Validity Checklist
- The test fails when the real target logic is intentionally broken.
- The assertion verifies functional behavior, not just mocked calls.
- Mocks/stubs are minimal and do not replace the unit under test.
- The test is deterministic across repeated local and CI runs.
- The test protects against the specific regression that was fixed.
## CI Failure Triage
@@ -114,6 +186,9 @@ When test checks fail:
Testing work is complete when:
- Required tests exist and validate intended behavior.
- Tests are proven valid (not fake-green) and reliable.
- Lint is clean: no errors and no simple/fixable warnings left.
- Pre-PR local gate passed: lint and all relevant tests pass locally before handoff for PR creation.
- Relevant local test commands pass.
- CI test failures are resolved or clearly documented with rationale.
- No temporary debugging files remain in the workspace.
+12 -69
View File
@@ -1,76 +1,19 @@
# MedAssist-ng - AI Coding Instructions
# MedAssist-ng - Copilot Entry Point
## Purpose
## VERY IMPORTANT
Use `AGENTS.md` as the canonical governance source. Read the referenced skill files before starting any task.
- Always keep agent work memory updated in `doku/memory_notes.md` so progress and decisions remain recoverable across context loss.
- Always keep a user-facing work report updated in `doku/report.md` so completed work is easy to review.
- This memory/report rule replaces the previous `doku/APP_BEHAVIOR.md` persistence requirement.
## Project Orientation (Read First)
Use `AGENTS.md` as the single source of truth for all governance, workflow, and skill rules.
- **Product**: MedAssist-ng is a medication planner with stock tracking, reminders (email/push), refill history, and schedule sharing.
- **Tech stack**: React + TypeScript + Vite (`frontend/`), Fastify + TypeScript + Drizzle + SQLite (`backend/`).
- **Request path**: Frontend uses `/api/*` only; backend route handlers live in `backend/src/routes/`.
- **Primary backend modules**:
- Auth/SSO: `backend/src/routes/auth.ts`, `backend/src/routes/oidc.ts`, `backend/src/plugins/auth.ts`
- Medications/data: `backend/src/routes/medications.ts`, `backend/src/db/schema.ts`
- Reminders: `backend/src/services/reminder-scheduler.ts`, `backend/src/routes/planner.ts`, `backend/src/routes/settings.ts`
- **Primary frontend modules**:
- Pages: `frontend/src/pages/`
- Shared app state: `frontend/src/context/AppContext.tsx`
- Domain hooks: `frontend/src/hooks/`
- Translations: `frontend/src/i18n/en.json`, `frontend/src/i18n/de.json`
## Required Startup Steps
Use this orientation for quick navigation before applying the rules below.
1. Read `AGENTS.md` first.
2. Identify triggered skills from `AGENTS.md` and read each referenced `SKILL.md` before making changes.
3. Follow delegation boundaries exactly (`@testing-manager` for testing, `@release-manager` for release orchestration).
## Always-On Rules
## Scope
- English only for project artifacts.
- **NEVER run remote git commands** — no `git push`, no `gh pr create/merge`, no `gh release`, no `git tag`. Prepare locally, then hand off to `@release-manager`.
- Testing work belongs to `@testing-manager`.
- PR/release/CI orchestration belongs to `@release-manager`.
- Keep changes local, focused, and consistent with existing UI/API patterns.
- Remove obsolete code when re-implementing — never leave dead code behind.
- **Document behavioral discoveries**: When you discover or clarify how a feature works (e.g., what triggers notifications, how thresholds interact, which code paths exist), **always** add or update the relevant section in `doku/APP_BEHAVIOR.md`. This is mandatory — do not rely on conversation context alone.
## MedAssist Essentials
- Frontend calls backend through `/api/*`.
- DB changes must stay backward-compatible (schema default + alter migration + null-safe reads).
---
## Skills (MANDATORY — read before every task)
Before starting any task, identify which skills apply and **read their full SKILL.md file** for detailed rules.
| Skill | Trigger | File |
|---|---|---|
| **Architecture Guard** | API endpoints, frontend API calls, routing, code placement | `.github/skills/medassist-architecture-guard/SKILL.md` |
| **DB Compatibility** | Persisted data, schema changes, migrations | `.github/skills/medassist-db-compat-check/SKILL.md` |
| **i18n Enforcer** ⚠️ | Any user-facing text in frontend or backend | `.github/skills/medassist-i18n-enforcer/SKILL.md` |
| **UI Consistency** | UI flows, modals, buttons, forms, settings | `.github/skills/medassist-ui-consistency/SKILL.md` |
| **Frontend Polish** | Visual quality improvements | `.github/skills/medassist-frontend-polish/SKILL.md` |
| **Security Sanity** | Backend routes, auth, file handling, external input | `.github/skills/medassist-security-sanity/SKILL.md` |
| **Observability Guard** | Services, schedulers, startup, failure handling | `.github/skills/medassist-observability-guard/SKILL.md` |
| **Config Change Guard** | `.env`, Docker, Vite proxy, runtime defaults | `.github/skills/medassist-config-change-guard/SKILL.md` |
| **Doc Sync Guard** | Behavior changes, setup, env vars, workflows | `.github/skills/medassist-doc-sync-guard/SKILL.md` |
| **Testing Handoff** | Writing/running tests, CI test failures | `.github/skills/medassist-testing-handoff/SKILL.md` |
| **Release Handoff** | Branch push, PR, merge, tagging, release | `.github/skills/medassist-release-handoff/SKILL.md` |
| **Skill Quality Review** | Creating/modifying skills | `.github/skills/medassist-skill-quality-review/SKILL.md` |
### Non-negotiable parity rules (always apply)
1. **Desktop + Mobile Parity**: Medication edit has two paths — `MedicationsPage.tsx` (desktop) and `MobileEditModal` (mobile). **Always update BOTH**.
2. **Notification Dual Code Paths**: Notifications have two code paths — `backend/src/services/reminder-scheduler.ts` (scheduler) and `backend/src/routes/planner.ts` (manual). **Always update BOTH**.
---
## Delegation
- **Testing handoff → `@testing-manager`**: test planning, writing, execution, CI test triage.
- **Release handoff → `@release-manager`**: PR/release orchestration, merge flow, workflow monitoring.
## Key References
- Canonical governance: `AGENTS.md`
- Skill files: `.github/skills/*/SKILL.md`
- Specialist agents: `.github/agents/testing-manager.agent.md`, `.github/agents/release-manager.agent.md`
This file intentionally stays minimal to prevent duplicated or conflicting instructions.
+14 -1
View File
@@ -7,6 +7,7 @@ updates:
schedule:
interval: "weekly"
day: "monday"
time: "06:20"
open-pull-requests-limit: 10
labels:
- "dependencies"
@@ -22,6 +23,7 @@ updates:
schedule:
interval: "weekly"
day: "monday"
time: "06:10"
open-pull-requests-limit: 10
labels:
- "dependencies"
@@ -37,9 +39,15 @@ updates:
schedule:
interval: "weekly"
day: "monday"
time: "06:00"
open-pull-requests-limit: 5
labels:
- "dependencies"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"
# GitHub Actions
- package-ecosystem: "github-actions"
@@ -47,7 +55,12 @@ updates:
schedule:
interval: "weekly"
day: "monday"
time: "06:30"
open-pull-requests-limit: 5
labels:
- "dependencies"
- "ci"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"
+1 -1
View File
@@ -13,7 +13,7 @@ Use one governance source to avoid duplicated or conflicting policy text.
## Skills
- `medassist-karpathy-core` — enforce assumption clarity, simplicity, surgical diffs, and verifiable execution.
- `medassist-karpathy-core` — enforce think-before-coding, simplicity-first changes, surgical diffs, and goal-driven verification.
- `medassist-architecture-guard` — enforce frontend/backend boundary and `/api/*` data-flow conventions.
- `medassist-db-compat-check` — enforce backward-compatible SQLite/Drizzle schema changes.
- `medassist-i18n-enforcer` — enforce translation-key-only UI copy with EN/DE parity.
@@ -0,0 +1,69 @@
---
name: medassist-karpathy-core
description: Apply assumption clarity, simplicity-first implementation, surgical diffs, and goal-driven verification for non-trivial coding tasks.
---
# Skill Instructions
Use this skill as an execution style layer for implementation tasks where overengineering, broad refactors, or unclear assumptions are likely.
## Use When
- The request is ambiguous and assumptions must be made explicit.
- The change can easily balloon in scope.
- A bug fix or feature needs explicit success criteria and verification.
- You need to keep diffs minimal and directly tied to the request.
## Do Not Use When
- The task is trivial and can be completed safely without extra process overhead.
- The task is only about ownership routing (use `medassist-testing-handoff` / `medassist-release-handoff`).
- The task is only about domain guardrails already covered by specialized skills (architecture, DB, i18n, UI, security, config, observability).
## Core Principles
### 1. Think Before Coding
- Do not assume silently.
- State assumptions explicitly.
- If multiple interpretations exist, present them instead of picking one invisibly.
- If uncertain or blocked by ambiguity, stop and ask.
- If a simpler approach exists, call it out.
### 2. Simplicity First
- Implement the minimum code required to solve the asked problem.
- Do not add speculative features, abstractions, or configurability.
- Avoid defensive handling for impossible scenarios.
- If the solution feels overcomplicated, simplify before finalizing.
### 3. Surgical Changes
- Touch only lines required for the request.
- Do not refactor unrelated areas.
- Match existing local style and patterns.
- Remove only unused code introduced by your own change.
- If unrelated dead code is discovered, mention it but do not remove it unless requested.
### 4. Goal-Driven Execution
- Translate requests into verifiable outcomes before implementation.
- For multi-step tasks, define short steps with checks.
- Verify the requested behavior explicitly before declaring done.
Example execution frame:
```text
1. [Step] -> verify: [check]
2. [Step] -> verify: [check]
3. [Step] -> verify: [check]
```
## Response Format
When this skill is used, report briefly:
- Assumptions made (or clarifications requested)
- Why the chosen approach is the simplest viable one
- What was changed (and what was intentionally not changed)
- Verification performed and result
@@ -26,6 +26,16 @@ Use `medassist-frontend-polish` only after these guardrails are satisfied.
- Avoid custom inline modal/button patterns that diverge from project design.
- Prefer extending existing CSS classes/styles instead of introducing parallel styling systems.
### Modal requirements (non-negotiable)
Every modal/overlay **must** follow these rules:
1. **Escape key**: Call `useEscapeKey(active, onClose)` from `hooks/useEscapeKey`. This registers a document-level `keydown` listener that works regardless of focus. **Never** rely on `onKeyDown` on an overlay div — it only fires when the overlay has focus, which almost never happens.
2. **Scroll lock**: Call `useScrollLock(active)` from `hooks/useScrollLock` if the modal is **not** already covered by App.tsx's centralized `useScrollLock` call. Page-local modals (e.g. `ReportModal`, `ExportModal`) must call it themselves.
3. **Click-outside close**: The overlay div gets `onClick={onClose}`, and `.modal-content` gets `onClick={(e) => e.stopPropagation()}`.
4. **Key event containment**: `.modal-content` gets `onKeyDown={(e) => { if (e.key !== "Escape") e.stopPropagation(); }}` — this prevents non-Escape keys from leaking out while still allowing Escape to propagate to the document-level handler.
5. **Nested sub-modals** (e.g. edit-stock inside MedDetailModal): Use `useEscapeKey` with `{ capture: true }` so the innermost modal intercepts Escape before the parent's handler fires.
## Decision Heuristics
1. If an equivalent component exists, reuse it.
@@ -0,0 +1,27 @@
name: Close inactive issues
on:
schedule:
- cron: "30 1 * * *"
workflow_dispatch:
jobs:
close-issues:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- name: Mark and close stale issues
uses: actions/stale@v10
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
days-before-issue-stale: 30
days-before-issue-close: 14
stale-issue-label: stale
stale-issue-message: "This issue is stale because it has been open for 30 days with no activity."
close-issue-message: "This issue was closed because it has been inactive for 14 days since being marked as stale."
days-before-pr-stale: -1
days-before-pr-close: -1
exempt-issue-labels: pinned,security
operations-per-run: 200
@@ -0,0 +1,37 @@
name: Dependabot Automerge
on:
pull_request_target:
types:
- opened
- reopened
- synchronize
- ready_for_review
permissions:
contents: write
pull-requests: write
jobs:
enable-automerge:
if: github.actor == 'dependabot[bot]'
runs-on: ubuntu-latest
steps:
- name: Read Dependabot metadata
id: metadata
uses: dependabot/fetch-metadata@v3
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Enable auto-merge for safe updates
if: >-
(steps.metadata.outputs.package-ecosystem == 'npm' ||
steps.metadata.outputs.package-ecosystem == 'github_actions') &&
(steps.metadata.outputs.update-type == 'version-update:semver-minor' ||
steps.metadata.outputs.update-type == 'version-update:semver-patch')
uses: peter-evans/enable-pull-request-automerge@v3
with:
token: ${{ secrets.GITHUB_TOKEN }}
pull-request-number: ${{ github.event.pull_request.number }}
merge-method: squash
+65 -16
View File
@@ -4,12 +4,27 @@ on:
push:
branches: [main]
tags: ['v*']
paths:
- 'backend/**'
- 'frontend/**'
- 'docker-compose.yml'
- 'docker-compose.dev.yml'
- '.github/workflows/docker-build.yml'
workflow_dispatch:
inputs:
tag:
description: 'Image tag (leave empty for "latest")'
description: 'Image/release tag (e.g. v1.19.1 or latest)'
required: false
default: ''
create_release:
description: 'Create GitHub release entry (requires tag starting with v)'
required: false
default: false
type: boolean
concurrency:
group: docker-build-${{ github.ref }}
cancel-in-progress: true
# Default minimal permissions
permissions:
@@ -48,10 +63,10 @@ jobs:
uses: actions/checkout@v6
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
uses: docker/setup-buildx-action@v4
- name: Log in to Container Registry
uses: docker/login-action@v3
uses: docker/login-action@v4
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -59,7 +74,7 @@ jobs:
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
uses: docker/metadata-action@v6
with:
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/medassist-ng-${{ matrix.image }}
tags: |
@@ -70,7 +85,7 @@ jobs:
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
- name: Build and push
uses: docker/build-push-action@v6
uses: docker/build-push-action@v7
with:
context: ${{ matrix.context }}
push: true
@@ -83,12 +98,12 @@ jobs:
sbom: false
# =============================================================================
# Create GitHub Release (only on tag push)
# Create GitHub Release (on tag push or manual dispatch with create_release)
# =============================================================================
create-release:
runs-on: ubuntu-latest
needs: build-and-push
if: startsWith(github.ref, 'refs/tags/v')
if: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'workflow_dispatch' && github.event.inputs.create_release == 'true')
permissions:
contents: write
@@ -98,10 +113,31 @@ jobs:
with:
fetch-depth: 0 # Fetch all history for changelog generation
- name: Resolve current tag
id: current_tag
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
CURRENT_TAG="${{ github.event.inputs.tag }}"
else
CURRENT_TAG="${GITHUB_REF#refs/tags/}"
fi
if [ -z "$CURRENT_TAG" ]; then
echo "Release tag is required. Provide workflow_dispatch input 'tag'."
exit 1
fi
if [[ "$CURRENT_TAG" != v* ]]; then
echo "Release tag must start with 'v' (example: v1.19.1)."
exit 1
fi
echo "value=$CURRENT_TAG" >> "$GITHUB_OUTPUT"
- name: Check if release exists
id: check_release
run: |
CURRENT_TAG=${GITHUB_REF#refs/tags/}
CURRENT_TAG="${{ steps.current_tag.outputs.value }}"
if gh release view "$CURRENT_TAG" &>/dev/null; then
echo "exists=true" >> $GITHUB_OUTPUT
echo "Release $CURRENT_TAG already exists, skipping creation"
@@ -115,25 +151,36 @@ jobs:
if: steps.check_release.outputs.exists == 'false'
id: prev_tag
run: |
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
CURRENT_TAG="${{ steps.current_tag.outputs.value }}"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
PREV_TAG=$(git tag --sort=-v:refname | grep '^v' | grep -vx "$CURRENT_TAG" | head -1 || true)
else
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
fi
echo "tag=${PREV_TAG}" >> $GITHUB_OUTPUT
- name: Generate changelog
if: steps.check_release.outputs.exists == 'false'
id: changelog
run: |
CURRENT_TAG=${GITHUB_REF#refs/tags/}
CURRENT_TAG="${{ steps.current_tag.outputs.value }}"
PREV_TAG="${{ steps.prev_tag.outputs.tag }}"
echo "## What's Changed" > changelog.md
echo "## What's New" > changelog.md
echo "" >> changelog.md
echo "This release includes updates and fixes shipped with ${CURRENT_TAG}." >> changelog.md
echo "" >> changelog.md
echo "### Highlights" >> changelog.md
echo "" >> changelog.md
if [ -n "$PREV_TAG" ]; then
# Get commits between tags
git log ${PREV_TAG}..${CURRENT_TAG} --pretty=format:"* %s (%h)" --no-merges >> changelog.md
echo "Changes from ${PREV_TAG} to ${CURRENT_TAG}:" >> changelog.md
git log ${PREV_TAG}..${CURRENT_TAG} --pretty=format:"- %s (%h)" --no-merges >> changelog.md
else
# First release - get recent commits
git log -20 --pretty=format:"* %s (%h)" --no-merges >> changelog.md
echo "Recent shipped commits:" >> changelog.md
git log -20 --pretty=format:"- %s (%h)" --no-merges >> changelog.md
fi
echo "" >> changelog.md
@@ -149,8 +196,10 @@ 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 }}
body_path: changelog.md
generate_release_notes: false
draft: false
+23 -6
View File
@@ -3,18 +3,33 @@ name: E2E Tests
on:
pull_request:
branches: [main]
paths:
- 'frontend/**'
- 'backend/**'
- '.github/workflows/e2e.yml'
# Minimal permissions for security
permissions:
contents: read
jobs:
changes:
name: Detect E2E relevance
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
e2e_relevant: ${{ steps.filter.outputs.e2e_relevant }}
steps:
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
e2e_relevant:
- 'frontend/**'
- 'backend/**'
e2e:
name: Playwright E2E
needs: changes
if: needs.changes.outputs.e2e_relevant == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
@@ -50,11 +65,13 @@ jobs:
run: npx playwright test --project=chromium
env:
CI: true
PLAYWRIGHT_WORKERS: 1
PLAYWRIGHT_HTML_OPEN: never
JWT_SECRET: e2e-test-secret-that-is-long-enough
SESSION_SECRET: e2e-test-session-secret-long-enough
- name: Upload Playwright report
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-report
@@ -62,7 +79,7 @@ jobs:
retention-days: 7
- name: Upload test results
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-results
+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@v7
uses: actions/github-script@v9
with:
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
script: |
+144
View File
@@ -0,0 +1,144 @@
name: Sync Project Fields
on:
issues:
types: [opened, labeled, unlabeled, reopened]
permissions: {}
jobs:
sync-fields:
name: Sync Type/Priority fields from labels
runs-on: ubuntu-latest
steps:
- name: Sync fields
uses: actions/github-script@v9
with:
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
script: |
const projectId = 'PVT_kwHOADH82s4BO2OT';
const issueNodeId = context.payload.issue.node_id;
const issueNumber = context.payload.issue.number;
const labels = (context.payload.issue.labels || []).map(l => l.name.toLowerCase());
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
const getProjectItem = async () => {
const data = await github.graphql(`
query($nodeId: ID!) {
node(id: $nodeId) {
... on Issue {
projectItems(first: 20) {
nodes {
id
project { id }
}
}
}
}
}
`, { nodeId: issueNodeId });
const items = data.node?.projectItems?.nodes || [];
return items.find(item => item.project.id === projectId) || null;
};
let projectItem = await getProjectItem();
// add-to-project may run in parallel; retry briefly before giving up
for (let i = 0; !projectItem && i < 6; i++) {
console.log(`Issue #${issueNumber} not in project yet. Retry ${i + 1}/6...`);
await sleep(10000);
projectItem = await getProjectItem();
}
if (!projectItem) {
console.log(`Issue #${issueNumber} is not in project board. Skipping field sync.`);
return;
}
const fieldsData = await github.graphql(`
query($projectId: ID!) {
node(id: $projectId) {
... on ProjectV2 {
fields(first: 50) {
nodes {
... on ProjectV2SingleSelectField {
id
name
options {
id
name
}
}
}
}
}
}
}
`, { projectId });
const singleSelectFields = fieldsData.node?.fields?.nodes || [];
const byName = new Map(singleSelectFields.map(f => [f.name, f]));
const typeField = byName.get('Type');
const priorityField = byName.get('Priority');
if (!typeField && !priorityField) {
console.log('Neither Type nor Priority field found. Nothing to update.');
return;
}
const pickOptionId = (field, optionName) => {
if (!field || !optionName) return null;
const opt = (field.options || []).find(o => o.name.toLowerCase() === optionName.toLowerCase());
return opt?.id || null;
};
let typeName = null;
if (labels.includes('bug')) typeName = 'Bug';
else if (labels.includes('enhancement')) typeName = 'Feature';
else if (labels.includes('documentation')) typeName = 'Documentation';
let priorityName = null;
if (labels.includes('priority/high')) priorityName = 'High';
else if (labels.includes('priority/low')) priorityName = 'Low';
else if (labels.includes('priority/medium')) priorityName = 'Medium';
else if (labels.includes('triage')) priorityName = 'Medium';
const updates = [];
const typeOptionId = pickOptionId(typeField, typeName);
if (typeField && typeOptionId) {
updates.push({ fieldId: typeField.id, optionId: typeOptionId, fieldName: 'Type', valueName: typeName });
}
const priorityOptionId = pickOptionId(priorityField, priorityName);
if (priorityField && priorityOptionId) {
updates.push({ fieldId: priorityField.id, optionId: priorityOptionId, fieldName: 'Priority', valueName: priorityName });
}
for (const update of updates) {
await github.graphql(`
mutation($projectId: ID!, $itemId: ID!, $fieldId: ID!, $optionId: String!) {
updateProjectV2ItemFieldValue(input: {
projectId: $projectId
itemId: $itemId
fieldId: $fieldId
value: { singleSelectOptionId: $optionId }
}) {
projectV2Item { id }
}
}
`, {
projectId,
itemId: projectItem.id,
fieldId: update.fieldId,
optionId: update.optionId
});
console.log(`Issue #${issueNumber}: set ${update.fieldName} = ${update.valueName}`);
}
if (updates.length === 0) {
console.log(`Issue #${issueNumber}: no matching field updates for labels [${labels.join(', ')}]`);
}
+3 -3
View File
@@ -22,7 +22,7 @@ jobs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
steps:
- uses: dorny/paths-filter@v3
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
@@ -73,7 +73,7 @@ jobs:
run: npm run test:coverage
- name: Upload coverage report
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
if: always()
with:
name: backend-coverage
@@ -118,7 +118,7 @@ jobs:
run: npm run build
- name: Upload coverage report
uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v7
if: always()
with:
name: frontend-coverage
+107
View File
@@ -0,0 +1,107 @@
name: Weekly Triage Report
on:
schedule:
- cron: '0 7 * * 1'
workflow_dispatch:
permissions:
contents: read
issues: write
jobs:
weekly-report:
runs-on: ubuntu-latest
steps:
- name: Build weekly summary
id: summary
uses: actions/github-script@v9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const since = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const weekLabel = new Date().toISOString().split('T')[0];
const q = async (query) => {
const res = await github.rest.search.issuesAndPullRequests({ q: query, per_page: 1 });
return res.data.total_count;
};
const openIssues = await q(`repo:${owner}/${repo} is:issue is:open`);
const newIssues = await q(`repo:${owner}/${repo} is:issue created:>=${since}`);
const bugs = await q(`repo:${owner}/${repo} is:issue is:open label:bug`);
const enhancements = await q(`repo:${owner}/${repo} is:issue is:open label:enhancement`);
const triage = await q(`repo:${owner}/${repo} is:issue is:open label:triage`);
const stale = await q(`repo:${owner}/${repo} is:issue is:open label:stale`);
const unassigned = await q(`repo:${owner}/${repo} is:issue is:open no:assignee`);
const body = [
`## Weekly Triage Report (${weekLabel})`,
'',
`- Open issues: **${openIssues}**`,
`- New issues (last 7 days): **${newIssues}**`,
`- Open bugs: **${bugs}**`,
`- Open enhancements: **${enhancements}**`,
`- In triage: **${triage}**`,
`- Stale: **${stale}**`,
`- Unassigned: **${unassigned}**`,
'',
'### Quick Links',
`- Triage queue: https://github.com/${owner}/${repo}/issues?q=is%3Aissue+is%3Aopen+label%3Atriage`,
`- Stale issues: https://github.com/${owner}/${repo}/issues?q=is%3Aissue+is%3Aopen+label%3Astale`,
`- Unassigned issues: https://github.com/${owner}/${repo}/issues?q=is%3Aissue+is%3Aopen+no%3Aassignee`,
].join('\n');
core.setOutput('title', `Weekly Triage Report - ${weekLabel}`);
core.setOutput('body', body);
- name: Publish report issue
uses: actions/github-script@v9
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
script: |
const owner = context.repo.owner;
const repo = context.repo.repo;
const title = `${{ steps.summary.outputs.title }}`;
const body = `${{ steps.summary.outputs.body }}`;
const existingReports = await github.paginate(github.rest.issues.listForRepo, {
owner,
repo,
state: 'open',
labels: 'triage',
per_page: 100,
});
for (const issue of existingReports) {
if (issue.pull_request) {
continue;
}
if (issue.title.startsWith('Weekly Triage Report - ') && issue.title !== title) {
await github.rest.issues.createComment({
owner,
repo,
issue_number: issue.number,
body: 'Closing this older weekly triage report before publishing the next one so only one weekly report issue stays open at a time.',
});
await github.rest.issues.update({
owner,
repo,
issue_number: issue.number,
state: 'closed',
});
}
}
await github.rest.issues.create({
owner,
repo,
title,
body,
labels: ['triage']
});
+27 -1
View File
@@ -79,6 +79,32 @@ Thumbs.db
.turbo/
.roo/
.roomodes
.claude/
AGENTS.md
docs/TECH_STACK.md
doku
doku/
# Local agent work logs stay on disk but must never go upstream.
doku/memory_notes.md
doku/report.md
plan/
.copilot-tracking/
.playwright-cli/
.agents/
skills-lock.json
# ===================
# Local Spec Kit 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/skills/accessibility/
.github/skills/frontend-design/
.github/skills/nodejs-backend-patterns/
.github/skills/nodejs-best-practices/
.github/skills/seo/
.playwright-mcp
+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*
+4 -1
View File
@@ -1,5 +1,8 @@
{
"vitest.root": "backend",
"vitest.enable": true,
"vitest.commandLine": "npm test --"
"vitest.commandLine": "npm test --",
"chat.tools.terminal.autoApprove": {
"test": true
}
}
+166
View File
@@ -0,0 +1,166 @@
{
"version": "2.0.0",
"tasks": [
{
"label": "E2E stable",
"type": "shell",
"command": "npm",
"args": [
"run",
"test:e2e"
],
"options": {
"cwd": "${workspaceFolder}/frontend"
},
"group": "test",
"problemMatcher": []
},
{
"label": "E2E stable + merged video",
"type": "shell",
"command": "npm",
"args": [
"run",
"test:e2e:with-video"
],
"options": {
"cwd": "${workspaceFolder}/frontend"
},
"group": "test",
"problemMatcher": []
},
{
"label": "E2E all browsers",
"type": "shell",
"command": "npm",
"args": [
"run",
"test:e2e:all"
],
"options": {
"cwd": "${workspaceFolder}/frontend"
},
"group": "test",
"problemMatcher": []
},
{
"label": "E2E all browsers + merged video",
"type": "shell",
"command": "npm",
"args": [
"run",
"test:e2e:all:with-video"
],
"options": {
"cwd": "${workspaceFolder}/frontend"
},
"group": "test",
"problemMatcher": []
},
{
"label": "E2E stable non-interactive",
"type": "shell",
"command": "cd frontend && PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=1 npm run test:e2e -- --workers=1",
"isBackground": false,
"group": "test"
},
{
"label": "Targeted frontend vitest",
"type": "shell",
"command": "cd frontend && npm test -- --run src/test/context/AppContext.test.tsx src/test/utils/schedule.test.ts",
"isBackground": false,
"group": "test"
},
{
"label": "Focused frontend shared schedule test",
"type": "shell",
"command": "cd frontend && npm run test:run -- --maxWorkers=1 src/test/components/SharedSchedule.test.tsx",
"isBackground": false,
"group": "test"
},
{
"label": "PR3 targeted validation",
"type": "shell",
"command": "git --no-pager diff --check -- .github/agents/release-manager.agent.md .github/agents/testing-manager.agent.md .gitignore .vscode/tasks.json && node -e \"JSON.parse(require('fs').readFileSync('.vscode/tasks.json','utf8')); console.log('tasks.json valid')\"",
"isBackground": false
},
{
"label": "US4 T038 frontend check+build",
"type": "shell",
"command": "cd frontend && npm run check && npm run build",
"isBackground": false
},
{
"label": "US4 T038 frontend check+build rerun",
"type": "shell",
"command": "cd frontend && npm run check && npm run build",
"isBackground": false
},
{
"label": "US4 T038 frontend gate final",
"type": "shell",
"command": "cd frontend && npm run check && npm run build",
"isBackground": false
},
{
"label": "US4 T038 frontend gate pass check",
"type": "shell",
"command": "cd frontend && npm run check && npm run build",
"isBackground": false
},
{
"label": "US4 T038 frontend build only",
"type": "shell",
"command": "cd frontend && npm run build",
"isBackground": false
},
{
"label": "US6 T050 backend check+build",
"type": "shell",
"command": "cd backend && npm run check && npm run build",
"isBackground": false
},
{
"label": "US6 backend biome autofix touched files",
"type": "shell",
"command": "cd backend && npx biome check --write src/db/client.ts src/db/db-utils.ts src/routes/medications.ts src/routes/planner.ts src/routes/settings.ts src/services/medication-enrichment/adapters.ts src/services/medication-enrichment/index.ts src/services/medications-service.ts",
"isBackground": false
},
{
"label": "US6 T050 backend gate rerun",
"type": "shell",
"command": "cd backend && npm run check && npm run build",
"isBackground": false
},
{
"label": "US6 T050 backend gate final",
"type": "shell",
"command": "cd backend && npm run check && npm run build",
"isBackground": false
},
{
"label": "Rewrite db-utils barrel",
"type": "shell",
"command": "cat > backend/src/db/db-utils.ts <<'EOF'\n/**\n * Compatibility barrel for DB utilities.\n *\n * New code should prefer importing from focused modules:\n * - ./path-utils.js\n * - ./migration-utils.js\n * - ./repair-utils.js\n */\n\nexport { ensureDefaultUser, runAlterMigrations, runDrizzleMigrations } from \"./migration-utils.js\";\nexport { buildDbUrl, ensureDataDirectory, getDataDir, getDbPaths } from \"./path-utils.js\";\nexport { repairOrphanedDoseIds, repairTrailingHyphenDoseIds } from \"./repair-utils.js\";\nEOF",
"isBackground": false
},
{
"label": "US6 T050 backend gate success attempt",
"type": "shell",
"command": "cd backend && npm run check && npm run build",
"isBackground": false
},
{
"label": "T039 targeted frontend parity tests",
"type": "shell",
"command": "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",
"isBackground": false
},
{
"label": "T044/T051 targeted backend regression tests",
"type": "shell",
"command": "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",
"isBackground": false
}
]
}
+95 -14
View File
@@ -10,7 +10,7 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/React-18-61DAFB?logo=react" alt="React 18" />
<img src="https://img.shields.io/badge/React-19-61DAFB?logo=react" alt="React 19" />
<img src="https://img.shields.io/badge/TypeScript-5-3178C6?logo=typescript" alt="TypeScript" />
<img src="https://img.shields.io/badge/Fastify-5-000000?logo=fastify" alt="Fastify" />
<img src="https://img.shields.io/badge/SQLite-Database-003B57?logo=sqlite" alt="SQLite" />
@@ -18,13 +18,13 @@
</p>
<p align="center">
<img src="https://img.shields.io/badge/Backend_Tests-526%2F526-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
<img src="https://img.shields.io/badge/Frontend_Tests-719%2F719-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
<img src="https://img.shields.io/badge/Backend_Tests-644%2F644-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
<img src="https://img.shields.io/badge/Frontend_Tests-891%2F891-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
</p>
### 🤖 AI-Generated Code
> This app was 100% coded with Claude Opus 4.5. Use at your own risk.
> This app was 100% coded with [Claude Opus 4.6](https://www.anthropic.com/claude) and [GPT-5.3 Codex](https://openai.com/index/gpt-5/). Use at your own risk.
### ⚠️ Disclaimer
@@ -119,10 +119,17 @@ Share your medication schedule with others via a public link.
</blockquote>
</details>
### Medication Setup
- Optional multi-source lookup inside the medication editor on desktop and mobile, prioritizing `RxNorm` and `openFDA` before `EMA`, 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
### Smart Inventory
- Track exact stock: packs, blisters, bottles, and loose pills
- Track exact stock with package profiles (blister, bottle, tube, liquid container)
- Display remaining days of supply
- Automatic calculation based on intake schedule
- Manual stock correction supports profile-specific stock semantics (sealed units + loose stock for blister, amount-based stock for bottle/tube/liquid)
### Medication Refill
- One-click refill with pack or loose pill options
@@ -132,6 +139,7 @@ Share your medication schedule with others via a public link.
### Flexible Schedules
- Daily, weekly, or custom intervals per medication
- Independent schedules for each medication
- Optional timeline filters for dashboard and shared schedule views
### Stock Alerts & Reminders
- Notifications before stock runs out
@@ -139,13 +147,18 @@ Share your medication schedule with others via a public link.
- Intake reminders via push notifications
### Trip Planner
- Calculate how many pills you need for a trip or date range
- Calculate medication demand for a trip or date range with package-aware units
- Plan ahead for vacations, business trips, or hospital stays
- Send demand reports via email or push notification
### Reports
- Generate medication reports as PDF, Markdown, or plain text
- Include intake history, refill history, and prescription details
### Multi-Person Support
- Manage medications for multiple people
- Share schedules via link. Recipients can mark doses as taken, you see it live
- Optionally embed the medication overview directly on shared links via a settings toggle
### Data Export & Import
- Export all your data (medications, dose history, settings) as JSON
@@ -171,7 +184,7 @@ The easiest way to deploy MedAssist-ng is with Docker Compose:
git clone https://github.com/DanielVolz/medassist-ng.git
cd medassist-ng
cp .env.example .env
docker compose up -d
docker compose -p medassist-ng up -d
```
Open `http://localhost:4174` and start tracking your medications.
@@ -188,8 +201,23 @@ All configuration is done via environment variables in `.env`. Copy `.env.exampl
| `PGID` | `1000` | Group ID for container file permissions |
| `PORT` | `3000` | Backend API port |
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS |
| `LOG_LEVEL` | `info` | Log verbosity (`debug`, `info`, `warn`, `error`) |
| `TZ` | `Europe/Berlin` | Timezone for scheduled reminders |
| `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` | Server default timezone for scheduled reminders (can be overridden per user in Settings) |
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
@@ -205,6 +233,43 @@ All configuration is done via environment variables in `.env`. Copy `.env.exampl
Generate secrets with: `openssl rand -hex 32`
### API Keys (Programmatic API Access)
When `AUTH_ENABLED=true`, you can create personal API keys and call protected endpoints with:
```bash
Authorization: Bearer ma_...
```
Available scopes:
- `read`: read-only access (`GET`, `HEAD`, `OPTIONS`)
- `write`: read + write access
Essential notes:
- Create keys in the app when authentication is enabled.
- The token is shown only once after creation.
- Creating a new key automatically deactivates previously active keys for the same user.
- API keys are stored hashed in the database.
Example usage:
```bash
curl http://localhost:3000/settings \
-H "Authorization: Bearer ma_..."
```
API reference:
- Interactive docs: `/docs`
- OpenAPI JSON: `/docs/json`
- With the bundled frontend ingress, these paths work on the normal app URL as well, for example `http://localhost:4174/docs` when docs are enabled.
- Key management endpoints for authenticated users:
- `GET /auth/api-keys`
- `POST /auth/api-keys`
- `DELETE /auth/api-keys/:id`
### OIDC / SSO
| Variable | Default | Description |
@@ -240,11 +305,15 @@ Generate secrets with: `openssl rand -hex 32`
| `REMINDER_MINUTES_BEFORE` | `15` | Minutes before intake to send reminder |
| `EXPIRY_WARNING_DAYS` | `30` | Days before expiry to show warning |
Intake reminder timing uses IANA timezones. The server uses `TZ` as default, and each user can set an override in Settings. If no user timezone is set, reminders continue using the server default.
### Push Notifications (Shoutrrr)
MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications, supporting many services with a single URL format.
**Supported services:** ntfy, Pushover, Gotify, Discord, Telegram, Slack, Matrix, and [many more](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
**Implemented URL schemes in MedAssist:** `ntfy://`, `discord://`, `pushover://`, `gotify://`, `telegram://`, plus direct `https://` webhooks.
This covers common providers like ntfy, Discord, Pushover, Gotify, Telegram, Slack webhooks, and many others via webhook URLs.
Configure push notifications in Settings → Push, or set defaults via environment variables:
@@ -259,9 +328,9 @@ Configure push notifications in Settings → Push, or set defaults via environme
These defaults are applied when a new user is created. Once a user saves settings in the app, their values take precedence.
| Variable | Default | Description |
|----------|---------|-------------|
| `DEFAULT_SHARE_STOCK_STATUS` | `true` | Show stock status (Normal/Low/Critical) on shared schedule links |
Complete list and details:
- [docs/DEFAULT_USER_SETTINGS.md](docs/DEFAULT_USER_SETTINGS.md)
#### URL Examples
@@ -282,6 +351,7 @@ Get your keys at [pushover.net](https://pushover.net/):
**Gotify** (self-hosted):
```
gotify://your-server.com/TOKEN
gotify://your-server.com:443/path/to/gotify/TOKEN?priority=1
```
**Discord**:
@@ -292,6 +362,7 @@ discord://TOKEN@WEBHOOK_ID
**Telegram**:
```
telegram://TOKEN@telegram?chats=CHAT_ID
telegram://TOKEN@telegram?chats=@your_channel,-1001234567890
```
For all services and options, see the [Shoutrrr documentation](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
@@ -299,11 +370,21 @@ For all services and options, see the [Shoutrrr documentation](https://containrr
# Development
```bash
docker compose -f docker-compose.dev.yml up
docker compose -p medassist-dev -f docker-compose.dev.yml up
```
- Frontend: `http://localhost:5173` (hot reload)
- Backend: `http://localhost:3000`
- API docs UI: `http://localhost:3000/docs` (when docs are enabled)
- OpenAPI JSON: `http://localhost:3000/docs/json` (when docs are enabled)
Useful local commands:
```bash
npm run lint
cd backend && npm run test:run
cd frontend && npm run test:run
```
# Acknowledgements
@@ -0,0 +1,2 @@
ALTER TABLE `medications` ADD `is_obsolete` integer DEFAULT false NOT NULL;
ALTER TABLE `medications` ADD `obsolete_at` integer;
@@ -0,0 +1 @@
ALTER TABLE `medications` ADD `medication_start_date` text DEFAULT '' NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE `dose_tracking` ADD `taken_source` text DEFAULT 'manual' NOT NULL;
@@ -0,0 +1,5 @@
ALTER TABLE `medications` ADD `medication_form` text(20) DEFAULT 'tablet' NOT NULL;--> statement-breakpoint
ALTER TABLE `medications` ADD `pill_form` text(20);--> statement-breakpoint
ALTER TABLE `medications` ADD `lifecycle_category` text(30) DEFAULT 'refill_when_empty' NOT NULL;--> statement-breakpoint
ALTER TABLE `medications` ADD `medication_end_date` text;--> statement-breakpoint
ALTER TABLE `medications` ADD `auto_mark_obsolete_after_end_date` integer DEFAULT true NOT NULL;
@@ -0,0 +1,18 @@
CREATE TABLE `api_keys` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`name` text(100) NOT NULL,
`key_hash` text(128) NOT NULL,
`token_prefix` text(24) DEFAULT '' NOT NULL,
`scope` text(10) DEFAULT 'write' NOT NULL,
`is_active` integer DEFAULT true NOT NULL,
`last_used_at` integer,
`expires_at` integer,
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `api_keys_key_hash_unique` ON `api_keys` (`key_hash`);--> statement-breakpoint
ALTER TABLE `medications` ADD `package_amount_value` integer DEFAULT 0 NOT NULL;--> statement-breakpoint
ALTER TABLE `medications` ADD `package_amount_unit` text(10) DEFAULT 'ml' NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE `user_settings` ADD `share_medication_overview` integer DEFAULT false NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE `user_settings` ADD `timezone` text DEFAULT '' NOT NULL;
@@ -0,0 +1,30 @@
CREATE TABLE `notification_action_groups` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`group_key` text(255) NOT NULL,
`sequence_id` text(255) NOT NULL,
`dose_ids_json` text NOT NULL,
`title` text(255) NOT NULL,
`message` text NOT NULL,
`language` text(10) DEFAULT 'en' NOT NULL,
`scheduled_for` integer,
`expires_at` integer NOT NULL,
`resolved_action` text(20),
`resolved_at` integer,
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `notification_action_groups_group_key_unique` ON `notification_action_groups` (`group_key`);--> statement-breakpoint
CREATE TABLE `notification_action_tokens` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`group_id` integer NOT NULL,
`token_hash` text(128) NOT NULL,
`kind` text(20) NOT NULL,
`used_at` integer,
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`group_id`) REFERENCES `notification_action_groups`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `notification_action_tokens_token_hash_unique` ON `notification_action_tokens` (`token_hash`);
@@ -0,0 +1 @@
ALTER TABLE `notification_action_groups` ADD `ntfy_original_message_id` text(255) DEFAULT '' 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
File diff suppressed because it is too large Load Diff
+49
View File
@@ -57,6 +57,55 @@
"when": 1770659669121,
"tag": "0007_add_share_stock_status",
"breakpoints": true
},
{
"idx": 8,
"version": "6",
"when": 1771160400000,
"tag": "0008_add_obsolete_medications",
"breakpoints": true
},
{
"idx": 9,
"version": "6",
"when": 1771164000000,
"tag": "0009_add_medication_start_date",
"breakpoints": true
},
{
"idx": 10,
"version": "6",
"when": 1771694832866,
"tag": "0010_add_dose_tracking_taken_source",
"breakpoints": true
},
{
"idx": 11,
"version": "6",
"when": 1772219947541,
"tag": "0011_add_medication_form_lifecycle_columns",
"breakpoints": true
},
{
"idx": 12,
"version": "6",
"when": 1772881208026,
"tag": "0012_add_api_keys_and_package_amount_columns",
"breakpoints": true
},
{
"idx": 13,
"version": "6",
"when": 1773348659979,
"tag": "0013_add_share_medication_overview",
"breakpoints": true
},
{
"idx": 14,
"version": "6",
"when": 1775849300000,
"tag": "0014_add_user_settings_timezone",
"breakpoints": true
}
]
}
+1627 -3231
View File
File diff suppressed because it is too large Load Diff
+28 -18
View File
@@ -1,6 +1,6 @@
{
"name": "medassist-ng-backend",
"version": "1.10.3",
"version": "1.23.0",
"private": true,
"type": "module",
"scripts": {
@@ -20,30 +20,40 @@
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/helmet": "^13.0.2",
"@fastify/jwt": "^10.0.0",
"@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",
"@libsql/client": "^0.17.0",
"@fastify/static": "^9.1.3",
"@fastify/swagger": "^9.7.0",
"@fastify/swagger-ui": "^5.2.6",
"@libsql/client": "^0.17.3",
"argon2": "^0.44.0",
"dotenv": "^17.3.1",
"drizzle-orm": "^0.45.1",
"fastify": "^5.7.4",
"nodemailer": "^8.0.1",
"openid-client": "^6.8.2",
"zod": "^3.23.8"
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"fastify": "^5.8.5",
"fastify-plugin": "^5.0.1",
"jose": "^6.2.3",
"nodemailer": "^8.0.7",
"openid-client": "^6.8.4",
"sharp": "^0.34.5",
"zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "^2.3.15",
"@types/node": "^25.2.3",
"@types/nodemailer": "^6.4.21",
"@types/supertest": "^6.0.2",
"@vitest/coverage-v8": "^4.0.18",
"drizzle-kit": "^0.31.9",
"@biomejs/biome": "^2.4.14",
"@types/node": "^25.6.0",
"@types/nodemailer": "^8.0.0",
"@types/supertest": "^7.2.0",
"@vitest/coverage-v8": "^4.1.5",
"drizzle-kit": "^0.31.10",
"pino-pretty": "^13.1.3",
"supertest": "^7.2.2",
"tsx": "^4.19.0",
"typescript": "^5.5.4",
"typescript": "^6.0.3",
"vitest": "^4.0.16"
},
"overrides": {
"@esbuild-kit/esm-loader": "2.6.5",
"@esbuild-kit/core-utils": "3.3.2",
"esbuild": "0.25.4"
}
}
+6 -18
View File
@@ -1,20 +1,12 @@
import { existsSync, statSync } from "node:fs";
import { resolve } from "node:path";
import { type Client, createClient } from "@libsql/client";
import dotenv from "dotenv";
import { drizzle } from "drizzle-orm/libsql";
import { log } from "../utils/logger.js";
// Import utilities from db-utils (side-effect-free)
import {
ensureDataDirectory,
ensureDefaultUser,
getDataDir,
getDbPaths,
repairOrphanedDoseIds,
repairTrailingHyphenDoseIds,
runAlterMigrations,
runDrizzleMigrations,
} from "./db-utils.js";
import { ensureDefaultUser, runAlterMigrations, runDrizzleMigrations } from "./migration-utils.js";
// Import utilities from focused DB modules (side-effect-free)
import { ensureDataDirectory, getDbPaths } from "./path-utils.js";
import { repairOrphanedDoseIds, repairTrailingHyphenDoseIds } from "./repair-utils.js";
// Re-export all utilities so existing imports from client.ts keep working
export {
@@ -65,8 +57,8 @@ let client: Client;
try {
client = createClient({ url });
log.debug(`[DB] Database client created successfully`);
} catch (err: any) {
log.error(`[DB] ERROR: Failed to create database client: ${err.message}`);
} catch (err: unknown) {
log.error(`[DB] ERROR: Failed to create database client: ${(err as Error).message}`);
log.error(`[DB] Database path: ${dbPath}`);
process.exit(1);
}
@@ -80,10 +72,6 @@ async function runMigrations() {
const migrateResult = await runDrizzleMigrations(db);
if (!migrateResult.success) {
log.error(`[DB] Migration error: ${migrateResult.error}`);
} else if (migrateResult.warning) {
log.warn(`[DB] Migration warning: ${migrateResult.warning}`);
} else {
log.debug(`[DB] Drizzle migrations completed`);
}
// Run ALTER TABLE migrations for backward compatibility
+9 -385
View File
@@ -1,388 +1,12 @@
/**
* Pure utility functions for database operations.
* Separated from client.ts to allow importing without triggering
* top-level database initialization side effects.
* Compatibility barrel for DB utilities.
*
* New code should prefer importing from focused modules:
* - ./path-utils.js
* - ./migration-utils.js
* - ./repair-utils.js
*/
import { accessSync, constants, existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { Client } from "@libsql/client";
import type { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
import { parseIntakesJson, parseLocalDateTime } from "../utils/scheduler-utils.js";
// Get migrations folder path (relative to this file's location)
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
// =============================================================================
// Path & Directory utilities
// =============================================================================
/**
* Get the data directory path.
*
* Resolution order:
* 1. DATA_DIR env var (set by docker-compose for containers)
* 2. Monorepo detection: if ../docker-compose.yml exists, we're in backend/
* subdirectory → use ../data (project root's data folder)
* 3. Fallback: resolve(cwd, "data") (running from project root or standalone)
*/
export function getDataDir(cwd: string = process.cwd()): string {
// Docker containers set DATA_DIR explicitly
if (process.env.DATA_DIR) return resolve(process.env.DATA_DIR);
// Local dev: detect if we're in backend/ subdirectory of the monorepo
if (existsSync(resolve(cwd, "..", "docker-compose.yml"))) {
return resolve(cwd, "..", "data");
}
// Default: data/ relative to cwd (running from project root)
return resolve(cwd, "data");
}
/** Build the database URL from a path */
export function buildDbUrl(dbPath: string): string {
return `file:${dbPath}`;
}
/** Get data directory and database path */
export function getDbPaths(cwd: string = process.cwd()): { dataDir: string; dbPath: string; url: string } {
const dataDir = getDataDir(cwd);
const dbPath = resolve(dataDir, "medassist-ng.db");
const url = buildDbUrl(dbPath);
return { dataDir, dbPath, url };
}
/** Ensure data directory exists and is writable */
export function ensureDataDirectory(dataDir: string): { success: boolean; error?: string } {
try {
if (!existsSync(dataDir)) {
mkdirSync(dataDir, { recursive: true });
}
// Check if directory is writable
accessSync(dataDir, constants.W_OK);
// Try to create a test file to verify write access
const testFile = resolve(dataDir, ".write-test");
writeFileSync(testFile, "test");
return { success: true };
} catch (err: any) {
return { success: false, error: err.message };
}
}
// =============================================================================
// Migration utilities
// =============================================================================
/** Run drizzle-kit migrations on the database */
export async function runDrizzleMigrations(
database: ReturnType<typeof drizzle>
): Promise<{ success: boolean; error?: string; warning?: string }> {
try {
await migrate(database, { migrationsFolder });
return { success: true };
} catch (err: any) {
// If the error is about existing schema objects, the DB is already up-to-date
// This happens when ALTER migrations in client.ts have already added the columns,
// or when tables were created before drizzle migrations were introduced
if (err.message?.includes("duplicate column") || err.message?.includes("already exists")) {
return { success: true, warning: `Schema already up-to-date: ${err.message}` };
}
return { success: false, error: err.message };
}
}
/** Run ALTER TABLE migrations for backward compatibility with older databases */
export async function runAlterMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
const errors: string[] = [];
// These add new columns to existing tables (silently fail if column already exists)
const alterMigrations = [
// Added in v1.x - repeat reminders and nagging settings
`ALTER TABLE user_settings ADD COLUMN skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0`,
`ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`,
`ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`,
`ALTER TABLE user_settings ADD COLUMN max_nagging_reminders integer NOT NULL DEFAULT 5`,
// Added in v1.2.3 - dismiss missed doses without deducting stock
`ALTER TABLE dose_tracking ADD COLUMN dismissed integer NOT NULL DEFAULT 0`,
// Added in v1.3.x - stock calculation mode (automatic/manual)
`ALTER TABLE user_settings ADD COLUMN stock_calculation_mode text NOT NULL DEFAULT 'automatic'`,
// Added for stock correction - hidden offset that doesn't affect looseTablets
`ALTER TABLE medications ADD COLUMN stock_adjustment integer NOT NULL DEFAULT 0`,
// Added for stock correction - timestamp to ignore consumed doses before correction
`ALTER TABLE medications ADD COLUMN last_stock_correction_at integer`,
// Added in v1.5.1 - dismiss past doses until date (robust against timestamp changes)
`ALTER TABLE medications ADD COLUMN dismissed_until text`,
// Added for more detailed reminder info display
`ALTER TABLE user_settings ADD COLUMN last_reminder_med_name text`,
`ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by text`,
// Added for package type support (blister vs bottle)
`ALTER TABLE medications ADD COLUMN package_type text NOT NULL DEFAULT 'blister'`,
`ALTER TABLE medications ADD COLUMN total_pills integer`,
// Added for dose unit selection (mg, g, mcg, ml, IU, etc.)
`ALTER TABLE medications ADD COLUMN dose_unit text DEFAULT 'mg'`,
// Added for intake-level takenBy: unified intakes structure
`ALTER TABLE medications ADD COLUMN intakes_json text NOT NULL DEFAULT '[]'`,
// Added for separate stock reminder tracking
`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`,
// Added for share stock visibility toggle
`ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`,
// Added for prescription refill tracking and reminders
`ALTER TABLE medications ADD COLUMN prescription_enabled integer NOT NULL DEFAULT 0`,
`ALTER TABLE medications ADD COLUMN prescription_authorized_refills integer`,
`ALTER TABLE medications ADD COLUMN prescription_remaining_refills integer`,
`ALTER TABLE medications ADD COLUMN prescription_low_refill_threshold integer NOT NULL DEFAULT 1`,
`ALTER TABLE medications ADD COLUMN prescription_expiry_date text`,
`ALTER TABLE user_settings ADD COLUMN email_prescription_reminders integer NOT NULL DEFAULT 1`,
`ALTER TABLE user_settings ADD COLUMN shoutrrr_prescription_reminders integer NOT NULL DEFAULT 1`,
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_sent text`,
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_channel text`,
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_med_names text`,
// Added for refill history prescription tracking
`ALTER TABLE refill_history ADD COLUMN used_prescription integer NOT NULL DEFAULT 0`,
];
for (const sql of alterMigrations) {
try {
await client.execute(sql);
} catch (e: any) {
// Silently ignore "duplicate column" errors - column already exists
if (!e.message?.includes("duplicate column")) {
errors.push(e.message);
}
}
}
// Create tables that might be missing (silently fail if already exists)
const createTableMigrations = [
// Added in v1.3.x - refill history tracking
`CREATE TABLE IF NOT EXISTS refill_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
medication_id INTEGER NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
packs_added INTEGER NOT NULL DEFAULT 0,
loose_pills_added INTEGER NOT NULL DEFAULT 0,
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
)`,
];
for (const sql of createTableMigrations) {
try {
await client.execute(sql);
} catch (e: any) {
// Silently ignore "table already exists" errors
if (!e.message?.includes("already exists")) {
errors.push(e.message);
}
}
}
// Create indexes that might be missing (silently fail if already exists)
const createIndexMigrations = [
// Added in v1.6.x - case-insensitive unique usernames
`CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`,
];
for (const sql of createIndexMigrations) {
try {
await client.execute(sql);
} catch (e: any) {
// Silently ignore "already exists" errors
if (!e.message?.includes("already exists")) {
errors.push(e.message);
}
}
}
return { success: errors.length === 0, errors };
}
// =============================================================================
// User utilities
// =============================================================================
/** Ensure default user exists for auth-disabled mode */
export async function ensureDefaultUser(client: Client, authEnabled: boolean): Promise<boolean> {
if (authEnabled) {
return false; // No default user needed
}
try {
const result = await client.execute("SELECT id FROM users WHERE id = 1");
if (result.rows.length === 0) {
await client.execute("INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')");
return true; // Created
}
return false; // Already exists
} catch (e: any) {
console.error(`[DB] Error creating default user:`, e.message);
return false;
}
}
// =============================================================================
// Startup repair: fix orphaned dose tracking IDs from past schedule changes
// =============================================================================
const MS_PER_DAY = 86_400_000;
/**
* Repair dose IDs that have a trailing hyphen caused by a frontend bug where
* `[].toString()` produced an empty string, resulting in IDs like "5-0-1729123200000-"
* instead of "5-0-1729123200000". This strips trailing hyphens from all dose IDs.
*
* This function is idempotent - safe to run on every startup.
*/
export async function repairTrailingHyphenDoseIds(client: Client): Promise<{ repaired: number; errors: string[] }> {
const errors: string[] = [];
let repaired = 0;
try {
const result = await client.execute(
"UPDATE dose_tracking SET dose_id = RTRIM(dose_id, '-') WHERE dose_id LIKE '%-'"
);
repaired = result.rowsAffected;
} catch (e: any) {
errors.push(`Trailing-hyphen repair failed: ${e.message}`);
}
return { repaired, errors };
}
/**
* Repair orphaned dose tracking IDs that no longer match the current intake schedule.
* This fixes dose IDs that became invalid when a medication's schedule was changed
* BEFORE the on-edit migration (PR #103) was introduced.
*
* For each medication, generates all valid schedule dateOnlyMs values from each intake's
* start date up to today, then checks all dose_tracking entries. Any dose whose timestamp
* doesn't match a valid schedule date is remapped to the nearest valid date.
*
* This function is idempotent - safe to run on every startup.
*/
export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired: number; errors: string[] }> {
const errors: string[] = [];
let repaired = 0;
try {
// Get all medications
const medsResult = await client.execute(
"SELECT id, intakes_json, usage_json, every_json, start_json, intake_reminders_enabled FROM medications"
);
if (medsResult.rows.length === 0) return { repaired, errors };
// Get all dose tracking entries
const dosesResult = await client.execute("SELECT id, dose_id FROM dose_tracking");
if (dosesResult.rows.length === 0) return { repaired, errors };
// Build a map of medId → dose entries for quick lookup
const dosesByMed = new Map<number, Array<{ id: number; doseId: string }>>();
for (const row of dosesResult.rows) {
const doseId = row.dose_id as string;
const parts = doseId.split("-");
if (parts.length < 3) continue;
const medId = parseInt(parts[0], 10);
if (Number.isNaN(medId)) continue;
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
dosesByMed.get(medId)!.push({ id: row.id as number, doseId });
}
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
for (const med of medsResult.rows) {
const medId = med.id as number;
const medDoses = dosesByMed.get(medId);
if (!medDoses || medDoses.length === 0) continue;
// Parse intakes
const intakes = parseIntakesJson(
med.intakes_json as string | null,
{
usageJson: (med.usage_json as string) || "[]",
everyJson: (med.every_json as string) || "[]",
startJson: (med.start_json as string) || "[]",
},
(med.intake_reminders_enabled as number) === 1
);
if (intakes.length === 0) continue;
// For each intake index, build the set of valid dateOnlyMs values
const validDatesByIntake = new Map<number, Set<number>>();
for (let idx = 0; idx < intakes.length; idx++) {
const intake = intakes[idx];
const start = parseLocalDateTime(intake.start);
const every = intake.every;
if (every <= 0 || Number.isNaN(start.getTime())) continue;
const validDates = new Set<number>();
for (let d = new Date(start); d <= today; d.setDate(d.getDate() + every)) {
validDates.add(new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime());
}
validDatesByIntake.set(idx, validDates);
}
// Check each dose entry
for (const dose of medDoses) {
const parts = dose.doseId.split("-");
if (parts.length < 3) continue;
const intakeIdx = parseInt(parts[1], 10);
const dateOnlyMs = parseInt(parts[2], 10);
if (Number.isNaN(intakeIdx) || Number.isNaN(dateOnlyMs)) continue;
const validDates = validDatesByIntake.get(intakeIdx);
if (!validDates) continue; // Unknown intake index - skip
// Check if this dose's timestamp is valid
if (validDates.has(dateOnlyMs)) continue; // Already valid - nothing to do
// Orphaned dose - find the nearest valid schedule date
const intake = intakes[intakeIdx];
if (!intake) continue;
const halfInterval = (intake.every * MS_PER_DAY) / 2;
let bestMatch: number | null = null;
let bestDist = Infinity;
for (const validDate of validDates) {
const dist = Math.abs(validDate - dateOnlyMs);
if (dist < bestDist && dist <= halfInterval) {
bestDist = dist;
bestMatch = validDate;
}
}
if (bestMatch !== null) {
// Rebuild dose ID with new timestamp, preserving person suffix
const personSuffix = parts.length > 3 ? `-${parts.slice(3).join("-")}` : "";
const newDoseId = `${medId}-${intakeIdx}-${bestMatch}${personSuffix}`;
try {
await client.execute({
sql: "UPDATE dose_tracking SET dose_id = ? WHERE id = ?",
args: [newDoseId, dose.id],
});
repaired++;
} catch (e: any) {
errors.push(`Failed to repair dose ${dose.id}: ${e.message}`);
}
}
}
}
} catch (e: any) {
errors.push(`Repair failed: ${e.message}`);
}
return { repaired, errors };
}
export { ensureDefaultUser, runAlterMigrations, runDrizzleMigrations } from "./migration-utils.js";
export { buildDbUrl, ensureDataDirectory, getDataDir, getDbPaths } from "./path-utils.js";
export { repairOrphanedDoseIds, repairTrailingHyphenDoseIds } from "./repair-utils.js";
+2 -2
View File
@@ -41,8 +41,8 @@ export async function executeMigration(
const executed = Number(tables.rows[0].count) || 0;
return { success: true, executed, errors };
} catch (err: any) {
errors.push(err.message);
} catch (err: unknown) {
errors.push((err as Error).message);
return { success: false, executed: 0, errors };
}
}
+189
View File
@@ -0,0 +1,189 @@
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { Client } from "@libsql/client";
import type { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
/** Run drizzle-kit migrations on the database */
export async function runDrizzleMigrations(
database: ReturnType<typeof drizzle>
): Promise<{ success: boolean; error?: string; warning?: string }> {
try {
await migrate(database, { migrationsFolder });
return { success: true };
} catch (err: unknown) {
const msg = (err as Error).message ?? "";
if (msg.includes("duplicate column") || msg.includes("already exists")) {
return { success: true };
}
return { success: false, error: msg };
}
}
/** Run ALTER TABLE migrations for backward compatibility with older databases */
export async function runAlterMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
const errors: string[] = [];
const alterMigrations = [
`ALTER TABLE user_settings ADD COLUMN skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0`,
`ALTER TABLE user_settings ADD COLUMN repeat_reminders_enabled integer NOT NULL DEFAULT 0`,
`ALTER TABLE user_settings ADD COLUMN reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30`,
`ALTER TABLE user_settings ADD COLUMN 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'`,
`ALTER TABLE medications ADD COLUMN stock_adjustment integer NOT NULL DEFAULT 0`,
`ALTER TABLE medications ADD COLUMN last_stock_correction_at integer`,
`ALTER TABLE medications ADD COLUMN dismissed_until text`,
`ALTER TABLE medications ADD COLUMN is_obsolete integer NOT NULL DEFAULT 0`,
`ALTER TABLE medications ADD COLUMN obsolete_at integer`,
`ALTER TABLE medications ADD COLUMN medication_start_date text NOT NULL DEFAULT ''`,
`ALTER TABLE medications ADD COLUMN medication_form text NOT NULL DEFAULT 'tablet'`,
`ALTER TABLE medications ADD COLUMN pill_form text`,
`ALTER TABLE medications ADD COLUMN lifecycle_category text NOT NULL DEFAULT 'refill_when_empty'`,
`ALTER TABLE medications ADD COLUMN medication_end_date text`,
`ALTER TABLE medications ADD COLUMN auto_mark_obsolete_after_end_date integer NOT NULL DEFAULT 1`,
`ALTER TABLE medications ADD COLUMN package_amount_value integer NOT NULL DEFAULT 0`,
`ALTER TABLE medications ADD COLUMN package_amount_unit text NOT NULL DEFAULT 'ml'`,
`ALTER TABLE user_settings ADD COLUMN last_reminder_med_name text`,
`ALTER TABLE user_settings ADD COLUMN last_reminder_taken_by text`,
`ALTER TABLE medications ADD COLUMN package_type text NOT NULL DEFAULT 'blister'`,
`ALTER TABLE medications ADD COLUMN total_pills integer`,
`ALTER TABLE medications ADD COLUMN dose_unit text DEFAULT 'mg'`,
`ALTER TABLE medications ADD COLUMN intakes_json text NOT NULL DEFAULT '[]'`,
`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`,
`ALTER TABLE user_settings ADD COLUMN share_schedule_today_only integer NOT NULL DEFAULT 0`,
`ALTER TABLE user_settings ADD COLUMN swap_dashboard_main_sections integer NOT NULL DEFAULT 0`,
`ALTER TABLE medications ADD COLUMN prescription_enabled integer NOT NULL DEFAULT 0`,
`ALTER TABLE medications ADD COLUMN prescription_authorized_refills integer`,
`ALTER TABLE medications ADD COLUMN prescription_remaining_refills integer`,
`ALTER TABLE medications ADD COLUMN prescription_low_refill_threshold integer NOT NULL DEFAULT 1`,
`ALTER TABLE medications ADD COLUMN prescription_expiry_date text`,
`ALTER TABLE user_settings ADD COLUMN email_prescription_reminders integer NOT NULL DEFAULT 1`,
`ALTER TABLE user_settings ADD COLUMN shoutrrr_prescription_reminders integer NOT NULL DEFAULT 1`,
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_sent text`,
`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 notification_action_groups ADD COLUMN ntfy_original_message_id text NOT NULL DEFAULT ''`,
];
for (const sql of alterMigrations) {
try {
await client.execute(sql);
} catch (e: unknown) {
if (!(e as Error).message?.includes("duplicate column")) {
errors.push((e as Error).message);
}
}
}
const createTableMigrations = [
`CREATE TABLE IF NOT EXISTS refill_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
medication_id INTEGER NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
packs_added INTEGER NOT NULL DEFAULT 0,
loose_pills_added INTEGER NOT NULL DEFAULT 0,
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
)`,
`CREATE TABLE IF NOT EXISTS notification_action_groups (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
group_key TEXT NOT NULL UNIQUE,
sequence_id TEXT NOT NULL,
ntfy_original_message_id TEXT NOT NULL DEFAULT '',
dose_ids_json TEXT NOT NULL,
title TEXT NOT NULL,
message TEXT NOT NULL,
language TEXT NOT NULL DEFAULT 'en',
scheduled_for INTEGER,
expires_at INTEGER NOT NULL,
resolved_action TEXT,
resolved_at INTEGER,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
)`,
`CREATE TABLE IF NOT EXISTS notification_action_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
group_id INTEGER NOT NULL REFERENCES notification_action_groups(id) ON DELETE CASCADE,
token_hash TEXT NOT NULL UNIQUE,
kind TEXT NOT NULL,
used_at INTEGER,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
)`,
`CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
token_prefix TEXT NOT NULL DEFAULT '',
scope TEXT NOT NULL DEFAULT 'write',
is_active INTEGER NOT NULL DEFAULT 1,
last_used_at INTEGER,
expires_at INTEGER,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
)`,
];
for (const sql of createTableMigrations) {
try {
await client.execute(sql);
} catch (e: unknown) {
if (!(e as Error).message?.includes("already exists")) {
errors.push((e as Error).message);
}
}
}
const createIndexMigrations = [
`CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`,
`CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`,
`CREATE UNIQUE INDEX IF NOT EXISTS notification_action_groups_group_key_unique ON notification_action_groups(group_key)`,
`CREATE UNIQUE INDEX IF NOT EXISTS notification_action_tokens_token_hash_unique ON notification_action_tokens(token_hash)`,
`CREATE INDEX IF NOT EXISTS api_keys_user_id_idx ON api_keys(user_id)`,
];
for (const sql of createIndexMigrations) {
try {
await client.execute(sql);
} catch (e: unknown) {
if (!(e as Error).message?.includes("already exists")) {
errors.push((e as Error).message);
}
}
}
return { success: errors.length === 0, errors };
}
/** Ensure default user exists for auth-disabled mode */
export async function ensureDefaultUser(client: Client, authEnabled: boolean): Promise<boolean> {
if (authEnabled) {
return false;
}
try {
const result = await client.execute("SELECT id FROM users WHERE id = 1");
if (result.rows.length === 0) {
await client.execute("INSERT INTO users (id, username, auth_provider) VALUES (1, 'default', 'local')");
return true;
}
return false;
} catch (e: unknown) {
console.error(`[DB] Error creating default user:`, (e as Error).message);
return false;
}
}
+48
View File
@@ -0,0 +1,48 @@
import { accessSync, constants, existsSync, mkdirSync } from "node:fs";
import { resolve } from "node:path";
/**
* Get the data directory path.
*
* Resolution order:
* 1. DATA_DIR env var (set by docker-compose for containers)
* 2. Monorepo detection: if ../docker-compose.yml exists, we're in backend/
* subdirectory -> use ../data (project root's data folder)
* 3. Fallback: resolve(cwd, "data") (running from project root or standalone)
*/
export function getDataDir(cwd: string = process.cwd()): string {
if (process.env.DATA_DIR) return resolve(process.env.DATA_DIR);
if (existsSync(resolve(cwd, "..", "docker-compose.yml"))) {
return resolve(cwd, "..", "data");
}
return resolve(cwd, "data");
}
/** Build the database URL from a path */
export function buildDbUrl(dbPath: string): string {
return `file:${dbPath}`;
}
/** Get data directory and database path */
export function getDbPaths(cwd: string = process.cwd()): { dataDir: string; dbPath: string; url: string } {
const dataDir = getDataDir(cwd);
const dbPath = resolve(dataDir, "medassist-ng.db");
const url = buildDbUrl(dbPath);
return { dataDir, dbPath, url };
}
/** Ensure data directory exists and is writable */
export function ensureDataDirectory(dataDir: string): { success: boolean; error?: string } {
try {
if (!existsSync(dataDir)) {
mkdirSync(dataDir, { recursive: true });
}
accessSync(dataDir, constants.W_OK);
return { success: true };
} catch (err: unknown) {
return { success: false, error: (err as Error).message };
}
}
+141
View File
@@ -0,0 +1,141 @@
import type { Client } from "@libsql/client";
import {
forEachScheduledOccurrenceInRange,
getDateOnlyTimestamp,
getScheduleMatchWindowMs,
parseIntakesJson,
parseLocalDateTime,
} from "../utils/scheduler-utils.js";
const MS_PER_DAY = 86_400_000;
/**
* Repair dose IDs that have a trailing hyphen caused by a frontend bug where
* [].toString() produced an empty string.
*/
export async function repairTrailingHyphenDoseIds(client: Client): Promise<{ repaired: number; errors: string[] }> {
const errors: string[] = [];
let repaired = 0;
try {
const result = await client.execute(
"UPDATE dose_tracking SET dose_id = RTRIM(dose_id, '-') WHERE dose_id LIKE '%-'"
);
repaired = result.rowsAffected;
} catch (e: unknown) {
errors.push(`Trailing-hyphen repair failed: ${(e as Error).message}`);
}
return { repaired, errors };
}
/**
* Repair orphaned dose tracking IDs that no longer match the current intake schedule.
*/
export async function repairOrphanedDoseIds(client: Client): Promise<{ repaired: number; errors: string[] }> {
const errors: string[] = [];
let repaired = 0;
try {
const medsResult = await client.execute(
"SELECT id, intakes_json, usage_json, every_json, start_json, intake_reminders_enabled FROM medications"
);
if (medsResult.rows.length === 0) return { repaired, errors };
const dosesResult = await client.execute("SELECT id, dose_id FROM dose_tracking");
if (dosesResult.rows.length === 0) return { repaired, errors };
const dosesByMed = new Map<number, Array<{ id: number; doseId: string }>>();
for (const row of dosesResult.rows) {
const doseId = row.dose_id as string;
const parts = doseId.split("-");
if (parts.length < 3) continue;
const medId = parseInt(parts[0], 10);
if (Number.isNaN(medId)) continue;
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
dosesByMed.get(medId)?.push({ id: row.id as number, doseId });
}
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
for (const med of medsResult.rows) {
const medId = med.id as number;
const medDoses = dosesByMed.get(medId);
if (!medDoses || medDoses.length === 0) continue;
const intakes = parseIntakesJson(
med.intakes_json as string | null,
{
usageJson: (med.usage_json as string) || "[]",
everyJson: (med.every_json as string) || "[]",
startJson: (med.start_json as string) || "[]",
},
(med.intake_reminders_enabled as number) === 1
);
if (intakes.length === 0) continue;
const validDatesByIntake = new Map<number, Set<number>>();
for (let idx = 0; idx < intakes.length; idx++) {
const intake = intakes[idx];
const start = parseLocalDateTime(intake.start);
const every = intake.every;
if (every <= 0 || Number.isNaN(start.getTime())) continue;
const validDates = new Set<number>();
forEachScheduledOccurrenceInRange(intake, start.getTime(), today.getTime() + MS_PER_DAY - 1, (occurrenceMs) => {
validDates.add(getDateOnlyTimestamp(new Date(occurrenceMs)));
});
validDatesByIntake.set(idx, validDates);
}
for (const dose of medDoses) {
const parts = dose.doseId.split("-");
if (parts.length < 3) continue;
const intakeIdx = parseInt(parts[1], 10);
const dateOnlyMs = parseInt(parts[2], 10);
if (Number.isNaN(intakeIdx) || Number.isNaN(dateOnlyMs)) continue;
const validDates = validDatesByIntake.get(intakeIdx);
if (!validDates || validDates.has(dateOnlyMs)) continue;
const intake = intakes[intakeIdx];
if (!intake) continue;
const halfInterval = getScheduleMatchWindowMs(intake);
let bestMatch: number | null = null;
let bestDist = Infinity;
for (const validDate of validDates) {
const dist = Math.abs(validDate - dateOnlyMs);
if (dist < bestDist && dist <= halfInterval) {
bestDist = dist;
bestMatch = validDate;
}
}
if (bestMatch !== null) {
const personSuffix = parts.length > 3 ? `-${parts.slice(3).join("-")}` : "";
const newDoseId = `${medId}-${intakeIdx}-${bestMatch}${personSuffix}`;
try {
await client.execute({
sql: "UPDATE dose_tracking SET dose_id = ? WHERE id = ?",
args: [newDoseId, dose.id],
});
repaired++;
} catch (e: unknown) {
errors.push(`Failed to repair dose ${dose.id}: ${(e as Error).message}`);
}
}
}
}
} catch (e: unknown) {
errors.push(`Repair failed: ${(e as Error).message}`);
}
return { repaired, errors };
}
+13
View File
@@ -64,10 +64,23 @@ 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,
share_schedule_today_only integer NOT NULL DEFAULT 0,
swap_dashboard_main_sections integer NOT NULL DEFAULT 0,
last_auto_email_sent text,
last_notification_type text,
last_notification_channel text,
last_reminder_med_name text,
last_reminder_taken_by text,
last_stock_reminder_sent text,
last_stock_reminder_channel text,
last_stock_reminder_med_names text,
last_prescription_reminder_sent text,
last_prescription_reminder_channel text,
last_prescription_reminder_med_names text,
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
+80 -3
View File
@@ -29,6 +29,11 @@ export const medications = sqliteTable("medications", {
genericName: text("generic_name", { length: 100 }),
takenByJson: text("taken_by_json").notNull().default("[]"), // JSON array of person names
packageType: text("package_type", { length: 20 }).notNull().default("blister"), // 'blister' or 'bottle'
medicationForm: text("medication_form", { length: 20 }).notNull().default("tablet"), // 'capsule' | 'tablet' | 'liquid' | 'topical'
pillForm: text("pill_form", { length: 20 }), // Only for blister/bottle with pill-based medications: 'tablet' | 'capsule'
lifecycleCategory: text("lifecycle_category", { length: 30 }).notNull().default("refill_when_empty"), // 'refill_when_empty' | 'treatment_period'
packageAmountValue: integer("package_amount_value").notNull().default(0), // Informational package quantity (ml/g)
packageAmountUnit: text("package_amount_unit", { length: 10 }).notNull().default("ml"), // 'ml' | 'g'
packCount: integer("pack_count").notNull().default(1),
blistersPerPack: integer("blisters_per_pack").notNull().default(1),
pillsPerBlister: integer("pills_per_blister").notNull().default(1),
@@ -47,6 +52,13 @@ export const medications = sqliteTable("medications", {
expiryDate: text("expiry_date"),
notes: text("notes"),
intakeRemindersEnabled: integer("intake_reminders_enabled", { mode: "boolean" }).notNull().default(false),
medicationStartDate: text("medication_start_date").notNull().default(""),
medicationEndDate: text("medication_end_date"),
autoMarkObsoleteAfterEndDate: integer("auto_mark_obsolete_after_end_date", { mode: "boolean" })
.notNull()
.default(true),
isObsolete: integer("is_obsolete", { mode: "boolean" }).notNull().default(false),
obsoleteAt: integer("obsolete_at", { mode: "timestamp" }),
prescriptionEnabled: integer("prescription_enabled", { mode: "boolean" }).notNull().default(false),
prescriptionAuthorizedRefills: integer("prescription_authorized_refills"),
prescriptionRemainingRefills: integer("prescription_remaining_refills"),
@@ -93,10 +105,18 @@ 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
upcomingTodayOnly: integer("upcoming_today_only", { mode: "boolean" }).notNull().default(false),
shareScheduleTodayOnly: integer("share_schedule_today_only", { mode: "boolean" }).notNull().default(false),
swapDashboardMainSections: integer("swap_dashboard_main_sections", { mode: "boolean" }).notNull().default(false),
// Last notification tracking (intake reminders)
lastAutoEmailSent: text("last_auto_email_sent"),
lastNotificationType: text("last_notification_type"),
@@ -130,6 +150,25 @@ export const refreshTokens = sqliteTable("refresh_tokens", {
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
// =============================================================================
// API Keys - Personal access tokens for programmatic API access
// =============================================================================
export const apiKeys = sqliteTable("api_keys", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name", { length: 100 }).notNull(),
keyHash: text("key_hash", { length: 128 }).notNull().unique(),
tokenPrefix: text("token_prefix", { length: 24 }).notNull().default(""),
scope: text("scope", { length: 10 }).notNull().default("write"), // 'read' | 'write'
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
lastUsedAt: integer("last_used_at", { mode: "timestamp" }),
expiresAt: integer("expires_at", { mode: "timestamp" }),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
// =============================================================================
// Share Tokens - For public schedule sharing by takenBy person
// =============================================================================
@@ -145,6 +184,43 @@ export const shareTokens = sqliteTable("share_tokens", {
expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires
});
// =============================================================================
// Notification Action Groups - Shared action state for reminder notifications
// =============================================================================
export const notificationActionGroups = sqliteTable("notification_action_groups", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
groupKey: text("group_key", { length: 255 }).notNull().unique(),
sequenceId: text("sequence_id", { length: 255 }).notNull(),
ntfyOriginalMessageId: text("ntfy_original_message_id", { length: 255 }).notNull().default(""),
doseIdsJson: text("dose_ids_json").notNull(),
title: text("title", { length: 255 }).notNull(),
message: text("message").notNull(),
language: text("language", { length: 10 }).notNull().default("en"),
scheduledFor: integer("scheduled_for", { mode: "timestamp" }),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
resolvedAction: text("resolved_action", { length: 20 }),
resolvedAt: integer("resolved_at", { mode: "timestamp" }),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
// =============================================================================
// Notification Action Tokens - Hashed tokens for public reminder responses
// =============================================================================
export const notificationActionTokens = sqliteTable("notification_action_tokens", {
id: integer("id").primaryKey({ autoIncrement: true }),
groupId: integer("group_id")
.notNull()
.references(() => notificationActionGroups.id, { onDelete: "cascade" }),
tokenHash: text("token_hash", { length: 128 }).notNull().unique(),
kind: text("kind", { length: 20 }).notNull(),
usedAt: integer("used_at", { mode: "timestamp" }),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
// =============================================================================
// Dose Tracking - Tracks when doses are marked as taken
// =============================================================================
@@ -156,7 +232,8 @@ export const doseTracking = sqliteTable("dose_tracking", {
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual, automatic, or notification
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // legacy column: true = intake skipped without stock deduction
});
// =============================================================================
+6
View File
@@ -179,6 +179,8 @@ type TranslationKeys = {
common: {
pill: string;
pills: string;
units: string;
ml: string;
blister: string;
blisters: string;
day: string;
@@ -299,6 +301,8 @@ const translations: Record<Language, TranslationKeys> = {
common: {
pill: "pill",
pills: "pills",
units: "units",
ml: "ml",
blister: "blister",
blisters: "blisters",
day: "day",
@@ -420,6 +424,8 @@ const translations: Record<Language, TranslationKeys> = {
common: {
pill: "Tablette",
pills: "Tabletten",
units: "Einheiten",
ml: "ml",
blister: "Blister",
blisters: "Blister",
day: "Tag",
+123 -7
View File
@@ -1,29 +1,38 @@
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import type { IncomingHttpHeaders } from "node:http";
import { resolve } from "node:path";
import cookie from "@fastify/cookie";
import cors from "@fastify/cors";
import helmet from "@fastify/helmet";
import jwt from "@fastify/jwt";
import fastifyMultipart from "@fastify/multipart";
import rateLimit from "@fastify/rate-limit";
import sensible from "@fastify/sensible";
import fastifyStatic from "@fastify/static";
import fastifySwagger from "@fastify/swagger";
import fastifySwaggerUi from "@fastify/swagger-ui";
import Fastify, { type FastifyInstance } from "fastify";
import { migrationsReady } from "./db/client.js";
import { getDataDir } from "./db/db-utils.js";
import { env } from "./plugins/env.js";
import { jwtPlugin } from "./plugins/jwt.js";
import { apiKeyRoutes } from "./routes/api-keys.js";
import { authRoutes } from "./routes/auth.js";
import { doseRoutes } from "./routes/doses.js";
import { exportRoutes } from "./routes/export.js";
import { healthRoutes } from "./routes/health.js";
import { medicationEnrichmentRoutes } from "./routes/medication-enrichment.js";
import { medicationRoutes } from "./routes/medications.js";
import { oidcRoutes } from "./routes/oidc.js";
import { plannerRoutes } from "./routes/planner.js";
import { refillRoutes } from "./routes/refills.js";
import { reportRoutes } from "./routes/report.js";
import { settingsRoutes } from "./routes/settings.js";
import { shareRoutes } from "./routes/share.js";
import { startIntakeReminderScheduler } from "./services/intake-reminder-scheduler.js";
import { startMedicationEnrichmentCatalogRefresh } from "./services/medication-enrichment/index.js";
import { startReminderScheduler } from "./services/reminder-scheduler.js";
import { documentationSchemaAjv } from "./utils/documentation-schema-keywords.js";
// Re-export utilities from server-config for external use
export {
@@ -44,6 +53,82 @@ import {
parseCorsOrigins,
} from "./utils/server-config.js";
function sanitizeCorrelationId(headers: IncomingHttpHeaders): string | null {
const rawHeader = headers["x-correlation-id"];
if (typeof rawHeader !== "string") return null;
const trimmed = rawHeader.trim();
if (!trimmed) return null;
if (trimmed.length > 128) return null;
if (!/^[A-Za-z0-9._:-]+$/.test(trimmed)) return null;
return trimmed;
}
function buildLoggerOptions(level: string) {
const runtimeEnv = process.env.NODE_ENV ?? "production";
const base = {
level,
timestamp: () => `,"time":"${new Date().toISOString()}"`,
};
// Human-readable logs in development, structured JSON in production/test
if (runtimeEnv === "development") {
return {
...base,
transport: { target: "pino-pretty", options: { translateTime: "SYS:yyyy-mm-dd HH:MM:ss.l" } },
};
}
return base;
}
async function registerApiDocs(app: FastifyInstance, enabled: boolean) {
if (!enabled) return;
await app.register(fastifySwagger, {
openapi: {
openapi: "3.0.3",
info: {
title: "MedAssist-ng API",
description: "MedAssist-ng backend API",
version: process.env.npm_package_version ?? "dev",
},
servers: [{ url: "/", description: "Current server" }],
tags: [
{ name: "health", description: "Service health endpoints" },
{ name: "auth", description: "Authentication and profile endpoints" },
{ name: "api-keys", description: "Programmatic API key management" },
{ name: "medication-enrichment", description: "Medication search and enrichment endpoints" },
{ name: "settings", description: "User settings and notification test endpoints" },
],
components: {
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "API key or JWT",
description: "Use Authorization: Bearer ma_... (API key) or a JWT token.",
},
cookieAuth: {
type: "apiKey",
in: "cookie",
name: "access_token",
description: "Session cookie set by login.",
},
},
},
},
hideUntagged: false,
});
await app.register(fastifySwaggerUi, {
routePrefix: "/docs",
staticCSP: true,
transformSpecificationClone: true,
uiConfig: {
docExpansion: "list",
deepLinking: false,
},
});
}
/** Create and configure Fastify app (without starting) */
export async function createApp(options?: {
logLevel?: string;
@@ -56,6 +141,7 @@ export async function createApp(options?: {
refreshTtlDays?: number;
isProduction?: boolean;
imagesDir?: string;
openApiDocsEnabled?: boolean;
}): Promise<FastifyInstance> {
const opts = {
logLevel: options?.logLevel ?? "info",
@@ -68,10 +154,19 @@ export async function createApp(options?: {
refreshTtlDays: options?.refreshTtlDays ?? 7,
isProduction: options?.isProduction ?? false,
imagesDir: options?.imagesDir ?? resolve(getDataDir(), "images"),
openApiDocsEnabled: options?.openApiDocsEnabled ?? false,
};
const app = Fastify({
logger: { level: opts.logLevel },
logger: buildLoggerOptions(opts.logLevel),
genReqId: (request) => sanitizeCorrelationId(request.headers) ?? randomUUID(),
ajv: documentationSchemaAjv,
});
app.addHook("onRequest", (request, reply, done) => {
request.correlationId = request.id;
reply.header("x-correlation-id", request.id);
done();
});
// Build config
@@ -94,9 +189,10 @@ export async function createApp(options?: {
// JWT plugin
const jwtConfig = getJwtConfig(opts.authEnabled, opts.jwtSecret);
await app.register(jwt, jwtConfig);
await app.register(jwtPlugin, jwtConfig);
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } });
await registerApiDocs(app, opts.openApiDocsEnabled);
// Only register static if directory exists
if (existsSync(opts.imagesDir)) {
@@ -110,14 +206,17 @@ export async function createApp(options?: {
// Register routes
await app.register(healthRoutes);
await app.register(authRoutes);
await app.register(apiKeyRoutes);
await app.register(oidcRoutes);
await app.register(medicationRoutes);
await app.register(medicationEnrichmentRoutes);
await app.register(settingsRoutes);
await app.register(plannerRoutes);
await app.register(shareRoutes);
await app.register(doseRoutes);
await app.register(exportRoutes);
await app.register(refillRoutes);
await app.register(reportRoutes);
return app;
}
@@ -136,9 +235,15 @@ log.info("[DB] Migrations complete, starting server...");
const imagesDir = ensureImagesDirectory();
const app = Fastify({
logger: {
level: env.LOG_LEVEL,
},
logger: buildLoggerOptions(env.LOG_LEVEL),
genReqId: (request) => sanitizeCorrelationId(request.headers) ?? randomUUID(),
ajv: documentationSchemaAjv,
});
app.addHook("onRequest", (request, reply, done) => {
request.correlationId = request.id;
reply.header("x-correlation-id", request.id);
done();
});
const origins = parseCorsOrigins(env.CORS_ORIGINS);
@@ -171,9 +276,10 @@ await app.register(cookie, { secret: env.COOKIE_SECRET ?? "dev-cookie-secret" })
// JWT plugin - only register with valid secret if auth is enabled
const jwtConfig = getJwtConfig(env.AUTH_ENABLED, env.JWT_SECRET);
await app.register(jwt, jwtConfig);
await app.register(jwtPlugin, jwtConfig);
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } }); // 10MB limit
await registerApiDocs(app, env.OPENAPI_DOCS_ENABLED);
await app.register(fastifyStatic, {
root: imagesDir,
prefix: "/images/",
@@ -182,14 +288,17 @@ await app.register(fastifyStatic, {
await app.register(healthRoutes);
await app.register(authRoutes);
await app.register(apiKeyRoutes);
await app.register(oidcRoutes);
await app.register(medicationRoutes);
await app.register(medicationEnrichmentRoutes);
await app.register(settingsRoutes);
await app.register(plannerRoutes);
await app.register(shareRoutes);
await app.register(doseRoutes);
await app.register(exportRoutes);
await app.register(refillRoutes);
await app.register(reportRoutes);
const start = async () => {
try {
@@ -203,6 +312,13 @@ const start = async () => {
error: (msg) => app.log.error(msg),
});
startMedicationEnrichmentCatalogRefresh({
info: (msg: string) => app.log.info(msg),
debug: (msg: string) => app.log.debug(msg),
warn: (msg: string) => app.log.warn(msg),
error: (msg: string) => app.log.error(msg),
});
// Start the intake reminder scheduler (checks every minute)
startIntakeReminderScheduler({
info: (msg) => app.log.info(msg),
+145 -9
View File
@@ -1,7 +1,9 @@
import { count, eq, sql } from "drizzle-orm";
import { pbkdf2Sync } from "node:crypto";
import { and, count, eq, sql } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { db } from "../db/client.js";
import { users } from "../db/schema.js";
import { apiKeys, users } from "../db/schema.js";
import { log } from "../utils/logger.js";
import { env } from "./env.js";
// =============================================================================
@@ -47,7 +49,7 @@ export async function getAnonymousUserId(): Promise<number> {
export interface AuthState {
authEnabled: boolean;
registrationEnabled: boolean;
localAuthEnabled: boolean;
formLoginEnabled: boolean;
oidcEnabled: boolean;
oidcProviderName: string;
hasUsers: boolean;
@@ -59,15 +61,18 @@ export async function getAuthState(): Promise<AuthState> {
const [result] = await db.select({ count: count() }).from(users).where(sql`${users.id} != ${ANONYMOUS_USER_ID}`);
const hasUsers = result.count > 0;
const needsSetup = env.AUTH_ENABLED && !hasUsers;
return {
authEnabled: env.AUTH_ENABLED,
// Registration: enabled via ENV OR no users exist (first-time setup)
registrationEnabled: env.REGISTRATION_ENABLED || !hasUsers,
localAuthEnabled: env.AUTH_ENABLED, // Password auth available when auth is enabled
// Form login: enabled when auth + form login are both on, or forced on for first-user setup
formLoginEnabled: needsSetup || (env.AUTH_ENABLED && env.FORM_LOGIN_ENABLED),
oidcEnabled: env.OIDC_ENABLED,
oidcProviderName: env.OIDC_PROVIDER_NAME,
hasUsers,
needsSetup: env.AUTH_ENABLED && !hasUsers,
needsSetup,
};
}
@@ -79,6 +84,84 @@ export interface RequestUser {
username: string;
}
const READ_ONLY_METHODS = new Set(["GET", "HEAD", "OPTIONS"]);
function isMutationMethod(method: string): boolean {
return !READ_ONLY_METHODS.has(method.toUpperCase());
}
function getApiKeyPepper(): string {
return env.JWT_SECRET || env.REFRESH_SECRET || "medassist-api-key-pepper";
}
export function hashApiKeyToken(token: string): string {
return pbkdf2Sync(token, getApiKeyPepper(), 120_000, 64, "sha512").toString("hex");
}
function getBearerToken(request: FastifyRequest): string | null {
const authHeader = request.headers.authorization;
if (!authHeader) return null;
const [scheme, value] = authHeader.split(" ");
if (!scheme || !value) return null;
if (scheme.toLowerCase() !== "bearer") return null;
const token = value.trim();
return token.length > 0 ? token : null;
}
async function tryApiKeyAuth(request: FastifyRequest, reply: FastifyReply): Promise<boolean> {
const bearerToken = getBearerToken(request);
if (!bearerToken) return false;
if (!bearerToken.startsWith("ma_")) {
reply.status(401).send({ error: "Invalid API key", code: "INVALID_API_KEY" });
throw new Error("INVALID_API_KEY");
}
const keyHash = hashApiKeyToken(bearerToken);
const [keyRow] = await db
.select()
.from(apiKeys)
.where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true)));
if (!keyRow) {
reply.status(401).send({ error: "Invalid API key", code: "INVALID_API_KEY" });
throw new Error("INVALID_API_KEY");
}
if (keyRow.expiresAt && keyRow.expiresAt.getTime() <= Date.now()) {
reply.status(401).send({ error: "API key expired", code: "API_KEY_EXPIRED" });
throw new Error("API_KEY_EXPIRED");
}
const [user] = await db.select().from(users).where(eq(users.id, keyRow.userId));
if (!user || !user.isActive) {
reply.status(401).send({ error: "User not found", code: "USER_NOT_FOUND" });
throw new Error("USER_NOT_FOUND");
}
const scope = keyRow.scope === "read" ? "read" : "write";
if (scope === "read" && isMutationMethod(request.method)) {
reply.status(403).send({ error: "API key scope does not allow this operation", code: "API_KEY_SCOPE_FORBIDDEN" });
throw new Error("API_KEY_SCOPE_FORBIDDEN");
}
request.user = { id: user.id, username: user.username };
request.authContext = {
method: "api_key",
scope,
apiKeyId: keyRow.id,
};
await db
.update(apiKeys)
.set({ lastUsedAt: new Date(), updatedAt: new Date() })
.where(and(eq(apiKeys.id, keyRow.id), eq(apiKeys.userId, user.id)));
return true;
}
// =============================================================================
// Auth Middleware Functions
// =============================================================================
@@ -91,6 +174,37 @@ export async function optionalAuth(request: FastifyRequest, _reply: FastifyReply
return;
}
const bearerToken = getBearerToken(request);
if (bearerToken?.startsWith("ma_")) {
const keyHash = hashApiKeyToken(bearerToken);
const [keyRow] = await db
.select()
.from(apiKeys)
.where(and(eq(apiKeys.keyHash, keyHash), eq(apiKeys.isActive, true)));
if (!keyRow) {
log.debug("[Auth] optionalAuth API key verification failed: key not found");
return;
}
if (keyRow.expiresAt && keyRow.expiresAt.getTime() <= Date.now()) {
log.debug("[Auth] optionalAuth API key verification failed: key expired");
return;
}
const [userByKey] = await db.select().from(users).where(eq(users.id, keyRow.userId));
if (userByKey?.isActive) {
request.user = { id: userByKey.id, username: userByKey.username };
request.authContext = {
method: "api_key",
scope: keyRow.scope === "read" ? "read" : "write",
apiKeyId: keyRow.id,
};
log.debug("[Auth] optionalAuth authenticated via API key");
return;
}
log.debug("[Auth] optionalAuth API key verification failed: user inactive or missing");
return;
}
const token = request.cookies.access_token;
if (!token) {
return;
@@ -104,9 +218,15 @@ export async function optionalAuth(request: FastifyRequest, _reply: FastifyReply
id: user.id,
username: user.username,
};
request.authContext = {
method: "session",
scope: "write",
};
log.debug("[Auth] optionalAuth authenticated via session token");
}
} catch {
// Invalid token, continue as anonymous
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
log.debug(`[Auth] optionalAuth session verification failed: ${errorMessage}`);
}
}
@@ -118,6 +238,10 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply)
return;
}
if (await tryApiKeyAuth(request, reply)) {
return;
}
const token = request.cookies.access_token;
if (!token) {
reply.status(401).send({ error: "Authentication required", code: "AUTH_REQUIRED" });
@@ -142,9 +266,21 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply)
id: user.id,
username: user.username,
};
} catch (err: any) {
request.authContext = {
method: "session",
scope: "write",
};
} catch (err: unknown) {
// Re-throw our own errors
if (err?.message === "AUTH_REQUIRED" || err?.message === "USER_NOT_FOUND" || err?.message === "ACCOUNT_DISABLED") {
if (
err instanceof Error &&
(err.message === "AUTH_REQUIRED" ||
err.message === "USER_NOT_FOUND" ||
err.message === "ACCOUNT_DISABLED" ||
err.message === "INVALID_API_KEY" ||
err.message === "API_KEY_EXPIRED" ||
err.message === "API_KEY_SCOPE_FORBIDDEN")
) {
throw err;
}
// JWT verification failed
+56 -18
View File
@@ -10,10 +10,15 @@ 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")
.optional(),
// ==========================================================================
// Auth Configuration
@@ -21,14 +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"),
// Disable local auth when using SSO only
.default("false")
.transform((v) => v === "true"),
// Disable username/password form login (useful for OIDC-only setups)
FORM_LOGIN_ENABLED: z
.string()
.default("true")
.transform((v) => v === "true"),
// JWT Secrets - only required when AUTH_ENABLED=true
JWT_SECRET: z.string().min(10).optional(),
@@ -38,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(),
@@ -59,16 +68,19 @@ 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
});
export type Env = z.infer<typeof EnvSchema>;
type ParsedEnv = z.infer<typeof EnvSchema>;
export type Env = ParsedEnv & {
OPENAPI_DOCS_ENABLED: boolean;
};
// Parse and validate
let parsed: z.infer<typeof EnvSchema>;
let parsed: ParsedEnv;
try {
parsed = EnvSchema.parse(process.env);
} catch (err) {
@@ -128,4 +140,30 @@ if (parsed.OIDC_ENABLED) {
}
}
export const env = parsed;
// Validate that at least one login method is available when auth is enabled
if (parsed.AUTH_ENABLED && !parsed.FORM_LOGIN_ENABLED && !parsed.OIDC_ENABLED) {
console.error("=".repeat(60));
console.error("AUTHENTICATION CONFIGURATION ERROR");
console.error("=".repeat(60));
console.error("AUTH_ENABLED=true but no login method is available.");
console.error("FORM_LOGIN_ENABLED=false and OIDC_ENABLED=false means users cannot log in.");
console.error("");
console.error("To fix this, either:");
console.error(" 1. Set FORM_LOGIN_ENABLED=true to allow username/password login");
console.error(" 2. Set OIDC_ENABLED=true to allow SSO login");
console.error("=".repeat(60));
process.exit(1);
}
// Warn about ineffective registration when form login is disabled
if (parsed.REGISTRATION_ENABLED && !parsed.FORM_LOGIN_ENABLED) {
console.warn(
"[config] REGISTRATION_ENABLED=true has no effect when FORM_LOGIN_ENABLED=false (no registration form available)"
);
}
export const env: Env = {
...parsed,
// Docs UI/spec are enabled in non-production by default.
OPENAPI_DOCS_ENABLED: parsed.OPENAPI_DOCS_ENABLED ?? parsed.NODE_ENV !== "production",
};
+86
View File
@@ -0,0 +1,86 @@
import { TextEncoder } from "node:util";
import type { FastifyPluginAsync, FastifyRequest } from "fastify";
import fastifyPlugin from "fastify-plugin";
import { SignJWT, jwtVerify as verifyJwt } from "jose";
const JWT_ALGORITHM = "HS256";
const encoder = new TextEncoder();
export interface JwtPluginOptions {
secret: string;
cookie: {
cookieName: string;
signed: boolean;
};
}
export interface JwtSignOptions {
expiresIn?: string | number;
key?: string;
}
export interface JwtVerifyOptions {
key?: string;
}
function getKey(secret: string): Uint8Array {
return encoder.encode(secret);
}
function getTokenFromRequest(request: FastifyRequest, cookieName: string): string {
const authorization = request.headers.authorization;
if (authorization) {
const [scheme, rawToken] = authorization.split(" ");
if (scheme?.toLowerCase() === "bearer" && rawToken?.trim()) {
return rawToken.trim();
}
}
const token = request.cookies?.[cookieName];
if (typeof token === "string" && token.length > 0) {
return token;
}
throw new Error("JWT token missing");
}
const jwtPluginImpl: FastifyPluginAsync<JwtPluginOptions> = async (app, options) => {
const defaultKey = getKey(options.secret);
app.decorate("jwt", {
sign(payload: Record<string, unknown>, signOptions?: JwtSignOptions) {
const tokenBuilder = new SignJWT(payload).setProtectedHeader({ alg: JWT_ALGORITHM, typ: "JWT" }).setIssuedAt();
if (signOptions?.expiresIn != null) {
tokenBuilder.setExpirationTime(signOptions.expiresIn);
}
return tokenBuilder.sign(getKey(signOptions?.key ?? options.secret));
},
async verify<T extends Record<string, unknown>>(token: string, verifyOptions?: JwtVerifyOptions): Promise<T> {
const { payload } = await verifyJwt(token, getKey(verifyOptions?.key ?? options.secret), {
algorithms: [JWT_ALGORITHM],
typ: "JWT",
});
return payload as T;
},
});
app.decorateRequest("jwtVerify", async function jwtVerify<
T extends Record<string, unknown>,
>(this: FastifyRequest, verifyOptions?: JwtVerifyOptions): Promise<T> {
const token = getTokenFromRequest(this, options.cookie.cookieName);
const { payload } = await verifyJwt(token, verifyOptions?.key ? getKey(verifyOptions.key) : defaultKey, {
algorithms: [JWT_ALGORITHM],
typ: "JWT",
});
return payload as T;
});
};
export const jwtPlugin = fastifyPlugin(jwtPluginImpl, {
name: "medassist-jwt-plugin",
});
+302
View File
@@ -0,0 +1,302 @@
import { randomBytes } from "node:crypto";
import { and, desc, eq } from "drizzle-orm";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { apiKeys } from "../db/schema.js";
import { hashApiKeyToken, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
const createApiKeySchema = z.object({
name: z.string().trim().min(3).max(100),
scope: z.enum(["read", "write"]).default("write"),
expiresInDays: z.number().int().min(1).max(3650).optional(),
});
const idParamSchema = z.object({
id: z.string().regex(/^\d+$/),
});
const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
{ bearerAuth: [] },
{ cookieAuth: [] },
];
const genericErrorSchema = {
type: "object",
properties: {
error: { type: "string" },
code: { type: "string" },
},
};
const apiKeyMetadataSchema = {
type: "object",
properties: {
id: { type: "number" },
name: { type: "string" },
tokenPrefix: { type: "string" },
scope: { type: "string", enum: ["read", "write"] },
isActive: { type: "boolean" },
lastUsedAt: { type: ["string", "null"], format: "date-time" },
expiresAt: { type: ["string", "null"], format: "date-time" },
createdAt: { type: ["string", "null"], format: "date-time" },
updatedAt: { type: ["string", "null"], format: "date-time" },
},
};
function normalizeDateTime(value: unknown): string | null {
if (value == null) {
return null;
}
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.toISOString();
}
if (typeof value === "number") {
const timestampMs = value < 1_000_000_000_000 ? value * 1000 : value;
const date = new Date(timestampMs);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
if (typeof value === "string") {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
return null;
}
function serializeApiKeyMetadata<
T extends {
id: number;
name: string;
tokenPrefix: string;
scope: string;
isActive: boolean;
lastUsedAt: unknown;
expiresAt: unknown;
createdAt: unknown;
updatedAt: unknown;
},
>(key: T) {
return {
id: key.id,
name: key.name,
tokenPrefix: key.tokenPrefix,
scope: key.scope,
isActive: key.isActive,
lastUsedAt: normalizeDateTime(key.lastUsedAt),
expiresAt: normalizeDateTime(key.expiresAt),
createdAt: normalizeDateTime(key.createdAt),
updatedAt: normalizeDateTime(key.updatedAt),
};
}
export async function apiKeyRoutes(app: FastifyInstance) {
app.addHook("preHandler", requireAuth);
app.get(
"/auth/api-keys",
{
schema: {
tags: ["api-keys"],
summary: "List API keys for the current user",
description: "Returns API key metadata. Raw API key tokens are never returned.",
security: protectedEndpointSecurity,
response: {
200: {
type: "object",
properties: {
keys: {
type: "array",
items: apiKeyMetadataSchema,
},
},
},
400: genericErrorSchema,
401: genericErrorSchema,
},
},
},
async (request, reply) => {
if (!env.AUTH_ENABLED) {
return reply.status(400).send({ error: "API keys are unavailable when auth is disabled" });
}
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" });
}
const keys = await db
.select({
id: apiKeys.id,
name: apiKeys.name,
tokenPrefix: apiKeys.tokenPrefix,
scope: apiKeys.scope,
isActive: apiKeys.isActive,
lastUsedAt: apiKeys.lastUsedAt,
expiresAt: apiKeys.expiresAt,
createdAt: apiKeys.createdAt,
updatedAt: apiKeys.updatedAt,
})
.from(apiKeys)
.where(eq(apiKeys.userId, authUser.id))
.orderBy(desc(apiKeys.createdAt));
return { keys: keys.map(serializeApiKeyMetadata) };
}
);
app.post<{ Body: z.infer<typeof createApiKeySchema> }>(
"/auth/api-keys",
{
schema: {
tags: ["api-keys"],
summary: "Create and rotate API key",
description:
"Creates a new API key and deactivates previously active API keys for the current user. The new token is returned only once.",
security: protectedEndpointSecurity,
body: {
type: "object",
required: ["name"],
properties: {
name: { type: "string", minLength: 3, maxLength: 100 },
scope: { type: "string", enum: ["read", "write"], default: "write" },
expiresInDays: { type: "number", minimum: 1, maximum: 3650 },
},
example: {
name: "Home Assistant integration",
scope: "write",
expiresInDays: 365,
},
},
response: {
201: {
type: "object",
properties: {
key: apiKeyMetadataSchema,
token: { type: "string" },
note: { type: "string" },
},
},
400: { anyOf: [genericErrorSchema, { type: "object" }] },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
if (!env.AUTH_ENABLED) {
return reply.status(400).send({ error: "API keys are unavailable when auth is disabled" });
}
const parsed = createApiKeySchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send(parsed.error.format());
}
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" });
}
const { name, scope, expiresInDays } = parsed.data;
const rawToken = `ma_${randomBytes(32).toString("hex")}`;
const tokenPrefix = `${rawToken.slice(0, 12)}...`;
const keyHash = hashApiKeyToken(rawToken);
const expiresAt = expiresInDays ? new Date(Date.now() + expiresInDays * 24 * 60 * 60 * 1000) : null;
// Keep a single active key per user: creating a new key invalidates old ones.
await db
.update(apiKeys)
.set({ isActive: false, updatedAt: new Date() })
.where(and(eq(apiKeys.userId, authUser.id), eq(apiKeys.isActive, true)));
const [created] = await db
.insert(apiKeys)
.values({
userId: authUser.id,
name,
keyHash,
tokenPrefix,
scope,
expiresAt,
})
.returning({
id: apiKeys.id,
name: apiKeys.name,
tokenPrefix: apiKeys.tokenPrefix,
scope: apiKeys.scope,
isActive: apiKeys.isActive,
lastUsedAt: apiKeys.lastUsedAt,
expiresAt: apiKeys.expiresAt,
createdAt: apiKeys.createdAt,
updatedAt: apiKeys.updatedAt,
});
return reply.status(201).send({
key: serializeApiKeyMetadata(created),
token: rawToken,
note: "Store this token now. It cannot be retrieved again.",
});
}
);
app.delete<{ Params: { id: string } }>(
"/auth/api-keys/:id",
{
schema: {
tags: ["api-keys"],
summary: "Deactivate API key",
description: "Deactivates one API key belonging to the current user.",
security: protectedEndpointSecurity,
params: {
type: "object",
required: ["id"],
properties: {
id: { type: "string", pattern: "^\\d+$" },
},
},
response: {
204: { type: "null" },
400: { anyOf: [genericErrorSchema, { type: "object" }] },
401: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
if (!env.AUTH_ENABLED) {
return reply.status(400).send({ error: "API keys are unavailable when auth is disabled" });
}
const parsedParams = idParamSchema.safeParse(request.params);
if (!parsedParams.success) {
return reply.status(400).send(parsedParams.error.format());
}
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" });
}
const keyId = Number(parsedParams.data.id);
const [existing] = await db
.select({ id: apiKeys.id, userId: apiKeys.userId })
.from(apiKeys)
.where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, authUser.id)));
if (!existing) {
return reply.status(404).send({ error: "API key not found", code: "API_KEY_NOT_FOUND" });
}
await db
.update(apiKeys)
.set({ isActive: false, updatedAt: new Date() })
.where(and(eq(apiKeys.id, keyId), eq(apiKeys.userId, authUser.id)));
return reply.status(204).send();
}
);
}
+315 -72
View File
@@ -1,13 +1,20 @@
import { randomBytes } from "node:crypto";
import { resolve } from "node:path";
import argon2 from "argon2";
import { eq, sql } from "drizzle-orm";
import type { FastifyInstance } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { getDataDir } from "../db/db-utils.js";
import { getDataDir } from "../db/path-utils.js";
import { refreshTokens, users } from "../db/schema.js";
import { getAuthState, requireAuth } from "../plugins/auth.js";
import type { AuthUser } from "../types/fastify.js";
import {
ALLOWED_IMAGE_MIME_TYPES,
removeImageFiles,
streamToBuffer,
writeOptimizedImageSet,
} from "../utils/image-upload.js";
// =============================================================================
// Argon2id Configuration - State of the Art Password Hashing
@@ -53,6 +60,7 @@ const sensitiveRateLimitConfig = {
const registerSchema = z.object({
username: z
.string()
.trim()
.min(3, "Username must be at least 3 characters")
.max(50, "Username must be at most 50 characters")
.regex(/^[a-zA-Z0-9_-]+$/, "Username can only contain letters, numbers, underscores, and hyphens"),
@@ -63,7 +71,7 @@ const registerSchema = z.object({
});
const loginSchema = z.object({
username: z.string().min(1, "Username is required"),
username: z.string().trim().min(1, "Username is required"),
password: z.string().min(1, "Password is required"),
rememberMe: z.boolean().optional().default(false),
});
@@ -77,10 +85,44 @@ const updateProfileSchema = z.object({
.optional(),
});
const authEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [{ bearerAuth: [] }, { cookieAuth: [] }];
const authErrorSchema = {
type: "object",
properties: {
error: { type: "string" },
code: { type: "string" },
},
};
function normalizeDateTime(value: unknown): string | null {
if (value == null) {
return null;
}
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.toISOString();
}
if (typeof value === "number") {
const timestampMs = value < 1_000_000_000_000 ? value * 1000 : value;
const date = new Date(timestampMs);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
if (typeof value === "string") {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
return null;
}
// =============================================================================
// Auth Routes
// =============================================================================
export async function authRoutes(app: FastifyInstance) {
const IMAGES_DIR = resolve(getDataDir(), "images");
// Token TTLs
const accessTtlMinutes = 15;
const refreshTtlDays = 14;
@@ -89,9 +131,33 @@ export async function authRoutes(app: FastifyInstance) {
// GET /auth/state - Public auth state (needed before login)
// Exempt from rate limit - lightweight state check called frequently
// ---------------------------------------------------------------------------
app.get("/auth/state", { config: { rateLimit: false } }, async () => {
return getAuthState();
});
app.get(
"/auth/state",
{
config: { rateLimit: false },
schema: {
tags: ["auth"],
summary: "Get authentication state",
description: "Returns auth and login mode state before user login.",
response: {
200: {
type: "object",
properties: {
authEnabled: { type: "boolean" },
registrationEnabled: { type: "boolean" },
formLoginEnabled: { type: "boolean" },
oidcEnabled: { type: "boolean" },
hasUsers: { type: "boolean" },
oidcProviderName: { type: "string" },
},
},
},
},
},
async () => {
return getAuthState();
}
);
// ---------------------------------------------------------------------------
// POST /auth/register - User registration
@@ -100,6 +166,40 @@ export async function authRoutes(app: FastifyInstance) {
"/auth/register",
{
config: { rateLimit: sensitiveRateLimitConfig },
schema: {
tags: ["auth"],
summary: "Register local user",
body: {
type: "object",
required: ["username", "password"],
properties: {
username: { type: "string", minLength: 3, maxLength: 50 },
password: { type: "string", minLength: 8, maxLength: 128 },
},
example: {
username: "daniel",
password: "correct-horse-battery-staple",
},
},
response: {
201: {
type: "object",
properties: {
ok: { type: "boolean" },
user: {
type: "object",
properties: {
id: { type: "number" },
username: { type: "string" },
},
},
message: { type: "string" },
},
},
400: authErrorSchema,
409: authErrorSchema,
},
},
},
async (request, reply) => {
// Check auth state
@@ -113,15 +213,15 @@ export async function authRoutes(app: FastifyInstance) {
return reply.status(400).send({ error: "Registration is disabled", code: "REGISTRATION_DISABLED" });
}
if (!state.localAuthEnabled) {
return reply.status(400).send({ error: "Local authentication is disabled", code: "LOCAL_AUTH_DISABLED" });
if (!state.formLoginEnabled) {
return reply.status(400).send({ error: "Form login is disabled", code: "FORM_LOGIN_DISABLED" });
}
// Validate input
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",
});
}
@@ -147,7 +247,7 @@ export async function authRoutes(app: FastifyInstance) {
})
.returning();
app.log.info(`User registered: ${username}`);
app.log.info(`[Auth] Account registered: username=${newUser.username}, userId=${newUser.id}`);
return reply.status(201).send({
ok: true,
@@ -167,6 +267,42 @@ export async function authRoutes(app: FastifyInstance) {
"/auth/login",
{
config: { rateLimit: sensitiveRateLimitConfig },
schema: {
tags: ["auth"],
summary: "Login with username and password",
body: {
type: "object",
required: ["username", "password"],
properties: {
username: { type: "string" },
password: { type: "string" },
rememberMe: { type: "boolean" },
},
example: {
username: "daniel",
password: "correct-horse-battery-staple",
rememberMe: true,
},
},
response: {
200: {
type: "object",
properties: {
ok: { type: "boolean" },
user: {
type: "object",
properties: {
id: { type: "number" },
username: { type: "string" },
avatarUrl: { type: ["string", "null"] },
},
},
},
},
400: authErrorSchema,
401: authErrorSchema,
},
},
},
async (request, reply) => {
const state = await getAuthState();
@@ -175,8 +311,8 @@ export async function authRoutes(app: FastifyInstance) {
return reply.status(400).send({ error: "Authentication is disabled", code: "AUTH_DISABLED" });
}
if (!state.localAuthEnabled) {
return reply.status(400).send({ error: "Local authentication is disabled", code: "LOCAL_AUTH_DISABLED" });
if (!state.formLoginEnabled) {
return reply.status(400).send({ error: "Form login is disabled", code: "FORM_LOGIN_DISABLED" });
}
const parsed = loginSchema.safeParse(request.body);
@@ -221,7 +357,7 @@ export async function authRoutes(app: FastifyInstance) {
await db.update(users).set({ lastLoginAt: new Date(), updatedAt: new Date() }).where(eq(users.id, user.id));
// Generate tokens
const accessToken = app.jwt.sign(
const accessToken = await app.jwt.sign(
{ sub: user.id, username: user.username },
{ expiresIn: `${accessTtlMinutes}m` }
);
@@ -235,12 +371,12 @@ export async function authRoutes(app: FastifyInstance) {
expiresAt: refreshExp,
});
const refreshToken = app.jwt.sign(
const refreshToken = await app.jwt.sign(
{ sub: user.id, jti: tokenId },
{ expiresIn: `${refreshTtlDays}d`, key: app.config.refreshSecret }
);
app.log.info(`User logged in: ${username} (rememberMe: ${rememberMe})`);
app.log.info(`[Auth] Login succeeded: username=${user.username}, userId=${user.id}, rememberMe=${rememberMe}`);
// Cookie options: with maxAge for "remember me", without for session cookie
const accessCookieOptions = rememberMe
@@ -271,6 +407,15 @@ export async function authRoutes(app: FastifyInstance) {
"/auth/refresh",
{
config: { rateLimit: authRateLimitConfig },
schema: {
tags: ["auth"],
summary: "Refresh access token",
description: "Requires refresh token cookie context.",
response: {
200: { type: "object", properties: { ok: { type: "boolean" } } },
401: authErrorSchema,
},
},
},
async (request, reply) => {
const refreshTokenCookie = request.cookies.refresh_token;
@@ -280,7 +425,7 @@ export async function authRoutes(app: FastifyInstance) {
try {
// Verify refresh token
const decoded = app.jwt.verify<{ sub: number; jti: string }>(refreshTokenCookie, {
const decoded = await app.jwt.verify<{ sub: number; jti: string }>(refreshTokenCookie, {
key: app.config.refreshSecret,
});
@@ -313,12 +458,12 @@ export async function authRoutes(app: FastifyInstance) {
});
// Generate new tokens
const newAccessToken = app.jwt.sign(
const newAccessToken = await app.jwt.sign(
{ sub: user.id, username: user.username },
{ expiresIn: `${accessTtlMinutes}m` }
);
const newRefreshToken = app.jwt.sign(
const newRefreshToken = await app.jwt.sign(
{ sub: user.id, jti: newTokenId },
{ expiresIn: `${refreshTtlDays}d`, key: app.config.refreshSecret }
);
@@ -340,13 +485,22 @@ export async function authRoutes(app: FastifyInstance) {
"/auth/logout",
{
config: { rateLimit: authRateLimitConfig },
schema: {
tags: ["auth"],
summary: "Logout and clear auth cookies",
response: {
200: { type: "object", properties: { ok: { type: "boolean" } } },
},
},
},
async (request, reply) => {
const refreshTokenCookie = request.cookies.refresh_token;
if (refreshTokenCookie) {
try {
const decoded = app.jwt.verify<{ jti: string }>(refreshTokenCookie, { key: app.config.refreshSecret });
const decoded = await app.jwt.verify<{ jti: string }>(refreshTokenCookie, {
key: app.config.refreshSecret,
});
// Revoke the refresh token
await db.update(refreshTokens).set({ revoked: true }).where(eq(refreshTokens.tokenId, decoded.jti));
@@ -365,26 +519,56 @@ export async function authRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
// GET /auth/me - Get current user profile
// ---------------------------------------------------------------------------
app.get("/auth/me", { preHandler: requireAuth }, async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "Not authenticated" });
}
app.get(
"/auth/me",
{
preHandler: requireAuth,
schema: {
tags: ["auth"],
summary: "Get current user profile",
security: authEndpointSecurity,
response: {
200: {
type: "object",
properties: {
id: { type: "number" },
username: { type: "string" },
avatarUrl: { type: ["string", "null"] },
authProvider: { type: "string" },
createdAt: { type: "string", format: "date-time" },
lastLoginAt: { type: ["string", "null"], format: "date-time" },
},
},
401: authErrorSchema,
404: authErrorSchema,
},
},
},
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
return reply.status(401).send({ error: "Not authenticated" });
}
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
if (!user) {
return reply.status(404).send({ error: "User not found" });
}
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
if (!user) {
return reply.status(404).send({ error: "User not found" });
}
return {
id: user.id,
username: user.username,
avatarUrl: user.avatarUrl,
authProvider: user.authProvider,
createdAt: user.createdAt,
lastLoginAt: user.lastLoginAt,
};
});
const createdAt =
normalizeDateTime(user.createdAt) ?? normalizeDateTime(user.updatedAt) ?? new Date(0).toISOString();
const lastLoginAt = normalizeDateTime(user.lastLoginAt);
return {
id: user.id,
username: user.username,
avatarUrl: user.avatarUrl,
authProvider: user.authProvider ?? "local",
createdAt,
lastLoginAt,
};
}
);
// ---------------------------------------------------------------------------
// PUT /auth/me - Update current user profile
@@ -394,6 +578,34 @@ export async function authRoutes(app: FastifyInstance) {
{
preHandler: requireAuth,
config: { rateLimit: authRateLimitConfig },
schema: {
tags: ["auth"],
summary: "Update current user profile",
security: authEndpointSecurity,
body: {
type: "object",
properties: {
currentPassword: { type: "string" },
newPassword: { type: "string", minLength: 8, maxLength: 128 },
},
example: {
currentPassword: "current-password",
newPassword: "new-strong-password",
},
},
response: {
200: {
type: "object",
properties: {
ok: { type: "boolean" },
message: { type: "string" },
},
},
400: authErrorSchema,
401: authErrorSchema,
404: authErrorSchema,
},
},
},
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
@@ -404,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",
});
}
@@ -452,6 +664,24 @@ export async function authRoutes(app: FastifyInstance) {
{
preHandler: requireAuth,
config: { rateLimit: authRateLimitConfig },
schema: {
tags: ["auth"],
summary: "Upload user avatar",
description: "Uploads and optimizes a profile image using multipart/form-data.",
security: authEndpointSecurity,
consumes: ["multipart/form-data"],
response: {
200: {
type: "object",
properties: {
ok: { type: "boolean" },
avatarUrl: { type: "string" },
},
},
400: authErrorSchema,
401: authErrorSchema,
},
},
},
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
@@ -461,36 +691,35 @@ export async function authRoutes(app: FastifyInstance) {
const data = await request.file();
if (!data) {
return reply.status(400).send({ error: "No file uploaded" });
return reply.status(400).send({ error: "No file uploaded", code: "NO_FILE" });
}
// Validate file type
const allowedTypes = ["image/jpeg", "image/png", "image/webp", "image/gif"];
if (!allowedTypes.includes(data.mimetype)) {
return reply.status(400).send({ error: "Invalid file type. Allowed: JPEG, PNG, WebP, GIF" });
if (!ALLOWED_IMAGE_MIME_TYPES.includes(data.mimetype)) {
return reply.status(400).send({ error: "Invalid file type", code: "INVALID_TYPE" });
}
// Generate unique filename
const ext = data.filename.split(".").pop() || "jpg";
const filename = `avatar_${authUser.id}_${Date.now()}.${ext}`;
let uploadBuffer: Buffer;
try {
uploadBuffer = await streamToBuffer(data.file);
} catch (error) {
if (error instanceof Error && error.message === "IMAGE_TOO_LARGE") {
return reply.status(400).send({ error: "Image too large", code: "IMAGE_TOO_LARGE" });
}
throw error;
}
// Save file
const fs = await import("node:fs/promises");
const path = await import("node:path");
const imagesDir = path.join(getDataDir(), "images");
await fs.mkdir(imagesDir, { recursive: true });
const buffer = await data.toBuffer();
await fs.writeFile(path.join(imagesDir, filename), buffer);
let filename: string;
try {
({ filename } = await writeOptimizedImageSet(IMAGES_DIR, `avatar_${authUser.id}`, uploadBuffer));
} catch {
return reply.status(400).send({ error: "Invalid image", code: "INVALID_IMAGE" });
}
// Delete old avatar if exists
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
if (user?.avatarUrl) {
try {
await fs.unlink(path.join(imagesDir, user.avatarUrl));
} catch {
// Ignore if file doesn't exist
}
removeImageFiles(IMAGES_DIR, user.avatarUrl);
}
// Update user
@@ -508,6 +737,16 @@ export async function authRoutes(app: FastifyInstance) {
{
preHandler: requireAuth,
config: { rateLimit: authRateLimitConfig },
schema: {
tags: ["auth"],
summary: "Delete user avatar",
security: authEndpointSecurity,
response: {
200: { type: "object", properties: { ok: { type: "boolean" } } },
401: authErrorSchema,
404: authErrorSchema,
},
},
},
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
@@ -521,13 +760,7 @@ export async function authRoutes(app: FastifyInstance) {
}
// Delete file
const fs = await import("node:fs/promises");
const path = await import("node:path");
try {
await fs.unlink(path.join(getDataDir(), "images", user.avatarUrl));
} catch {
// Ignore if file doesn't exist
}
removeImageFiles(IMAGES_DIR, user.avatarUrl);
// Update user
await db.update(users).set({ avatarUrl: null, updatedAt: new Date() }).where(eq(users.id, authUser.id));
@@ -544,6 +777,22 @@ export async function authRoutes(app: FastifyInstance) {
{
preHandler: requireAuth,
config: { rateLimit: sensitiveRateLimitConfig },
schema: {
tags: ["auth"],
summary: "Delete current user account",
description: "Deletes the current account and related data (cascade delete).",
security: authEndpointSecurity,
response: {
200: {
type: "object",
properties: {
ok: { type: "boolean" },
message: { type: "string" },
},
},
401: authErrorSchema,
},
},
},
async (request, reply) => {
const authUser = request.user as unknown as AuthUser | null;
@@ -554,19 +803,13 @@ export async function authRoutes(app: FastifyInstance) {
// Delete avatar file if exists
const [user] = await db.select().from(users).where(eq(users.id, authUser.id));
if (user?.avatarUrl) {
const fs = await import("node:fs/promises");
const path = await import("node:path");
try {
await fs.unlink(path.join(getDataDir(), "images", user.avatarUrl));
} catch {
// Ignore if file doesn't exist
}
removeImageFiles(IMAGES_DIR, user.avatarUrl);
}
// Delete user - cascade delete handles all related data
await db.delete(users).where(eq(users.id, authUser.id));
app.log.info(`User deleted account: ${authUser.username} (ID: ${authUser.id})`);
app.log.info(`[Auth] Account deleted: username=${authUser.username}, userId=${authUser.id}`);
// Clear auth cookies
return reply
+507 -83
View File
@@ -2,10 +2,23 @@ import { and, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { doseTracking, shareTokens } from "../db/schema.js";
import { doseTracking, medications, shareTokens, userSettings } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import { computeMedicationCurrentStock } from "../services/current-stock.js";
import type { AuthUser } from "../types/fastify.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
tokenParamsSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import {
parseIntakesJson,
parseLocalDateTime,
parseTakenByJson,
personTakesMedication,
} from "../utils/scheduler-utils.js";
// =============================================================================
// Validation Schemas
@@ -22,6 +35,41 @@ const dismissDosesSchema = z.object({
doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"),
});
const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
{ bearerAuth: [] },
{ cookieAuth: [] },
];
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
const doseReadResponseSchema = {
type: "object",
properties: {
doses: {
type: "array",
items: {
type: "object",
properties: {
doseId: { type: "string" },
takenAt: { type: "number" },
markedBy: { type: ["string", "null"] },
takenSource: { type: "string" },
dismissed: { type: "boolean" },
},
},
},
},
} as const;
function getValidationErrorMessage(error: z.ZodError): string {
const firstIssue = error.issues[0];
if (!firstIssue) {
return "Invalid input";
}
return firstIssue.code === "invalid_type" && firstIssue.input === undefined ? "Required" : firstIssue.message;
}
// Helper to get user ID from request
// Returns anonymous user ID when auth is disabled
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
@@ -38,42 +86,231 @@ async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<
return authUser.id;
}
type ParsedDoseId = {
medicationId: number;
intakeIndex: number;
timestampMs: number;
personSuffix: string | null;
};
function parseDoseId(doseId: string): ParsedDoseId | null {
const match = doseIdPattern.exec(doseId);
if (!match) return null;
const medicationId = Number.parseInt(match[1], 10);
const intakeIndex = Number.parseInt(match[2], 10);
const timestampMs = Number.parseInt(match[3], 10);
const personSuffix = match[4] ? match[4].trim() : null;
if (Number.isNaN(medicationId) || Number.isNaN(intakeIndex) || Number.isNaN(timestampMs) || intakeIndex < 0) {
return null;
}
return {
medicationId,
intakeIndex,
timestampMs,
personSuffix,
};
}
async function getActiveShareToken(token: string): Promise<{
share: typeof shareTokens.$inferSelect | null;
reason: "not_found" | "expired" | "ok";
}> {
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) return { share: null, reason: "not_found" };
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
return { share: null, reason: "expired" };
}
return { share, reason: "ok" };
}
async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseId: string): Promise<boolean> {
const parsedDose = parseDoseId(doseId);
if (!parsedDose) {
return false;
}
const [medication] = await db
.select()
.from(medications)
.where(and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, share.userId)));
if (!medication) {
return false;
}
const medTakenBy = parseTakenByJson(medication.takenByJson);
const intakes = parseIntakesJson(
medication.intakesJson,
{ usageJson: medication.usageJson, everyJson: medication.everyJson, startJson: medication.startJson },
medication.intakeRemindersEnabled ?? false
);
if (!personTakesMedication(share.takenBy, medTakenBy, intakes)) {
return false;
}
const intake = intakes[parsedDose.intakeIndex];
if (!intake) {
return false;
}
const expectedPersons = intake.takenBy ? [intake.takenBy] : medTakenBy;
if (expectedPersons.length === 0) {
return parsedDose.personSuffix === null;
}
if (!parsedDose.personSuffix) {
return intake.takenBy === null;
}
return expectedPersons.includes(parsedDose.personSuffix);
}
async function isDoseOutOfStock(options: {
userId: number;
doseId: string;
stockCalculationMode: "automatic" | "manual";
}): Promise<boolean> {
const parsedDose = parseDoseId(options.doseId);
if (!parsedDose) {
return false;
}
const [medication] = await db
.select()
.from(medications)
.where(and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, options.userId)));
if (!medication) {
return false;
}
const intakes = parseIntakesJson(
medication.intakesJson,
{ usageJson: medication.usageJson, everyJson: medication.everyJson, startJson: medication.startJson },
medication.intakeRemindersEnabled ?? false
);
const intake = intakes[parsedDose.intakeIndex];
const scheduledOccurrenceMs = intake
? (() => {
const doseDate = new Date(parsedDose.timestampMs);
const intakeStart = parseLocalDateTime(intake.start);
return new Date(
doseDate.getFullYear(),
doseDate.getMonth(),
doseDate.getDate(),
intakeStart.getHours(),
intakeStart.getMinutes(),
intakeStart.getSeconds(),
intakeStart.getMilliseconds()
).getTime();
})()
: parsedDose.timestampMs;
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, options.userId));
const stockBeforeDoseMs = Math.max(0, scheduledOccurrenceMs - 1);
return (
computeMedicationCurrentStock({
medication,
doses,
stockCalculationMode: options.stockCalculationMode,
nowMs: stockBeforeDoseMs,
}) <= 0
);
}
// =============================================================================
// Dose Tracking Routes
// =============================================================================
export async function doseRoutes(app: FastifyInstance) {
applyOpenApiRouteStandards(app, {
tag: "doses",
protectedByDefault: false,
protectedPaths: [/^\/doses\/taken$/, /^\/doses\/taken\/:doseId$/, /^\/doses\/dismiss$/],
});
// ---------------------------------------------------------------------------
// GET /doses/taken - PROTECTED: Get all taken doses for the user
// Suppress request logs — polled every 5s by frontend
// ---------------------------------------------------------------------------
app.get("/doses/taken", { preHandler: requireAuth }, async (request, reply) => {
const userId = await getUserId(request, reply);
app.get(
"/doses/taken",
{
preHandler: requireAuth,
logLevel: "warn",
schema: {
tags: ["doses"],
security: protectedEndpointSecurity,
response: {
200: doseReadResponseSchema,
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
// Get all taken doses for this user (no time limit)
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
// Get all taken doses for this user (no time limit)
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
return {
doses: doses.map((d) => ({
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
dismissed: d.dismissed ?? false,
})),
};
});
return {
doses: doses.map((d) => ({
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
takenSource: d.takenSource ?? "manual",
dismissed: d.dismissed ?? false,
})),
};
}
);
// ---------------------------------------------------------------------------
// POST /doses/taken - PROTECTED: Mark a dose as taken
// ---------------------------------------------------------------------------
app.post<{ Body: z.infer<typeof markDoseSchema> }>(
"/doses/taken",
{ preHandler: requireAuth },
{
preHandler: requireAuth,
schema: {
tags: ["doses"],
security: protectedEndpointSecurity,
body: {
type: "object",
properties: {
doseId: { type: "string" },
},
example: {
doseId: "1:2026-03-11T08:00:00.000Z:Daniel",
},
},
response: {
200: {
type: "object",
properties: {
success: { type: "boolean" },
message: { type: "string" },
},
},
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
409: genericErrorSchema,
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
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),
});
}
@@ -89,11 +326,22 @@ export async function doseRoutes(app: FastifyInstance) {
return { success: true, message: "Already marked" };
}
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
const outOfStock = await isDoseOutOfStock({
userId,
doseId,
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
});
if (outOfStock) {
return reply.status(409).send({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
}
// Insert new record
await db.insert(doseTracking).values({
userId,
doseId,
markedBy: null, // Marked by the user themselves
takenSource: "manual",
});
return { success: true };
@@ -105,7 +353,24 @@ export async function doseRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
app.delete<{ Params: { doseId: string } }>(
"/doses/taken/:doseId",
{ preHandler: requireAuth },
{
preHandler: requireAuth,
schema: {
tags: ["doses"],
security: protectedEndpointSecurity,
params: {
type: "object",
required: ["doseId"],
properties: {
doseId: { type: "string", minLength: 1 },
},
},
response: {
200: { type: "object", properties: { success: { type: "boolean" } } },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
@@ -134,14 +399,40 @@ export async function doseRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
app.post<{ Body: z.infer<typeof dismissDosesSchema> }>(
"/doses/dismiss",
{ preHandler: requireAuth },
{
preHandler: requireAuth,
schema: {
tags: ["doses"],
security: protectedEndpointSecurity,
body: {
type: "object",
properties: {
doseIds: { type: "array", items: { type: "string" } },
},
example: {
doseIds: ["1:2026-03-11T08:00:00.000Z:Daniel", "1:2026-03-11T20:00:00.000Z:Daniel"],
},
},
response: {
200: {
type: "object",
properties: {
success: { type: "boolean" },
dismissedCount: { type: "integer" },
},
},
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
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),
});
}
@@ -171,6 +462,7 @@ export async function doseRoutes(app: FastifyInstance) {
userId,
doseId,
markedBy: null,
takenAt: new Date(0),
dismissed: true,
});
dismissedCount++;
@@ -184,77 +476,149 @@ export async function doseRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
// DELETE /doses/dismiss - PROTECTED: Clear all dismissed doses (un-dismiss)
// ---------------------------------------------------------------------------
app.delete("/doses/dismiss", { preHandler: requireAuth }, async (request, reply) => {
const userId = await getUserId(request, reply);
app.delete(
"/doses/dismiss",
{
preHandler: requireAuth,
schema: {
tags: ["doses"],
security: protectedEndpointSecurity,
response: {
200: {
type: "object",
properties: {
success: { type: "boolean" },
clearedCount: { type: "integer" },
},
},
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
// Delete all dismissed-only records (not taken ones)
// For taken+dismissed, just remove the dismissed flag
const dismissed = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, true)));
// Delete all dismissed-only records (not taken ones)
// For taken+dismissed, just remove the dismissed flag
const dismissed = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.dismissed, true)));
for (const d of dismissed) {
if (d.markedBy !== null || d.takenAt) {
// This was also marked as taken - just remove dismissed flag
await db.update(doseTracking).set({ dismissed: false }).where(eq(doseTracking.id, d.id));
} else {
// This was only dismissed - delete it
await db.delete(doseTracking).where(eq(doseTracking.id, d.id));
for (const d of dismissed) {
const hasRealTakenTimestamp = d.takenAt instanceof Date ? d.takenAt.getTime() > 0 : Boolean(d.takenAt);
if (d.markedBy !== null || hasRealTakenTimestamp) {
// This was also marked as taken - just remove dismissed flag
await db.update(doseTracking).set({ dismissed: false }).where(eq(doseTracking.id, d.id));
} else {
// This was only dismissed - delete it
await db.delete(doseTracking).where(eq(doseTracking.id, d.id));
}
}
}
return { success: true, clearedCount: dismissed.length };
});
return { success: true, clearedCount: dismissed.length };
}
);
// ---------------------------------------------------------------------------
// GET /share/:token/doses - PUBLIC: Get taken doses for a share link
// Suppress request logs — polled every 5s by SharedSchedule
// ---------------------------------------------------------------------------
app.get<{ Params: { token: string } }>("/share/:token/doses", async (request, reply) => {
const { token } = request.params;
app.get<{ Params: { token: string } }>(
"/share/:token/doses",
{
schema: {
params: tokenParamsSchema,
response: {
200: doseReadResponseSchema,
404: genericErrorSchema,
},
},
logLevel: "warn",
config: {
rateLimit: {
max: 60,
timeWindow: "1 minute",
errorResponseBuilder: () => ({ error: "rate_limited" }),
},
},
},
async (request, reply) => {
const { token } = request.params;
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
return reply.notFound("Share link not found");
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected read: token=${token}, reason=${reason}`);
return reply.notFound("Share link not found");
}
// Get all taken doses for this user (no time limit)
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
return {
doses: doses.map((d) => ({
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
takenSource: d.takenSource ?? "manual",
dismissed: d.dismissed ?? false,
})),
};
}
// Get all taken doses for this user (no time limit)
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
return {
doses: doses.map((d) => ({
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
dismissed: d.dismissed ?? false,
})),
};
});
);
// ---------------------------------------------------------------------------
// POST /share/:token/doses - PUBLIC: Mark a dose as taken via share link
// ---------------------------------------------------------------------------
app.post<{ Params: { token: string }; Body: z.infer<typeof shareDoseSchema> }>(
"/share/:token/doses",
{
schema: {
params: tokenParamsSchema,
body: {
type: "object",
properties: {
doseId: { type: "string" },
},
example: {
doseId: "1:2026-03-11T08:00:00.000Z:Daniel",
},
},
response: {
200: { type: "object", properties: { success: { type: "boolean" }, message: { type: "string" } } },
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
409: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const { token } = request.params;
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),
});
}
const { doseId } = parsed.data;
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected mark: token=${token}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
request.log.warn(
`[ShareDose] Rejected invalid doseId in mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
}
// Check if already marked
const [existing] = await db
.select()
@@ -262,16 +626,40 @@ export async function doseRoutes(app: FastifyInstance) {
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
if (existing) {
request.log.debug(
`[ShareDose] Duplicate mark ignored: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return { success: true, message: "Already marked" };
}
// Insert new record - marked by the takenBy person
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
const outOfStock = await isDoseOutOfStock({
userId: share.userId,
doseId,
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
});
if (outOfStock) {
request.log.info(
`[ShareDose] Rejected out-of-stock mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(409).send({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
}
// Insert new record - marked by the shared person, or the concrete intake person for an "all" link.
const parsedShareDose = parseDoseId(doseId);
const markedBy = share.takenBy === "all" ? (parsedShareDose?.personSuffix ?? share.takenBy) : share.takenBy;
await db.insert(doseTracking).values({
userId: share.userId,
doseId,
markedBy: share.takenBy, // e.g. "Daniel"
markedBy,
takenSource: "manual",
});
request.log.info(
`[ShareDose] Dose marked via share link: token=${token}, ownerUserId=${share.userId}, shareTakenBy=${share.takenBy}, markedBy=${markedBy}, doseId=${doseId}`
);
return { success: true };
}
);
@@ -279,28 +667,64 @@ export async function doseRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
// DELETE /share/:token/doses/:doseId - PUBLIC: Unmark a dose via share link
// ---------------------------------------------------------------------------
app.delete<{ Params: { token: string; doseId: string } }>("/share/:token/doses/:doseId", async (request, reply) => {
const { token, doseId } = request.params;
app.delete<{ Params: { token: string; doseId: string } }>(
"/share/:token/doses/:doseId",
{
schema: {
params: {
type: "object",
required: ["token", "doseId"],
properties: {
token: tokenParamsSchema.properties.token,
doseId: { type: "string", minLength: 1 },
},
},
response: {
200: { type: "object", properties: { success: { type: "boolean" } } },
400: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (request, reply) => {
const { token, doseId } = request.params;
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
return reply.notFound("Share link not found");
const { share, reason } = await getActiveShareToken(token);
if (!share) {
request.log.warn(`[ShareDose] Rejected unmark: token=${token}, doseId=${doseId}, reason=${reason}`);
return reply.notFound("Share link not found");
}
const isValidShareDoseId = await validateShareDoseId(share, doseId);
if (!isValidShareDoseId) {
request.log.warn(
`[ShareDose] Rejected invalid doseId in unmark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
}
// Check if this dose was dismissed
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
if (existing?.dismissed) {
// Already dismissed - keep the record as-is
request.log.debug(
`[ShareDose] Unmark ignored for dismissed dose: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
} else {
// Not dismissed - delete the record entirely
await db
.delete(doseTracking)
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
request.log.info(
`[ShareDose] Dose unmarked via share link: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
);
}
return { success: true };
}
// Check if this dose was dismissed
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
if (existing?.dismissed) {
// Already dismissed - keep the record as-is
} else {
// Not dismissed - delete the record entirely
await db.delete(doseTracking).where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
}
return { success: true };
});
);
}
+467 -217
View File
@@ -2,22 +2,28 @@ import { randomBytes } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import { extname, resolve } from "node:path";
import { eq } from "drizzle-orm";
import type { FastifyInstance } from "fastify";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { getDataDir } from "../db/db-utils.js";
import { doseTracking, medications, shareTokens, userSettings } from "../db/schema.js";
import { getDataDir } from "../db/path-utils.js";
import { doseTracking, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
import { parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import { normalizePackageType, PACKAGE_TYPES } from "../utils/package-profiles.js";
import { normalizeIntake, parseIntakesJson, parseTakenByJson } from "../utils/scheduler-utils.js";
const IMAGES_DIR = resolve(getDataDir(), "images");
// =============================================================================
// Export Format Version (bump this when format changes)
// =============================================================================
const EXPORT_VERSION = "1.0";
const EXPORT_VERSION = "1.5";
// =============================================================================
// Zod Schemas for Import Validation
@@ -27,6 +33,9 @@ const scheduleSchema = z.object({
usage: z.number().nonnegative(),
every: z.number().int().min(1),
start: z.string(), // ISO datetime string
scheduleMode: z.unknown().optional(),
weekdays: z.unknown().optional(),
intakeUnit: z.enum(["ml", "tsp", "tbsp"]).nullable().optional(),
remind: z.boolean().optional().default(false),
takenBy: z.string().nullable().optional(), // Per-intake takenBy (new field)
});
@@ -35,9 +44,12 @@ const inventorySchema = z.object({
packCount: z.number().int().min(0).default(1),
blistersPerPack: z.number().int().min(1).default(1),
pillsPerBlister: z.number().int().min(1).default(1),
totalPills: z.number().int().nullable().optional(), // For bottle type: total capacity
looseTablets: z.number().int().min(0).default(0),
stockAdjustment: z.number().int().default(0), // Manual stock correction
packageType: z.enum(["blister", "bottle"]).default("blister"),
packageType: z.enum(PACKAGE_TYPES).default("blister"),
packageAmountValue: z.number().int().min(0).default(0),
packageAmountUnit: z.enum(["ml", "g"]).default("ml"),
});
const medicationExportSchema = z.object({
@@ -45,18 +57,27 @@ const medicationExportSchema = z.object({
name: z.string().min(1),
genericName: z.string().nullable().optional(),
takenBy: z.array(z.string()).default([]),
medicationForm: z.enum(["capsule", "tablet", "liquid", "topical"]).default("tablet"),
pillForm: z.enum(["capsule", "tablet"]).nullable().optional(),
lifecycleCategory: z.enum(["refill_when_empty", "treatment_period"]).default("refill_when_empty"),
inventory: inventorySchema,
pillWeightMg: z.number().int().nullable().optional(),
doseUnit: z.enum(["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"]).default("mg"),
schedules: z.array(scheduleSchema).default([]),
medicationStartDate: z.string().nullable().optional(),
medicationEndDate: z.string().nullable().optional(),
autoMarkObsoleteAfterEndDate: z.boolean().default(true),
expiryDate: z.string().nullable().optional(),
notes: z.string().nullable().optional(),
intakeRemindersEnabled: z.boolean().default(false),
isObsolete: z.boolean().default(false),
obsoleteAt: z.string().nullable().optional(),
prescriptionEnabled: z.boolean().default(false),
prescriptionAuthorizedRefills: z.number().int().min(0).nullable().optional(),
prescriptionRemainingRefills: z.number().int().min(0).nullable().optional(),
prescriptionLowRefillThreshold: z.number().int().min(0).default(1),
prescriptionExpiryDate: z.string().nullable().optional(),
dismissedUntil: z.string().nullable().optional(), // ISO date string for dismissed past doses
image: z.string().nullable().optional(), // base64 data URL or null
lastStockCorrectionAt: z.string().nullable().optional(), // ISO datetime of last stock correction
});
@@ -67,10 +88,20 @@ const doseHistorySchema = z.object({
scheduledTime: z.string(), // ISO datetime
takenAt: z.string(), // ISO datetime
markedBy: z.string().nullable().optional(),
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")
});
const refillHistoryExportSchema = z.object({
medicationRef: z.string(), // References _exportId
packsAdded: 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
});
const shareLinkSchema = z.object({
takenBy: z.string().min(1),
scheduleDays: z.number().int().min(1).default(30),
@@ -78,34 +109,44 @@ const shareLinkSchema = z.object({
regenerateToken: z.boolean().default(true),
});
const settingsExportSchema = z
.object({
// Email notifications
emailEnabled: z.boolean().default(false),
notificationEmail: z.string().nullable().optional(),
emailStockReminders: z.boolean().default(true),
emailIntakeReminders: z.boolean().default(true),
emailPrescriptionReminders: z.boolean().default(true),
// Push notifications
shoutrrrEnabled: z.boolean().optional(),
shoutrrrUrl: z.string().nullable().optional(),
shoutrrrStockReminders: z.boolean().default(true),
shoutrrrIntakeReminders: z.boolean().default(true),
shoutrrrPrescriptionReminders: z.boolean().default(true),
// Reminder settings
reminderDaysBefore: z.number().int().default(7),
repeatDailyReminders: z.boolean().default(false),
skipRemindersForTakenDoses: z.boolean().default(false),
repeatRemindersEnabled: z.boolean().default(false),
reminderRepeatIntervalMinutes: z.number().int().default(30),
maxNaggingReminders: z.number().int().default(5),
// Stock thresholds
lowStockDays: z.number().int().default(30),
normalStockDays: z.number().int().default(90),
highStockDays: z.number().int().default(180),
// UI preferences
language: z.string().default("en"),
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
const settingsSchemaBase = z.object({
// Email notifications
emailEnabled: z.boolean().default(false),
notificationEmail: z.string().nullable().optional(),
emailStockReminders: z.boolean().default(true),
emailIntakeReminders: z.boolean().default(true),
emailPrescriptionReminders: z.boolean().default(true),
// Push notifications
shoutrrrEnabled: z.boolean().optional(),
shoutrrrUrl: z.string().nullable().optional(),
shoutrrrStockReminders: z.boolean().default(true),
shoutrrrIntakeReminders: z.boolean().default(true),
shoutrrrPrescriptionReminders: z.boolean().default(true),
// Reminder settings
reminderDaysBefore: z.number().int().default(7),
repeatDailyReminders: z.boolean().default(false),
skipRemindersForTakenDoses: z.boolean().default(false),
repeatRemindersEnabled: z.boolean().default(false),
reminderRepeatIntervalMinutes: z.number().int().default(30),
maxNaggingReminders: z.number().int().default(5),
// Stock thresholds
lowStockDays: z.number().int().default(30),
normalStockDays: z.number().int().default(90),
highStockDays: z.number().int().default(180),
expiryWarningDays: z.number().int().default(90),
// UI preferences
language: z.string().default("en"),
stockCalculationMode: z.enum(["automatic", "manual"]).default("automatic"),
shareMedicationOverview: z.boolean().default(false),
});
const exportSettingsSchema = settingsSchemaBase.optional();
const importSettingsSchema = settingsSchemaBase
.extend({
// Accept the removed field from legacy exports so old backups still import,
// but do not map it back into current runtime settings.
shareStockStatus: z.boolean().optional(),
})
.optional();
@@ -115,16 +156,80 @@ const importDataSchema = z.object({
includeSensitiveData: z.boolean().default(false),
medications: z.array(medicationExportSchema).default([]),
doseHistory: z.array(doseHistorySchema).default([]),
settings: settingsExportSchema,
refillHistory: z.array(refillHistoryExportSchema).default([]),
settings: importSettingsSchema,
shareLinks: z.array(shareLinkSchema).default([]),
});
const exportQuerystringSchema = {
type: "object",
properties: {
includeSensitive: { type: "string", enum: ["true", "false"] },
includeImages: { type: "string", enum: ["true", "false"] },
},
} as const;
const exportResponseSchema = {
type: "object",
properties: {
version: { type: "string" },
exportedAt: { type: "string", format: "date-time" },
includeSensitiveData: { type: "boolean" },
medications: { type: "array", items: { type: "object", additionalProperties: true } },
doseHistory: { type: "array", items: { type: "object", additionalProperties: true } },
refillHistory: { type: "array", items: { type: "object", additionalProperties: true } },
settings: { type: "object", additionalProperties: true },
shareLinks: { type: "array", items: { type: "object", additionalProperties: true } },
},
} as const;
const importBodyOpenApiSchema = {
type: "object",
required: ["version", "exportedAt"],
properties: {
version: { type: "string" },
exportedAt: { type: "string", format: "date-time" },
includeSensitiveData: { type: "boolean" },
medications: { type: "array", items: { type: "object", additionalProperties: true } },
doseHistory: { type: "array", items: { type: "object", additionalProperties: true } },
refillHistory: { type: "array", items: { type: "object", additionalProperties: true } },
settings: { type: "object", additionalProperties: true },
shareLinks: { type: "array", items: { type: "object", additionalProperties: true } },
},
example: {
version: "1.8.0",
exportedAt: "2026-03-11T10:15:00.000Z",
includeSensitiveData: true,
medications: [
{
name: "Ibuprofen 400",
packageType: "box",
packCount: 1,
looseTablets: 8,
intakes: [
{
usage: 1,
every: 8,
start: "2026-03-11T08:00:00.000Z",
takenBy: "Daniel",
remind: true,
},
],
},
],
doseHistory: [{ doseId: "1:2026-03-11T08:00:00.000Z:Daniel", takenAt: 1773216000000 }],
refillHistory: [{ packsAdded: 1, loosePillsAdded: 4, quantityAdded: 34, refillDate: "2026-03-10T12:00:00.000Z" }],
settings: { language: "en", stockCalculationMode: "automatic" },
shareLinks: [{ takenBy: "Daniel", scheduleDays: 14 }],
},
} as const;
// =============================================================================
// Helper Functions
// =============================================================================
// Helper to get user ID from request
async function getUserId(request: any, reply: any): Promise<number> {
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
if (!env.AUTH_ENABLED) {
return getAnonymousUserId();
}
@@ -138,9 +243,16 @@ async function getUserId(request: any, reply: any): Promise<number> {
}
// Parse intakes from DB format to export format (with per-intake takenBy)
function parseIntakesForExport(
row: typeof medications.$inferSelect
): Array<{ usage: number; every: number; start: string; remind: boolean; takenBy: string | null }> {
function parseIntakesForExport(row: typeof medications.$inferSelect): Array<{
usage: number;
every: number;
start: string;
scheduleMode: "interval" | "weekdays";
weekdays: Array<"mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun">;
intakeUnit: "ml" | "tsp" | "tbsp" | null;
remind: boolean;
takenBy: string | null;
}> {
// Use the new parseIntakesJson which falls back to legacy format
const intakes = parseIntakesJson(
row.intakesJson,
@@ -152,6 +264,9 @@ function parseIntakesForExport(
usage: intake.usage,
every: intake.every,
start: intake.start,
scheduleMode: intake.scheduleMode ?? "interval",
weekdays: intake.weekdays ?? [],
intakeUnit: intake.intakeUnit ?? null,
remind: intake.intakeRemindersEnabled,
takenBy: intake.takenBy, // Per-intake takenBy
}));
@@ -240,190 +355,265 @@ function buildDoseId(medicationId: number, blisterIndex: number, timestampMs: nu
export async function exportRoutes(app: FastifyInstance) {
// All export routes require auth
app.addHook("preHandler", requireAuth);
applyOpenApiRouteStandards(app, { tag: "export", protectedByDefault: true });
// ---------------------------------------------------------------------------
// GET /export - Export all user data
// ---------------------------------------------------------------------------
app.get<{ Querystring: { includeSensitive?: string; includeImages?: string } }>("/export", async (request, reply) => {
const userId = await getUserId(request, reply);
const includeSensitive = request.query.includeSensitive === "true";
const includeImages = request.query.includeImages !== "false"; // Default to true
// 1. Load all medications
const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
// Build medication ID to export ID mapping
const medIdToExportId = new Map<number, string>();
const exportMedications = meds.map((med, index) => {
const exportId = `med-${index + 1}`;
medIdToExportId.set(med.id, exportId);
// Safely convert lastStockCorrectionAt to ISO string
let lastStockCorrectionAtIso: string | null = null;
if (med.lastStockCorrectionAt) {
try {
if (med.lastStockCorrectionAt instanceof Date && !Number.isNaN(med.lastStockCorrectionAt.getTime())) {
lastStockCorrectionAtIso = med.lastStockCorrectionAt.toISOString();
} else if (typeof med.lastStockCorrectionAt === "number" || typeof med.lastStockCorrectionAt === "string") {
const d = new Date(med.lastStockCorrectionAt);
lastStockCorrectionAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : null;
}
} catch {
lastStockCorrectionAtIso = null;
}
}
return {
_exportId: exportId,
name: med.name,
genericName: med.genericName,
takenBy: parseTakenByJson(med.takenByJson),
inventory: {
packCount: med.packCount ?? 1,
blistersPerPack: med.blistersPerPack ?? 1,
pillsPerBlister: med.pillsPerBlister ?? 1,
looseTablets: med.looseTablets ?? 0,
stockAdjustment: med.stockAdjustment ?? 0,
packageType: med.packageType ?? "blister",
app.get<{ Querystring: { includeSensitive?: string; includeImages?: string } }>(
"/export",
{
schema: {
querystring: exportQuerystringSchema,
response: {
200: exportResponseSchema,
401: genericErrorSchema,
},
pillWeightMg: med.pillWeightMg,
doseUnit: med.doseUnit ?? "mg",
schedules: parseIntakesForExport(med),
expiryDate: med.expiryDate,
notes: med.notes,
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
prescriptionEnabled: med.prescriptionEnabled ?? false,
prescriptionAuthorizedRefills: med.prescriptionAuthorizedRefills ?? null,
prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null,
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
prescriptionExpiryDate: med.prescriptionExpiryDate ?? null,
image: includeImages ? imageToBase64(med.imageUrl) : null,
lastStockCorrectionAt: lastStockCorrectionAtIso,
};
});
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
const includeSensitive = request.query.includeSensitive === "true";
const includeImages = request.query.includeImages !== "false"; // Default to true
// 2. Load all dose tracking entries
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
// 1. Load all medications
const meds = await db.select().from(medications).where(eq(medications.userId, userId)).orderBy(medications.id);
const medicationById = new Map(meds.map((med) => [med.id, med]));
const exportDoseHistory = doses
.map((dose) => {
const parsed = parseDoseId(dose.doseId);
if (!parsed) return null;
// Build medication ID to export ID mapping
const medIdToExportId = new Map<number, string>();
const exportMedications = meds.map((med, index) => {
const exportId = `med-${index + 1}`;
medIdToExportId.set(med.id, exportId);
const exportId = medIdToExportId.get(parsed.medicationId);
if (!exportId) return null; // Orphaned dose, skip
// Safely convert takenAt to ISO string
let takenAtIso: string;
try {
if (dose.takenAt instanceof Date && !Number.isNaN(dose.takenAt.getTime())) {
takenAtIso = dose.takenAt.toISOString();
} else if (typeof dose.takenAt === "number" || typeof dose.takenAt === "string") {
const d = new Date(dose.takenAt);
takenAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
} else {
takenAtIso = new Date().toISOString();
// Safely convert lastStockCorrectionAt to ISO string
let lastStockCorrectionAtIso: string | null = null;
if (med.lastStockCorrectionAt) {
try {
if (med.lastStockCorrectionAt instanceof Date && !Number.isNaN(med.lastStockCorrectionAt.getTime())) {
lastStockCorrectionAtIso = med.lastStockCorrectionAt.toISOString();
} else if (typeof med.lastStockCorrectionAt === "number" || typeof med.lastStockCorrectionAt === "string") {
const d = new Date(med.lastStockCorrectionAt);
lastStockCorrectionAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : null;
}
} catch {
lastStockCorrectionAtIso = null;
}
} catch {
takenAtIso = new Date().toISOString();
}
// Safely convert scheduled time
let scheduledTimeIso: string;
try {
const d = new Date(parsed.timestampMs);
scheduledTimeIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
} catch {
scheduledTimeIso = new Date().toISOString();
}
return {
medicationRef: exportId,
scheduleIndex: parsed.blisterIndex,
scheduledTime: scheduledTimeIso,
takenAt: takenAtIso,
markedBy: dose.markedBy,
dismissed: dose.dismissed ?? false,
takenByPerson: parsed.person,
_exportId: exportId,
name: med.name,
genericName: med.genericName,
takenBy: parseTakenByJson(med.takenByJson),
medicationForm: med.medicationForm ?? "tablet",
pillForm: med.pillForm ?? null,
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
inventory: {
packCount: med.packCount ?? 1,
blistersPerPack: med.blistersPerPack ?? 1,
pillsPerBlister: med.pillsPerBlister ?? 1,
totalPills: med.totalPills ?? null,
looseTablets: med.looseTablets ?? 0,
stockAdjustment: med.stockAdjustment ?? 0,
packageType: normalizePackageType(med.packageType),
packageAmountValue: med.packageAmountValue ?? 0,
packageAmountUnit: (med.packageAmountUnit ?? "ml") as "ml" | "g",
},
pillWeightMg: med.pillWeightMg,
doseUnit: med.doseUnit ?? "mg",
schedules: parseIntakesForExport(med),
medicationStartDate: med.medicationStartDate || null,
medicationEndDate: med.medicationEndDate || null,
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
expiryDate: med.expiryDate,
notes: med.notes,
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
isObsolete: med.isObsolete ?? false,
obsoleteAt: med.obsoleteAt?.toISOString() ?? null,
prescriptionEnabled: med.prescriptionEnabled ?? false,
prescriptionAuthorizedRefills: med.prescriptionAuthorizedRefills ?? null,
prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null,
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
prescriptionExpiryDate: med.prescriptionExpiryDate ?? null,
dismissedUntil: med.dismissedUntil ?? null,
image: includeImages ? imageToBase64(med.imageUrl) : null,
lastStockCorrectionAt: lastStockCorrectionAtIso,
};
})
.filter((d): d is NonNullable<typeof d> => d !== null);
});
// 3. Load user settings
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
// 2. Load all dose tracking entries
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
const exportSettings = settings
? {
emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail,
emailStockReminders: settings.emailStockReminders,
emailIntakeReminders: settings.emailIntakeReminders,
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
// Only include sensitive data if requested
shoutrrrEnabled: includeSensitive ? settings.shoutrrrEnabled : undefined,
shoutrrrUrl: includeSensitive ? settings.shoutrrrUrl : undefined,
shoutrrrStockReminders: settings.shoutrrrStockReminders,
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders,
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
repeatRemindersEnabled: settings.repeatRemindersEnabled,
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes,
maxNaggingReminders: settings.maxNaggingReminders,
lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays,
language: settings.language,
stockCalculationMode: settings.stockCalculationMode,
}
: undefined;
const exportDoseHistory = doses
.map((dose) => {
const parsed = parseDoseId(dose.doseId);
if (!parsed) return null;
// 4. Load share links
const shares = await db.select().from(shareTokens).where(eq(shareTokens.userId, userId));
const exportId = medIdToExportId.get(parsed.medicationId);
if (!exportId) return null; // Orphaned dose, skip
const exportShareLinks = shares.map((share) => {
// Safely convert expiresAt to ISO string
let expiresAtIso: string | null = null;
if (share.expiresAt) {
try {
if (share.expiresAt instanceof Date && !Number.isNaN(share.expiresAt.getTime())) {
expiresAtIso = share.expiresAt.toISOString();
} else if (typeof share.expiresAt === "number" || typeof share.expiresAt === "string") {
const d = new Date(share.expiresAt);
expiresAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : null;
// Safely convert takenAt to ISO string
let takenAtIso: string;
try {
if (dose.takenAt instanceof Date && !Number.isNaN(dose.takenAt.getTime())) {
takenAtIso = dose.takenAt.toISOString();
} else if (typeof dose.takenAt === "number" || typeof dose.takenAt === "string") {
const d = new Date(dose.takenAt);
takenAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
} else {
takenAtIso = new Date().toISOString();
}
} catch {
takenAtIso = new Date().toISOString();
}
// Safely convert scheduled time
let scheduledTimeIso: string;
try {
const d = new Date(parsed.timestampMs);
scheduledTimeIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
} catch {
scheduledTimeIso = new Date().toISOString();
}
return {
medicationRef: exportId,
scheduleIndex: parsed.blisterIndex,
scheduledTime: scheduledTimeIso,
takenAt: takenAtIso,
markedBy: dose.markedBy,
takenSource: dose.takenSource === "automatic" ? "automatic" : "manual",
dismissed: dose.dismissed ?? false,
takenByPerson: parsed.person,
};
})
.filter((d): d is NonNullable<typeof d> => d !== null);
// 3. Load user settings
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
const exportSettings = settings
? {
emailEnabled: settings.emailEnabled,
notificationEmail: settings.notificationEmail,
emailStockReminders: settings.emailStockReminders,
emailIntakeReminders: settings.emailIntakeReminders,
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
// Only include sensitive data if requested
shoutrrrEnabled: includeSensitive ? settings.shoutrrrEnabled : undefined,
shoutrrrUrl: includeSensitive ? settings.shoutrrrUrl : undefined,
shoutrrrStockReminders: settings.shoutrrrStockReminders,
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders,
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses,
repeatRemindersEnabled: settings.repeatRemindersEnabled,
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes,
maxNaggingReminders: settings.maxNaggingReminders,
lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays,
expiryWarningDays: settings.expiryWarningDays,
language: settings.language,
stockCalculationMode: settings.stockCalculationMode,
shareMedicationOverview: settings.shareMedicationOverview ?? false,
}
: undefined;
// 4. Load share links
const shares = await db.select().from(shareTokens).where(eq(shareTokens.userId, userId));
const exportShareLinks = shares.map((share) => {
// Safely convert expiresAt to ISO string
let expiresAtIso: string | null = null;
if (share.expiresAt) {
try {
if (share.expiresAt instanceof Date && !Number.isNaN(share.expiresAt.getTime())) {
expiresAtIso = share.expiresAt.toISOString();
} else if (typeof share.expiresAt === "number" || typeof share.expiresAt === "string") {
const d = new Date(share.expiresAt);
expiresAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : null;
}
} catch {
expiresAtIso = null;
}
} catch {
expiresAtIso = null;
}
}
return {
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
expiresAt: expiresAtIso,
regenerateToken: true, // Always regenerate tokens on import for security
return {
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
expiresAt: expiresAtIso,
regenerateToken: true, // Always regenerate tokens on import for security
};
});
// 5. Load refill history
const refills = await db.select().from(refillHistory).where(eq(refillHistory.userId, userId));
const exportRefillHistory = refills
.map((refill) => {
const exportId = medIdToExportId.get(refill.medicationId);
if (!exportId) return null; // Orphaned refill, skip
const medication = medicationById.get(refill.medicationId);
const packageType = normalizePackageType(medication?.packageType);
const pillsPerPack = Math.max(1, (medication?.blistersPerPack ?? 1) * (medication?.pillsPerBlister ?? 1));
const quantityAdded =
packageType === "bottle" || packageType === "tube" || packageType === "liquid_container"
? (refill.loosePillsAdded ?? 0)
: (refill.packsAdded ?? 0) * pillsPerPack + (refill.loosePillsAdded ?? 0);
// Safely convert refillDate to ISO string
let refillDateIso: string;
try {
if (refill.refillDate instanceof Date && !Number.isNaN(refill.refillDate.getTime())) {
refillDateIso = refill.refillDate.toISOString();
} else if (typeof refill.refillDate === "number" || typeof refill.refillDate === "string") {
const d = new Date(refill.refillDate);
refillDateIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
} else {
refillDateIso = new Date().toISOString();
}
} catch {
refillDateIso = new Date().toISOString();
}
return {
medicationRef: exportId,
packsAdded: refill.packsAdded ?? 0,
loosePillsAdded: refill.loosePillsAdded ?? 0,
quantityAdded,
usedPrescription: refill.usedPrescription ?? false,
refillDate: refillDateIso,
};
})
.filter((r): r is NonNullable<typeof r> => r !== null);
// Build export object
const exportData = {
version: EXPORT_VERSION,
exportedAt: new Date().toISOString(),
includeSensitiveData: includeSensitive,
medications: exportMedications,
doseHistory: exportDoseHistory,
refillHistory: exportRefillHistory,
settings: exportSettings,
shareLinks: exportShareLinks,
};
});
// Build export object
const exportData = {
version: EXPORT_VERSION,
exportedAt: new Date().toISOString(),
includeSensitiveData: includeSensitive,
medications: exportMedications,
doseHistory: exportDoseHistory,
settings: exportSettings,
shareLinks: exportShareLinks,
};
// Set download headers
const now = new Date();
const dateStr = now.toISOString().replace(/[-:]/g, "").replace(/T/, "-").slice(0, 13);
const authUser = env.AUTH_ENABLED ? (request.user as unknown as AuthUser | null) : null;
const userPart = authUser?.username ? `-${authUser.username}` : "";
const filename = `medassist-export${userPart}-${dateStr}.json`;
reply.header("Content-Type", "application/json");
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
// Set download headers
const filename = `medassist-export-${new Date().toISOString().split("T")[0]}.json`;
reply.header("Content-Type", "application/json");
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
return exportData;
});
return exportData;
}
);
// ---------------------------------------------------------------------------
// POST /import - Import user data (replaces all existing data!)
@@ -436,6 +626,29 @@ export async function exportRoutes(app: FastifyInstance) {
rawBody: true,
},
bodyLimit: 50 * 1024 * 1024, // 50 MB
schema: {
body: importBodyOpenApiSchema,
response: {
200: {
type: "object",
properties: {
success: { type: "boolean" },
imported: {
type: "object",
properties: {
medications: { type: "integer" },
doseHistory: { type: "integer" },
refillHistory: { type: "integer" },
settings: { type: "integer" },
shareLinks: { type: "integer" },
},
},
},
},
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
@@ -469,7 +682,8 @@ export async function exportRoutes(app: FastifyInstance) {
}
}
// Delete in order: doses, share tokens, medications, settings
// 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));
@@ -479,25 +693,28 @@ export async function exportRoutes(app: FastifyInstance) {
const exportIdToNewId = new Map<string, number>();
for (const med of importData.medications) {
// Convert schedules to both legacy and new formats
const usageJson = JSON.stringify(med.schedules.map((s) => s.usage));
const everyJson = JSON.stringify(med.schedules.map((s) => s.every));
const startJson = JSON.stringify(med.schedules.map((s) => s.start));
const normalizedSchedules = med.schedules.map((schedule) =>
normalizeIntake({
usage: schedule.usage,
every: schedule.every,
start: schedule.start,
scheduleMode: schedule.scheduleMode,
weekdays: schedule.weekdays,
intakeUnit: schedule.intakeUnit ?? null,
takenBy: schedule.takenBy || null,
intakeRemindersEnabled: schedule.remind ?? false,
})
);
const usageJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.usage));
const everyJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.every));
const startJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.start));
const takenByJson = JSON.stringify(med.takenBy);
// Build intakesJson array (new unified format with per-intake takenBy)
const intakesJson = JSON.stringify(
med.schedules.map((s) => ({
usage: s.usage,
every: s.every,
start: s.start,
takenBy: s.takenBy || null,
intakeRemindersEnabled: s.remind ?? false,
}))
);
const intakesJson = JSON.stringify(normalizedSchedules);
// Check if any schedule has remind enabled
const intakeRemindersEnabled = med.schedules.some((s) => s.remind) || med.intakeRemindersEnabled;
const intakeRemindersEnabled =
normalizedSchedules.some((schedule) => schedule.intakeRemindersEnabled) || med.intakeRemindersEnabled;
const [inserted] = await db
.insert(medications)
@@ -506,15 +723,24 @@ export async function exportRoutes(app: FastifyInstance) {
name: med.name,
genericName: med.genericName || null,
takenByJson,
packageType: med.inventory.packageType ?? "blister",
medicationForm: med.medicationForm ?? "tablet",
pillForm: med.pillForm || null,
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
packageType: normalizePackageType(med.inventory.packageType),
packageAmountValue: med.inventory.packageAmountValue ?? 0,
packageAmountUnit: med.inventory.packageAmountUnit ?? "ml",
packCount: med.inventory.packCount,
blistersPerPack: med.inventory.blistersPerPack,
pillsPerBlister: med.inventory.pillsPerBlister,
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,
@@ -522,11 +748,14 @@ export async function exportRoutes(app: FastifyInstance) {
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();
@@ -558,12 +787,15 @@ export async function exportRoutes(app: FastifyInstance) {
doseId,
takenAt: new Date(dose.takenAt),
markedBy: dose.markedBy || null,
takenSource: dose.takenSource ?? "manual",
dismissed: dose.dismissed ?? false,
});
}
// 5. Import settings
if (importData.settings) {
// Legacy exports may still contain shareStockStatus. The current app no longer
// uses that setting, so imports accept it for compatibility and then ignore it.
await db.insert(userSettings).values({
userId,
emailEnabled: importData.settings.emailEnabled ?? false,
@@ -585,8 +817,10 @@ export async function exportRoutes(app: FastifyInstance) {
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,
});
}
@@ -604,11 +838,27 @@ export async function exportRoutes(app: FastifyInstance) {
});
}
// 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 ?? refill.quantityAdded ?? 0,
usedPrescription: refill.usedPrescription ?? false,
refillDate: new Date(refill.refillDate),
});
}
return {
success: true,
imported: {
medications: importData.medications.length,
doseHistory: importData.doseHistory.length,
refillHistory: importData.refillHistory.length,
settings: importData.settings ? 1 : 0,
shareLinks: importData.shareLinks.length,
},
+28 -7
View File
@@ -2,6 +2,7 @@ import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import type { FastifyInstance } from "fastify";
import { applyOpenApiRouteStandards } from "../utils/openapi-route-standards.js";
// Read version from package.json at startup
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -10,11 +11,31 @@ const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
const backendVersion = packageJson.version || "unknown";
export async function healthRoutes(app: FastifyInstance) {
// Exempt from rate limit - lightweight health check
app.get("/health", { config: { rateLimit: false } }, async () => ({
status: "ok",
version: backendVersion,
smtpConfigured: Boolean(process.env.SMTP_HOST),
shoutrrrConfigured: Boolean(process.env.SHOUTRRR_URL),
}));
applyOpenApiRouteStandards(app, { tag: "health", protectedByDefault: false });
// Exempt from rate limit + suppress request logs (called every 30s by Docker healthcheck)
app.get(
"/health",
{
config: { rateLimit: false },
logLevel: "warn",
schema: {
response: {
200: {
type: "object",
properties: {
status: { type: "string", enum: ["ok"] },
version: { type: "string" },
smtpConfigured: { type: "boolean" },
},
},
},
},
},
async () => ({
status: "ok",
version: backendVersion,
smtpConfigured: Boolean(process.env.SMTP_HOST),
})
);
}
+243
View File
@@ -0,0 +1,243 @@
import type { FastifyInstance, FastifyReply } from "fastify";
import { z } from "zod";
import { requireAuth } from "../plugins/auth.js";
import {
enrichMedicationSelection,
MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT,
MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT,
type MedicationEnrichmentEnrichRequest,
MedicationEnrichmentServiceError,
searchMedicationEnrichment,
} from "../services/medication-enrichment/index.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
const searchQuerySchema = z.object({
q: z.string().trim().min(1).max(120),
limit: z.coerce
.number()
.int()
.min(1)
.max(MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT)
.default(MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT),
});
const enrichBodySchema = z.object({
query: z.string().trim().min(1).max(120),
name: z.string().trim().min(1).max(140),
genericName: z.string().trim().max(140).nullable().optional(),
code: z.string().trim().min(1).max(160).nullable().optional(),
source: z.enum(["ema", "rxnorm", "openfda"]).nullable().optional(),
});
const searchQueryOpenApiSchema = {
type: "object",
required: ["q"],
properties: {
q: { type: "string", minLength: 1, maxLength: 120 },
limit: {
anyOf: [
{ type: "string", pattern: "^[0-9]+$" },
{
type: "integer",
minimum: 1,
maximum: MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT,
default: MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT,
},
],
},
},
} as const;
const enrichBodyOpenApiSchema = {
type: "object",
required: ["query", "name"],
properties: {
query: { type: "string", minLength: 1, maxLength: 120 },
name: { type: "string", minLength: 1, maxLength: 140 },
genericName: { type: "string", nullable: true, maxLength: 140 },
code: { type: "string", nullable: true, maxLength: 160 },
source: { type: "string", nullable: true, enum: ["ema", "rxnorm", "openfda"] },
},
} as const;
const strengthOptionSchema = {
type: "object",
properties: {
label: { type: "string" },
pillWeightMg: { type: "number", nullable: true },
doseUnit: {
anyOf: [{ type: "string", enum: ["mg", "g", "mcg", "ml", "IU", "units", "drops", "puffs"] }, { type: "null" }],
},
},
} as const;
const packageOptionSchema = {
type: "object",
properties: {
label: { type: "string" },
description: { type: "string" },
packageType: { type: "string", enum: ["blister", "bottle", "tube", "liquid_container"] },
packCount: { type: "integer", minimum: 1 },
blistersPerPack: { type: "integer", minimum: 1, nullable: true },
pillsPerBlister: { type: "integer", minimum: 1, nullable: true },
totalPills: { type: "integer", minimum: 0, nullable: true },
looseTablets: { type: "integer", minimum: 0, nullable: true },
packageAmountValue: { type: "integer", minimum: 1, nullable: true },
packageAmountUnit: {
anyOf: [{ type: "string", enum: ["ml", "g"] }, { type: "null" }],
},
},
} as const;
const searchResponseSchema = {
type: "object",
properties: {
query: { type: "string" },
normalizedQuery: { type: "string" },
hasMore: { type: "boolean" },
results: {
type: "array",
items: {
type: "object",
properties: {
code: { type: "string" },
name: { type: "string" },
genericName: { type: "string", nullable: true },
authorisationHolder: { type: "string", nullable: true },
therapeuticArea: { type: "string", nullable: true },
matchType: { type: "string", enum: ["brand", "ingredient"] },
genericStatus: { type: "string", enum: ["generic", "original", "unknown"] },
authorisationDate: { type: "string", nullable: true },
source: { type: "string", enum: ["ema", "rxnorm", "openfda"] },
packageOptions: { type: "array", items: packageOptionSchema },
},
},
},
},
} as const;
const enrichResponseSchema = {
type: "object",
properties: {
selection: {
type: "object",
properties: {
name: { type: "string" },
genericName: { type: "string", nullable: true },
therapeuticArea: { type: "string", nullable: true },
indication: { type: "string", nullable: true },
atcCode: { type: "string", nullable: true },
source: {
type: "string",
enum: ["ema", "rxnorm", "openfda", "ema+rxnorm", "ema+openfda", "rxnorm+openfda", "ema+rxnorm+openfda"],
},
},
},
suggestions: {
type: "object",
properties: {
name: { type: "string" },
genericName: { type: "string", nullable: true },
medicationForm: {
anyOf: [{ type: "string", enum: ["capsule", "tablet", "liquid", "topical"] }, { type: "null" }],
},
strengthOptions: { type: "array", items: strengthOptionSchema },
packageOptions: { type: "array", items: packageOptionSchema },
},
},
meta: {
type: "object",
properties: {
rxNormMatched: { type: "boolean" },
openFdaMatched: { type: "boolean" },
partial: { type: "boolean" },
note: { type: "string", nullable: true },
},
},
},
} as const;
function sendServiceError(error: unknown, reply: FastifyReply) {
if (error instanceof MedicationEnrichmentServiceError) {
return reply.status(error.statusCode).send({ error: error.message, code: error.code });
}
return reply.status(503).send({
error: "Medication enrichment request failed.",
code: "MEDICATION_ENRICHMENT_REQUEST_FAILED",
});
}
export async function medicationEnrichmentRoutes(app: FastifyInstance) {
app.addHook("preHandler", requireAuth);
applyOpenApiRouteStandards(app, { tag: "medication-enrichment", protectedByDefault: true });
app.get(
"/medication-enrichment/search",
{
schema: {
querystring: searchQueryOpenApiSchema,
response: {
200: searchResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
503: genericErrorSchema,
},
},
},
async (request, reply) => {
const parsed = searchQuerySchema.safeParse(request.query);
if (!parsed.success) return reply.status(400).send(parsed.error.format());
try {
return await searchMedicationEnrichment(parsed.data.q, parsed.data.limit);
} catch (error) {
request.log.warn(
{
code:
error instanceof MedicationEnrichmentServiceError ? error.code : "MEDICATION_ENRICHMENT_REQUEST_FAILED",
},
"[MedicationEnrichment] Search request failed"
);
return sendServiceError(error, reply);
}
}
);
app.post<{ Body: MedicationEnrichmentEnrichRequest }>(
"/medication-enrichment/enrich",
{
schema: {
body: enrichBodyOpenApiSchema,
response: {
200: enrichResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
404: genericErrorSchema,
503: genericErrorSchema,
},
},
},
async (request, reply) => {
const parsed = enrichBodySchema.safeParse(request.body);
if (!parsed.success) return reply.status(400).send(parsed.error.format());
try {
return await enrichMedicationSelection(parsed.data, request.log);
} catch (error) {
request.log.warn(
{
code:
error instanceof MedicationEnrichmentServiceError ? error.code : "MEDICATION_ENRICHMENT_REQUEST_FAILED",
},
"[MedicationEnrichment] Enrich request failed"
);
return sendServiceError(error, reply);
}
}
);
}
File diff suppressed because it is too large Load Diff
+89 -53
View File
@@ -5,6 +5,7 @@ import * as client from "openid-client";
import { db } from "../db/client.js";
import { refreshTokens, users } from "../db/schema.js";
import { env } from "../plugins/env.js";
import { applyOpenApiRouteStandards, genericErrorSchema } from "../utils/openapi-route-standards.js";
// =============================================================================
// OIDC Configuration Cache
@@ -49,12 +50,14 @@ function getFrontendUrl(): string {
// OIDC Routes
// =============================================================================
export async function oidcRoutes(app: FastifyInstance) {
applyOpenApiRouteStandards(app, { tag: "auth", protectedByDefault: false });
if (!env.OIDC_ENABLED) {
// Register a disabled route that returns an error
app.get("/auth/oidc/login", async (_request, reply) => {
app.get("/auth/oidc/login", { schema: { response: { 400: genericErrorSchema } } }, async (_request, reply) => {
return reply.status(400).send({ error: "OIDC authentication is not enabled" });
});
app.get("/auth/oidc/callback", async (_request, reply) => {
app.get("/auth/oidc/callback", { schema: { response: { 400: genericErrorSchema } } }, async (_request, reply) => {
return reply.status(400).send({ error: "OIDC authentication is not enabled" });
});
return;
@@ -63,64 +66,91 @@ export async function oidcRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
// GET /auth/oidc/login - Initiates OIDC flow
// ---------------------------------------------------------------------------
app.get("/auth/oidc/login", async (_request, reply) => {
try {
const config = await getOIDCConfig();
app.get(
"/auth/oidc/login",
{
schema: {
response: {
302: { type: "null", description: "Redirect to OIDC provider" },
500: genericErrorSchema,
},
},
},
async (request, reply) => {
try {
const config = await getOIDCConfig();
// Generate PKCE values
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const state = generateState();
// Generate PKCE values
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const state = generateState();
// Store PKCE verifier and state in signed cookies (short-lived)
reply.setCookie("oidc_code_verifier", codeVerifier, {
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 600, // 10 minutes
signed: true,
});
// Store PKCE verifier and state in signed cookies (short-lived)
reply.setCookie("oidc_code_verifier", codeVerifier, {
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 600, // 10 minutes
signed: true,
});
reply.setCookie("oidc_state", state, {
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 600,
signed: true,
});
reply.setCookie("oidc_state", state, {
httpOnly: true,
secure: env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
maxAge: 600,
signed: true,
});
// Build authorization URL
const redirectUri = env.OIDC_REDIRECT_URI!;
const scope = env.OIDC_SCOPES;
// Build authorization URL
const redirectUri = env.OIDC_REDIRECT_URI!;
const scope = env.OIDC_SCOPES;
const authUrl = client.buildAuthorizationUrl(config, {
redirect_uri: redirectUri,
scope,
state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
const authUrl = client.buildAuthorizationUrl(config, {
redirect_uri: redirectUri,
scope,
state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
return reply.redirect(authUrl.href);
} catch (err: any) {
console.error("[OIDC] Login error:", err);
return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`);
return reply.redirect(authUrl.href);
} catch (err: unknown) {
request.log.error({ err }, "[OIDC] Login initialization failed");
return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`);
}
}
});
);
// ---------------------------------------------------------------------------
// GET /auth/oidc/callback - Handles callback from OIDC provider
// ---------------------------------------------------------------------------
app.get<{ Querystring: { code?: string; state?: string; error?: string; error_description?: string } }>(
"/auth/oidc/callback",
{
schema: {
querystring: {
type: "object",
properties: {
code: { type: "string" },
state: { type: "string" },
error: { type: "string" },
error_description: { type: "string" },
},
},
response: {
302: { type: "null", description: "Redirect back to frontend" },
},
},
},
async (request, reply) => {
const { code, state, error, error_description } = request.query;
// Handle OIDC provider errors
if (error) {
console.error(`[OIDC] Provider error: ${error} - ${error_description}`);
app.log.warn({ error, errorDescription: error_description }, "[OIDC] Provider returned error");
return reply.redirect(`${getFrontendUrl()}/?error=oidc_${error}`);
}
@@ -131,14 +161,14 @@ export async function oidcRoutes(app: FastifyInstance) {
// Verify state
const storedState = request.unsignCookie(request.cookies.oidc_state || "");
if (!storedState.valid || storedState.value !== state) {
console.error("[OIDC] State mismatch");
request.log.warn("[OIDC] State mismatch during callback validation");
return reply.redirect(`${getFrontendUrl()}/?error=oidc_state_mismatch`);
}
// Get code verifier
const storedVerifier = request.unsignCookie(request.cookies.oidc_code_verifier || "");
if (!storedVerifier.valid || !storedVerifier.value) {
console.error("[OIDC] Missing code verifier");
request.log.warn("[OIDC] Missing/invalid code verifier cookie");
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_verifier`);
}
@@ -159,7 +189,7 @@ export async function oidcRoutes(app: FastifyInstance) {
// Get user info
const sub = tokens.claims()?.sub;
if (!sub) {
console.error("[OIDC] Missing sub claim in token");
request.log.error("[OIDC] Missing sub claim in token response");
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_sub`);
}
const userInfo = await client.fetchUserInfo(config, tokens.access_token, sub);
@@ -167,11 +197,17 @@ export async function oidcRoutes(app: FastifyInstance) {
// Extract username from configured claim
const usernameClaim = env.OIDC_USERNAME_CLAIM;
const username =
(userInfo as any)[usernameClaim] || userInfo.preferred_username || userInfo.email || userInfo.sub;
(userInfo as Record<string, string>)[usernameClaim] ||
userInfo.preferred_username ||
userInfo.email ||
userInfo.sub;
const oidcSubject = userInfo.sub;
if (!username || !oidcSubject) {
console.error("[OIDC] Missing required user info:", { username, oidcSubject });
request.log.error(
{ hasUsername: Boolean(username), hasOidcSubject: Boolean(oidcSubject) },
"[OIDC] Missing required user info"
);
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_user_info`);
}
@@ -202,7 +238,7 @@ export async function oidcRoutes(app: FastifyInstance) {
// Set cookies (use app's centralized cookie options)
request.log.debug(
`[OIDC] Setting cookies for user ${user.username}, NODE_ENV=${env.NODE_ENV}, secure=${app.config.cookieOptions.secure}`
`[OIDC] Setting auth cookies for username=${user.username}, userId=${user.id}, NODE_ENV=${env.NODE_ENV}, secure=${app.config.cookieOptions.secure}`
);
setAuthCookies(app, reply, accessToken, refreshToken);
@@ -210,8 +246,8 @@ export async function oidcRoutes(app: FastifyInstance) {
// In dev: CORS_ORIGINS contains the frontend URL
const frontendUrl = env.CORS_ORIGINS.split(",")[0] || "http://localhost:5173";
return reply.redirect(`${frontendUrl}/dashboard`);
} catch (err: any) {
console.error("[OIDC] Callback error:", err);
} catch (err: unknown) {
request.log.error({ err }, "[OIDC] Callback processing failed");
return reply.redirect(`${getFrontendUrl()}/?error=oidc_callback_failed`);
}
}
@@ -252,7 +288,7 @@ async function findOrCreateOIDCUser(
// Check if auto-create is enabled
if (!env.OIDC_AUTO_CREATE_USERS) {
console.error(`[OIDC] User creation disabled and user not found: ${username}`);
// No logger is available in this helper, route-level logs already capture callback failures.
return null;
}
@@ -276,7 +312,7 @@ async function findOrCreateOIDCUser(
// JWT Token Generation (reused from auth.ts logic)
// =============================================================================
async function generateAccessToken(app: FastifyInstance, userId: number, username: string): Promise<string> {
return app.jwt.sign({ sub: userId, username }, { expiresIn: `${env.ACCESS_TOKEN_TTL_MINUTES}m` });
return await app.jwt.sign({ sub: userId, username }, { expiresIn: `${env.ACCESS_TOKEN_TTL_MINUTES}m` });
}
async function generateRefreshToken(
@@ -286,7 +322,7 @@ async function generateRefreshToken(
const tokenId = randomBytes(32).toString("hex");
const expiresAt = new Date(Date.now() + env.REFRESH_TOKEN_TTL_DAYS * 24 * 60 * 60 * 1000);
const refreshToken = app.jwt.sign(
const refreshToken = await app.jwt.sign(
{ sub: userId, jti: tokenId, type: "refresh" },
{ expiresIn: `${env.REFRESH_TOKEN_TTL_DAYS}d` }
);
File diff suppressed because it is too large Load Diff
+299 -105
View File
@@ -2,24 +2,100 @@ import { and, desc, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { medications, refillHistory } from "../db/schema.js";
import { doseTracking, medications, refillHistory, userSettings } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import { computeMedicationCurrentStock } from "../services/current-stock.js";
import type { AuthUser } from "../types/fastify.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
idParamsSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import { isAmountBasedPackageType, 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 = {
type: "object",
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 quantity.",
example: {
packsAdded: 1,
loosePillsAdded: 4,
quantityAdded: 4,
usePrescription: true,
},
} as const;
const refillResponseSchema = {
type: "object",
properties: {
success: { type: "boolean" },
refill: {
type: "object",
properties: {
id: { type: "number" },
packsAdded: { type: "integer" },
loosePillsAdded: { type: "integer" },
quantityAdded: { type: "number" },
totalPillsAdded: { type: "number" },
refillDate: { type: "string", format: "date-time" },
},
},
newStock: {
type: "object",
properties: {
packCount: { type: "integer" },
looseTablets: { type: "integer" },
totalPills: { type: "number" },
},
},
prescription: {
type: "object",
properties: {
used: { type: "boolean" },
remainingRefills: { type: "integer" },
authorizedRefills: { type: "integer" },
lowRefillThreshold: { type: "integer" },
enabled: { type: "boolean" },
},
},
},
} as const;
const refillHistoryItemSchema = {
type: "object",
properties: {
id: { type: "number" },
packsAdded: { type: "integer" },
loosePillsAdded: { type: "integer" },
quantityAdded: { type: "number" },
totalPillsAdded: { type: "number" },
usedPrescription: { type: "boolean" },
refillDate: { type: "string", format: "date-time" },
},
} as const;
export async function refillRoutes(app: FastifyInstance) {
// All refill routes require auth
app.addHook("preHandler", requireAuth);
applyOpenApiRouteStandards(app, { tag: "refills", protectedByDefault: true });
// Helper to get user ID from request
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
@@ -35,129 +111,247 @@ export async function refillRoutes(app: FastifyInstance) {
}
// POST /medications/:id/refill - Add stock to medication
app.post<{ Params: { id: string } }>("/medications/:id/refill", async (req, reply) => {
const parsed = refillSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send(parsed.error.format());
app.post<{ Params: { id: string } }>(
"/medications/:id/refill",
{
schema: {
params: idParamsSchema,
body: refillBodyOpenApiSchema,
response: {
200: refillResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
404: genericErrorSchema,
409: genericErrorSchema,
},
},
},
async (req, reply) => {
const parsed = refillSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send(parsed.error.format());
const medId = Number(req.params.id);
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
const medId = Number(req.params.id);
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
const userId = await getUserId(req, reply);
const userId = await getUserId(req, reply);
// Verify ownership
const [med] = await db
.select()
.from(medications)
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
if (!med) return reply.notFound("Medication not found");
// Verify ownership
const [med] = await db
.select()
.from(medications)
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
if (!med) return reply.notFound("Medication not found");
const { packsAdded, loosePillsAdded, usePrescription } = parsed.data;
const { packsAdded, loosePillsAdded, quantityAdded, usePrescription } = parsed.data;
const packageType = normalizePackageType(med.packageType);
const isBottle = packageType === "bottle";
const isAmountBased = isAmountBasedPackageType(packageType);
const isCountBasedAmountPackage = isAmountBased && !isBottle;
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
if (usePrescription) {
if (!(med.prescriptionEnabled ?? false)) {
return reply.status(400).send({ error: "Prescription refill is not enabled for this medication" });
const configuredAmountPerPackage = Number(med.packageAmountValue ?? 0);
const fallbackAmountPerPackage = Math.max(
1,
Math.round((med.totalPills ?? med.looseTablets ?? 0) / Math.max(1, med.packCount || 1))
);
const amountPerPackage =
Number.isFinite(configuredAmountPerPackage) && configuredAmountPerPackage > 0
? configuredAmountPerPackage
: fallbackAmountPerPackage;
const requestedPackAdds = Math.max(0, packsAdded);
const requestedLooseAdds = Math.max(0, loosePillsAdded);
const requestedQuantityAdds = Math.max(0, quantityAdded > 0 ? quantityAdded : requestedLooseAdds);
const requestedAmountAdds = isCountBasedAmountPackage ? requestedQuantityAdds : requestedLooseAdds;
const derivedCountFromAmount = Math.max(0, Math.round(requestedAmountAdds / amountPerPackage));
let effectivePacksAdded = requestedPackAdds;
if (isBottle) {
effectivePacksAdded = 0;
} else if (isCountBasedAmountPackage) {
effectivePacksAdded = Math.max(requestedPackAdds, derivedCountFromAmount);
}
const remaining = med.prescriptionRemainingRefills ?? 0;
if (remaining <= 0) {
return reply.status(409).send({ error: "No remaining prescription refills" });
const effectiveLoosePillsAdded = isCountBasedAmountPackage
? effectivePacksAdded * amountPerPackage
: requestedAmountAdds;
const remainingPrescriptionRefills = med.prescriptionRemainingRefills ?? 0;
const totalPillsAdded = isAmountBased
? effectiveLoosePillsAdded
: effectivePacksAdded * pillsPerPack + effectiveLoosePillsAdded;
if (effectivePacksAdded < 1 && effectiveLoosePillsAdded < 1) {
return reply.status(400).send({ error: "Must add at least one pack or some loose pills" });
}
}
// Update medication stock
const newPackCount = med.packCount + packsAdded;
const newLooseTablets = med.looseTablets + loosePillsAdded;
if (usePrescription) {
if (!(med.prescriptionEnabled ?? false)) {
return reply.status(400).send({ error: "Prescription refill is not enabled for this medication" });
}
if (remainingPrescriptionRefills <= 0) {
return reply.status(409).send({ error: "No remaining prescription refills" });
}
if (!isBottle && effectivePacksAdded > remainingPrescriptionRefills) {
return reply.status(409).send({ error: "Packs to add exceed remaining prescription refills" });
}
}
const newRemainingRefills = usePrescription
? Math.max(0, (med.prescriptionRemainingRefills ?? 0) - 1)
: (med.prescriptionRemainingRefills ?? null);
const refillBaselineAt = new Date();
const [settings] = await db
.select({ stockCalculationMode: userSettings.stockCalculationMode })
.from(userSettings)
.where(eq(userSettings.userId, userId));
const stockCalculationMode = settings?.stockCalculationMode === "manual" ? "manual" : "automatic";
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
const currentStockAtRefill = computeMedicationCurrentStock({
medication: med,
doses,
stockCalculationMode,
nowMs: refillBaselineAt.getTime(),
});
const targetCurrentStock = currentStockAtRefill + totalPillsAdded;
await db
.update(medications)
.set({
// Update medication stock. Refill establishes a new stock baseline at the current visible
// stock level so previously consumed doses are not "resurrected" when lastStockCorrectionAt resets.
let newPackCount = med.packCount + effectivePacksAdded;
let newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
let newStockAdjustment = med.stockAdjustment ?? 0;
let newTotalAmount = med.totalPills ?? med.looseTablets;
if (isBottle) {
newLooseTablets = targetCurrentStock;
newStockAdjustment = 0;
} else if (isCountBasedAmountPackage) {
newPackCount = Math.max(1, Math.ceil(targetCurrentStock / amountPerPackage));
newLooseTablets = targetCurrentStock;
newTotalAmount = targetCurrentStock;
newStockAdjustment = 0;
} else {
const structuralBaseAfterRefill = newPackCount * pillsPerPack + newLooseTablets;
newStockAdjustment = targetCurrentStock - structuralBaseAfterRefill;
}
let consumedRefills = 0;
if (usePrescription) {
consumedRefills = isBottle ? 1 : effectivePacksAdded;
}
const newRemainingRefills = usePrescription
? Math.max(0, remainingPrescriptionRefills - consumedRefills)
: (med.prescriptionRemainingRefills ?? null);
const updatePayload: {
packCount: number;
looseTablets: number;
stockAdjustment: number;
totalPills?: number;
packageAmountValue?: number;
prescriptionRemainingRefills: number | null;
lastStockCorrectionAt: Date;
updatedAt: Date;
} = {
packCount: newPackCount,
looseTablets: newLooseTablets,
stockAdjustment: newStockAdjustment,
prescriptionRemainingRefills: newRemainingRefills,
stockAdjustment: 0, // Reset offset since we're adding to base stock
lastStockCorrectionAt: new Date(), // Reset consumed counter to now
updatedAt: new Date(),
})
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
lastStockCorrectionAt: refillBaselineAt,
updatedAt: refillBaselineAt,
};
// Create refill history entry
const [refill] = await db
.insert(refillHistory)
.values({
medicationId: medId,
userId,
packsAdded,
loosePillsAdded,
usedPrescription: usePrescription,
})
.returning();
if (isCountBasedAmountPackage) {
updatePayload.totalPills = newTotalAmount;
updatePayload.packageAmountValue = amountPerPackage;
}
// Calculate pills added for response (packageType-aware)
const isBottle = (med.packageType ?? "blister") === "bottle";
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
const totalPillsAdded = isBottle ? loosePillsAdded : packsAdded * pillsPerPack + loosePillsAdded;
const newTotalPills = isBottle
? newLooseTablets + (med.stockAdjustment ?? 0)
: newPackCount * pillsPerPack + newLooseTablets + (med.stockAdjustment ?? 0);
await db
.update(medications)
.set(updatePayload)
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
return {
success: true,
refill: {
id: refill.id,
packsAdded,
loosePillsAdded,
totalPillsAdded,
refillDate: refill.refillDate,
},
newStock: {
packCount: newPackCount,
looseTablets: newLooseTablets,
totalPills: newTotalPills,
},
prescription: {
used: usePrescription,
remainingRefills: newRemainingRefills,
authorizedRefills: med.prescriptionAuthorizedRefills ?? null,
lowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
enabled: med.prescriptionEnabled ?? false,
},
};
});
// Create refill history entry
const [refill] = await db
.insert(refillHistory)
.values({
medicationId: medId,
userId,
packsAdded: effectivePacksAdded,
loosePillsAdded: effectiveLoosePillsAdded,
usedPrescription: usePrescription,
})
.returning();
return {
success: true,
refill: {
id: refill.id,
packsAdded: effectivePacksAdded,
loosePillsAdded: effectiveLoosePillsAdded,
quantityAdded: totalPillsAdded,
totalPillsAdded,
refillDate: refill.refillDate,
},
newStock: {
packCount: newPackCount,
looseTablets: newLooseTablets,
totalPills: targetCurrentStock,
},
prescription: {
used: usePrescription,
remainingRefills: newRemainingRefills,
authorizedRefills: med.prescriptionAuthorizedRefills ?? null,
lowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
enabled: med.prescriptionEnabled ?? false,
},
};
}
);
// GET /medications/:id/refills - Get refill history for a medication
app.get<{ Params: { id: string } }>("/medications/:id/refills", async (req, reply) => {
const medId = Number(req.params.id);
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
app.get<{ Params: { id: string } }>(
"/medications/:id/refills",
{
schema: {
params: idParamsSchema,
response: {
200: { type: "array", items: refillHistoryItemSchema },
400: genericErrorSchema,
401: genericErrorSchema,
404: genericErrorSchema,
},
},
},
async (req, reply) => {
const medId = Number(req.params.id);
if (Number.isNaN(medId)) return reply.badRequest("Invalid medication id");
const userId = await getUserId(req, reply);
const userId = await getUserId(req, reply);
// Verify ownership
const [med] = await db
.select()
.from(medications)
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
if (!med) return reply.notFound("Medication not found");
// Verify ownership
const [med] = await db
.select()
.from(medications)
.where(and(eq(medications.id, medId), eq(medications.userId, userId)));
if (!med) return reply.notFound("Medication not found");
// Get refill history, newest first
const refills = await db
.select()
.from(refillHistory)
.where(eq(refillHistory.medicationId, medId))
.orderBy(desc(refillHistory.refillDate));
// Get refill history, newest first
const refills = await db
.select()
.from(refillHistory)
.where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)))
.orderBy(desc(refillHistory.refillDate));
const isBottle = (med.packageType ?? "blister") === "bottle";
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
const packageType = normalizePackageType(med.packageType);
const isBottle = packageType === "bottle";
const isAmountBased = isAmountBasedPackageType(packageType);
const pillsPerPack = isBottle ? 0 : med.blistersPerPack * med.pillsPerBlister;
return refills.map((r) => ({
id: r.id,
packsAdded: r.packsAdded,
loosePillsAdded: r.loosePillsAdded,
totalPillsAdded: isBottle ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
usedPrescription: r.usedPrescription ?? false,
refillDate: r.refillDate,
}));
});
return refills.map((r) => ({
id: r.id,
packsAdded: r.packsAdded,
loosePillsAdded: r.loosePillsAdded,
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,
}));
}
);
}
+218
View File
@@ -0,0 +1,218 @@
import { and, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { doseTracking, medications, refillHistory } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
const reportDataSchema = z.object({
medicationIds: z.array(z.number().int().positive()).min(1).max(100),
takenByFilter: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
});
const reportDataBodyOpenApiSchema = {
type: "object",
required: ["medicationIds"],
properties: {
medicationIds: {
type: "array",
minItems: 1,
maxItems: 100,
items: { type: "integer", minimum: 1 },
},
takenByFilter: {
type: "array",
maxItems: 50,
items: { type: "string", minLength: 1, maxLength: 100 },
},
},
example: {
medicationIds: [1, 3, 5],
takenByFilter: ["Daniel"],
},
} as const;
function matchesTakenByFilter(doseId: string, takenByFilter: Set<string> | null): boolean {
if (!takenByFilter) return true;
const parts = doseId.split("-");
if (parts.length < 4) return false;
const takenBy = parts.at(-1)?.trim();
if (!takenBy) return false;
return takenByFilter.has(takenBy);
}
const reportDataResponseSchema = {
type: "object",
additionalProperties: {
type: "object",
properties: {
dosesTaken: { type: "integer" },
automaticDosesTaken: { type: "integer" },
dosesSkipped: { type: "integer" },
firstDoseAt: { type: "string" },
lastDoseAt: { type: "string" },
refills: {
type: "array",
items: {
type: "object",
properties: {
packsAdded: { type: "integer" },
loosePillsAdded: { type: "integer" },
quantityAdded: { type: "integer" },
usedPrescription: { type: "boolean" },
refillDate: { type: "string", format: "date-time" },
},
},
},
},
},
} as const;
export async function reportRoutes(app: FastifyInstance) {
app.addHook("preHandler", requireAuth);
applyOpenApiRouteStandards(app, { tag: "report", protectedByDefault: true });
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
if (!env.AUTH_ENABLED) {
return getAnonymousUserId();
}
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
reply.status(401).send({ error: "User not authenticated", code: "AUTH_REQUIRED" });
throw new Error("AUTH_REQUIRED");
}
return authUser.id;
}
// POST /medications/report-data - Get aggregated dose/refill data for report generation
app.post(
"/medications/report-data",
{
schema: {
body: reportDataBodyOpenApiSchema,
response: {
200: reportDataResponseSchema,
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
403: genericErrorSchema,
},
},
},
async (req, reply) => {
const parsed = reportDataSchema.safeParse(req.body);
if (!parsed.success) return reply.status(400).send(parsed.error.format());
const userId = await getUserId(req, reply);
const { medicationIds, takenByFilter } = parsed.data;
const normalizedTakenByFilter = takenByFilter?.length
? new Set(takenByFilter.map((value) => value.trim()))
: null;
// Verify all medications belong to this user
const userMeds = await db
.select({
id: medications.id,
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) {
if (!userMedIds.has(id)) {
return reply.status(403).send({ error: "Access denied to medication" });
}
}
// Fetch dose tracking for all requested medications
// doseId format: "{medicationId}-{blisterIndex}-{dateMs}" or "{medicationId}-{blisterIndex}-{dateMs}-{takenBy}"
const allDoses = await db
.select({
doseId: doseTracking.doseId,
takenAt: doseTracking.takenAt,
dismissed: doseTracking.dismissed,
takenSource: doseTracking.takenSource,
})
.from(doseTracking)
.where(eq(doseTracking.userId, userId));
// Group doses by medication ID
const dosesByMed = new Map<number, { takenAt: Date; dismissed: boolean; takenSource: string }[]>();
for (const dose of allDoses) {
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
if (!matchesTakenByFilter(dose.doseId, normalizedTakenByFilter)) continue;
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
dosesByMed.get(medId)!.push({
takenAt: dose.takenAt,
dismissed: dose.dismissed,
takenSource: dose.takenSource ?? "manual",
});
}
// Fetch refill history for requested medications
const result: Record<
number,
{
dosesTaken: number;
automaticDosesTaken: number;
dosesSkipped: number;
firstDoseAt: string | null;
lastDoseAt: string | null;
refills: {
packsAdded: number;
loosePillsAdded: number;
quantityAdded: number;
usedPrescription: boolean;
refillDate: string;
}[];
}
> = {};
for (const medId of medicationIds) {
const doses = dosesByMed.get(medId) ?? [];
const takenDoses = doses.filter((d) => !d.dismissed);
const automaticTakenDoses = takenDoses.filter((d) => d.takenSource === "automatic");
const skippedDoses = doses.filter((d) => d.dismissed);
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
const medication = medMap.get(medId);
const pillsPerPack = Math.max(1, (medication?.blistersPerPack ?? 1) * (medication?.pillsPerBlister ?? 1));
const isAmountBased = medication?.packageType === "liquid_container" || medication?.packageType === "tube";
// Get refills for this medication scoped to the authenticated user.
const refills = await db
.select()
.from(refillHistory)
.where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)));
result[medId] = {
dosesTaken: takenDoses.length,
automaticDosesTaken: automaticTakenDoses.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),
})),
};
}
return result;
}
);
}
File diff suppressed because it is too large Load Diff
+401 -141
View File
@@ -1,12 +1,20 @@
import { randomBytes } from "node:crypto";
import { eq } from "drizzle-orm";
import { and, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { medications, shareTokens, userSettings, users } from "../db/schema.js";
import { doseTracking, medications, shareTokens, userSettings, users } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import { buildSharedMedicationOverview } from "../services/coverage.js";
import type { AuthUser } from "../types/fastify.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
tokenParamsSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
import {
getAllTakenByForMedication,
parseIntakesJson,
@@ -14,9 +22,6 @@ import {
personTakesMedication,
} from "../utils/scheduler-utils.js";
// Share token validity: 1 year in milliseconds
const SHARE_TOKEN_VALIDITY_MS = 365 * 24 * 60 * 60 * 1000;
// =============================================================================
// Validation Schemas
// =============================================================================
@@ -25,6 +30,72 @@ const createShareSchema = z.object({
scheduleDays: z.number().int().min(1).max(365).default(30),
});
const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
{ bearerAuth: [] },
{ cookieAuth: [] },
];
const shareTokenPattern = /^[a-f0-9]{16}$/;
const createShareBodyOpenApiSchema = {
type: "object",
properties: {
takenBy: { type: "string" },
scheduleDays: { type: "integer", minimum: 1, maximum: 365, default: 30 },
},
example: {
takenBy: "Daniel",
scheduleDays: 14,
},
} as const;
const shareReadResponseSchema = {
type: "object",
properties: {
takenBy: { type: "string" },
sharedBy: { type: "string" },
scheduleDays: { type: "integer" },
medications: { type: "array", items: { type: "object", additionalProperties: true } },
shareMedicationOverview: { type: "boolean" },
medicationOverview: {
anyOf: [{ type: "array", items: { type: "object", additionalProperties: true } }, { type: "null" }],
},
stockThresholds: { type: "object", additionalProperties: { type: "number" } },
stockCalculationMode: { type: "string", enum: ["automatic", "manual"] },
upcomingTodayOnly: { type: "boolean" },
shareScheduleTodayOnly: { type: "boolean" },
},
} as const;
const shareExpiredResponseSchema = {
type: "object",
properties: {
error: { type: "string" },
code: { type: "string" },
ownerUsername: { type: "string" },
takenBy: { type: "string" },
expiredAt: { type: "string", format: "date-time" },
},
} as const;
const shareOverviewExpiredResponseSchema = {
type: "object",
properties: {
error: { type: "string" },
expiredAt: { type: "string", format: "date-time" },
},
} as const;
const shareOverviewResponseSchema = {
type: "object",
properties: {
takenBy: { type: "string" },
sharedBy: { type: "string" },
generatedAt: { type: "string", format: "date-time" },
medications: { type: "array", items: { type: "object", additionalProperties: true } },
},
} as const;
// Helper to get user ID from request
// Returns anonymous user ID when auth is disabled
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
@@ -45,131 +116,276 @@ async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<
// Share Routes
// =============================================================================
export async function shareRoutes(app: FastifyInstance) {
applyOpenApiRouteStandards(app, {
tag: "share",
protectedByDefault: false,
protectedPaths: [/^\/share$/, /^\/share\/people$/],
});
// ---------------------------------------------------------------------------
// GET /share/:token - PUBLIC: Get shared schedule by token
// ---------------------------------------------------------------------------
app.get<{ Params: { token: string } }>("/share/:token", async (request, reply) => {
const { token } = request.params;
app.get<{ Params: { token: string } }>(
"/share/:token",
{
schema: {
params: tokenParamsSchema,
response: {
200: shareReadResponseSchema,
404: genericErrorSchema,
410: shareExpiredResponseSchema,
},
},
config: {
rateLimit: {
max: 60,
timeWindow: "1 minute",
errorResponseBuilder: () => ({ error: "rate_limited" }),
},
},
},
async (request, reply) => {
const { token } = request.params;
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
return reply.status(404).send({
error: "Share link not found",
code: "NOT_FOUND",
});
}
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
request.log.warn(`[Share] Invalid share token requested: token=${token}`);
return reply.status(404).send({
error: "Share link not found",
code: "NOT_FOUND",
});
}
// Check if token has expired
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
// Get the username of the owner to show in the expired message
// Check if token has expired
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
request.log.warn(
`[Share] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
);
// Get the username of the owner to show in the expired message
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
return reply.status(410).send({
error: "Share link has expired",
code: "EXPIRED",
ownerUsername: owner?.username ?? "the owner",
takenBy: share.takenBy,
expiredAt: share.expiresAt.toISOString(),
});
}
// Get user settings for stock thresholds
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
// Get the username of the owner who created this share link
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
return reply.status(410).send({
error: "Share link has expired",
code: "EXPIRED",
ownerUsername: owner?.username ?? "the owner",
takenBy: share.takenBy,
expiredAt: share.expiresAt.toISOString(),
// Get medications for this user filtered by takenBy (search in JSON array)
// Use SQLite JSON function to check if takenBy is in the array
const allMeds = await db
.select()
.from(medications)
.where(and(eq(medications.userId, share.userId), eq(medications.isObsolete, false)));
// Filter medications where takenBy matches either medication-level OR any intake-level takenBy
const meds = allMeds.filter((med) => {
const takenByArray = parseTakenByJson(med.takenByJson);
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
return personTakesMedication(share.takenBy, takenByArray, intakes);
});
}
// Get user settings for stock thresholds
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
// Parse blisters and build schedule data
const medicationsWithBlisters = meds.map((med) => {
// Parse intakes from new format, falling back to legacy
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
// Get the username of the owner who created this share link
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
// Convert to legacy blisters format for backward compat
const blisters = intakes.map((i) => ({
usage: i.usage,
every: i.every,
start: i.start,
}));
// Get medications for this user filtered by takenBy (search in JSON array)
// Use SQLite JSON function to check if takenBy is in the array
const allMeds = await db.select().from(medications).where(eq(medications.userId, share.userId));
// Parse takenBy JSON array
const takenByArray = parseTakenByJson(med.takenByJson);
// Filter medications where takenBy matches either medication-level OR any intake-level takenBy
const meds = allMeds.filter((med) => {
const takenByArray = parseTakenByJson(med.takenByJson);
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
return personTakesMedication(share.takenBy, takenByArray, intakes);
});
// Parse blisters and build schedule data
const medicationsWithBlisters = meds.map((med) => {
// Parse intakes from new format, falling back to legacy
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
// Convert to legacy blisters format for backward compat
const blisters = intakes.map((i) => ({
usage: i.usage,
every: i.every,
start: i.start,
}));
// Parse takenBy JSON array
const takenByArray = parseTakenByJson(med.takenByJson);
const totalPills =
(med.packageType ?? "blister") === "bottle"
const totalPills = isAmountBasedPackageType(med.packageType)
? med.looseTablets + (med.stockAdjustment ?? 0)
: med.packCount * med.blistersPerPack * med.pillsPerBlister + med.looseTablets + (med.stockAdjustment ?? 0);
return {
id: med.id,
name: med.name,
genericName: med.genericName,
pillWeightMg: med.pillWeightMg,
doseUnit: med.doseUnit ?? "mg",
imageUrl: med.imageUrl,
totalPills,
packageType: med.packageType ?? "blister",
packCount: med.packCount,
blistersPerPack: med.blistersPerPack,
looseTablets: med.looseTablets,
pillsPerBlister: med.pillsPerBlister,
takenBy: takenByArray,
intakes, // New unified format with per-intake takenBy
blisters, // Legacy format for backward compat
dismissedUntil: med.dismissedUntil,
updatedAt: med.updatedAt, // For filtering out doses from previous schedule configurations
lastStockCorrectionAt: med.lastStockCorrectionAt?.getTime() ?? null,
stockAdjustment: med.stockAdjustment ?? 0,
};
});
return {
id: med.id,
name: med.name,
genericName: med.genericName,
pillWeightMg: med.pillWeightMg,
doseUnit: med.doseUnit ?? "mg",
imageUrl: med.imageUrl,
totalPills,
packageType: normalizePackageType(med.packageType),
packCount: med.packCount,
blistersPerPack: med.blistersPerPack,
looseTablets: med.looseTablets,
pillsPerBlister: med.pillsPerBlister,
takenBy: takenByArray,
intakes, // New unified format with per-intake takenBy
blisters, // Legacy format for backward compat
dismissedUntil: med.dismissedUntil,
updatedAt: med.updatedAt, // For filtering out doses from previous schedule configurations
lastStockCorrectionAt: med.lastStockCorrectionAt?.getTime() ?? null,
stockAdjustment: med.stockAdjustment ?? 0,
};
});
return {
takenBy: share.takenBy,
sharedBy: owner?.username ?? null,
scheduleDays: share.scheduleDays,
medications: medicationsWithBlisters,
stockThresholds: {
lowStockDays: settings?.lowStockDays ?? 30,
normalStockDays: settings?.normalStockDays ?? 60,
highStockDays: settings?.highStockDays ?? 90,
reminderDaysBefore: settings?.reminderDaysBefore ?? 7,
expiryWarningDays: settings?.expiryWarningDays ?? 90,
const shareMedicationOverview = settings?.shareMedicationOverview ?? false;
const medicationOverview = shareMedicationOverview
? buildSharedMedicationOverview({
medications: meds,
doses: await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId)),
thresholdDays: settings?.lowStockDays ?? 30,
})
: null;
return {
takenBy: share.takenBy,
sharedBy: owner?.username ?? null,
scheduleDays: share.scheduleDays,
medications: medicationsWithBlisters,
shareMedicationOverview,
medicationOverview,
stockThresholds: {
lowStockDays: settings?.lowStockDays ?? 30,
normalStockDays: settings?.normalStockDays ?? 60,
highStockDays: settings?.highStockDays ?? 90,
reminderDaysBefore: settings?.reminderDaysBefore ?? 7,
expiryWarningDays: settings?.expiryWarningDays ?? 90,
},
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
upcomingTodayOnly: settings?.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings?.shareScheduleTodayOnly ?? false,
};
}
);
// ---------------------------------------------------------------------------
// GET /share/:token/overview - PUBLIC: Read-only medication overview by token
// ---------------------------------------------------------------------------
app.get<{ Params: { token: string } }>(
"/share/:token/overview",
{
schema: {
params: tokenParamsSchema,
response: {
200: shareOverviewResponseSchema,
404: genericErrorSchema,
410: shareOverviewExpiredResponseSchema,
},
},
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
shareStockStatus: settings?.shareStockStatus ?? true,
};
});
config: {
rateLimit: {
max: 60,
timeWindow: "1 minute",
errorResponseBuilder: () => ({ error: "rate_limited" }),
},
},
},
async (request, reply) => {
reply.header("Cache-Control", "no-store");
const { token } = request.params;
if (!shareTokenPattern.test(token)) {
request.log.warn(`[ShareOverview] Rejected invalid token format: token=${token}`);
return reply.status(404).send({ error: "not_found" });
}
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
request.log.warn(`[ShareOverview] Unknown token requested: token=${token}`);
return reply.status(404).send({ error: "not_found" });
}
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
request.log.warn(
`[ShareOverview] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
);
return reply.status(410).send({
error: "expired",
expiredAt: share.expiresAt.toISOString(),
});
}
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, share.userId));
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
const allMeds = await db
.select()
.from(medications)
.where(and(eq(medications.userId, share.userId), eq(medications.isObsolete, false)));
const meds = allMeds.filter((med) => {
const takenByArray = parseTakenByJson(med.takenByJson);
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
return personTakesMedication(share.takenBy, takenByArray, intakes);
});
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
const overview = buildSharedMedicationOverview({
medications: meds,
doses,
thresholdDays: settings?.lowStockDays ?? 30,
});
return {
takenBy: share.takenBy,
sharedBy: owner?.username ?? null,
generatedAt: new Date().toISOString(),
medications: overview,
};
}
);
// ---------------------------------------------------------------------------
// POST /share - PROTECTED: Create a new share link
// ---------------------------------------------------------------------------
app.post<{ Body: z.infer<typeof createShareSchema> }>(
"/share",
{ preHandler: requireAuth },
{
preHandler: requireAuth,
schema: {
tags: ["share"],
security: protectedEndpointSecurity,
body: createShareBodyOpenApiSchema,
response: {
200: {
type: "object",
properties: {
reused: { type: "boolean" },
token: { type: "string" },
shareUrl: { type: "string" },
expiresAt: { type: ["string", "null"] },
},
},
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
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",
});
}
@@ -177,7 +393,10 @@ export async function shareRoutes(app: FastifyInstance) {
const { takenBy, scheduleDays } = parsed.data;
// Check if user has medications for this takenBy (search in both medication-level and intake-level)
const allMeds = await db.select().from(medications).where(eq(medications.userId, userId));
const allMeds = await db
.select()
.from(medications)
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
const medsForPerson = allMeds.filter((med) => {
const takenByArray = parseTakenByJson(med.takenByJson);
const intakes = parseIntakesJson(
@@ -195,25 +414,47 @@ export async function shareRoutes(app: FastifyInstance) {
});
}
// Generate unique token (8 bytes = 16 hex chars)
// Keep exactly one active share link per person/user.
// If a link already exists, return the same token and only update settings.
const [existingShare] = await db
.select()
.from(shareTokens)
.where(and(eq(shareTokens.userId, userId), eq(shareTokens.takenBy, takenBy)));
if (existingShare) {
await db.update(shareTokens).set({ scheduleDays, expiresAt: null }).where(eq(shareTokens.id, existingShare.id));
request.log.info(
`[Share] Reused existing share token: token=${existingShare.token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}`
);
return {
reused: true,
token: existingShare.token,
shareUrl: `/share/${existingShare.token}`,
expiresAt: null,
};
}
const token = randomBytes(8).toString("hex");
// Set expiration date (1 year from now)
const expiresAt = new Date(Date.now() + SHARE_TOKEN_VALIDITY_MS);
// Create share token
await db.insert(shareTokens).values({
userId: userId,
userId,
token,
takenBy,
scheduleDays,
expiresAt,
expiresAt: null,
});
request.log.info(
`[Share] Created new share token: token=${token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}`
);
return {
reused: false,
token,
shareUrl: `/share/${token}`,
expiresAt: expiresAt.toISOString(),
expiresAt: null,
};
}
);
@@ -221,37 +462,56 @@ export async function shareRoutes(app: FastifyInstance) {
// ---------------------------------------------------------------------------
// GET /share/people - PROTECTED: Get list of unique takenBy values
// ---------------------------------------------------------------------------
app.get("/share/people", { preHandler: requireAuth }, async (request, reply) => {
const userId = await getUserId(request, reply);
app.get(
"/share/people",
{
preHandler: requireAuth,
schema: {
tags: ["share"],
security: protectedEndpointSecurity,
response: {
200: {
type: "object",
properties: {
people: { type: "array", items: { type: "string" } },
},
},
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
// Get all unique takenBy values for this user (from both medication-level and intake-level)
const meds = await db
.select({
takenByJson: medications.takenByJson,
intakesJson: medications.intakesJson,
usageJson: medications.usageJson,
everyJson: medications.everyJson,
startJson: medications.startJson,
intakeRemindersEnabled: medications.intakeRemindersEnabled,
})
.from(medications)
.where(eq(medications.userId, userId));
// Get all unique takenBy values for this user (from both medication-level and intake-level)
const meds = await db
.select({
takenByJson: medications.takenByJson,
intakesJson: medications.intakesJson,
usageJson: medications.usageJson,
everyJson: medications.everyJson,
startJson: medications.startJson,
intakeRemindersEnabled: medications.intakeRemindersEnabled,
})
.from(medications)
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
// Collect all unique person names from medication-level AND intake-level takenBy
const allPeople = new Set<string>();
for (const med of meds) {
const takenByArray = parseTakenByJson(med.takenByJson);
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
const allForMed = getAllTakenByForMedication(takenByArray, intakes);
for (const person of allForMed) {
if (person) allPeople.add(person);
// Collect all unique person names from medication-level AND intake-level takenBy
const allPeople = new Set<string>();
for (const med of meds) {
const takenByArray = parseTakenByJson(med.takenByJson);
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
const allForMed = getAllTakenByForMedication(takenByArray, intakes);
for (const person of allForMed) {
if (person) allPeople.add(person);
}
}
}
return { people: [...allPeople].sort() };
});
return { people: [...allPeople].sort() };
}
);
}
+212
View File
@@ -0,0 +1,212 @@
import type { doseTracking, medications } from "../db/schema.js";
import { isAmountBasedPackageType } from "../utils/package-profiles.js";
import {
getAverageOccurrencesPerDay,
getNextScheduledOccurrenceTime,
getTodayInTimezone,
type Intake,
normalizeIntakeUsageForStock,
parseIntakesJson,
} from "../utils/scheduler-utils.js";
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
type MedicationRow = typeof medications.$inferSelect;
type DoseRow = typeof doseTracking.$inferSelect;
export type SharedMedicationOverviewItem = {
name: string;
genericName: string | null;
imageUrl: string | null;
packageType: string;
packCount: number;
packageAmountValue: number | null;
packageAmountUnit: "ml" | "g" | null;
blistersPerPack: number;
pillsPerBlister: number;
totalPills: number | null;
looseTablets: number;
currentStock: number | null;
capacity: number | null;
daysLeft: number | null;
nextIntakeDate: string | null;
depletionDate: string | null;
priority: "normal" | "high" | "out-of-stock" | null;
expiryDate: string | null;
medicationStartDate: string | null;
prescriptionEnabled: boolean;
prescriptionRemainingRefills: number | null;
};
function toDateOnlyString(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function parseDateOnly(dateOnly: string): Date {
const [year, month, day] = dateOnly.split("-").map((value) => Number.parseInt(value, 10));
return new Date(year, month - 1, day, 0, 0, 0, 0);
}
function computeCapacity(medication: MedicationRow): number {
if (isAmountBasedPackageType(medication.packageType)) {
return medication.totalPills ?? medication.looseTablets;
}
return medication.packCount * medication.blistersPerPack * medication.pillsPerBlister;
}
function computeDailyDoseRate(intakes: Intake[], medication: MedicationRow): number {
return intakes.reduce((sum, intake) => {
const normalizedUsage = normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
return sum + normalizedUsage * getAverageOccurrencesPerDay(intake);
}, 0);
}
function computeNextIntakeDate(intakes: Intake[], todayDateOnly: string): string | null {
const today = parseDateOnly(todayDateOnly);
let nextOccurrenceMs: number | null = null;
for (const intake of intakes) {
const occurrenceMs = getNextScheduledOccurrenceTime(intake, today.getTime(), true);
if (occurrenceMs === null) {
continue;
}
if (nextOccurrenceMs === null || occurrenceMs < nextOccurrenceMs) {
nextOccurrenceMs = occurrenceMs;
}
}
return nextOccurrenceMs === null ? null : toDateOnlyString(new Date(nextOccurrenceMs));
}
function computeTakenAmount(
medication: MedicationRow,
intakes: Intake[],
dosesByMedication: Map<number, DoseRow[]>
): number {
const doseRows = dosesByMedication.get(medication.id) ?? [];
if (doseRows.length === 0) return 0;
const correctionDateOnlyMs = medication.lastStockCorrectionAt
? new Date(
medication.lastStockCorrectionAt.getFullYear(),
medication.lastStockCorrectionAt.getMonth(),
medication.lastStockCorrectionAt.getDate(),
0,
0,
0,
0
).getTime()
: 0;
let takenAmount = 0;
for (const dose of doseRows) {
if (dose.dismissed) continue;
const match = doseIdPattern.exec(dose.doseId);
if (!match) continue;
const intakeIndex = Number.parseInt(match[2], 10);
const doseDateOnlyMs = Number.parseInt(match[3], 10);
if (Number.isNaN(intakeIndex) || Number.isNaN(doseDateOnlyMs)) continue;
if (doseDateOnlyMs < correctionDateOnlyMs) continue;
const intake = intakes[intakeIndex];
if (!intake) continue;
takenAmount += normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
}
return takenAmount;
}
function toNullableDate(value: string | null): string | null {
if (!value) return null;
return value.trim() ? value : null;
}
function computeOverviewPriority(
currentStock: number,
daysLeft: number | null,
thresholdDays: number
): "normal" | "high" | "out-of-stock" {
if (currentStock <= 0 || daysLeft === 0) return "out-of-stock";
if (daysLeft !== null && daysLeft <= thresholdDays) return "high";
return "normal";
}
export function buildSharedMedicationOverview(options: {
medications: MedicationRow[];
doses: DoseRow[];
thresholdDays: number;
}): SharedMedicationOverviewItem[] {
const { medications: medicationRows, doses, thresholdDays } = options;
const dosesByMedication = new Map<number, DoseRow[]>();
for (const dose of doses) {
const match = doseIdPattern.exec(dose.doseId);
if (!match) continue;
const medicationId = Number.parseInt(match[1], 10);
if (Number.isNaN(medicationId)) continue;
const existing = dosesByMedication.get(medicationId) ?? [];
existing.push(dose);
dosesByMedication.set(medicationId, existing);
}
const todayDateOnly = getTodayInTimezone();
const todayDate = parseDateOnly(todayDateOnly);
return medicationRows.map((medication) => {
const intakes = parseIntakesJson(
medication.intakesJson,
{
usageJson: medication.usageJson,
everyJson: medication.everyJson,
startJson: medication.startJson,
},
medication.intakeRemindersEnabled ?? false
);
const capacity = computeCapacity(medication);
const dailyDoseRate = computeDailyDoseRate(intakes, medication);
const takenAmount = computeTakenAmount(medication, intakes, dosesByMedication);
const rawCurrentStock = capacity + (medication.stockAdjustment ?? 0) - takenAmount;
const currentStock = Math.max(0, Math.floor(rawCurrentStock));
const daysLeft = dailyDoseRate > 0 ? Math.floor(currentStock / dailyDoseRate) : null;
const depletionDate =
daysLeft === null ? null : toDateOnlyString(new Date(todayDate.getTime() + daysLeft * 86_400_000));
const priority = computeOverviewPriority(currentStock, daysLeft, thresholdDays);
return {
name: medication.name,
genericName: medication.genericName,
imageUrl: medication.imageUrl,
packageType: medication.packageType,
packCount: medication.packCount,
packageAmountValue: medication.packageAmountValue,
packageAmountUnit:
medication.packageAmountUnit === "g" || medication.packageAmountUnit === "ml"
? medication.packageAmountUnit
: null,
blistersPerPack: medication.blistersPerPack,
pillsPerBlister: medication.pillsPerBlister,
totalPills: medication.totalPills,
looseTablets: medication.looseTablets,
currentStock,
capacity,
daysLeft,
nextIntakeDate: computeNextIntakeDate(intakes, todayDateOnly),
depletionDate,
priority,
expiryDate: toNullableDate(medication.expiryDate),
medicationStartDate: toNullableDate(medication.medicationStartDate),
prescriptionEnabled: medication.prescriptionEnabled ?? false,
prescriptionRemainingRefills: medication.prescriptionRemainingRefills,
};
});
}
+158
View File
@@ -0,0 +1,158 @@
import type { doseTracking, medications } from "../db/schema.js";
import { isAmountBasedPackageType } from "../utils/package-profiles.js";
import {
countScheduledOccurrencesInRange,
getDateOnlyTimestamp,
getNextScheduledOccurrenceTime,
normalizeIntakeUsageForStock,
parseIntakesJson,
parseLocalDateTime,
parseTakenByJson,
} from "../utils/scheduler-utils.js";
type MedicationRow = typeof medications.$inferSelect;
type DoseRow = typeof doseTracking.$inferSelect;
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
function getDoseTakenAtMs(dose: DoseRow): number {
const rawTakenAt = Number(dose.takenAt);
if (Number.isFinite(rawTakenAt)) {
return rawTakenAt < 1_000_000_000_000 ? rawTakenAt * 1000 : rawTakenAt;
}
return new Date(dose.takenAt).getTime();
}
export function computeMedicationCurrentStock(options: {
medication: MedicationRow;
doses: DoseRow[];
stockCalculationMode: "automatic" | "manual";
nowMs?: number;
}): number {
const { medication, doses, stockCalculationMode, nowMs = Date.now() } = options;
const intakes = parseIntakesJson(
medication.intakesJson,
{
usageJson: medication.usageJson,
everyJson: medication.everyJson,
startJson: medication.startJson,
},
medication.intakeRemindersEnabled ?? false
);
const baseStock = isAmountBasedPackageType(medication.packageType)
? medication.looseTablets + (medication.stockAdjustment ?? 0)
: medication.packCount * medication.blistersPerPack * medication.pillsPerBlister +
medication.looseTablets +
(medication.stockAdjustment ?? 0);
const relevantDoses = doses.filter((dose) => !dose.dismissed);
const stockCorrectionCutoff = medication.lastStockCorrectionAt
? new Date(medication.lastStockCorrectionAt).getTime()
: 0;
let consumed = 0;
if (stockCalculationMode === "automatic") {
const medicationTakenBy = parseTakenByJson(medication.takenByJson);
intakes.forEach((intake, intakeIndex) => {
const usage = normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
const intakeStart = parseLocalDateTime(intake.start).getTime();
if (Number.isNaN(intakeStart)) return;
const effectiveStart =
stockCorrectionCutoff > 0 && stockCorrectionCutoff >= intakeStart
? getNextScheduledOccurrenceTime(intake, stockCorrectionCutoff, false)
: intakeStart;
if (effectiveStart === null) return;
let peopleForThisIntake: Array<string | null>;
if (intake.takenBy) {
peopleForThisIntake = [intake.takenBy];
} else if (medicationTakenBy.length > 0) {
peopleForThisIntake = medicationTakenBy;
} else {
peopleForThisIntake = [null];
}
let lastAutoConsumedDateMs = 0;
if (effectiveStart <= nowMs) {
const { count: occurrences, lastOccurrenceMs } = countScheduledOccurrencesInRange(
intake,
effectiveStart,
nowMs
);
consumed += occurrences * usage * peopleForThisIntake.length;
if (lastOccurrenceMs !== null) {
lastAutoConsumedDateMs = getDateOnlyTimestamp(new Date(lastOccurrenceMs));
}
}
const stockCorrectionDateOnly =
stockCorrectionCutoff > 0 ? getDateOnlyTimestamp(new Date(stockCorrectionCutoff)) : 0;
const earlyCutoff = Math.max(lastAutoConsumedDateMs, stockCorrectionDateOnly);
for (const dose of relevantDoses) {
const match = doseIdPattern.exec(dose.doseId);
if (!match) continue;
const parsedMedicationId = Number.parseInt(match[1], 10);
const parsedIntakeIndex = Number.parseInt(match[2], 10);
const doseDateOnlyMs = Number.parseInt(match[3], 10);
if (
Number.isNaN(parsedMedicationId) ||
Number.isNaN(parsedIntakeIndex) ||
Number.isNaN(doseDateOnlyMs) ||
parsedMedicationId !== medication.id ||
parsedIntakeIndex !== intakeIndex
) {
continue;
}
if (doseDateOnlyMs > earlyCutoff) {
consumed += usage;
}
}
});
} else {
intakes.forEach((intake, intakeIndex) => {
const usage = normalizeIntakeUsageForStock(intake, medication.medicationForm, medication.packageType);
const intakeStart = parseLocalDateTime(intake.start);
const intakeStartDateOnly = new Date(
intakeStart.getFullYear(),
intakeStart.getMonth(),
intakeStart.getDate()
).getTime();
if (Number.isNaN(intakeStartDateOnly)) return;
for (const dose of relevantDoses) {
const match = doseIdPattern.exec(dose.doseId);
if (!match) continue;
const parsedMedicationId = Number.parseInt(match[1], 10);
const parsedIntakeIndex = Number.parseInt(match[2], 10);
const doseDateOnlyMs = Number.parseInt(match[3], 10);
if (
Number.isNaN(parsedMedicationId) ||
Number.isNaN(parsedIntakeIndex) ||
Number.isNaN(doseDateOnlyMs) ||
parsedMedicationId !== medication.id ||
parsedIntakeIndex !== intakeIndex
) {
continue;
}
const takenAtMs = getDoseTakenAtMs(dose);
const afterCorrectionOrNoCorrection = stockCorrectionCutoff === 0 || takenAtMs > stockCorrectionCutoff;
if (doseDateOnlyMs >= intakeStartDateOnly && afterCorrectionOrNoCorrection) {
consumed += usage;
}
}
});
}
return Math.max(0, Math.floor(baseStock - consumed));
}
+348 -156
View File
@@ -1,10 +1,9 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { and, eq, gte, lte } from "drizzle-orm";
import nodemailer from "nodemailer";
import { db } from "../db/client.js";
import { getDataDir } from "../db/db-utils.js";
import { doseTracking, medications } from "../db/schema.js";
import { getDataDir } from "../db/path-utils.js";
import { doseTracking, medications, users } from "../db/schema.js";
import {
getDateLocale,
getFooterHtml,
@@ -13,36 +12,39 @@ import {
type Language,
t,
} from "../i18n/translations.js";
import { getAllUserSettings, sendShoutrrrNotification, type UserSettings } from "../routes/settings.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 Intake,
type IntakeReminderState,
normalizeIntakeUsageForStock,
parseIntakeReminderState,
parseIntakesJson,
parseTakenByJson,
type UpcomingIntake,
} from "../utils/scheduler-utils.js";
import { updateReminderSentTime, updateUserReminderSentTime } from "./reminder-scheduler.js";
import { computeMedicationCurrentStock } from "./current-stock.js";
import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "./notifications/delivery.js";
import { updateReminderSentTime, updateUserReminderSentTime } from "./notifications/state.js";
const REMINDER_MINUTES_BEFORE = parseInt(process.env.REMINDER_MINUTES_BEFORE ?? "15", 10);
const CHECK_INTERVAL_MS = 60 * 1000; // Check every 1 minute
const intakeReminderStateFile = resolve(getDataDir(), "intake-reminder-state.json");
function loadIntakeReminderState(): IntakeReminderState {
function loadIntakeReminderState(logger: ServiceLogger): IntakeReminderState {
try {
if (existsSync(intakeReminderStateFile)) {
return parseIntakeReminderState(readFileSync(intakeReminderStateFile, "utf-8"));
}
} catch {
// ignore
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`[IntakeReminder] Failed to load reminder state file=${intakeReminderStateFile}: ${errorMessage}`);
}
return createDefaultIntakeReminderState();
}
@@ -51,6 +53,194 @@ function saveIntakeReminderState(state: IntakeReminderState): void {
writeFileSync(intakeReminderStateFile, JSON.stringify(state, null, 2));
}
function buildDoseIdForIntake(intake: UpcomingIntake & { medicationId: number; blisterIndex: number }): string {
const intakeDate = intake.intakeTime;
const dateOnlyMs = new Date(intakeDate.getFullYear(), intakeDate.getMonth(), intakeDate.getDate()).getTime();
if (intake.takenBy) {
return `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}-${intake.takenBy}`;
}
return `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`;
}
async function getUsernameForLog(userId: number): Promise<string> {
const user = await db.select({ username: users.username }).from(users).where(eq(users.id, userId));
const username = user[0]?.username?.trim();
return username && username.length > 0 ? username : `unknown-user-${userId}`;
}
function formatIntakeLog(intake: {
medName: string;
medicationId: number;
blisterIndex: number;
intakeTime: Date;
intakeTimeStr: string;
usage: number;
doseUnit?: string;
takenBy?: string | null;
}): string {
const takenBy = intake.takenBy ? intake.takenBy : "none";
const doseUnit = intake.doseUnit ?? "mg";
return `${intake.medName} (medId=${intake.medicationId}, intakeIndex=${intake.blisterIndex}, time=${intake.intakeTime.toISOString()}, localTime=${intake.intakeTimeStr}, usage=${intake.usage} ${doseUnit}, takenBy=${takenBy})`;
}
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}`;
}
async function autoMarkDueIntakesAsTaken(
settings: UserSettings & { userId: number },
rows: (typeof medications.$inferSelect)[],
locale: string,
tz: string,
logger: ServiceLogger
): Promise<number> {
if (settings.stockCalculationMode !== "automatic") {
logger.debug(
`[IntakeReminder] Auto-mark disabled for userId=${settings.userId} because stockCalculationMode=${settings.stockCalculationMode}`
);
return 0;
}
const now = new Date();
const nowInTimezone = new Date(now.toLocaleString("en-US", { timeZone: tz }));
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
todayStart.setHours(0, 0, 0, 0);
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: tz }));
todayEnd.setHours(23, 59, 59, 999);
const existingToday = await db
.select({ doseId: doseTracking.doseId })
.from(doseTracking)
.where(
and(
eq(doseTracking.userId, settings.userId),
gte(doseTracking.takenAt, todayStart),
lte(doseTracking.takenAt, todayEnd)
)
);
const existingDoseIds = new Set(existingToday.map((d) => d.doseId));
const trackedDoses = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, settings.userId), eq(doseTracking.dismissed, false)));
let inserted = 0;
for (const med of rows) {
if (med.isObsolete) {
continue;
}
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
if (intakes.length === 0) {
continue;
}
const medicationTakenBy = parseTakenByJson(med.takenByJson);
const medDisplayName = getMedicationDisplayName({ id: med.id, name: med.name, genericName: med.genericName });
let remainingStock = computeMedicationCurrentStock({
medication: med,
doses: trackedDoses,
stockCalculationMode: settings.stockCalculationMode,
nowMs: now.getTime(),
});
if (remainingStock <= 0) {
continue;
}
const todaysIntakes = getTodaysIntakes(
medDisplayName,
intakes,
medicationTakenBy,
med.pillWeightMg,
locale,
tz,
med.id,
med.doseUnit ?? "mg"
);
for (const intake of todaysIntakes) {
const intakeTimeInTimezone = new Date(intake.intakeTime.toLocaleString("en-US", { timeZone: tz }));
if (intakeTimeInTimezone.getTime() > nowInTimezone.getTime()) {
continue;
}
if (intake.medicationId === undefined || intake.blisterIndex === undefined) {
continue;
}
const doseId = buildDoseIdForIntake({
...intake,
medicationId: intake.medicationId,
blisterIndex: intake.blisterIndex,
});
if (existingDoseIds.has(doseId)) {
continue;
}
const intakeDefinition = intakes[intake.blisterIndex];
const usage = intakeDefinition
? normalizeIntakeUsageForStock(intakeDefinition, med.medicationForm, med.packageType)
: 0;
if (remainingStock <= 0) {
break;
}
await db.insert(doseTracking).values({
userId: settings.userId,
doseId,
takenAt: intake.intakeTime,
markedBy: null,
takenSource: "automatic",
dismissed: false,
});
logger.info(
`[IntakeReminder] Auto-marked intake for userId=${settings.userId}: ${formatIntakeLog({
medName: intake.medName,
medicationId: intake.medicationId,
blisterIndex: intake.blisterIndex,
intakeTime: intake.intakeTime,
intakeTimeStr: intake.intakeTimeStr,
usage: intake.usage,
doseUnit: intake.doseUnit,
takenBy: intake.takenBy,
})}`
);
existingDoseIds.add(doseId);
trackedDoses.push({
id: 0,
userId: settings.userId,
doseId,
takenAt: intake.intakeTime,
markedBy: null,
takenSource: "automatic",
dismissed: false,
});
remainingStock = Math.max(0, remainingStock - usage);
inserted++;
}
}
if (inserted === 0) {
logger.debug(`[IntakeReminder] Auto-mark completed for userId=${settings.userId}: no due intakes`);
} else {
logger.info(`[IntakeReminder] Auto-mark completed for userId=${settings.userId}: inserted=${inserted}`);
}
return inserted;
}
async function sendIntakeReminderEmail(
email: string,
intakes: UpcomingIntake[],
@@ -59,15 +249,10 @@ async function sendIntakeReminderEmail(
repeatIntervalMinutes?: number,
currentCount?: number,
maxCount?: number
): Promise<{ success: boolean; error?: string }> {
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS; // Token takes precedence
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
const smtpSecure = process.env.SMTP_SECURE === "true";
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
): Promise<{ success: boolean; error?: string; messageId?: string; smtpResponse?: string }> {
const smtp = getSmtpConfig();
if (!smtpHost || !smtpUser) {
if (!smtp.host || !smtp.user) {
return { success: false, error: "SMTP not configured" };
}
@@ -192,30 +377,23 @@ ${getFooterPlain(language)}`;
? `[Reminder] ${t(tr.intakeReminder.subject, { medications: intakes.map((i) => i.medName).join(", ") })}`
: t(tr.intakeReminder.subject, { medications: intakes.map((i) => i.medName).join(", ") });
try {
const transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: {
user: smtpUser,
pass: smtpPass ?? "",
},
});
const mailResult = await sendEmailNotification({
to: email,
subject: `💊 ${subject}`,
text: plainText,
html,
from: smtp.from,
});
await transporter.sendMail({
from: smtpFrom,
to: email,
subject: `💊 ${subject}`,
text: plainText,
html,
});
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return { success: false, error: errorMessage };
if (!mailResult.success) {
return { success: false, error: mailResult.error ?? "Unknown error" };
}
return {
success: true,
messageId: mailResult.messageId,
smtpResponse: mailResult.smtpResponse,
};
}
async function checkAndSendIntakeReminders(logger: ServiceLogger): Promise<void> {
@@ -223,29 +401,55 @@ async function checkAndSendIntakeReminders(logger: ServiceLogger): Promise<void>
// Get all user settings to iterate over each user
const allUserSettings = await getAllUserSettings();
logger.debug(`[IntakeReminder] Scheduler cycle loaded user settings count=${allUserSettings.length}`);
if (allUserSettings.length === 0) {
logger.debug(`[IntakeReminder] No users with settings found`);
return; // No users with settings
}
logger.debug(`[IntakeReminder] Found ${allUserSettings.length} users to check`);
for (const userSettings of allUserSettings) {
await checkAndSendIntakeRemindersForUser(userSettings, logger);
}
logger.debug(`[IntakeReminder] Scheduler cycle finished`);
}
async function checkAndSendIntakeRemindersForUser(
export async function checkAndSendIntakeRemindersForUser(
settings: UserSettings & { userId: number },
logger: ServiceLogger
): Promise<void> {
const username = await getUsernameForLog(settings.userId);
logger.info(
`[IntakeReminder] Evaluating intake reminders for user=${username} (userId=${settings.userId}, emailEnabled=${settings.emailEnabled}, pushEnabled=${settings.shoutrrrEnabled}, skipTaken=${settings.skipRemindersForTakenDoses}, repeat=${settings.repeatRemindersEnabled}, mode=${settings.stockCalculationMode})`
);
const language = settings.language;
const tr = getTranslations(language);
logger.debug(
`[IntakeReminder] Checking user ${settings.userId} - repeat:${settings.repeatRemindersEnabled} skip:${settings.skipRemindersForTakenDoses}`
);
const rows = await db
.select()
.from(medications)
.where(and(eq(medications.userId, settings.userId), eq(medications.isObsolete, false)));
const activeRows = rows.filter((med) => med.isObsolete !== true).sort((left, right) => left.id - right.id);
const locale = getDateLocale(language);
const tz = getEffectiveTimezone(settings.timezone ?? null);
const autoMarkedCount = await autoMarkDueIntakesAsTaken(settings, activeRows, locale, tz, logger);
if (autoMarkedCount > 0) {
logger.info(
`[IntakeReminder] Auto-mark summary for user=${username} (userId=${settings.userId}): autoMarkedCount=${autoMarkedCount}`
);
}
if (settings.stockCalculationMode === "automatic" && settings.skipRemindersForTakenDoses) {
logger.info(
`[IntakeReminder] Reminder sending skipped for user=${username} (userId=${settings.userId}) because stockCalculationMode=automatic and skipRemindersForTakenDoses=true`
);
return;
}
// Check if any intake reminder notifications are enabled (granular check)
const emailEnabled = settings.emailEnabled && settings.notificationEmail && settings.emailIntakeReminders;
@@ -253,37 +457,35 @@ async function checkAndSendIntakeRemindersForUser(
if (!emailEnabled && !shoutrrrEnabled) {
logger.debug(
`[IntakeReminder] User ${settings.userId}: No intake notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`
`[IntakeReminder] Notification sending disabled for user=${username} (userId=${settings.userId}): both email and push intake reminders are off`
);
return; // No intake reminder notifications enabled for this user
}
logger.debug(
`[IntakeReminder] User ${settings.userId}: Notifications enabled (email:${emailEnabled}, shoutrrr:${shoutrrrEnabled})`
);
// Build medication entries that have at least one reminder-enabled intake.
// Intake-level reminders are the single source of truth.
const reminderEntries = activeRows
.map((med) => {
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
false
);
const intakesWithReminders = intakes.filter((intake) => intake.intakeRemindersEnabled === true);
return { med, intakes, intakesWithReminders };
})
.filter((entry) => entry.intakesWithReminders.length > 0);
// Get all medications with intake reminders enabled for this user
const rows = await db
.select()
.from(medications)
.where(eq(medications.userId, settings.userId))
.orderBy(medications.id);
const medsWithReminders = rows.filter((row) => row.intakeRemindersEnabled);
if (medsWithReminders.length === 0) {
logger.debug(`[IntakeReminder] User ${settings.userId}: No medications have reminders enabled`);
if (reminderEntries.length === 0) {
logger.debug(
`[IntakeReminder] No reminder-enabled intake definitions for user=${username} (userId=${settings.userId})`
);
return; // No medications have reminders enabled for this user
}
logger.debug(
`[IntakeReminder] User ${settings.userId}: Found ${medsWithReminders.length} medications with reminders`
);
const state = loadIntakeReminderState();
const state = loadIntakeReminderState(logger);
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
const locale = getDateLocale(language);
const tz = getTimezone();
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 }));
@@ -292,44 +494,31 @@ async function checkAndSendIntakeRemindersForUser(
const todayEnd = new Date(now.toLocaleString("en-US", { timeZone: tz }));
todayEnd.setHours(23, 59, 59, 999);
logger.debug(
`[IntakeReminder] User ${settings.userId}: Today range: ${todayStart.toISOString()} to ${todayEnd.toISOString()}`
);
// Find intakes: upcoming ones in reminder window + past ones for repeat reminders
for (const med of medsWithReminders) {
// Parse intakes using new format (with per-intake takenBy), falling back to legacy
const intakes = parseIntakesJson(
med.intakesJson,
{ usageJson: med.usageJson, everyJson: med.everyJson, startJson: med.startJson },
med.intakeRemindersEnabled ?? false
);
for (const { med, intakes, intakesWithReminders } of reminderEntries) {
// Medication-level takenBy (for fallback/display purposes)
const medicationTakenBy = parseTakenByJson(med.takenByJson);
logger.debug(
`[IntakeReminder] User ${settings.userId}: Processing medication "${med.name}" with ${intakes.length} intakes`
);
// Filter intakes that have reminders enabled (per-intake setting or medication-level)
const intakesWithReminders = intakes.filter((intake, idx) => {
const hasReminder = intake.intakeRemindersEnabled || med.intakeRemindersEnabled;
if (!hasReminder) {
logger.debug(`[IntakeReminder] User ${settings.userId}: Intake ${idx} has reminders disabled, skipping`);
}
return hasReminder;
});
const medDisplayName = getMedicationDisplayName({ id: med.id, name: med.name, genericName: med.genericName });
// Process each intake separately to track blisterIndex
intakesWithReminders.forEach((intake, blisterIndex) => {
intakesWithReminders.forEach((intake, _blisterIndex) => {
const actualIndex = intakes.indexOf(intake); // Get the actual index in original array
logger.debug(
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - start: ${intake.start}, every: ${intake.every} days, usage: ${intake.usage}, takenBy: ${intake.takenBy || "(none)"}`
const todaysIntakesForThisDefinition = getTodaysIntakes(
medDisplayName,
[intake],
medicationTakenBy,
med.pillWeightMg,
locale,
tz,
med.id,
med.doseUnit ?? "mg"
);
scheduledIntakesTodayCount += todaysIntakesForThisDefinition.length;
// Always get upcoming intakes (15 min before) for first reminders
const upcomingIntakes = getUpcomingIntakes(
med.name,
medDisplayName,
[intake],
REMINDER_MINUTES_BEFORE,
medicationTakenBy,
@@ -340,9 +529,6 @@ async function checkAndSendIntakeRemindersForUser(
med.id,
med.doseUnit ?? "mg"
);
logger.debug(
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} found ${upcomingIntakes.length} upcoming intakes (reminder window)`
);
// Add upcoming intakes for first reminders
allUpcoming.push(
@@ -355,25 +541,9 @@ async function checkAndSendIntakeRemindersForUser(
// If repeat reminders enabled, also check for missed intakes (past the intake time)
if (settings.repeatRemindersEnabled) {
const allTodaysIntakes = getTodaysIntakes(
med.name,
[intake],
medicationTakenBy,
med.pillWeightMg,
locale,
tz,
med.id,
med.doseUnit ?? "mg"
);
logger.debug(
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} - all today's intakes: ${allTodaysIntakes.length}, times: ${allTodaysIntakes.map((i) => i.intakeTime.toISOString()).join(", ")}`
);
const missedIntakes = allTodaysIntakes.filter(
const missedIntakes = todaysIntakesForThisDefinition.filter(
(todayIntake) => todayIntake.intakeTime.getTime() < now.getTime()
);
logger.debug(
`[IntakeReminder] User ${settings.userId}: Intake ${actualIndex} found ${missedIntakes.length} missed intakes (past intake time)`
);
// Add missed intakes for repeat reminders (only if not already in upcoming list)
const upcomingTimes = new Set(upcomingIntakes.map((i) => i.intakeTime.getTime()));
@@ -390,13 +560,17 @@ async function checkAndSendIntakeRemindersForUser(
});
}
logger.debug(`[IntakeReminder] User ${settings.userId}: Total ${allUpcoming.length} intakes for today`);
if (allUpcoming.length === 0) {
logger.debug(`[IntakeReminder] User ${settings.userId}: No intakes for today`);
logger.debug(
`[IntakeReminder] No upcoming intakes in reminder window for user=${username} (userId=${settings.userId}, scheduledToday=${scheduledIntakesTodayCount})`
);
return; // No upcoming intakes for today
}
logger.info(
`[IntakeReminder] Candidate intakes for user=${username} (userId=${settings.userId}): scheduledToday=${scheduledIntakesTodayCount}, candidates=${allUpcoming.length}`
);
// Determine which doses need reminders (new or repeated)
const nowMs = Date.now();
const maxReminders = settings.maxNaggingReminders ?? 5;
@@ -424,9 +598,6 @@ async function checkAndSendIntakeRemindersForUser(
// Recently missed — scheduler likely recovered from sleep/restart.
// Send a catch-up reminder (counts as first nagging reminder).
remindersToSend.push({ ...intake, currentSendCount: 1, maxReminders, isAdvanceReminder: false });
logger.info(
`[IntakeReminder] User ${settings.userId}: Catch-up reminder for recently missed "${intake.medName}" at ${intake.intakeTimeStr} (${Math.round(minutesSinceIntake)} min ago)`
);
} else {
// Long ago — seed state without notification (user likely already noticed)
state.reminders[key] = {
@@ -435,16 +606,10 @@ async function checkAndSendIntakeRemindersForUser(
sendCount: 0,
advanceSent: false,
};
logger.debug(
`[IntakeReminder] User ${settings.userId}: Seeding state for old past "${intake.medName}" at ${intake.intakeTimeStr} (no notification — ${Math.round(minutesSinceIntake)} min ago)`
);
}
} else {
// Upcoming - this is advance reminder (no counter)
remindersToSend.push({ ...intake, currentSendCount: 0, maxReminders, isAdvanceReminder: true });
logger.debug(
`[IntakeReminder] User ${settings.userId}: Advance reminder for "${intake.medName}" at ${intake.intakeTimeStr}`
);
}
} else if (settings.repeatRemindersEnabled && isIntakePast) {
// Intake time passed - check if we need to send nagging reminder
@@ -456,27 +621,41 @@ async function checkAndSendIntakeRemindersForUser(
const currentNaggingCount = existingEntry.sendCount;
if (currentNaggingCount >= maxReminders) {
// Max nagging reminders reached - stop
logger.debug(
`[IntakeReminder] User ${settings.userId}: Max nagging (${maxReminders}) reached for "${intake.medName}" at ${intake.intakeTimeStr}`
);
} else if (timeSinceLastReminder >= intervalMs) {
const nextSendCount = currentNaggingCount + 1;
remindersToSend.push({ ...intake, currentSendCount: nextSendCount, maxReminders, isAdvanceReminder: false });
logger.debug(
`[IntakeReminder] User ${settings.userId}: Nagging reminder for "${intake.medName}" at ${intake.intakeTimeStr} (${nextSendCount}/${maxReminders})`
);
}
}
// Else: Already sent and either repeats disabled or intake not yet past - skip
}
if (remindersToSend.length === 0) {
logger.debug(
`[IntakeReminder] No reminders to send for user=${username} (userId=${settings.userId}) after state/repeat evaluation`
);
return; // All reminders already sent and no repeats needed
}
logger.info(
`[IntakeReminder] Reminders selected for user=${username} (userId=${settings.userId}): count=${remindersToSend.length} :: ${remindersToSend
.map((intake) =>
formatIntakeLog({
medName: intake.medName,
medicationId: intake.medicationId,
blisterIndex: intake.blisterIndex,
intakeTime: intake.intakeTime,
intakeTimeStr: intake.intakeTimeStr,
usage: intake.usage,
doseUnit: intake.doseUnit,
takenBy: intake.takenBy,
})
)
.join(" | ")}`
);
// If skipRemindersForTakenDoses is enabled, filter out doses that were already taken today
if (settings.skipRemindersForTakenDoses) {
const beforeFilterCount = remindersToSend.length;
// Query doses marked as taken today (takenAt is timestamp, stored as seconds since epoch)
const takenToday = await db
.select()
@@ -502,33 +681,30 @@ async function checkAndSendIntakeRemindersForUser(
// For person-specific intake, check if that person has taken it
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}-${intake.takenBy}`;
const isTaken = takenDoseIds.has(doseId);
if (isTaken) {
logger.debug(
`[IntakeReminder] User ${settings.userId}: Skipping "${intake.medName}" - dose ${doseId} already taken`
);
}
return !isTaken;
} else {
// For non-person-specific intakes
const doseId = `${intake.medicationId}-${intake.blisterIndex}-${dateOnlyMs}`;
const isTaken = takenDoseIds.has(doseId);
if (isTaken) {
logger.debug(
`[IntakeReminder] User ${settings.userId}: Skipping "${intake.medName}" - dose ${doseId} already taken`
);
}
return !isTaken;
}
});
const filteredOutCount = beforeFilterCount - remindersToSend.length;
if (filteredOutCount > 0) {
logger.info(
`[IntakeReminder] Removed reminders for already taken doses for user=${username} (userId=${settings.userId}): removed=${filteredOutCount}, remaining=${remindersToSend.length}`
);
}
if (remindersToSend.length === 0) {
logger.debug(`[IntakeReminder] User ${settings.userId}: All doses taken, skipping reminders`);
logger.info(
`[IntakeReminder] All candidate reminders already taken for user=${username} (userId=${settings.userId}); nothing to send`
);
return;
}
}
logger.info(`[IntakeReminder] User ${settings.userId}: Sending reminder for ${remindersToSend.length} intakes...`);
// Determine if this is a repeat reminder:
// - Any intake already has a state entry AND is past (repeat after first reminder)
// - OR intake is past even without state entry (missed the 15-min window)
@@ -558,10 +734,14 @@ async function checkAndSendIntakeRemindersForUser(
hasNaggingReminder ? maxReminderCount : undefined
);
emailSuccess = result.success;
if (result.success) {
logger.info(`[IntakeReminder] User ${settings.userId}: Email sent successfully`);
if (!result.success) {
logger.error(
`[IntakeReminder] Email delivery failed for user=${username} (userId=${settings.userId}): ${result.error}`
);
} else {
logger.error(`[IntakeReminder] User ${settings.userId}: Failed to send email: ${result.error}`);
logger.info(
`[IntakeReminder] Email delivered for user=${username} (userId=${settings.userId}, recipient=${settings.notificationEmail}, reminders=${remindersToSend.length}, messageId=${result.messageId ?? "n/a"})`
);
}
}
@@ -622,12 +802,16 @@ async function checkAndSendIntakeRemindersForUser(
repeatNote +
`\n\n---\n${getFooterPlain(language)}`;
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
const result = await sendPushNotification(settings.shoutrrrUrl!, title, message);
shoutrrrSuccess = result.success;
if (result.success) {
logger.info(`[IntakeReminder] User ${settings.userId}: Push notification sent successfully`);
if (!result.success) {
logger.error(
`[IntakeReminder] Push delivery failed for user=${username} (userId=${settings.userId}): ${result.error}`
);
} else {
logger.error(`[IntakeReminder] User ${settings.userId}: Failed to send push: ${result.error}`);
logger.info(
`[IntakeReminder] Push delivered for user=${username} (userId=${settings.userId}, reminders=${remindersToSend.length})`
);
}
}
@@ -684,7 +868,8 @@ async function checkAndSendIntakeRemindersForUser(
saveIntakeReminderState(state);
// Update global reminder state for UI display
const channel = emailSuccess && shoutrrrSuccess ? "both" : emailSuccess ? "email" : "push";
const singleChannel = emailSuccess ? "email" : "push";
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
updateReminderSentTime("intake", channel);
// Also update user settings in database so frontend can display the info
@@ -693,6 +878,13 @@ async function checkAndSendIntakeRemindersForUser(
const medName = firstReminder?.medName;
const takenBy = firstReminder?.takenBy || undefined;
await updateUserReminderSentTime(settings.userId, "intake", channel, medName, takenBy);
logger.info(
`[IntakeReminder] Reminder state persisted for user=${username} (userId=${settings.userId}, channel=${channel}, reminders=${remindersToSend.length}, firstMed=${medName ?? "n/a"}, firstTakenBy=${takenBy ?? "none"})`
);
} else {
logger.info(
`[IntakeReminder] No reminder channel succeeded for user=${username} (userId=${settings.userId}, remindersAttempted=${remindersToSend.length})`
);
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,13 @@
export {
MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT,
MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT,
type MedicationEnrichmentCombinedSource,
type MedicationEnrichmentEnrichRequest,
type MedicationEnrichmentEnrichResponse,
type MedicationEnrichmentPackageOption,
type MedicationEnrichmentSearchResponse,
type MedicationEnrichmentSearchResult,
type MedicationEnrichmentSearchSource,
MedicationEnrichmentServiceError,
type MedicationEnrichmentStrengthOption,
} from "../medication-enrichment.js";
@@ -0,0 +1,20 @@
export {
MEDICATION_ENRICHMENT_SEARCH_DEFAULT_LIMIT,
MEDICATION_ENRICHMENT_SEARCH_MAX_LIMIT,
type MedicationEnrichmentCombinedSource,
type MedicationEnrichmentEnrichRequest,
type MedicationEnrichmentEnrichResponse,
type MedicationEnrichmentPackageOption,
type MedicationEnrichmentSearchResponse,
type MedicationEnrichmentSearchResult,
type MedicationEnrichmentSearchSource,
MedicationEnrichmentServiceError,
type MedicationEnrichmentStrengthOption,
} from "./adapters.js";
export {
enrichMedicationSelection,
searchMedicationEnrichment,
startMedicationEnrichmentCatalogRefresh,
startMedicationEnrichmentService,
} from "./search.js";
@@ -0,0 +1,6 @@
export {
enrichMedicationSelection,
searchMedicationEnrichment,
startMedicationEnrichmentCatalogRefresh,
startMedicationEnrichmentService,
} from "../medication-enrichment.js";
@@ -0,0 +1,76 @@
import { forEachScheduledOccurrenceInRange, type Intake, parseIntakesJson } from "../utils/scheduler-utils.js";
function isIntakeUnit(value: unknown): value is "ml" | "tsp" | "tbsp" {
return value === "ml" || value === "tsp" || value === "tbsp";
}
export function parseRawIntakeUnits(intakesJson: string | null | undefined): Array<"ml" | "tsp" | "tbsp" | null> {
if (!intakesJson) return [];
try {
const parsed = JSON.parse(intakesJson);
if (!Array.isArray(parsed)) return [];
return parsed.map((item: unknown) => {
if (!item || typeof item !== "object") return null;
const unit = (item as Record<string, unknown>).intakeUnit;
return isIntakeUnit(unit) ? unit : null;
});
} catch {
return [];
}
}
export function parseIntakesWithUnits(
intakesJson: string | null | undefined,
legacyRow: { usageJson: string; everyJson: string; startJson: string },
medicationIntakeRemindersEnabled?: boolean
): Intake[] {
const intakes = parseIntakesJson(intakesJson, legacyRow, medicationIntakeRemindersEnabled);
const rawUnits = parseRawIntakeUnits(intakesJson);
if (rawUnits.length === 0) return intakes;
return intakes.map((intake, idx) => ({
...intake,
intakeUnit: rawUnits[idx] ?? intake.intakeUnit ?? null,
}));
}
export function normalizeDateTime(value: unknown): string | null {
if (value == null) {
return null;
}
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value.toISOString();
}
if (typeof value === "number") {
const timestampMs = value < 1_000_000_000_000 ? value * 1000 : value;
const date = new Date(timestampMs);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
if (typeof value === "string") {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date.toISOString();
}
return null;
}
export function calculateUsageInRange(
blisters: Array<Pick<Intake, "usage" | "every" | "start" | "scheduleMode" | "weekdays">>,
start: Date,
end: Date
): number {
if (end.getTime() <= start.getTime()) {
return 0;
}
let total = 0;
blisters.forEach((blister) => {
forEachScheduledOccurrenceInRange(blister, start.getTime(), end.getTime() - 1, () => {
total += blister.usage;
});
});
return Number(total.toFixed(2));
}
@@ -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 };
}
@@ -0,0 +1,109 @@
import { getFooterPlain, getTranslations, type Language, t } from "../../i18n/translations.js";
export type StockReminderItem = {
name: string;
medsLeft: number;
daysLeft: number | null;
depletionDate: string | null;
isCritical?: boolean;
};
export type PrescriptionReminderItem = {
name: string;
remainingRefills: number;
};
function splitStockItems(items: StockReminderItem[]): {
emptyItems: StockReminderItem[];
criticalItems: StockReminderItem[];
lowItems: StockReminderItem[];
} {
const emptyItems = items.filter((item) => item.medsLeft <= 0);
const criticalItems = items.filter((item) => item.medsLeft > 0 && item.isCritical !== false);
const lowItems = items.filter((item) => item.medsLeft > 0 && item.isCritical === false);
return { emptyItems, criticalItems, lowItems };
}
export function buildStockReminderPushNotification(
items: StockReminderItem[],
language: Language
): { title: string; message: string } {
const tr = getTranslations(language);
const { emptyItems, criticalItems, lowItems } = splitStockItems(items);
const titleParts: string[] = [];
if (emptyItems.length > 0) titleParts.push(`🚨 ${emptyItems.length} ${tr.push.empty}`);
if (criticalItems.length > 0) titleParts.push(`🚨 ${criticalItems.length} ${tr.push.critical}`);
if (lowItems.length > 0) titleParts.push(`⚠️ ${lowItems.length} ${tr.push.lowStock}`);
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.push.reorderNow}`;
const messageParts: string[] = [];
if (emptyItems.length > 0) {
messageParts.push(`🚨 ${tr.push.emptySection}:`);
emptyItems.forEach((item) => messageParts.push(`${item.name}`));
}
if (criticalItems.length > 0) {
if (messageParts.length > 0) messageParts.push("");
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 })}`
)
);
}
if (lowItems.length > 0) {
if (messageParts.length > 0) messageParts.push("");
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 })}`
)
);
}
return {
title,
message: `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`,
};
}
export function buildPrescriptionReminderPushNotification(
items: PrescriptionReminderItem[],
language: Language
): { title: string; message: string } {
const tr = getTranslations(language);
const emptyItems = items.filter((item) => item.remainingRefills <= 0);
const lowItems = items.filter((item) => item.remainingRefills > 0);
const titleParts: string[] = [];
if (emptyItems.length > 0) {
titleParts.push(
`🚨 ${emptyItems.length} ${emptyItems.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}`
);
}
if (lowItems.length > 0) {
titleParts.push(
`🚨 ${lowItems.length} ${lowItems.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}`
);
}
const messageParts: string[] = [];
if (emptyItems.length > 0) {
messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`);
emptyItems.forEach((item) => messageParts.push(`${item.name}`));
}
if (lowItems.length > 0) {
if (messageParts.length > 0) messageParts.push("");
messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`);
lowItems.forEach((item) =>
messageParts.push(
`${item.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: item.remainingRefills })}`
)
);
}
return {
title: `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`,
message: `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`,
};
}
@@ -0,0 +1,137 @@
import nodemailer from "nodemailer";
import { sendShoutrrrNotification } from "../../routes/settings.js";
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.";
}
export type EmailDeliveryRequest = {
to: string;
subject: string;
text: string;
html: string;
from?: string;
};
export type EmailDeliveryResult = {
success: boolean;
error?: string;
messageId?: string;
smtpResponse?: string;
};
export function getSmtpConfig(): {
host?: string;
user?: string;
pass?: string;
port: number;
secure: boolean;
from?: string;
} {
const host = process.env.SMTP_HOST;
const user = process.env.SMTP_USER;
const pass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
const port = parseInt(process.env.SMTP_PORT ?? "587", 10);
const secure = process.env.SMTP_SECURE === "true";
const from = process.env.SMTP_FROM ?? user;
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) {
return { success: false, error: "SMTP not configured" };
}
try {
const transporter = createSmtpTransport(smtp);
if (!transporter) {
return { success: false, error: "SMTP not configured" };
}
const mailResult = await transporter.sendMail({
from: input.from ?? smtp.from,
to: input.to,
subject: input.subject,
text: input.text,
html: input.html,
});
const deliveryError = getDeliveryError(mailResult);
if (deliveryError) {
return { success: false, error: deliveryError };
}
return {
success: true,
messageId: mailResult.messageId,
smtpResponse: typeof mailResult.response === "string" ? mailResult.response : undefined,
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return { success: false, error: errorMessage };
}
}
export async function sendPushNotification(
url: string,
title: string,
message: string
): Promise<{ success: boolean; error?: string }> {
try {
const result = await sendShoutrrrNotification(url, title, message);
if (!result.success) {
return { success: false, error: result.error };
}
return { success: true };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return { success: false, error: errorMessage };
}
}
@@ -0,0 +1,20 @@
export {
buildPrescriptionReminderPushNotification,
buildStockReminderPushNotification,
type PrescriptionReminderItem,
type StockReminderItem,
} from "./builders.js";
export {
type EmailDeliveryRequest,
type EmailDeliveryResult,
getSmtpConfig,
sendEmailNotification,
sendPushNotification,
} from "./delivery.js";
export {
getReminderState,
loadReminderState,
saveReminderState,
updateReminderSentTime,
updateUserReminderSentTime,
} from "./state.js";
@@ -0,0 +1,93 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
import { eq } from "drizzle-orm";
import { db } from "../../db/client.js";
import { getDataDir } from "../../db/db-utils.js";
import { userSettings } from "../../db/schema.js";
import {
createDefaultReminderState,
getTodayInTimezone,
parseReminderState,
type ReminderState,
} from "../../utils/scheduler-utils.js";
const reminderStateFile = resolve(getDataDir(), "reminder-state.json");
export function loadReminderState(): ReminderState {
try {
if (existsSync(reminderStateFile)) {
return parseReminderState(readFileSync(reminderStateFile, "utf-8"));
}
} catch {
// ignore
}
return createDefaultReminderState();
}
export function saveReminderState(state: ReminderState): void {
writeFileSync(reminderStateFile, JSON.stringify(state, null, 2));
}
export function getReminderState(): ReminderState {
return loadReminderState();
}
export function updateReminderSentTime(
type: "stock" | "intake" | "prescription" = "stock",
channel: "email" | "push" | "both" = "email"
): void {
const state = loadReminderState();
const today = getTodayInTimezone();
saveReminderState({
...state,
lastAutoEmailSent: new Date().toISOString(),
lastAutoEmailDate: today,
lastNotificationType: type,
lastNotificationChannel: channel,
});
}
// Stock and intake reminders are tracked separately so neither overwrites the other.
export async function updateUserReminderSentTime(
userId: number,
type: "stock" | "intake" | "prescription" = "stock",
channel: "email" | "push" | "both" = "email",
medName?: string,
takenBy?: string
): Promise<void> {
const now = new Date().toISOString();
if (type === "stock") {
await db
.update(userSettings)
.set({
lastStockReminderSent: now,
lastStockReminderChannel: channel,
lastStockReminderMedNames: medName ?? null,
})
.where(eq(userSettings.userId, userId));
return;
}
if (type === "prescription") {
await db
.update(userSettings)
.set({
lastPrescriptionReminderSent: now,
lastPrescriptionReminderChannel: channel,
lastPrescriptionReminderMedNames: medName ?? null,
})
.where(eq(userSettings.userId, userId));
return;
}
await db
.update(userSettings)
.set({
lastAutoEmailSent: now,
lastNotificationType: type,
lastNotificationChannel: channel,
lastReminderMedName: medName ?? null,
lastReminderTakenBy: takenBy ?? null,
})
.where(eq(userSettings.userId, userId));
}
+57
View File
@@ -0,0 +1,57 @@
import { getPlannerUnitKind, isAmountBasedPackageType } from "../utils/package-profiles.js";
// Escape HTML to prevent XSS in email templates.
export function escapeHtml(text: string): string {
const htmlEscapes: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
return text.replace(/[&<>"']/g, (char) => htmlEscapes[char] || char);
}
type MailDeliveryInfo = {
accepted?: unknown;
rejected?: unknown;
response?: unknown;
};
function normalizeRecipients(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value
.map((entry) => (typeof entry === "string" ? entry : String(entry ?? "")))
.map((entry) => entry.trim())
.filter(Boolean);
}
export 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.";
}
export function isContainerPackage(packageType?: string): boolean {
return isAmountBasedPackageType(packageType);
}
export function getPlannerUnit(
packageType: string | undefined,
tr: { common: { units: string; ml: string; pills: string } }
): string {
const unitKind = getPlannerUnitKind(packageType);
if (unitKind === "units") return tr.common.units;
if (unitKind === "ml") return tr.common.ml;
return tr.common.pills;
}
File diff suppressed because it is too large Load Diff
+359
View File
@@ -0,0 +1,359 @@
import { eq } from "drizzle-orm";
import { db } from "../db/client.js";
import { userSettings } from "../db/schema.js";
import type { Language } from "../i18n/translations.js";
export type UserSettings = {
userId: number;
timezone?: string | null;
emailEnabled: boolean;
notificationEmail: string | null;
emailStockReminders: boolean;
emailIntakeReminders: boolean;
emailPrescriptionReminders: boolean;
shoutrrrEnabled: boolean;
shoutrrrUrl: string | null;
shoutrrrStockReminders: boolean;
shoutrrrIntakeReminders: boolean;
shoutrrrPrescriptionReminders: boolean;
reminderDaysBefore: number;
repeatDailyReminders: boolean;
skipRemindersForTakenDoses: boolean;
repeatRemindersEnabled: boolean;
reminderRepeatIntervalMinutes: number;
maxNaggingReminders: number;
lowStockDays: number;
normalStockDays: number;
highStockDays: number;
language: Language;
stockCalculationMode: "automatic" | "manual";
shareMedicationOverview: boolean;
upcomingTodayOnly: boolean;
shareScheduleTodayOnly: boolean;
swapDashboardMainSections: boolean;
lastAutoEmailSent: string | null;
lastNotificationType: string | null;
lastNotificationChannel: string | null;
lastReminderMedName: string | null;
lastReminderTakenBy: string | null;
lastStockReminderSent: string | null;
lastStockReminderChannel: string | null;
lastStockReminderMedNames: string | null;
lastPrescriptionReminderSent: string | null;
lastPrescriptionReminderChannel: string | null;
lastPrescriptionReminderMedNames: string | null;
};
export function classifyTestEmailFailure(error: unknown): { status: number; code: string; message: string } {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
const normalizedMessage = errorMessage.toLowerCase();
if (
normalizedMessage.includes("smtp rejected all recipients") ||
normalizedMessage.includes("all recipients were rejected") ||
normalizedMessage.includes("recipient address rejected") ||
normalizedMessage.includes("nullmx")
) {
return {
status: 400,
code: "EMAIL_RECIPIENT_REJECTED",
message: `Failed to send email: ${errorMessage}`,
};
}
if (errorMessage.includes("SMTP did not confirm accepted recipients")) {
return {
status: 502,
code: "SMTP_DELIVERY_UNCONFIRMED",
message: `Failed to send email: ${errorMessage}`,
};
}
return {
status: 500,
code: "TEST_EMAIL_FAILED",
message: `Failed to send email: ${errorMessage}`,
};
}
export function getNotificationProvider(url: string): string {
if (url.startsWith("discord://")) return "discord";
if (url.startsWith("telegram://")) return "telegram";
if (url.startsWith("gotify://")) return "gotify";
if (url.startsWith("pushover://")) return "pushover";
if (url.startsWith("ntfy://")) return "ntfy";
try {
const parsed = new URL(url);
return parsed.hostname || "https";
} catch {
return "unknown";
}
}
function envBool(key: string, defaultVal: boolean): boolean {
const val = process.env[key];
if (val === undefined) return defaultVal;
return val === "true" || val === "1";
}
function envInt(key: string, defaultVal: number): number {
const val = process.env[key];
if (val === undefined) return defaultVal;
const parsed = parseInt(val, 10);
return Number.isNaN(parsed) ? defaultVal : parsed;
}
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),
emailIntakeReminders: envBool("DEFAULT_EMAIL_INTAKE_REMINDERS", true),
emailPrescriptionReminders: envBool("DEFAULT_EMAIL_PRESCRIPTION_REMINDERS", true),
shoutrrrEnabled: envBool("DEFAULT_SHOUTRRR_ENABLED", false),
shoutrrrUrl: process.env.DEFAULT_SHOUTRRR_URL || null,
shoutrrrStockReminders: envBool("DEFAULT_SHOUTRRR_STOCK_REMINDERS", true),
shoutrrrIntakeReminders: envBool("DEFAULT_SHOUTRRR_INTAKE_REMINDERS", true),
shoutrrrPrescriptionReminders: envBool("DEFAULT_SHOUTRRR_PRESCRIPTION_REMINDERS", true),
reminderDaysBefore: envInt("REMINDER_DAYS_BEFORE", 7),
repeatDailyReminders: envBool("DEFAULT_REPEAT_DAILY_REMINDERS", false),
skipRemindersForTakenDoses: envBool("DEFAULT_SKIP_REMINDERS_FOR_TAKEN_DOSES", false),
repeatRemindersEnabled: envBool("DEFAULT_REPEAT_REMINDERS_ENABLED", false),
reminderRepeatIntervalMinutes: envInt("DEFAULT_REMINDER_REPEAT_INTERVAL_MINUTES", 30),
maxNaggingReminders: envInt("DEFAULT_MAX_NAGGING_REMINDERS", 5),
lowStockDays: envInt("DEFAULT_LOW_STOCK_DAYS", 30),
normalStockDays: envInt("DEFAULT_NORMAL_STOCK_DAYS", 90),
highStockDays: envInt("DEFAULT_HIGH_STOCK_DAYS", 180),
language: (process.env.DEFAULT_LANGUAGE as "en" | "de") || "en",
stockCalculationMode: (process.env.DEFAULT_STOCK_CALCULATION_MODE as "automatic" | "manual") || "automatic",
shareMedicationOverview: envBool("DEFAULT_SHARE_MEDICATION_OVERVIEW", false),
upcomingTodayOnly: envBool("DEFAULT_UPCOMING_TODAY_ONLY", false),
shareScheduleTodayOnly: envBool("DEFAULT_SHARE_SCHEDULE_TODAY_ONLY", false),
swapDashboardMainSections: false,
lastAutoEmailSent: null,
lastNotificationType: null,
lastNotificationChannel: null,
lastReminderMedName: null,
lastReminderTakenBy: null,
lastStockReminderSent: null,
lastStockReminderChannel: null,
lastStockReminderMedNames: null,
lastPrescriptionReminderSent: null,
lastPrescriptionReminderChannel: null,
lastPrescriptionReminderMedNames: null,
};
}
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();
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
return "Localhost URLs are not allowed";
}
const ipMatch = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
if (ipMatch) {
const [, a, b] = ipMatch.map(Number);
if (
a === 10 ||
a === 127 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254)
) {
return "Private IP addresses are not allowed";
}
}
if (
hostname.endsWith(".local") ||
hostname.endsWith(".internal") ||
hostname.endsWith(".lan") ||
hostname === "metadata.google.internal"
) {
return "Internal hostnames are not allowed";
}
return null;
}
export function sanitizeNotificationUrl(
urlStr: string
): { url: string; isNtfy: boolean; auth?: { user: string; pass: string } } | { error: string } {
try {
if (urlStr.startsWith("discord://")) {
const parsedDiscord = new URL(urlStr);
const webhookId = parsedDiscord.hostname;
const webhookToken = parsedDiscord.username;
if (!webhookId || !webhookToken) {
return { error: "Invalid Discord URL format" };
}
if (!/^\d+$/.test(webhookId)) {
return { error: "Invalid Discord webhook ID" };
}
if (!/^[A-Za-z0-9._-]+$/.test(webhookToken)) {
return { error: "Invalid Discord webhook token" };
}
const discordWebhookUrl = `https://discord.com/api/webhooks/${webhookId}/${webhookToken}`;
return { url: discordWebhookUrl, isNtfy: false };
}
const isNtfy = urlStr.startsWith("ntfy://");
const normalizedUrl = isNtfy ? urlStr.replace("ntfy://", "https://") : urlStr;
const parsed = new URL(normalizedUrl);
if (!["http:", "https:"].includes(parsed.protocol)) {
return { error: "Only HTTP/HTTPS protocols are allowed" };
}
const hostValidationError = validateNotificationHostname(parsed.hostname);
if (hostValidationError) {
return { error: hostValidationError };
}
const reconstructedUrl = `${parsed.protocol}//${parsed.host}${parsed.pathname}${parsed.search}`;
const auth =
isNtfy && parsed.username && parsed.password ? { user: parsed.username, pass: parsed.password } : undefined;
return { url: reconstructedUrl, isNtfy, auth };
} catch {
return { error: "Invalid URL format" };
}
}
async function getOrCreateUserSettings(userId: number) {
let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
if (!settings) {
[settings] = await db
.insert(userSettings)
.values({
userId,
...getDefaultSettings(),
})
.returning();
}
return settings;
}
export async function loadUserSettingsFromDb(userId: number): Promise<UserSettings> {
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,
emailIntakeReminders: settings.emailIntakeReminders,
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
shoutrrrEnabled: settings.shoutrrrEnabled,
shoutrrrUrl: settings.shoutrrrUrl,
shoutrrrStockReminders: settings.shoutrrrStockReminders,
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders,
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays,
language: settings.language as Language,
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
shareMedicationOverview: settings.shareMedicationOverview ?? false,
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
lastAutoEmailSent: settings.lastAutoEmailSent,
lastNotificationType: settings.lastNotificationType,
lastNotificationChannel: settings.lastNotificationChannel,
lastReminderMedName: settings.lastReminderMedName ?? null,
lastReminderTakenBy: settings.lastReminderTakenBy ?? null,
lastStockReminderSent: settings.lastStockReminderSent ?? null,
lastStockReminderChannel: settings.lastStockReminderChannel ?? null,
lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null,
lastPrescriptionReminderSent: settings.lastPrescriptionReminderSent ?? null,
lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null,
lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null,
};
}
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,
emailIntakeReminders: settings.emailIntakeReminders,
emailPrescriptionReminders: settings.emailPrescriptionReminders ?? true,
shoutrrrEnabled: settings.shoutrrrEnabled,
shoutrrrUrl: settings.shoutrrrUrl,
shoutrrrStockReminders: settings.shoutrrrStockReminders,
shoutrrrIntakeReminders: settings.shoutrrrIntakeReminders,
shoutrrrPrescriptionReminders: settings.shoutrrrPrescriptionReminders ?? true,
reminderDaysBefore: settings.reminderDaysBefore,
repeatDailyReminders: settings.repeatDailyReminders,
skipRemindersForTakenDoses: settings.skipRemindersForTakenDoses ?? false,
repeatRemindersEnabled: settings.repeatRemindersEnabled ?? false,
reminderRepeatIntervalMinutes: settings.reminderRepeatIntervalMinutes ?? 30,
maxNaggingReminders: settings.maxNaggingReminders ?? 5,
lowStockDays: settings.lowStockDays,
normalStockDays: settings.normalStockDays,
highStockDays: settings.highStockDays,
language: settings.language as Language,
stockCalculationMode: (settings.stockCalculationMode as "automatic" | "manual") ?? "automatic",
shareMedicationOverview: settings.shareMedicationOverview ?? false,
upcomingTodayOnly: settings.upcomingTodayOnly ?? false,
shareScheduleTodayOnly: settings.shareScheduleTodayOnly ?? false,
swapDashboardMainSections: settings.swapDashboardMainSections ?? false,
lastAutoEmailSent: settings.lastAutoEmailSent,
lastNotificationType: settings.lastNotificationType,
lastNotificationChannel: settings.lastNotificationChannel,
lastReminderMedName: settings.lastReminderMedName ?? null,
lastReminderTakenBy: settings.lastReminderTakenBy ?? null,
lastStockReminderSent: settings.lastStockReminderSent ?? null,
lastStockReminderChannel: settings.lastStockReminderChannel ?? null,
lastStockReminderMedNames: settings.lastStockReminderMedNames ?? null,
lastPrescriptionReminderSent: settings.lastPrescriptionReminderSent ?? null,
lastPrescriptionReminderChannel: settings.lastPrescriptionReminderChannel ?? null,
lastPrescriptionReminderMedNames: settings.lastPrescriptionReminderMedNames ?? null,
}));
}
+96 -15
View File
@@ -3,11 +3,12 @@
*/
import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt";
import sensible from "@fastify/sensible";
import type { Client } from "@libsql/client";
import Fastify, { type FastifyInstance } from "fastify";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { 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(() => {
@@ -28,7 +29,7 @@ vi.mock("../db/client.js", () => ({
vi.mock("../plugins/env.js", () => ({
env: {
AUTH_ENABLED: true,
LOCAL_AUTH_ENABLED: true,
FORM_LOGIN_ENABLED: true,
REGISTRATION_ENABLED: true,
OIDC_ENABLED: false,
NODE_ENV: "test",
@@ -97,11 +98,11 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
beforeAll(async () => {
await createSchema(testClient);
app = Fastify({ logger: false });
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(sensible);
await app.register(cookie, { secret: "test-cookie-secret-12345" });
await app.register(jwt, {
await app.register(jwtPlugin, {
secret: "test-jwt-secret-12345",
cookie: { cookieName: "access_token", signed: false },
});
@@ -144,7 +145,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
const data = response.json();
expect(data.authEnabled).toBe(true);
expect(data.registrationEnabled).toBe(true);
expect(data.localAuthEnabled).toBe(true);
expect(data.formLoginEnabled).toBe(true);
});
});
@@ -228,7 +229,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
});
expect(response.statusCode).toBe(400);
expect(response.json().code).toBe("VALIDATION_ERROR");
expect(response.json().code).toBe("FST_ERR_VALIDATION");
});
it("should reject short username", async () => {
@@ -241,10 +242,61 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
},
});
expect(response.statusCode).toBe(400);
expect(response.json().code).toBe("FST_ERR_VALIDATION");
});
it("should register with trimmed username when input has whitespace", async () => {
const response = await app.inject({
method: "POST",
url: "/auth/register",
payload: {
username: " trimuser ",
password: "TestPassword123",
},
});
expect(response.statusCode).toBe(201);
expect(response.json().user.username).toBe("trimuser");
});
it("should reject whitespace-only username on registration", async () => {
const response = await app.inject({
method: "POST",
url: "/auth/register",
payload: {
username: " ",
password: "TestPassword123",
},
});
expect(response.statusCode).toBe(400);
expect(response.json().code).toBe("VALIDATION_ERROR");
});
it("should reject duplicate username even with surrounding whitespace", async () => {
await app.inject({
method: "POST",
url: "/auth/register",
payload: {
username: "spacedupe",
password: "TestPassword123",
},
});
const response = await app.inject({
method: "POST",
url: "/auth/register",
payload: {
username: " spacedupe ",
password: "AnotherPassword123",
},
});
expect(response.statusCode).toBe(409);
expect(response.json().code).toBe("USERNAME_EXISTS");
});
it("should reject invalid username characters", async () => {
const response = await app.inject({
method: "POST",
@@ -294,8 +346,8 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
// Should set cookies
const cookies = response.cookies;
expect(cookies.find((c: any) => c.name === "access_token")).toBeDefined();
expect(cookies.find((c: any) => c.name === "refresh_token")).toBeDefined();
expect(cookies.find((c: { name: string }) => c.name === "access_token")).toBeDefined();
expect(cookies.find((c: { name: string }) => c.name === "refresh_token")).toBeDefined();
});
it("should login case-insensitively with different username casing", async () => {
@@ -341,6 +393,35 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
expect(response.json().code).toBe("INVALID_CREDENTIALS");
});
it("should login successfully when username has leading/trailing whitespace", async () => {
const response = await app.inject({
method: "POST",
url: "/auth/login",
payload: {
username: " loginuser ",
password: "TestPassword123",
},
});
expect(response.statusCode).toBe(200);
expect(response.json().ok).toBe(true);
expect(response.json().user.username).toBe("loginuser");
});
it("should reject whitespace-only username on login", async () => {
const response = await app.inject({
method: "POST",
url: "/auth/login",
payload: {
username: " ",
password: "TestPassword123",
},
});
expect(response.statusCode).toBe(400);
expect(response.json().code).toBe("VALIDATION_ERROR");
});
it("should support rememberMe option", async () => {
const response = await app.inject({
method: "POST",
@@ -393,7 +474,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
},
});
const refreshToken = login.cookies.find((c: any) => c.name === "refresh_token");
const refreshToken = login.cookies.find((c: { name: string }) => c.name === "refresh_token");
const response = await app.inject({
method: "POST",
@@ -456,7 +537,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
},
});
const refreshToken = login.cookies.find((c: any) => c.name === "refresh_token");
const refreshToken = login.cookies.find((c: { name: string }) => c.name === "refresh_token");
const response = await app.inject({
method: "POST",
@@ -506,7 +587,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
},
});
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
const response = await app.inject({
method: "GET",
@@ -604,7 +685,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
},
});
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
const response = await app.inject({
method: "PUT",
@@ -653,7 +734,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
},
});
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
const response = await app.inject({
method: "PUT",
@@ -689,7 +770,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
},
});
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
const response = await app.inject({
method: "PUT",
@@ -742,7 +823,7 @@ describe("Auth Routes (AUTH_ENABLED=true)", () => {
},
});
const accessToken = login.cookies.find((c: any) => c.name === "access_token");
const accessToken = login.cookies.find((c: { name: string }) => c.name === "access_token");
// Delete account
const response = await app.inject({
@@ -0,0 +1,486 @@
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, mockedEnv } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
const { drizzle } = require("drizzle-orm/libsql");
const client = createClient({ url: ":memory:" });
const db = drizzle(client);
return {
testClient: client,
testDb: db,
mockedEnv: {
AUTH_ENABLED: true,
REGISTRATION_ENABLED: true,
FORM_LOGIN_ENABLED: true,
OIDC_ENABLED: false,
OIDC_PROVIDER_NAME: "SSO",
NODE_ENV: "test",
LOG_LEVEL: "silent",
PORT: 3000,
CORS_ORIGINS: "*",
JWT_SECRET: "test-jwt-secret",
REFRESH_SECRET: "test-refresh-secret",
COOKIE_SECRET: "test-cookie-secret",
ACCESS_TOKEN_TTL_MINUTES: 15,
REFRESH_TOKEN_TTL_DAYS: 7,
OPENAPI_DOCS_ENABLED: false,
},
};
});
vi.mock("../db/client.js", () => ({
db: testDb,
migrationsReady: Promise.resolve(),
}));
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
const { medicationRoutes } = await import("../routes/medications.js");
const { doseRoutes } = await import("../routes/doses.js");
const { refillRoutes } = await import("../routes/refills.js");
const { shareRoutes } = await import("../routes/share.js");
const { reportRoutes } = await import("../routes/report.js");
const { exportRoutes } = await import("../routes/export.js");
const { hashApiKeyToken } = await import("../plugins/auth.js");
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
async function clearTables() {
await testClient.execute("DELETE FROM refill_history");
await testClient.execute("DELETE FROM dose_tracking");
await testClient.execute("DELETE FROM share_tokens");
await testClient.execute("DELETE FROM user_settings");
await testClient.execute("DELETE FROM medications");
await testClient.execute("DELETE FROM api_keys");
await testClient.execute("DELETE FROM refresh_tokens");
await testClient.execute("DELETE FROM users");
}
async function createUser(username: string) {
const result = await testClient.execute({
sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id",
args: [username],
});
return Number(result.rows[0].id);
}
async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
const token = await app.jwt.sign({ sub: userId, username });
return `access_token=${token}`;
}
async function insertApiKey(options: {
userId: number;
token: string;
scope?: "read" | "write";
isActive?: boolean;
expiresAt?: Date | null;
}) {
const expiresAtValue = options.expiresAt ? Math.floor(options.expiresAt.getTime() / 1000) : null;
await testClient.execute({
sql: `INSERT INTO api_keys (user_id, name, key_hash, token_prefix, scope, is_active, expires_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
args: [
options.userId,
"Seeded Key",
hashApiKeyToken(options.token),
`${options.token.slice(0, 12)}...`,
options.scope ?? "write",
options.isActive === false ? 0 : 1,
expiresAtValue,
],
});
}
async function seedMedication(options: {
userId: number;
name: string;
takenBy?: string[];
packCount?: number;
looseTablets?: number;
start?: string;
}) {
const start = options.start ?? "2026-01-01T08:00:00.000Z";
const takenBy = options.takenBy ?? ["Daniel"];
const result = await testClient.execute({
sql: `INSERT INTO medications (
user_id, name, generic_name, taken_by_json, medication_form, package_type,
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
usage_json, every_json, start_json, intakes_json,
stock_adjustment, intake_reminders_enabled
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
args: [
options.userId,
options.name,
`${options.name} Generic`,
JSON.stringify(takenBy),
"tablet",
"blister",
options.packCount ?? 1,
1,
10,
options.looseTablets ?? 0,
JSON.stringify([1]),
JSON.stringify([1]),
JSON.stringify([start]),
JSON.stringify([
{
usage: 1,
every: 1,
start,
takenBy: takenBy[0] ?? null,
intakeRemindersEnabled: true,
},
]),
0,
1,
],
});
return Number(result.rows[0].id);
}
async function seedDose(options: { userId: number; doseId: string; dismissed?: boolean }) {
await testClient.execute({
sql: "INSERT INTO dose_tracking (user_id, dose_id, dismissed) VALUES (?, ?, ?)",
args: [options.userId, options.doseId, options.dismissed ? 1 : 0],
});
}
async function seedRefill(options: {
userId: number;
medicationId: number;
packsAdded?: number;
loosePillsAdded?: number;
}) {
await testClient.execute({
sql: `INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription)
VALUES (?, ?, ?, ?, 0)`,
args: [options.medicationId, options.userId, options.packsAdded ?? 1, options.loosePillsAdded ?? 0],
});
}
function buildMedicationPayload(name: string) {
return {
name,
genericName: `${name} Generic`,
takenBy: ["Daniel"],
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
blisters: [{ usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z" }],
};
}
function buildImportPayload() {
return {
version: "1.3",
exportedAt: new Date().toISOString(),
includeSensitiveData: false,
medications: [],
doseHistory: [],
refillHistory: [],
settings: {
emailEnabled: false,
emailStockReminders: true,
emailIntakeReminders: true,
emailPrescriptionReminders: true,
shoutrrrStockReminders: true,
shoutrrrIntakeReminders: true,
shoutrrrPrescriptionReminders: true,
reminderDaysBefore: 7,
repeatDailyReminders: false,
skipRemindersForTakenDoses: false,
repeatRemindersEnabled: false,
reminderRepeatIntervalMinutes: 30,
maxNaggingReminders: 5,
lowStockDays: 30,
normalStockDays: 90,
highStockDays: 180,
language: "en",
stockCalculationMode: "automatic",
shareStockStatus: true,
},
shareLinks: [],
};
}
describe("Real business route authz contracts", () => {
let app: FastifyInstance;
beforeAll(async () => {
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(sensible);
await app.register(cookie, { secret: "test-cookie-secret" });
await app.register(jwtPlugin, {
secret: "test-jwt-secret",
cookie: { cookieName: "access_token", signed: false },
});
await app.register(medicationRoutes);
await app.register(doseRoutes);
await app.register(refillRoutes);
await app.register(shareRoutes);
await app.register(reportRoutes);
await app.register(exportRoutes);
await app.ready();
});
afterAll(async () => {
await app.close();
testClient.close();
});
beforeEach(async () => {
vi.clearAllMocks();
await clearTables();
});
it("rejects protected business endpoints without authentication", async () => {
const endpoints: Array<{
method: "GET" | "POST";
url: string;
payload?: Record<string, unknown>;
}> = [
{ method: "GET", url: "/medications" },
{ method: "GET", url: "/doses/taken" },
{ method: "POST", url: "/share", payload: { takenBy: "Daniel", scheduleDays: 7 } },
{ method: "GET", url: "/export" },
{ method: "POST", url: "/medications/report-data", payload: { medicationIds: [1] } },
{ method: "POST", url: "/medications/1/refill", payload: { packsAdded: 1, loosePillsAdded: 0 } },
];
for (const endpoint of endpoints) {
const response = await app.inject({ method: endpoint.method, url: endpoint.url, payload: endpoint.payload });
expect(response.statusCode, `${endpoint.method} ${endpoint.url}`).toBe(401);
expect(response.json()).toMatchObject({ code: "AUTH_REQUIRED" });
}
});
it("scopes medication listing and export output to the authenticated user", async () => {
const ownerId = await createUser("owner-medications");
const otherId = await createUser("other-medications");
const ownerCookie = await buildSessionCookie(app, ownerId, "owner-medications");
await seedMedication({ userId: ownerId, name: "Owner Only Med" });
await seedMedication({ userId: otherId, name: "Other User Med" });
const listResponse = await app.inject({
method: "GET",
url: "/medications",
headers: { cookie: ownerCookie },
});
expect(listResponse.statusCode).toBe(200);
expect(listResponse.body).toContain("Owner Only Med");
expect(listResponse.body).not.toContain("Other User Med");
const exportResponse = await app.inject({
method: "GET",
url: "/export",
headers: { cookie: ownerCookie },
});
expect(exportResponse.statusCode).toBe(200);
expect(exportResponse.body).toContain("Owner Only Med");
expect(exportResponse.body).not.toContain("Other User Med");
});
it("returns 404 when a user updates or deletes another user's medication", async () => {
const ownerId = await createUser("owner-update");
const otherId = await createUser("other-update");
const otherCookie = await buildSessionCookie(app, otherId, "other-update");
const medicationId = await seedMedication({ userId: ownerId, name: "Protected Medication" });
const updateResponse = await app.inject({
method: "PUT",
url: `/medications/${medicationId}`,
headers: { cookie: otherCookie },
payload: buildMedicationPayload("Updated By Stranger"),
});
expect(updateResponse.statusCode).toBe(404);
const deleteResponse = await app.inject({
method: "DELETE",
url: `/medications/${medicationId}`,
headers: { cookie: otherCookie },
});
expect(deleteResponse.statusCode).toBe(404);
const dbState = await testClient.execute({
sql: "SELECT name FROM medications WHERE id = ?",
args: [medicationId],
});
expect(dbState.rows).toEqual([expect.objectContaining({ name: "Protected Medication" })]);
});
it("scopes dose reads and writes to the authenticated user", async () => {
const ownerId = await createUser("owner-dose");
const otherId = await createUser("other-dose");
const ownerCookie = await buildSessionCookie(app, ownerId, "owner-dose");
const otherCookie = await buildSessionCookie(app, otherId, "other-dose");
await seedDose({ userId: ownerId, doseId: "101-0-1760000000000" });
await seedDose({ userId: otherId, doseId: "202-0-1760000000000" });
const listResponse = await app.inject({
method: "GET",
url: "/doses/taken",
headers: { cookie: ownerCookie },
});
expect(listResponse.statusCode).toBe(200);
expect(listResponse.body).toContain("101-0-1760000000000");
expect(listResponse.body).not.toContain("202-0-1760000000000");
const deleteResponse = await app.inject({
method: "DELETE",
url: "/doses/taken/101-0-1760000000000",
headers: { cookie: otherCookie },
});
expect(deleteResponse.statusCode).toBe(200);
const ownerDose = await testClient.execute({
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [ownerId, "101-0-1760000000000"],
});
expect(Number(ownerDose.rows[0].count)).toBe(1);
});
it("enforces medication ownership on refill history and report generation", async () => {
const ownerId = await createUser("owner-refill");
const otherId = await createUser("other-refill");
const otherCookie = await buildSessionCookie(app, otherId, "other-refill");
const medicationId = await seedMedication({ userId: ownerId, name: "Owner Refill Med", packCount: 2 });
await seedRefill({ userId: ownerId, medicationId });
const refillListResponse = await app.inject({
method: "GET",
url: `/medications/${medicationId}/refills`,
headers: { cookie: otherCookie },
});
expect(refillListResponse.statusCode).toBe(404);
const refillMutationResponse = await app.inject({
method: "POST",
url: `/medications/${medicationId}/refill`,
headers: { cookie: otherCookie },
payload: { packsAdded: 1, loosePillsAdded: 0 },
});
expect(refillMutationResponse.statusCode).toBe(404);
const reportResponse = await app.inject({
method: "POST",
url: "/medications/report-data",
headers: { cookie: otherCookie },
payload: { medicationIds: [medicationId] },
});
expect(reportResponse.statusCode).toBe(403);
expect(reportResponse.json()).toMatchObject({ error: "Access denied to medication" });
});
it("scopes share people to the authenticated user's medications", async () => {
const ownerId = await createUser("owner-share");
const otherId = await createUser("other-share");
const ownerCookie = await buildSessionCookie(app, ownerId, "owner-share");
await seedMedication({ userId: ownerId, name: "Daniel Med", takenBy: ["Daniel"] });
await seedMedication({ userId: otherId, name: "Anna Med", takenBy: ["Anna"] });
const response = await app.inject({
method: "GET",
url: "/share/people",
headers: { cookie: ownerCookie },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ people: ["Daniel"] });
});
it("rejects mutation routes for read-only API keys across business endpoints", async () => {
const userId = await createUser("readonly-business-key");
const medicationId = await seedMedication({ userId, name: "Readonly Med" });
const apiToken = "ma_readonly_business_routes_123456789";
await insertApiKey({ userId, token: apiToken, scope: "read" });
const responses = await Promise.all([
app.inject({
method: "POST",
url: "/medications",
headers: { authorization: `Bearer ${apiToken}` },
payload: buildMedicationPayload("Blocked Create"),
}),
app.inject({
method: "POST",
url: "/doses/taken",
headers: { authorization: `Bearer ${apiToken}` },
payload: { doseId: "1-0-1760000000000" },
}),
app.inject({
method: "POST",
url: `/medications/${medicationId}/refill`,
headers: { authorization: `Bearer ${apiToken}` },
payload: { packsAdded: 1, loosePillsAdded: 0 },
}),
app.inject({
method: "POST",
url: "/share",
headers: { authorization: `Bearer ${apiToken}` },
payload: { takenBy: "Daniel", scheduleDays: 7 },
}),
app.inject({
method: "POST",
url: "/import",
headers: { authorization: `Bearer ${apiToken}` },
payload: buildImportPayload(),
}),
]);
for (const response of responses) {
expect(response.statusCode).toBe(403);
expect(response.json()).toMatchObject({ code: "API_KEY_SCOPE_FORBIDDEN" });
}
});
it("allows read-only API keys to use read endpoints while keeping data scoped to the key owner", async () => {
const userId = await createUser("readonly-export-user");
const otherId = await createUser("readonly-export-other");
await seedMedication({ userId, name: "Readable Owner Med" });
await seedMedication({ userId: otherId, name: "Unreadable Other Med" });
const apiToken = "ma_readonly_export_access_123456789";
await insertApiKey({ userId, token: apiToken, scope: "read" });
const response = await app.inject({
method: "GET",
url: "/export",
headers: { authorization: `Bearer ${apiToken}` },
});
expect(response.statusCode).toBe(200);
expect(response.body).toContain("Readable Owner Med");
expect(response.body).not.toContain("Unreadable Other Med");
});
});
+3 -3
View File
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url";
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
// Import utility functions from db-utils (no side effects, unlike client.ts which initializes the DB)
import {
@@ -248,10 +248,10 @@ describe("Database Client Utilities", () => {
expect(result.success).toBe(true);
});
it("should create .write-test file", () => {
it("should not leave .write-test residue", () => {
const result = ensureDataDirectory(testDir);
expect(result.success).toBe(true);
expect(existsSync(resolve(testDir, ".write-test"))).toBe(true);
expect(existsSync(resolve(testDir, ".write-test"))).toBe(false);
});
it("should return error for invalid path", () => {
+131
View File
@@ -0,0 +1,131 @@
import { afterEach, describe, expect, it, vi } from "vitest";
type ClientTestOptions = {
dirWritable?: boolean;
authEnabled?: boolean;
};
async function loadDbClientModule(options: ClientTestOptions = {}) {
const { dirWritable = true, authEnabled = false } = options;
vi.resetModules();
vi.restoreAllMocks();
process.env.AUTH_ENABLED = authEnabled ? "true" : "false";
process.env.DOTENV_PATH = "/tmp/medassist-nonexistent.env";
const existsSync = vi.fn().mockReturnValue(false);
const statSync = vi.fn().mockReturnValue({ mode: 0o40755, uid: 1000, gid: 1000 });
vi.doMock("node:fs", () => ({ existsSync, statSync }));
const dotenvConfig = vi.fn();
vi.doMock("dotenv", () => ({ default: { config: dotenvConfig } }));
const createClient = vi.fn().mockReturnValue({ execute: vi.fn() });
vi.doMock("@libsql/client", () => ({ createClient }));
const drizzle = vi.fn().mockReturnValue({ __db: true });
vi.doMock("drizzle-orm/libsql", () => ({ drizzle }));
const ensureDataDirectory = vi
.fn()
.mockReturnValue(dirWritable ? { success: true } : { success: false, error: "permission denied" });
const getDbPaths = vi.fn().mockReturnValue({
dataDir: "/tmp/medassist-data",
dbPath: "/tmp/medassist-data/medassist-ng.db",
url: "file:/tmp/medassist-data/medassist-ng.db",
});
const runDrizzleMigrations = vi.fn().mockResolvedValue({ success: true });
const runAlterMigrations = vi.fn().mockResolvedValue({ errors: [] });
const repairTrailingHyphenDoseIds = vi.fn().mockResolvedValue({ repaired: 0, errors: [] });
const repairOrphanedDoseIds = vi.fn().mockResolvedValue({ repaired: 0, errors: [] });
const ensureDefaultUser = vi.fn().mockResolvedValue(false);
vi.doMock("../db/path-utils.js", () => ({
getDataDir: vi.fn(),
buildDbUrl: vi.fn(),
ensureDataDirectory,
getDbPaths,
}));
vi.doMock("../db/migration-utils.js", () => ({
runDrizzleMigrations,
runAlterMigrations,
ensureDefaultUser,
}));
vi.doMock("../db/repair-utils.js", () => ({
repairTrailingHyphenDoseIds,
repairOrphanedDoseIds,
}));
const log = {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
vi.doMock("../utils/logger.js", () => ({ log }));
const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as never);
const modulePromise = import("../db/client.js");
return {
modulePromise,
mocks: {
existsSync,
statSync,
dotenvConfig,
createClient,
drizzle,
ensureDataDirectory,
getDbPaths,
runDrizzleMigrations,
runAlterMigrations,
repairTrailingHyphenDoseIds,
repairOrphanedDoseIds,
ensureDefaultUser,
log,
exitSpy,
},
};
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("db/client bootstrap", () => {
it("initializes db and runs migrations when directory is writable", async () => {
const { modulePromise, mocks } = await loadDbClientModule({ dirWritable: true, authEnabled: false });
const mod = await modulePromise;
expect(mod.db).toBeTruthy();
expect(mod.migrationsReady).toBeInstanceOf(Promise);
await mod.migrationsReady;
expect(mocks.ensureDataDirectory).toHaveBeenCalledWith("/tmp/medassist-data");
expect(mocks.createClient).toHaveBeenCalledWith({ url: "file:/tmp/medassist-data/medassist-ng.db" });
expect(mocks.runDrizzleMigrations).toHaveBeenCalledTimes(1);
expect(mocks.runAlterMigrations).toHaveBeenCalledTimes(1);
expect(mocks.repairTrailingHyphenDoseIds).toHaveBeenCalledTimes(1);
expect(mocks.repairOrphanedDoseIds).toHaveBeenCalledTimes(1);
expect(mocks.ensureDefaultUser).toHaveBeenCalledWith(expect.anything(), false);
});
it("passes auth-enabled flag to ensureDefaultUser", async () => {
const { modulePromise, mocks } = await loadDbClientModule({ dirWritable: true, authEnabled: true });
const mod = await modulePromise;
await mod.migrationsReady;
expect(mocks.ensureDefaultUser).toHaveBeenCalledWith(expect.anything(), true);
});
it("exits when data directory is not writable", async () => {
const { modulePromise } = await loadDbClientModule({ dirWritable: false });
await expect(modulePromise).rejects.toThrow("process.exit:1");
});
});
@@ -0,0 +1,106 @@
import { describe, expect, it, vi } from "vitest";
import {
calculateUsageInRange,
normalizeDateTime,
parseIntakesWithUnits,
parseRawIntakeUnits,
} from "../services/medications-service.js";
import { escapeHtml, getDeliveryError, getPlannerUnit, isContainerPackage } from "../services/planner-service.js";
describe("medications-service decomposition regression", () => {
it("preserves intake unit parsing from unified intakes_json", () => {
const intakesJson = JSON.stringify([
{ usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z", intakeUnit: "ml" },
{ usage: 2, every: 1, start: "2026-01-01T20:00:00.000Z", intakeUnit: "bogus" },
]);
expect(parseRawIntakeUnits(intakesJson)).toEqual(["ml", null]);
const parsed = parseIntakesWithUnits(
intakesJson,
{
usageJson: "[1,2]",
everyJson: "[1,1]",
startJson: '["2026-01-01T08:00:00.000Z","2026-01-01T20:00:00.000Z"]',
},
false
);
expect(parsed[0]?.intakeUnit).toBe("ml");
expect(parsed[1]?.intakeUnit).toBeNull();
});
it("normalizes date-time values and keeps invalid input null-safe", () => {
expect(normalizeDateTime("2026-01-01T00:00:00.000Z")).toBe("2026-01-01T00:00:00.000Z");
expect(normalizeDateTime(1_767_225_600)).toBe("2026-01-01T00:00:00.000Z");
expect(normalizeDateTime("not-a-date")).toBeNull();
expect(normalizeDateTime(undefined)).toBeNull();
});
it("calculates range usage with split-safe helper behavior", () => {
const usage = calculateUsageInRange(
[
{ usage: 1, every: 1, start: "2026-01-01T08:00:00.000Z", scheduleMode: "interval", weekdays: [] },
{ usage: 0.5, every: 1, start: "2026-01-01T20:00:00.000Z", scheduleMode: "interval", weekdays: [] },
],
new Date("2026-01-01T00:00:00.000Z"),
new Date("2026-01-02T00:00:00.000Z")
);
expect(usage).toBe(1.5);
});
});
describe("planner-service decomposition regression", () => {
it("keeps HTML escaping and SMTP delivery error parsing stable", () => {
expect(escapeHtml(`<script>alert('x')</script>`)).toBe("&lt;script&gt;alert(&#39;x&#39;)&lt;/script&gt;");
expect(getDeliveryError({ accepted: ["ok@example.com"], rejected: [] })).toBeNull();
expect(getDeliveryError({ accepted: [], rejected: ["bad@example.com"] })).toContain("SMTP rejected all recipients");
expect(getDeliveryError({ accepted: [], rejected: [], response: "550 relay denied" })).toContain(
"550 relay denied"
);
});
it("maps package type to expected planner units after service extraction", () => {
const tr = { common: { units: "units", ml: "ml", pills: "pills" } };
expect(isContainerPackage("bottle")).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("blister", tr)).toBe("pills");
});
});
describe("settings-service decomposition regression", () => {
it("keeps notification URL and classification helpers stable", async () => {
vi.resetModules();
vi.doMock("../db/client.js", () => ({ db: {} }));
vi.doMock("../db/schema.js", () => ({ userSettings: { userId: "userId" } }));
const { classifyTestEmailFailure, getNotificationProvider, sanitizeNotificationUrl, validateNotificationHostname } =
await import("../services/settings-service.js");
expect(classifyTestEmailFailure(new Error("SMTP rejected all recipients: person@example.com"))).toMatchObject({
status: 400,
code: "EMAIL_RECIPIENT_REJECTED",
});
expect(classifyTestEmailFailure(new Error("SMTP did not confirm accepted recipients."))).toMatchObject({
status: 502,
code: "SMTP_DELIVERY_UNCONFIRMED",
});
expect(getNotificationProvider("telegram://token@chat-id")).toBe("telegram");
expect(getNotificationProvider("https://hooks.slack.com/services/a/b/c")).toBe("hooks.slack.com");
expect(validateNotificationHostname("127.0.0.1")).toContain("not allowed");
expect(validateNotificationHostname("example.com")).toBeNull();
expect(sanitizeNotificationUrl("discord://abc@not-a-number")).toEqual({ error: "Invalid Discord webhook ID" });
expect(sanitizeNotificationUrl("ntfy://user:pass@ntfy.sh/topic")).toMatchObject({
url: "https://ntfy.sh/topic",
isNtfy: true,
auth: { user: "user", pass: "pass" },
});
});
});
+328 -386
View File
@@ -1,487 +1,412 @@
/**
* Tests for /doses/taken API endpoints.
* Tests marking doses as taken, listing taken doses, and unmarking.
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest";
import { buildTestApp, clearTestData, closeTestApp, createTestUser, type TestContext } from "./setup.js";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import cookie from "@fastify/cookie";
import { 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";
// =============================================================================
// Route Registration
// Since we can't easily import routes that depend on the global db,
// we'll create simplified route handlers for testing the core logic.
// =============================================================================
const { testClient, testDb, mockedEnv } = vi.hoisted(() => {
const { createClient } = require("@libsql/client");
const { drizzle } = require("drizzle-orm/libsql");
const client = createClient({ url: ":memory:" });
const db = drizzle(client);
async function registerDoseRoutes(ctx: TestContext) {
const { app, client } = ctx;
return {
testClient: client,
testDb: db,
mockedEnv: {
AUTH_ENABLED: true,
REGISTRATION_ENABLED: true,
FORM_LOGIN_ENABLED: true,
OIDC_ENABLED: false,
OIDC_PROVIDER_NAME: "SSO",
NODE_ENV: "test",
LOG_LEVEL: "silent",
PORT: 3000,
CORS_ORIGINS: "*",
JWT_SECRET: "test-jwt-secret",
REFRESH_SECRET: "test-refresh-secret",
COOKIE_SECRET: "test-cookie-secret",
ACCESS_TOKEN_TTL_MINUTES: 15,
REFRESH_TOKEN_TTL_DAYS: 7,
OPENAPI_DOCS_ENABLED: false,
},
};
});
// GET /doses/taken - List all taken doses
app.get("/doses/taken", async (_request, _reply) => {
// In test mode, use user ID 1 (will be created in tests)
const userId = 1;
vi.mock("../db/client.js", () => ({
db: testDb,
migrationsReady: Promise.resolve(),
}));
const result = await client.execute({
sql: `SELECT dose_id, taken_at, marked_by FROM dose_tracking WHERE user_id = ?`,
args: [userId],
});
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
return {
doses: result.rows.map((d) => ({
doseId: d.dose_id,
takenAt: (d.taken_at as number) * 1000, // Convert to ms
markedBy: d.marked_by,
})),
};
const { doseRoutes } = await import("../routes/doses.js");
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const migrationsFolder = resolve(__dirname, "../../drizzle");
async function clearTables() {
await testClient.execute("DELETE FROM dose_tracking");
await testClient.execute("DELETE FROM share_tokens");
await testClient.execute("DELETE FROM api_keys");
await testClient.execute("DELETE FROM refresh_tokens");
await testClient.execute("DELETE FROM medications");
await testClient.execute("DELETE FROM user_settings");
await testClient.execute("DELETE FROM users");
}
async function createUser(username: string) {
const result = await testClient.execute({
sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id",
args: [username],
});
// POST /doses/taken - Mark a dose as taken
app.post<{ Body: { doseId: string } }>("/doses/taken", async (request, reply) => {
const userId = 1;
const { doseId } = request.body || {};
return Number(result.rows[0].id);
}
if (!doseId || typeof doseId !== "string" || doseId.length === 0) {
return reply.status(400).send({ error: "doseId is required" });
}
// Check if already marked
const existing = await client.execute({
sql: `SELECT id FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
args: [userId, doseId],
});
if (existing.rows.length > 0) {
return { success: true, message: "Already marked" };
}
// Insert new record
await client.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, NULL)`,
args: [userId, doseId],
});
return { success: true };
});
// DELETE /doses/taken/:doseId - Unmark a dose
app.delete<{ Params: { doseId: string } }>("/doses/taken/:doseId", async (request, _reply) => {
const userId = 1;
const { doseId } = request.params;
// Check if this dose was also dismissed
const existing = await client.execute({
sql: `SELECT id, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
args: [userId, doseId],
});
if (existing.rows.length > 0 && existing.rows[0].dismissed) {
// Already dismissed - keep the record as-is (don't delete)
// The dose stays dismissed, we just ignore the undo request
} else {
// Not dismissed - delete the record entirely
await client.execute({
sql: `DELETE FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
args: [userId, doseId],
});
}
return { success: true };
});
// POST /doses/dismiss - Dismiss missed doses without deducting stock
app.post<{ Body: { doseIds: string[] } }>("/doses/dismiss", async (request, reply) => {
const userId = 1;
const { doseIds } = request.body || {};
if (!doseIds || !Array.isArray(doseIds) || doseIds.length === 0) {
return reply.status(400).send({ error: "doseIds array is required" });
}
let dismissedCount = 0;
for (const doseId of doseIds) {
// Check if already exists
const existing = await client.execute({
sql: `SELECT id, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
args: [userId, doseId],
});
if (existing.rows.length > 0) {
// Update to dismissed if not already
if (!existing.rows[0].dismissed) {
await client.execute({
sql: `UPDATE dose_tracking SET dismissed = 1 WHERE id = ?`,
args: [existing.rows[0].id],
});
dismissedCount++;
}
} else {
// Insert new dismissed record
await client.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, dismissed) VALUES (?, ?, 1)`,
args: [userId, doseId],
});
dismissedCount++;
}
}
return { success: true, dismissedCount };
async function insertMedication(options: {
id: number;
userId: number;
takenBy?: string[];
packCount?: number;
looseTablets?: number;
start?: string;
}) {
const intakeStart = options.start ?? "2025-01-01T08:00:00.000Z";
await testClient.execute({
sql: `INSERT INTO medications (
id, user_id, name, taken_by_json, medication_form, package_type,
pack_count, blisters_per_pack, pills_per_blister, loose_tablets, stock_adjustment,
usage_json, every_json, start_json, intakes_json, intake_reminders_enabled
) VALUES (?, ?, 'Test Medication', ?, 'tablet', 'blister', ?, 1, 10, ?, 0, '[1]', '[1]', ?, '[]', 0)`,
args: [
options.id,
options.userId,
JSON.stringify(options.takenBy ?? []),
options.packCount ?? 1,
options.looseTablets ?? 0,
intakeStart,
"[]",
],
});
}
// =============================================================================
// Tests
// =============================================================================
async function insertUserSettings(userId: number, stockCalculationMode: "automatic" | "manual" = "automatic") {
await testClient.execute({
sql: "INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, ?)",
args: [userId, stockCalculationMode],
});
}
async function _insertShareToken(userId: number, token: string, takenBy: string) {
await testClient.execute({
sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, 30)",
args: [userId, token, takenBy],
});
}
async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
const token = await app.jwt.sign({ sub: userId, username });
return `access_token=${token}`;
}
async function insertDose(options: {
userId: number;
doseId: string;
markedBy?: string | null;
dismissed?: boolean;
takenAt?: number | null;
takenSource?: "manual" | "automatic";
}) {
await testClient.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by, dismissed, taken_at, taken_source)
VALUES (?, ?, ?, ?, ?, ?)`,
args: [
options.userId,
options.doseId,
options.markedBy ?? null,
options.dismissed ? 1 : 0,
options.takenAt === undefined ? Math.floor(Date.now() / 1000) : (options.takenAt ?? 0),
options.takenSource ?? "manual",
],
});
}
describe("Dose Tracking API", () => {
let ctx: TestContext;
let app: FastifyInstance;
let userId: number;
let cookieHeader: string;
beforeAll(async () => {
ctx = await buildTestApp();
await registerDoseRoutes(ctx);
await ctx.app.ready();
await migrate(testDb, { migrationsFolder });
await runAlterMigrations(testClient);
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
await app.register(cookie, { secret: "test-cookie-secret" });
await app.register(jwtPlugin, {
secret: "test-jwt-secret",
cookie: { cookieName: "access_token", signed: false },
});
await app.register(doseRoutes);
await app.ready();
});
afterAll(async () => {
await closeTestApp(ctx);
await app.close();
testClient.close();
});
beforeEach(async () => {
await clearTestData(ctx.client);
// Create test user - will get ID 1 since table is cleared
userId = await createTestUser(ctx.client, { username: "testuser" });
// Reset SQLite autoincrement so user gets ID 1
await ctx.client.execute("DELETE FROM sqlite_sequence WHERE name='users'");
await clearTestData(ctx.client);
userId = await createTestUser(ctx.client, { username: "testuser" });
await clearTables();
userId = await createUser("dose-test-user");
cookieHeader = await buildSessionCookie(app, userId, "dose-test-user");
});
// ---------------------------------------------------------------------------
// POST /doses/taken
// ---------------------------------------------------------------------------
describe("POST /doses/taken", () => {
it("should mark a dose as taken", async () => {
it("marks a dose as taken", async () => {
const doseId = "1-0-1735344000000";
const response = await ctx.app.inject({
const response = await app.inject({
method: "POST",
url: "/doses/taken",
headers: { cookie: cookieHeader },
payload: { doseId },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
// Verify in database
const result = await ctx.client.execute({
sql: `SELECT dose_id, marked_by FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
const result = await testClient.execute({
sql: "SELECT dose_id, marked_by, taken_source FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, doseId],
});
expect(result.rows.length).toBe(1);
expect(result.rows[0].dose_id).toBe(doseId);
expect(result.rows[0].marked_by).toBeNull();
expect(result.rows).toEqual([
expect.objectContaining({ dose_id: doseId, marked_by: null, taken_source: "manual" }),
]);
});
it("should return idempotent response when dose already marked", async () => {
it("returns an idempotent response when the dose is already marked", async () => {
const doseId = "1-0-1735344000000";
await insertDose({ userId, doseId });
// Mark once
await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
// Mark again
const response = await ctx.app.inject({
const response = await app.inject({
method: "POST",
url: "/doses/taken",
headers: { cookie: cookieHeader },
payload: { doseId },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, message: "Already marked" });
// Should still only have one record
const result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
const countResult = await testClient.execute({
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, doseId],
});
expect(result.rows[0].count).toBe(1);
expect(Number(countResult.rows[0].count)).toBe(1);
});
it("should reject request without doseId", async () => {
const response = await ctx.app.inject({
it("rejects requests without a doseId", async () => {
const response = await app.inject({
method: "POST",
url: "/doses/taken",
headers: { cookie: cookieHeader },
payload: {},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "doseId is required" });
expect(response.json()).toEqual({ error: "Required" });
});
it("should reject request with empty doseId", async () => {
const response = await ctx.app.inject({
it("accepts dose IDs with a person suffix and special characters", async () => {
const doseId = "5-0-1735344000000-Max Müller";
const response = await app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId: "" },
headers: { cookie: cookieHeader },
payload: { doseId },
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "doseId is required" });
expect(response.statusCode).toBe(200);
const getResponse = await app.inject({
method: "GET",
url: "/doses/taken",
headers: { cookie: cookieHeader },
});
expect(getResponse.statusCode).toBe(200);
expect(getResponse.json().doses[0].doseId).toBe(doseId);
});
it("rejects taking a dose when the medication is out of stock", async () => {
await insertMedication({ id: 5, userId, packCount: 0, looseTablets: 0 });
await insertUserSettings(userId, "automatic");
const response = await app.inject({
method: "POST",
url: "/doses/taken",
headers: { cookie: cookieHeader },
payload: { doseId: "5-0-1735344000000" },
});
expect(response.statusCode).toBe(409);
expect(response.json()).toEqual({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
});
it("allows taking a historical dose when stock existed at that occurrence", async () => {
await insertMedication({
id: 6,
userId,
packCount: 1,
looseTablets: 0,
start: "2025-01-01T08:00:00.000Z",
});
await insertUserSettings(userId, "automatic");
const historicalDoseId = "6-0-1736064000000";
const response = await app.inject({
method: "POST",
url: "/doses/taken",
headers: { cookie: cookieHeader },
payload: { doseId: historicalDoseId },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
});
});
// ---------------------------------------------------------------------------
// GET /doses/taken
// ---------------------------------------------------------------------------
describe("GET /doses/taken", () => {
it("should return empty array when no doses taken", async () => {
const response = await ctx.app.inject({
it("returns an empty array when no doses were taken", async () => {
const response = await app.inject({
method: "GET",
url: "/doses/taken",
headers: { cookie: cookieHeader },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ doses: [] });
});
it("should return list of taken doses", async () => {
const doseId1 = "1-0-1735344000000";
const doseId2 = "1-0-1735430400000";
// Mark two doses
await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId: doseId1 },
});
await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId: doseId2 },
it("returns only the authenticated user's taken doses with metadata", async () => {
const otherUserId = await createUser("dose-other-user");
await insertDose({
userId,
doseId: "1-0-1735344000000",
markedBy: "Daniel",
takenSource: "automatic",
});
await insertDose({ userId, doseId: "1-0-1735430400000" });
await insertDose({ userId: otherUserId, doseId: "9-0-1735516800000" });
const response = await ctx.app.inject({
const response = await app.inject({
method: "GET",
url: "/doses/taken",
headers: { cookie: cookieHeader },
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.doses).toHaveLength(2);
expect(data.doses.map((d: any) => d.doseId).sort()).toEqual([doseId1, doseId2].sort());
// Each dose should have a takenAt timestamp
for (const dose of data.doses) {
expect(dose.takenAt).toBeTypeOf("number");
expect(dose.takenAt).toBeGreaterThan(0);
expect(dose.markedBy).toBeNull();
}
});
it("should include markedBy when present", async () => {
const doseId = "1-0-1735344000000";
// Insert directly with markedBy
await ctx.client.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`,
args: [userId, doseId, "Daniel"],
});
const response = await ctx.app.inject({
method: "GET",
url: "/doses/taken",
});
expect(response.statusCode).toBe(200);
const data = response.json();
expect(data.doses).toHaveLength(1);
expect(data.doses[0].markedBy).toBe("Daniel");
expect(data.doses.map((dose: { doseId: string }) => dose.doseId).sort()).toEqual([
"1-0-1735344000000",
"1-0-1735430400000",
]);
expect(data.doses).toEqual(
expect.arrayContaining([
expect.objectContaining({ markedBy: "Daniel", takenSource: "automatic" }),
expect.objectContaining({ markedBy: null, takenSource: "manual" }),
])
);
});
});
// ---------------------------------------------------------------------------
// DELETE /doses/taken/:doseId
// ---------------------------------------------------------------------------
describe("DELETE /doses/taken/:doseId", () => {
it("should unmark a dose", async () => {
it("unmarks an existing dose", async () => {
const doseId = "1-0-1735344000000";
await insertDose({ userId, doseId });
// Mark first
await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
// Verify marked
let result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id = ?`,
args: [doseId],
});
expect(result.rows[0].count).toBe(1);
// Unmark
const response = await ctx.app.inject({
const response = await app.inject({
method: "DELETE",
url: `/doses/taken/${encodeURIComponent(doseId)}`,
headers: { cookie: cookieHeader },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
// Verify unmarked
result = await ctx.client.execute({
sql: `SELECT COUNT(*) as count FROM dose_tracking WHERE dose_id = ?`,
args: [doseId],
const countResult = await testClient.execute({
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, doseId],
});
expect(result.rows[0].count).toBe(0);
expect(Number(countResult.rows[0].count)).toBe(0);
});
it("should succeed even if dose was not marked", async () => {
const doseId = "nonexistent-dose-id";
it("keeps the record when the dose is dismissed", async () => {
const doseId = "1-0-1735344000000";
await insertDose({ userId, doseId, dismissed: true });
const response = await ctx.app.inject({
const response = await app.inject({
method: "DELETE",
url: `/doses/taken/${encodeURIComponent(doseId)}`,
headers: { cookie: cookieHeader },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
const result = await testClient.execute({
sql: "SELECT dose_id, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, doseId],
});
expect(result.rows).toEqual([expect.objectContaining({ dose_id: doseId, dismissed: 1 })]);
});
it("should preserve dismissed status when unmarking a dose", async () => {
const doseId = "1-0-1735344000000";
// First dismiss the dose
await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds: [doseId] },
});
// Verify it's dismissed
let result = await ctx.client.execute({
sql: `SELECT dismissed, taken_at FROM dose_tracking WHERE dose_id = ?`,
args: [doseId],
});
expect(result.rows[0].dismissed).toBe(1);
const originalTakenAt = result.rows[0].taken_at;
// Now try to unmark it (undo) - should keep the dismissed record
const response = await ctx.app.inject({
it("still succeeds when the dose does not exist", async () => {
const response = await app.inject({
method: "DELETE",
url: `/doses/taken/${encodeURIComponent(doseId)}`,
url: "/doses/taken/nonexistent-dose-id",
headers: { cookie: cookieHeader },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
// Verify the record still exists and is still dismissed
result = await ctx.client.execute({
sql: `SELECT dose_id, dismissed, taken_at FROM dose_tracking WHERE dose_id = ?`,
args: [doseId],
});
expect(result.rows.length).toBe(1);
expect(result.rows[0].dismissed).toBe(1);
expect(result.rows[0].taken_at).toBe(originalTakenAt); // unchanged
});
});
// ---------------------------------------------------------------------------
// Dose ID Format Tests
// ---------------------------------------------------------------------------
describe("Dose ID Format", () => {
it("should handle standard dose ID format: {medId}-{blisterIdx}-{timestamp}", async () => {
const doseId = "5-0-1735344000000";
const response = await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
});
it("should handle dose ID with person: {medId}-{blisterIdx}-{timestamp}-{person}", async () => {
const doseId = "5-0-1735344000000-Daniel";
const response = await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true });
});
it("should handle special characters in dose ID", async () => {
// Dose ID with URL-unsafe characters (edge case)
const doseId = "5-0-1735344000000-Max Müller";
const response = await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
expect(response.statusCode).toBe(200);
// Can retrieve it
const getResponse = await ctx.app.inject({
method: "GET",
url: "/doses/taken",
});
expect(getResponse.json().doses[0].doseId).toBe(doseId);
});
});
// ---------------------------------------------------------------------------
// Dismiss Doses Tests (POST /doses/dismiss)
// ---------------------------------------------------------------------------
describe("POST /doses/dismiss", () => {
it("should dismiss multiple doses", async () => {
const doseIds = ["1-0-1735344000000", "1-0-1735430400000"];
const response = await ctx.app.inject({
it("dismisses multiple doses", async () => {
const response = await app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds },
headers: { cookie: cookieHeader },
payload: { doseIds: ["1-0-1735344000000", "1-0-1735430400000"] },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, dismissedCount: 2 });
// Verify in database
const result = await ctx.client.execute({
sql: `SELECT dose_id, dismissed FROM dose_tracking WHERE user_id = ? AND dismissed = 1`,
const result = await testClient.execute({
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dismissed = 1",
args: [userId],
});
expect(result.rows.length).toBe(2);
expect(Number(result.rows[0].count)).toBe(2);
});
it("should not double-count already dismissed doses", async () => {
it("does not double-count already dismissed doses", async () => {
const doseId = "1-0-1735344000000";
await insertDose({ userId, doseId, dismissed: true });
// Dismiss once
await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds: [doseId] },
});
// Dismiss again
const response = await ctx.app.inject({
const response = await app.inject({
method: "POST",
url: "/doses/dismiss",
headers: { cookie: cookieHeader },
payload: { doseIds: [doseId] },
});
@@ -489,54 +414,71 @@ describe("Dose Tracking API", () => {
expect(response.json()).toEqual({ success: true, dismissedCount: 0 });
});
it("should reject empty doseIds array", async () => {
const response = await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: { doseIds: [] },
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "doseIds array is required" });
});
it("should reject missing doseIds", async () => {
const response = await ctx.app.inject({
method: "POST",
url: "/doses/dismiss",
payload: {},
});
expect(response.statusCode).toBe(400);
expect(response.json()).toEqual({ error: "doseIds array is required" });
});
it("should dismiss a dose that was already taken (convert to dismissed)", async () => {
it("converts a taken dose into a dismissed one", async () => {
const doseId = "1-0-1735344000000";
await insertDose({ userId, doseId, dismissed: false });
// First mark as taken
await ctx.app.inject({
method: "POST",
url: "/doses/taken",
payload: { doseId },
});
// Then dismiss it
const response = await ctx.app.inject({
const response = await app.inject({
method: "POST",
url: "/doses/dismiss",
headers: { cookie: cookieHeader },
payload: { doseIds: [doseId] },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, dismissedCount: 1 });
// Verify it's now dismissed
const result = await ctx.client.execute({
sql: `SELECT dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?`,
const result = await testClient.execute({
sql: "SELECT dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
args: [userId, doseId],
});
expect(result.rows[0].dismissed).toBe(1);
expect(result.rows).toEqual([expect.objectContaining({ dismissed: 1 })]);
});
it("rejects missing or empty doseIds", async () => {
const emptyResponse = await app.inject({
method: "POST",
url: "/doses/dismiss",
headers: { cookie: cookieHeader },
payload: { doseIds: [] },
});
expect(emptyResponse.statusCode).toBe(400);
expect(emptyResponse.json()).toEqual({ error: "At least one doseId is required" });
const missingResponse = await app.inject({
method: "POST",
url: "/doses/dismiss",
headers: { cookie: cookieHeader },
payload: {},
});
expect(missingResponse.statusCode).toBe(400);
expect(missingResponse.json()).toEqual({ error: "Required" });
});
});
describe("DELETE /doses/dismiss", () => {
it("clears dismissed-only records and removes the dismissed flag from taken doses", async () => {
await insertDose({ userId, doseId: "1-0-1735344000000", dismissed: true, takenAt: null });
await insertDose({ userId, doseId: "1-0-1735430400000", dismissed: true, markedBy: "Daniel" });
const response = await app.inject({
method: "DELETE",
url: "/doses/dismiss",
headers: { cookie: cookieHeader },
});
expect(response.statusCode).toBe(200);
expect(response.json()).toEqual({ success: true, clearedCount: 2 });
const rows = await testClient.execute({
sql: "SELECT dose_id, dismissed, marked_by FROM dose_tracking WHERE user_id = ? ORDER BY dose_id ASC",
args: [userId],
});
expect(rows.rows).toEqual([
expect.objectContaining({ dose_id: "1-0-1735430400000", dismissed: 0, marked_by: "Daniel" }),
]);
});
});
});
File diff suppressed because it is too large Load Diff
+76
View File
@@ -0,0 +1,76 @@
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
const ORIGINAL_ENV = { ...process.env };
describe("plugins/env runtime validation", () => {
beforeEach(() => {
vi.resetModules();
vi.restoreAllMocks();
process.env = {
...ORIGINAL_ENV,
DOTENV_PATH: "/tmp/medassist-nonexistent.env",
};
});
afterAll(() => {
process.env = ORIGINAL_ENV;
});
it("loads with defaults when auth and oidc are disabled", async () => {
delete process.env.AUTH_ENABLED;
delete process.env.OIDC_ENABLED;
delete process.env.JWT_SECRET;
delete process.env.REFRESH_SECRET;
delete process.env.COOKIE_SECRET;
const mod = await import("../plugins/env.js");
expect(mod.env.AUTH_ENABLED).toBe(false);
expect(mod.env.OIDC_ENABLED).toBe(false);
expect(mod.env.PORT).toBe(3000);
});
it("exits when auth is enabled but secrets are missing", async () => {
process.env.AUTH_ENABLED = "true";
delete process.env.JWT_SECRET;
delete process.env.REFRESH_SECRET;
delete process.env.COOKIE_SECRET;
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as never);
await expect(import("../plugins/env.js")).rejects.toThrow("process.exit:1");
});
it("exits when oidc is enabled but required settings are missing", async () => {
process.env.AUTH_ENABLED = "false";
process.env.OIDC_ENABLED = "true";
delete process.env.OIDC_ISSUER_URL;
delete process.env.OIDC_CLIENT_ID;
delete process.env.OIDC_CLIENT_SECRET;
delete process.env.OIDC_REDIRECT_URI;
vi.spyOn(process, "exit").mockImplementation(((code?: number) => {
throw new Error(`process.exit:${code ?? 0}`);
}) as never);
await expect(import("../plugins/env.js")).rejects.toThrow("process.exit:1");
});
it("loads when auth and oidc settings are complete", async () => {
process.env.AUTH_ENABLED = "true";
process.env.JWT_SECRET = "jwt-secret-for-runtime-test";
process.env.REFRESH_SECRET = "refresh-secret-runtime-test";
process.env.COOKIE_SECRET = "cookie-secret-runtime-test";
process.env.OIDC_ENABLED = "true";
process.env.OIDC_ISSUER_URL = "https://auth.example.com";
process.env.OIDC_CLIENT_ID = "medassist";
process.env.OIDC_CLIENT_SECRET = "super-secret-client";
process.env.OIDC_REDIRECT_URI = "https://app.example.com/api/auth/oidc/callback";
const mod = await import("../plugins/env.js");
expect(mod.env.AUTH_ENABLED).toBe(true);
expect(mod.env.OIDC_ENABLED).toBe(true);
expect(mod.env.OIDC_CLIENT_ID).toBe("medassist");
});
});
+26 -15
View File
@@ -3,40 +3,41 @@ import { z } from "zod";
// Mock process.exit to prevent tests from exiting
const mockExit = vi.fn();
vi.spyOn(process, "exit").mockImplementation(mockExit as any);
vi.spyOn(process, "exit").mockImplementation(mockExit as unknown as (...args: unknown[]) => never);
// Re-create the schema from env.ts for testing
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");
+58 -9
View File
@@ -23,10 +23,12 @@ async function registerExportRoutes(ctx: TestContext) {
const userId = 1; // Test user ID
// Helper to parse blisters from DB
function parseBlisters(row: any): Array<{ usage: number; every: number; start: string; remind: boolean }> {
const usage = JSON.parse(row.usage_json || "[]") as number[];
const every = JSON.parse(row.every_json || "[]") as number[];
const start = JSON.parse(row.start_json || "[]") as string[];
function parseBlisters(
row: Record<string, unknown>
): Array<{ usage: number; every: number; start: string; remind: boolean }> {
const usage = JSON.parse((row.usage_json as string) || "[]") as number[];
const every = JSON.parse((row.every_json as string) || "[]") as number[];
const start = JSON.parse((row.start_json as string) || "[]") as string[];
const len = Math.min(usage.length, every.length, start.length);
return Array.from({ length: len }, (_, i) => ({
usage: usage[i],
@@ -99,7 +101,7 @@ async function registerExportRoutes(ctx: TestContext) {
args: [userId],
});
let settings;
let settings: Record<string, unknown> | undefined;
if (settingsResult.rows.length > 0) {
const s = settingsResult.rows[0];
settings = {
@@ -150,7 +152,8 @@ async function registerExportRoutes(ctx: TestContext) {
});
// POST /import
app.post<{ Body: any }>("/import", async (request, reply) => {
app.post("/import", async (request, reply) => {
// biome-ignore lint/suspicious/noExplicitAny: test helper with dynamic import data shape
const importData = request.body as any;
// Basic validation
@@ -167,9 +170,15 @@ async function registerExportRoutes(ctx: TestContext) {
// Import medications
const exportIdToNewId = new Map<string, number>();
for (const med of importData.medications || []) {
const usageJson = JSON.stringify((med.schedules || []).map((s: any) => s.usage));
const everyJson = JSON.stringify((med.schedules || []).map((s: any) => s.every));
const startJson = JSON.stringify((med.schedules || []).map((s: any) => s.start));
const usageJson = JSON.stringify(
((med.schedules as Array<Record<string, unknown>>) || []).map((s: Record<string, unknown>) => s.usage)
);
const everyJson = JSON.stringify(
((med.schedules as Array<Record<string, unknown>>) || []).map((s: Record<string, unknown>) => s.every)
);
const startJson = JSON.stringify(
((med.schedules as Array<Record<string, unknown>>) || []).map((s: Record<string, unknown>) => s.start)
);
const takenByJson = JSON.stringify(med.takenBy || []);
const result = await client.execute({
@@ -402,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 () => {
@@ -548,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, {

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