Compare commits

...

455 Commits

Author SHA1 Message Date
Daniel Volz db1f545f59 fix: preserve ntfy action clear flags for settings delivery 2026-05-10 21:42:24 +02:00
Daniel Volz 35234bc93f docs: narrow ntfy recovery env example 2026-05-10 21:38:57 +02:00
Daniel Volz d5fc18c9af chore: trim ntfy recovery PR to shipping scope 2026-05-10 21:37:59 +02:00
Daniel Volz 6f89e38263 feat: add ntfy interactive settings test delivery 2026-05-10 21:35:44 +02:00
Daniel Volz 72e04db365 feat: add ntfy notification action context service 2026-05-10 21:35:44 +02:00
Daniel Volz e0f83e799a feat: add ntfy notification action renderer 2026-05-10 21:35:44 +02:00
Daniel Volz 79d646ccbb feat: add ntfy notification action foundation 2026-05-10 21:35:42 +02:00
Daniel Volz 72ba4d1272 chore: support proxied frontend dev hosts 2026-05-10 20:49:15 +02:00
Daniel Volz eba77c9520 fix: preserve frontend medication deep links 2026-05-10 20:25:14 +02:00
Daniel Volz d4b8ddc590 fix: restore shared schedule skip actions
(cherry picked from commit eca068b1c7f494bbb2caf7edcd480bf67f76df33)
2026-05-10 20:13:39 +02:00
Daniel Volz 4d6c568668 docs: refresh default user settings reference 2026-05-10 19:33:13 +02:00
Daniel Volz 12dc77455c chore: tighten local agent workspace rules (#576)
* chore: tighten local agent workspace rules

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

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

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

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

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

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

* fix: adapt backend validation for zod v4

---------

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


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

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

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

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

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

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

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


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

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

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

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

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

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

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


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

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

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

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


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

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

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

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

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

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

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

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

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


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

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

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

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


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

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

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

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

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

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

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

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

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

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

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


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

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

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

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


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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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


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

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

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

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

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


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

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

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

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


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

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-06 10:49:32 +02:00
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
Daniel Volz a016e45ef2 feat: frontend LOG_LEVEL support via logger utility (#209)
- Inject LOG_LEVEL at build time via Vite define (__LOG_LEVEL__, default: warn)
- Create frontend logger utility (frontend/src/utils/logger.ts) mirroring backend API
- Replace all console.error calls with log.error in MedicationsPage, AppContext, Auth
- Supports levels: silent > error > warn > info > debug

Closes #205
2026-02-14 20:28:06 +01:00
Daniel Volz cbc71822b0 fix: highlight empty medications in planner email with red background (#208)
- Add light red background (#fef2f2) to table rows where medication is out of stock
- Consistent with stock reminder email styling

Closes #204
2026-02-14 20:24:28 +01:00
Daniel Volz 150be1e114 feat: add prescription refills column to planner table and email (#207)
- Add 6th column 'Prescription refills' to frontend Planner table
- Add matching column to backend planner email (HTML + plaintext)
- Show remaining refills for meds with prescription tracking, '–' otherwise
- Add backend translations for new column header (EN + DE)
- Add frontend i18n keys for prescription refills column
- Update planner tests with medications table schema

Closes #203
2026-02-14 20:21:09 +01:00
Daniel Volz 6ff0ad2745 fix: mobile modal UX improvements (delete confirm, browser-back, z-index) (#206)
- Replace browser confirm() with ConfirmModal for delete confirmation
- Add dedicated history entry for delete dialog so browser back dismisses it
- Track unsaved-changes warning source to restore correct context on cancel
- Add overlayClassName prop to ConfirmModal for nested z-index layering
- Add .nested-confirm CSS class for proper modal stacking
- Add i18n keys for delete confirmation dialog (EN + DE)

Closes #202
2026-02-14 20:17:01 +01:00
Daniel Volz 0ffab23b6d feat: add back button in medication edit header (#201) 2026-02-14 19:22:37 +01:00
github-actions[bot] b4ddf9fd65 chore: update test count badges [skip ci] 2026-02-14 18:12:36 +00:00
Daniel Volz 8273b07231 feat: track number of prescription repeats (#193)
* feat: track prescription repeats and refill reminders

* test: align backend and frontend suites with current prescription and UI behavior

* test: update frontend and backend expectations for latest reminders and refill flow
2026-02-14 19:07:36 +01:00
Daniel Volz edf42bb068 fix: show reminder icon per intake dose in schedule (#198)
* fix: show reminder icon per intake dose in schedule

* test: align schedule reminder icon test with intake-level flag
2026-02-14 18:53:52 +01:00
github-actions[bot] e2c274014f chore: update test count badges [skip ci] 2026-02-14 17:47:54 +00:00
Daniel Volz 732a28dcc5 chore: sync copilot guidance and docker dev proxy defaults (#199) 2026-02-14 18:43:49 +01:00
Daniel Volz 684abd7fb6 fix: handle usernames case-insensitively in auth and oidc (#197) 2026-02-14 18:43:30 +01:00
dependabot[bot] bb693243c1 build(deps): bump github/codeql-action from 3 to 4 (#176)
Bumps [github/codeql-action](https://github.com/github/codeql-action) from 3 to 4.
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: github/codeql-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-02-13 20:28:14 +01:00
dependabot[bot] fcc84e2d0b build(deps): bump actions/upload-artifact from 4 to 6 (#174)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  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-02-13 20:28:10 +01:00
dependabot[bot] 91c55f8cc3 build(deps): bump docker/build-push-action from 5 to 6 (#172)
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 6.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/build-push-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-02-13 20:27:58 +01:00
dependabot[bot] 12d1fbbb30 build(deps-dev): bump @vitejs/plugin-react in /frontend (#178)
Bumps [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) from 4.7.0 to 5.1.4.
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@5.1.4/packages/plugin-react)

---
updated-dependencies:
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 5.1.4
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-13 20:27:46 +01:00
dependabot[bot] 836c48264f build(deps-dev): bump jsdom from 27.4.0 to 28.0.0 in /frontend (#183)
Bumps [jsdom](https://github.com/jsdom/jsdom) from 27.4.0 to 28.0.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/27.4.0...28.0.0)

---
updated-dependencies:
- dependency-name: jsdom
  dependency-version: 28.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>
2026-02-13 20:27:42 +01:00
dependabot[bot] 12bfc61565 build(deps): bump i18next from 24.2.3 to 25.8.7 in /frontend (#181)
Bumps [i18next](https://github.com/i18next/i18next) from 24.2.3 to 25.8.7.
- [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/v24.2.3...v25.8.7)

---
updated-dependencies:
- dependency-name: i18next
  dependency-version: 25.8.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-13 20:27:38 +01:00
dependabot[bot] 2c829da924 build(deps): bump zod from 3.25.76 to 4.3.6 in /frontend (#185)
Bumps [zod](https://github.com/colinhacks/zod) from 3.25.76 to 4.3.6.
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v3.25.76...v4.3.6)

---
updated-dependencies:
- dependency-name: zod
  dependency-version: 4.3.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-02-13 20:27:35 +01:00
dependabot[bot] 874babe1d8 build(deps-dev): bump @types/node from 22.19.3 to 25.2.3 in /backend (#191)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.19.3 to 25.2.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.2.3
  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-13 20:20:40 +01:00
dependabot[bot] c9039b6e87 build(deps): bump dotenv from 16.6.1 to 17.3.1 in /backend (#190)
Bumps [dotenv](https://github.com/motdotla/dotenv) from 16.6.1 to 17.3.1.
- [Changelog](https://github.com/motdotla/dotenv/blob/master/CHANGELOG.md)
- [Commits](https://github.com/motdotla/dotenv/compare/v16.6.1...v17.3.1)

---
updated-dependencies:
- dependency-name: dotenv
  dependency-version: 17.3.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-02-13 20:20:37 +01:00
dependabot[bot] 5918eb5aae build(deps): bump nodemailer from 7.0.11 to 8.0.1 in /backend (#189)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 7.0.11 to 8.0.1.
- [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/v7.0.11...v8.0.1)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 8.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-02-13 20:20:33 +01:00
dependabot[bot] 19d3f83aef build(deps): bump @fastify/static from 8.3.0 to 9.0.0 in /backend (#187)
Bumps [@fastify/static](https://github.com/fastify/fastify-static) from 8.3.0 to 9.0.0.
- [Release notes](https://github.com/fastify/fastify-static/releases)
- [Commits](https://github.com/fastify/fastify-static/compare/v8.3.0...v9.0.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-13 20:20:30 +01:00
dependabot[bot] 6922a856c0 build(deps): bump @fastify/cors from 10.1.0 to 11.2.0 in /backend (#186)
Bumps [@fastify/cors](https://github.com/fastify/fastify-cors) from 10.1.0 to 11.2.0.
- [Release notes](https://github.com/fastify/fastify-cors/releases)
- [Commits](https://github.com/fastify/fastify-cors/compare/v10.1.0...v11.2.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-13 20:20:26 +01:00
dependabot[bot] 45a319dc06 build(deps): bump @fastify/cookie from 10.0.1 to 11.0.2 in /backend (#184)
Bumps [@fastify/cookie](https://github.com/fastify/fastify-cookie) from 10.0.1 to 11.0.2.
- [Release notes](https://github.com/fastify/fastify-cookie/releases)
- [Commits](https://github.com/fastify/fastify-cookie/compare/v10.0.1...v11.0.2)

---
updated-dependencies:
- dependency-name: "@fastify/cookie"
  dependency-version: 11.0.2
  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-13 20:11:18 +01:00
dependabot[bot] 81ac12ba60 build(deps): bump the minor-and-patch group in /frontend with 7 updates (#177)
Bumps the minor-and-patch group in /frontend with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [i18next-browser-languagedetector](https://github.com/i18next/i18next-browser-languageDetector) | `8.2.0` | `8.2.1` |
| [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom) | `7.12.0` | `7.13.0` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.3.12` | `2.3.15` |
| [@playwright/test](https://github.com/microsoft/playwright) | `1.58.1` | `1.58.2` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.0.17` | `4.0.18` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `7.3.0` | `7.3.1` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.0.17` | `4.0.18` |


Updates `i18next-browser-languagedetector` from 8.2.0 to 8.2.1
- [Changelog](https://github.com/i18next/i18next-browser-languageDetector/blob/master/CHANGELOG.md)
- [Commits](https://github.com/i18next/i18next-browser-languageDetector/compare/v8.2.0...v8.2.1)

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

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

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

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

Updates `vite` from 7.3.0 to 7.3.1
- [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/v7.3.1/packages/vite)

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

---
updated-dependencies:
- dependency-name: i18next-browser-languagedetector
  dependency-version: 8.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: react-router-dom
  dependency-version: 7.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.3.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@playwright/test"
  dependency-version: 1.58.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.0.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vite
  dependency-version: 7.3.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: vitest
  dependency-version: 4.0.18
  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-13 20:11:01 +01:00
dependabot[bot] 6c10f9af0c build(deps): bump the minor-and-patch group in /backend with 10 updates (#182)
Bumps the minor-and-patch group in /backend with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [@fastify/multipart](https://github.com/fastify/fastify-multipart) | `9.3.0` | `9.4.0` |
| [@libsql/client](https://github.com/tursodatabase/libsql-client-ts/tree/HEAD/packages/libsql-client) | `0.10.0` | `0.17.0` |
| [argon2](https://github.com/ranisalt/node-argon2) | `0.40.3` | `0.44.0` |
| [fastify](https://github.com/fastify/fastify) | `5.7.3` | `5.7.4` |
| [openid-client](https://github.com/panva/openid-client) | `6.8.1` | `6.8.2` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.3.12` | `2.3.15` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `4.0.16` | `4.0.18` |
| [drizzle-kit](https://github.com/drizzle-team/drizzle-orm) | `0.31.8` | `0.31.9` |
| [supertest](https://github.com/ladjs/supertest) | `7.1.4` | `7.2.2` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `4.0.16` | `4.0.18` |


Updates `@fastify/multipart` from 9.3.0 to 9.4.0
- [Release notes](https://github.com/fastify/fastify-multipart/releases)
- [Commits](https://github.com/fastify/fastify-multipart/compare/v9.3.0...v9.4.0)

Updates `@libsql/client` from 0.10.0 to 0.17.0
- [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.0/packages/libsql-client)

Updates `argon2` from 0.40.3 to 0.44.0
- [Release notes](https://github.com/ranisalt/node-argon2/releases)
- [Commits](https://github.com/ranisalt/node-argon2/commits/v0.44.0)

Updates `fastify` from 5.7.3 to 5.7.4
- [Release notes](https://github.com/fastify/fastify/releases)
- [Commits](https://github.com/fastify/fastify/compare/v5.7.3...v5.7.4)

Updates `openid-client` from 6.8.1 to 6.8.2
- [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.1...v6.8.2)

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

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

Updates `drizzle-kit` from 0.31.8 to 0.31.9
- [Release notes](https://github.com/drizzle-team/drizzle-orm/releases)
- [Commits](https://github.com/drizzle-team/drizzle-orm/compare/drizzle-kit@0.31.8...drizzle-kit@0.31.9)

Updates `supertest` from 7.1.4 to 7.2.2
- [Release notes](https://github.com/ladjs/supertest/releases)
- [Commits](https://github.com/ladjs/supertest/compare/v7.1.4...v7.2.2)

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

---
updated-dependencies:
- dependency-name: "@fastify/multipart"
  dependency-version: 9.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: "@libsql/client"
  dependency-version: 0.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: argon2
  dependency-version: 0.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: fastify
  dependency-version: 5.7.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: openid-client
  dependency-version: 6.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@biomejs/biome"
  dependency-version: 2.3.15
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.0.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: drizzle-kit
  dependency-version: 0.31.9
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: supertest
  dependency-version: 7.2.2
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: vitest
  dependency-version: 4.0.18
  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-13 20:10:51 +01:00
dependabot[bot] 6eb7bf6d0d build(deps-dev): bump lint-staged from 15.5.2 to 16.2.7 (#175)
Bumps [lint-staged](https://github.com/lint-staged/lint-staged) from 15.5.2 to 16.2.7.
- [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/v15.5.2...v16.2.7)

---
updated-dependencies:
- dependency-name: lint-staged
  dependency-version: 16.2.7
  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-13 20:10:39 +01:00
dependabot[bot] 2a97a78810 build(deps-dev): bump @biomejs/biome from 2.3.12 to 2.3.15 (#173)
Bumps [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) from 2.3.12 to 2.3.15.
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.3.15/packages/@biomejs/biome)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.3.15
  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-13 20:10:28 +01:00
dependabot[bot] 92ea6d5f8b build(deps): bump actions/setup-node from 4 to 6 (#171)
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 4 to 6.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/setup-node
  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-02-13 20:10:05 +01:00
dependabot[bot] 0c83648a56 build(deps): bump actions/checkout from 4 to 6 (#170)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  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-02-13 20:08:56 +01:00
Daniel Volz 77b0f3a0f9 chore: improve dev tooling (CI tests, dependabot, coverage) (#169)
- Add frontend unit tests with coverage to CI test workflow
- Add dependabot.yml for automated dependency updates (npm + GitHub Actions)
- Add backend coverage thresholds (60/65/50/60) to vitest.config.ts
- Exclude services/ and logger from coverage (untestable schedulers)
2026-02-13 19:52:33 +01:00
Daniel Volz 82d8bec91b chore: add noNestedTernary biome rule (warn) (#168)
- Enforce no nested ternary expressions via biome linter
- No existing code violations found
- Complements clarity-over-brevity coding guideline
2026-02-13 19:32:17 +01:00
Daniel Volz 7122121c12 chore: release v1.10.3 (#167) 2026-02-13 19:02:38 +01:00
Daniel Volz 36ee80b554 chore: add workflow to auto-move project items to Done on close/merge (#165)
- New workflow project-auto-done.yml triggers on issue close and PR merge
- Uses GraphQL to find the project item and update Status to Done
- Handles both issues and pull requests with proper type detection
- Skips gracefully if item is not on the board or already Done
- Update release-manager.agent.md to reflect automation (manual is now fallback)
2026-02-13 18:45:51 +01:00
Daniel Volz 33342e7e25 docs: add mandatory project board update steps to release-manager (#164)
- Add critical safety rule: always verify project board status after merge
- Correct misleading claim that Closes #N auto-moves project status (it doesn't)
- Add concrete GraphQL mutation commands for moving items to Done
- Include known project field IDs for Status column
2026-02-13 18:42:08 +01:00
github-actions[bot] 19d5ef71ab chore: update test count badges [skip ci] 2026-02-13 17:37:51 +00:00
Daniel Volz 5c09f97cb3 test: improve frontend test coverage (#163)
- Export DashboardPage helper functions for testability
- Add new test files: App, SharedSchedule, AppContext, UnsavedChangesContext, useUnsavedChangesWarning
- Expand existing test coverage for Auth, MedDetailModal, MobileEditModal, DashboardPage, MedicationsPage, PlannerPage, and more
- Add edge case and error handling tests across components, hooks, and pages
2026-02-13 18:34:19 +01:00
Copilot 0b0472f2f5 Fix OIDC token exchange behind HTTPS reverse proxy (#162)
* Initial plan

* Fix OIDC callback URL construction for HTTPS reverse proxy

- Replace hardcoded http:// URL with OIDC_REDIRECT_URI from environment
- Build complete callback URL with query parameters for proper validation
- Fixes token exchange 401 errors when running behind HTTPS reverse proxy

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

* Update OIDC_REDIRECT_URI documentation to clarify full URL requirement

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

* fix: format oidc.ts to pass biome check

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-02-13 18:29:33 +01:00
dependabot[bot] 38f3533dd9 build(deps-dev): bump qs from 6.14.1 to 6.14.2 in /backend (#158)
Bumps [qs](https://github.com/ljharb/qs) from 6.14.1 to 6.14.2.
- [Changelog](https://github.com/ljharb/qs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/ljharb/qs/compare/v6.14.1...v6.14.2)

---
updated-dependencies:
- dependency-name: qs
  dependency-version: 6.14.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-12 21:43:41 +01:00
dependabot[bot] 463c756447 build(deps): bump fast-xml-parser and @aws-sdk/client-ses in /backend (#157)
Bumps [fast-xml-parser](https://github.com/NaturalIntelligence/fast-xml-parser) and [@aws-sdk/client-ses](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ses). These dependencies needed to be updated together.

Updates `fast-xml-parser` from 5.2.5 to 5.3.4
- [Release notes](https://github.com/NaturalIntelligence/fast-xml-parser/releases)
- [Changelog](https://github.com/NaturalIntelligence/fast-xml-parser/blob/master/CHANGELOG.md)
- [Commits](https://github.com/NaturalIntelligence/fast-xml-parser/compare/v5.2.5...v5.3.4)

Updates `@aws-sdk/client-ses` from 3.956.0 to 3.988.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ses/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.988.0/clients/client-ses)

---
updated-dependencies:
- dependency-name: fast-xml-parser
  dependency-version: 5.3.4
  dependency-type: indirect
- dependency-name: "@aws-sdk/client-ses"
  dependency-version: 3.988.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-12 21:43:35 +01:00
Daniel Volz 4275dca838 fix: improve modal scroll lock and e2e script workflow (#156) 2026-02-12 21:43:28 +01:00
Daniel Volz 6072d8eb2e docs: consolidate copilot governance and add medassist skills (#160) 2026-02-12 21:18:50 +01:00
Daniel Volz 98939877db feat: expand Playwright E2E coverage (#155)
* feat: comprehensive Playwright E2E test rewrite

Rewrite all E2E tests with correct CSS selectors, add new spec files,
and implement robust auth handling to work within backend rate limits.

Changes:
- Rewrite fixtures/index.ts with JWT-based /auth/me mock to avoid
  10 req/min rate limit on /auth/me during test runs
- Rewrite auth.setup.ts with offline JWT validity check to reuse
  existing auth state across runs (saves login rate-limit budget)
- Rewrite auth.spec.ts (6 tests) - login page, fields, submit,
  redirect guard, invalid credentials, login/register toggle
- Rewrite dashboard.spec.ts (8 tests) - header, nav tabs,
  navigation, overview/schedules sections, days selector, redirect
- Rewrite medications.spec.ts (8 tests) - form fields, stock
  inventory, package type toggle, intake schedule, save/cancel,
  unsaved changes guard
- Rewrite settings.spec.ts (12 tests) - language, notification
  matrix, thresholds, calculation mode, toggle switch, export/import,
  user menu navigation
- Create planner.spec.ts (9 tests) - form, date inputs, calculate,
  reset, checkbox, submit, tab state, eyebrow heading
- Create schedule.spec.ts (12 tests) - timeline, days selector,
  past/future toggles, day blocks, today highlight, collapse/expand,
  overview table, share button
- Update playwright.config.ts: remove mobile projects, enable
  webServer section for CI
- Add .github/workflows/e2e.yml CI workflow for Playwright tests

Total: 57 E2E tests across 6 spec files, all passing consistently
across 5+ consecutive runs without backend restart.

Closes #154

* feat: add comprehensive E2E data tests with medication CRUD, dashboard, planner, schedule

Add 48 new Playwright E2E tests covering real medication data scenarios:
- medication-crud: 14 tests for create/edit/delete/list via UI form
- dashboard-data: 13 tests for overview table, timeline, dose tracking
- planner-data: 9 tests for demand calculator with results/status chips
- schedule-data: 11 tests for timeline, collapse/expand, dose mark/undo

Infrastructure improvements:
- Add API helpers (createMedicationViaAPI, deleteMedicationViaAPI,
  deleteAllMedicationsViaAPI) with retry logic for rate-limit resilience
- Configure chromium-data project for serial execution with retry:1
- Add /auth/me mock to avoid rate-limit exhaustion on auth endpoint
- Increase navigateTo reliability with networkidle waits
- Increase auth token validity threshold from 2 to 10 minutes
- Make backend rate limit configurable via RATE_LIMIT_MAX env var
- Set RATE_LIMIT_MAX=300 in dev docker-compose for E2E test support

Total suite: 57 empty-state + 48 data tests = 105 tests (chromium)

* test: add E2E tests for medication editing, stock status, and share schedule

- medication-edit.spec.ts: 10 tests covering generic name, notes,
  taken-by add/remove, expiry date, refill, intake schedule editing,
  adding intake rows, reminder toggle, and package type changes
- stock-status.spec.ts: 12 tests verifying dashboard shows correct
  status chips (High/Normal/Warning/Danger) for different stock levels,
  overview table, reorder card, detail modal, and planner integration
- share-schedule.spec.ts: 10 tests for taken-by badges, share button,
  share dialog, link generation, shared schedule page navigation,
  dose tracking on shared page, and notes display
- fixtures/index.ts: add createShareTokenViaAPI, updateSettingsViaAPI
  helpers; expand createMedicationViaAPI with takenBy, notes, expiryDate
- playwright.config.ts: update testMatch/testIgnore for new test files
- docker-compose.dev.yml: increase RATE_LIMIT_MAX to 1000 for E2E tests

* docs: refine release-manager instructions for CLI safety and commit-linked release notes

* fix: resolve PR155 CI failures for frontend lint and e2e proxy

* fix: stabilize auth-related e2e checks in CI
2026-02-12 20:06:11 +01:00
Copilot 0f6a580ceb feat: add GitHub Project automation for feature request tracking (#114)
* Initial plan

* feat: add GitHub Project automation for feature request tracking

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-10 17:50:58 +01:00
Daniel Volz 30271915d3 chore: release v1.10.2 (#153) 2026-02-10 16:44:21 +01:00
Daniel Volz 1c50e9395f fix: past days UX improvements and clear missed logic (#152)
- Render past days above 'Show past days' toggle when expanded
- Auto-scroll to today when expanding past days
- Remove blue hover color from past day dividers (use opacity instead)
- Fix 'All taken' logic: green only for manually taken doses
- Yellow styling stays for days with non-taken doses (even after dismissal)
- Warning icon disappears after 'Clear missed' (dismissed doses not counted)
2026-02-10 16:42:23 +01:00
Daniel Volz e335729399 fix: prevent badge workflow push rejection on concurrent runs (#151)
Add git pull --rebase before push to handle cases where main moved
between checkout and push (e.g., two Docker builds triggering badge
updates simultaneously). Also add concurrency group to cancel
duplicate runs.
2026-02-09 21:09:45 +01:00
github-actions[bot] 399d63caec chore: update test count badges [skip ci] 2026-02-09 20:02:55 +00:00
Daniel Volz ffbe957f41 chore: release v1.10.1 (#150) 2026-02-09 21:01:42 +01:00
Daniel Volz 749e92b135 fix: bottle total capacity backward compatibility (#149)
* fix: bottle total capacity shows dash for old medications

Old medications created before the totalPills column was added had
totalPills=null. This caused two issues:

1. MedDetailModal showed '—' instead of the actual capacity in the
   Package Details section (while the Stock section showed correct values)
2. Edit form showed an empty Total Capacity field on mobile

Fix: Fall back to packageSize (looseTablets for bottles) when totalPills
is null, matching the behavior already used in MedicationsPage and the
stock display section.

Added test for backward compatibility scenario.

* chore: retrigger CI
2026-02-09 20:59:30 +01:00
Daniel Volz 5093f96e8a fix: intake reminder catch-up for missed advance notification window (#148)
When the scheduler missed the exact notification minute (due to system sleep,
high load, or GC pauses), the advance reminder was permanently lost. A dead zone
existed between the notify time and the intake time where neither advance nor
missed-intake logic would trigger.

Changes:
- getUpcomingIntakes now catches up intakes where the notify window passed but
  the intake time is still in the future
- Seeding logic sends a catch-up notification for recently missed intakes
  (within grace period) instead of silently seeding state
- Added 4 tests covering catch-up scenarios
2026-02-09 20:58:08 +01:00
github-actions[bot] bd6eccdb22 chore: update test count badges [skip ci] 2026-02-09 18:37:26 +00:00
Daniel Volz 9d289d45c9 chore: release v1.10.0 (#147) 2026-02-09 19:36:04 +01:00
Daniel Volz 3ec1460c4e feat: frontend improvements - shared schedule, bottle type, settings UI, planner notifications (#146)
- Rewrite SharedSchedule to match DashboardPage rendering with time-based consumption
- Add bottle package type support across all views (MedDetail, Refill, Planner, Dashboard)
- Redesign settings page with colored threshold chips, validation, and stock reminder display
- Add shareStockStatus toggle and send manual reminder button
- Pill/pills singular/plural consistency across all views
- Planner send notification via push (Shoutrrr) in addition to email
- Stock overflow warning and past-missed day styling
- Update README: bottles in Smart Inventory, push in Trip Planner, new ENV section
- 708 passing frontend tests including new coverage for all changes
2026-02-09 19:33:54 +01:00
Daniel Volz f56f2b7c88 feat: backend improvements - reminder tracking, share stock status, planner notifications (#145)
- Separate stock/intake reminder tracking in DB with dedicated columns
- Add shareStockStatus setting to control stock visibility on shared links
- Rewrite planner notification to support both email and Shoutrrr push
- Add push notification footer text for intake and stock reminders
- New DB migrations: stock_reminder_tracking (0006), share_stock_status (0007)
- Update backend i18n with demandCalculator section and critically low text
- Add 514 passing backend tests including new coverage for all changes
2026-02-09 19:32:32 +01:00
github-actions[bot] 8ff652459d chore: update test count badges [skip ci] 2026-02-09 07:15:26 +00:00
Daniel Volz fb937e795b fix: planner usage calculation uses user-selected start date (#144)
The Demand Calculator used max(now, start) as the effective planner start,
which caused asymmetric counting when the current time fell between morning
and evening doses. For example, at 15:00 a medication with 07:00+20:00
intakes over 3 days showed 5 pills (2+3) instead of 6 (3+3) because the
morning dose on the start day was skipped while the evening was counted.

Changes:
- Use the user-selected start date directly instead of max(now, start)
- Optimize calculateUsageInRange to skip ahead to the relevant range
  instead of iterating from the original blister start date
- Add regression tests for asymmetric counting and blister-before-range
2026-02-09 08:10:13 +01:00
Daniel Volz 6d6f906a9a chore: update CI workflow and agent configuration (#143)
- docker-build.yml: build on tags + main, set latest only on tags
- release-manager.agent.md: add one-PR-per-feature/fix rule
2026-02-08 22:17:03 +01:00
Daniel Volz 3de1b2ef0c fix: UI polish for intake form, dashboard cards, and schedule (#142)
- Intake form: replace remind checkbox with bell icon + toggle switch
- Intake form: smart takenBy dropdown based on medication's people
- Dashboard: hide DETAILS row for pill bottles on mobile cards
- Dashboard: use status-chip with icons in schedule view (past/today/future)
- Dashboard: reduce spacing between icons and status chips on mobile
- MedDetailModal: show package type in PACKAGE DETAILS heading
- PlannerPage: show dash for bottle blisters column
- Shorten Pill Bottle label in EN/DE translations
- Update related tests
2026-02-08 22:13:52 +01:00
Daniel Volz b07b586eef chore: replace console.log with structured logging (#141)
- Add startup logger (utils/logger.ts) with LOG_LEVEL support
- Add ServiceLogger type for scheduler functions
- Replace all console.log calls with leveled log methods
- Downgrade verbose scheduler info logs to debug level
- Remove unnecessary console.log in auth plugin
2026-02-08 22:09:27 +01:00
Daniel Volz ffcd8983b4 revert: undo "fix: update backend and frontend images to use main tag" (#140)
This reverts commit cdf0088b0f.
2026-02-08 20:17:15 +00:00
daniel cdf0088b0f fix: update backend and frontend images to use 'main' tag 2026-02-08 19:47:23 +00:00
github-actions[bot] 152608731b chore: update test count badges [skip ci] 2026-02-08 19:37:42 +00:00
Daniel Volz 291a90d401 chore: release v1.9.0 (#139) 2026-02-08 20:34:29 +01:00
Daniel Volz 8c5deed4c2 feat: theme dropdown with system preference and comprehensive bottle-type fixes (#138)
- Replace dark/light toggle with Light/Dark/System dropdown menu
- System theme follows OS prefers-color-scheme setting
- Apply theme dropdown to shared schedule page
- Fix 7 packageType (bottle) bugs across stock calc, share, refills, export/import
- Fix planner bottle-type stock calculation and display
- Fix dailyRate double-counting with per-intake takenBy
- Fix About modal update check stale caching
- Fix intake reminder past-intake seeding and push title
- Fix phantom DB path in drizzle.config.ts
- Fix mobile dose field visibility
- Make medication name clickable in dashboard reminder bar
- Improve planner checkbox UX with inline tooltip
- Add 20+ new tests covering all fixes
2026-02-08 20:32:40 +01:00
github-actions[bot] b19bcf02c2 chore: update test count badges [skip ci] 2026-02-08 16:32:40 +00:00
Daniel Volz 27a9910dbd chore: release v1.8.8 (#137) 2026-02-08 17:29:37 +01:00
Daniel Volz eb2e445398 fix: correct stock calculation for both manual and automatic modes (#136)
Manual mode: Use takenAt timestamp instead of dose date-only comparison
to correctly distinguish doses taken before vs after stock correction
on the same day. Add polling race condition guard (mutationInFlightRef)
so Take/Undo immediately reflects in dashboard stock.

Automatic mode: Grid-align effectiveStart to the medication schedule
and use hybrid consumed calculation (time-based + early-taken doses)
for accurate stock counting.
2026-02-08 17:27:47 +01:00
Daniel Volz 61b8812808 ci: fix release workflow ordering and remove redundant workflows (#135)
- Tag builds now also set 'latest' Docker tag (fixes race condition where
  main-push build could overwrite latest with older version)
- Remove duplicate release.yml (create-release job in docker-build.yml
  already handles GitHub releases)
- Remove redundant version-bump.yml (release.sh already bumps versions
  in the release PR)
- Change update-test-badges.yml trigger to workflow_run after successful
  docker-build (prevents parallel execution and ensures correct ordering)
- Update agent instructions and CI documentation to reflect changes
2026-02-08 16:57:40 +01:00
Daniel Volz f7838bd919 chore: release v1.8.7 (#134) 2026-02-08 15:14:14 +01:00
github-actions[bot] b0fd3f4187 chore: update test count badges [skip ci] 2026-02-08 14:13:07 +00:00
Daniel Volz b91717fc19 fix: stock correction not working for bottle type and manual calculation mode (#133)
- Fix bottle type: submitStockCorrection used blister formula for baseTotal
  but getMedTotal uses only looseTablets for bottles. Now uses getPackageSize()
  which handles both types correctly.
- Fix manual mode: same-day taken doses were counted as consumed after a stock
  correction (>= comparison with date-only timestamps). Changed to > so doses
  on the correction day are excluded.
- Add agent instruction: only release-manager may create PRs/push/merge.
2026-02-08 15:12:17 +01:00
Daniel Volz a065adcd82 ci: remove redundant test jobs from docker-build workflow (#132)
Tests are already guaranteed by branch protection (test.yml must pass
before PR can be merged to main). Running them again in docker-build.yml
was redundant and slowed down image builds.

This reduces test runs from 3x to 2x per code change:
- test.yml on PR (required by branch protection)
- update-test-badges.yml on main push (needed for badge counts)

Docker image builds now start immediately after merge.
2026-02-08 15:05:33 +01:00
Daniel Volz 6edf2fa341 docs: add rule to keep README.md up to date after code changes (#131) 2026-02-08 14:45:30 +01:00
Daniel Volz 9e3d548536 chore: make release script non-interactive with CI retry logic (#130)
- Remove y/N confirmation prompt for automation
- Add wait_for_ci() with retry logic (polls until checks appear)
- Auto-detect git remote (origin or github)
- Remove unused /etc/nginx/conf.d tmpfs from compose
- Update release-manager agent docs to match
2026-02-08 14:13:11 +01:00
Daniel Volz e55e415a50 chore: release v1.8.6 (#129) 2026-02-08 14:06:03 +01:00
Daniel Volz 5253d14af7 fix: make frontend image self-contained for read-only filesystems (#128)
Revert Dockerfile to use /tmp redirect for envsubst output, so the image
works regardless of docker-compose.yml tmpfs configuration. Removes the
uid=101,gid=101 requirement from compose that was a breaking change.
2026-02-08 14:03:53 +01:00
github-actions[bot] 4f75d78a2b chore: update test count badges [skip ci] 2026-02-08 12:54:19 +00:00
Daniel Volz 8f9b65147b fix: use PAT for badge workflow to bypass branch protection (#127) 2026-02-08 13:53:19 +01:00
Daniel Volz 571ab00918 chore: release v1.8.5 (#126) 2026-02-08 13:35:52 +01:00
Daniel Volz 27f5478dad fix: clean up nginx read-only filesystem approach (#125)
Remove Dockerfile /tmp workaround hacks (NGINX_ENVSUBST_OUTPUT_DIR and sed).
Use tmpfs with uid=101,gid=101 in docker-compose.yml instead, so the
nginx user can write to /etc/nginx/conf.d directly under read_only: true.
2026-02-08 13:33:40 +01:00
Daniel Volz 5cd519be50 chore: release v1.8.4 (#124) 2026-02-08 13:12:58 +01:00
Daniel Volz e0c5eb4bf3 feat: simplify About modal with single version link to GitHub release (#123)
- Replace separate Frontend/Backend versions with single app version
- Version is now a clickable link to the GitHub release page
- Replace stopwatch SVG with actual app logo (favicon.svg)
- Fix update check UX: previous result stays visible during re-check
- Add 1s minimum delay for update check spinner visibility
- Reserve space for update result to prevent modal jumping
- Remove unused i18n keys (frontend/backend)
- Update release-manager docs with version link info
2026-02-08 13:09:33 +01:00
Daniel Volz aa92bcd96d fix: nginx read_only filesystem compatibility for envsubst (#122)
Redirect NGINX_ENVSUBST_OUTPUT_DIR to /tmp and update nginx.conf include
path so envsubst works with read_only: true in docker-compose.
Add tmpfs mount for /etc/nginx/conf.d for additional write layer.
2026-02-08 13:07:21 +01:00
Daniel Volz 1798a608bc fix: badge workflow commits directly instead of creating PRs (#121)
* fix: badge workflow commits directly instead of creating PRs

Replace peter-evans/create-pull-request with direct git push.
Removes need for pull-requests:write permission and the repo setting
'Allow GitHub Actions to create pull requests'.

Uses [skip ci] in commit message to avoid triggering itself.

* chore: trigger CI
2026-02-08 12:25:33 +01:00
Daniel Volz 2ec9db1c13 chore: release v1.8.3 (#120) 2026-02-08 12:09:52 +01:00
Daniel Volz 042f0cfb29 docs: add version files reminder to release manager agent (#119)
Document that both backend/package.json and frontend/package.json
must be updated before tagging a release, since the About modal
reads versions from these files.
2026-02-08 12:06:20 +01:00
Daniel Volz 78a0d3ac8e fix: use dynamic BACKEND_URL for nginx reverse proxy (#118)
Fixes #96

- nginx.conf converted to template processed by envsubst at container start
- BACKEND_URL env var (default: backend:3000) replaces hardcoded container name
- Docker DNS resolver used for dynamic upstream resolution
- Dockerfile copies nginx.conf as template to /etc/nginx/templates/

This prevents frontend breakage when users customize container names
in their docker-compose.yml.
2026-02-08 12:05:43 +01:00
Daniel Volz 7d6664e684 fix: auto-detect data directory in monorepo without DATA_DIR env var (#117)
- getDataDir() now detects monorepo by checking for ../docker-compose.yml
- DATA_DIR env var removed from .env and .env.example (no longer needed for local dev)
- Docker compose files explicitly set DATA_DIR=/app/data for containers
- Updated tests for monorepo detection logic
2026-02-08 12:04:09 +01:00
Daniel Volz 2a84a43654 fix: unify data directory for dev and prod environments (#116)
Add DATA_DIR env var support to configure the data directory path.
All hardcoded resolve(cwd, 'data') paths now use a central getDataDir()
function from db-utils.ts that checks DATA_DIR first, falling back to
resolve(cwd, 'data').

This prevents local dev (cd backend && npm run dev) from creating a
separate backend/data/ directory instead of using the root data/ folder.

Changes:
- Add getDataDir() to db-utils.ts as single source of truth
- Update all 8 source files that reference the data directory
- Add dotenv fallback to ../.env for local dev from backend/
- Add DATA_DIR documentation to .env.example
- Add 7 new tests for getDataDir and getDbPaths with DATA_DIR
- 493 tests pass, TypeScript clean
2026-02-08 11:20:55 +01:00
Daniel Volz 99bb9c3931 fix: backend planner phantom consumption + PUT stock reset (#115)
Two bugs in the backend medications route:

1. Planner /medications/usage had the same +1 phantom consumption bug
   that was fixed in the frontend (PR #109). After a stock correction,
   effectiveStart was set to max(blisterStart, correctionCutoff) instead
   of correctionCutoff + period, causing 1 dose to be immediately
   counted as consumed.

2. PUT /medications/:id did not reset stockAdjustment when stock fields
   (packCount, blistersPerPack, pillsPerBlister, looseTablets) changed.
   If a user edited stock values to correct their inventory, the old
   stockAdjustment offset was preserved, resulting in wrong totals.

Added 4 tests covering both scenarios.
2026-02-08 11:05:56 +01:00
Daniel Volz 6b3a7b4104 fix: prevent tests from creating stale backend/data directory (#112)
Extract DB utility functions (buildDbUrl, getDbPaths, ensureDataDirectory,
runAlterMigrations, etc.) from client.ts into db-utils.ts.

client.ts contained top-level initialization code (ensureDataDirectory,
createClient) that ran on every import. database.test.ts imported utility
functions from client.ts, which triggered the initialization as a side
effect — creating backend/data/ with a .write-test file and
medassist-ng.db every time tests ran.

Now database.test.ts imports from db-utils.ts (side-effect-free), and
client.ts re-exports everything for backward compatibility.
2026-02-07 14:14:10 +01:00
Daniel Volz 2d9cd0ad1a ci: add path filtering to skip unnecessary CI runs (#111)
test.yml: Use dorny/paths-filter to detect changed paths. Backend
tests only run when backend/**, biome.json, or the workflow itself
changes. Frontend build only runs when frontend/**, biome.json, or
the workflow changes. Jobs skipped via job-level 'if:' are treated
as passed by GitHub required checks.

codeql.yml: Only run on push/PR when JS/TS source files, package
files, or CodeQL config changes. Weekly schedule and manual dispatch
remain unfiltered.
2026-02-07 13:37:13 +01:00
Daniel Volz 098a7655a5 chore: add release-manager agent and move release docs (#110)
Create dedicated GitHub Copilot agent for release management with
4 tasks: branch/PR workflow, version determination, release execution,
and release notes writing.

Move release-specific instructions (workflow, release notes format,
breaking changes) from copilot-instructions.md to the agent file
to keep concerns separated.
2026-02-07 13:32:50 +01:00
Daniel Volz f73c79c6cf fix: stock correction no longer neutralized by phantom consumption (#109)
After correcting medication stock, the coverage calculation immediately
counted 1 dose as consumed (due to +1 in occurrences formula), which
neutralized small corrections like +1 pill.

Fix: start consumption counting from stockCorrectionCutoff + period
(the next scheduled dose) instead of from the correction time itself.

Added 3 frontend tests for stock correction scenarios and 6 backend
e2e tests for the PATCH /medications/:id/stock-adjustment endpoint.
2026-02-07 13:30:44 +01:00
Daniel Volz 06943f5831 fix: add pull-requests write permission to badge workflow (#108)
peter-evans/create-pull-request requires pull-requests: write permission
to create PRs via the GITHUB_TOKEN.
2026-02-07 12:41:26 +01:00
Daniel Volz 73b3eb6686 fix: replace event count limit with time-based window for past schedule (#107)
The groupedSchedule useMemo used slice(0, 2000) to limit events. With daily
medications having start dates far in the past, thousands of past events would
fill all 2000 slots, pushing today and future events completely out of the
display. This caused the past schedule to only show weekly medications (fewer
events) while daily medications appeared missing.

Replace the fixed count limit with a time-based window: only past events
within the scheduleDays window (30/90/180 days) are included. All today and
future events are always included regardless.

Coverage calculations are not affected as they use schedule.events directly.
2026-02-07 00:35:14 +01:00
Daniel Volz a4313afc34 fix: use PR instead of direct push for badge updates (#106)
Branch protection prevents direct pushes to main.
Use peter-evans/create-pull-request action instead.
2026-02-07 00:15:05 +01:00
Daniel Volz 690cb2ff74 fix: correct dose ID generation for empty takenBy arrays (#105)
The takenBy field is a string[]. Empty arrays [] are truthy in JavaScript,
causing d.takenBy ? [...] patterns to generate dose IDs with trailing
hyphens (e.g., '5-0-173...-') instead of base IDs ('5-0-173...').

This mismatch between ID generation and computeMissedPastDoseIds (which
correctly uses .length > 0) caused doses to always appear as missed.

Changes:
- Add expandDoseIds() helper function using correct .length > 0 check
- Replace 8 buggy inline patterns in DashboardPage.tsx
- Refactor SchedulePage.tsx to use shared expandDoseIds()
- Add backend startup repair to strip trailing hyphens from existing IDs
- Add 12 new tests (6 frontend + 6 backend)
2026-02-07 00:08:58 +01:00
Daniel Volz 21127b38ab fix: repair orphaned dose tracking IDs on startup (#104)
Add repairOrphanedDoseIds() function that runs during app startup
(after ALTER migrations) to fix dose tracking entries that became
invalid when medication schedules were changed before PR #103.

The function:
- Generates valid schedule dates for each medication's current intakes
- Detects dose_tracking entries whose dateOnlyMs doesn't match any
  valid schedule date
- Remaps orphaned doses to the nearest valid schedule date within
  half the intake interval
- Preserves person suffixes in dose IDs
- Is idempotent (safe to run on every startup)

This complements PR #103 which only migrates dose IDs on future edits.
The startup repair fixes existing broken data in production databases.

Includes 8 tests covering: valid doses unchanged, 1-day shift repair,
person suffix preservation, out-of-range detection, idempotency,
multi-medication handling, and legacy format fallback.
2026-02-06 22:59:40 +01:00
Daniel Volz f5f189e0a4 fix: migrate dose tracking IDs when intake schedule changes (#103)
When a medication's start date or interval changes, the generated dose
IDs shift (dateOnlyMs values change). Previously, doses marked as taken
under the old schedule were orphaned — they no longer matched the new
schedule's dose IDs, causing them to appear as missed.

Now the PUT /medications/:id endpoint:
1. Parses old intakes from the existing medication row
2. Detects which intake indices had schedule changes
3. Maps old dateOnlyMs values to the nearest new dateOnlyMs
4. Updates dose_tracking entries with the migrated IDs
5. Preserves person suffixes (e.g. -Alice) during migration

Also fixes the start-date cleanup to use date-only comparison,
preventing doses on the start date from being incorrectly deleted
when the start time is after midnight.

Adds 4 integration tests covering weekly day shift, person suffix
preservation, time-only changes, and interval changes.
2026-02-06 22:38:28 +01:00
Daniel Volz 43c5402592 fix: add workflow_dispatch trigger to test badge workflow (#102)
Allows manual triggering of the badge update workflow, useful when
the ANSI fix or other workflow-only changes need to take effect
without waiting for source code changes.
2026-02-06 22:27:01 +01:00
Daniel Volz 02bae889b4 fix: strip ANSI escape codes in test badge workflow (#101)
Vitest 4 outputs ANSI color codes in test results, which caused the
grep regex to fail when extracting test counts. The badge workflow
silently skipped the update, leaving stale counts in the README.

Add a sed pass to strip ANSI escape sequences before parsing.
2026-02-06 22:24:09 +01:00
Daniel Volz ae45054ab7 fix: reset stock adjustment offset on refill (#99)
- Reset stockAdjustment to 0 and lastStockCorrectionAt to now when
  a refill is added, so consumed-pill tracking restarts from the
  new base stock level
2026-02-06 22:04:14 +01:00
Daniel Volz 5818dcc00d feat: add checkbox to include consumption from today until planner start date (#98)
- Add 'Include consumption from today until start date' checkbox to planner
- When checked, usage calculation starts from today instead of max(today, startDate)
- Persist checkbox state in localStorage per user
- Add i18n translations (EN + DE)
- Update planner tests to use dynamic future dates
2026-02-06 22:01:01 +01:00
Daniel Volz 01deea1fa0 fix: dose tracking broken for per-intake takenBy and after medication edits (#100)
- Remove broken isDoseFromPreviousSchedule that falsely dismissed all past doses
  after any medication edit (compared dateOnlyMs < updatedAt incorrectly)
- Fix takenBy normalization in AppContext: event.takenBy (string|null) was passed
  through as-is via || operator instead of being properly converted to string[]
- Fix DashboardPage: 5 locations treated dose.takenBy as single string instead of
  iterating the array, causing per-person dose tracking to silently fail
- Extract isDoseDismissed and computeMissedPastDoseIds as pure testable functions
  from AppContext.tsx into utils/schedule.ts
- Update SharedSchedule.tsx to use shared isDoseDismissed from utils
- Add 22 regression tests covering isDoseDismissed, computeMissedPastDoseIds,
  and full dose-tracking-survives-medication-edit workflows
- Add 'fix bugs, don't test around them' rule to copilot instructions
2026-02-06 21:55:21 +01:00
Copilot 869b5774fb Add Playwright E2E testing infrastructure for local development (#95)
* Initial plan

* Add Playwright E2E testing infrastructure

- Add @playwright/test dependency
- Create playwright.config.ts with best practices configuration
- Create e2e test structure with fixtures and auth setup
- Add E2E tests for auth, dashboard, medications, and settings pages
- Add npm scripts for running E2E tests
- Update .gitignore for Playwright artifacts
- Add E2E test job to CI workflow
- Update vite.config.ts to support BACKEND_URL env variable
- Update biome.json to include e2e files in linting

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

* Remove waitForTimeout anti-pattern from E2E tests

Replace hard-coded timeouts with proper Playwright waiting strategies:
- Use waitForLoadState('networkidle') for page load
- Use element.waitFor() for dynamic elements
- Use expect assertions for state verification

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

* Remove E2E tests from CI workflow

E2E tests will only be run locally as requested.

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>
Co-authored-by: Daniel Volz <mail@danielvolz.org>
2026-02-05 08:26:08 +01:00
dependabot[bot] 7b88d71c8f build(deps): bump @isaacs/brace-expansion in /backend (#94)
Bumps @isaacs/brace-expansion from 5.0.0 to 5.0.1.

---
updated-dependencies:
- dependency-name: "@isaacs/brace-expansion"
  dependency-version: 5.0.1
  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-02-05 07:53:32 +01:00
dependabot[bot] 6296aa1251 build(deps): bump fastify from 5.6.2 to 5.7.3 in /backend (#91)
Bumps [fastify](https://github.com/fastify/fastify) from 5.6.2 to 5.7.3.
- [Release notes](https://github.com/fastify/fastify/releases)
- [Commits](https://github.com/fastify/fastify/compare/v5.6.2...v5.7.3)

---
updated-dependencies:
- dependency-name: fastify
  dependency-version: 5.7.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>
2026-02-05 07:43:42 +01:00
Daniel Volz d2bf5e61c0 chore: release v1.7.1 (#93) 2026-02-03 05:58:54 +01:00
Daniel Volz 31a89356fe fix: prevent crash when takenBy is not an array (#92)
- Add Array.isArray() checks before calling .map() on dose.takenBy
- Fixes TypeError: dose.takenBy.map is not a function
- Affects AppContext missedPastDoseIds calculation
- Affects SchedulePage dose ID generation (3 locations)

This hotfix prevents the app from crashing when dose.takenBy
is null, undefined, or any non-array value.
2026-02-03 05:57:11 +01:00
Daniel Volz 9984392b76 chore: release v1.7.0 (#90) 2026-02-01 00:23:54 +01:00
Daniel Volz 571d94bf7e feat: Add package type support and per-intake takenBy (#89)
## Package Type Feature
- Add 'blister' and 'bottle' package types for medications
- Bottle type uses totalPills for capacity and looseTablets for current stock
- Blister type continues to use packCount/blistersPerPack/pillsPerBlister
- Add doseUnit field for flexible dosing (mg, ml, IU, etc.)
- Full UI support in medication form and detail modal

## Per-Intake TakenBy
- Move takenBy from medication level to individual intakes
- Each intake schedule can now be assigned to a different person
- Update scheduler-utils to handle per-intake takenBy
- Update SharedSchedule to filter by per-intake takenBy
- Backward compatible with existing medication data

## UI Improvements
- Add PasswordInput component with show/hide toggle
- Centralize stockThresholds in AppContext for consistent status display
- Fix SharedSchedule sync issues with per-intake takenBy
- Improve mobile editing experience

## Technical
- Add migrations 0004 and 0005 for schema changes
- Update all relevant tests (1064 tests passing)
- Maintain backward compatibility with ALTER migrations
2026-01-31 23:49:11 +01:00
Daniel Volz ac4b8151e4 fix: filter out doses from previous schedules in SharedSchedule (#88)
- Add updatedAt field to share API response
- Add isDoseFromPreviousSchedule check in SharedSchedule
- Don't count doses scheduled before medication update as missed
- Syncs SharedSchedule behavior with main app's AppContext logic
2026-01-31 08:54:09 +01:00
Daniel Volz b2026637db chore: release v1.6.5 (#87) 2026-01-30 22:27:41 +01:00
Daniel Volz 99ef5bd622 feat: streamline dashboard UI and improve refill reminder (#86)
- Hide Reorder Reminder card when reminders are enabled (avoids redundancy with Reminder Bar)
- Show all low stock medications in Reminder Bar instead of just the next one
- Rename 'Reorder' to 'Refill' throughout the app
- Make medication names clickable in Refill Reminder card (opens detail modal)
- Add daysLeft display for each low stock medication
- Update translations (EN + DE)
2026-01-30 22:21:05 +01:00
Daniel Volz 1dcd333fde feat: add account deletion feature (#85)
* feat: add account deletion feature

- Add DELETE /auth/me endpoint to delete user account and all data
- Add deleteAccount() method to AuthContext
- Add Delete Account button with confirmation modal in UserProfile
- Add danger zone styling (.btn-danger, .profile-danger-zone)
- Add i18n translations for EN and DE
- Add backend tests for account deletion endpoint
- Add timeout settings to frontend vitest.config.ts
- Reduce CI timeout for frontend tests (10min -> 5min)

* fix: improve delete account section layout

- Make profile modal scrollable with max-height
- Add proper horizontal margin to danger zone
- Align delete section with form content

* fix: use ConfirmModal component for delete account dialog

- Replace inline modal with existing ConfirmModal component
- Ensures consistent button styling across all modals
- Add UI consistency rule to AGENTS.md and copilot-instructions.md

* fix: consistent styling for delete account section

- Remove warning text (users know what delete means)
- Remove border-bottom from danger zone title (section has border-top)
- Update copilot-instructions and AGENTS.md with stricter UI consistency rules
- Remove unused deleteAccountHint i18n keys

* chore: remove pre-push test hook (CI handles tests)

Tests were running twice - in pre-push hook and GitHub CI.
Removing local pre-push tests since CI provides authoritative test results.
Use 'npm test' manually before pushing if you want local feedback.
2026-01-30 21:13:11 +01:00
Daniel Volz 9ed039724e fix: use test:run script and add timeouts to badge workflow (#84)
- Add test:run script to frontend package.json (consistent with backend)
- Use npm run test:run instead of npm run test -- --run
- Add timeout-minutes to prevent infinite hangs
2026-01-30 19:30:07 +01:00
Daniel Volz 156e54f0ea fix: add CI=true to test badge workflow (#83)
Frontend tests were running in watch mode without CI=true env var,
causing the workflow to hang for 30+ minutes.
2026-01-30 19:15:54 +01:00
Daniel Volz 47e8dfe9bc fix: use date-only timestamp for stable dose IDs (#82)
- Use date-only timestamp instead of full timestamp for dose ID generation
- Ensures changing intake times doesn't invalidate past dose tracking
- IDs are now immune to time configuration changes
2026-01-30 19:12:25 +01:00
Daniel Volz aed0b20875 refactor: deduplicate formatters and improve test mocks (#81)
- Consolidate duplicate date formatting utilities
- Use shared formatters across backend and frontend
- Clean up test mocks to use consistent test data
- Remove redundant formatting functions
2026-01-30 18:37:24 +01:00
Daniel Volz fcd1b79c56 chore: add .roo/, .roomodes, and AGENTS.md to .gitignore (#80)
* chore: add .roo/ to gitignore

* chore: add .roo/, .roomodes, and AGENTS.md to .gitignore
2026-01-30 18:35:00 +01:00
Daniel Volz e725700d10 fix: only count missed doses scheduled after medication update (#79)
When medication intake times change, dose IDs change (they include
timestamps). Previously, this caused all past doses to appear as
'missed' because the old 'taken' markers no longer matched.

Now doses are only counted as 'missed' if they were scheduled AFTER
the medication's last update (updatedAt). This means:
- Legitimately missed doses still show as missed (e.g., yesterday's
  dose not taken)
- Doses from before a schedule change are NOT counted as missed
  (they were from a previous schedule configuration)

Changes:
- AppContext: Add isDoseFromPreviousSchedule helper
- SchedulePage: Use context's missedPastDoseIds instead of local calc
- Update tests to include missedPastDoseIds in mocks
2026-01-25 20:45:11 +01:00
Daniel Volz 8685e802cd fix: add frontend tests to pre-push hook (#78) 2026-01-25 20:04:03 +01:00
Daniel Volz 1793f636bf docs: update release workflow instructions (#77)
- Remove reference to release script (not used)
- Document automatic version bump via GitHub Action
- Simplify release process description
2026-01-25 19:52:10 +01:00
Daniel Volz 9cf931f243 ci: add automatic version bump on GitHub release (#76)
Creates a workflow that triggers when a release is published and
automatically updates package.json versions in backend/ and frontend/
to match the release tag version.
2026-01-25 19:49:01 +01:00
Daniel Volz 85f4d2dd21 chore: update package.json versions to 1.6.0 (#75)
The release script created tag v1.6.0 but did not update the version
numbers in package.json files. This fix ensures the About modal
displays the correct version.
2026-01-25 19:36:19 +01:00
Daniel Volz 01283ebd15 chore: rename MedAssist to MedAssist-ng in all frontend UI (#74)
Update all visible text from 'MedAssist' to 'MedAssist-ng':
- Auth page titles (login, register)
- Loading/error/initializing states
- SharedSchedule page (loading, expired, error, footer)
- AboutModal fallback text
- i18n strings for export file validation (EN/DE)
- Related test expectations
2026-01-25 19:32:17 +01:00
Daniel Volz 18bcb96869 fix: add automatic retry for auth state fetch on connection errors (#73)
When the server is restarting (e.g., during tsx watch hot reload), the
initial auth state fetch may fail. This change adds automatic retry
logic (up to 3 attempts with 1s delay) to handle transient connection
errors gracefully instead of immediately showing the error screen.
2026-01-25 19:16:24 +01:00
Daniel Volz d516bdea7d fix: add credentials to all fetch calls for auth cookie support (#72)
* fix: add credentials to all fetch calls for auth cookie support

- Add credentials: include to useMedications.ts fetch calls
- Add credentials: include to MedicationsPage.tsx save function
- Add credentials: include to useSettings.ts settings update
- Add credentials: include to useShare.ts share generation
- Add credentials: include to DashboardPage.tsx reminder email
- Add credentials: include to PlannerPage.tsx usage calculation
- Make create-release workflow skip if release already exists

* fix: default to ntfy-style notifications for HTTP URLs

- Change notification logic to use plain text format by default
- Only use JSON format for known webhook services (Discord, Slack, Telegram, Gotify)
- This fixes ntfy URLs not being recognized when hostname doesn't contain 'ntfy'

* feat: highlight medication being edited

- Add blue border and background to the medication row being edited
- Show medication avatar and name in the edit form header
- Makes it easy to identify which medication is being edited when there are many

* fix: use proper URL parsing for webhook detection (CodeQL security fix)

Replace vulnerable .includes() URL checks with proper URL hostname
parsing to prevent bypass attacks (e.g., evil.com?hooks.slack.com).

Fixes CodeQL alerts #33 and #34 (js/incomplete-url-substring-sanitization)
2026-01-25 19:10:41 +01:00
Daniel Volz cab0fcbba7 feat: mobile UI improvements, biome linting, and reminder info display (#71)
* fix: make dismissed doses robust against schedule/timezone changes

- Store dismissedUntil date (YYYY-MM-DD) per medication instead of individual dose IDs
- Add POST /medications/dismiss-until endpoint to set dismissed date
- Add DELETE /medications/:id/dismiss-until endpoint to clear dismissed date
- Update frontend to use medication-level dismissedUntil for filtering
- Remove old dismissMissedDoses function from useDoses hook (was using dose IDs)
- Add backward-compatible ALTER TABLE migration for dismissed_until column
- Add 5 integration tests for dismiss-until functionality
- Update test schemas with new column

The old approach stored individual dose IDs which broke when schedule or timezone
settings changed (dose IDs contain timestamps). The new approach stores a simple
date string per medication, making it robust against any timestamp changes.

* chore: add Biome linter and Husky pre-commit hook

* chore: add unified biome config and pre-push hook

- Add root-level biome.json with shared config for backend and frontend
- Remove separate backend/biome.json and frontend/biome.json
- Add .husky/pre-push hook to run backend tests before push
- Update package.json lint-staged config to use root biome config

* feat(db): add reminder info columns to schema

- Add dismissed_until column to medications table
- Add last_reminder_med_name and last_reminder_taken_by to user_settings
- Generate Drizzle migration 0003
- Add backward-compatible ALTER migrations in client.ts

* feat(frontend): add unsaved changes warning

- Add UnsavedChangesContext for tracking unsaved form state
- Add useUnsavedChangesWarning hook for browser close warning
- Wrap App with UnsavedChangesProvider
- Add i18n translations for unsaved changes dialog (en/de)

* style: apply biome formatting across codebase

- Apply consistent formatting to all TypeScript files
- Organize imports alphabetically
- Use double quotes and tabs consistently
- Fix trailing commas (es5 style)
- Remove frontend/biome.json deletion (already deleted)

* fix(tests): add missing columns to test schemas

Add last_reminder_med_name and last_reminder_taken_by columns to
test CREATE TABLE statements in:
- planner.test.ts
- e2e-routes.test.ts
- integration.test.ts

Also improve runDrizzleMigrations to handle duplicate column errors
gracefully (returns warning instead of failing).

* fix(planner): add missing 'as unknown' type cast for request.user

* fix(security): address CodeQL XSS and SSRF warnings

- Escape all user-provided strings in email HTML templates
- Coerce numeric values with Number() to prevent type injection
- Add redirect:error to fetch() to prevent SSRF via redirect
- Document SSRF validation in settings.ts

* fix(security): refactor SSRF mitigation to reconstruct URL from validated components

CodeQL traces taint through validation functions that return the same string.
Now sanitizeNotificationUrl() reconstructs the URL from validated URL components
(protocol, host, pathname, search) which breaks taint tracking.

- Renamed to sanitizeNotificationUrl() to clarify it returns sanitized data
- Returns reconstructed URL built from URL() parsed components
- Extracts auth credentials separately instead of including in URL string
- Added isNtfy flag to avoid re-parsing the sanitized URL

* fix(security): add SSRF suppression comment for validated notification URL

The fetch() uses a URL that has been validated by sanitizeNotificationUrl():
- Only http/https protocols
- Blocks localhost and loopback IPs
- Blocks private IP ranges (10.x, 172.16-31.x, 192.168.x, 169.254.x)
- Blocks internal hostnames (.local, .internal, .lan)
- redirect: 'error' prevents redirect bypass

This is an intentional feature: users configure their own notification endpoints.
2026-01-25 18:01:35 +01:00
Daniel Volz ecdb9bcbe0 ci: auto-update test count badges in README (#70) 2026-01-23 22:36:26 +01:00
Daniel Volz 9b0d8037e7 docs: show test counts in README badges (454/454 backend, 611/611 frontend) (#69) 2026-01-23 22:27:27 +01:00
Daniel Volz a4d1dd215a docs: add CI status badges to README (#68) 2026-01-23 22:24:29 +01:00
Daniel Volz 8e2fd0a761 chore: release v1.5.0 (#67)
* chore: release v1.4.0

* feat: timezone-aware locale formatting

- Add TIMEZONE_TO_REGION map for 50+ timezones worldwide
- Combine app language with timezone region (e.g., en + Europe/Berlin → en-DE)
- Fix times displaying in wrong timezone (treated as UTC instead of local)
- Add parseLocalDateTime() to handle ISO strings without UTC conversion
- Users now get regional formatting (24h time, local date format) regardless of app language
- Swedish user with en-SE locale now gets yyyy-mm-dd format and 24h time
- German user with en-DE locale gets dd.mm.yyyy format and 24h time
- Add missing i18n translation key 'lastSent'
- Update all getSystemLocale() calls to pass app language parameter

* chore: release v1.5.0

* fix: timezone-independent test for CI (use 14:00 instead of 22:00)

* fix: make timezone test independent of server timezone
2026-01-23 21:42:57 +01:00
Copilot 0a4f8c5948 [WIP] Increase frontend test coverage to above 80% (#63)
* Initial plan

* refactor: simplify useMedicationForm tests to avoid memory issues

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

* Changes before error encountered

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

* test: add comprehensive tests for SchedulePage, SettingsPage, MedicationsPage, and PlannerPage

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

* test: add SharedSchedule theme persistence tests

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

* test: add comprehensive MobileEditModal tests

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

* test: add comprehensive MedDetailModal tests

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

* fix: use fixed timestamps in tests for deterministic behavior

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-01-23 07:36:44 +01:00
Daniel Volz fd055a3a2a Feat/frontend tests (#62)
* test(frontend): add vitest test infrastructure

- Add vitest, testing-library, jsdom dependencies
- Configure vitest with jsdom environment
- Add test setup with mocks for fetch, localStorage, matchMedia
- Set 75% coverage threshold

* Add frontend tests (#61)

* Initial plan

* Add frontend tests - utilities, hooks, and components (21% coverage)

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

* Add more component tests (Auth, AboutModal, ExportModal) and useRefill hook tests - 30% coverage

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

* Add useMedicationForm utility function tests - 30% coverage

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

* Add AppHeader tests and more schedule.ts tests - 32% coverage

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

* Changes before error encountered

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

* Fix page tests and add more tests - 326 tests passing, 34% coverage

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

* Add tests for ProfileModal, UserFilterModal, MedDetailModal - 361 tests, 36% coverage

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

* Add SharedSchedule tests - 366 tests, 39% coverage

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

* Expand page tests - 383 tests, 39% coverage

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

* Add MobileEditModal tests - 409 tests, 40% coverage

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

* Expand Dashboard and Schedule page tests - 427 tests, 40% coverage

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

* Fix code review issues - remove invalid remindEnabled property

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>

---------

Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
Co-authored-by: DanielVolz <3275994+DanielVolz@users.noreply.github.com>
2026-01-22 10:25:11 +01:00
Daniel Volz 8718311876 refactor(frontend): modularize App.tsx into components, pages, hooks, and context (#60)
- Extract App.tsx from 764 lines to ~404 lines
- Create reusable components: MedDetailModal, MobileEditModal, ShareDialog, etc.
- Add AppContext for global state management
- Split pages: DashboardPage, MedicationsPage, SchedulePage, SettingsPage, PlannerPage
- Create custom hooks: useAuth, useMedications, useSettings, useDoses, useSchedule
- Add utility functions in separate modules
- Fix stock status logic (>30 days = green/normal)
- Fix reminder threshold calculation (use reminderDaysBefore not lowStockDays)
- Fix takenBy validation (send [] instead of null)
- Fix datetime format for blister start times (add Z suffix)
- Style 'All OK' status as green/bold

BREAKING: None - all existing functionality preserved
2026-01-22 05:38:34 +01:00
Daniel Volz 89edd74de3 chore: release v1.4.1 (#59)
* chore: release v1.4.0

* chore: release v1.4.1
2026-01-20 19:35:00 +01:00
Daniel Volz 30d72f625d chore: unify data folder and update AI instructions (#58)
- Use single ./data folder for both dev and prod (removes ./backend/data)
- Update docker-compose.dev.yml to use ./data:/app/data
- Remove backend/data/ from .gitignore (only data/ needed)
- Add 'NEVER create PRs without permission' rule to copilot-instructions.md
2026-01-20 19:32:35 +01:00
Daniel Volz cea1a8b119 chore: improve .gitignore and add shared vscode settings (#57)
- Better organized with clear sections
- Added SQLite WAL/SHM files
- Added OS files (Thumbs.db, swap files)
- Added misc caches (.cache/, .turbo/)
- Keep .vscode/settings.json for shared vitest config
- Added root data/ folder (docker-compose mount point)
2026-01-20 19:22:45 +01:00
Copilot 3aa2b608b0 Fix missing Drizzle migrations in production Docker image (#56)
* Initial plan

* fix: Add drizzle migrations folder to production Docker image

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-01-20 18:31:28 +01:00
Daniel Volz e24a540f17 fix: show package size in user medications modal (#54)
The user medications modal (clicking on a 'taken by' badge) was showing
the adjusted stock as total (e.g. 152/152) instead of the package size
(e.g. 152/196).

Changed from getMedTotal() to getPackageSize() for the denominator.
2026-01-18 17:25:47 +01:00
Daniel Volz fae96c9fdd docs: add AI release notes workflow to instructions (#53)
* chore: improve release script for branch protection

- Create PR for version bump instead of direct push to main
- Wait for CI checks before merging
- Auto-merge PR and create signed tag
- Better error handling and gh CLI validation
- Works with GitHub branch protection rules

* chore(ci): create draft releases for manual release notes

Release notes should be descriptive, not auto-generated commit lists.
The workflow now creates a DRAFT release with a template.
User edits the release notes following the style guide, then publishes.

* docs: add AI release notes workflow to instructions
2026-01-18 15:30:07 +01:00
Daniel Volz 11b55fc638 chore: improve release script for branch protection (#52)
* chore: improve release script for branch protection

- Create PR for version bump instead of direct push to main
- Wait for CI checks before merging
- Auto-merge PR and create signed tag
- Better error handling and gh CLI validation
- Works with GitHub branch protection rules

* chore(ci): create draft releases for manual release notes

Release notes should be descriptive, not auto-generated commit lists.
The workflow now creates a DRAFT release with a template.
User edits the release notes following the style guide, then publishes.
2026-01-18 15:20:18 +01:00
Daniel Volz b68c0b0737 chore: release v1.4.0 (#51) 2026-01-18 15:14:55 +01:00
Daniel Volz 1920b47924 feat: Add About section with version info and update check (#50)
* feat: add About section with version info and update check

- Add About menu item in user dropdown
- Show frontend and backend versions separately
- Add 'Check for Updates' feature using GitHub API
- Compare versions using semver logic
- Cache update check results in sessionStorage (1 hour TTL)
- Link to GitHub repository
- Add i18n translations for EN and DE
- Extend health endpoint to return backend version

* fix: correct i18n interpolation in About modal

- Fix copyright year using dynamic interpolation
- Fix update available message (remove duplicate version placeholder)
- Add download link for available updates
- Change license to GPL-3.0

* fix: correct license to MIT

* chore: sync package.json versions to v1.3.1
2026-01-18 15:12:21 +01:00
Daniel Volz 857b1462e3 fix: include stockAdjustment in export/import (#49)
The stockAdjustment and lastStockCorrectionAt fields were not being
exported or imported, causing stock corrections to be lost when
doing an export/import cycle.

Changes:
- Add stockAdjustment to inventory schema in export validation
- Add lastStockCorrectionAt to medication export schema
- Export both fields when generating export data
- Import both fields when restoring from backup
2026-01-18 14:39:39 +01:00
Daniel Volz 813aa0faf9 fix: show package size instead of adjusted total in medications list and modal (#48)
The 'Total' display should show the base package capacity (packs × blisters × pills + loose),
not the corrected stock amount. This is the fixed capacity of a full package.

- Add getPackageSize() helper to calculate base total without stockAdjustment
- Use packageSize in medications list 'Total: X pills'
- Use packageSize in medication detail modal 'Current Stock: X / Y'
- getMedTotal() still includes stockAdjustment for coverage calculations
2026-01-18 14:19:26 +01:00
Daniel Volz 75bb7abebc feat: Stock Correction Modal (#47)
* feat: add stock correction modal with blister-based input

- Add 'Correct Stock' button to medication detail modal
- New modal with Full Blisters + Partial Blister Pills inputs
- Auto-conversion for edge cases (full/negative partial)
- New stockAdjustment field for DB corrections without touching looseTablets
- New lastStockCorrectionAt timestamp to ignore old consumed doses after correction
- Tracking data preserved for future statistics
- Add Drizzle migrations for new columns
- Add translations for en/de

* fix: add stock_adjustment columns to e2e/integration test schemas
2026-01-18 12:53:25 +01:00
Daniel Volz bb46b26ec6 feat: improve export/import UI with modal and integrated success message (#46)
- Replace export checkbox with modal offering 'With Images' or 'Data Only' options
- Replace styled label with proper button for file import
- Replace browser alert() with integrated success banner for import confirmation
- Add i18n translations for new modal texts (EN/DE)

The export modal provides a cleaner UX with clear explanations for each option.
The import success message now displays inline with theme-appropriate styling.
2026-01-18 09:37:25 +01:00
Daniel Volz 8d22669bef fix: export/import dismissed doses and person-specific dose IDs (#45)
- Add 'dismissed' field to dose history export/import
- Add 'takenByPerson' field to handle person-suffixed dose IDs (e.g., 5-0-timestamp-Daniel)
- Update parseDoseId() to extract person suffix from dose ID
- Update buildDoseId() to include optional person suffix

This fixes import losing:
1. Which past doses were marked as taken
2. Which doses were dismissed (cleared missed)
3. Person-specific dose tracking for shared schedules
2026-01-18 09:19:23 +01:00
Daniel Volz fb0b3df794 feat: add option to exclude images from export (#44)
- Add 'Include medication images' checkbox in export section
- Default: enabled (full backup with images)
- Disabled: much smaller export (~50 KB instead of several MB)
- Helpful for quick backups or when importing to another instance
2026-01-18 09:12:12 +01:00
Daniel Volz 48ae48a165 fix: increase body size limit for large imports (#43)
- Increase nginx client_max_body_size from 10MB to 50MB
- Add bodyLimit: 50MB to Fastify import route
- Allows importing exports with many base64-encoded images
2026-01-18 09:05:57 +01:00
Daniel Volz a190667320 fix: improve import error handling and add refill_history table migration (#42)
- Add CREATE TABLE IF NOT EXISTS for refill_history in ALTER migrations
- Improve frontend import error handling to show server errors properly
- Parse response as text first to handle non-JSON error responses
2026-01-18 08:55:48 +01:00
Daniel Volz cfdca04df9 fix: handle invalid date values in export route (#41)
Added robust date handling to prevent 'Invalid time value' errors when
exporting dose history and share links. The code now safely handles:
- Date objects that might be invalid
- Timestamps stored as numbers or strings
- Missing or null values

Falls back to current date if conversion fails.
2026-01-18 08:36:17 +01:00
Daniel Volz a28e3724ae docs: emphasize ALTER migration requirement for new features (#40)
Added prominent warning in copilot-instructions.md that every new feature
touching DB must include ALTER migrations in client.ts, not just schema.ts.
This prevents production 500 errors on existing databases.
2026-01-18 08:27:29 +01:00
Daniel Volz 42d00dd1c0 fix: add stock_calculation_mode ALTER migration for backward compatibility (#39)
Older production databases were missing the stock_calculation_mode column,
causing 500 errors on /export endpoint. Added migration to add column
with default value 'automatic'.
2026-01-18 08:23:35 +01:00
Daniel Volz 8928915947 fix: remove duplicate 'New Medication' button from edit form (#38)
The button was redundant as it already exists in the medication list header.
2026-01-17 23:13:57 +01:00
Daniel Volz cfd37ca526 fix: close medication detail modal before navigating to edit (#37)
When clicking 'Edit' in the medication detail modal, the modal
now properly closes before navigating to the medications page.
Previously the modal remained visible behind the edit form.
2026-01-17 23:08:04 +01:00
Daniel Volz 288e075786 fix: browser back gesture closes modal instead of navigating (#36)
* fix: browser back gesture closes modal instead of navigating

- Push history state when opening medication detail modal
- Handle popstate event to close modal on browser back
- Replace direct setSelectedMed(null) with closeMedDetail() helper
- Improves mobile UX: swiping back closes modal instead of leaving page

* feat: add back-swipe support for all modals

- Add history.pushState/popstate handling for all modal types
- Profile, ShareDialog, EditModal, RefillModal, ImageLightbox,
  ScheduleLightbox, UserFilter now all support browser back button
- Mobile users can now swipe back to close any modal instead of
  navigating away from the app
- ESC key also triggers proper history-based close for all modals
- Fix duplicate openShareDialog function
- Fix recursive call bug in openUserFilter

* fix: prevent past days count from wrapping to new line

- Add flex-wrap: nowrap to .past-days-toggle
- Add white-space: nowrap and flex-shrink: 0 to .past-days-count
- Ensures (7 Tage), (14 Tage) etc. stays on same line as label

* fix: improve schedule row layout for mobile screens

- Stack schedule label and value vertically on small screens (<400px)
- Add word-break for long text values
- Prevents 'Einnahmeprüfung' and '15 Min. vor geplanter Zeit' from overlapping

* feat: add back-swipe support for image lightbox on share page

- Add history.pushState/popstate handling for lightbox in SharedSchedule
- Mobile users can now swipe back to close image instead of navigating away
2026-01-17 23:00:39 +01:00
Daniel Volz 13c6430dee fix(ci): disable provenance/sbom to remove unknown/unknown image (#35) 2026-01-17 22:22:35 +01:00
Daniel Volz ec3793dd05 chore(ci): add path filters to skip CI for docs-only changes (#33)
* chore(ci): add path filters to skip CI for docs-only changes

- test.yml: Only run on changes to backend/**, frontend/**, or the workflow itself
- docker-build.yml: Only run on code/docker changes (tags still trigger unconditionally)

* docs: move screenshots below GIF and add visual indentation
2026-01-17 22:06:34 +01:00
Daniel Volz d5f6ceba19 docs: Update README with new features and screenshots (#32)
- Add Medication Refill and Data Export/Import to features
- Add collapsible screenshots section with 8 new screenshots
- Include desktop views: Dashboard, Medication Detail, Edit Form, Planner, Shared Schedule
- Include mobile views: Dashboard, Medications, Schedule
- Remove old dashboard.png, add properly named screenshots
2026-01-17 21:57:29 +01:00
Daniel Volz 6f0553d7dd docs: update release notes style guide (#31)
- Add structured format with What's New, Features, Where to Find It
- Prefer bold feature names with inline descriptions
- Minimize emoji usage
- Add comprehensive example based on v1.3.0 release
2026-01-17 21:18:10 +01:00
Daniel Volz 82b2be48cd feat: Add Medication Refill feature with mobile UI improvements (#30)
* feat: Add Medication Refill feature with UI improvements

- Add refill functionality to medications (add packs/loose pills)
- Add refill API endpoint with history tracking
- Add refill section in edit forms (desktop & mobile)
- Add refill modal in medication detail view
- Add refill history display with expand/collapse
- Add schedule lightbox for clicking medication images
- Improve button styling with primary/info/success classes
- Move '+ New entry' button to medication list header
- Lightbox size: 50% desktop, 90% mobile
- Update selectedMed sync after stock changes
- Migrate from schema-sql.ts to Drizzle Kit migrations

* fix: Improve mobile tooltips and refill modal layout

- Center tooltips on screen for mobile devices (fixed position)
- Close tooltips automatically when scrolling on touch devices
- Use click-based tooltip activation instead of hover on mobile
- Fix refill modal buttons to display in two rows on mobile
2026-01-17 20:39:18 +01:00
Daniel Volz 269a549563 docs: update release notes guidelines - less is more (#29)
- Add 'Keep it short' principle
- Add DO NOT include list (no technical details)
- Simplify example release notes
- Remove verbose example with implementation details
2026-01-16 22:04:34 +01:00
Daniel Volz 055c0dfe10 feat: Add Clear Missed Doses feature (#28)
- Add dismissed column to dose_tracking table schema
- Add POST /doses/dismiss endpoint for batch dismissing
- Add DELETE /doses/dismiss endpoint to un-dismiss all
- Add frontend dismissedDoses state and missedPastDoseIds useMemo
- Add Clear missed button with confirmation dialog
- Add CSS styles for .past-days-header and .clear-missed-btn
- Add i18n translations for en/de
- Add 5 tests for dismiss endpoints
- Update test schemas with dismissed column

Allows users to acknowledge missed doses without deducting stock.
Closes #28
2026-01-16 21:56:35 +01:00
Daniel Volz 318f63657b docs: add Pushover and improve push notification documentation (#27)
- Add Pushover to supported services list in UI
- Add Gotify to supported services list
- Add URL placeholder with examples (ntfy, pushover)
- Add link to shoutrrr.dev for all available services
- Change input type from 'url' to 'text' (shoutrrr URLs aren't HTTP URLs)
- Add comprehensive Push Notifications section to README
- Include URL examples for ntfy, Pushover, Gotify, Discord, Telegram

Closes feature request for Pushover support.
2026-01-16 21:05:40 +01:00
Daniel Volz 718157e472 fix: clean up Export/Import UI (#26)
- Fix tooltip visibility (overflow: visible for export card)
- Remove unnecessary 'Include sensitive data' checkbox
- Always export all data including notification URLs
- Remove unused CSS styles for checkbox and warning
2026-01-16 20:50:29 +01:00
Daniel Volz f00f11aa55 docs: add rule to never release without explicit permission (#25) 2026-01-16 20:35:25 +01:00
Daniel Volz 4081e03970 fix(ui): improve Export/Import section layout (#24)
* fix(ui): improve Export/Import section layout and styling

- Redesign as two-column card layout with icons
- Remove CAPSLOCK from labels
- Add proper descriptions for export and import sections
- Improve checkbox and button styling
- Make responsive for mobile

* fix(ui): clean up Export/Import section design

- Remove ugly folder icons
- Replace hint text box with info tooltip on title
- Cleaner h3 styling with uppercase letters
- Better visual hierarchy
2026-01-16 20:32:39 +01:00
Daniel Volz 9cfbf89d46 fix: correct release workflow to find previous tag (#23) 2026-01-16 20:06:29 +01:00
Daniel Volz ffab9ef4da feat: Add data export/import functionality (#22)
* feat: add data export/import functionality

- Add /export and /import API endpoints with schema-independent JSON format
- Export includes: medications, dose history, settings, share links
- Uses _exportId references for medications, remapped on import
- Images exported as base64 data URLs
- Optional sensitive data inclusion (shoutrrr URLs, etc.)
- Import replaces all existing data with confirmation warning
- Add comprehensive test coverage
- Add English and German translations
- Add frontend UI in Settings page with export/import controls

* fix: correct JSX structure and TypeScript types

- Fix modal placement outside ternary expression in Settings
- Add type assertion for request.body in import route test

* docs: translate copilot-instructions to English

- Add explicit rule that English is the primary language
- Translate all German sections to English
- User may communicate in German, but all project artifacts must be English
2026-01-16 19:59:48 +01:00
Daniel Volz ed707444a2 chore: release v1.1.0 (#19) 2026-01-10 21:29:53 +01:00
Daniel Volz d0a40bde88 feat: Nagging reminders with max limit + ENV defaults for settings (#18)
* ci: prevent duplicate test runs - tests only on PRs, inline tests for builds

* docs: add testing and CI/CD documentation

* security: fix CodeQL vulnerabilities (SSRF, XSS, rate limiting)

- Add URL validation to prevent SSRF attacks on notification endpoints
  - Block private IPs (10.x, 172.16-31.x, 192.168.x, 169.254.x)
  - Block localhost and internal hostnames
  - Only allow HTTP/HTTPS protocols
- Add HTML escaping for medication names in email templates (XSS)
- Add stricter rate limiting for auth routes (5 req/15min for login/register)
- Add SSRF protection tests (405 tests total)

* security: add rate limiting to remaining auth routes

* chore: add CodeQL config to suppress rate-limit false positives

Rate limiting IS implemented via @fastify/rate-limit plugin:
- Global: 100 req/min (index.ts)
- Auth routes: 5-10 req/min via config.rateLimit option

CodeQL doesn't recognize Fastify's plugin-based rate limiting pattern.

* ci: switch to CodeQL Advanced Setup

- Add custom codeql.yml workflow
- Configure to use codeql-config.yml
- Exclude js/missing-rate-limiting rule (false positive)
  Rate limiting is implemented via @fastify/rate-limit plugin

* ci: add explicit permissions to workflows

Fixes CodeQL 'Workflow does not contain permissions' warnings.
Sets minimal 'contents: read' at top level.

* ci: add manual trigger to CodeQL workflow

* ci: add explicit permissions to all workflow jobs

* build(deps): bump esbuild, @vitest/coverage-v8 and vitest in /backend

Bumps [esbuild](https://github.com/evanw/esbuild) to 0.27.2 and updates ancestor dependencies [esbuild](https://github.com/evanw/esbuild), [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) and [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest). These dependencies need to be updated together.


Updates `esbuild` from 0.21.5 to 0.27.2
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG-2024.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.21.5...v0.27.2)

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

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

---
updated-dependencies:
- dependency-name: esbuild
  dependency-version: 0.27.2
  dependency-type: indirect
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 4.0.16
  dependency-type: direct:development
- dependency-name: vitest
  dependency-version: 4.0.16
  dependency-type: direct:development
...

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

* docs: add GitHub issue templates

- Bug report template with deployment type, browser info, logs
- Feature request template with affected area, priority
- Config with link to discussions and README
- Optimize test.yml to skip tests for non-code changes

* Initial plan

* Remove database schema duplication by creating shared schema-sql.ts module

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

* Refactor frontend date formatting to eliminate duplication

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

* docs: Add branch protection warning and PR workflow to instructions

* ci: remove paths filter from test workflow to fix branch protection

* fix: add .js extension to schema-sql imports for ESM compatibility (#15)

* feat: add setting to skip reminders for taken doses

- Add skipRemindersForTakenDoses setting to database schema
- Extend settings API to save and load new setting
- Update intake reminder scheduler to filter taken doses
- Add frontend toggle in settings with i18n (EN/DE)
- Only check doses from today (timezone-aware)
- Update all test schemas with new field
- All 405 tests passing

* feat: add repeat reminders for missed doses

- Add repeatRemindersEnabled and reminderRepeatIntervalMinutes settings
- Refactor intake reminder state from array to object with sendCount tracking
- Update scheduler to send repeated reminders at configurable intervals
- Only remind for today's doses (timezone-aware filtering)
- Add frontend toggle and interval input (5-480 minutes) in settings
- Maintain backward compatibility for old state file format
- Update all test schemas and assertions
- All 406 tests passing

* feat: add nagging reminders with max limit and ENV defaults

- Add maxNaggingReminders setting to limit repeat reminders (1-20)
- Add ENV defaults for all user settings (DEFAULT_*)
- Add ALTER TABLE migrations for backward compatibility
- Add smtpConfigured/shoutrrrConfigured to health endpoint
- Fix Push toggle to allow enabling without existing URL
- Disable skip/repeat toggles when no notifications enabled
- Add Pocket ID button to registration page
- Add getTodaysIntakes() for repeat reminder logic
- Update translations (en/de) for new settings
- Add comprehensive tests for new features

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@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-01-10 21:05:44 +01:00
dependabot[bot] e754729e08 build(deps): bump react-router and react-router-dom in /frontend (#17)
Bumps [react-router](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router) to 7.12.0 and updates ancestor dependency [react-router-dom](https://github.com/remix-run/react-router/tree/HEAD/packages/react-router-dom). These dependencies need to be updated together.


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

Updates `react-router-dom` from 7.11.0 to 7.12.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.12.0/packages/react-router-dom)

---
updated-dependencies:
- dependency-name: react-router
  dependency-version: 7.12.0
  dependency-type: indirect
- dependency-name: react-router-dom
  dependency-version: 7.12.0
  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-01-09 07:58:44 +01:00
356 changed files with 110946 additions and 24850 deletions
+82 -3
View File
@@ -11,9 +11,40 @@ PGID=1000
PORT=3000
CORS_ORIGINS=http://localhost:4174
LOG_LEVEL=info
LOG_LEVEL=warn
# Timezone for scheduled reminders (e.g., Europe/Berlin, America/New_York)
# 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
# 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
# =============================================================================
@@ -25,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=
@@ -78,4 +112,49 @@ REMINDER_DAYS_BEFORE=7
# Admin settings (not editable in UI)
REMINDER_HOUR=6 # 24h format (0-23), e.g. 6 = 6:00 AM, 18 = 6:00 PM
REMINDER_MINUTES_BEFORE=15 # Minutes before intake to send reminder
EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
# =============================================================================
# Default User Settings (applied when new user is created)
# =============================================================================
# These ENV values are only used as DEFAULTS when a new user is created.
# Once a user saves their settings in the app, these ENV values are ignored
# for that user - their saved preferences take precedence.
#
# Useful for server admins to pre-configure settings for all new users.
# =============================================================================
# Email notifications (requires SMTP config above)
# DEFAULT_EMAIL_ENABLED=false
# 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
# DEFAULT_REMINDER_REPEAT_INTERVAL_MINUTES=30
# DEFAULT_MAX_NAGGING_REMINDERS=5
# DEFAULT_SKIP_REMINDERS_FOR_TAKEN_DOSES=false
# Stock reminder settings
# DEFAULT_REPEAT_DAILY_REMINDERS=false
# Stock thresholds (days of supply)
# DEFAULT_LOW_STOCK_DAYS=30
# DEFAULT_NORMAL_STOCK_DAYS=90
# DEFAULT_HIGH_STOCK_DAYS=180
# 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_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
+533
View File
@@ -0,0 +1,533 @@
---
name: release-manager
description: Manages the full release lifecycle - from branching and PRs through versioning and GitHub release notes. Use when code changes are complete and ready to ship.
argument-hint: Describe what was changed, e.g., "fix stock correction bug" or "new refill tracking feature"
---
# Release Manager Agent
You are the release manager for **MedAssist-ng**. Your job is to guide code from "done" to "released" following the project's strict branch protection, CI pipeline, and semantic versioning rules.
**All output (commits, PR titles, release notes) MUST be in English**, even if the user communicates in German.
## 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).
## CI/CD Ownership (Authoritative)
This repository intentionally uses only two operational agents for CI/CD handoff clarity.
- **No separate CI/CD agent is used.**
- **`@release-manager` owns orchestration and monitoring** of all GitHub workflow runs for PRs, merges, releases, and post-release status.
- **`@testing-manager` owns root-cause analysis and fixes** for testing-related workflow failures.
### Current Workflow Assignment
| Workflow | Primary Owner | Responsibility |
|---------|----------------|----------------|
| `.github/workflows/test.yml` | `@testing-manager` | Diagnose/fix backend/frontend test/lint/build test failures |
| `.github/workflows/e2e.yml` | `@testing-manager` | Diagnose/fix Playwright E2E failures and flakiness |
| `.github/workflows/codeql.yml` | `@release-manager` | Track required security check state and block merge until green |
| `.github/workflows/docker-build.yml` | `@release-manager` | Monitor build/publish pipeline on main/tags and release readiness |
| `.github/workflows/update-test-badges.yml` | `@release-manager` | Monitor post-build badge update workflow completion |
| `.github/workflows/add-to-project.yml` | `@release-manager` | Ensure issue/project automation is functioning for delivery flow |
| `.github/workflows/project-auto-done.yml` | `@release-manager` | Auto-move project items to "Done" when issues close or PRs merge |
### Monitoring Rule (Must Follow)
- 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 Operations (GitHub MCP + gh CLI Exception)
- 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.
---
## PR Strategy: One PR per Feature/Fix
**Each feature or bug fix MUST be submitted as its own separate PR.** Do NOT bundle multiple unrelated changes into a single PR.
**Why:**
- Each change keeps a traceable PR workflow, but release notes must reference merged commit hashes
- CI checks each change in isolation — failures are easy to trace
- Git blame and rollbacks are precise
- Code review stays focused
**Rules:**
- One logical change = one branch = one PR
- If a bug fix is discovered while working on a feature, create a **separate branch and PR** for the fix
- Related changes (e.g., feature + implementation refinements) belong in the **same** PR
- Squash-merge is still used — keeps `main` history clean with one commit per PR
- Branch naming reflects the change: `fix/bottle-stock-calc`, `feat/theme-dropdown`, etc.
**Example — bad (bundled):**
```
PR #138: "feat: theme dropdown, fix bottle bugs, fix planner, fix reminders"
```
**Example — good (separate):**
```
PR #138: "fix: bottle-type stock calculations across all subsystems"
PR #139: "fix: intake reminder past-intake seeding"
PR #140: "feat: theme dropdown with Light/Dark/System options"
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. 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
1. Determine branch name from the change type:
- 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 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
```
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"
```
Commit message prefixes: `feat:`, `fix:`, `chore:`, `refactor:`, `docs:`
### Step 3: Push and Create PR
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
```
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 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 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
```
---
## Task 2: Determine Version Number
When the user wants to create a release:
### Step 1: Check Current Version
```bash
grep '"version"' backend/package.json
```
Also check the latest git tag:
```bash
git tag --sort=-v:refname | head -5
```
### Step 2: Analyze Changes Since Last Release
```bash
git log $(git describe --tags --abbrev=0)..HEAD --oneline
```
Read through the commits to understand what changed.
### Step 3: Select SemVer Level
Apply these rules strictly:
| Change Type | Version Bump | Example |
|------------|-------------|---------|
| Bug fixes only, no new features | **patch** | `1.4.2` → `1.4.3` |
| New features (backward compatible) | **minor** | `1.4.2` → `1.5.0` |
| Breaking changes (DB schema without migration, removed ENV vars, changed API) | **major** | `1.4.2` → `2.0.0` |
**Guidelines:**
- When in doubt between patch and minor, prefer **minor** if any user-visible behavior is new.
- Bug fixes that also introduce small UX improvements = **patch**.
- Multiple bug fixes in one release = still **patch**.
- New UI sections, new API endpoints, new settings = **minor**.
- If a user can run `docker compose pull && docker compose up -d` without changing anything → NOT a breaking change.
**Present your version recommendation to the user with reasoning and wait for confirmation.**
---
## Task 3: Execute Release
Use the release script — it is **fully non-interactive** (no y/N prompts) and handles the entire flow automatically:
```bash
./scripts/release.sh <patch|minor|major|x.y.z>
```
The script performs these steps in order:
1. Checks out and updates `main`
2. Creates release branch `chore/release-X.Y.Z`
3. Bumps version in `backend/package.json` and `frontend/package.json`
4. Commits, pushes, and creates a PR
5. Waits for CI checks (with retry logic — polls every 15s, waits up to 10 minutes)
6. Merges the PR (squash + delete branch)
7. Creates a signed tag `vX.Y.Z` and pushes it
**Release precondition:** never start the release flow from a dirty or stale mixed workspace. If the repository root contains unrelated/stale diffs, first switch to a clean base that matches the authoritative remote main.
**The script auto-detects the git remote** (`origin` or `github`) and uses it consistently.
**CI wait behavior:** GitHub Actions can take 10-30 seconds before checks appear on a new PR. The script waits 20 seconds initially, then polls every 15 seconds until checks are registered, then watches them to completion. Maximum wait is 10 minutes.
**On failure:** If CI fails, the script exits with an error. The release branch and PR remain open for inspection. Fix the issue, push to the branch, and the PR will re-run CI. Then merge manually or re-run the script.
### Version Files (MANDATORY)
The version number is displayed in the **About modal** (Settings → About) as a single unified app version. This version is a **clickable link** pointing to the corresponding GitHub release (`https://github.com/DanielVolz/medassist-ng/releases/tag/vX.Y.Z`). The version is read from:
- **`backend/package.json`** → Backend version, returned by `/health` endpoint
- **`frontend/package.json`** → Frontend version, injected at build time via Vite's `__APP_VERSION__` define and used to construct the release link
**Both files MUST be updated to the new version before tagging a release.** If forgotten:
- The About modal will show the old version
- The version link will point to a non-existent GitHub release page
### Manual Release (if script is not available)
1. Create release branch:
```bash
git checkout main && git pull origin main
git checkout -b chore/release-X.Y.Z
```
2. Update versions in **both** `backend/package.json` and `frontend/package.json` to `X.Y.Z`
3. Commit, push, create PR, wait for CI, merge (same as Task 1)
4. Create signed tag:
```bash
git checkout main && git pull origin main
git tag -s "vX.Y.Z" -m "Release vX.Y.Z"
git push origin "vX.Y.Z"
```
### After Tagging
- The `docker-build.yml` workflow automatically builds and pushes Docker images to GHCR with both versioned tags (`1.8.7`, `1.8`) and `latest`.
- The `update-test-badges.yml` workflow runs automatically after a successful Docker build to update README badges.
- Track progress: `https://github.com/DanielVolz/medassist-ng/actions`
---
## Task 4: Write Release Notes
When the user asks to write release notes (MANDATORY for minor/major releases):
### Step 1: Gather Changes
```bash
git log vPREVIOUS..vNEW --oneline
```
Read the actual code changes (not just commit messages) to understand what was added or fixed.
### Step 2: Write Release Notes
**Release title:** Use just `vX.Y.Z` (e.g., `v1.4.1`), NOT "Release vX.Y.Z".
**Required structure:**
1. **"What's New"** (1-2 sentences): Brief intro explaining the main change
2. **"New Features" / "Bug Fixes" / "Improvements"**: Grouped bullet points with **bold feature names** and descriptions
3. **"Where to Find It"**: Tell users where they can access the new feature or see the fix
4. **Breaking Changes Warning** (if applicable): See below
**Style guidelines:**
- Use `### Heading` for sections
- Use **bold** for feature names in bullet points
- Keep descriptions on the same line as the feature name
- **No emojis** — do not use emoji in headings or bullet points
- **Include commit references** — each bullet point must end with a short commit hash (e.g., `(ab12cd3)`) that links to the commit URL.
- **Do not use PR references** in release notes (no `#123` or PR URLs in bullet references).
- Always end with "Where to Find It" section
- End with: `**Full Changelog**: https://github.com/DanielVolz/medassist-ng/compare/vPREV...vNEW`
**ONLY include user-relevant changes.** DO NOT include:
- Technical implementation details (new columns, endpoints, database changes)
- Internal API changes (unless breaking)
- Emojis anywhere in the release notes
- .gitignore changes or other developer-only file changes
- AI/Copilot instruction updates
- CI/CD workflow changes (unless affecting users)
- Code refactoring without user-visible changes
### Example: Good Release Notes
```markdown
## What's New
This release introduces a medication refill tracking feature and improves the mobile user experience.
### New Features
- **Medication Refill**: Track when you refill your medications with a single click. Add full packs or individual pills and view complete refill history. (ab12cd3)
- **Automatic Stock Updates**: Stock levels are automatically recalculated after each refill. (ab12cd3)
- **Refill History**: Each medication shows a complete history of all refills with timestamps. (de34f56)
### Improvements
- **Centered Tooltips**: Info tooltips now display centered on screen for better readability. (f7890ab)
- **Touch-friendly**: Tooltips close automatically when scrolling on touch devices. (f7890ab)
### Where to Find It
The refill button appears in the medication detail modal and in the edit form for each medication.
**Full Changelog**: https://github.com/DanielVolz/medassist-ng/compare/v1.2.3...v1.3.0
```
### Breaking Changes Warning
If the update breaks existing configurations or stored data, it MUST be prominently warned:
**Breaking Changes include:**
- Database schema changes without automatic migration
- Removed or renamed ENV variables
- Changed API endpoints
- Incompatible `.env` format changes
- Loss of stored data after update
**Format:**
```markdown
## ⚠️ BREAKING CHANGES - Please read before updating!
**Database migration required**: This update changes the database schema.
Existing installations need to:
1. Create backup of `data/` folder
2. Stop containers
3. Perform update
4. If issues occur: Rollback using backup
**ENV variables changed**:
- `OLD_VAR` was renamed to `NEW_VAR`
- `REMOVED_VAR` is no longer supported
```
**What is NOT a Breaking Change:**
- ✅ New optional columns with DEFAULT values
- ✅ New ENV variables (with sensible defaults)
- ✅ New features that don't affect existing data
- ✅ Bug fixes that correct behavior
### Step 3: Publish
Publish the release via `gh` CLI:
```bash
# 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)
When the release includes **new features** (minor or major version bump), you MUST check whether the `README.md` needs to be updated **before** executing the release.
### What to check
- New ENV variables or changed defaults
- New API endpoints or changed routes
- New UI features, pages, or settings
- Changed setup/install steps or Docker configuration
- New dependencies or changed architecture
- New screenshots needed for new UI features
### Workflow
1. Review the changes included in the release
2. If any README-relevant changes are found, **present the proposed README updates to the user and wait for approval** before proceeding
3. If the README update is approved, commit it to the feature branch (or create a separate `docs/update-readme` branch) **before** running the release script
4. Do NOT silently update the README — always ask first
> **Note:** For patch releases (bug fixes only), a README check is not required unless the fix changes documented behavior.
---
## Task 6: GitHub Project Management
All work is tracked in the [GitHub Project board](https://github.com/users/DanielVolz/projects/1) (Project ID: `PVT_kwHOADH82s4BO2OT`).
### Board Columns (Status)
| Column | Color | Description |
|--------|-------|-------------|
| Triage | Purple | New issues needing review |
| Backlog | Green | Accepted, not yet started |
| Ready | Blue | Ready to be picked up |
| In progress | Yellow | Currently being worked on |
| Done | Orange | Completed |
### Custom Fields
| Field | Options | Usage |
|-------|---------|-------|
| **Type** | Bug (red), Feature (green), Chore (gray), Documentation (blue) | Categorize the work |
| **Priority** | High (red), Medium (orange), Low (yellow) | Set urgency |
| **Size** | XS, S, M, L, XL | Estimate effort |
### Workflow During PRs
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 issue/project status via GitHub MCP.
**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 |
|--------|-----------|
| Triage | `826183f5` |
| Backlog | `c7cb819e` |
| Ready | `13307944` |
| In progress | `732e285e` |
| Done | `ca45af98` |
Status field ID: `PVTSSF_lAHOADH82s4BO2OTzg9bdkE`
### Issue Labels
| Label | Applied by | Purpose |
|-------|-----------|--------|
| `enhancement` | Feature request template | New features |
| `bug` | Bug report template | Bug fixes |
| `triage` | Both templates | Needs review |
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
```
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, 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)
Ready for release?
7. Check current version (git tag + package.json)
8. Analyze changes → determine SemVer level
9. If minor/major: check README.md for needed updates (Task 5)
10. Run ./scripts/release.sh <patch|minor|major>
(or manually: branch → version bump → PR → CI → merge → tag)
11. Write release notes (mandatory for minor/major)
12. Publish GitHub release
Docker images built automatically via CI
```
+194
View File
@@ -0,0 +1,194 @@
---
name: testing-manager
description: Owns testing strategy, test implementation, local validation, and CI test triage for backend, frontend, and Playwright E2E.
argument-hint: Describe what to test, e.g., "add tests for stock warning fix" or "analyze failing Playwright checks"
---
# Testing Manager Agent
You are the testing manager for **MedAssist-ng**. Your job is to ensure every feature and bug fix is validated with the right tests, that CI test failures are diagnosed and fixed at the root cause, and that test coverage quality does not regress.
**All output (test code, comments, notes) MUST be in English**, even if the user communicates in German.
## Critical Testing Rules
- **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
- **`@testing-manager` owns testing workflows only**: `.github/workflows/test.yml` and `.github/workflows/e2e.yml`.
- **`@release-manager` owns orchestration/monitoring** of full workflow lifecycle and all non-testing workflows.
- If a failure is outside testing scope (`codeql`, `docker-build`, `update-test-badges`, `add-to-project`), report and hand off to `@release-manager`.
## Test Stack & Locations
- **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:
- Backend tests: `backend/src/test/*.test.ts`
- 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. 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 && 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 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 && 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 && 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
- Prefer using test utilities from backend test setup (e.g. `buildTestApp`, helper factories).
- 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 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
When test checks fail:
1. Retrieve exact failed jobs and logs.
2. Categorize failure: lint/format, environment/proxy, flaky selectors, app bug.
3. Fix root cause.
4. Re-run focused tests locally.
5. Re-run broader checks if needed.
6. Hand off for PR/merge via `@release-manager`.
## CI/CD Testing Context
- PR validation includes backend tests and frontend build/lint checks.
- E2E runs in GitHub Actions through `.github/workflows/e2e.yml`.
- Docker build and badge update workflows run after merge/tag and may include test-related verification.
### Testing Workflow Focus (Current)
| Workflow | Testing-Manager Action |
|---------|------------------------|
| `.github/workflows/test.yml` | Investigate failures, implement fixes, revalidate locally |
| `.github/workflows/e2e.yml` | Investigate failures/flakes, stabilize tests, revalidate locally |
## Done Criteria
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.
+17 -366
View File
@@ -1,374 +1,25 @@
# MedAssist-ng - AI Coding Instructions
# MedAssist-ng - Copilot Entry Point
## Architecture Overview
## VERY IMPORTANT - Prioritized Constraints
MedAssist-ng is a **medication tracking and planning app** with a monorepo structure:
**First: Update Memory and Reports**
- Always keep agent work memory updated in `doku/memory_notes.md` so progress and decisions remain recoverable across context loss.
- If `doku/memory_notes.md` is missing, create it immediately.
- Always keep a user-facing work report updated in `doku/report.md` so completed work is easy to review.
- If `doku/report.md` is missing, create it immediately.
- This memory/report rule replaces the previous `doku/APP_BEHAVIOR.md` persistence requirement.
- **Backend**: Fastify 5 + TypeScript + SQLite (Drizzle ORM) at `backend/`
- **Frontend**: React 18 + Vite + TypeScript at `frontend/`
- **Database**: SQLite with migrations in `backend/src/db/migrations/`
- **Deployment**: Docker Compose with separate dev containers
- **i18n**: English (en) and German (de) via react-i18next
**Second: Follow Governance Rules**
- Consult `AGENTS.md` for all governance, workflow, and skill rules.
### Data Flow
```
Frontend (React) → /api/* proxy → Backend (Fastify) → SQLite
↓ (Vite rewrites /api to /)
```
Use `AGENTS.md` as the single source of truth for all governance, workflow, and skill rules.
The Vite proxy at `frontend/vite.config.ts` rewrites `/api/*` to `/` - so frontend calls `/api/medications` but backend route is just `/medications`.
## Required Startup Steps
## Development Commands
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).
```bash
# Start dev environment (preferred)
docker compose -f docker-compose.dev.yml up
## Scope
# Or run services separately:
cd backend && npm run dev # tsx watch on port 3000
cd frontend && npm run dev # Vite on port 5173
# Production
docker compose up -d
# Database migrations
cd backend && npm run migrate
# Run tests
cd backend && npm test # Run all tests
cd backend && npm run test:coverage # Run with coverage report
```
## Testing (MANDATORY)
> ⚠️ **WICHTIG**: Jede neue Funktionalität MUSS mit Tests abgedeckt werden!
> Pull Requests ohne Tests für neue Features werden nicht akzeptiert.
### Test-Framework
- **Vitest 2.1** mit v8 Coverage
- Tests in `backend/src/test/*.test.ts`
- Coverage-Ziel: Mindestens gleiche oder bessere Coverage nach Änderungen
### Test-Struktur
| Datei | Testet |
|-------|--------|
| `routes.test.ts` | API-Endpunkte (Auth, Medications, Doses, Settings, Share, Planner) |
| `services.test.ts` | Scheduler-Utilities (Timezone, Blisters, Usage-Berechnung) |
| `db.test.ts` | Datenbank-Schema und Operationen |
### Tests schreiben
```typescript
// Backend Test Beispiel (backend/src/test/example.test.ts)
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createTestApp, createTestUser } from './routes.test'; // Test-Utilities
describe('Feature Name', () => {
let app: FastifyInstance;
let authToken: string;
beforeAll(async () => {
app = await createTestApp();
const user = await createTestUser(app);
authToken = user.token;
});
afterAll(async () => {
await app.close();
});
it('should do something specific', async () => {
const response = await app.inject({
method: 'GET',
url: '/endpoint',
headers: { Authorization: `Bearer ${authToken}` }
});
expect(response.statusCode).toBe(200);
expect(response.json()).toHaveProperty('expectedField');
});
});
```
### Test-Commands
```bash
cd backend
npm test # Alle Tests ausführen
npm run test:coverage # Mit Coverage-Report
npm test -- --watch # Watch-Mode für Entwicklung
npm test -- -t "test name" # Einzelnen Test ausführen
```
## CI/CD Pipeline (GitHub Actions)
### Workflow-Übersicht
```
Pull Request erstellt
┌─────────────────────────────────────┐
│ test.yml │
│ ├─ backend-test (parallel) │
│ │ ├─ npm ci │
│ │ ├─ tsc --noEmit (Type-Check) │
│ │ └─ npm run test:coverage │
│ └─ frontend-build (parallel) │
│ ├─ npm ci │
│ └─ npm run build │
└─────────────────────────────────────┘
↓ Tests müssen bestehen
PR kann gemerged werden
Push to main / Tag erstellt
┌─────────────────────────────────────┐
│ docker-build.yml │
│ ├─ backend-test (parallel) │
│ ├─ frontend-build (parallel) │
│ └─ build-and-push (nach Tests) │
│ ├─ Docker Images bauen │
│ └─ Push zu GHCR │
└─────────────────────────────────────┘
```
### Branch Protection
> ⚠️ **WICHTIG**: Der `main` Branch ist geschützt!
> Direktes Pushen nach `main` ist **nicht möglich** - GitHub lehnt den Push ab.
> Alle Änderungen müssen über Pull Requests erfolgen.
- **main** Branch ist geschützt (Repository Rules)
- Direktes Pushen wird von GitHub abgelehnt mit: `GH013: Repository rule violations`
- PRs benötigen:
-`backend-test` Status Check bestanden
-`frontend-build` Status Check bestanden
- Nach erfolgreichem Merge wird der Feature-Branch automatisch gelöscht
**Workflow für Änderungen:**
```bash
# 1. Feature Branch erstellen
git checkout -b feat/mein-feature
# 2. Änderungen committen und pushen
git add . && git commit -m "feat: Beschreibung"
git push -u origin feat/mein-feature
# 3. PR erstellen (via GitHub CLI oder Web)
gh pr create --title "Mein Feature" --body "Beschreibung"
# 4. Warten bis CI grün ist, dann mergen
gh pr merge --squash --delete-branch
```
### Workflow-Dateien
| Datei | Trigger | Zweck |
|-------|---------|-------|
| `.github/workflows/test.yml` | Pull Requests | Tests ausführen, PR blockieren bei Fehlern |
| `.github/workflows/docker-build.yml` | Push to main, Tags | Tests + Docker Images bauen und pushen |
### Neuen Code hinzufügen - Checkliste
1. ✅ Feature implementieren
2. ✅ Tests für das Feature schreiben
3. ✅ Lokal `npm run test:coverage` ausführen
4. ✅ Coverage darf nicht sinken
5. ✅ Feature Branch erstellen und pushen
6. ✅ Pull Request erstellen
7. ✅ Warten bis CI grün ist
8. ✅ PR mergen (Branch wird automatisch gelöscht)
## Key Patterns
### Backend Routes (`backend/src/routes/`)
| Route File | Endpoints |
|------------|-----------|
| `auth.ts` | `/auth/login`, `/auth/register`, `/auth/logout`, `/auth/refresh`, `/auth/me` |
| `medications.ts` | CRUD `/medications`, `/medications/:id/image` |
| `doses.ts` | `/doses/taken` - track dose intake |
| `planner.ts` | `/medications/usage` - calculate usage for date range |
| `settings.ts` | `/settings` - user settings CRUD |
| `share.ts` | `/share` - create share tokens, `/share/:token` - public access |
| `health.ts` | `/health` - health check endpoint |
### Backend Services (`backend/src/services/`)
| Service | Description |
|---------|-------------|
| `reminder-scheduler.ts` | Stock reminder emails/push notifications |
| `intake-reminder-scheduler.ts` | Intake reminder notifications |
### Frontend (`frontend/src/App.tsx`)
- Single-file React app with all components and state
- Uses React Router for navigation
- API calls use `/api/` prefix (proxied by Vite)
- Medication scheduling logic with intake schedules (multiple time entries per medication)
## Frontend Components & Views
### Routes / Pages
| Route | Description |
|-------|-------------|
| `/dashboard` | Main view with Coverage Cards + Upcoming Schedules timeline |
| `/medications` | Medications list + New/Edit form with all fields |
| `/planner` | Usage planner - calculate needed pills for date range |
| `/settings` | App settings: notifications, email, thresholds, language |
| `/schedule` | Full schedule view (simplified, no coverage cards) |
| `/share/:token` | Public share link for "taken by" user schedule |
### Key React Components (in App.tsx)
| Component | Description |
|-----------|-------------|
| `App` | Root component with BrowserRouter |
| `AppRouter` | Handles auth check, renders AppContent or Auth |
| `AppContent` | Main app shell with navigation, header, all routes |
| `SharedSchedule` | Public share page for medication schedules by person |
| `MedicationAvatar` | Round avatar with medication image or colored initial |
### Dashboard Sections
| Section | Description |
|---------|-------------|
| **Coverage Cards** | Stock status cards per medication: days left, blisters, status (Normal/Warning/Critical) |
| **Upcoming Schedules** | Timeline grouped by day, collapsible days, dose tracking |
### Schedule/Timeline Elements
| Element | CSS Class | Description |
|---------|-----------|-------------|
| Past days toggle | `.past-days-toggle` | Click to show/hide past days |
| Day container | `.day-block` | Container for one day, collapsible |
| Today highlight | `.day-block.today` | Blue border/background for current day |
| Past day | `.day-block.past` | Dashed border, reduced opacity |
| All taken | `.day-block.all-taken` | Green styling when all doses taken |
| Day header | `.day-divider` | Date header with collapse toggle arrow |
| Collapse icon | `.day-collapse-icon` | ▶/▼ arrow for expand/collapse |
| Day summary | `.day-summary` | Shows "X/Y" doses taken or "✓ All taken" |
| Medication row | `.time-row` | One medication's doses for that day |
| Dose item | `.dose-item` | Individual dose with time, amount, take/undo button |
| Dose taken | `.dose-item.taken` | Green background when dose is marked taken |
| Dose overdue | `.dose-item.overdue` | Styling for past untaken doses |
| Dose future | `.dose-item.future` | Disabled button for future days |
### Medication Form (New/Edit)
| Field | Description |
|-------|-------------|
| Commercial Name | Main medication name (required) |
| Generic Name | Scientific/generic name (optional) |
| Taken By | Person taking the medication (optional, enables filtering/sharing) |
| Packs | Number of full packs |
| Blisters per Pack | Strips/blisters in each pack |
| Pills per Blister | Tablets per strip |
| Loose Pills | Extra pills not in blisters |
| Pill Weight (mg) | Weight per pill for dose calculation display |
| Expiry Date | Medication expiration |
| Notes | Free text notes |
| Image Upload | Medication photo (preview for new, direct upload for edit) |
| **Intake Schedule** | One or more intake entries defining usage pattern |
### Intake Schedule
Each blister defines a recurring intake:
- **Usage (Pills)**: How many pills per dose
- **Every (Days)**: Interval (1 = daily, 7 = weekly)
- **Start (Date/Time)**: When the schedule starts (determines past/future doses)
- **Remind checkbox**: Enable intake reminders (🔔)
### Modals
| Modal | Trigger | Content |
|-------|---------|---------|
| Medication Detail | Click on coverage card or medication row | Full medication info, stock, schedule preview, edit/delete/ICS buttons |
| Image Lightbox | Click medication image | Full-size medication image |
| Share Dialog | "Share" button on schedules | Generate share link for specific "taken by" person |
| User Schedule Filter | Click on "taken by" badge | Filter schedule by person |
### Settings Sections
| Section | Settings |
|---------|----------|
| General | Language toggle (EN/DE) |
| Stock Thresholds | Warning days, critical days, expiry warning days |
| Email Notifications | Enable, email address, stock/intake toggles |
| Push Notifications (Shoutrrr) | Enable, URL (ntfy/gotify/etc), stock/intake toggles |
| Reminder Settings | Days before, repeat daily |
| SMTP | Email config (read-only from .env) |
## Database Schema (`backend/src/db/schema.ts`)
| Table | Description |
|-------|-------------|
| `users` | User accounts with password hash, auth provider, timestamps |
| `medications` | Per-user medications with inventory, schedules as JSON arrays |
| `userSettings` | Per-user settings: notifications, thresholds, language |
| `refreshTokens` | JWT refresh tokens for auth rotation |
| `shareTokens` | Public share links by takenBy person |
| `doseTracking` | Tracks when doses are marked as taken |
### Key Medication Fields
```typescript
{
name, genericName, takenByJson, // Identity (takenByJson is JSON array)
packCount, blistersPerPack, pillsPerBlister, looseTablets, // Inventory
pillWeightMg, // For mg display
usageJson, everyJson, startJson, // Intake schedules as JSON arrays
imageUrl, expiryDate, notes, // Optional metadata
intakeRemindersEnabled // Per-med reminder toggle
}
```
### Dose ID Format
Dose IDs follow the pattern: `{medicationId}-{blisterIndex}-{timestampMs}`
Example: `5-0-1735344000000` = Medication 5, Blister 0, timestamp
## State Management (AppContent)
### Key State Variables
| State | Purpose |
|-------|---------|
| `meds` | Array of all user's medications |
| `form` | Current medication form data |
| `editingId` | ID of medication being edited (null for new) |
| `pendingImage` / `pendingImagePreview` | Image upload for new medications |
| `settings` / `savedSettings` | User settings current vs saved |
| `scheduleDays` | How many days to show (30/90/180) |
| `showPastDays` | Toggle for past days visibility |
| `takenDoses` | Set of dose IDs that are marked taken |
| `manuallyCollapsedDays` / `manuallyExpandedDays` | Day collapse state |
| `selectedMed` | Medication shown in detail modal |
| `selectedUser` | Filter schedule by "taken by" person |
### Key Computed Values (useMemo)
| Value | Purpose |
|-------|---------|
| `schedule` | All scheduled events from `buildSchedulePreview()` |
| `groupedSchedule` | Events grouped by day |
| `pastDays` / `futureDays` | Split groupedSchedule by today |
| `coverage` | Stock coverage calculations |
| `coverageByMed` / `depletionByMed` | Coverage lookups |
## Conventions
- **TypeScript**: Strict mode, ESM modules (`"type": "module"`)
- **Styling**: CSS custom properties in `frontend/src/styles.css`, dark/light theme via `data-theme`
- **API responses**: Return objects directly, Fastify serializes to JSON
- **Environment**: Copy `.env.example``.env`, secrets must be 10+ chars
- **i18n**: All UI text via `t('key')` function, translations in `frontend/src/i18n/*.json`
## Database Schema Changes
When adding new database columns:
1. **Update schema**: `backend/src/db/schema.ts` - Add the Drizzle column definition
2. **Update client.ts**: `backend/src/db/client.ts` - Add column to `CREATE TABLE IF NOT EXISTS`
3. **Update migrate.ts**: `backend/src/db/migrate.ts` - Same as client.ts
4. **Delete old DB**: `rm backend/data/medassist-ng.db` and restart
## File Locations
| Purpose | Location |
|---------|----------|
| Backend entry | `backend/src/index.ts` |
| Database schema | `backend/src/db/schema.ts` |
| Backend routes | `backend/src/routes/*.ts` |
| Backend services | `backend/src/services/*.ts` |
| Frontend app | `frontend/src/App.tsx` |
| Frontend auth | `frontend/src/components/Auth.tsx` |
| Styles | `frontend/src/styles.css` |
| i18n English | `frontend/src/i18n/en.json` |
| i18n German | `frontend/src/i18n/de.json` |
| Docker prod | `docker-compose.yml` |
| Docker dev | `docker-compose.dev.yml` |
| Env template | `.env.example` |
This file intentionally stays minimal to prevent duplicated or conflicting instructions.
+66
View File
@@ -0,0 +1,66 @@
version: 2
updates:
# Backend dependencies
- package-ecosystem: "npm"
directory: "/backend"
schedule:
interval: "weekly"
day: "monday"
time: "06:20"
open-pull-requests-limit: 10
labels:
- "dependencies"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"
# Frontend dependencies
- package-ecosystem: "npm"
directory: "/frontend"
schedule:
interval: "weekly"
day: "monday"
time: "06:10"
open-pull-requests-limit: 10
labels:
- "dependencies"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"
# Root dev dependencies
- package-ecosystem: "npm"
directory: "/"
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"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "06:30"
open-pull-requests-limit: 5
labels:
- "dependencies"
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"
+28
View File
@@ -0,0 +1,28 @@
# MedAssist Agent Skills
This directory contains project skills for VS Code Copilot.
Each skill lives in its own folder and must include a `SKILL.md` file.
## Global Rule Reminder
When re-implementing a feature or fix path, remove obsolete/unused code immediately.
Do not leave dead code behind.
Also follow the canonical global engineering rules in `AGENTS.md`.
Use one governance source to avoid duplicated or conflicting policy text.
## Skills
- `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.
- `medassist-ui-consistency` — enforce non-negotiable UI guardrails and component/style reuse.
- `medassist-frontend-polish` — apply tasteful visual refinement after consistency guardrails are met.
- `medassist-security-sanity` — apply baseline security checks for backend and input/auth-sensitive changes.
- `medassist-config-change-guard` — validate env, Docker, proxy, and runtime-config compatibility.
- `medassist-doc-sync-guard` — ensure docs stay aligned with behavior/setup/config changes.
- `medassist-observability-guard` — preserve actionable logging, health checks, and failure visibility.
- `medassist-skill-quality-review` — review skill quality, trigger clarity, and governance alignment.
- `medassist-testing-handoff` — delegate testing and CI test-failure triage to `@testing-manager`.
- `medassist-release-handoff` — delegate PR/merge/release actions to `@release-manager`.
@@ -0,0 +1,35 @@
---
name: medassist-architecture-guard
description: Guard MedAssist architectural boundaries and route/data-flow conventions when changing backend or frontend code, including equivalent requests phrased in German.
---
# Skill Instructions
Use this skill when a task touches API endpoints, frontend API calls, routing, or code placement.
## Goals
- Keep responsibilities in the correct layer.
- Preserve MedAssist proxy and routing conventions.
- Prevent architecture drift and cross-layer anti-patterns.
## Required Checks
1. Frontend network calls use `/api/*` paths.
2. Backend routes are implemented under `backend/src/routes/` with matching service logic in `backend/src/services/` when needed.
3. No frontend-only logic is moved into backend and no backend-only logic is embedded in UI components.
4. Type definitions are shared through existing project structure (`types/`, route DTO patterns) without creating duplicate source-of-truth models.
## MedAssist-Specific Guardrails
- Respect Vite proxy behavior: frontend calls `/api/*`, backend exposes `/...` routes.
- Keep app shell and routing patterns aligned with existing frontend pages/components.
- Prefer minimal, local changes over broad restructures.
## Response Format
When this skill is used, summarize:
- Which architectural checks were applied
- Which files are affected
- Any boundary risks found and how they were resolved
@@ -0,0 +1,43 @@
---
name: medassist-config-change-guard
description: Validate MedAssist configuration changes across env vars, Docker compose, proxy settings, and runtime defaults, including equivalent requests phrased in German.
---
# Skill Instructions
Use this skill when changes touch `.env`, Docker files, Vite proxy settings, runtime defaults, or app startup behavior.
## Objective
Prevent configuration drift and broken local/CI environments.
## Required Checks
1. New/changed config has safe defaults.
2. Env changes are backward-compatible where feasible.
3. Docker/dev runtime changes remain consistent across services.
4. Frontend/backend URL/proxy conventions remain valid (`/api/*`).
5. Documentation reflects configuration changes.
## Files to Prioritize
- `.env.example`
- `docker-compose.yml`
- `docker-compose.dev.yml`
- `frontend/vite.config.ts`
- Relevant package scripts and startup files
## Anti-Patterns
- Hidden required env vars with no defaults.
- Inconsistent host/port/proxy settings across environments.
- Config changes without doc updates.
## Response Format
Report:
- Config files reviewed
- Compatibility impact (none/low/high)
- Required follow-up updates
- Final readiness recommendation
@@ -0,0 +1,40 @@
---
name: medassist-db-compat-check
description: Enforce backward-compatible database changes for MedAssist SQLite and Drizzle migrations, including equivalent requests phrased in German.
---
# Skill Instructions
Use this skill for any feature or fix that adds or reads persisted data.
## Mandatory Sequence
For every new persisted field/column:
1. Add the column in `backend/src/db/schema.ts` with `NOT NULL DEFAULT <value>`.
2. Generate migration with Drizzle Kit.
3. Add matching `ALTER TABLE` logic in `backend/src/db/client.ts` inside `runAlterMigrations()`.
4. Read values null-safe in routes/services (`?? defaultValue`).
## Hard Rules
- Never remove or rename existing columns.
- Never add non-null columns without defaults.
- Never read newly added fields without fallback.
- Never manually edit generated Drizzle SQL migrations.
## Verification Checklist
- Schema update exists.
- Generated migration exists.
- Alter migration for existing DBs exists.
- Runtime reads are fallback-safe.
## Response Format
Report these items explicitly:
- New/changed columns
- Added alter-migration statements
- Null-safe read locations
- Remaining migration risk (if any)
@@ -0,0 +1,39 @@
---
name: medassist-doc-sync-guard
description: Ensure MedAssist documentation stays aligned with behavior changes in APIs, configuration, setup, and operations, including equivalent requests phrased in German.
---
# Skill Instructions
Use this skill when code changes alter behavior, setup steps, environment variables, user workflows, or operational commands.
## Objective
Keep docs consistent with actual product behavior and avoid stale setup/run guidance.
## Required Checks
1. If API behavior changed, verify relevant docs are updated.
2. If ENV/config changed, update documented variables/defaults.
3. If workflow/commands changed, update setup/run instructions.
4. If user-facing behavior changed, update user-facing description.
## Candidate Documentation Files
- `README.md`
- `docs/PROJECT_SETUP.md`
- `docs/TECH_STACK.md`
## Anti-Patterns
- Shipping behavior changes without docs updates.
- Updating docs with speculative/unverified commands.
- Duplicating conflicting instructions across files.
## Response Format
Return:
- Doc files that should change
- Proposed update summary per file
- Any intentionally skipped docs and reason
@@ -0,0 +1,67 @@
---
name: medassist-frontend-polish
description: Improve frontend visual quality within the existing MedAssist design system, without introducing new themes, font stacks, or disruptive UI patterns, including equivalent requests phrased in German.
---
# Skill Instructions
Use this skill when the user wants UI improvements, better styling, or a more polished frontend, but the feature must stay consistent with MedAssist product UX.
## Scope
This is the **visual enhancement skill**.
It refines quality *within* existing product conventions.
Apply `medassist-ui-consistency` rules first, then use this skill for tasteful polish.
## Do Not Use This Skill For
- Replacing base UI patterns/components with new ones.
- New design-system direction, visual identity, or broad layout language changes.
- Marketing/brand-experiment pages that intentionally break product conventions.
## Objective
Deliver production-grade visual refinement that feels intentionally designed while remaining fully consistent with existing MedAssist components, spacing, typography, and interaction patterns.
## Strict Constraints
- Reuse existing components and patterns first (`ConfirmModal`, `MedicationAvatar`, existing form/button/layout patterns).
- Do not introduce new global theme systems, font families, or visual identity changes.
- Do not invent new UX flows, pages, or interaction models unless explicitly requested.
- Keep frontend text i18n-safe: use `t("...")` and EN/DE keys.
- Respect accessibility and readability over decorative effects.
## Allowed Enhancements
- Better spacing rhythm and visual hierarchy.
- Cleaner grouping, alignment, and density adjustments.
- Improved states (hover, focus, disabled, loading) using existing style language.
- Subtle transitions/micro-interactions that do not distract and do not change behavior.
- Consistent empty/error/success presentation using existing UI conventions.
## Not Allowed
- Random aesthetic overhauls.
- New color systems or hardcoded ad-hoc colors that break current theme tokens.
- Heavy animation, parallax, or attention-stealing motion.
- Typography experiments that diverge from current product style.
- "Creative" layout changes that reduce usability or consistency.
## Implementation Workflow
1. Confirm `medassist-ui-consistency` guardrails are satisfied.
2. Identify existing components and CSS patterns to reuse.
3. Define the smallest visual changes that improve clarity and quality.
4. Apply refinements in-place without changing core behavior.
5. Validate consistency across neighboring views/components.
6. Ensure i18n and accessibility are preserved.
## Response Format
When using this skill, report:
- Reused components and style primitives
- Specific polish improvements applied
- Any trade-offs/constraints respected
- Confirmation that no new design system or disruptive UX pattern was introduced
@@ -0,0 +1,31 @@
---
name: medassist-i18n-enforcer
description: Enforce MedAssist i18n rules so UI copy is always translation-key based for English and German, including equivalent requests phrased in German.
---
# Skill Instructions
Use this skill when changing frontend UI text, form labels, alerts, dialogs, or page content.
## Rules
- Do not hardcode new user-facing strings in React components.
- Use translation keys via `t("...")`.
- Add or update matching keys in:
- `frontend/src/i18n/en.json`
- `frontend/src/i18n/de.json`
- Keep semantic key naming consistent with existing namespaces.
## Validation
1. Every new UI string has a key.
2. English and German entries are both present.
3. No fallback-to-English hardcoded text remains in JSX.
## Response Format
List:
- New keys added
- Files where keys were used
- Any intentionally unchanged text and reason
@@ -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
@@ -0,0 +1,41 @@
---
name: medassist-observability-guard
description: Ensure MedAssist changes preserve actionable logging, health checks, and clear operational error visibility, including equivalent requests phrased in German.
---
# Skill Instructions
Use this skill when changes affect backend services, schedulers, integrations, startup flow, or failure handling.
## Objective
Maintain operational visibility so failures are detectable, diagnosable, and actionable.
## Required Checks
1. Critical paths keep clear error reporting.
2. Health-check behavior remains intact and meaningful.
3. Logs contain actionable context without leaking secrets.
4. Errors are surfaced with enough detail for debugging.
5. Silent failure paths are avoided.
## MedAssist Focus Areas
- `backend/src/index.ts`
- `backend/src/routes/health.ts`
- `backend/src/services/*`
- Scheduler and notification flows
## Anti-Patterns
- Swallowed exceptions.
- Generic logs with no context.
- Missing visibility for background failures.
## Response Format
Return:
- Observability touchpoints reviewed
- Gaps found and suggested fixes
- Operational risk level
@@ -0,0 +1,30 @@
---
name: medassist-release-handoff
description: Enforce MedAssist release ownership by preventing remote git/release actions by normal agents and delegating to release-manager, including equivalent requests phrased in German.
---
# Skill Instructions
Use this skill when a request includes branch push, PR creation, merge, tagging, release notes publishing, or release orchestration.
## Ownership Rules
- Remote git/release actions are owned by `@release-manager`.
- Normal agent/Copilot must not perform:
- `git push`
- PR creation/merge
- tag/release creation
## Required Behavior
1. Perform local code edits only.
2. Summarize local changes clearly.
3. Provide handoff instruction to `@release-manager` for shipping steps.
## Response Format
When this skill applies, return:
- "Release handoff required"
- Delegate target: `@release-manager`
- Shipping checklist (branch, PR, CI, merge, release)
@@ -0,0 +1,43 @@
---
name: medassist-security-sanity
description: Apply baseline security checks to MedAssist code changes, especially for backend routes, auth flows, and input handling, including equivalent requests phrased in German.
---
# Skill Instructions
Use this skill when a change touches backend routes, auth/session logic, file handling, imports/exports, or external input.
## Objective
Prevent common security regressions with fast, practical checks during implementation.
## Required Checks
1. Validate and sanitize external input at API boundaries.
2. Enforce auth/authz server-side for protected actions.
3. Ensure secrets/tokens are never hardcoded or logged.
4. Avoid information leakage in error responses.
5. Keep permission-sensitive operations explicit and auditable.
## MedAssist Focus Areas
- Route handlers in `backend/src/routes/`.
- Auth-related code in `backend/src/plugins/` and auth routes.
- Data import/export and sharing endpoints.
- File/image upload and serving paths.
## Anti-Patterns
- Trusting frontend-only checks.
- Accepting unchecked query/body/path input.
- Returning raw internal errors to clients.
- Weak defaults for sensitive operations.
## Response Format
Report:
- Security-sensitive files reviewed
- Findings by severity (critical/major/minor)
- Concrete remediation actions
- Residual risk (if any)
@@ -0,0 +1,42 @@
---
name: medassist-skill-quality-review
description: Review MedAssist skills for trigger quality, scope boundaries, and conflicts with AGENTS governance, including equivalent requests phrased in German.
---
# Skill Instructions
Use this skill when creating or modifying any skill under `.github/skills/`.
## Objective
Keep skills discoverable, non-overlapping, and aligned with canonical governance in `AGENTS.md`.
## Required Checks
1. Frontmatter has clear `name` and specific `description` trigger language.
2. Scope boundaries are explicit (`when to use` / `do not use`).
3. No conflicts with `AGENTS.md` ownership rules.
4. No policy duplication that can drift from canonical governance.
5. References to related skills are explicit where workflows chain.
## Quality Signals
- Trigger phrases are concrete and task-shaped.
- Instructions are concise, actionable, and deterministic.
- Response format is clear and useful for downstream handoff.
## Anti-Patterns
- Vague descriptions that match everything.
- Duplicate skills with overlapping responsibilities.
- Contradictory ownership guidance.
- Long policy blocks copied from other files.
## Response Format
Return:
- Scope/trigger issues found
- Overlap/conflict findings
- Suggested minimal edits
- Final pass/fail recommendation
@@ -0,0 +1,31 @@
---
name: medassist-testing-handoff
description: Enforce MedAssist testing ownership by delegating test planning, execution, and CI test failure triage to testing-manager, including equivalent requests phrased in German.
---
# Skill Instructions
Use this skill whenever a task includes writing tests, running tests, or diagnosing test-related CI failures.
## Ownership Rules
- Test planning, implementation, and execution are owned by `@testing-manager`.
- CI test-failure triage (`test.yml`, `e2e.yml`) is owned by `@testing-manager`.
- Normal coding agent should hand off testing tasks instead of executing testing workflows directly.
## Handoff Template
Use this structure for delegation:
1. Scope: feature/fix and affected files
2. Expected behavior
3. Suggested test layers (unit/integration/e2e)
4. CI failure context (if applicable)
## Response Format
When triggered, output:
- "Testing handoff required"
- Delegate target: `@testing-manager`
- Minimal handoff brief (scope + expected behavior)
@@ -0,0 +1,52 @@
---
name: medassist-ui-consistency
description: Enforce non-negotiable MedAssist UI guardrails by reusing existing components, styles, and interaction patterns, including equivalent requests phrased in German.
---
# Skill Instructions
Use this skill when implementing or editing UI flows, modals, buttons, forms, schedule views, or settings screens.
## Scope
This is the **guardrail skill** for UI work.
Use it to enforce consistency and prevent design drift.
Use `medassist-frontend-polish` only after these guardrails are satisfied.
## Do Not Use This Skill For
- Creative visual redesign requests where no product consistency constraints apply.
- Marketing-style one-off pages outside MedAssist product UI conventions.
## Rules
- Reuse existing components (for example `ConfirmModal`, `MedicationAvatar`) before creating new primitives.
- Keep spacing, typography, and button styles aligned with existing patterns.
- 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.
2. If small variant is needed, extend existing styles minimally.
3. If a new component is unavoidable, match existing naming and structure conventions.
## Response Format
Provide:
- Reused components/styles
- Any new UI element and why reuse was not possible
- Consistency risks reviewed
- Confirmation that `medassist-frontend-polish` constraints remain compatible (if polish work is also requested)
+19
View File
@@ -0,0 +1,19 @@
name: Add to Project
on:
issues:
types: [opened, labeled]
permissions: {}
jobs:
add-to-project:
name: Add issue to project
runs-on: ubuntu-latest
steps:
- uses: actions/add-to-project@v1.0.2
with:
project-url: ${{ vars.PROJECT_URL }}
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
labeled: enhancement, bug, triage
label-operator: OR
@@ -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
+26 -4
View File
@@ -3,8 +3,30 @@ name: "CodeQL"
on:
push:
branches: [main]
paths:
- '**.js'
- '**.ts'
- '**.tsx'
- '**.jsx'
- 'backend/package.json'
- 'backend/package-lock.json'
- 'frontend/package.json'
- 'frontend/package-lock.json'
- '.github/codeql/**'
- '.github/workflows/codeql.yml'
pull_request:
branches: [main]
paths:
- '**.js'
- '**.ts'
- '**.tsx'
- '**.jsx'
- 'backend/package.json'
- 'backend/package-lock.json'
- 'frontend/package.json'
- 'frontend/package-lock.json'
- '.github/codeql/**'
- '.github/workflows/codeql.yml'
schedule:
- cron: "0 6 * * 1" # Weekly on Monday at 6am UTC
workflow_dispatch: # Allow manual trigger
@@ -25,18 +47,18 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
uses: github/codeql-action/init@v4
with:
languages: ${{ matrix.language }}
config-file: ./.github/codeql/codeql-config.yml
- name: Autobuild
uses: github/codeql-action/autobuild@v3
uses: github/codeql-action/autobuild@v4
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
uses: github/codeql-action/analyze@v4
with:
category: "/language:${{ matrix.language }}"
@@ -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
+93 -60
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:
@@ -20,50 +35,16 @@ env:
jobs:
# =============================================================================
# Run Tests First
# =============================================================================
backend-test:
name: Backend Tests
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: backend/package-lock.json
- run: npm ci
- run: npx tsc --noEmit
- run: npm run test:run
frontend-build:
name: Frontend Build
runs-on: ubuntu-latest
permissions:
contents: read
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- run: npm ci
- run: npm run build
# =============================================================================
# Build and Push Docker Images (only after tests pass)
# Build and Push Docker Images
# Triggered on pushes to main (tagged as "main") and version tags (v*).
# Tests are NOT run here — branch protection on main requires all PR checks
# (backend-test + frontend-build from test.yml) to pass before merge.
# Tags are created from main, so code is already tested.
#
# main push → "main" tag only (for testing before release)
# Tag builds → semver tags (e.g., 1.9.0, 1.9) plus "latest"
# =============================================================================
build-and-push:
needs: [backend-test, frontend-build]
runs-on: ubuntu-latest
permissions:
contents: read
@@ -79,13 +60,13 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
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 }}
@@ -93,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: |
@@ -101,10 +82,10 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=${{ github.event.inputs.tag || 'latest' }},enable=${{ github.event_name == 'workflow_dispatch' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
- name: Build and push
uses: docker/build-push-action@v5
uses: docker/build-push-action@v7
with:
context: ${{ matrix.context }}
push: true
@@ -113,44 +94,93 @@ jobs:
cache-from: type=gha
cache-to: type=gha,mode=max
platforms: linux/amd64,linux/arm64
provenance: false
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
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
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="${{ 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"
else
echo "exists=false" >> $GITHUB_OUTPUT
fi
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Get previous tag
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
@@ -165,8 +195,11 @@ jobs:
echo "**Full Changelog**: https://github.com/${{ github.repository }}/compare/${PREV_TAG}...${CURRENT_TAG}" >> changelog.md
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
if: steps.check_release.outputs.exists == 'false'
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
+87
View File
@@ -0,0 +1,87 @@
name: E2E Tests
on:
pull_request:
branches: [main]
# 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:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: |
backend/package-lock.json
frontend/package-lock.json
- name: Install backend dependencies
working-directory: backend
run: npm ci
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Install Playwright browsers
working-directory: frontend
run: npx playwright install --with-deps chromium
- name: Run E2E tests (Chromium only)
working-directory: frontend
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@v7
if: always()
with:
name: playwright-report
path: frontend/playwright-report/
retention-days: 7
- name: Upload test results
uses: actions/upload-artifact@v7
if: always()
with:
name: playwright-results
path: frontend/test-results/
retention-days: 7
+105
View File
@@ -0,0 +1,105 @@
name: Move Done in Project
on:
issues:
types: [closed]
pull_request:
types: [closed]
permissions: {}
jobs:
move-to-done:
name: Move to Done
runs-on: ubuntu-latest
if: >-
(github.event_name == 'issues' && github.event.issue.state_reason == 'completed') ||
(github.event_name == 'pull_request' && github.event.pull_request.merged == true)
steps:
- name: Move project item to Done
uses: actions/github-script@v9
with:
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
script: |
const projectId = 'PVT_kwHOADH82s4BO2OT';
const statusFieldId = 'PVTSSF_lAHOADH82s4BO2OTzg9bdkE';
const doneOptionId = 'ca45af98';
// Determine content ID (issue or PR node ID)
const nodeId = context.payload.issue?.node_id || context.payload.pull_request?.node_id;
const number = context.payload.issue?.number || context.payload.pull_request?.number;
const type = context.payload.issue ? 'issue' : 'pull_request';
console.log(`Processing ${type} #${number} (${nodeId})`);
// Find the project item by content node ID
const result = await github.graphql(`
query($nodeId: ID!) {
node(id: $nodeId) {
... on Issue {
projectItems(first: 10) {
nodes {
id
project { id }
fieldValueByName(name: "Status") {
... on ProjectV2ItemFieldSingleSelectValue {
name
optionId
}
}
}
}
}
... on PullRequest {
projectItems(first: 10) {
nodes {
id
project { id }
fieldValueByName(name: "Status") {
... on ProjectV2ItemFieldSingleSelectValue {
name
optionId
}
}
}
}
}
}
}
`, { nodeId });
const items = result.node?.projectItems?.nodes || [];
const projectItem = items.find(item => item.project.id === projectId);
if (!projectItem) {
console.log(`${type} #${number} is not in the project board — skipping.`);
return;
}
const currentStatus = projectItem.fieldValueByName?.name || 'unknown';
if (currentStatus === 'Done') {
console.log(`${type} #${number} is already "Done" — skipping.`);
return;
}
console.log(`Moving ${type} #${number} from "${currentStatus}" to "Done"...`);
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: statusFieldId,
optionId: doneOptionId
});
console.log(`Successfully moved ${type} #${number} to "Done".`);
-42
View File
@@ -1,42 +0,0 @@
name: Create Release
on:
push:
tags: ['v*']
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate changelog
id: changelog
run: |
# Get previous tag
PREV_TAG=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
if [ -z "$PREV_TAG" ]; then
# First release - get all commits
CHANGES=$(git log --pretty=format:"- %s" HEAD)
else
# Get commits since last tag
CHANGES=$(git log --pretty=format:"- %s" ${PREV_TAG}..HEAD)
fi
# Write to file for multiline support
echo "$CHANGES" > changelog.txt
- name: Create Release
uses: softprops/action-gh-release@v1
with:
body_path: changelog.txt
generate_release_notes: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+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(', ')}]`);
}
+54 -7
View File
@@ -10,10 +10,38 @@ permissions:
jobs:
# =============================================================================
# Backend Tests
# Detect which paths changed to skip unnecessary jobs
# =============================================================================
changes:
name: Detect Changes
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
steps:
- uses: dorny/paths-filter@v4
id: filter
with:
filters: |
backend:
- 'backend/**'
- 'biome.json'
- '.github/workflows/test.yml'
frontend:
- 'frontend/**'
- 'biome.json'
- '.github/workflows/test.yml'
# =============================================================================
# Backend Tests (skipped if no backend-related files changed)
# =============================================================================
backend-test:
name: Backend Tests
needs: changes
if: needs.changes.outputs.backend == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
@@ -23,10 +51,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
@@ -35,6 +63,9 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: TypeScript type check
run: npx tsc --noEmit
@@ -42,7 +73,7 @@ jobs:
run: npm run test:coverage
- name: Upload coverage report
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
if: always()
with:
name: backend-coverage
@@ -50,10 +81,12 @@ jobs:
retention-days: 7
# =============================================================================
# Frontend Build Validation
# Frontend Tests & Build (skipped if no frontend-related files changed)
# =============================================================================
frontend-build:
name: Frontend Build
needs: changes
if: needs.changes.outputs.frontend == 'true'
runs-on: ubuntu-latest
permissions:
contents: read
@@ -63,10 +96,10 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
@@ -75,5 +108,19 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Run tests with coverage
run: npm run test:coverage
- name: TypeScript type check & build
run: npm run build
- name: Upload coverage report
uses: actions/upload-artifact@v7
if: always()
with:
name: frontend-coverage
path: frontend/coverage/
retention-days: 7
+111
View File
@@ -0,0 +1,111 @@
name: Update Test Badges
on:
workflow_dispatch:
workflow_run:
workflows: ["Build and Push Docker Images"]
types: [completed]
branches: [main]
permissions:
contents: write
# Prevent parallel badge workflows from racing each other
concurrency:
group: update-test-badges
cancel-in-progress: true
jobs:
update-badges:
name: Update Test Count Badges
runs-on: ubuntu-latest
# Only run after successful docker builds, not failed ones
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
steps:
- name: Checkout repository
uses: actions/checkout@v6
with:
token: ${{ secrets.BADGE_TOKEN || secrets.GITHUB_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: '**/package-lock.json'
- name: Install backend dependencies
working-directory: backend
run: npm ci
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Run backend tests and capture count
id: backend-tests
working-directory: backend
timeout-minutes: 5
env:
CI: true
run: |
OUTPUT=$(npm run test:run 2>&1) || true
echo "$OUTPUT"
# Strip ANSI escape codes, then extract "Tests X passed" from output
CLEAN=$(echo "$OUTPUT" | sed 's/\x1b\[[0-9;]*m//g')
PASSED=$(echo "$CLEAN" | grep -oP 'Tests\s+\K\d+(?=\s+passed)' | tail -1)
echo "count=$PASSED" >> $GITHUB_OUTPUT
- name: Run frontend tests and capture count
id: frontend-tests
working-directory: frontend
timeout-minutes: 5
env:
CI: true
run: |
OUTPUT=$(npm run test:run 2>&1) || true
echo "$OUTPUT"
# Strip ANSI escape codes, then extract "Tests X passed" from output
CLEAN=$(echo "$OUTPUT" | sed 's/\x1b\[[0-9;]*m//g')
PASSED=$(echo "$CLEAN" | grep -oP 'Tests\s+\K\d+(?=\s+passed)' | tail -1)
echo "count=$PASSED" >> $GITHUB_OUTPUT
- name: Update README badges
run: |
BACKEND_COUNT="${{ steps.backend-tests.outputs.count }}"
FRONTEND_COUNT="${{ steps.frontend-tests.outputs.count }}"
echo "Backend tests: $BACKEND_COUNT"
echo "Frontend tests: $FRONTEND_COUNT"
# Only update if we got valid counts
if [[ -n "$BACKEND_COUNT" && -n "$FRONTEND_COUNT" ]]; then
# URL encode the slash for shields.io
BACKEND_BADGE="https://img.shields.io/badge/Backend_Tests-${BACKEND_COUNT}%2F${BACKEND_COUNT}-brightgreen?logo=vitest"
FRONTEND_BADGE="https://img.shields.io/badge/Frontend_Tests-${FRONTEND_COUNT}%2F${FRONTEND_COUNT}-brightgreen?logo=vitest"
# Update README using sed
sed -i "s|https://img.shields.io/badge/Backend_Tests-[^\"]*|$BACKEND_BADGE|g" README.md
sed -i "s|https://img.shields.io/badge/Frontend_Tests-[^\"]*|$FRONTEND_BADGE|g" README.md
echo "Updated badges in README.md"
else
echo "Could not extract test counts, skipping update"
exit 0
fi
- name: Commit and push badge updates
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add README.md
if git diff --cached --quiet; then
echo "No badge changes to commit"
else
git commit -m "chore: update test count badges [skip ci]"
# Rebase on latest main to avoid push rejection when concurrent
# badge workflows or other [skip ci] commits land between checkout and push
git pull --rebase origin main
git push
fi
+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']
});
+117 -26
View File
@@ -1,33 +1,124 @@
# Node
# ===================
# Dependencies
# ===================
node_modules/
.pnpm-store/
# ===================
# Build outputs
# ===================
dist/
build/
.tmp/
*.tsbuildinfo
# ===================
# Test & Coverage
# ===================
coverage/
.nyc_output/
# Playwright
/frontend/playwright-report/
/frontend/test-results/
/frontend/e2e/.auth/
/frontend/blob-report/
# ===================
# Environment
# ===================
.env
.env.*
!.env.example
# ===================
# Database & Data
# ===================
*.db
*.sqlite
*.sqlite3
*.db-journal
*.db-wal
*.db-shm
data/
# ===================
# Logs
# ===================
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Build outputs
dist/
build/
coverage/
.tmp/
# Env
.env
.env.*
!.env.example
# SQLite
*.db
*.sqlite
*.sqlite3
*.db-journal
backend/data/
# Logs
logs/
*.log
# Editor
.vscode/
.idea/
# ===================
# OS files
# ===================
.DS_Store
Thumbs.db
*.swp
*.swo
*~
# ===================
# IDE / Editor
# ===================
.idea/
*.sublime-project
*.sublime-workspace
# Keep shared VS Code settings
# .vscode/ is NOT ignored - settings.json is useful for the team
# ===================
# Misc
# ===================
*.local
.cache/
.turbo/
.roo/
.roomodes
.claude/
AGENTS.md
docs/TECH_STACK.md
doku/
# Local agent work logs stay on disk but must never go upstream.
doku/memory_notes.md
doku/report.md
plan/
.copilot-tracking/
.playwright-cli/
.agents/
skills-lock.json
# ===================
# Local Spec Kit 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
# Local GSD/copilot generated workspace artifacts (not for upstream)
.github/agents/copilot-instructions.md
.github/agents/gsd-*.agent.md
.github/agents/medassist-feature-orchestrator.agent.md
.github/agents/speckit.*.agent.md
.github/get-shit-done/
.github/gsd-file-manifest.json
.github/prompts/speckit.*.prompt.md
.github/skills/gsd-*/
.planning/
doku/memory_notes.md
doku/report.md
ops/medtest/
+1
View File
@@ -0,0 +1 @@
npx lint-staged
+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*
+8
View File
@@ -0,0 +1,8 @@
{
"vitest.root": "backend",
"vitest.enable": true,
"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
}
]
}
+247 -10
View File
@@ -10,16 +10,21 @@
</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" />
<img src="https://img.shields.io/badge/Docker-Ready-2496ED?logo=docker" alt="Docker" />
</p>
<p align="center">
<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
@@ -28,6 +33,7 @@
> **Think of this app as a helpful tool, but make all health decisions independently!**
- [Features](#features)
- [Screenshots](#screenshots)
- [Getting Started](#getting-started)
- [Configuration](#configuration)
- [Development](#development)
@@ -38,14 +44,102 @@
<img src="docs/gifs/MedAssist-demo.gif" alt="MedAssist-ng Dashboard" width="100%" />
</p>
<a id="screenshots"></a>
<details>
<summary><strong>Screenshots</strong></summary>
<blockquote>
<details>
<summary>Dashboard</summary>
Overview with stock status, reorder reminders, and upcoming schedules.
<img src="docs/screenshots/dashboard-desktop.png" alt="Dashboard" width="100%" />
</details>
<details>
<summary>Medication Detail</summary>
View medication details, stock information, and intake schedule.
<img src="docs/screenshots/medication-detail-modal.png" alt="Medication Detail Modal" width="100%" />
</details>
<details>
<summary>Medications & Edit Form</summary>
Manage your medications with the edit form and refill feature.
<img src="docs/screenshots/medications-edit-desktop.png" alt="Medications Edit Form" width="100%" />
</details>
<details>
<summary>Demand Calculator (Planner)</summary>
Calculate how many pills you need for a specific date range.
<img src="docs/screenshots/planner-desktop.png" alt="Planner - Demand Calculator" width="100%" />
</details>
<details>
<summary>Shared Schedule</summary>
Share your medication schedule with others via a public link.
<img src="docs/screenshots/share-schedule-desktop.png" alt="Shared Schedule" width="100%" />
</details>
<details>
<summary>Mobile Views</summary>
<table>
<tr>
<td align="center" width="33%">
<strong>Dashboard</strong><br>
<img src="docs/screenshots/dashboard-mobile.png" alt="Mobile Dashboard" width="100%" />
</td>
<td align="center" width="33%">
<strong>Medications</strong><br>
<img src="docs/screenshots/medications-mobile.png" alt="Mobile Medications" width="100%" />
</td>
<td align="center" width="33%">
<strong>Schedule</strong><br>
<img src="docs/screenshots/schedule-mobile.png" alt="Mobile Schedule" width="100%" />
</td>
</tr>
</table>
</details>
</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, 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
- Complete refill history per medication
- Automatic stock updates after each refill
### 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
@@ -53,16 +147,27 @@
- 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
- Import previously exported data with automatic ID remapping
- Choose whether to include sensitive data in exports
### Notifications
- Email via SMTP
- Push notifications via ntfy, Gotify, Telegram, Discord (Shoutrrr)
- Push notifications via ntfy, Pushover, Gotify, Telegram, Discord & more ([Shoutrrr](https://containrrr.dev/shoutrrr/))
- Supports both stock warnings and intake reminders
### Privacy & Security
@@ -79,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.
@@ -96,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
@@ -113,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 |
@@ -121,7 +278,7 @@ Generate secrets with: `openssl rand -hex 32`
| `OIDC_ISSUER_URL` | — | OIDC provider URL |
| `OIDC_CLIENT_ID` | — | Client ID from OIDC provider |
| `OIDC_CLIENT_SECRET` | — | Client secret from OIDC provider |
| `OIDC_REDIRECT_URI` | — | Callback URL |
| `OIDC_REDIRECT_URI` | — | Full callback URL (e.g., `https://your-domain.com/api/auth/oidc/callback`) |
| `OIDC_SCOPES` | `openid profile email` | Scopes to request |
| `OIDC_USERNAME_CLAIM` | `preferred_username` | Claim for username |
| `OIDC_AUTO_CREATE_USERS` | `true` | Auto-create users on first SSO login |
@@ -148,14 +305,94 @@ 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.
**Implemented URL schemes in MedAssist:** `ntfy://`, `discord://`, `pushover://`, `gotify://`, `telegram://`, plus direct `https://` webhooks.
This covers common providers like ntfy, Discord, Pushover, Gotify, Telegram, Slack webhooks, and many others via webhook URLs.
Configure push notifications in Settings → Push, or set defaults via environment variables:
| Variable | Default | Description |
|----------|---------|-------------|
| `DEFAULT_SHOUTRRR_ENABLED` | `false` | Enable push notifications by default |
| `DEFAULT_SHOUTRRR_URL` | — | Shoutrrr URL (see examples below) |
| `DEFAULT_SHOUTRRR_STOCK_REMINDERS` | `true` | Send stock warnings via push |
| `DEFAULT_SHOUTRRR_INTAKE_REMINDERS` | `true` | Send intake reminders via push |
### Default User Settings
These defaults are applied when a new user is created. Once a user saves settings in the app, their values take precedence.
Complete list and details:
- [docs/DEFAULT_USER_SETTINGS.md](docs/DEFAULT_USER_SETTINGS.md)
#### URL Examples
**ntfy** (free, self-hostable):
```
ntfy://ntfy.sh/your-topic
ntfy://user:password@your-server.com/topic
```
**Pushover** (free app for iOS/Android):
```
pushover://shoutrrr:API_TOKEN@USER_KEY/
```
Get your keys at [pushover.net](https://pushover.net/):
- **User Key**: Shown on your dashboard (top right)
- **API Token**: Create an application → copy the API Token
**Gotify** (self-hosted):
```
gotify://your-server.com/TOKEN
gotify://your-server.com:443/path/to/gotify/TOKEN?priority=1
```
**Discord**:
```
discord://TOKEN@WEBHOOK_ID
```
**Telegram**:
```
telegram://TOKEN@telegram?chats=CHAT_ID
telegram://TOKEN@telegram?chats=@your_channel,-1001234567890
```
For all services and options, see the [Shoutrrr documentation](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
# 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)
If you run the frontend dev server behind a reverse proxy or on a remote host, you can optionally set these frontend-only environment variables before starting Vite:
- `VITE_ALLOWED_HOSTS`: comma-separated hostnames allowed to connect to the dev server; defaults to `localhost,127.0.0.1`
- `VITE_HMR_HOST`: public hostname used for HMR websocket connections
- `VITE_HMR_PROTOCOL`: optional websocket protocol override (`ws` or `wss`)
- `VITE_HMR_CLIENT_PORT`: optional public websocket port exposed to the browser
- `VITE_HMR_PORT`: optional server-side websocket port for the Vite process
Useful local commands:
```bash
npm run lint
cd backend && npm run test:run
cd frontend && npm run test:run
```
# Acknowledgements
+35
View File
@@ -0,0 +1,35 @@
# Dependencies
node_modules/
# Build outputs
dist/
coverage/
# Development files
*.log
npm-debug.log*
# Test files
src/test/
*.test.ts
vitest.config.ts
# Local data (mounted as volume in production)
data/
# IDE
.vscode/
.idea/
# OS files
.DS_Store
Thumbs.db
# Git
.git/
.gitignore
# Docker
Dockerfile
.dockerignore
docker-compose*.yml
+3
View File
@@ -46,6 +46,9 @@ COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
# Copy drizzle migrations folder (required for database setup)
COPY drizzle ./drizzle
# Create data directory and set ownership to node user (UID 1000)
RUN mkdir -p /app/data && chown -R node:node /app
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/db/schema.ts",
out: "./drizzle",
dialect: "sqlite",
dbCredentials: {
url: process.env.DATABASE_URL || "./data/medassist-ng.db",
},
});
+112
View File
@@ -0,0 +1,112 @@
CREATE TABLE `dose_tracking` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`dose_id` text(255) NOT NULL,
`taken_at` integer DEFAULT (strftime('%s','now')) NOT NULL,
`marked_by` text(100),
`dismissed` integer DEFAULT false NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `medications` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`name` text(100) NOT NULL,
`generic_name` text(100),
`taken_by_json` text DEFAULT '[]' NOT NULL,
`pack_count` integer DEFAULT 1 NOT NULL,
`blisters_per_pack` integer DEFAULT 1 NOT NULL,
`pills_per_blister` integer DEFAULT 1 NOT NULL,
`loose_tablets` integer DEFAULT 0 NOT NULL,
`pill_weight_mg` integer,
`usage_json` text DEFAULT '[]' NOT NULL,
`every_json` text DEFAULT '[]' NOT NULL,
`start_json` text DEFAULT '[]' NOT NULL,
`image_url` text,
`expiry_date` text,
`notes` text,
`intake_reminders_enabled` integer DEFAULT false 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 TABLE `refill_history` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`medication_id` integer NOT NULL,
`user_id` integer NOT NULL,
`packs_added` integer DEFAULT 0 NOT NULL,
`loose_pills_added` integer DEFAULT 0 NOT NULL,
`refill_date` integer DEFAULT (strftime('%s','now')) NOT NULL,
FOREIGN KEY (`medication_id`) REFERENCES `medications`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE TABLE `refresh_tokens` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`token_id` text(255) NOT NULL,
`expires_at` integer NOT NULL,
`rotated_at` integer,
`revoked` integer DEFAULT false NOT NULL,
`created_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 `refresh_tokens_token_id_unique` ON `refresh_tokens` (`token_id`);--> statement-breakpoint
CREATE TABLE `share_tokens` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`token` text(64) NOT NULL,
`taken_by` text(100) NOT NULL,
`schedule_days` integer DEFAULT 30 NOT NULL,
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
`expires_at` integer,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `share_tokens_token_unique` ON `share_tokens` (`token`);--> statement-breakpoint
CREATE TABLE `user_settings` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`email_enabled` integer DEFAULT false NOT NULL,
`notification_email` text,
`email_stock_reminders` integer DEFAULT true NOT NULL,
`email_intake_reminders` integer DEFAULT true NOT NULL,
`shoutrrr_enabled` integer DEFAULT false NOT NULL,
`shoutrrr_url` text,
`shoutrrr_stock_reminders` integer DEFAULT true NOT NULL,
`shoutrrr_intake_reminders` integer DEFAULT true NOT NULL,
`reminder_days_before` integer DEFAULT 7 NOT NULL,
`repeat_daily_reminders` integer DEFAULT false NOT NULL,
`skip_reminders_for_taken_doses` integer DEFAULT false NOT NULL,
`repeat_reminders_enabled` integer DEFAULT false NOT NULL,
`reminder_repeat_interval_minutes` integer DEFAULT 30 NOT NULL,
`max_nagging_reminders` integer DEFAULT 5 NOT NULL,
`low_stock_days` integer DEFAULT 30 NOT NULL,
`normal_stock_days` integer DEFAULT 90 NOT NULL,
`high_stock_days` integer DEFAULT 180 NOT NULL,
`expiry_warning_days` integer DEFAULT 90 NOT NULL,
`language` text(10) DEFAULT 'en' NOT NULL,
`stock_calculation_mode` text(20) DEFAULT 'automatic' NOT NULL,
`last_auto_email_sent` text,
`last_notification_type` text,
`last_notification_channel` text,
`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 `user_settings_user_id_unique` ON `user_settings` (`user_id`);--> statement-breakpoint
CREATE TABLE `users` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`username` text(100) NOT NULL,
`password_hash` text(255),
`avatar_url` text(255),
`auth_provider` text(50) DEFAULT 'local' NOT NULL,
`oidc_subject` text(255),
`is_active` integer DEFAULT true NOT NULL,
`last_login_at` integer,
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `users_username_unique` ON `users` (`username`);
@@ -0,0 +1 @@
ALTER TABLE `medications` ADD `stock_adjustment` integer DEFAULT 0 NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE `medications` ADD `last_stock_correction_at` integer;
@@ -0,0 +1,3 @@
ALTER TABLE `medications` ADD `dismissed_until` text;--> statement-breakpoint
ALTER TABLE `user_settings` ADD `last_reminder_med_name` text;--> statement-breakpoint
ALTER TABLE `user_settings` ADD `last_reminder_taken_by` text;
@@ -0,0 +1,3 @@
-- Add package type support (blister vs bottle)
ALTER TABLE medications ADD COLUMN package_type TEXT DEFAULT 'blister' NOT NULL;
ALTER TABLE medications ADD COLUMN total_pills INTEGER;
@@ -0,0 +1,3 @@
-- Add dose_unit column and intakes JSON array for per-intake takenBy support
ALTER TABLE `medications` ADD `dose_unit` text(20) DEFAULT 'mg';--> statement-breakpoint
ALTER TABLE `medications` ADD `intakes_json` text DEFAULT '[]' NOT NULL;
@@ -0,0 +1,3 @@
ALTER TABLE `user_settings` ADD `last_stock_reminder_sent` text;--> statement-breakpoint
ALTER TABLE `user_settings` ADD `last_stock_reminder_channel` text;--> statement-breakpoint
ALTER TABLE `user_settings` ADD `last_stock_reminder_med_names` text;
@@ -0,0 +1 @@
ALTER TABLE `user_settings` ADD `share_stock_status` integer DEFAULT true NOT NULL;
@@ -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,8 @@
ALTER TABLE `medications` ADD `prescription_enabled` integer NOT NULL DEFAULT 0;
ALTER TABLE `medications` ADD `prescription_authorized_refills` integer;
ALTER TABLE `medications` ADD `prescription_remaining_refills` integer;
ALTER TABLE `medications` ADD `prescription_low_refill_threshold` integer NOT NULL DEFAULT 1;
ALTER TABLE `medications` ADD `prescription_expiry_date` text;
ALTER TABLE `user_settings` ADD `email_prescription_reminders` integer NOT NULL DEFAULT 1;
ALTER TABLE `user_settings` ADD `shoutrrr_prescription_reminders` integer NOT NULL DEFAULT 1;
@@ -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;
+819
View File
@@ -0,0 +1,819 @@
{
"version": "6",
"dialect": "sqlite",
"id": "0e7f882c-b6e8-4d7b-a6a8-a076969c3e76",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"dose_tracking": {
"name": "dose_tracking",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dose_id": {
"name": "dose_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_at": {
"name": "taken_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
},
"marked_by": {
"name": "marked_by",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dismissed": {
"name": "dismissed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {},
"foreignKeys": {
"dose_tracking_user_id_users_id_fk": {
"name": "dose_tracking_user_id_users_id_fk",
"tableFrom": "dose_tracking",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"medications": {
"name": "medications",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"generic_name": {
"name": "generic_name",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"taken_by_json": {
"name": "taken_by_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"pack_count": {
"name": "pack_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"blisters_per_pack": {
"name": "blisters_per_pack",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"pills_per_blister": {
"name": "pills_per_blister",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"loose_tablets": {
"name": "loose_tablets",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"pill_weight_mg": {
"name": "pill_weight_mg",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"usage_json": {
"name": "usage_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"every_json": {
"name": "every_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"start_json": {
"name": "start_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expiry_date": {
"name": "expiry_date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"intake_reminders_enabled": {
"name": "intake_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {
"medications_user_id_users_id_fk": {
"name": "medications_user_id_users_id_fk",
"tableFrom": "medications",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refill_history": {
"name": "refill_history",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"medication_id": {
"name": "medication_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"packs_added": {
"name": "packs_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"loose_pills_added": {
"name": "loose_pills_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"refill_date": {
"name": "refill_date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
}
},
"indexes": {},
"foreignKeys": {
"refill_history_medication_id_medications_id_fk": {
"name": "refill_history_medication_id_medications_id_fk",
"tableFrom": "refill_history",
"tableTo": "medications",
"columnsFrom": [
"medication_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"refill_history_user_id_users_id_fk": {
"name": "refill_history_user_id_users_id_fk",
"tableFrom": "refill_history",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refresh_tokens": {
"name": "refresh_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token_id": {
"name": "token_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rotated_at": {
"name": "rotated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"revoked": {
"name": "revoked",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"refresh_tokens_token_id_unique": {
"name": "refresh_tokens_token_id_unique",
"columns": [
"token_id"
],
"isUnique": true
}
},
"foreignKeys": {
"refresh_tokens_user_id_users_id_fk": {
"name": "refresh_tokens_user_id_users_id_fk",
"tableFrom": "refresh_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"share_tokens": {
"name": "share_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_by": {
"name": "taken_by",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"schedule_days": {
"name": "schedule_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"share_tokens_token_unique": {
"name": "share_tokens_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {
"share_tokens_user_id_users_id_fk": {
"name": "share_tokens_user_id_users_id_fk",
"tableFrom": "share_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_settings": {
"name": "user_settings",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_enabled": {
"name": "email_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notification_email": {
"name": "notification_email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email_stock_reminders": {
"name": "email_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"email_intake_reminders": {
"name": "email_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_enabled": {
"name": "shoutrrr_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"shoutrrr_url": {
"name": "shoutrrr_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"shoutrrr_stock_reminders": {
"name": "shoutrrr_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_intake_reminders": {
"name": "shoutrrr_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"reminder_days_before": {
"name": "reminder_days_before",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 7
},
"repeat_daily_reminders": {
"name": "repeat_daily_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"skip_reminders_for_taken_doses": {
"name": "skip_reminders_for_taken_doses",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"repeat_reminders_enabled": {
"name": "repeat_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"reminder_repeat_interval_minutes": {
"name": "reminder_repeat_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"max_nagging_reminders": {
"name": "max_nagging_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 5
},
"low_stock_days": {
"name": "low_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"normal_stock_days": {
"name": "normal_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"high_stock_days": {
"name": "high_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 180
},
"expiry_warning_days": {
"name": "expiry_warning_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"language": {
"name": "language",
"type": "text(10)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'en'"
},
"stock_calculation_mode": {
"name": "stock_calculation_mode",
"type": "text(20)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'automatic'"
},
"last_auto_email_sent": {
"name": "last_auto_email_sent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_type": {
"name": "last_notification_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_channel": {
"name": "last_notification_channel",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"user_settings_user_id_unique": {
"name": "user_settings_user_id_unique",
"columns": [
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"user_settings_user_id_users_id_fk": {
"name": "user_settings_user_id_users_id_fk",
"tableFrom": "user_settings",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avatar_url": {
"name": "avatar_url",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"auth_provider": {
"name": "auth_provider",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'local'"
},
"oidc_subject": {
"name": "oidc_subject",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"last_login_at": {
"name": "last_login_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+827
View File
@@ -0,0 +1,827 @@
{
"version": "6",
"dialect": "sqlite",
"id": "bcb60728-38c0-4965-adac-829c02240d89",
"prevId": "0e7f882c-b6e8-4d7b-a6a8-a076969c3e76",
"tables": {
"dose_tracking": {
"name": "dose_tracking",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dose_id": {
"name": "dose_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_at": {
"name": "taken_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
},
"marked_by": {
"name": "marked_by",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dismissed": {
"name": "dismissed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {},
"foreignKeys": {
"dose_tracking_user_id_users_id_fk": {
"name": "dose_tracking_user_id_users_id_fk",
"tableFrom": "dose_tracking",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"medications": {
"name": "medications",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"generic_name": {
"name": "generic_name",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"taken_by_json": {
"name": "taken_by_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"pack_count": {
"name": "pack_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"blisters_per_pack": {
"name": "blisters_per_pack",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"pills_per_blister": {
"name": "pills_per_blister",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"loose_tablets": {
"name": "loose_tablets",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"stock_adjustment": {
"name": "stock_adjustment",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"pill_weight_mg": {
"name": "pill_weight_mg",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"usage_json": {
"name": "usage_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"every_json": {
"name": "every_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"start_json": {
"name": "start_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expiry_date": {
"name": "expiry_date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"intake_reminders_enabled": {
"name": "intake_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {
"medications_user_id_users_id_fk": {
"name": "medications_user_id_users_id_fk",
"tableFrom": "medications",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refill_history": {
"name": "refill_history",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"medication_id": {
"name": "medication_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"packs_added": {
"name": "packs_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"loose_pills_added": {
"name": "loose_pills_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"refill_date": {
"name": "refill_date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
}
},
"indexes": {},
"foreignKeys": {
"refill_history_medication_id_medications_id_fk": {
"name": "refill_history_medication_id_medications_id_fk",
"tableFrom": "refill_history",
"tableTo": "medications",
"columnsFrom": [
"medication_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"refill_history_user_id_users_id_fk": {
"name": "refill_history_user_id_users_id_fk",
"tableFrom": "refill_history",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refresh_tokens": {
"name": "refresh_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token_id": {
"name": "token_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rotated_at": {
"name": "rotated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"revoked": {
"name": "revoked",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"refresh_tokens_token_id_unique": {
"name": "refresh_tokens_token_id_unique",
"columns": [
"token_id"
],
"isUnique": true
}
},
"foreignKeys": {
"refresh_tokens_user_id_users_id_fk": {
"name": "refresh_tokens_user_id_users_id_fk",
"tableFrom": "refresh_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"share_tokens": {
"name": "share_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_by": {
"name": "taken_by",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"schedule_days": {
"name": "schedule_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"share_tokens_token_unique": {
"name": "share_tokens_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {
"share_tokens_user_id_users_id_fk": {
"name": "share_tokens_user_id_users_id_fk",
"tableFrom": "share_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_settings": {
"name": "user_settings",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_enabled": {
"name": "email_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notification_email": {
"name": "notification_email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email_stock_reminders": {
"name": "email_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"email_intake_reminders": {
"name": "email_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_enabled": {
"name": "shoutrrr_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"shoutrrr_url": {
"name": "shoutrrr_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"shoutrrr_stock_reminders": {
"name": "shoutrrr_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_intake_reminders": {
"name": "shoutrrr_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"reminder_days_before": {
"name": "reminder_days_before",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 7
},
"repeat_daily_reminders": {
"name": "repeat_daily_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"skip_reminders_for_taken_doses": {
"name": "skip_reminders_for_taken_doses",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"repeat_reminders_enabled": {
"name": "repeat_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"reminder_repeat_interval_minutes": {
"name": "reminder_repeat_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"max_nagging_reminders": {
"name": "max_nagging_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 5
},
"low_stock_days": {
"name": "low_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"normal_stock_days": {
"name": "normal_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"high_stock_days": {
"name": "high_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 180
},
"expiry_warning_days": {
"name": "expiry_warning_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"language": {
"name": "language",
"type": "text(10)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'en'"
},
"stock_calculation_mode": {
"name": "stock_calculation_mode",
"type": "text(20)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'automatic'"
},
"last_auto_email_sent": {
"name": "last_auto_email_sent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_type": {
"name": "last_notification_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_channel": {
"name": "last_notification_channel",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"user_settings_user_id_unique": {
"name": "user_settings_user_id_unique",
"columns": [
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"user_settings_user_id_users_id_fk": {
"name": "user_settings_user_id_users_id_fk",
"tableFrom": "user_settings",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avatar_url": {
"name": "avatar_url",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"auth_provider": {
"name": "auth_provider",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'local'"
},
"oidc_subject": {
"name": "oidc_subject",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"last_login_at": {
"name": "last_login_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+834
View File
@@ -0,0 +1,834 @@
{
"version": "6",
"dialect": "sqlite",
"id": "098ee506-e43d-4ccb-bee5-c387905695ab",
"prevId": "bcb60728-38c0-4965-adac-829c02240d89",
"tables": {
"dose_tracking": {
"name": "dose_tracking",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dose_id": {
"name": "dose_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_at": {
"name": "taken_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
},
"marked_by": {
"name": "marked_by",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dismissed": {
"name": "dismissed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {},
"foreignKeys": {
"dose_tracking_user_id_users_id_fk": {
"name": "dose_tracking_user_id_users_id_fk",
"tableFrom": "dose_tracking",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"medications": {
"name": "medications",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"generic_name": {
"name": "generic_name",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"taken_by_json": {
"name": "taken_by_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"pack_count": {
"name": "pack_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"blisters_per_pack": {
"name": "blisters_per_pack",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"pills_per_blister": {
"name": "pills_per_blister",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"loose_tablets": {
"name": "loose_tablets",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"stock_adjustment": {
"name": "stock_adjustment",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"last_stock_correction_at": {
"name": "last_stock_correction_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pill_weight_mg": {
"name": "pill_weight_mg",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"usage_json": {
"name": "usage_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"every_json": {
"name": "every_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"start_json": {
"name": "start_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expiry_date": {
"name": "expiry_date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"intake_reminders_enabled": {
"name": "intake_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {
"medications_user_id_users_id_fk": {
"name": "medications_user_id_users_id_fk",
"tableFrom": "medications",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refill_history": {
"name": "refill_history",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"medication_id": {
"name": "medication_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"packs_added": {
"name": "packs_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"loose_pills_added": {
"name": "loose_pills_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"refill_date": {
"name": "refill_date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
}
},
"indexes": {},
"foreignKeys": {
"refill_history_medication_id_medications_id_fk": {
"name": "refill_history_medication_id_medications_id_fk",
"tableFrom": "refill_history",
"tableTo": "medications",
"columnsFrom": [
"medication_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"refill_history_user_id_users_id_fk": {
"name": "refill_history_user_id_users_id_fk",
"tableFrom": "refill_history",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refresh_tokens": {
"name": "refresh_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token_id": {
"name": "token_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rotated_at": {
"name": "rotated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"revoked": {
"name": "revoked",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"refresh_tokens_token_id_unique": {
"name": "refresh_tokens_token_id_unique",
"columns": [
"token_id"
],
"isUnique": true
}
},
"foreignKeys": {
"refresh_tokens_user_id_users_id_fk": {
"name": "refresh_tokens_user_id_users_id_fk",
"tableFrom": "refresh_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"share_tokens": {
"name": "share_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_by": {
"name": "taken_by",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"schedule_days": {
"name": "schedule_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"share_tokens_token_unique": {
"name": "share_tokens_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {
"share_tokens_user_id_users_id_fk": {
"name": "share_tokens_user_id_users_id_fk",
"tableFrom": "share_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_settings": {
"name": "user_settings",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_enabled": {
"name": "email_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notification_email": {
"name": "notification_email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email_stock_reminders": {
"name": "email_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"email_intake_reminders": {
"name": "email_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_enabled": {
"name": "shoutrrr_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"shoutrrr_url": {
"name": "shoutrrr_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"shoutrrr_stock_reminders": {
"name": "shoutrrr_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_intake_reminders": {
"name": "shoutrrr_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"reminder_days_before": {
"name": "reminder_days_before",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 7
},
"repeat_daily_reminders": {
"name": "repeat_daily_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"skip_reminders_for_taken_doses": {
"name": "skip_reminders_for_taken_doses",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"repeat_reminders_enabled": {
"name": "repeat_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"reminder_repeat_interval_minutes": {
"name": "reminder_repeat_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"max_nagging_reminders": {
"name": "max_nagging_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 5
},
"low_stock_days": {
"name": "low_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"normal_stock_days": {
"name": "normal_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"high_stock_days": {
"name": "high_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 180
},
"expiry_warning_days": {
"name": "expiry_warning_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"language": {
"name": "language",
"type": "text(10)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'en'"
},
"stock_calculation_mode": {
"name": "stock_calculation_mode",
"type": "text(20)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'automatic'"
},
"last_auto_email_sent": {
"name": "last_auto_email_sent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_type": {
"name": "last_notification_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_channel": {
"name": "last_notification_channel",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"user_settings_user_id_unique": {
"name": "user_settings_user_id_unique",
"columns": [
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"user_settings_user_id_users_id_fk": {
"name": "user_settings_user_id_users_id_fk",
"tableFrom": "user_settings",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avatar_url": {
"name": "avatar_url",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"auth_provider": {
"name": "auth_provider",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'local'"
},
"oidc_subject": {
"name": "oidc_subject",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"last_login_at": {
"name": "last_login_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+855
View File
@@ -0,0 +1,855 @@
{
"version": "6",
"dialect": "sqlite",
"id": "4f1d8273-1e60-4da1-9bfc-bd51c2784836",
"prevId": "098ee506-e43d-4ccb-bee5-c387905695ab",
"tables": {
"dose_tracking": {
"name": "dose_tracking",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dose_id": {
"name": "dose_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_at": {
"name": "taken_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
},
"marked_by": {
"name": "marked_by",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dismissed": {
"name": "dismissed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {},
"foreignKeys": {
"dose_tracking_user_id_users_id_fk": {
"name": "dose_tracking_user_id_users_id_fk",
"tableFrom": "dose_tracking",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"medications": {
"name": "medications",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"generic_name": {
"name": "generic_name",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"taken_by_json": {
"name": "taken_by_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"pack_count": {
"name": "pack_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"blisters_per_pack": {
"name": "blisters_per_pack",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"pills_per_blister": {
"name": "pills_per_blister",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"loose_tablets": {
"name": "loose_tablets",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"stock_adjustment": {
"name": "stock_adjustment",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"last_stock_correction_at": {
"name": "last_stock_correction_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pill_weight_mg": {
"name": "pill_weight_mg",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"usage_json": {
"name": "usage_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"every_json": {
"name": "every_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"start_json": {
"name": "start_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expiry_date": {
"name": "expiry_date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"intake_reminders_enabled": {
"name": "intake_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"dismissed_until": {
"name": "dismissed_until",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {
"medications_user_id_users_id_fk": {
"name": "medications_user_id_users_id_fk",
"tableFrom": "medications",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refill_history": {
"name": "refill_history",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"medication_id": {
"name": "medication_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"packs_added": {
"name": "packs_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"loose_pills_added": {
"name": "loose_pills_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"refill_date": {
"name": "refill_date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
}
},
"indexes": {},
"foreignKeys": {
"refill_history_medication_id_medications_id_fk": {
"name": "refill_history_medication_id_medications_id_fk",
"tableFrom": "refill_history",
"tableTo": "medications",
"columnsFrom": [
"medication_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"refill_history_user_id_users_id_fk": {
"name": "refill_history_user_id_users_id_fk",
"tableFrom": "refill_history",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refresh_tokens": {
"name": "refresh_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token_id": {
"name": "token_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rotated_at": {
"name": "rotated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"revoked": {
"name": "revoked",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"refresh_tokens_token_id_unique": {
"name": "refresh_tokens_token_id_unique",
"columns": [
"token_id"
],
"isUnique": true
}
},
"foreignKeys": {
"refresh_tokens_user_id_users_id_fk": {
"name": "refresh_tokens_user_id_users_id_fk",
"tableFrom": "refresh_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"share_tokens": {
"name": "share_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_by": {
"name": "taken_by",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"schedule_days": {
"name": "schedule_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"share_tokens_token_unique": {
"name": "share_tokens_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {
"share_tokens_user_id_users_id_fk": {
"name": "share_tokens_user_id_users_id_fk",
"tableFrom": "share_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_settings": {
"name": "user_settings",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_enabled": {
"name": "email_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notification_email": {
"name": "notification_email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email_stock_reminders": {
"name": "email_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"email_intake_reminders": {
"name": "email_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_enabled": {
"name": "shoutrrr_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"shoutrrr_url": {
"name": "shoutrrr_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"shoutrrr_stock_reminders": {
"name": "shoutrrr_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_intake_reminders": {
"name": "shoutrrr_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"reminder_days_before": {
"name": "reminder_days_before",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 7
},
"repeat_daily_reminders": {
"name": "repeat_daily_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"skip_reminders_for_taken_doses": {
"name": "skip_reminders_for_taken_doses",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"repeat_reminders_enabled": {
"name": "repeat_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"reminder_repeat_interval_minutes": {
"name": "reminder_repeat_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"max_nagging_reminders": {
"name": "max_nagging_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 5
},
"low_stock_days": {
"name": "low_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"normal_stock_days": {
"name": "normal_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"high_stock_days": {
"name": "high_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 180
},
"expiry_warning_days": {
"name": "expiry_warning_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"language": {
"name": "language",
"type": "text(10)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'en'"
},
"stock_calculation_mode": {
"name": "stock_calculation_mode",
"type": "text(20)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'automatic'"
},
"last_auto_email_sent": {
"name": "last_auto_email_sent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_type": {
"name": "last_notification_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_channel": {
"name": "last_notification_channel",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_reminder_med_name": {
"name": "last_reminder_med_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_reminder_taken_by": {
"name": "last_reminder_taken_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"user_settings_user_id_unique": {
"name": "user_settings_user_id_unique",
"columns": [
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"user_settings_user_id_users_id_fk": {
"name": "user_settings_user_id_users_id_fk",
"tableFrom": "user_settings",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avatar_url": {
"name": "avatar_url",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"auth_provider": {
"name": "auth_provider",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'local'"
},
"oidc_subject": {
"name": "oidc_subject",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"last_login_at": {
"name": "last_login_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+886
View File
@@ -0,0 +1,886 @@
{
"version": "6",
"dialect": "sqlite",
"id": "fb61e5fd-152d-4e61-8836-e2fd1d28e3f0",
"prevId": "4f1d8273-1e60-4da1-9bfc-bd51c2784836",
"tables": {
"dose_tracking": {
"name": "dose_tracking",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dose_id": {
"name": "dose_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_at": {
"name": "taken_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
},
"marked_by": {
"name": "marked_by",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dismissed": {
"name": "dismissed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {},
"foreignKeys": {
"dose_tracking_user_id_users_id_fk": {
"name": "dose_tracking_user_id_users_id_fk",
"tableFrom": "dose_tracking",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"medications": {
"name": "medications",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"generic_name": {
"name": "generic_name",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"taken_by_json": {
"name": "taken_by_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"package_type": {
"name": "package_type",
"type": "text(20)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'blister'"
},
"pack_count": {
"name": "pack_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"blisters_per_pack": {
"name": "blisters_per_pack",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"pills_per_blister": {
"name": "pills_per_blister",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"total_pills": {
"name": "total_pills",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"loose_tablets": {
"name": "loose_tablets",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"stock_adjustment": {
"name": "stock_adjustment",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"last_stock_correction_at": {
"name": "last_stock_correction_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pill_weight_mg": {
"name": "pill_weight_mg",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dose_unit": {
"name": "dose_unit",
"type": "text(20)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'mg'"
},
"usage_json": {
"name": "usage_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"every_json": {
"name": "every_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"start_json": {
"name": "start_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"intakes_json": {
"name": "intakes_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expiry_date": {
"name": "expiry_date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"intake_reminders_enabled": {
"name": "intake_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"dismissed_until": {
"name": "dismissed_until",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {
"medications_user_id_users_id_fk": {
"name": "medications_user_id_users_id_fk",
"tableFrom": "medications",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refill_history": {
"name": "refill_history",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"medication_id": {
"name": "medication_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"packs_added": {
"name": "packs_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"loose_pills_added": {
"name": "loose_pills_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"refill_date": {
"name": "refill_date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
}
},
"indexes": {},
"foreignKeys": {
"refill_history_medication_id_medications_id_fk": {
"name": "refill_history_medication_id_medications_id_fk",
"tableFrom": "refill_history",
"tableTo": "medications",
"columnsFrom": [
"medication_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"refill_history_user_id_users_id_fk": {
"name": "refill_history_user_id_users_id_fk",
"tableFrom": "refill_history",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refresh_tokens": {
"name": "refresh_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token_id": {
"name": "token_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rotated_at": {
"name": "rotated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"revoked": {
"name": "revoked",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"refresh_tokens_token_id_unique": {
"name": "refresh_tokens_token_id_unique",
"columns": [
"token_id"
],
"isUnique": true
}
},
"foreignKeys": {
"refresh_tokens_user_id_users_id_fk": {
"name": "refresh_tokens_user_id_users_id_fk",
"tableFrom": "refresh_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"share_tokens": {
"name": "share_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_by": {
"name": "taken_by",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"schedule_days": {
"name": "schedule_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"share_tokens_token_unique": {
"name": "share_tokens_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {
"share_tokens_user_id_users_id_fk": {
"name": "share_tokens_user_id_users_id_fk",
"tableFrom": "share_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_settings": {
"name": "user_settings",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_enabled": {
"name": "email_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notification_email": {
"name": "notification_email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email_stock_reminders": {
"name": "email_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"email_intake_reminders": {
"name": "email_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_enabled": {
"name": "shoutrrr_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"shoutrrr_url": {
"name": "shoutrrr_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"shoutrrr_stock_reminders": {
"name": "shoutrrr_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_intake_reminders": {
"name": "shoutrrr_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"reminder_days_before": {
"name": "reminder_days_before",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 7
},
"repeat_daily_reminders": {
"name": "repeat_daily_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"skip_reminders_for_taken_doses": {
"name": "skip_reminders_for_taken_doses",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"repeat_reminders_enabled": {
"name": "repeat_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"reminder_repeat_interval_minutes": {
"name": "reminder_repeat_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"max_nagging_reminders": {
"name": "max_nagging_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 5
},
"low_stock_days": {
"name": "low_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"normal_stock_days": {
"name": "normal_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"high_stock_days": {
"name": "high_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 180
},
"expiry_warning_days": {
"name": "expiry_warning_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"language": {
"name": "language",
"type": "text(10)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'en'"
},
"stock_calculation_mode": {
"name": "stock_calculation_mode",
"type": "text(20)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'automatic'"
},
"last_auto_email_sent": {
"name": "last_auto_email_sent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_type": {
"name": "last_notification_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_channel": {
"name": "last_notification_channel",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_reminder_med_name": {
"name": "last_reminder_med_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_reminder_taken_by": {
"name": "last_reminder_taken_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"user_settings_user_id_unique": {
"name": "user_settings_user_id_unique",
"columns": [
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"user_settings_user_id_users_id_fk": {
"name": "user_settings_user_id_users_id_fk",
"tableFrom": "user_settings",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avatar_url": {
"name": "avatar_url",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"auth_provider": {
"name": "auth_provider",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'local'"
},
"oidc_subject": {
"name": "oidc_subject",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"last_login_at": {
"name": "last_login_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+907
View File
@@ -0,0 +1,907 @@
{
"version": "6",
"dialect": "sqlite",
"id": "7cd75e33-b3d8-4930-a60b-2a0a9f644c6d",
"prevId": "fb61e5fd-152d-4e61-8836-e2fd1d28e3f0",
"tables": {
"dose_tracking": {
"name": "dose_tracking",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dose_id": {
"name": "dose_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_at": {
"name": "taken_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
},
"marked_by": {
"name": "marked_by",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dismissed": {
"name": "dismissed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {},
"foreignKeys": {
"dose_tracking_user_id_users_id_fk": {
"name": "dose_tracking_user_id_users_id_fk",
"tableFrom": "dose_tracking",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"medications": {
"name": "medications",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"generic_name": {
"name": "generic_name",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"taken_by_json": {
"name": "taken_by_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"package_type": {
"name": "package_type",
"type": "text(20)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'blister'"
},
"pack_count": {
"name": "pack_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"blisters_per_pack": {
"name": "blisters_per_pack",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"pills_per_blister": {
"name": "pills_per_blister",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"total_pills": {
"name": "total_pills",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"loose_tablets": {
"name": "loose_tablets",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"stock_adjustment": {
"name": "stock_adjustment",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"last_stock_correction_at": {
"name": "last_stock_correction_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pill_weight_mg": {
"name": "pill_weight_mg",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dose_unit": {
"name": "dose_unit",
"type": "text(20)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'mg'"
},
"usage_json": {
"name": "usage_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"every_json": {
"name": "every_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"start_json": {
"name": "start_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"intakes_json": {
"name": "intakes_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expiry_date": {
"name": "expiry_date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"intake_reminders_enabled": {
"name": "intake_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"dismissed_until": {
"name": "dismissed_until",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {
"medications_user_id_users_id_fk": {
"name": "medications_user_id_users_id_fk",
"tableFrom": "medications",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refill_history": {
"name": "refill_history",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"medication_id": {
"name": "medication_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"packs_added": {
"name": "packs_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"loose_pills_added": {
"name": "loose_pills_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"refill_date": {
"name": "refill_date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
}
},
"indexes": {},
"foreignKeys": {
"refill_history_medication_id_medications_id_fk": {
"name": "refill_history_medication_id_medications_id_fk",
"tableFrom": "refill_history",
"tableTo": "medications",
"columnsFrom": [
"medication_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"refill_history_user_id_users_id_fk": {
"name": "refill_history_user_id_users_id_fk",
"tableFrom": "refill_history",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refresh_tokens": {
"name": "refresh_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token_id": {
"name": "token_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rotated_at": {
"name": "rotated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"revoked": {
"name": "revoked",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"refresh_tokens_token_id_unique": {
"name": "refresh_tokens_token_id_unique",
"columns": [
"token_id"
],
"isUnique": true
}
},
"foreignKeys": {
"refresh_tokens_user_id_users_id_fk": {
"name": "refresh_tokens_user_id_users_id_fk",
"tableFrom": "refresh_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"share_tokens": {
"name": "share_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_by": {
"name": "taken_by",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"schedule_days": {
"name": "schedule_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"share_tokens_token_unique": {
"name": "share_tokens_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {
"share_tokens_user_id_users_id_fk": {
"name": "share_tokens_user_id_users_id_fk",
"tableFrom": "share_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_settings": {
"name": "user_settings",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_enabled": {
"name": "email_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notification_email": {
"name": "notification_email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email_stock_reminders": {
"name": "email_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"email_intake_reminders": {
"name": "email_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_enabled": {
"name": "shoutrrr_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"shoutrrr_url": {
"name": "shoutrrr_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"shoutrrr_stock_reminders": {
"name": "shoutrrr_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_intake_reminders": {
"name": "shoutrrr_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"reminder_days_before": {
"name": "reminder_days_before",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 7
},
"repeat_daily_reminders": {
"name": "repeat_daily_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"skip_reminders_for_taken_doses": {
"name": "skip_reminders_for_taken_doses",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"repeat_reminders_enabled": {
"name": "repeat_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"reminder_repeat_interval_minutes": {
"name": "reminder_repeat_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"max_nagging_reminders": {
"name": "max_nagging_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 5
},
"low_stock_days": {
"name": "low_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"normal_stock_days": {
"name": "normal_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"high_stock_days": {
"name": "high_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 180
},
"expiry_warning_days": {
"name": "expiry_warning_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"language": {
"name": "language",
"type": "text(10)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'en'"
},
"stock_calculation_mode": {
"name": "stock_calculation_mode",
"type": "text(20)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'automatic'"
},
"last_auto_email_sent": {
"name": "last_auto_email_sent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_type": {
"name": "last_notification_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_channel": {
"name": "last_notification_channel",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_reminder_med_name": {
"name": "last_reminder_med_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_reminder_taken_by": {
"name": "last_reminder_taken_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_stock_reminder_sent": {
"name": "last_stock_reminder_sent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_stock_reminder_channel": {
"name": "last_stock_reminder_channel",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_stock_reminder_med_names": {
"name": "last_stock_reminder_med_names",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"user_settings_user_id_unique": {
"name": "user_settings_user_id_unique",
"columns": [
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"user_settings_user_id_users_id_fk": {
"name": "user_settings_user_id_users_id_fk",
"tableFrom": "user_settings",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avatar_url": {
"name": "avatar_url",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"auth_provider": {
"name": "auth_provider",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'local'"
},
"oidc_subject": {
"name": "oidc_subject",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"last_login_at": {
"name": "last_login_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
+915
View File
@@ -0,0 +1,915 @@
{
"version": "6",
"dialect": "sqlite",
"id": "b6f1ee4b-cc31-4060-a4d4-bcd4fdc5bd87",
"prevId": "7cd75e33-b3d8-4930-a60b-2a0a9f644c6d",
"tables": {
"dose_tracking": {
"name": "dose_tracking",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"dose_id": {
"name": "dose_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_at": {
"name": "taken_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
},
"marked_by": {
"name": "marked_by",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dismissed": {
"name": "dismissed",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
}
},
"indexes": {},
"foreignKeys": {
"dose_tracking_user_id_users_id_fk": {
"name": "dose_tracking_user_id_users_id_fk",
"tableFrom": "dose_tracking",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"medications": {
"name": "medications",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"generic_name": {
"name": "generic_name",
"type": "text(100)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"taken_by_json": {
"name": "taken_by_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"package_type": {
"name": "package_type",
"type": "text(20)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'blister'"
},
"pack_count": {
"name": "pack_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"blisters_per_pack": {
"name": "blisters_per_pack",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"pills_per_blister": {
"name": "pills_per_blister",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 1
},
"total_pills": {
"name": "total_pills",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"loose_tablets": {
"name": "loose_tablets",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"stock_adjustment": {
"name": "stock_adjustment",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"last_stock_correction_at": {
"name": "last_stock_correction_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"pill_weight_mg": {
"name": "pill_weight_mg",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"dose_unit": {
"name": "dose_unit",
"type": "text(20)",
"primaryKey": false,
"notNull": false,
"autoincrement": false,
"default": "'mg'"
},
"usage_json": {
"name": "usage_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"every_json": {
"name": "every_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"start_json": {
"name": "start_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"intakes_json": {
"name": "intakes_json",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'[]'"
},
"image_url": {
"name": "image_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"expiry_date": {
"name": "expiry_date",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"notes": {
"name": "notes",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"intake_reminders_enabled": {
"name": "intake_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"dismissed_until": {
"name": "dismissed_until",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {},
"foreignKeys": {
"medications_user_id_users_id_fk": {
"name": "medications_user_id_users_id_fk",
"tableFrom": "medications",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refill_history": {
"name": "refill_history",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"medication_id": {
"name": "medication_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"packs_added": {
"name": "packs_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"loose_pills_added": {
"name": "loose_pills_added",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 0
},
"refill_date": {
"name": "refill_date",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "(strftime('%s','now'))"
}
},
"indexes": {},
"foreignKeys": {
"refill_history_medication_id_medications_id_fk": {
"name": "refill_history_medication_id_medications_id_fk",
"tableFrom": "refill_history",
"tableTo": "medications",
"columnsFrom": [
"medication_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
},
"refill_history_user_id_users_id_fk": {
"name": "refill_history_user_id_users_id_fk",
"tableFrom": "refill_history",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"refresh_tokens": {
"name": "refresh_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token_id": {
"name": "token_id",
"type": "text(255)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"rotated_at": {
"name": "rotated_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"revoked": {
"name": "revoked",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"refresh_tokens_token_id_unique": {
"name": "refresh_tokens_token_id_unique",
"columns": [
"token_id"
],
"isUnique": true
}
},
"foreignKeys": {
"refresh_tokens_user_id_users_id_fk": {
"name": "refresh_tokens_user_id_users_id_fk",
"tableFrom": "refresh_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"share_tokens": {
"name": "share_tokens",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"token": {
"name": "token",
"type": "text(64)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"taken_by": {
"name": "taken_by",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"schedule_days": {
"name": "schedule_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
}
},
"indexes": {
"share_tokens_token_unique": {
"name": "share_tokens_token_unique",
"columns": [
"token"
],
"isUnique": true
}
},
"foreignKeys": {
"share_tokens_user_id_users_id_fk": {
"name": "share_tokens_user_id_users_id_fk",
"tableFrom": "share_tokens",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"user_settings": {
"name": "user_settings",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"user_id": {
"name": "user_id",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email_enabled": {
"name": "email_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"notification_email": {
"name": "notification_email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"email_stock_reminders": {
"name": "email_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"email_intake_reminders": {
"name": "email_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_enabled": {
"name": "shoutrrr_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"shoutrrr_url": {
"name": "shoutrrr_url",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"shoutrrr_stock_reminders": {
"name": "shoutrrr_stock_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"shoutrrr_intake_reminders": {
"name": "shoutrrr_intake_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"reminder_days_before": {
"name": "reminder_days_before",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 7
},
"repeat_daily_reminders": {
"name": "repeat_daily_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"skip_reminders_for_taken_doses": {
"name": "skip_reminders_for_taken_doses",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"repeat_reminders_enabled": {
"name": "repeat_reminders_enabled",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"reminder_repeat_interval_minutes": {
"name": "reminder_repeat_interval_minutes",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"max_nagging_reminders": {
"name": "max_nagging_reminders",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 5
},
"low_stock_days": {
"name": "low_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 30
},
"normal_stock_days": {
"name": "normal_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"high_stock_days": {
"name": "high_stock_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 180
},
"expiry_warning_days": {
"name": "expiry_warning_days",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": 90
},
"language": {
"name": "language",
"type": "text(10)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'en'"
},
"stock_calculation_mode": {
"name": "stock_calculation_mode",
"type": "text(20)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'automatic'"
},
"share_stock_status": {
"name": "share_stock_status",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"last_auto_email_sent": {
"name": "last_auto_email_sent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_type": {
"name": "last_notification_type",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_notification_channel": {
"name": "last_notification_channel",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_reminder_med_name": {
"name": "last_reminder_med_name",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_reminder_taken_by": {
"name": "last_reminder_taken_by",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_stock_reminder_sent": {
"name": "last_stock_reminder_sent",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_stock_reminder_channel": {
"name": "last_stock_reminder_channel",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"last_stock_reminder_med_names": {
"name": "last_stock_reminder_med_names",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"user_settings_user_id_unique": {
"name": "user_settings_user_id_unique",
"columns": [
"user_id"
],
"isUnique": true
}
},
"foreignKeys": {
"user_settings_user_id_users_id_fk": {
"name": "user_settings_user_id_users_id_fk",
"tableFrom": "user_settings",
"tableTo": "users",
"columnsFrom": [
"user_id"
],
"columnsTo": [
"id"
],
"onDelete": "cascade",
"onUpdate": "no action"
}
},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"users": {
"name": "users",
"columns": {
"id": {
"name": "id",
"type": "integer",
"primaryKey": true,
"notNull": true,
"autoincrement": true
},
"username": {
"name": "username",
"type": "text(100)",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"password_hash": {
"name": "password_hash",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"avatar_url": {
"name": "avatar_url",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"auth_provider": {
"name": "auth_provider",
"type": "text(50)",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'local'"
},
"oidc_subject": {
"name": "oidc_subject",
"type": "text(255)",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"is_active": {
"name": "is_active",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": true
},
"last_login_at": {
"name": "last_login_at",
"type": "integer",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "CURRENT_TIMESTAMP"
}
},
"indexes": {
"users_username_unique": {
"name": "users_username_unique",
"columns": [
"username"
],
"isUnique": true
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}
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
+111
View File
@@ -0,0 +1,111 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1768600500759,
"tag": "0000_init",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1768734577830,
"tag": "0001_add_stock_adjustment",
"breakpoints": true
},
{
"idx": 2,
"version": "6",
"when": 1768736677092,
"tag": "0002_add_last_stock_correction_at",
"breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1769354512857,
"tag": "0003_add_reminder_info_columns",
"breakpoints": true
},
{
"idx": 4,
"version": "6",
"when": 1769886564000,
"tag": "0004_add_package_type",
"breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1769893708813,
"tag": "0005_add_intakes_json",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1770626907896,
"tag": "0006_add_stock_reminder_tracking",
"breakpoints": true
},
{
"idx": 7,
"version": "6",
"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
}
]
}
+1963 -2577
View File
File diff suppressed because it is too large Load Diff
+37 -21
View File
@@ -1,6 +1,6 @@
{
"name": "medassist-ng-backend",
"version": "1.0.2",
"version": "1.23.0",
"private": true,
"type": "module",
"scripts": {
@@ -10,34 +10,50 @@
"migrate": "tsx src/db/migrate.ts",
"test": "vitest",
"test:run": "vitest run",
"test:coverage": "vitest run --coverage"
"test:coverage": "vitest run --coverage",
"lint": "npx biome check .",
"lint:fix": "npx biome check --write .",
"format": "npx biome format --write .",
"check": "npx biome check . && tsc --noEmit"
},
"dependencies": {
"@fastify/cookie": "^10.0.1",
"@fastify/cors": "^10.0.1",
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/helmet": "^13.0.2",
"@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^9.3.0",
"@fastify/multipart": "^10.0.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/sensible": "^6.0.4",
"@fastify/static": "^8.3.0",
"@libsql/client": "^0.10.0",
"argon2": "^0.40.0",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.32.2",
"fastify": "^5.0.0",
"nodemailer": "^7.0.11",
"openid-client": "^6.8.1",
"zod": "^3.23.8"
"@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.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": {
"@types/node": "^22.7.4",
"@types/nodemailer": "^6.4.21",
"@types/supertest": "^6.0.2",
"@vitest/coverage-v8": "^4.0.16",
"supertest": "^7.0.0",
"@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"
}
}
+80 -113
View File
@@ -1,88 +1,29 @@
import { createClient, Client } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { existsSync, mkdirSync, accessSync, constants, statSync, writeFileSync } from "fs";
import { resolve } from "path";
import { existsSync, statSync } from "node:fs";
import { type Client, createClient } from "@libsql/client";
import dotenv from "dotenv";
import { getTableCreationSQL } from "./schema-sql.js";
import { drizzle } from "drizzle-orm/libsql";
import { log } from "../utils/logger.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";
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
// Re-export all utilities so existing imports from client.ts keep working
export {
buildDbUrl,
ensureDataDirectory,
ensureDefaultUser,
getDataDir,
getDbPaths,
repairOrphanedDoseIds,
repairTrailingHyphenDoseIds,
runAlterMigrations,
runDrizzleMigrations,
} from "./db-utils.js";
// =============================================================================
// Exported utility functions for testing
// =============================================================================
/** 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 = resolve(cwd, "data");
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 };
}
}
/** Get the SQL statements for creating all tables (re-exported from schema-sql) */
export { getTableCreationSQL } from "./schema-sql.js";
/** Run table creation migrations on a client */
export async function runTableMigrations(client: Client): Promise<{ success: boolean; errors: string[] }> {
const tableCreations = getTableCreationSQL();
const errors: string[] = [];
for (const sql of tableCreations) {
try {
await client.execute(sql);
} catch (e: any) {
errors.push(e.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; // 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;
}
}
// Load .env: try cwd first, then parent dir (for local dev running from backend/)
const envPath = process.env.DOTENV_PATH || (existsSync(".env") ? ".env" : "../.env");
dotenv.config({ path: envPath });
// =============================================================================
// Database initialization (runs on import)
@@ -91,53 +32,79 @@ export async function ensureDefaultUser(client: Client, authEnabled: boolean): P
// Use absolute path to ensure it works in Docker
const { dataDir, dbPath, url } = getDbPaths();
console.log(`[DB] Data directory: ${dataDir}`);
console.log(`[DB] Database path: ${dbPath}`);
console.log(`[DB] Database URL: ${url}`);
log.debug(`[DB] Data directory: ${dataDir}`);
log.debug(`[DB] Database path: ${dbPath}`);
log.debug(`[DB] Database URL: ${url}`);
// Ensure data directory exists and is writable
const dirResult = ensureDataDirectory(dataDir);
if (!dirResult.success) {
console.error(`[DB] ERROR: Cannot access data directory: ${dirResult.error}`);
console.error(`[DB] Please ensure the volume mount has correct permissions.`);
console.error(`[DB] Try running on host: sudo chown -R 1000:1000 ${dataDir}`);
process.exit(1);
log.error(`[DB] ERROR: Cannot access data directory: ${dirResult.error}`);
log.error(`[DB] Please ensure the volume mount has correct permissions.`);
log.error(`[DB] Try running on host: sudo chown -R 1000:1000 ${dataDir}`);
process.exit(1);
} else {
console.log(`[DB] Data directory is writable`);
// Log directory stats
const stats = statSync(dataDir);
console.log(`[DB] Directory permissions: ${stats.mode.toString(8)}`);
console.log(`[DB] Directory UID: ${stats.uid}, GID: ${stats.gid}`);
console.log(`[DB] Write test successful`);
log.debug(`[DB] Data directory is writable`);
// Log directory stats
const stats = statSync(dataDir);
log.debug(`[DB] Directory permissions: ${stats.mode.toString(8)}`);
log.debug(`[DB] Directory UID: ${stats.uid}, GID: ${stats.gid}`);
log.debug(`[DB] Write test successful`);
}
let client: Client;
try {
client = createClient({ url });
console.log(`[DB] Database client created successfully`);
} catch (err: any) {
console.error(`[DB] ERROR: Failed to create database client: ${err.message}`);
console.error(`[DB] Database path: ${dbPath}`);
process.exit(1);
client = createClient({ url });
log.debug(`[DB] Database client created successfully`);
} 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);
}
export const db = drizzle(client);
// Auto-run migrations (self-healing database)
async function runMigrations() {
const result = await runTableMigrations(client);
if (result.errors.length > 0) {
result.errors.forEach(err => console.error(`[DB] Table creation error:`, err));
}
console.log(`[DB] Tables verified/created`);
// Run drizzle-kit generated migrations
log.info(`[DB] Running migrations...`);
const migrateResult = await runDrizzleMigrations(db);
if (!migrateResult.success) {
log.error(`[DB] Migration error: ${migrateResult.error}`);
}
// If auth is disabled, ensure a default user exists (ID=1)
const authEnabled = process.env.AUTH_ENABLED === "true";
const created = await ensureDefaultUser(client, authEnabled);
if (created) {
console.log(`[DB] Created default user for auth-disabled mode`);
}
// Run ALTER TABLE migrations for backward compatibility
const alterResult = await runAlterMigrations(client);
if (alterResult.errors.length > 0) {
alterResult.errors.forEach((err) => log.error(`[DB] ALTER migration error: ${err}`));
}
log.debug(`[DB] Tables verified/created`);
// Repair dose IDs with trailing hyphens (from frontend takenBy bug)
const trailingResult = await repairTrailingHyphenDoseIds(client);
if (trailingResult.repaired > 0) {
log.info(`[DB] Repaired ${trailingResult.repaired} dose IDs with trailing hyphens`);
}
if (trailingResult.errors.length > 0) {
trailingResult.errors.forEach((err) => log.error(`[DB] Trailing-hyphen repair error: ${err}`));
}
// Repair orphaned dose tracking IDs from past schedule changes
const repairResult = await repairOrphanedDoseIds(client);
if (repairResult.repaired > 0) {
log.info(`[DB] Repaired ${repairResult.repaired} orphaned dose tracking IDs`);
}
if (repairResult.errors.length > 0) {
repairResult.errors.forEach((err) => log.error(`[DB] Dose repair error: ${err}`));
}
// If auth is disabled, ensure a default user exists (ID=1)
const authEnabled = process.env.AUTH_ENABLED === "true";
const created = await ensureDefaultUser(client, authEnabled);
if (created) {
log.info(`[DB] Created default user for auth-disabled mode`);
}
}
// Export promise so server can await it before starting
+12
View File
@@ -0,0 +1,12 @@
/**
* Compatibility barrel for DB utilities.
*
* New code should prefer importing from focused modules:
* - ./path-utils.js
* - ./migration-utils.js
* - ./repair-utils.js
*/
export { ensureDefaultUser, runAlterMigrations, runDrizzleMigrations } from "./migration-utils.js";
export { buildDbUrl, ensureDataDirectory, getDataDir, getDbPaths } from "./path-utils.js";
export { repairOrphanedDoseIds, repairTrailingHyphenDoseIds } from "./repair-utils.js";
+55 -46
View File
@@ -1,48 +1,59 @@
import { createClient, Client } from "@libsql/client";
import { existsSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { type Client, createClient } from "@libsql/client";
import dotenv from "dotenv";
import fs from "fs";
import path from "path";
import { getTableCreationSQL } from "./schema-sql.js";
import { drizzle } from "drizzle-orm/libsql";
import { migrate } from "drizzle-orm/libsql/migrator";
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
// Load .env: try cwd first, then parent dir (for local dev running from backend/)
const envPath = process.env.DOTENV_PATH || (existsSync(".env") ? ".env" : "../.env");
dotenv.config({ path: envPath });
// 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");
// =============================================================================
// Exported utility functions for testing
// =============================================================================
/** Get the full migration SQL string (re-exported from schema-sql) */
export { getTableCreationSQL };
/** Split SQL string into individual statements */
/** Split SQL string into individual statements (for backwards compatibility with tests) */
export function splitSQLStatements(sql: string): string[] {
return sql.split(';').filter(s => s.trim().length > 0);
return sql.split(";").filter((s) => s.trim().length > 0);
}
/** Execute migration statements on a client */
export async function executeMigration(client: Client): Promise<{ success: boolean; executed: number; errors: string[] }> {
const statements = getTableCreationSQL();
const errors: string[] = [];
let executed = 0;
/** Execute drizzle migrations on a database */
export async function executeMigration(
client: Client
): Promise<{ success: boolean; executed: number; errors: string[] }> {
const errors: string[] = [];
const db = drizzle(client);
for (const stmt of statements) {
try {
await client.execute(stmt);
executed++;
} catch (err: any) {
errors.push(err.message);
}
}
try {
await migrate(db, { migrationsFolder });
return { success: errors.length === 0, executed, errors };
// Count tables as a proxy for "executed" statements
const tables = await client.execute(
"SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__drizzle%'"
);
const executed = Number(tables.rows[0].count) || 0;
return { success: true, executed, errors };
} catch (err: unknown) {
errors.push((err as Error).message);
return { success: false, executed: 0, errors };
}
}
/** Get a preview of statement (first N characters) */
export function getStatementPreview(stmt: string, maxLength: number = 50): string {
const trimmed = stmt.trim();
if (trimmed.length <= maxLength) {
return trimmed;
}
return trimmed.substring(0, maxLength) + "...";
const trimmed = stmt.trim();
if (trimmed.length <= maxLength) {
return trimmed;
}
return `${trimmed.substring(0, maxLength)}...`;
}
// =============================================================================
@@ -52,27 +63,25 @@ export function getStatementPreview(stmt: string, maxLength: number = 50): strin
const url = "file:./data/medassist-ng.db";
async function main() {
console.log("Starting database setup...");
console.log("Database URL:", url);
const client = createClient({ url });
const statements = getTableCreationSQL();
for (const stmt of statements) {
console.log("Executing:", getStatementPreview(stmt));
await client.execute(stmt);
}
console.log("[DB] Starting database setup...");
console.log("[DB] Database URL:", url);
console.log("[DB] Migrations folder:", migrationsFolder);
console.log("Database setup complete!");
process.exit(0);
const client = createClient({ url });
const db = drizzle(client);
console.log("[DB] Running drizzle migrations...");
await migrate(db, { migrationsFolder });
console.log("[DB] Database setup complete!");
process.exit(0);
}
// Only run main() if this file is executed directly (not imported)
const isMainModule = import.meta.url === `file://${process.argv[1]}`;
if (isMainModule) {
main().catch((err) => {
console.error("Migration failed:", err);
process.exit(1);
});
main().catch((err) => {
console.error("Migration failed:", err);
process.exit(1);
});
}
+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 };
}
+36 -8
View File
@@ -8,8 +8,8 @@
* Each statement creates a table if it doesn't exist.
*/
export function getTableCreationSQL(): string[] {
return [
`CREATE TABLE IF NOT EXISTS users (
return [
`CREATE TABLE IF NOT EXISTS users (
id integer PRIMARY KEY AUTOINCREMENT,
username text NOT NULL UNIQUE,
password_hash text,
@@ -21,7 +21,7 @@ export function getTableCreationSQL(): string[] {
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
updated_at integer NOT NULL DEFAULT (strftime('%s','now'))
)`,
`CREATE TABLE IF NOT EXISTS medications (
`CREATE TABLE IF NOT EXISTS medications (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
name text NOT NULL,
@@ -42,7 +42,7 @@ export function getTableCreationSQL(): string[] {
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS user_settings (
`CREATE TABLE IF NOT EXISTS user_settings (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL UNIQUE,
email_enabled integer NOT NULL DEFAULT 0,
@@ -55,19 +55,36 @@ export function getTableCreationSQL(): string[] {
shoutrrr_intake_reminders integer NOT NULL DEFAULT 1,
reminder_days_before integer NOT NULL DEFAULT 7,
repeat_daily_reminders integer NOT NULL DEFAULT 0,
skip_reminders_for_taken_doses integer NOT NULL DEFAULT 0,
repeat_reminders_enabled integer NOT NULL DEFAULT 0,
reminder_repeat_interval_minutes integer NOT NULL DEFAULT 30,
max_nagging_reminders integer NOT NULL DEFAULT 5,
low_stock_days integer NOT NULL DEFAULT 30,
normal_stock_days integer NOT NULL DEFAULT 90,
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
)`,
`CREATE TABLE IF NOT EXISTS refresh_tokens (
`CREATE TABLE IF NOT EXISTS refresh_tokens (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
token_id text NOT NULL UNIQUE,
@@ -77,7 +94,7 @@ export function getTableCreationSQL(): string[] {
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS share_tokens (
`CREATE TABLE IF NOT EXISTS share_tokens (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
token text NOT NULL UNIQUE,
@@ -87,13 +104,24 @@ export function getTableCreationSQL(): string[] {
expires_at integer,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
`CREATE TABLE IF NOT EXISTS dose_tracking (
`CREATE TABLE IF NOT EXISTS dose_tracking (
id integer PRIMARY KEY AUTOINCREMENT,
user_id integer NOT NULL,
dose_id text NOT NULL,
taken_at integer NOT NULL DEFAULT (strftime('%s','now')),
marked_by text,
dismissed integer NOT NULL DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
];
`CREATE TABLE IF NOT EXISTS refill_history (
id integer PRIMARY KEY AUTOINCREMENT,
medication_id integer NOT NULL,
user_id integer NOT NULL,
packs_added integer NOT NULL DEFAULT 0,
loose_pills_added integer NOT NULL DEFAULT 0,
refill_date integer NOT NULL DEFAULT (strftime('%s','now')),
FOREIGN KEY (medication_id) REFERENCES medications(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
)`,
];
}
+217 -77
View File
@@ -1,114 +1,254 @@
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { sql } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
// =============================================================================
// Users - Simple auth, no roles (every user is equal)
// =============================================================================
export const users = sqliteTable("users", {
id: integer("id").primaryKey({ autoIncrement: true }),
username: text("username", { length: 100 }).notNull().unique(),
passwordHash: text("password_hash", { length: 255 }),
avatarUrl: text("avatar_url", { length: 255 }),
authProvider: text("auth_provider", { length: 50 }).notNull().default("local"),
oidcSubject: text("oidc_subject", { length: 255 }), // OIDC provider's unique user ID (sub claim)
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
lastLoginAt: integer("last_login_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`),
id: integer("id").primaryKey({ autoIncrement: true }),
username: text("username", { length: 100 }).notNull().unique(),
passwordHash: text("password_hash", { length: 255 }),
avatarUrl: text("avatar_url", { length: 255 }),
authProvider: text("auth_provider", { length: 50 }).notNull().default("local"),
oidcSubject: text("oidc_subject", { length: 255 }), // OIDC provider's unique user ID (sub claim)
isActive: integer("is_active", { mode: "boolean" }).notNull().default(true),
lastLoginAt: integer("last_login_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`),
});
// =============================================================================
// Medications - Per user
// =============================================================================
export const medications = sqliteTable("medications", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
name: text("name", { length: 100 }).notNull(),
genericName: text("generic_name", { length: 100 }),
takenByJson: text("taken_by_json").notNull().default("[]"), // JSON array of person names
packCount: integer("pack_count").notNull().default(1),
blistersPerPack: integer("blisters_per_pack").notNull().default(1),
pillsPerBlister: integer("pills_per_blister").notNull().default(1),
looseTablets: integer("loose_tablets").notNull().default(0),
pillWeightMg: integer("pill_weight_mg"),
usageJson: text("usage_json").notNull().default("[]"),
everyJson: text("every_json").notNull().default("[]"),
startJson: text("start_json").notNull().default("[]"),
imageUrl: text("image_url"),
expiryDate: text("expiry_date"),
notes: text("notes"),
intakeRemindersEnabled: integer("intake_reminders_enabled", { mode: "boolean" }).notNull().default(false),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name", { length: 100 }).notNull(),
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),
totalPills: integer("total_pills"), // For bottle type: total capacity of the container
looseTablets: integer("loose_tablets").notNull().default(0), // For blister: extra loose pills; for bottle: current stock
stockAdjustment: integer("stock_adjustment").notNull().default(0), // Hidden offset from stock corrections
lastStockCorrectionAt: integer("last_stock_correction_at", { mode: "timestamp" }), // When stock was last corrected - consumed doses before this don't count
pillWeightMg: integer("pill_weight_mg"),
doseUnit: text("dose_unit", { length: 20 }).default("mg"), // Unit for the dose (mg, g, mcg, ml, IU, etc.)
usageJson: text("usage_json").notNull().default("[]"), // DEPRECATED: Use intakesJson instead
everyJson: text("every_json").notNull().default("[]"), // DEPRECATED: Use intakesJson instead
startJson: text("start_json").notNull().default("[]"), // DEPRECATED: Use intakesJson instead
// New unified intakes structure: [{usage, every, start, takenBy, intakeRemindersEnabled}]
intakesJson: text("intakes_json").notNull().default("[]"),
imageUrl: text("image_url"),
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"),
prescriptionLowRefillThreshold: integer("prescription_low_refill_threshold").notNull().default(1),
prescriptionExpiryDate: text("prescription_expiry_date"),
dismissedUntil: text("dismissed_until"), // ISO date string (e.g. "2026-01-23") - all past doses until this date are dismissed
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
// =============================================================================
// User Settings - Per user (email, push, thresholds, language)
// =============================================================================
export const userSettings = sqliteTable("user_settings", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id").notNull().unique().references(() => users.id, { onDelete: "cascade" }),
// Email notifications
emailEnabled: integer("email_enabled", { mode: "boolean" }).notNull().default(false),
notificationEmail: text("notification_email"),
emailStockReminders: integer("email_stock_reminders", { mode: "boolean" }).notNull().default(true),
emailIntakeReminders: integer("email_intake_reminders", { mode: "boolean" }).notNull().default(true),
// Push notifications (shoutrrr/ntfy)
shoutrrrEnabled: integer("shoutrrr_enabled", { mode: "boolean" }).notNull().default(false),
shoutrrrUrl: text("shoutrrr_url"),
shoutrrrStockReminders: integer("shoutrrr_stock_reminders", { mode: "boolean" }).notNull().default(true),
shoutrrrIntakeReminders: integer("shoutrrr_intake_reminders", { mode: "boolean" }).notNull().default(true),
// Reminder settings
reminderDaysBefore: integer("reminder_days_before").notNull().default(7),
repeatDailyReminders: integer("repeat_daily_reminders", { mode: "boolean" }).notNull().default(false),
// Stock thresholds (days)
lowStockDays: integer("low_stock_days").notNull().default(30),
normalStockDays: integer("normal_stock_days").notNull().default(90),
highStockDays: integer("high_stock_days").notNull().default(180),
// UI preferences
language: text("language", { length: 10 }).notNull().default("en"),
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
// Last notification tracking
lastAutoEmailSent: text("last_auto_email_sent"),
lastNotificationType: text("last_notification_type"),
lastNotificationChannel: text("last_notification_channel"),
// Timestamps
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.notNull()
.unique()
.references(() => users.id, { onDelete: "cascade" }),
// Email notifications
emailEnabled: integer("email_enabled", { mode: "boolean" }).notNull().default(false),
notificationEmail: text("notification_email"),
emailStockReminders: integer("email_stock_reminders", { mode: "boolean" }).notNull().default(true),
emailIntakeReminders: integer("email_intake_reminders", { mode: "boolean" }).notNull().default(true),
emailPrescriptionReminders: integer("email_prescription_reminders", { mode: "boolean" }).notNull().default(true),
// Push notifications (shoutrrr/ntfy)
shoutrrrEnabled: integer("shoutrrr_enabled", { mode: "boolean" }).notNull().default(false),
shoutrrrUrl: text("shoutrrr_url"),
shoutrrrStockReminders: integer("shoutrrr_stock_reminders", { mode: "boolean" }).notNull().default(true),
shoutrrrIntakeReminders: integer("shoutrrr_intake_reminders", { mode: "boolean" }).notNull().default(true),
shoutrrrPrescriptionReminders: integer("shoutrrr_prescription_reminders", { mode: "boolean" })
.notNull()
.default(true),
// Reminder settings
reminderDaysBefore: integer("reminder_days_before").notNull().default(7),
repeatDailyReminders: integer("repeat_daily_reminders", { mode: "boolean" }).notNull().default(false),
skipRemindersForTakenDoses: integer("skip_reminders_for_taken_doses", { mode: "boolean" }).notNull().default(false),
repeatRemindersEnabled: integer("repeat_reminders_enabled", { mode: "boolean" }).notNull().default(false),
reminderRepeatIntervalMinutes: integer("reminder_repeat_interval_minutes").notNull().default(30),
maxNaggingReminders: integer("max_nagging_reminders").notNull().default(5),
// Stock thresholds (days)
lowStockDays: integer("low_stock_days").notNull().default(30),
normalStockDays: integer("normal_stock_days").notNull().default(90),
highStockDays: integer("high_stock_days").notNull().default(180),
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"),
// 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"),
lastNotificationChannel: text("last_notification_channel"),
lastReminderMedName: text("last_reminder_med_name"),
lastReminderTakenBy: text("last_reminder_taken_by"),
// Last stock reminder tracking (separate from intake)
lastStockReminderSent: text("last_stock_reminder_sent"),
lastStockReminderChannel: text("last_stock_reminder_channel"),
lastStockReminderMedNames: text("last_stock_reminder_med_names"),
// Last prescription reminder tracking (separate from stock/intake)
lastPrescriptionReminderSent: text("last_prescription_reminder_sent"),
lastPrescriptionReminderChannel: text("last_prescription_reminder_channel"),
lastPrescriptionReminderMedNames: text("last_prescription_reminder_med_names"),
// Timestamps
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
// =============================================================================
// Refresh Tokens - For JWT rotation
// =============================================================================
export const refreshTokens = sqliteTable("refresh_tokens", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
tokenId: text("token_id", { length: 255 }).notNull().unique(),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
rotatedAt: integer("rotated_at", { mode: "timestamp" }),
revoked: integer("revoked", { mode: "boolean" }).notNull().default(false),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
tokenId: text("token_id", { length: 255 }).notNull().unique(),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
rotatedAt: integer("rotated_at", { mode: "timestamp" }),
revoked: integer("revoked", { mode: "boolean" }).notNull().default(false),
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
// =============================================================================
export const shareTokens = sqliteTable("share_tokens", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
token: text("token", { length: 64 }).notNull().unique(),
takenBy: text("taken_by", { length: 100 }).notNull(),
scheduleDays: integer("schedule_days").notNull().default(30),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
token: text("token", { length: 64 }).notNull().unique(),
takenBy: text("taken_by", { length: 100 }).notNull(),
scheduleDays: integer("schedule_days").notNull().default(30),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires
});
// =============================================================================
// Notification Action Groups - Shared action state for reminder notifications
// =============================================================================
export const notificationActionGroups = sqliteTable("notification_action_groups", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
groupKey: text("group_key", { length: 255 }).notNull().unique(),
sequenceId: text("sequence_id", { length: 255 }).notNull(),
ntfyOriginalMessageId: text("ntfy_original_message_id", { length: 255 }).notNull().default(""),
doseIdsJson: text("dose_ids_json").notNull(),
title: text("title", { length: 255 }).notNull(),
message: text("message").notNull(),
language: text("language", { length: 10 }).notNull().default("en"),
scheduledFor: integer("scheduled_for", { mode: "timestamp" }),
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
resolvedAction: text("resolved_action", { length: 20 }),
resolvedAt: integer("resolved_at", { mode: "timestamp" }),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
// =============================================================================
// Notification Action Tokens - Hashed tokens for public reminder responses
// =============================================================================
export const notificationActionTokens = sqliteTable("notification_action_tokens", {
id: integer("id").primaryKey({ autoIncrement: true }),
groupId: integer("group_id")
.notNull()
.references(() => notificationActionGroups.id, { onDelete: "cascade" }),
tokenHash: text("token_hash", { length: 128 }).notNull().unique(),
kind: text("kind", { length: 20 }).notNull(),
usedAt: integer("used_at", { mode: "timestamp" }),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
});
// =============================================================================
// Dose Tracking - Tracks when doses are marked as taken
// =============================================================================
export const doseTracking = sqliteTable("dose_tracking", {
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
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
id: integer("id").primaryKey({ autoIncrement: true }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual, automatic, or notification
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // legacy column: true = intake skipped without stock deduction
});
// =============================================================================
// Refill History - Tracks when medication stock was refilled
// =============================================================================
export const refillHistory = sqliteTable("refill_history", {
id: integer("id").primaryKey({ autoIncrement: true }),
medicationId: integer("medication_id")
.notNull()
.references(() => medications.id, { onDelete: "cascade" }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
packsAdded: integer("packs_added").notNull().default(0),
loosePillsAdded: integer("loose_pills_added").notNull().default(0),
usedPrescription: integer("used_prescription", { mode: "boolean" }).notNull().default(false),
refillDate: integer("refill_date", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
});
+488 -175
View File
@@ -1,193 +1,506 @@
// Backend translations for notifications
export type Language = "en" | "de";
/**
* Map timezone to region code (ISO 3166-1 alpha-2).
* This allows combining app language with regional formatting.
*/
const TIMEZONE_TO_REGION: Record<string, string> = {
// Europe
"Europe/Berlin": "DE",
"Europe/Vienna": "AT",
"Europe/Zurich": "CH",
"Europe/London": "GB",
"Europe/Dublin": "IE",
"Europe/Paris": "FR",
"Europe/Madrid": "ES",
"Europe/Rome": "IT",
"Europe/Amsterdam": "NL",
"Europe/Brussels": "BE",
"Europe/Warsaw": "PL",
"Europe/Prague": "CZ",
"Europe/Stockholm": "SE",
"Europe/Oslo": "NO",
"Europe/Copenhagen": "DK",
"Europe/Helsinki": "FI",
"Europe/Athens": "GR",
"Europe/Lisbon": "PT",
"Europe/Moscow": "RU",
"Europe/Kiev": "UA",
"Europe/Kyiv": "UA",
"Europe/Budapest": "HU",
"Europe/Bucharest": "RO",
// Americas
"America/New_York": "US",
"America/Chicago": "US",
"America/Denver": "US",
"America/Los_Angeles": "US",
"America/Phoenix": "US",
"America/Toronto": "CA",
"America/Vancouver": "CA",
"America/Mexico_City": "MX",
"America/Sao_Paulo": "BR",
"America/Buenos_Aires": "AR",
// Asia/Pacific
"Asia/Tokyo": "JP",
"Asia/Shanghai": "CN",
"Asia/Hong_Kong": "HK",
"Asia/Singapore": "SG",
"Asia/Seoul": "KR",
"Asia/Dubai": "AE",
"Asia/Kolkata": "IN",
"Australia/Sydney": "AU",
"Australia/Melbourne": "AU",
"Pacific/Auckland": "NZ",
};
/**
* Get region code from TZ environment variable.
*/
function getRegionFromTimezone(): string | undefined {
const tz = process.env.TZ;
if (!tz) return undefined;
return TIMEZONE_TO_REGION[tz];
}
type TranslationKeys = {
// Stock reminder email
stockReminder: {
subject: string;
title: string;
description: string;
alertSingle: string;
alertMultiple: string;
tableHeaders: {
medication: string;
pills: string;
days: string;
runsOut: string;
};
footer: string;
repeatDailyNote: string;
};
// Intake reminder email
intakeReminder: {
subject: string;
title: string;
description: string;
alertSingle: string;
alertMultiple: string;
tableHeaders: {
medication: string;
dosage: string;
time: string;
};
pills: string;
takenBy: string;
footer: string;
};
// Push notifications
push: {
stockTitle: string;
stockTitleMultiple: string;
intakeTitle: string;
pillsLeft: string;
daysLeft: string;
pillsAt: string;
repeatDailyNote: string;
empty: string;
low: string;
reorderNow: string;
emptySection: string;
lowSection: string;
};
// Common
common: {
pill: string;
pills: string;
day: string;
days: string;
soon: string;
};
// Stock reminder (shared across email + push)
stockReminder: {
subject: string;
title: string;
description: string;
descriptionEmpty: string;
descriptionMixed: string;
alertSingle: string;
alertMultiple: string;
alertEmptySingle: string;
alertEmptyMultiple: string;
alertLowSingle: string;
alertLowMultiple: string;
alertLowStockSingle: string;
alertLowStockMultiple: string;
descriptionLow: string;
tableHeaders: {
medication: string;
pills: string;
days: string;
runsOut: string;
};
now: string;
repeatDailyNote: string;
};
// Intake reminder email
intakeReminder: {
subject: string;
title: string;
description: string;
alertSingle: string;
alertMultiple: string;
tableHeaders: {
medication: string;
dosage: string;
time: string;
};
pills: string;
takenBy: string;
};
// Push notifications
push: {
stockTitle: string;
stockTitleMultiple: string;
intakeTitle: string;
pillsLeft: string;
daysLeft: string;
pillsAt: string;
repeatDailyNote: string;
empty: string;
low: string;
critical: string;
lowStock: string;
reorderNow: string;
emptySection: string;
lowSection: string;
criticalSection: string;
lowStockSection: string;
};
// Prescription reminder (shared across email + push)
prescriptionReminder: {
subjectSingle: string;
subjectMultiple: string;
pushTitleLow: string;
pushTitleEmpty: string;
pushEmpty: string;
pushEmptySingle: string;
pushLow: string;
pushLowSingle: string;
pushRenewNow: string;
pushEmptySection: string;
pushLowSection: string;
pushRefillsLeft: string;
title: string;
titleEmpty: string;
descriptionLow: string;
descriptionEmpty: string;
alertLowSingle: string;
alertLowMultiple: string;
alertEmptySingle: string;
alertEmptyMultiple: string;
line: string;
lineEmpty: string;
expiresSuffix: string;
repeatDailyNote: string;
tableHeaders: {
medication: string;
refillsLeft: string;
reminderThreshold: string;
prescriptionExpires: string;
};
};
// Demand calculator email
demandCalculator: {
subject: string;
title: string;
description: string;
summaryOutOfStock: string;
summaryAllOk: string;
tableHeaders: {
medication: string;
usage: string;
needed: string;
prescriptionRefills: string;
available: string;
status: string;
};
statusEnough: string;
statusEmpty: string;
prescriptionNotApplicable: string;
};
// Common
common: {
pill: string;
pills: string;
units: string;
ml: string;
blister: string;
blisters: string;
day: string;
days: string;
soon: string;
footer: string;
};
};
const translations: Record<Language, TranslationKeys> = {
en: {
stockReminder: {
subject: "MedAssist-ng Auto-Reminder: {count} Medication{s} Running Low",
title: "⚠️ MedAssist-ng - Automatic Reorder Reminder",
description: "The following medications are running low and need to be reordered:",
alertSingle: "⚠️ 1 medication running low!",
alertMultiple: "⚠️ {count} medications running low!",
tableHeaders: {
medication: "Medication",
pills: "Pills",
days: "Days",
runsOut: "Runs Out",
},
footer: "🤖 Automatic reminder from MedAssist-ng",
repeatDailyNote: "You are receiving this daily reminder because 'Repeat Daily' is enabled in settings.",
},
intakeReminder: {
subject: "MedAssist-ng: Medication Reminder - {medications}",
title: "💊 MedAssist-ng - Intake Reminder",
description: "Time to take your medication in {minutes} minutes:",
alertSingle: "💊 1 medication scheduled",
alertMultiple: "💊 {count} medications scheduled",
tableHeaders: {
medication: "Medication",
dosage: "Dosage",
time: "Time",
},
pills: "pills",
takenBy: "for {name}",
footer: "🤖 Automatic reminder from MedAssist-ng",
},
push: {
stockTitle: "MedAssist-ng: 1 Medication Running Low",
stockTitleMultiple: "MedAssist-ng: {count} Medications Running Low",
intakeTitle: "💊 Medication Reminder in {minutes} min",
pillsLeft: "{count} pills",
daysLeft: "{count} days left",
pillsAt: "{count} pills at {time}",
repeatDailyNote: "(Daily reminder enabled)",
empty: "Empty",
low: "Low",
reorderNow: "Reorder Now!",
emptySection: "EMPTY (reorder immediately)",
lowSection: "RUNNING LOW (reorder soon)",
},
common: {
pill: "pill",
pills: "pills",
day: "day",
days: "days",
soon: "soon",
},
},
de: {
stockReminder: {
subject: "MedAssist-ng Auto-Erinnerung: {count} Medikament{e} wird knapp",
title: "⚠️ MedAssist-ng - Automatische Nachbestell-Erinnerung",
description: "Die folgenden Medikamente gehen zur Neige und sollten nachbestellt werden:",
alertSingle: "⚠️ 1 Medikament wird knapp!",
alertMultiple: "⚠️ {count} Medikamente werden knapp!",
tableHeaders: {
medication: "Medikament",
pills: "Tabletten",
days: "Tage",
runsOut: "Aufgebraucht",
},
footer: "🤖 Automatische Erinnerung von MedAssist-ng",
repeatDailyNote: "Sie erhalten diese tägliche Erinnerung, weil 'Täglich wiederholen' in den Einstellungen aktiviert ist.",
},
intakeReminder: {
subject: "MedAssist-ng: Einnahme-Erinnerung - {medications}",
title: "💊 MedAssist-ng - Einnahme-Erinnerung",
description: "Zeit für Ihre Medikamente in {minutes} Minuten:",
alertSingle: "💊 1 Medikament geplant",
alertMultiple: "💊 {count} Medikamente geplant",
tableHeaders: {
medication: "Medikament",
dosage: "Dosis",
time: "Uhrzeit",
},
pills: "Tabletten",
takenBy: "für {name}",
footer: "🤖 Automatische Erinnerung von MedAssist-ng",
},
push: {
stockTitle: "MedAssist-ng: 1 Medikament wird knapp",
stockTitleMultiple: "MedAssist-ng: {count} Medikamente werden knapp",
intakeTitle: "💊 Einnahme-Erinnerung in {minutes} Min.",
pillsLeft: "{count} Tabletten",
daysLeft: "{count} Tage übrig",
pillsAt: "{count} Tabletten um {time}",
repeatDailyNote: "(Tägliche Erinnerung aktiviert)",
empty: "Leer",
low: "Knapp",
reorderNow: "Jetzt nachbestellen!",
emptySection: "LEER (sofort nachbestellen)",
lowSection: "WIRD KNAPP (bald nachbestellen)",
},
common: {
pill: "Tablette",
pills: "Tabletten",
day: "Tag",
days: "Tage",
soon: "bald",
},
},
en: {
stockReminder: {
subject: "MedAssist-ng: ⚠️ {count} Medication{s} Running Critically Low",
title: "⚠️ MedAssist-ng: Automatic Reorder Reminder",
description: "The following medications are running critically low and need to be reordered:",
descriptionEmpty: "The following medications are empty and need to be reordered immediately:",
descriptionMixed: "The following medications need to be reordered:",
alertSingle: "⚠️ 1 medication running critically low!",
alertMultiple: "⚠️ {count} medications running critically low!",
alertEmptySingle: "🚨 1 medication empty - reorder immediately!",
alertEmptyMultiple: "🚨 {count} medications empty - reorder immediately!",
alertLowSingle: "⚠️ 1 medication running critically low",
alertLowMultiple: "⚠️ {count} medications running critically low",
alertLowStockSingle: "⚠️ 1 medication running low",
alertLowStockMultiple: "⚠️ {count} medications running low",
descriptionLow: "The following medications are running low and should be reordered soon:",
tableHeaders: {
medication: "Medication",
pills: "Pills",
days: "Days",
runsOut: "Runs Out",
},
now: "NOW",
repeatDailyNote: "You are receiving this daily reminder because 'Repeat Daily' is enabled in settings.",
},
intakeReminder: {
subject: "MedAssist-ng: Medication Reminder - {medications}",
title: "💊 MedAssist-ng - Intake Reminder",
description: "Time to take your medication in {minutes} minutes:",
alertSingle: "💊 1 medication scheduled",
alertMultiple: "💊 {count} medications scheduled",
tableHeaders: {
medication: "Medication",
dosage: "Dosage",
time: "Time",
},
pills: "pills",
takenBy: "for {name}",
},
push: {
stockTitle: "MedAssist-ng: 1 Medication Running Critically Low",
stockTitleMultiple: "MedAssist-ng: {count} Medications Running Critically Low",
intakeTitle: "💊 Reminder: Medication intake in {minutes} min",
pillsLeft: "{count} pills",
daysLeft: "{count} days left",
pillsAt: "{count} pills at {time}",
repeatDailyNote: "(Daily reminder enabled)",
empty: "Empty",
low: "Critical",
critical: "Critical",
lowStock: "Low",
reorderNow: "Reorder Now!",
emptySection: "Empty (reorder immediately)",
lowSection: "Running critically low",
criticalSection: "Running critically low",
lowStockSection: "Running low",
},
prescriptionReminder: {
subjectSingle: "MedAssist-ng: 🚨 Prescription Refill Reminder",
subjectMultiple: "MedAssist-ng: 🚨 {count} Prescriptions Need Renewal Soon",
pushTitleLow: "💊 MedAssist-ng: {count} prescriptions are running low",
pushTitleEmpty: "💊 MedAssist-ng: {count} prescriptions need renewal now",
pushEmpty: "prescriptions out of refills",
pushEmptySingle: "prescription out of refills",
pushLow: "prescriptions low on refills",
pushLowSingle: "prescription low on refills",
pushRenewNow: "Renew Now!",
pushEmptySection: "Prescriptions with no refills left",
pushLowSection: "Prescriptions running low on refills",
pushRefillsLeft: "{count} refill(s) remaining on this prescription",
title: "⚠️ MedAssist-ng - Prescription Reminder",
titleEmpty: "🚨 MedAssist-ng - Prescription Reminder",
descriptionLow: "Some prescriptions are low on remaining refills.",
descriptionEmpty: "Some prescriptions have no refills left. Contact your doctor for renewal.",
alertLowSingle: "⚠️ 1 prescription is low on refills",
alertLowMultiple: "⚠️ {count} prescriptions are low on refills",
alertEmptySingle: "🚨 1 prescription needs renewal now",
alertEmptyMultiple: "🚨 {count} prescriptions need renewal now",
line: "{name}: {refills} refill(s) remaining on this prescription{expirySuffix}",
lineEmpty: "{name}: no refills remaining on this prescription{expirySuffix}",
expiresSuffix: ", expires {date}",
repeatDailyNote: "You are receiving this daily reminder because 'Repeat Daily' is enabled in settings.",
tableHeaders: {
medication: "Medication",
refillsLeft: "Prescription refills left",
reminderThreshold: "Reminder threshold",
prescriptionExpires: "Prescription expires",
},
},
demandCalculator: {
subject: "MedAssist-ng: Supply Overview ({from} - {until})",
title: "MedAssist-ng: Demand Calculator",
description: "Supply overview from {from} to {until}",
summaryOutOfStock: "⚠️ {count} medication{s} will be out of stock during this period.",
summaryAllOk: "✓ All medications have sufficient supply for this period.",
tableHeaders: {
medication: "Medication",
usage: "Usage",
needed: "Blisters needed",
prescriptionRefills: "Prescription refills",
available: "Available",
status: "Status",
},
statusEnough: "✓ Enough",
statusEmpty: "✗ Empty",
prescriptionNotApplicable: "",
},
common: {
pill: "pill",
pills: "pills",
units: "units",
ml: "ml",
blister: "blister",
blisters: "blisters",
day: "day",
days: "days",
soon: "soon",
footer: "🤖 Sent from MedAssist-ng",
},
},
de: {
stockReminder: {
subject: "MedAssist-ng: ⚠️ {count} Medikament{e} kritisch niedrig",
title: "⚠️ MedAssist-ng: Automatische Nachbestell-Erinnerung",
description: "Die folgenden Medikamente sind kritisch niedrig und sollten nachbestellt werden:",
descriptionEmpty: "Die folgenden Medikamente sind leer und müssen sofort nachbestellt werden:",
descriptionMixed: "Die folgenden Medikamente müssen nachbestellt werden:",
alertSingle: "⚠️ 1 Medikament kritisch niedrig!",
alertMultiple: "⚠️ {count} Medikamente kritisch niedrig!",
alertEmptySingle: "🚨 1 Medikament leer - sofort nachbestellen!",
alertEmptyMultiple: "🚨 {count} Medikamente leer - sofort nachbestellen!",
alertLowSingle: "⚠️ 1 Medikament kritisch niedrig",
alertLowMultiple: "⚠️ {count} Medikamente kritisch niedrig",
alertLowStockSingle: "⚠️ 1 Medikament niedrig",
alertLowStockMultiple: "⚠️ {count} Medikamente niedrig",
descriptionLow: "Die folgenden Medikamente werden knapp und sollten bald nachbestellt werden:",
tableHeaders: {
medication: "Medikament",
pills: "Tabletten",
days: "Tage",
runsOut: "Aufgebraucht",
},
now: "JETZT",
repeatDailyNote:
"Sie erhalten diese tägliche Erinnerung, weil 'Täglich wiederholen' in den Einstellungen aktiviert ist.",
},
intakeReminder: {
subject: "MedAssist-ng: Einnahme-Erinnerung - {medications}",
title: "💊 MedAssist-ng - Einnahme-Erinnerung",
description: "Zeit für Ihre Medikamente in {minutes} Minuten:",
alertSingle: "💊 1 Medikament geplant",
alertMultiple: "💊 {count} Medikamente geplant",
tableHeaders: {
medication: "Medikament",
dosage: "Dosis",
time: "Uhrzeit",
},
pills: "Tabletten",
takenBy: "für {name}",
},
push: {
stockTitle: "MedAssist-ng: 1 Medikament kritisch niedrig",
stockTitleMultiple: "MedAssist-ng: {count} Medikamente kritisch niedrig",
intakeTitle: "💊 Erinnerung: Medikamenteneinnahme in {minutes} Min.",
pillsLeft: "{count} Tabletten",
daysLeft: "{count} Tage übrig",
pillsAt: "{count} Tabletten um {time}",
repeatDailyNote: "(Tägliche Erinnerung aktiviert)",
empty: "Leer",
low: "Kritisch",
critical: "Kritisch",
lowStock: "Niedrig",
reorderNow: "Jetzt nachbestellen!",
emptySection: "Leer (sofort nachbestellen)",
lowSection: "Kritisch niedrig",
criticalSection: "Kritisch niedrig",
lowStockSection: "Niedrig",
},
prescriptionReminder: {
subjectSingle: "MedAssist-ng: 🚨 Rezept-Nachfüll-Erinnerung",
subjectMultiple: "MedAssist-ng: 🚨 {count} Rezepte müssen bald erneuert werden",
pushTitleLow: "💊 MedAssist-ng: {count} Rezept(e) haben nur noch wenige Nachfüllungen",
pushTitleEmpty: "💊 MedAssist-ng: {count} Rezept(e) müssen jetzt erneuert werden",
pushEmpty: "Rezepte ohne verbleibende Nachfüllung",
pushEmptySingle: "Rezept ohne verbleibende Nachfüllung",
pushLow: "Rezepte mit wenigen verbleibenden Nachfüllungen",
pushLowSingle: "Rezept mit wenigen verbleibenden Nachfüllungen",
pushRenewNow: "Jetzt erneuern!",
pushEmptySection: "Rezepte ohne Nachfüllungen",
pushLowSection: "Rezepte mit bald aufgebrauchten Nachfüllungen",
pushRefillsLeft: "{count} Nachfüllung(en) für dieses Rezept übrig",
title: "⚠️ MedAssist-ng - Rezept-Erinnerung",
titleEmpty: "🚨 MedAssist-ng - Rezept-Erinnerung",
descriptionLow: "Einige Rezepte haben nur noch wenige Nachfüllungen.",
descriptionEmpty:
"Einige Rezepte haben keine Nachfüllungen mehr. Bitte kontaktieren Sie Ihren Arzt für eine Erneuerung.",
alertLowSingle: "⚠️ 1 Rezept ist bei den Nachfüllungen niedrig",
alertLowMultiple: "⚠️ {count} Rezepte sind bei den Nachfüllungen niedrig",
alertEmptySingle: "🚨 1 Rezept muss jetzt erneuert werden",
alertEmptyMultiple: "🚨 {count} Rezepte müssen jetzt erneuert werden",
line: "{name}: {refills} Nachfüllung(en) für dieses Rezept übrig{expirySuffix}",
lineEmpty: "{name}: keine Nachfüllung mehr für dieses Rezept{expirySuffix}",
expiresSuffix: ", läuft ab {date}",
repeatDailyNote:
"Sie erhalten diese tägliche Erinnerung, weil 'Täglich wiederholen' in den Einstellungen aktiviert ist.",
tableHeaders: {
medication: "Medikament",
refillsLeft: "Rezept-Nachfüllungen übrig",
reminderThreshold: "Erinnerungsschwelle",
prescriptionExpires: "Rezeptablauf",
},
},
demandCalculator: {
subject: "MedAssist-ng: Bestandsübersicht ({from} - {until})",
title: "MedAssist-ng: Bedarfsrechner",
description: "Bestandsübersicht von {from} bis {until}",
summaryOutOfStock: "⚠️ {count} Medikament{e} wird im Zeitraum nicht ausreichen.",
summaryAllOk: "✓ Alle Medikamente reichen für diesen Zeitraum.",
tableHeaders: {
medication: "Medikament",
usage: "Verbrauch",
needed: "Blister benötigt",
prescriptionRefills: "Rezept-Nachfüllungen",
available: "Verfügbar",
status: "Status",
},
statusEnough: "✓ Ausreichend",
statusEmpty: "✗ Leer",
prescriptionNotApplicable: "",
},
common: {
pill: "Tablette",
pills: "Tabletten",
units: "Einheiten",
ml: "ml",
blister: "Blister",
blisters: "Blister",
day: "Tag",
days: "Tage",
soon: "bald",
footer: "🤖 Gesendet von MedAssist-ng",
},
},
};
export function getTranslations(language: Language): TranslationKeys {
return translations[language] || translations.en;
return translations[language] || translations.en;
}
// Helper function to replace placeholders in strings
export function t(template: string, params: Record<string, string | number> = {}): string {
let result = template;
for (const [key, value] of Object.entries(params)) {
result = result.replace(new RegExp(`\\{${key}\\}`, "g"), String(value));
}
return result;
let result = template;
for (const [key, value] of Object.entries(params)) {
result = result.replace(new RegExp(`\\{${key}\\}`, "g"), String(value));
}
return result;
}
// Get date locale for toLocaleDateString
/**
* Get locale for formatting based on language and timezone region.
* Combines language (en/de) with region from timezone (DE/US/etc.)
* Example: lang=en + TZ=Europe/Berlin en-DE (English text, German format = 24h time)
*/
export function getDateLocale(language: Language): string {
switch (language) {
case "de":
return "de-DE";
case "en":
default:
return "en-US";
}
const region = getRegionFromTimezone();
if (region) {
return `${language}-${region}`;
}
// Fallback: use language default
switch (language) {
case "de":
return "de-DE";
default:
return "en-US";
}
}
/**
* Get the app URL from the first CORS_ORIGINS entry.
* Falls back to empty string if not set.
*/
export function getAppUrl(): string {
const origins = process.env.CORS_ORIGINS || "";
return origins.split(",")[0]?.trim() || "";
}
/**
* Get the unified footer as HTML with MedAssist-ng as a link to the instance.
* @param variant - 'planner' uses the Medication Planner footer text
*/
export function getFooterHtml(language: Language): string {
const tr = getTranslations(language);
const appUrl = getAppUrl();
const appName = appUrl
? `<a href="${appUrl}" style="color: #6b7280; text-decoration: underline;">MedAssist-ng</a>`
: "MedAssist-ng";
return tr.common.footer.replace("MedAssist-ng", appName);
}
/**
* Get the unified footer as plain text.
* @param variant - 'planner' uses the Medication Planner footer text
*/
export function getFooterPlain(language: Language): string {
const tr = getTranslations(language);
const appUrl = getAppUrl();
if (appUrl) {
return `${tr.common.footer} (${appUrl})`;
}
return tr.common.footer;
}
+254 -127
View File
@@ -1,207 +1,334 @@
import Fastify, { FastifyInstance } from "fastify";
import helmet from "@fastify/helmet";
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 fastifyMultipart from "@fastify/multipart";
import rateLimit from "@fastify/rate-limit";
import sensible from "@fastify/sensible";
import cookie from "@fastify/cookie";
import jwt from "@fastify/jwt";
import fastifyMultipart from "@fastify/multipart";
import fastifyStatic from "@fastify/static";
import { resolve } from "path";
import { existsSync } from "fs";
import { env } from "./plugins/env.js";
import fastifySwagger from "@fastify/swagger";
import fastifySwaggerUi from "@fastify/swagger-ui";
import Fastify, { type FastifyInstance } from "fastify";
import { migrationsReady } from "./db/client.js";
import { healthRoutes } from "./routes/health.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 { oidcRoutes } from "./routes/oidc.js";
import { medicationRoutes } from "./routes/medications.js";
import { settingsRoutes } from "./routes/settings.js";
import { plannerRoutes } from "./routes/planner.js";
import { shareRoutes } from "./routes/share.js";
import { doseRoutes } from "./routes/doses.js";
import { startReminderScheduler } from "./services/reminder-scheduler.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 {
parseCorsOrigins,
buildBaseCookieOptions,
buildRefreshCookieOptions,
buildAppConfig,
ensureImagesDirectory,
getJwtConfig,
buildAppConfig,
buildBaseCookieOptions,
buildRefreshCookieOptions,
ensureImagesDirectory,
getJwtConfig,
parseCorsOrigins,
} from "./utils/server-config.js";
import {
parseCorsOrigins,
buildBaseCookieOptions,
buildRefreshCookieOptions,
buildAppConfig,
ensureImagesDirectory,
getJwtConfig,
buildAppConfig,
buildBaseCookieOptions,
buildRefreshCookieOptions,
ensureImagesDirectory,
getJwtConfig,
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;
corsOrigins?: string[];
authEnabled?: boolean;
jwtSecret?: string;
refreshSecret?: string;
cookieSecret?: string;
accessTtlMinutes?: number;
refreshTtlDays?: number;
isProduction?: boolean;
imagesDir?: string;
logLevel?: string;
corsOrigins?: string[];
authEnabled?: boolean;
jwtSecret?: string;
refreshSecret?: string;
cookieSecret?: string;
accessTtlMinutes?: number;
refreshTtlDays?: number;
isProduction?: boolean;
imagesDir?: string;
openApiDocsEnabled?: boolean;
}): Promise<FastifyInstance> {
const opts = {
logLevel: options?.logLevel ?? "info",
corsOrigins: options?.corsOrigins ?? ["http://localhost:5173"],
authEnabled: options?.authEnabled ?? false,
jwtSecret: options?.jwtSecret,
refreshSecret: options?.refreshSecret,
cookieSecret: options?.cookieSecret ?? "dev-cookie-secret",
accessTtlMinutes: options?.accessTtlMinutes ?? 15,
refreshTtlDays: options?.refreshTtlDays ?? 7,
isProduction: options?.isProduction ?? false,
imagesDir: options?.imagesDir ?? resolve(process.cwd(), "data/images"),
};
const opts = {
logLevel: options?.logLevel ?? "info",
corsOrigins: options?.corsOrigins ?? ["http://localhost:5173"],
authEnabled: options?.authEnabled ?? false,
jwtSecret: options?.jwtSecret,
refreshSecret: options?.refreshSecret,
cookieSecret: options?.cookieSecret ?? "dev-cookie-secret",
accessTtlMinutes: options?.accessTtlMinutes ?? 15,
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 },
});
const app = Fastify({
logger: buildLoggerOptions(opts.logLevel),
genReqId: (request) => sanitizeCorrelationId(request.headers) ?? randomUUID(),
ajv: documentationSchemaAjv,
});
// Build config
const appConfig = buildAppConfig({
jwtSecret: opts.jwtSecret,
refreshSecret: opts.refreshSecret,
accessTtlMinutes: opts.accessTtlMinutes,
refreshTtlDays: opts.refreshTtlDays,
isProduction: opts.isProduction,
});
app.addHook("onRequest", (request, reply, done) => {
request.correlationId = request.id;
reply.header("x-correlation-id", request.id);
done();
});
app.decorate("config", appConfig);
// Build config
const appConfig = buildAppConfig({
jwtSecret: opts.jwtSecret,
refreshSecret: opts.refreshSecret,
accessTtlMinutes: opts.accessTtlMinutes,
refreshTtlDays: opts.refreshTtlDays,
isProduction: opts.isProduction,
});
// Register plugins
await app.register(sensible);
await app.register(helmet);
await app.register(cors, { origin: opts.corsOrigins, credentials: true });
await app.register(rateLimit, { max: 100, timeWindow: "1 minute" });
await app.register(cookie, { secret: opts.cookieSecret });
app.decorate("config", appConfig);
// JWT plugin
const jwtConfig = getJwtConfig(opts.authEnabled, opts.jwtSecret);
await app.register(jwt, jwtConfig);
// Register plugins
await app.register(sensible);
await app.register(helmet);
await app.register(cors, { origin: opts.corsOrigins, credentials: true });
await app.register(rateLimit, { max: 300, timeWindow: "1 minute" });
await app.register(cookie, { secret: opts.cookieSecret });
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } });
// Only register static if directory exists
if (existsSync(opts.imagesDir)) {
await app.register(fastifyStatic, {
root: opts.imagesDir,
prefix: "/images/",
decorateReply: false,
});
}
// JWT plugin
const jwtConfig = getJwtConfig(opts.authEnabled, opts.jwtSecret);
await app.register(jwtPlugin, jwtConfig);
// Register routes
await app.register(healthRoutes);
await app.register(authRoutes);
await app.register(oidcRoutes);
await app.register(medicationRoutes);
await app.register(settingsRoutes);
await app.register(plannerRoutes);
await app.register(shareRoutes);
await app.register(doseRoutes);
await app.register(fastifyMultipart, { limits: { fileSize: 10 * 1024 * 1024 } });
await registerApiDocs(app, opts.openApiDocsEnabled);
return app;
// Only register static if directory exists
if (existsSync(opts.imagesDir)) {
await app.register(fastifyStatic, {
root: opts.imagesDir,
prefix: "/images/",
decorateReply: false,
});
}
// 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;
}
// =============================================================================
// Server initialization (runs on import)
// =============================================================================
import { log } from "./utils/logger.js";
// Wait for database migrations before anything else
await migrationsReady;
console.log("[DB] Migrations complete, starting server...");
log.info("[DB] Migrations complete, starting server...");
// Ensure images directory exists
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);
// Auth token TTLs (hardcoded - no need for user configuration)
const accessTtlMinutes = env.ACCESS_TOKEN_TTL_MINUTES; // Access token TTL
const refreshTtlDays = env.REFRESH_TOKEN_TTL_DAYS; // Refresh token TTL
const accessTtlMinutes = env.ACCESS_TOKEN_TTL_MINUTES; // Access token TTL
const refreshTtlDays = env.REFRESH_TOKEN_TTL_DAYS; // Refresh token TTL
const baseCookieOptions = buildBaseCookieOptions(accessTtlMinutes, env.NODE_ENV === "production");
const refreshCookieOptions = buildRefreshCookieOptions(baseCookieOptions, refreshTtlDays);
// Config decorator - only include secrets if auth is enabled
app.decorate("config", {
accessSecret: env.JWT_SECRET ?? "",
refreshSecret: env.REFRESH_SECRET ?? "",
accessTtl: accessTtlMinutes,
refreshTtl: refreshTtlDays,
cookieOptions: baseCookieOptions,
refreshCookieOptions,
accessSecret: env.JWT_SECRET ?? "",
refreshSecret: env.REFRESH_SECRET ?? "",
accessTtl: accessTtlMinutes,
refreshTtl: refreshTtlDays,
cookieOptions: baseCookieOptions,
refreshCookieOptions,
});
await app.register(sensible);
await app.register(helmet);
await app.register(cors, { origin: origins, credentials: true });
await app.register(rateLimit, {
max: 100,
timeWindow: "1 minute",
max: Number(process.env.RATE_LIMIT_MAX) || 100,
timeWindow: "1 minute",
});
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/",
decorateReply: false,
root: imagesDir,
prefix: "/images/",
decorateReply: false,
});
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 {
await app.listen({ port: env.PORT, host: "0.0.0.0" });
app.log.info(`Server running on ${env.PORT}`);
// Start the automatic reminder scheduler
startReminderScheduler({
info: (msg) => app.log.info(msg),
error: (msg) => app.log.error(msg),
});
// Start the intake reminder scheduler (checks every minute)
startIntakeReminderScheduler({
info: (msg) => app.log.info(msg),
error: (msg) => app.log.error(msg),
});
} catch (err) {
app.log.error(err);
process.exit(1);
}
try {
await app.listen({ port: env.PORT, host: "0.0.0.0" });
app.log.info(`Server running on ${env.PORT}`);
// Start the automatic reminder scheduler
startReminderScheduler({
info: (msg) => app.log.info(msg),
debug: (msg) => app.log.debug(msg),
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),
debug: (msg) => app.log.debug(msg),
error: (msg) => app.log.error(msg),
});
} catch (err) {
app.log.error(err);
process.exit(1);
}
};
start();
+236 -101
View File
@@ -1,8 +1,10 @@
import { FastifyInstance, FastifyRequest, FastifyReply } from "fastify";
import { env } from "./env.js";
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 { sql, count, eq } from "drizzle-orm";
import { apiKeys, users } from "../db/schema.js";
import { log } from "../utils/logger.js";
import { env } from "./env.js";
// =============================================================================
// Anonymous User - Used when AUTH_ENABLED=false
@@ -17,67 +19,147 @@ let anonymousUserVerified = false;
* Uses a fixed ID (999999999) that will never collide with auto-increment IDs.
*/
export async function getAnonymousUserId(): Promise<number> {
// Return cached if already verified
if (anonymousUserVerified) {
return ANONYMOUS_USER_ID;
}
// Return cached if already verified
if (anonymousUserVerified) {
return ANONYMOUS_USER_ID;
}
// Check if anonymous user exists
const [existing] = await db.select().from(users).where(eq(users.id, ANONYMOUS_USER_ID));
if (existing) {
anonymousUserVerified = true;
return ANONYMOUS_USER_ID;
}
// Check if anonymous user exists
const [existing] = await db.select().from(users).where(eq(users.id, ANONYMOUS_USER_ID));
// Create anonymous user with fixed ID (SQLite allows explicit ID)
await db.run(sql`
if (existing) {
anonymousUserVerified = true;
return ANONYMOUS_USER_ID;
}
// Create anonymous user with fixed ID (SQLite allows explicit ID)
await db.run(sql`
INSERT INTO users (id, username, password_hash, auth_provider, is_active, created_at, updated_at)
VALUES (${ANONYMOUS_USER_ID}, ${ANONYMOUS_USERNAME}, NULL, 'anonymous', 1, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`);
anonymousUserVerified = true;
console.log(`Created anonymous user with fixed ID ${ANONYMOUS_USER_ID} for no-auth mode`);
return ANONYMOUS_USER_ID;
anonymousUserVerified = true;
return ANONYMOUS_USER_ID;
}
// =============================================================================
// Auth State - Computed at runtime
// =============================================================================
export interface AuthState {
authEnabled: boolean;
registrationEnabled: boolean;
localAuthEnabled: boolean;
oidcEnabled: boolean;
oidcProviderName: string;
hasUsers: boolean;
needsSetup: boolean;
authEnabled: boolean;
registrationEnabled: boolean;
formLoginEnabled: boolean;
oidcEnabled: boolean;
oidcProviderName: string;
hasUsers: boolean;
needsSetup: boolean;
}
export async function getAuthState(): Promise<AuthState> {
// Count only real users (not the anonymous user with fixed ID)
const [result] = await db.select({ count: count() }).from(users).where(sql`${users.id} != ${ANONYMOUS_USER_ID}`);
const hasUsers = result.count > 0;
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
oidcEnabled: env.OIDC_ENABLED,
oidcProviderName: env.OIDC_PROVIDER_NAME,
hasUsers,
needsSetup: env.AUTH_ENABLED && !hasUsers,
};
// Count only real users (not the anonymous user with fixed ID)
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,
// 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,
};
}
// =============================================================================
// Request User Type (no roles - all users are equal)
// =============================================================================
export interface RequestUser {
id: number;
username: string;
id: number;
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;
}
// =============================================================================
@@ -87,78 +169,131 @@ export interface RequestUser {
/**
* Optional auth - verifies JWT if present, but doesn't require it
*/
export async function optionalAuth(request: FastifyRequest, reply: FastifyReply) {
if (!env.AUTH_ENABLED) {
return;
}
export async function optionalAuth(request: FastifyRequest, _reply: FastifyReply) {
if (!env.AUTH_ENABLED) {
return;
}
const token = request.cookies.access_token;
if (!token) {
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;
}
try {
const decoded = await request.jwtVerify<{ sub: number; username: string }>();
const [user] = await db.select().from(users).where(sql`${users.id} = ${decoded.sub}`);
if (user && user.isActive) {
request.user = {
id: user.id,
username: user.username,
};
}
} catch {
// Invalid token, continue as anonymous
}
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;
}
try {
const decoded = await request.jwtVerify<{ sub: number; username: string }>();
const [user] = await db.select().from(users).where(sql`${users.id} = ${decoded.sub}`);
if (user?.isActive) {
request.user = {
id: user.id,
username: user.username,
};
request.authContext = {
method: "session",
scope: "write",
};
log.debug("[Auth] optionalAuth authenticated via session token");
}
} catch (err: unknown) {
const errorMessage = err instanceof Error ? err.message : String(err);
log.debug(`[Auth] optionalAuth session verification failed: ${errorMessage}`);
}
}
/**
* Required auth - requires valid JWT when auth is enabled
*/
export async function requireAuth(request: FastifyRequest, reply: FastifyReply) {
if (!env.AUTH_ENABLED) {
return;
}
if (!env.AUTH_ENABLED) {
return;
}
const token = request.cookies.access_token;
if (!token) {
reply.status(401).send({ error: "Authentication required", code: "AUTH_REQUIRED" });
throw new Error("AUTH_REQUIRED");
}
if (await tryApiKeyAuth(request, reply)) {
return;
}
try {
const decoded = await request.jwtVerify<{ sub: number; username: string }>();
const [user] = await db.select().from(users).where(sql`${users.id} = ${decoded.sub}`);
if (!user) {
reply.status(401).send({ error: "User not found", code: "USER_NOT_FOUND" });
throw new Error("USER_NOT_FOUND");
}
if (!user.isActive) {
reply.status(401).send({ error: "Account disabled", code: "ACCOUNT_DISABLED" });
throw new Error("ACCOUNT_DISABLED");
}
const token = request.cookies.access_token;
if (!token) {
reply.status(401).send({ error: "Authentication required", code: "AUTH_REQUIRED" });
throw new Error("AUTH_REQUIRED");
}
request.user = {
id: user.id,
username: user.username,
};
} catch (err: any) {
// Re-throw our own errors
if (err?.message === "AUTH_REQUIRED" || err?.message === "USER_NOT_FOUND" || err?.message === "ACCOUNT_DISABLED") {
throw err;
}
// JWT verification failed
reply.status(401).send({ error: "Invalid or expired token", code: "INVALID_TOKEN" });
throw new Error("INVALID_TOKEN");
}
try {
const decoded = await request.jwtVerify<{ sub: number; username: string }>();
const [user] = await db.select().from(users).where(sql`${users.id} = ${decoded.sub}`);
if (!user) {
reply.status(401).send({ error: "User not found", code: "USER_NOT_FOUND" });
throw new Error("USER_NOT_FOUND");
}
if (!user.isActive) {
reply.status(401).send({ error: "Account disabled", code: "ACCOUNT_DISABLED" });
throw new Error("ACCOUNT_DISABLED");
}
request.user = {
id: user.id,
username: user.username,
};
request.authContext = {
method: "session",
scope: "write",
};
} catch (err: unknown) {
// Re-throw our own errors
if (
err instanceof Error &&
(err.message === "AUTH_REQUIRED" ||
err.message === "USER_NOT_FOUND" ||
err.message === "ACCOUNT_DISABLED" ||
err.message === "INVALID_API_KEY" ||
err.message === "API_KEY_EXPIRED" ||
err.message === "API_KEY_SCOPE_FORBIDDEN")
) {
throw err;
}
// JWT verification failed
reply.status(401).send({ error: "Invalid or expired token", code: "INVALID_TOKEN" });
throw new Error("INVALID_TOKEN");
}
}
/**
* Auth state endpoint plugin
*/
export async function authPlugin(app: FastifyInstance) {
app.get("/auth/state", async () => {
return getAuthState();
});
app.get("/auth/state", async () => {
return getAuthState();
});
}
+147 -86
View File
@@ -1,108 +1,169 @@
import { z } from "zod";
import { existsSync } from "node:fs";
import dotenv from "dotenv";
import { z } from "zod";
dotenv.config({ path: process.env.DOTENV_PATH || ".env" });
// Load .env: try cwd first, then parent dir (for local dev running from backend/)
const envPath = process.env.DOTENV_PATH || (existsSync(".env") ? ".env" : "../.env");
dotenv.config({ path: envPath });
const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
PORT: z.string().transform((v) => parseInt(v, 10)).default("3000"),
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
LOG_LEVEL: z.string().default("info"),
// ==========================================================================
// Auth Configuration
// ==========================================================================
// Master switch: Enable/disable authentication (default: disabled for easy setup)
AUTH_ENABLED: z.string().transform((v) => v === "true").default("false"),
// 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
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
PORT: z
.string()
.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(),
// JWT Secrets - only required when AUTH_ENABLED=true
JWT_SECRET: z.string().min(10).optional(),
REFRESH_SECRET: z.string().min(10).optional(),
COOKIE_SECRET: z.string().min(10).optional(),
// Token TTL settings
ACCESS_TOKEN_TTL_MINUTES: z.string().transform((v) => parseInt(v, 10)).default("15"),
REFRESH_TOKEN_TTL_DAYS: z.string().transform((v) => parseInt(v, 10)).default("7"),
// ==========================================================================
// Auth Configuration
// ==========================================================================
// Master switch: Enable/disable authentication (default: disabled for easy setup)
AUTH_ENABLED: z
.string()
.default("false")
.transform((v) => v === "true"),
// Allow new user registrations (auto-enabled if no users exist)
REGISTRATION_ENABLED: z
.string()
.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"),
// ==========================================================================
// OIDC SSO Configuration (Pocket ID, Authelia, etc.)
// ==========================================================================
OIDC_ENABLED: z.string().transform((v) => v === "true").default("false"),
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(),
OIDC_REDIRECT_URI: z.string().url().optional(), // e.g., https://medassist.example.com/api/auth/oidc/callback
OIDC_SCOPES: z.string().default("openid profile email"),
OIDC_AUTO_CREATE_USERS: z.string().transform((v) => v === "true").default("true"),
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), // or 'email', 'sub'
OIDC_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button
// JWT Secrets - only required when AUTH_ENABLED=true
JWT_SECRET: z.string().min(10).optional(),
REFRESH_SECRET: z.string().min(10).optional(),
COOKIE_SECRET: z.string().min(10).optional(),
// Token TTL settings
ACCESS_TOKEN_TTL_MINUTES: z
.string()
.default("15")
.transform((v) => parseInt(v, 10)),
REFRESH_TOKEN_TTL_DAYS: z
.string()
.default("7")
.transform((v) => parseInt(v, 10)),
// ==========================================================================
// OIDC SSO Configuration (Pocket ID, Authelia, etc.)
// ==========================================================================
OIDC_ENABLED: z
.string()
.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(),
OIDC_REDIRECT_URI: z.string().url().optional(), // e.g., https://medassist.example.com/api/auth/oidc/callback
OIDC_SCOPES: z.string().default("openid profile email"),
OIDC_AUTO_CREATE_USERS: z
.string()
.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);
parsed = EnvSchema.parse(process.env);
} catch (err) {
console.error("=".repeat(60));
console.error("ENVIRONMENT CONFIGURATION ERROR");
console.error("=".repeat(60));
console.error(err);
console.error("\nPlease check your .env file or environment variables.");
console.error("=".repeat(60));
process.exit(1);
console.error("=".repeat(60));
console.error("ENVIRONMENT CONFIGURATION ERROR");
console.error("=".repeat(60));
console.error(err);
console.error("\nPlease check your .env file or environment variables.");
console.error("=".repeat(60));
process.exit(1);
}
// Validate that secrets are provided when auth is enabled
if (parsed.AUTH_ENABLED) {
const missing: string[] = [];
if (!parsed.JWT_SECRET) missing.push("JWT_SECRET");
if (!parsed.REFRESH_SECRET) missing.push("REFRESH_SECRET");
if (!parsed.COOKIE_SECRET) missing.push("COOKIE_SECRET");
if (missing.length > 0) {
console.error("=".repeat(60));
console.error("AUTHENTICATION CONFIGURATION ERROR");
console.error("=".repeat(60));
console.error(`AUTH_ENABLED=true but missing required secrets: ${missing.join(", ")}`);
console.error("");
console.error("To fix this, either:");
console.error(" 1. Set these environment variables with secure random values:");
console.error(" Generate with: openssl rand -hex 32");
console.error("");
console.error(" 2. Or disable authentication by removing AUTH_ENABLED=true");
console.error("=".repeat(60));
process.exit(1);
}
const missing: string[] = [];
if (!parsed.JWT_SECRET) missing.push("JWT_SECRET");
if (!parsed.REFRESH_SECRET) missing.push("REFRESH_SECRET");
if (!parsed.COOKIE_SECRET) missing.push("COOKIE_SECRET");
if (missing.length > 0) {
console.error("=".repeat(60));
console.error("AUTHENTICATION CONFIGURATION ERROR");
console.error("=".repeat(60));
console.error(`AUTH_ENABLED=true but missing required secrets: ${missing.join(", ")}`);
console.error("");
console.error("To fix this, either:");
console.error(" 1. Set these environment variables with secure random values:");
console.error(" Generate with: openssl rand -hex 32");
console.error("");
console.error(" 2. Or disable authentication by removing AUTH_ENABLED=true");
console.error("=".repeat(60));
process.exit(1);
}
}
// Validate OIDC configuration when enabled
if (parsed.OIDC_ENABLED) {
const missing: string[] = [];
if (!parsed.OIDC_ISSUER_URL) missing.push("OIDC_ISSUER_URL");
if (!parsed.OIDC_CLIENT_ID) missing.push("OIDC_CLIENT_ID");
if (!parsed.OIDC_CLIENT_SECRET) missing.push("OIDC_CLIENT_SECRET");
if (!parsed.OIDC_REDIRECT_URI) missing.push("OIDC_REDIRECT_URI");
if (missing.length > 0) {
console.error("=".repeat(60));
console.error("OIDC CONFIGURATION ERROR");
console.error("=".repeat(60));
console.error(`OIDC_ENABLED=true but missing required settings: ${missing.join(", ")}`);
console.error("");
console.error("Required OIDC settings:");
console.error(" OIDC_ISSUER_URL=https://your-oidc-provider.com");
console.error(" OIDC_CLIENT_ID=your-client-id");
console.error(" OIDC_CLIENT_SECRET=your-client-secret");
console.error(" OIDC_REDIRECT_URI=https://your-app.com/api/auth/oidc/callback");
console.error("=".repeat(60));
process.exit(1);
}
const missing: string[] = [];
if (!parsed.OIDC_ISSUER_URL) missing.push("OIDC_ISSUER_URL");
if (!parsed.OIDC_CLIENT_ID) missing.push("OIDC_CLIENT_ID");
if (!parsed.OIDC_CLIENT_SECRET) missing.push("OIDC_CLIENT_SECRET");
if (!parsed.OIDC_REDIRECT_URI) missing.push("OIDC_REDIRECT_URI");
if (missing.length > 0) {
console.error("=".repeat(60));
console.error("OIDC CONFIGURATION ERROR");
console.error("=".repeat(60));
console.error(`OIDC_ENABLED=true but missing required settings: ${missing.join(", ")}`);
console.error("");
console.error("Required OIDC settings:");
console.error(" OIDC_ISSUER_URL=https://your-oidc-provider.com");
console.error(" OIDC_CLIENT_ID=your-client-id");
console.error(" OIDC_CLIENT_SECRET=your-client-secret");
console.error(" OIDC_REDIRECT_URI=https://your-app.com/api/auth/oidc/callback");
console.error("=".repeat(60));
process.exit(1);
}
}
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();
}
);
}
+715 -406
View File
File diff suppressed because it is too large Load Diff
+678 -179
View File
@@ -1,231 +1,730 @@
import { FastifyInstance } from "fastify";
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 { eq, and } from "drizzle-orm";
import { requireAuth, getAnonymousUserId } from "../plugins/auth.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
// =============================================================================
const markDoseSchema = z.object({
doseId: z.string().min(1, "doseId is required"),
doseId: z.string().min(1, "doseId is required"),
});
const shareDoseSchema = z.object({
doseId: z.string().min(1, "doseId is required"),
doseId: z.string().min(1, "doseId is required"),
});
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: any, reply: any): Promise<number> {
// If auth is disabled, use the anonymous user
if (!env.AUTH_ENABLED) {
return getAnonymousUserId();
}
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
reply.status(401).send({ error: "Not authenticated" });
throw new Error("AUTH_REQUIRED");
}
return authUser.id;
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
// If auth is disabled, use the anonymous user
if (!env.AUTH_ENABLED) {
return getAnonymousUserId();
}
const authUser = request.user as unknown as AuthUser | null;
if (!authUser) {
reply.status(401).send({ error: "Not authenticated" });
throw new Error("AUTH_REQUIRED");
}
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) {
// ---------------------------------------------------------------------------
// GET /doses/taken - PROTECTED: Get all taken doses for the user
// ---------------------------------------------------------------------------
app.get(
"/doses/taken",
{ preHandler: requireAuth },
async (request, reply) => {
const userId = await getUserId(request, reply);
applyOpenApiRouteStandards(app, {
tag: "doses",
protectedByDefault: false,
protectedPaths: [/^\/doses\/taken$/, /^\/doses\/taken\/:doseId$/, /^\/doses\/dismiss$/],
});
// Get all taken doses for this user (no time limit)
const doses = await db.select()
.from(doseTracking)
.where(eq(doseTracking.userId, userId));
// ---------------------------------------------------------------------------
// 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,
logLevel: "warn",
schema: {
tags: ["doses"],
security: protectedEndpointSecurity,
response: {
200: doseReadResponseSchema,
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
return {
doses: doses.map((d) => ({
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
})),
};
}
);
// Get all taken doses for this user (no time limit)
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
// ---------------------------------------------------------------------------
// POST /doses/taken - PROTECTED: Mark a dose as taken
// ---------------------------------------------------------------------------
app.post<{ Body: z.infer<typeof markDoseSchema> }>(
"/doses/taken",
{ preHandler: requireAuth },
async (request, reply) => {
const userId = await getUserId(request, reply);
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,
})),
};
}
);
const parsed = markDoseSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: parsed.error.errors[0]?.message ?? "Invalid input",
});
}
// ---------------------------------------------------------------------------
// POST /doses/taken - PROTECTED: Mark a dose as taken
// ---------------------------------------------------------------------------
app.post<{ Body: z.infer<typeof markDoseSchema> }>(
"/doses/taken",
{
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 { doseId } = parsed.data;
const parsed = markDoseSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: getValidationErrorMessage(parsed.error),
});
}
// Check if already marked
const [existing] = await db.select()
.from(doseTracking)
.where(
and(
eq(doseTracking.userId, userId),
eq(doseTracking.doseId, doseId)
)
);
const { doseId } = parsed.data;
if (existing) {
return { success: true, message: "Already marked" };
}
// Check if already marked
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
// Insert new record
await db.insert(doseTracking).values({
userId,
doseId,
markedBy: null, // Marked by the user themselves
});
if (existing) {
return { success: true, message: "Already marked" };
}
return { success: true };
}
);
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" });
}
// ---------------------------------------------------------------------------
// DELETE /doses/taken/:doseId - PROTECTED: Unmark a dose
// ---------------------------------------------------------------------------
app.delete<{ Params: { doseId: string } }>(
"/doses/taken/:doseId",
{ preHandler: requireAuth },
async (request, reply) => {
const userId = await getUserId(request, reply);
// Insert new record
await db.insert(doseTracking).values({
userId,
doseId,
markedBy: null, // Marked by the user themselves
takenSource: "manual",
});
const { doseId } = request.params;
return { success: true };
}
);
await db.delete(doseTracking).where(
and(
eq(doseTracking.userId, userId),
eq(doseTracking.doseId, doseId)
)
);
// ---------------------------------------------------------------------------
// DELETE /doses/taken/:doseId - PROTECTED: Unmark a dose
// ---------------------------------------------------------------------------
app.delete<{ Params: { doseId: string } }>(
"/doses/taken/:doseId",
{
preHandler: requireAuth,
schema: {
tags: ["doses"],
security: protectedEndpointSecurity,
params: {
type: "object",
required: ["doseId"],
properties: {
doseId: { type: "string", minLength: 1 },
},
},
response: {
200: { type: "object", properties: { success: { type: "boolean" } } },
401: genericErrorSchema,
},
},
},
async (request, reply) => {
const userId = await getUserId(request, reply);
return { success: true };
}
);
const { doseId } = request.params;
// ---------------------------------------------------------------------------
// GET /share/:token/doses - PUBLIC: Get taken doses for a share link
// ---------------------------------------------------------------------------
app.get<{ Params: { token: string } }>(
"/share/:token/doses",
async (request, reply) => {
const { token } = request.params;
// Check if this dose was dismissed
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
return reply.notFound("Share link not found");
}
if (existing?.dismissed) {
// Already dismissed - keep the record as-is
// The dose stays dismissed, we just acknowledge the undo request
} else {
// Not dismissed - delete the record entirely
await db.delete(doseTracking).where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
}
// Get all taken doses for this user (no time limit)
const doses = await db.select()
.from(doseTracking)
.where(eq(doseTracking.userId, share.userId));
return { success: true };
}
);
return {
doses: doses.map((d) => ({
doseId: d.doseId,
takenAt: d.takenAt?.getTime() ?? Date.now(),
markedBy: d.markedBy,
})),
};
}
);
// ---------------------------------------------------------------------------
// POST /doses/dismiss - PROTECTED: Dismiss missed doses without deducting stock
// ---------------------------------------------------------------------------
app.post<{ Body: z.infer<typeof dismissDosesSchema> }>(
"/doses/dismiss",
{
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);
// ---------------------------------------------------------------------------
// 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",
async (request, reply) => {
const { token } = request.params;
const parsed = dismissDosesSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: getValidationErrorMessage(parsed.error),
});
}
const parsed = shareDoseSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: parsed.error.errors[0]?.message ?? "Invalid input",
});
}
const { doseIds } = parsed.data;
const { doseId } = parsed.data;
// Insert dismissed records for each dose that doesn't exist yet
let dismissedCount = 0;
for (const doseId of doseIds) {
// Check if already exists (taken or dismissed)
const [existing] = await db
.select()
.from(doseTracking)
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
return reply.notFound("Share link not found");
}
if (existing) {
// Already exists - update to dismissed if not already
if (!existing.dismissed) {
await db
.update(doseTracking)
.set({ dismissed: true })
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
dismissedCount++;
}
} else {
// Create new dismissed record
await db.insert(doseTracking).values({
userId,
doseId,
markedBy: null,
takenAt: new Date(0),
dismissed: true,
});
dismissedCount++;
}
}
// Check if already marked
const [existing] = await db.select()
.from(doseTracking)
.where(
and(
eq(doseTracking.userId, share.userId),
eq(doseTracking.doseId, doseId)
)
);
return { success: true, dismissedCount };
}
);
if (existing) {
return { success: true, message: "Already marked" };
}
// ---------------------------------------------------------------------------
// DELETE /doses/dismiss - PROTECTED: Clear all dismissed doses (un-dismiss)
// ---------------------------------------------------------------------------
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);
// Insert new record - marked by the takenBy person
await db.insert(doseTracking).values({
userId: share.userId,
doseId,
markedBy: share.takenBy, // e.g. "Daniel"
});
// 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)));
return { success: true };
}
);
for (const d of dismissed) {
const hasRealTakenTimestamp = d.takenAt instanceof Date ? d.takenAt.getTime() > 0 : Boolean(d.takenAt);
// ---------------------------------------------------------------------------
// 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;
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));
}
}
// Find share token
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
if (!share) {
return reply.notFound("Share link not found");
}
return { success: true, clearedCount: dismissed.length };
}
);
await db.delete(doseTracking).where(
and(
eq(doseTracking.userId, share.userId),
eq(doseTracking.doseId, doseId)
)
);
// ---------------------------------------------------------------------------
// 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",
{
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;
return { success: true };
}
);
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,
})),
};
}
);
// ---------------------------------------------------------------------------
// 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: getValidationErrorMessage(parsed.error),
});
}
const { doseId } = parsed.data;
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()
.from(doseTracking)
.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" };
}
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,
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 };
}
);
// ---------------------------------------------------------------------------
// DELETE /share/:token/doses/:doseId - PUBLIC: Unmark a dose via share link
// ---------------------------------------------------------------------------
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;
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 };
}
);
}
+868
View File
@@ -0,0 +1,868 @@
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, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { getDataDir } from "../db/path-utils.js";
import { doseTracking, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import type { AuthUser } from "../types/fastify.js";
import {
applyOpenApiRouteStandards,
genericErrorSchema,
validationErrorSchema,
} from "../utils/openapi-route-standards.js";
import { normalizePackageType, PACKAGE_TYPES } from "../utils/package-profiles.js";
import { 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.5";
// =============================================================================
// Zod Schemas for Import Validation
// =============================================================================
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)
});
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(PACKAGE_TYPES).default("blister"),
packageAmountValue: z.number().int().min(0).default(0),
packageAmountUnit: z.enum(["ml", "g"]).default("ml"),
});
const medicationExportSchema = z.object({
_exportId: z.string(),
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
});
const doseHistorySchema = z.object({
medicationRef: z.string(), // References _exportId
scheduleIndex: z.number().int().min(0),
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),
expiresAt: z.string().nullable().optional(), // ISO datetime
regenerateToken: z.boolean().default(true),
});
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();
const importDataSchema = z.object({
version: z.string(),
exportedAt: z.string(),
includeSensitiveData: z.boolean().default(false),
medications: z.array(medicationExportSchema).default([]),
doseHistory: z.array(doseHistorySchema).default([]),
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: 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: "Not authenticated" });
throw new Error("AUTH_REQUIRED");
}
return authUser.id;
}
// 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;
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,
{ usageJson: row.usageJson, everyJson: row.everyJson, startJson: row.startJson },
row.intakeRemindersEnabled ?? false
);
return intakes.map((intake) => ({
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
}));
}
// Read image file and convert to base64 data URL
function imageToBase64(imageUrl: string | null): string | null {
if (!imageUrl) return null;
const imagePath = resolve(IMAGES_DIR, imageUrl);
if (!existsSync(imagePath)) return null;
try {
const imageBuffer = readFileSync(imagePath);
const ext = extname(imageUrl).toLowerCase();
const mimeTypes: Record<string, string> = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".webp": "image/webp",
".gif": "image/gif",
};
const mimeType = mimeTypes[ext] || "image/jpeg";
return `data:${mimeType};base64,${imageBuffer.toString("base64")}`;
} catch {
return null;
}
}
// Save base64 image to file and return filename
function base64ToImage(base64: string, medicationId: number): string | null {
if (!base64 || !base64.startsWith("data:")) return null;
try {
// Parse data URL: "data:image/jpeg;base64,/9j/4AAQ..."
const matches = base64.match(/^data:image\/(\w+);base64,(.+)$/);
if (!matches) return null;
const ext = matches[1] === "jpeg" ? "jpg" : matches[1];
const data = matches[2];
const buffer = Buffer.from(data, "base64");
const filename = `med-${medicationId}-${Date.now()}.${ext}`;
const filepath = resolve(IMAGES_DIR, filename);
// Ensure images directory exists
if (!existsSync(IMAGES_DIR)) {
mkdirSync(IMAGES_DIR, { recursive: true });
}
writeFileSync(filepath, buffer);
return filename;
} catch {
return null;
}
}
// Parse dose ID to extract medication ID and timestamp
// Format: "{medicationId}-{blisterIndex}-{timestampMs}" or "{medicationId}-{blisterIndex}-{timestampMs}-{person}"
function parseDoseId(
doseId: string
): { medicationId: number; blisterIndex: number; timestampMs: number; person: string | null } | null {
const parts = doseId.split("-");
if (parts.length < 3) return null;
const medicationId = parseInt(parts[0], 10);
const blisterIndex = parseInt(parts[1], 10);
const timestampMs = parseInt(parts[2], 10);
if (Number.isNaN(medicationId) || Number.isNaN(blisterIndex) || Number.isNaN(timestampMs)) return null;
// Check if there's a person suffix (4th part onwards, could be multi-part name)
const person = parts.length > 3 ? parts.slice(3).join("-") : null;
return { medicationId, blisterIndex, timestampMs, person };
}
// Build dose ID from parts (with optional person suffix)
function buildDoseId(medicationId: number, blisterIndex: number, timestampMs: number, person?: string | null): string {
const base = `${medicationId}-${blisterIndex}-${timestampMs}`;
return person ? `${base}-${person}` : base;
}
// =============================================================================
// Export Routes
// =============================================================================
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",
{
schema: {
querystring: exportQuerystringSchema,
response: {
200: exportResponseSchema,
401: genericErrorSchema,
},
},
},
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);
const medicationById = new Map(meds.map((med) => [med.id, med]));
// 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),
medicationForm: med.medicationForm ?? "tablet",
pillForm: med.pillForm ?? null,
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
inventory: {
packCount: med.packCount ?? 1,
blistersPerPack: med.blistersPerPack ?? 1,
pillsPerBlister: med.pillsPerBlister ?? 1,
totalPills: med.totalPills ?? null,
looseTablets: med.looseTablets ?? 0,
stockAdjustment: med.stockAdjustment ?? 0,
packageType: normalizePackageType(med.packageType),
packageAmountValue: med.packageAmountValue ?? 0,
packageAmountUnit: (med.packageAmountUnit ?? "ml") as "ml" | "g",
},
pillWeightMg: med.pillWeightMg,
doseUnit: med.doseUnit ?? "mg",
schedules: parseIntakesForExport(med),
medicationStartDate: med.medicationStartDate || null,
medicationEndDate: med.medicationEndDate || null,
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
expiryDate: med.expiryDate,
notes: med.notes,
intakeRemindersEnabled: med.intakeRemindersEnabled ?? false,
isObsolete: med.isObsolete ?? false,
obsoleteAt: med.obsoleteAt?.toISOString() ?? null,
prescriptionEnabled: med.prescriptionEnabled ?? false,
prescriptionAuthorizedRefills: med.prescriptionAuthorizedRefills ?? null,
prescriptionRemainingRefills: med.prescriptionRemainingRefills ?? null,
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
prescriptionExpiryDate: med.prescriptionExpiryDate ?? null,
dismissedUntil: med.dismissedUntil ?? null,
image: includeImages ? imageToBase64(med.imageUrl) : null,
lastStockCorrectionAt: lastStockCorrectionAtIso,
};
});
// 2. Load all dose tracking entries
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
const exportDoseHistory = doses
.map((dose) => {
const parsed = parseDoseId(dose.doseId);
if (!parsed) return null;
const exportId = medIdToExportId.get(parsed.medicationId);
if (!exportId) return null; // Orphaned dose, skip
// Safely convert takenAt to ISO string
let takenAtIso: string;
try {
if (dose.takenAt instanceof Date && !Number.isNaN(dose.takenAt.getTime())) {
takenAtIso = dose.takenAt.toISOString();
} else if (typeof dose.takenAt === "number" || typeof dose.takenAt === "string") {
const d = new Date(dose.takenAt);
takenAtIso = !Number.isNaN(d.getTime()) ? d.toISOString() : new Date().toISOString();
} else {
takenAtIso = new Date().toISOString();
}
} catch {
takenAtIso = new Date().toISOString();
}
// 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;
}
}
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,
};
// Set download headers
const now = new Date();
const dateStr = now.toISOString().replace(/[-:]/g, "").replace(/T/, "-").slice(0, 13);
const authUser = env.AUTH_ENABLED ? (request.user as unknown as AuthUser | null) : null;
const userPart = authUser?.username ? `-${authUser.username}` : "";
const filename = `medassist-export${userPart}-${dateStr}.json`;
reply.header("Content-Type", "application/json");
reply.header("Content-Disposition", `attachment; filename="${filename}"`);
return exportData;
}
);
// ---------------------------------------------------------------------------
// POST /import - Import user data (replaces all existing data!)
// ---------------------------------------------------------------------------
app.post(
"/import",
{
config: {
// Increase body limit to 50MB to handle exports with base64 images
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);
// 1. Parse and validate import data
const parsed = importDataSchema.safeParse(request.body);
if (!parsed.success) {
return reply.status(400).send({
error: "Invalid import data format",
details: parsed.error.format(),
});
}
const importData = parsed.data;
// 2. Delete all existing user data (in correct order to respect foreign keys)
// Note: CASCADE delete should handle this, but let's be explicit
// First, delete images for existing medications
const existingMeds = await db.select().from(medications).where(eq(medications.userId, userId));
for (const med of existingMeds) {
if (med.imageUrl) {
const imagePath = resolve(IMAGES_DIR, med.imageUrl);
if (existsSync(imagePath)) {
try {
unlinkSync(imagePath);
} catch {
/* ignore */
}
}
}
}
// Delete in order: refill history, doses, share tokens, medications, settings
await db.delete(refillHistory).where(eq(refillHistory.userId, userId));
await db.delete(doseTracking).where(eq(doseTracking.userId, userId));
await db.delete(shareTokens).where(eq(shareTokens.userId, userId));
await db.delete(medications).where(eq(medications.userId, userId));
await db.delete(userSettings).where(eq(userSettings.userId, userId));
// 3. Import medications and build ID mapping
const exportIdToNewId = new Map<string, number>();
for (const med of importData.medications) {
const normalizedSchedules = med.schedules.map((schedule) =>
normalizeIntake({
usage: schedule.usage,
every: schedule.every,
start: schedule.start,
scheduleMode: schedule.scheduleMode,
weekdays: schedule.weekdays,
intakeUnit: schedule.intakeUnit ?? null,
takenBy: schedule.takenBy || null,
intakeRemindersEnabled: schedule.remind ?? false,
})
);
const usageJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.usage));
const everyJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.every));
const startJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.start));
const takenByJson = JSON.stringify(med.takenBy);
const intakesJson = JSON.stringify(normalizedSchedules);
// Check if any schedule has remind enabled
const intakeRemindersEnabled =
normalizedSchedules.some((schedule) => schedule.intakeRemindersEnabled) || med.intakeRemindersEnabled;
const [inserted] = await db
.insert(medications)
.values({
userId,
name: med.name,
genericName: med.genericName || null,
takenByJson,
medicationForm: med.medicationForm ?? "tablet",
pillForm: med.pillForm || null,
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
packageType: normalizePackageType(med.inventory.packageType),
packageAmountValue: med.inventory.packageAmountValue ?? 0,
packageAmountUnit: med.inventory.packageAmountUnit ?? "ml",
packCount: med.inventory.packCount,
blistersPerPack: med.inventory.blistersPerPack,
pillsPerBlister: med.inventory.pillsPerBlister,
looseTablets: med.inventory.looseTablets,
totalPills: med.inventory.totalPills ?? null,
stockAdjustment: med.inventory.stockAdjustment ?? 0,
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
pillWeightMg: med.pillWeightMg || null,
doseUnit: med.doseUnit ?? "mg",
medicationStartDate: med.medicationStartDate || "",
medicationEndDate: med.medicationEndDate || null,
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
intakesJson,
usageJson,
everyJson,
startJson,
expiryDate: med.expiryDate || null,
notes: med.notes || null,
intakeRemindersEnabled,
isObsolete: med.isObsolete ?? false,
obsoleteAt: med.obsoleteAt ? new Date(med.obsoleteAt) : null,
prescriptionEnabled: med.prescriptionEnabled ?? false,
prescriptionAuthorizedRefills: med.prescriptionEnabled ? (med.prescriptionAuthorizedRefills ?? null) : null,
prescriptionRemainingRefills: med.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? null) : null,
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
prescriptionExpiryDate: med.prescriptionExpiryDate || null,
dismissedUntil: med.dismissedUntil || null,
imageUrl: null, // Will be set after image is saved
})
.returning();
// Save mapping
exportIdToNewId.set(med._exportId, inserted.id);
// Save image if present
if (med.image) {
const imageUrl = base64ToImage(med.image, inserted.id);
if (imageUrl) {
await db.update(medications).set({ imageUrl }).where(eq(medications.id, inserted.id));
}
}
}
// 4. Import dose history with remapped medication IDs
for (const dose of importData.doseHistory) {
const newMedId = exportIdToNewId.get(dose.medicationRef);
if (!newMedId) continue; // Skip orphaned doses
// Convert ISO timestamp back to milliseconds for dose ID
const timestampMs = new Date(dose.scheduledTime).getTime();
// Rebuild dose ID with optional person suffix
const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs, dose.takenByPerson);
await db.insert(doseTracking).values({
userId,
doseId,
takenAt: new Date(dose.takenAt),
markedBy: dose.markedBy || null,
takenSource: dose.takenSource ?? "manual",
dismissed: dose.dismissed ?? false,
});
}
// 5. Import settings
if (importData.settings) {
// 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,
notificationEmail: importData.settings.notificationEmail || null,
emailStockReminders: importData.settings.emailStockReminders ?? true,
emailIntakeReminders: importData.settings.emailIntakeReminders ?? true,
emailPrescriptionReminders: importData.settings.emailPrescriptionReminders ?? true,
shoutrrrEnabled: importData.settings.shoutrrrEnabled ?? false,
shoutrrrUrl: importData.settings.shoutrrrUrl || null,
shoutrrrStockReminders: importData.settings.shoutrrrStockReminders ?? true,
shoutrrrIntakeReminders: importData.settings.shoutrrrIntakeReminders ?? true,
shoutrrrPrescriptionReminders: importData.settings.shoutrrrPrescriptionReminders ?? true,
reminderDaysBefore: importData.settings.reminderDaysBefore ?? 7,
repeatDailyReminders: importData.settings.repeatDailyReminders ?? false,
skipRemindersForTakenDoses: importData.settings.skipRemindersForTakenDoses ?? false,
repeatRemindersEnabled: importData.settings.repeatRemindersEnabled ?? false,
reminderRepeatIntervalMinutes: importData.settings.reminderRepeatIntervalMinutes ?? 30,
maxNaggingReminders: importData.settings.maxNaggingReminders ?? 5,
lowStockDays: importData.settings.lowStockDays ?? 30,
normalStockDays: importData.settings.normalStockDays ?? 90,
highStockDays: importData.settings.highStockDays ?? 180,
expiryWarningDays: importData.settings.expiryWarningDays ?? 90,
language: importData.settings.language ?? "en",
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
shareMedicationOverview: importData.settings.shareMedicationOverview ?? false,
});
}
// 6. Import share links (with new tokens)
for (const share of importData.shareLinks) {
// Always generate new token for security
const token = randomBytes(8).toString("hex");
await db.insert(shareTokens).values({
userId,
token,
takenBy: share.takenBy,
scheduleDays: share.scheduleDays,
expiresAt: share.expiresAt ? new Date(share.expiresAt) : null,
});
}
// 7. Import refill history with remapped medication IDs
for (const refill of importData.refillHistory) {
const newMedId = exportIdToNewId.get(refill.medicationRef);
if (!newMedId) continue; // Skip orphaned refill records
await db.insert(refillHistory).values({
medicationId: newMedId,
userId,
packsAdded: refill.packsAdded ?? 0,
loosePillsAdded: refill.loosePillsAdded ?? 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,
},
};
}
);
}

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