Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47d230ace2 | |||
| 812b14df03 | |||
| c78fc43083 | |||
| e4a1b449c6 | |||
| 767ae23843 | |||
| 3eb56885f9 | |||
| c5b08b28c1 | |||
| 1eb7579706 | |||
| e69e46f9fc | |||
| 1f5dd36b5c | |||
| 545793fdd2 | |||
| 2f5fc2d9e9 | |||
| 4212469cd5 | |||
| db602d8360 | |||
| a95c6e3657 |
@@ -10,6 +10,8 @@ PUID=1000
|
|||||||
PGID=1000
|
PGID=1000
|
||||||
|
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
# Docker Compose quickstart serves the frontend on http://localhost:4174.
|
||||||
|
# Local Vite development usually uses http://localhost:5173 or http://localhost:4173 instead.
|
||||||
CORS_ORIGINS=http://localhost:4174
|
CORS_ORIGINS=http://localhost:4174
|
||||||
|
|
||||||
# Server default timezone for scheduled reminders.
|
# Server default timezone for scheduled reminders.
|
||||||
@@ -18,8 +20,11 @@ TZ=Europe/Berlin
|
|||||||
|
|
||||||
# Public base URL used for notification action links.
|
# Public base URL used for notification action links.
|
||||||
# Required for intake reminder action buttons.
|
# Required for intake reminder action buttons.
|
||||||
|
# Use an externally reachable HTTPS URL for remote/self-hosted access.
|
||||||
# PUBLIC_APP_URL=https://medassist.example.com
|
# PUBLIC_APP_URL=https://medassist.example.com
|
||||||
# If this uses a non-local host, include that origin in CORS_ORIGINS.
|
# If this uses a non-local host, include that origin in CORS_ORIGINS.
|
||||||
|
# Local Vite development automatically allows this hostname; set
|
||||||
|
# VITE_ALLOWED_HOSTS only when you need additional development hostnames.
|
||||||
|
|
||||||
# Log level: debug, info, warn, error, silent
|
# Log level: debug, info, warn, error, silent
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|||||||
@@ -1,26 +1,13 @@
|
|||||||
# MedAssist-ng - Copilot Entry Point
|
# MedAssist-ng - Copilot Entry Point
|
||||||
|
|
||||||
## VERY IMPORTANT - Prioritized Constraints
|
This file is intentionally thin. `AGENTS.md` is the canonical governance file for this repository.
|
||||||
|
|
||||||
**First: Update Memory and Reports**
|
If rules differ between files, follow `AGENTS.md`.
|
||||||
- Always keep agent work memory updated in `doku/memory_notes.md` so progress and decisions remain recoverable across context loss.
|
|
||||||
- If `doku/memory_notes.md` is missing, create it immediately.
|
|
||||||
- Always keep a user-facing work report updated in `doku/report.md` so completed work is easy to review.
|
|
||||||
- If `doku/report.md` is missing, create it immediately.
|
|
||||||
- This memory/report rule replaces the previous `doku/APP_BEHAVIOR.md` persistence requirement.
|
|
||||||
|
|
||||||
**Second: Follow Governance Rules**
|
|
||||||
- Consult `AGENTS.md` for governance, workflow, and skill rules when that file exists in the workspace.
|
|
||||||
|
|
||||||
When `AGENTS.md` exists in the workspace, use it as the single source of truth for governance, workflow, and skill rules.
|
|
||||||
|
|
||||||
## Required Startup Steps
|
## Required Startup Steps
|
||||||
|
|
||||||
1. Read `AGENTS.md` first when it exists in the workspace.
|
1. Read `AGENTS.md` first when it exists in the workspace.
|
||||||
2. If `AGENTS.md` exists, identify triggered skills from it and read each referenced `SKILL.md` before making changes.
|
2. Ensure `doku/memory_notes.md` and `doku/report.md` exist and keep them updated during meaningful work. These files are local-only and must not be staged or committed unless explicitly requested.
|
||||||
3. Follow delegation boundaries exactly (`@testing-manager` for testing, `@release-manager` for release orchestration).
|
3. Identify triggered skills from `AGENTS.md` and read only the matching `SKILL.md` files before making changes.
|
||||||
4. When work moves into a different thematic area, create or switch to a dedicated local branch or worktree before editing code, and reuse the same branch/worktree for follow-up work inside that same theme.
|
4. Follow delegation boundaries from `AGENTS.md`: `@testing-manager` for testing work and `@release-manager` for release orchestration, including the documented fallback protocol when a required specialist is unavailable.
|
||||||
|
5. Keep all non-canonical instruction files brief and aligned with `AGENTS.md`; do not duplicate full governance here.
|
||||||
## Scope
|
|
||||||
|
|
||||||
This file intentionally stays minimal to prevent duplicated or conflicting instructions.
|
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ on:
|
|||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: docker-build-${{ github.ref }}
|
group: docker-build-${{ github.ref }}
|
||||||
|
# Cancel older runs on the same ref so the shared branch tag stays aligned
|
||||||
|
# with the newest commit instead of racing older builds against newer ones.
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
# Default minimal permissions
|
# Default minimal permissions
|
||||||
|
|||||||
@@ -18,8 +18,8 @@
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="https://img.shields.io/badge/Backend_Tests-696%2F696-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
<img src="https://img.shields.io/badge/Backend_Tests-715%2F715-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||||
<img src="https://img.shields.io/badge/Frontend_Tests-919%2F919-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
<img src="https://img.shields.io/badge/Frontend_Tests-949%2F949-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
### 🤖 AI-Generated Code
|
### 🤖 AI-Generated Code
|
||||||
@@ -157,10 +157,13 @@ Share your medication schedule with others via a public link.
|
|||||||
### Multi-Person Support
|
### Multi-Person Support
|
||||||
- Manage medications for multiple people
|
- Manage medications for multiple people
|
||||||
- Share schedules via link. Recipients can mark doses as taken, you see it live
|
- Share schedules via link. Recipients can mark doses as taken, you see it live
|
||||||
|
- Optionally allow shared links to view and edit intake journal notes for their visible schedule window
|
||||||
- Optionally embed the medication overview directly on shared links via a settings toggle
|
- Optionally embed the medication overview directly on shared links via a settings toggle
|
||||||
|
|
||||||
### Data Export & Import
|
### Data Export & Import
|
||||||
- Export all your data (medications, dose history, settings) as JSON
|
- Export all your data (medications, dose history, intake journal notes, settings) as JSON
|
||||||
|
- Review validated import contents before replacing current data
|
||||||
|
- Optionally download a fresh backup before confirming import
|
||||||
- Import previously exported data with automatic ID remapping
|
- Import previously exported data with automatic ID remapping
|
||||||
- Choose whether to include sensitive data in exports
|
- Choose whether to include sensitive data in exports
|
||||||
|
|
||||||
@@ -188,6 +191,16 @@ docker compose -p medassist-ng up -d
|
|||||||
|
|
||||||
Open `http://localhost:4174` and start tracking your medications.
|
Open `http://localhost:4174` and start tracking your medications.
|
||||||
|
|
||||||
|
### Verify Deployment
|
||||||
|
|
||||||
|
After the containers start, confirm the stack is actually healthy:
|
||||||
|
|
||||||
|
1. Run `docker compose ps` and confirm the `backend` service is `healthy` and the `frontend` service is running.
|
||||||
|
2. Open `http://localhost:3000/health` and confirm the backend responds with JSON that includes `"status":"ok"`.
|
||||||
|
3. Open `http://localhost:4174` and confirm the app shell loads and can reach the API.
|
||||||
|
|
||||||
|
If the frontend loads but API requests fail, check the backend health endpoint first and confirm `CORS_ORIGINS` includes the frontend origin you are using. If you plan to open reminder or share links from another device, set `PUBLIC_APP_URL` to the externally reachable app URL instead of relying on `localhost`.
|
||||||
|
|
||||||
# Configuration
|
# Configuration
|
||||||
|
|
||||||
Configure the application with environment variables in `.env`. Keep the basic container settings in the README and use the dedicated docs for the full reference.
|
Configure the application with environment variables in `.env`. Keep the basic container settings in the README and use the dedicated docs for the full reference.
|
||||||
@@ -206,7 +219,7 @@ Optional but commonly needed:
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `PUBLIC_APP_URL` | — | Public base URL for notification action links |
|
| `PUBLIC_APP_URL` | — | Public base URL for notification action and share links |
|
||||||
|
|
||||||
Detailed configuration references:
|
Detailed configuration references:
|
||||||
|
|
||||||
@@ -218,6 +231,13 @@ Detailed configuration references:
|
|||||||
|
|
||||||
Development setup and local commands are documented in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).
|
Development setup and local commands are documented in [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).
|
||||||
|
|
||||||
|
For cross-stack maintenance work and pre-PR validation, the repository root now exposes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
# Acknowledgements
|
# Acknowledgements
|
||||||
|
|
||||||
This project was inspired by [MedAssist](https://github.com/njic/medassist) by njic.
|
This project was inspired by [MedAssist](https://github.com/njic/medassist) by njic.
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE `intake_journal` (
|
||||||
|
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||||
|
`user_id` integer NOT NULL,
|
||||||
|
`dose_tracking_id` integer NOT NULL,
|
||||||
|
`medication_id` integer NOT NULL,
|
||||||
|
`scheduled_for` integer NOT NULL,
|
||||||
|
`note` text NOT NULL,
|
||||||
|
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||||
|
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`dose_tracking_id`) REFERENCES `dose_tracking`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||||
|
FOREIGN KEY (`medication_id`) REFERENCES `medications`(`id`) ON UPDATE no action ON DELETE cascade
|
||||||
|
);
|
||||||
|
--> statement-breakpoint
|
||||||
|
CREATE UNIQUE INDEX `intake_journal_dose_tracking_id_unique` ON `intake_journal` (`dose_tracking_id`);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `share_tokens` ADD `allow_journal_notes` integer DEFAULT false NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -106,6 +106,20 @@
|
|||||||
"when": 1775849300000,
|
"when": 1775849300000,
|
||||||
"tag": "0014_add_user_settings_timezone",
|
"tag": "0014_add_user_settings_timezone",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 15,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1778962021119,
|
||||||
|
"tag": "0015_add_intake_journal",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 16,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1779044316043,
|
||||||
|
"tag": "0016_add_share_allow_journal_notes",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
Generated
+158
-159
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.23.0",
|
"version": "1.25.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.23.0",
|
"version": "1.25.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/cors": "^11.2.0",
|
"@fastify/cors": "^11.2.0",
|
||||||
@@ -32,14 +32,14 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.15",
|
"@biomejs/biome": "^2.4.15",
|
||||||
"@types/node": "^25.6.2",
|
"@types/node": "^25.8.0",
|
||||||
"@types/nodemailer": "^8.0.0",
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/supertest": "^7.2.0",
|
"@types/supertest": "^7.2.0",
|
||||||
"@vitest/coverage-v8": "^4.1.5",
|
"@vitest/coverage-v8": "^4.1.6",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"supertest": "^7.2.2",
|
"supertest": "^7.2.2",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.22.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vitest": "^4.0.16"
|
"vitest": "^4.0.16"
|
||||||
}
|
}
|
||||||
@@ -1862,9 +1862,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.127.0",
|
"version": "0.130.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
|
||||||
"integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
|
"integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -1897,9 +1897,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
|
||||||
"integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
|
"integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1914,9 +1914,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
|
||||||
"integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
|
"integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1931,9 +1931,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-x64": {
|
"node_modules/@rolldown/binding-darwin-x64": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
|
||||||
"integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
|
"integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1948,9 +1948,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
|
||||||
"integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
|
"integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -1965,9 +1965,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
|
||||||
"integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
|
"integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -1982,9 +1982,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
|
||||||
"integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
|
"integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -1999,9 +1999,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
|
||||||
"integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
|
"integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2016,9 +2016,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
|
||||||
"integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
|
"integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -2033,9 +2033,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
|
||||||
"integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
|
"integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -2050,9 +2050,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
|
||||||
"integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
|
"integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2067,9 +2067,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
|
||||||
"integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
|
"integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2084,9 +2084,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
|
||||||
"integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
|
"integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2101,9 +2101,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
|
||||||
"integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
|
"integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
@@ -2120,9 +2120,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
|
||||||
"integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
|
"integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -2137,9 +2137,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
|
||||||
"integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
|
"integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -2154,9 +2154,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||||
"integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
|
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -2168,9 +2168,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@tybys/wasm-util": {
|
"node_modules/@tybys/wasm-util": {
|
||||||
"version": "0.10.1",
|
"version": "0.10.2",
|
||||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||||
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
|
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
@@ -2218,12 +2218,12 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.6.2",
|
"version": "25.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
|
||||||
"integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==",
|
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.19.0"
|
"undici-types": ">=7.24.0 <7.24.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/nodemailer": {
|
"node_modules/@types/nodemailer": {
|
||||||
@@ -2270,14 +2270,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/coverage-v8": {
|
"node_modules/@vitest/coverage-v8": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz",
|
||||||
"integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==",
|
"integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bcoe/v8-coverage": "^1.0.2",
|
"@bcoe/v8-coverage": "^1.0.2",
|
||||||
"@vitest/utils": "4.1.5",
|
"@vitest/utils": "4.1.6",
|
||||||
"ast-v8-to-istanbul": "^1.0.0",
|
"ast-v8-to-istanbul": "^1.0.0",
|
||||||
"istanbul-lib-coverage": "^3.2.2",
|
"istanbul-lib-coverage": "^3.2.2",
|
||||||
"istanbul-lib-report": "^3.0.1",
|
"istanbul-lib-report": "^3.0.1",
|
||||||
@@ -2291,8 +2291,8 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@vitest/browser": "4.1.5",
|
"@vitest/browser": "4.1.6",
|
||||||
"vitest": "4.1.5"
|
"vitest": "4.1.6"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@vitest/browser": {
|
"@vitest/browser": {
|
||||||
@@ -2301,16 +2301,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
|
||||||
"integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
|
"integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.1.0",
|
"@standard-schema/spec": "^1.1.0",
|
||||||
"@types/chai": "^5.2.2",
|
"@types/chai": "^5.2.2",
|
||||||
"@vitest/spy": "4.1.5",
|
"@vitest/spy": "4.1.6",
|
||||||
"@vitest/utils": "4.1.5",
|
"@vitest/utils": "4.1.6",
|
||||||
"chai": "^6.2.2",
|
"chai": "^6.2.2",
|
||||||
"tinyrainbow": "^3.1.0"
|
"tinyrainbow": "^3.1.0"
|
||||||
},
|
},
|
||||||
@@ -2319,13 +2319,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/mocker": {
|
"node_modules/@vitest/mocker": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
|
||||||
"integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
|
"integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/spy": "4.1.5",
|
"@vitest/spy": "4.1.6",
|
||||||
"estree-walker": "^3.0.3",
|
"estree-walker": "^3.0.3",
|
||||||
"magic-string": "^0.30.21"
|
"magic-string": "^0.30.21"
|
||||||
},
|
},
|
||||||
@@ -2346,9 +2346,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/pretty-format": {
|
"node_modules/@vitest/pretty-format": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz",
|
||||||
"integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
|
"integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -2359,13 +2359,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/runner": {
|
"node_modules/@vitest/runner": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz",
|
||||||
"integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
|
"integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/utils": "4.1.5",
|
"@vitest/utils": "4.1.6",
|
||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -2373,14 +2373,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/snapshot": {
|
"node_modules/@vitest/snapshot": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz",
|
||||||
"integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
|
"integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/pretty-format": "4.1.5",
|
"@vitest/pretty-format": "4.1.6",
|
||||||
"@vitest/utils": "4.1.5",
|
"@vitest/utils": "4.1.6",
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
},
|
},
|
||||||
@@ -2389,9 +2389,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/spy": {
|
"node_modules/@vitest/spy": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz",
|
||||||
"integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
|
"integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -2399,13 +2399,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/utils": {
|
"node_modules/@vitest/utils": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz",
|
||||||
"integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
|
"integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/pretty-format": "4.1.5",
|
"@vitest/pretty-format": "4.1.6",
|
||||||
"convert-source-map": "^2.0.0",
|
"convert-source-map": "^2.0.0",
|
||||||
"tinyrainbow": "^3.1.0"
|
"tinyrainbow": "^3.1.0"
|
||||||
},
|
},
|
||||||
@@ -2536,9 +2536,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/brace-expansion": {
|
"node_modules/brace-expansion": {
|
||||||
"version": "5.0.5",
|
"version": "5.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"balanced-match": "^4.0.2"
|
"balanced-match": "^4.0.2"
|
||||||
@@ -4168,9 +4168,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.12",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -4411,9 +4411,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.12",
|
"version": "8.5.14",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||||
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
|
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -4548,14 +4548,14 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.0.0-rc.17",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
|
||||||
"integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
|
"integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "=0.127.0",
|
"@oxc-project/types": "=0.130.0",
|
||||||
"@rolldown/pluginutils": "1.0.0-rc.17"
|
"@rolldown/pluginutils": "^1.0.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"rolldown": "bin/cli.mjs"
|
"rolldown": "bin/cli.mjs"
|
||||||
@@ -4564,21 +4564,21 @@
|
|||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rolldown/binding-android-arm64": "1.0.0-rc.17",
|
"@rolldown/binding-android-arm64": "1.0.1",
|
||||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
|
"@rolldown/binding-darwin-arm64": "1.0.1",
|
||||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.17",
|
"@rolldown/binding-darwin-x64": "1.0.1",
|
||||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
|
"@rolldown/binding-freebsd-x64": "1.0.1",
|
||||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
|
"@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
|
||||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
|
"@rolldown/binding-linux-arm64-gnu": "1.0.1",
|
||||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
|
"@rolldown/binding-linux-arm64-musl": "1.0.1",
|
||||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
|
"@rolldown/binding-linux-ppc64-gnu": "1.0.1",
|
||||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
|
"@rolldown/binding-linux-s390x-gnu": "1.0.1",
|
||||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
|
"@rolldown/binding-linux-x64-gnu": "1.0.1",
|
||||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
|
"@rolldown/binding-linux-x64-musl": "1.0.1",
|
||||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
|
"@rolldown/binding-openharmony-arm64": "1.0.1",
|
||||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
|
"@rolldown/binding-wasm32-wasi": "1.0.1",
|
||||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
|
"@rolldown/binding-win32-arm64-msvc": "1.0.1",
|
||||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
|
"@rolldown/binding-win32-x64-msvc": "1.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/safe-regex2": {
|
"node_modules/safe-regex2": {
|
||||||
@@ -5032,14 +5032,13 @@
|
|||||||
"optional": true
|
"optional": true
|
||||||
},
|
},
|
||||||
"node_modules/tsx": {
|
"node_modules/tsx": {
|
||||||
"version": "4.21.0",
|
"version": "4.22.1",
|
||||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.1.tgz",
|
||||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
"integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"esbuild": "~0.27.0",
|
"esbuild": "~0.28.0"
|
||||||
"get-tsconfig": "^4.7.5"
|
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsx": "dist/cli.mjs"
|
"tsx": "dist/cli.mjs"
|
||||||
@@ -5080,9 +5079,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.19.2",
|
"version": "7.24.6",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/vary": {
|
"node_modules/vary": {
|
||||||
@@ -5095,16 +5094,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.10",
|
"version": "8.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
|
||||||
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
|
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
"postcss": "^8.5.10",
|
"postcss": "^8.5.14",
|
||||||
"rolldown": "1.0.0-rc.17",
|
"rolldown": "1.0.1",
|
||||||
"tinyglobby": "^0.2.16"
|
"tinyglobby": "^0.2.16"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -5121,7 +5120,7 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/node": "^20.19.0 || >=22.12.0",
|
"@types/node": "^20.19.0 || >=22.12.0",
|
||||||
"@vitejs/devtools": "^0.1.0",
|
"@vitejs/devtools": "^0.1.18",
|
||||||
"esbuild": "^0.27.0 || ^0.28.0",
|
"esbuild": "^0.27.0 || ^0.28.0",
|
||||||
"jiti": ">=1.21.0",
|
"jiti": ">=1.21.0",
|
||||||
"less": "^4.0.0",
|
"less": "^4.0.0",
|
||||||
@@ -5173,19 +5172,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vitest": {
|
"node_modules/vitest": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
|
||||||
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
|
"integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "4.1.5",
|
"@vitest/expect": "4.1.6",
|
||||||
"@vitest/mocker": "4.1.5",
|
"@vitest/mocker": "4.1.6",
|
||||||
"@vitest/pretty-format": "4.1.5",
|
"@vitest/pretty-format": "4.1.6",
|
||||||
"@vitest/runner": "4.1.5",
|
"@vitest/runner": "4.1.6",
|
||||||
"@vitest/snapshot": "4.1.5",
|
"@vitest/snapshot": "4.1.6",
|
||||||
"@vitest/spy": "4.1.5",
|
"@vitest/spy": "4.1.6",
|
||||||
"@vitest/utils": "4.1.5",
|
"@vitest/utils": "4.1.6",
|
||||||
"es-module-lexer": "^2.0.0",
|
"es-module-lexer": "^2.0.0",
|
||||||
"expect-type": "^1.3.0",
|
"expect-type": "^1.3.0",
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
@@ -5213,12 +5212,12 @@
|
|||||||
"@edge-runtime/vm": "*",
|
"@edge-runtime/vm": "*",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||||
"@vitest/browser-playwright": "4.1.5",
|
"@vitest/browser-playwright": "4.1.6",
|
||||||
"@vitest/browser-preview": "4.1.5",
|
"@vitest/browser-preview": "4.1.6",
|
||||||
"@vitest/browser-webdriverio": "4.1.5",
|
"@vitest/browser-webdriverio": "4.1.6",
|
||||||
"@vitest/coverage-istanbul": "4.1.5",
|
"@vitest/coverage-istanbul": "4.1.6",
|
||||||
"@vitest/coverage-v8": "4.1.5",
|
"@vitest/coverage-v8": "4.1.6",
|
||||||
"@vitest/ui": "4.1.5",
|
"@vitest/ui": "4.1.6",
|
||||||
"happy-dom": "*",
|
"happy-dom": "*",
|
||||||
"jsdom": "*",
|
"jsdom": "*",
|
||||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
@@ -5302,9 +5301,9 @@
|
|||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
"node_modules/ws": {
|
"node_modules/ws": {
|
||||||
"version": "8.20.0",
|
"version": "8.20.1",
|
||||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-backend",
|
"name": "medassist-ng-backend",
|
||||||
"version": "1.25.0",
|
"version": "1.26.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -41,14 +41,14 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.15",
|
"@biomejs/biome": "^2.4.15",
|
||||||
"@types/node": "^25.6.2",
|
"@types/node": "^25.8.0",
|
||||||
"@types/nodemailer": "^8.0.0",
|
"@types/nodemailer": "^8.0.0",
|
||||||
"@types/supertest": "^7.2.0",
|
"@types/supertest": "^7.2.0",
|
||||||
"@vitest/coverage-v8": "^4.1.5",
|
"@vitest/coverage-v8": "^4.1.6",
|
||||||
"drizzle-kit": "^0.31.10",
|
"drizzle-kit": "^0.31.10",
|
||||||
"pino-pretty": "^13.1.3",
|
"pino-pretty": "^13.1.3",
|
||||||
"supertest": "^7.2.2",
|
"supertest": "^7.2.2",
|
||||||
"tsx": "^4.19.0",
|
"tsx": "^4.22.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vitest": "^4.0.16"
|
"vitest": "^4.0.16"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_channel text`,
|
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_channel text`,
|
||||||
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_med_names 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 refill_history ADD COLUMN used_prescription integer NOT NULL DEFAULT 0`,
|
||||||
|
`ALTER TABLE share_tokens ADD COLUMN allow_journal_notes integer NOT NULL DEFAULT 0`,
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const sql of alterMigrations) {
|
for (const sql of alterMigrations) {
|
||||||
@@ -97,6 +98,16 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
loose_pills_added INTEGER NOT NULL DEFAULT 0,
|
loose_pills_added INTEGER NOT NULL DEFAULT 0,
|
||||||
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||||
)`,
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS intake_journal (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
dose_tracking_id INTEGER NOT NULL REFERENCES dose_tracking(id) ON DELETE CASCADE,
|
||||||
|
medication_id INTEGER NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
|
||||||
|
scheduled_for INTEGER NOT NULL,
|
||||||
|
note TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS notification_action_groups (
|
`CREATE TABLE IF NOT EXISTS notification_action_groups (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -164,6 +175,7 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
const createIndexMigrations = [
|
const createIndexMigrations = [
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`,
|
`CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`,
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`,
|
`CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`,
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS intake_journal_dose_tracking_id_unique ON intake_journal(dose_tracking_id)`,
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS notification_action_groups_group_key_unique ON notification_action_groups(group_key)`,
|
`CREATE UNIQUE INDEX IF NOT EXISTS notification_action_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 UNIQUE INDEX IF NOT EXISTS notification_action_tokens_token_hash_unique ON notification_action_tokens(token_hash)`,
|
||||||
`CREATE INDEX IF NOT EXISTS api_keys_user_id_idx ON api_keys(user_id)`,
|
`CREATE INDEX IF NOT EXISTS api_keys_user_id_idx ON api_keys(user_id)`,
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export function getTableCreationSQL(): string[] {
|
|||||||
token text NOT NULL UNIQUE,
|
token text NOT NULL UNIQUE,
|
||||||
taken_by text NOT NULL,
|
taken_by text NOT NULL,
|
||||||
schedule_days integer NOT NULL DEFAULT 30,
|
schedule_days integer NOT NULL DEFAULT 30,
|
||||||
|
allow_journal_notes integer NOT NULL DEFAULT 0,
|
||||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
expires_at integer,
|
expires_at integer,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
|||||||
@@ -180,6 +180,7 @@ export const shareTokens = sqliteTable("share_tokens", {
|
|||||||
token: text("token", { length: 64 }).notNull().unique(),
|
token: text("token", { length: 64 }).notNull().unique(),
|
||||||
takenBy: text("taken_by", { length: 100 }).notNull(),
|
takenBy: text("taken_by", { length: 100 }).notNull(),
|
||||||
scheduleDays: integer("schedule_days").notNull().default(30),
|
scheduleDays: integer("schedule_days").notNull().default(30),
|
||||||
|
allowJournalNotes: integer("allow_journal_notes", { mode: "boolean" }).notNull().default(false),
|
||||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires
|
expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires
|
||||||
});
|
});
|
||||||
@@ -236,6 +237,27 @@ export const doseTracking = sqliteTable("dose_tracking", {
|
|||||||
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // legacy column: true = intake skipped without stock deduction
|
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // legacy column: true = intake skipped without stock deduction
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// Intake Journal - Optional owner-scoped note for a tracked dose event
|
||||||
|
// =============================================================================
|
||||||
|
export const intakeJournal = sqliteTable("intake_journal", {
|
||||||
|
id: integer("id").primaryKey({ autoIncrement: true }),
|
||||||
|
userId: integer("user_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => users.id, { onDelete: "cascade" }),
|
||||||
|
doseTrackingId: integer("dose_tracking_id")
|
||||||
|
.notNull()
|
||||||
|
.unique()
|
||||||
|
.references(() => doseTracking.id, { onDelete: "cascade" }),
|
||||||
|
medicationId: integer("medication_id")
|
||||||
|
.notNull()
|
||||||
|
.references(() => medications.id, { onDelete: "cascade" }),
|
||||||
|
scheduledFor: integer("scheduled_for", { mode: "timestamp" }).notNull(),
|
||||||
|
note: text("note").notNull(),
|
||||||
|
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||||
|
});
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Refill History - Tracks when medication stock was refilled
|
// Refill History - Tracks when medication stock was refilled
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { authRoutes } from "./routes/auth.js";
|
|||||||
import { doseRoutes } from "./routes/doses.js";
|
import { doseRoutes } from "./routes/doses.js";
|
||||||
import { exportRoutes } from "./routes/export.js";
|
import { exportRoutes } from "./routes/export.js";
|
||||||
import { healthRoutes } from "./routes/health.js";
|
import { healthRoutes } from "./routes/health.js";
|
||||||
|
import { intakeJournalRoutes } from "./routes/intake-journal.js";
|
||||||
import { medicationEnrichmentRoutes } from "./routes/medication-enrichment.js";
|
import { medicationEnrichmentRoutes } from "./routes/medication-enrichment.js";
|
||||||
import { medicationRoutes } from "./routes/medications.js";
|
import { medicationRoutes } from "./routes/medications.js";
|
||||||
import { notificationActionRoutes } from "./routes/notification-actions.js";
|
import { notificationActionRoutes } from "./routes/notification-actions.js";
|
||||||
@@ -109,6 +110,7 @@ async function registerApiDocs(app: FastifyInstance, enabled: boolean) {
|
|||||||
{ name: "health", description: "Service health endpoints" },
|
{ name: "health", description: "Service health endpoints" },
|
||||||
{ name: "auth", description: "Authentication and profile endpoints" },
|
{ name: "auth", description: "Authentication and profile endpoints" },
|
||||||
{ name: "api-keys", description: "Programmatic API key management" },
|
{ name: "api-keys", description: "Programmatic API key management" },
|
||||||
|
{ name: "intake-journal", description: "Owner-only intake journal CRUD and history endpoints" },
|
||||||
{ name: "medication-enrichment", description: "Medication search and enrichment endpoints" },
|
{ name: "medication-enrichment", description: "Medication search and enrichment endpoints" },
|
||||||
{ name: "settings", description: "User settings and notification test endpoints" },
|
{ name: "settings", description: "User settings and notification test endpoints" },
|
||||||
],
|
],
|
||||||
@@ -248,6 +250,7 @@ export async function createApp(options?: {
|
|||||||
await app.register(notificationActionRoutes);
|
await app.register(notificationActionRoutes);
|
||||||
await app.register(shareRoutes);
|
await app.register(shareRoutes);
|
||||||
await app.register(doseRoutes);
|
await app.register(doseRoutes);
|
||||||
|
await app.register(intakeJournalRoutes);
|
||||||
await app.register(exportRoutes);
|
await app.register(exportRoutes);
|
||||||
await app.register(refillRoutes);
|
await app.register(refillRoutes);
|
||||||
await app.register(reportRoutes);
|
await app.register(reportRoutes);
|
||||||
@@ -349,6 +352,7 @@ await app.register(plannerRoutes);
|
|||||||
await app.register(notificationActionRoutes);
|
await app.register(notificationActionRoutes);
|
||||||
await app.register(shareRoutes);
|
await app.register(shareRoutes);
|
||||||
await app.register(doseRoutes);
|
await app.register(doseRoutes);
|
||||||
|
await app.register(intakeJournalRoutes);
|
||||||
await app.register(exportRoutes);
|
await app.register(exportRoutes);
|
||||||
await app.register(refillRoutes);
|
await app.register(refillRoutes);
|
||||||
await app.register(reportRoutes);
|
await app.register(reportRoutes);
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ async function tryApiKeyAuth(request: FastifyRequest, reply: FastifyReply): Prom
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [user] = await db.select().from(users).where(eq(users.id, keyRow.userId));
|
const [user] = await db.select().from(users).where(eq(users.id, keyRow.userId));
|
||||||
if (!user || !user.isActive) {
|
if (!user?.isActive) {
|
||||||
reply.status(401).send({ error: "User not found", code: "USER_NOT_FOUND" });
|
reply.status(401).send({ error: "User not found", code: "USER_NOT_FOUND" });
|
||||||
throw new Error("USER_NOT_FOUND");
|
throw new Error("USER_NOT_FOUND");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -438,7 +438,7 @@ export async function authRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
// Get user
|
// Get user
|
||||||
const [user] = await db.select().from(users).where(eq(users.id, decoded.sub));
|
const [user] = await db.select().from(users).where(eq(users.id, decoded.sub));
|
||||||
if (!user || !user.isActive) {
|
if (!user?.isActive) {
|
||||||
return reply.status(401).send({ error: "User not found or disabled", code: "USER_INVALID" });
|
return reply.status(401).send({ error: "User not found or disabled", code: "USER_INVALID" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+567
-37
@@ -1,19 +1,26 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, inArray } from "drizzle-orm";
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { doseTracking, medications, shareTokens, userSettings } from "../db/schema.js";
|
import { doseTracking, intakeJournal, medications, shareTokens, userSettings } from "../db/schema.js";
|
||||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
import { computeMedicationCurrentStock } from "../services/current-stock.js";
|
import { computeMedicationCurrentStock } from "../services/current-stock.js";
|
||||||
import { dismissDosesForUser, markDoseTakenForUser } from "../services/dose-tracking-service.js";
|
import { markDoseTakenForUser } from "../services/dose-tracking-service.js";
|
||||||
|
import {
|
||||||
|
getIntakeJournalForDoseEvent,
|
||||||
|
resolveTrackedDoseEventForUser,
|
||||||
|
upsertIntakeJournalForDoseEvent,
|
||||||
|
} from "../services/intake-journal-service.js";
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
|
import { toLocalDateTimeOffsetString } from "../utils/local-date-time.js";
|
||||||
import {
|
import {
|
||||||
applyOpenApiRouteStandards,
|
applyOpenApiRouteStandards,
|
||||||
genericErrorSchema,
|
genericErrorSchema,
|
||||||
tokenParamsSchema,
|
tokenParamsSchema,
|
||||||
validationErrorSchema,
|
validationErrorSchema,
|
||||||
} from "../utils/openapi-route-standards.js";
|
} from "../utils/openapi-route-standards.js";
|
||||||
|
import { redactTokenForLog } from "../utils/redaction.js";
|
||||||
import {
|
import {
|
||||||
parseIntakesJson,
|
parseIntakesJson,
|
||||||
parseLocalDateTime,
|
parseLocalDateTime,
|
||||||
@@ -32,6 +39,10 @@ const shareDoseSchema = z.object({
|
|||||||
doseId: z.string().min(1, "doseId is required"),
|
doseId: z.string().min(1, "doseId is required"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const shareJournalUpsertSchema = z.object({
|
||||||
|
note: z.string().max(4000),
|
||||||
|
});
|
||||||
|
|
||||||
const dismissDosesSchema = z.object({
|
const dismissDosesSchema = z.object({
|
||||||
doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"),
|
doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"),
|
||||||
});
|
});
|
||||||
@@ -56,12 +67,52 @@ const doseReadResponseSchema = {
|
|||||||
markedBy: { type: ["string", "null"] },
|
markedBy: { type: ["string", "null"] },
|
||||||
takenSource: { type: "string" },
|
takenSource: { type: "string" },
|
||||||
dismissed: { type: "boolean" },
|
dismissed: { type: "boolean" },
|
||||||
|
hasJournalNote: { type: "boolean" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const shareJournalEntrySchema = {
|
||||||
|
type: "object",
|
||||||
|
required: [
|
||||||
|
"doseTrackingId",
|
||||||
|
"doseId",
|
||||||
|
"medicationId",
|
||||||
|
"medicationName",
|
||||||
|
"scheduledFor",
|
||||||
|
"dismissed",
|
||||||
|
"takenSource",
|
||||||
|
"note",
|
||||||
|
"updatedAt",
|
||||||
|
],
|
||||||
|
properties: {
|
||||||
|
doseTrackingId: { type: "integer" },
|
||||||
|
doseId: { type: "string" },
|
||||||
|
medicationId: { type: "integer" },
|
||||||
|
medicationName: { type: "string" },
|
||||||
|
scheduledFor: { type: "string", format: "date-time" },
|
||||||
|
takenAt: { type: ["string", "null"], format: "date-time" },
|
||||||
|
dismissed: { type: "boolean" },
|
||||||
|
takenSource: { type: "string", enum: ["manual", "automatic"] },
|
||||||
|
markedBy: { type: ["string", "null"] },
|
||||||
|
note: { type: ["string", "null"] },
|
||||||
|
updatedAt: { type: ["string", "null"], format: "date-time" },
|
||||||
|
createdAt: { type: ["string", "null"], format: "date-time" },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const shareJournalResponseSchema = {
|
||||||
|
type: "object",
|
||||||
|
required: ["entry"],
|
||||||
|
properties: {
|
||||||
|
entry: shareJournalEntrySchema,
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
function getValidationErrorMessage(error: z.ZodError): string {
|
function getValidationErrorMessage(error: z.ZodError): string {
|
||||||
const firstIssue = error.issues[0];
|
const firstIssue = error.issues[0];
|
||||||
if (!firstIssue) {
|
if (!firstIssue) {
|
||||||
@@ -71,6 +122,18 @@ function getValidationErrorMessage(error: z.ZodError): string {
|
|||||||
return firstIssue.code === "invalid_type" && firstIssue.input === undefined ? "Required" : firstIssue.message;
|
return firstIssue.code === "invalid_type" && firstIssue.input === undefined ? "Required" : firstIssue.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serializeJournalTakenAt(value: Date | null, dismissed: boolean): string | null {
|
||||||
|
if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dismissed && value.getTime() <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
// Helper to get user ID from request
|
// Helper to get user ID from request
|
||||||
// Returns anonymous user ID when auth is disabled
|
// Returns anonymous user ID when auth is disabled
|
||||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||||
@@ -135,6 +198,10 @@ async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseI
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!isDoseInsideShareScheduleWindow(share, parsedDose)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
const [medication] = await db
|
const [medication] = await db
|
||||||
.select()
|
.select()
|
||||||
.from(medications)
|
.from(medications)
|
||||||
@@ -172,6 +239,24 @@ async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseI
|
|||||||
return expectedPersons.includes(parsedDose.personSuffix);
|
return expectedPersons.includes(parsedDose.personSuffix);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getLocalDayStartMs(value: Date | number): number {
|
||||||
|
const date = typeof value === "number" ? new Date(value) : new Date(value.getTime());
|
||||||
|
date.setHours(0, 0, 0, 0);
|
||||||
|
return date.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDoseInsideShareScheduleWindow(share: typeof shareTokens.$inferSelect, parsedDose: ParsedDoseId): boolean {
|
||||||
|
const scheduleDays = Math.max(1, share.scheduleDays ?? 30);
|
||||||
|
const todayStart = getLocalDayStartMs(new Date());
|
||||||
|
const earliestVisible = new Date(todayStart);
|
||||||
|
earliestVisible.setDate(earliestVisible.getDate() - (scheduleDays - 1));
|
||||||
|
const latestVisibleExclusive = new Date(todayStart);
|
||||||
|
latestVisibleExclusive.setDate(latestVisibleExclusive.getDate() + scheduleDays);
|
||||||
|
const doseDayStart = getLocalDayStartMs(parsedDose.timestampMs);
|
||||||
|
|
||||||
|
return doseDayStart >= earliestVisible.getTime() && doseDayStart < latestVisibleExclusive.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
async function isDoseOutOfStock(options: {
|
async function isDoseOutOfStock(options: {
|
||||||
userId: number;
|
userId: number;
|
||||||
doseId: string;
|
doseId: string;
|
||||||
@@ -226,6 +311,81 @@ async function isDoseOutOfStock(options: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function markDoseSkippedForUser(input: {
|
||||||
|
userId: number;
|
||||||
|
doseId: string;
|
||||||
|
}): Promise<"created" | "updated" | "already_skipped"> {
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(doseTracking)
|
||||||
|
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)));
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
if (existing.dismissed) {
|
||||||
|
return "already_skipped";
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(doseTracking)
|
||||||
|
.set({ dismissed: true })
|
||||||
|
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)));
|
||||||
|
return "updated";
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.insert(doseTracking).values({
|
||||||
|
userId: input.userId,
|
||||||
|
doseId: input.doseId,
|
||||||
|
markedBy: null,
|
||||||
|
takenAt: new Date(0),
|
||||||
|
dismissed: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return "created";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function undoDoseSkippedForUser(input: { userId: number; doseId: string }): Promise<boolean> {
|
||||||
|
const [existing] = await db
|
||||||
|
.select()
|
||||||
|
.from(doseTracking)
|
||||||
|
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)));
|
||||||
|
|
||||||
|
if (!existing?.dismissed) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasRealTakenTimestamp =
|
||||||
|
existing.takenAt instanceof Date ? existing.takenAt.getTime() > 0 : Boolean(existing.takenAt);
|
||||||
|
if (existing.markedBy !== null || hasRealTakenTimestamp) {
|
||||||
|
await db.update(doseTracking).set({ dismissed: false }).where(eq(doseTracking.id, existing.id));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(doseTracking).where(eq(doseTracking.id, existing.id));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSharedJournalEntryDto(input: {
|
||||||
|
event: NonNullable<Awaited<ReturnType<typeof resolveTrackedDoseEventForUser>>>;
|
||||||
|
journalEntry: Awaited<ReturnType<typeof getIntakeJournalForDoseEvent>>;
|
||||||
|
}) {
|
||||||
|
const { event, journalEntry } = input;
|
||||||
|
|
||||||
|
return {
|
||||||
|
doseTrackingId: event.doseTrackingId,
|
||||||
|
doseId: event.doseId,
|
||||||
|
medicationId: event.medicationId,
|
||||||
|
medicationName: event.medicationName,
|
||||||
|
scheduledFor: toLocalDateTimeOffsetString(journalEntry?.scheduledFor ?? event.scheduledFor),
|
||||||
|
takenAt: serializeJournalTakenAt(event.takenAt, event.dismissed),
|
||||||
|
dismissed: event.dismissed,
|
||||||
|
takenSource: event.takenSource,
|
||||||
|
markedBy: event.markedBy,
|
||||||
|
note: journalEntry?.note ?? null,
|
||||||
|
updatedAt: journalEntry?.updatedAt?.toISOString() ?? null,
|
||||||
|
createdAt: journalEntry?.createdAt?.toISOString() ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Dose Tracking Routes
|
// Dose Tracking Routes
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -233,7 +393,13 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
applyOpenApiRouteStandards(app, {
|
applyOpenApiRouteStandards(app, {
|
||||||
tag: "doses",
|
tag: "doses",
|
||||||
protectedByDefault: false,
|
protectedByDefault: false,
|
||||||
protectedPaths: [/^\/doses\/taken$/, /^\/doses\/taken\/:doseId$/, /^\/doses\/dismiss$/],
|
protectedPaths: [
|
||||||
|
/^\/doses\/taken$/,
|
||||||
|
/^\/doses\/taken\/:doseId$/,
|
||||||
|
/^\/doses\/dismiss$/,
|
||||||
|
/^\/doses\/skip$/,
|
||||||
|
/^\/doses\/skip\/:doseId$/,
|
||||||
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -383,6 +549,83 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /doses/skip - PROTECTED: Mark a single dose as skipped
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.post<{ Body: z.infer<typeof markDoseSchema> }>(
|
||||||
|
"/doses/skip",
|
||||||
|
{
|
||||||
|
preHandler: requireAuth,
|
||||||
|
schema: {
|
||||||
|
tags: ["doses"],
|
||||||
|
security: protectedEndpointSecurity,
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
required: ["doseId"],
|
||||||
|
properties: {
|
||||||
|
doseId: { type: "string", minLength: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
success: { type: "boolean" },
|
||||||
|
message: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||||
|
401: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = await getUserId(request, reply);
|
||||||
|
const parsed = markDoseSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await markDoseSkippedForUser({ userId, doseId: parsed.data.doseId });
|
||||||
|
if (status === "already_skipped") {
|
||||||
|
return { success: true, message: "Already skipped" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DELETE /doses/skip/:doseId - PROTECTED: Undo a single skipped dose
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.delete<{ Params: { doseId: string } }>(
|
||||||
|
"/doses/skip/:doseId",
|
||||||
|
{
|
||||||
|
preHandler: requireAuth,
|
||||||
|
schema: {
|
||||||
|
tags: ["doses"],
|
||||||
|
security: protectedEndpointSecurity,
|
||||||
|
params: {
|
||||||
|
type: "object",
|
||||||
|
required: ["doseId"],
|
||||||
|
properties: {
|
||||||
|
doseId: { type: "string", minLength: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: { type: "object", properties: { success: { type: "boolean" } } },
|
||||||
|
401: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = await getUserId(request, reply);
|
||||||
|
await undoDoseSkippedForUser({ userId, doseId: request.params.doseId });
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /doses/dismiss - PROTECTED: Dismiss missed doses without deducting stock
|
// POST /doses/dismiss - PROTECTED: Dismiss missed doses without deducting stock
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -431,27 +674,8 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
// becomes dismissed, regardless of whether it already has a taken timestamp.
|
// becomes dismissed, regardless of whether it already has a taken timestamp.
|
||||||
let dismissedCount = 0;
|
let dismissedCount = 0;
|
||||||
for (const doseId of doseIds) {
|
for (const doseId of doseIds) {
|
||||||
const [existing] = await db
|
const status = await markDoseSkippedForUser({ userId, doseId });
|
||||||
.select()
|
if (status !== "already_skipped") {
|
||||||
.from(doseTracking)
|
|
||||||
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
if (!existing.dismissed) {
|
|
||||||
await db
|
|
||||||
.update(doseTracking)
|
|
||||||
.set({ dismissed: true })
|
|
||||||
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
|
|
||||||
dismissedCount++;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await db.insert(doseTracking).values({
|
|
||||||
userId,
|
|
||||||
doseId,
|
|
||||||
markedBy: null,
|
|
||||||
takenAt: new Date(0),
|
|
||||||
dismissed: true,
|
|
||||||
});
|
|
||||||
dismissedCount++;
|
dismissedCount++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -533,28 +757,332 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const { token } = request.params;
|
const { token } = request.params;
|
||||||
|
const tokenRef = redactTokenForLog(token);
|
||||||
|
|
||||||
const { share, reason } = await getActiveShareToken(token);
|
const { share, reason } = await getActiveShareToken(token);
|
||||||
if (!share) {
|
if (!share) {
|
||||||
request.log.warn(`[ShareDose] Rejected read: token=${token}, reason=${reason}`);
|
request.log.warn(`[ShareDose] Rejected read: tokenRef=${tokenRef}, reason=${reason}`);
|
||||||
return reply.notFound("Share link not found");
|
return reply.notFound("Share link not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get all taken doses for this user (no time limit)
|
// Keep public dose reads scoped to the selected share person and visible schedule window.
|
||||||
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
|
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
|
||||||
|
const visibleDoses: (typeof doseTracking.$inferSelect)[] = [];
|
||||||
|
for (const dose of doses) {
|
||||||
|
if (await validateShareDoseId(share, dose.doseId)) {
|
||||||
|
visibleDoses.push(dose);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const journalDoseTrackingIds = new Set<number>();
|
||||||
|
if ((share.allowJournalNotes ?? false) && visibleDoses.length > 0) {
|
||||||
|
const journalRows = await db
|
||||||
|
.select({ doseTrackingId: intakeJournal.doseTrackingId })
|
||||||
|
.from(intakeJournal)
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(intakeJournal.userId, share.userId),
|
||||||
|
inArray(
|
||||||
|
intakeJournal.doseTrackingId,
|
||||||
|
visibleDoses.map((dose) => dose.id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const row of journalRows) {
|
||||||
|
journalDoseTrackingIds.add(row.doseTrackingId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
doses: doses.map((d) => ({
|
doses: visibleDoses.map((d) => ({
|
||||||
doseId: d.doseId,
|
doseId: d.doseId,
|
||||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||||
markedBy: d.markedBy,
|
markedBy: d.markedBy,
|
||||||
takenSource: d.takenSource ?? "manual",
|
takenSource: d.takenSource ?? "manual",
|
||||||
dismissed: d.dismissed ?? false,
|
dismissed: d.dismissed ?? false,
|
||||||
|
hasJournalNote: journalDoseTrackingIds.has(d.id),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
app.get<{ Params: { token: string; doseId: string } }>(
|
||||||
|
"/share/:token/journal/event/:doseId",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: "object",
|
||||||
|
required: ["token", "doseId"],
|
||||||
|
properties: {
|
||||||
|
token: tokenParamsSchema.properties.token,
|
||||||
|
doseId: { type: "string", minLength: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: shareJournalResponseSchema,
|
||||||
|
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||||
|
403: genericErrorSchema,
|
||||||
|
404: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const { token, doseId } = request.params;
|
||||||
|
const tokenRef = redactTokenForLog(token);
|
||||||
|
|
||||||
|
const { share, reason } = await getActiveShareToken(token);
|
||||||
|
if (!share) {
|
||||||
|
request.log.warn(`[ShareJournal] Rejected read: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
|
||||||
|
return reply.notFound("Share link not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(share.allowJournalNotes ?? false)) {
|
||||||
|
return reply
|
||||||
|
.status(403)
|
||||||
|
.send({ error: "Journal notes are not enabled for this share link", code: "NOT_ENABLED" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
||||||
|
if (!isValidShareDoseId) {
|
||||||
|
return reply.status(400).send({ error: "Invalid or unauthorized doseId", code: "INVALID_DOSE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await resolveTrackedDoseEventForUser({ userId: share.userId, doseId });
|
||||||
|
if (!event) {
|
||||||
|
return reply
|
||||||
|
.status(404)
|
||||||
|
.send({ error: "Tracked dose event not found for this share link", code: "DOSE_NOT_FOUND" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const journalEntry = await getIntakeJournalForDoseEvent({ userId: share.userId, doseId });
|
||||||
|
return { entry: buildSharedJournalEntryDto({ event, journalEntry }) };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.put<{ Params: { token: string; doseId: string }; Body: z.infer<typeof shareJournalUpsertSchema> }>(
|
||||||
|
"/share/:token/journal/event/:doseId",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: "object",
|
||||||
|
required: ["token", "doseId"],
|
||||||
|
properties: {
|
||||||
|
token: tokenParamsSchema.properties.token,
|
||||||
|
doseId: { type: "string", minLength: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
required: ["note"],
|
||||||
|
properties: {
|
||||||
|
note: { type: "string", maxLength: 4000 },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: shareJournalResponseSchema,
|
||||||
|
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||||
|
403: genericErrorSchema,
|
||||||
|
404: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const { token, doseId } = request.params;
|
||||||
|
const tokenRef = redactTokenForLog(token);
|
||||||
|
|
||||||
|
const parsed = shareJournalUpsertSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error), code: "VALIDATION_ERROR" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedNote = parsed.data.note.trim();
|
||||||
|
if (normalizedNote.length === 0) {
|
||||||
|
return reply.status(400).send({ error: "Journal note cannot be empty", code: "EMPTY_NOTE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { share, reason } = await getActiveShareToken(token);
|
||||||
|
if (!share) {
|
||||||
|
request.log.warn(`[ShareJournal] Rejected save: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
|
||||||
|
return reply.notFound("Share link not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(share.allowJournalNotes ?? false)) {
|
||||||
|
return reply
|
||||||
|
.status(403)
|
||||||
|
.send({ error: "Journal notes are not enabled for this share link", code: "NOT_ENABLED" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
||||||
|
if (!isValidShareDoseId) {
|
||||||
|
return reply.status(400).send({ error: "Invalid or unauthorized doseId", code: "INVALID_DOSE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await resolveTrackedDoseEventForUser({ userId: share.userId, doseId });
|
||||||
|
if (!event) {
|
||||||
|
return reply
|
||||||
|
.status(404)
|
||||||
|
.send({ error: "Tracked dose event not found for this share link", code: "DOSE_NOT_FOUND" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const journalEntry = await upsertIntakeJournalForDoseEvent({
|
||||||
|
userId: share.userId,
|
||||||
|
doseId,
|
||||||
|
note: normalizedNote,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { entry: buildSharedJournalEntryDto({ event, journalEntry }) };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { token: string; doseId: string } }>(
|
||||||
|
"/share/:token/journal/event/:doseId",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: "object",
|
||||||
|
required: ["token", "doseId"],
|
||||||
|
properties: {
|
||||||
|
token: tokenParamsSchema.properties.token,
|
||||||
|
doseId: { type: "string", minLength: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
403: genericErrorSchema,
|
||||||
|
404: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const { token, doseId } = request.params;
|
||||||
|
const tokenRef = redactTokenForLog(token);
|
||||||
|
|
||||||
|
const { share, reason } = await getActiveShareToken(token);
|
||||||
|
if (!share) {
|
||||||
|
request.log.warn(`[ShareJournal] Rejected delete: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
|
||||||
|
return reply.notFound("Share link not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!(share.allowJournalNotes ?? false)) {
|
||||||
|
return reply
|
||||||
|
.status(403)
|
||||||
|
.send({ error: "Journal notes are not enabled for this share link", code: "NOT_ENABLED" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return reply.status(403).send({ error: "Shared links cannot delete journal notes", code: "DELETE_NOT_ALLOWED" });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /share/:token/doses/skip - PUBLIC: Mark a dose as skipped via share link
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.post<{ Params: { token: string }; Body: z.infer<typeof shareDoseSchema> }>(
|
||||||
|
"/share/:token/doses/skip",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: tokenParamsSchema,
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
required: ["doseId"],
|
||||||
|
properties: {
|
||||||
|
doseId: { type: "string", minLength: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
success: { type: "boolean" },
|
||||||
|
message: { type: "string" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||||
|
404: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const { token } = request.params;
|
||||||
|
const tokenRef = redactTokenForLog(token);
|
||||||
|
|
||||||
|
const parsed = shareDoseSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { doseId } = parsed.data;
|
||||||
|
const { share, reason } = await getActiveShareToken(token);
|
||||||
|
if (!share) {
|
||||||
|
request.log.warn(`[ShareDose] Rejected skip: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
|
||||||
|
return reply.notFound("Share link not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
||||||
|
if (!isValidShareDoseId) {
|
||||||
|
request.log.warn(
|
||||||
|
`[ShareDose] Rejected invalid doseId in skip request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||||
|
);
|
||||||
|
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const status = await markDoseSkippedForUser({ userId: share.userId, doseId });
|
||||||
|
if (status === "already_skipped") {
|
||||||
|
return { success: true, message: "Already skipped" };
|
||||||
|
}
|
||||||
|
|
||||||
|
request.log.info(
|
||||||
|
`[ShareDose] Dose skipped via share link: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||||
|
);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DELETE /share/:token/doses/skip/:doseId - PUBLIC: Undo a skipped dose via share link
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.delete<{ Params: { token: string; doseId: string } }>(
|
||||||
|
"/share/:token/doses/skip/:doseId",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
params: {
|
||||||
|
type: "object",
|
||||||
|
required: ["token", "doseId"],
|
||||||
|
properties: {
|
||||||
|
token: tokenParamsSchema.properties.token,
|
||||||
|
doseId: { type: "string", minLength: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: { type: "object", properties: { success: { type: "boolean" } } },
|
||||||
|
400: genericErrorSchema,
|
||||||
|
404: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const { token, doseId } = request.params;
|
||||||
|
const tokenRef = redactTokenForLog(token);
|
||||||
|
|
||||||
|
const { share, reason } = await getActiveShareToken(token);
|
||||||
|
if (!share) {
|
||||||
|
request.log.warn(`[ShareDose] Rejected undo skip: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
|
||||||
|
return reply.notFound("Share link not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
||||||
|
if (!isValidShareDoseId) {
|
||||||
|
request.log.warn(
|
||||||
|
`[ShareDose] Rejected invalid doseId in undo skip request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||||
|
);
|
||||||
|
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
|
||||||
|
}
|
||||||
|
|
||||||
|
await undoDoseSkippedForUser({ userId: share.userId, doseId });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /share/:token/doses - PUBLIC: Mark a dose as taken via share link
|
// POST /share/:token/doses - PUBLIC: Mark a dose as taken via share link
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -582,6 +1110,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const { token } = request.params;
|
const { token } = request.params;
|
||||||
|
const tokenRef = redactTokenForLog(token);
|
||||||
|
|
||||||
const parsed = shareDoseSchema.safeParse(request.body);
|
const parsed = shareDoseSchema.safeParse(request.body);
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
@@ -594,14 +1123,14 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const { share, reason } = await getActiveShareToken(token);
|
const { share, reason } = await getActiveShareToken(token);
|
||||||
if (!share) {
|
if (!share) {
|
||||||
request.log.warn(`[ShareDose] Rejected mark: token=${token}, doseId=${doseId}, reason=${reason}`);
|
request.log.warn(`[ShareDose] Rejected mark: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
|
||||||
return reply.notFound("Share link not found");
|
return reply.notFound("Share link not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
||||||
if (!isValidShareDoseId) {
|
if (!isValidShareDoseId) {
|
||||||
request.log.warn(
|
request.log.warn(
|
||||||
`[ShareDose] Rejected invalid doseId in mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
`[ShareDose] Rejected invalid doseId in mark request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||||
);
|
);
|
||||||
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
|
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
|
||||||
}
|
}
|
||||||
@@ -614,7 +1143,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
request.log.debug(
|
request.log.debug(
|
||||||
`[ShareDose] Duplicate mark ignored: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
`[ShareDose] Duplicate mark ignored: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||||
);
|
);
|
||||||
return { success: true, message: "Already marked" };
|
return { success: true, message: "Already marked" };
|
||||||
}
|
}
|
||||||
@@ -627,7 +1156,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
if (outOfStock) {
|
if (outOfStock) {
|
||||||
request.log.info(
|
request.log.info(
|
||||||
`[ShareDose] Rejected out-of-stock mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
`[ShareDose] Rejected out-of-stock mark request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||||
);
|
);
|
||||||
return reply.status(409).send({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
|
return reply.status(409).send({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
|
||||||
}
|
}
|
||||||
@@ -644,7 +1173,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
request.log.info(
|
request.log.info(
|
||||||
`[ShareDose] Dose marked via share link: token=${token}, ownerUserId=${share.userId}, shareTakenBy=${share.takenBy}, markedBy=${markedBy}, doseId=${doseId}`
|
`[ShareDose] Dose marked via share link: tokenRef=${tokenRef}, ownerUserId=${share.userId}, shareTakenBy=${share.takenBy}, markedBy=${markedBy}, doseId=${doseId}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
@@ -675,17 +1204,18 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const { token, doseId } = request.params;
|
const { token, doseId } = request.params;
|
||||||
|
const tokenRef = redactTokenForLog(token);
|
||||||
|
|
||||||
const { share, reason } = await getActiveShareToken(token);
|
const { share, reason } = await getActiveShareToken(token);
|
||||||
if (!share) {
|
if (!share) {
|
||||||
request.log.warn(`[ShareDose] Rejected unmark: token=${token}, doseId=${doseId}, reason=${reason}`);
|
request.log.warn(`[ShareDose] Rejected unmark: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
|
||||||
return reply.notFound("Share link not found");
|
return reply.notFound("Share link not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
||||||
if (!isValidShareDoseId) {
|
if (!isValidShareDoseId) {
|
||||||
request.log.warn(
|
request.log.warn(
|
||||||
`[ShareDose] Rejected invalid doseId in unmark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
`[ShareDose] Rejected invalid doseId in unmark request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||||
);
|
);
|
||||||
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
|
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
|
||||||
}
|
}
|
||||||
@@ -699,7 +1229,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
if (existing?.dismissed) {
|
if (existing?.dismissed) {
|
||||||
// Already dismissed - keep the record as-is
|
// Already dismissed - keep the record as-is
|
||||||
request.log.debug(
|
request.log.debug(
|
||||||
`[ShareDose] Unmark ignored for dismissed dose: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
`[ShareDose] Unmark ignored for dismissed dose: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Not dismissed - delete the record entirely
|
// Not dismissed - delete the record entirely
|
||||||
@@ -707,7 +1237,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
|||||||
.delete(doseTracking)
|
.delete(doseTracking)
|
||||||
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
|
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
|
||||||
request.log.info(
|
request.log.info(
|
||||||
`[ShareDose] Dose unmarked via share link: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
`[ShareDose] Dose unmarked via share link: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+373
-179
@@ -6,9 +6,13 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
import { getDataDir } from "../db/path-utils.js";
|
import { getDataDir } from "../db/path-utils.js";
|
||||||
import { doseTracking, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js";
|
import { doseTracking, intakeJournal, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js";
|
||||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||||
import { env } from "../plugins/env.js";
|
import { env } from "../plugins/env.js";
|
||||||
|
import {
|
||||||
|
listIntakeJournalExportPayloadsForUser,
|
||||||
|
restoreIntakeJournalForImportedDose,
|
||||||
|
} from "../services/intake-journal-export.js";
|
||||||
import type { AuthUser } from "../types/fastify.js";
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
import {
|
import {
|
||||||
applyOpenApiRouteStandards,
|
applyOpenApiRouteStandards,
|
||||||
@@ -23,7 +27,7 @@ const IMAGES_DIR = resolve(getDataDir(), "images");
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Export Format Version (bump this when format changes)
|
// Export Format Version (bump this when format changes)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
const EXPORT_VERSION = "1.5";
|
const EXPORT_VERSION = "1.6";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Zod Schemas for Import Validation
|
// Zod Schemas for Import Validation
|
||||||
@@ -91,6 +95,9 @@ const doseHistorySchema = z.object({
|
|||||||
takenSource: z.enum(["manual", "automatic"]).default("manual"),
|
takenSource: z.enum(["manual", "automatic"]).default("manual"),
|
||||||
dismissed: z.boolean().default(false),
|
dismissed: z.boolean().default(false),
|
||||||
takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel")
|
takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel")
|
||||||
|
journalNote: z.string().nullable().optional(),
|
||||||
|
journalCreatedAt: z.string().nullable().optional(),
|
||||||
|
journalUpdatedAt: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const refillHistoryExportSchema = z.object({
|
const refillHistoryExportSchema = z.object({
|
||||||
@@ -105,6 +112,7 @@ const refillHistoryExportSchema = z.object({
|
|||||||
const shareLinkSchema = z.object({
|
const shareLinkSchema = z.object({
|
||||||
takenBy: z.string().min(1),
|
takenBy: z.string().min(1),
|
||||||
scheduleDays: z.number().int().min(1).default(30),
|
scheduleDays: z.number().int().min(1).default(30),
|
||||||
|
allowJournalNotes: z.boolean().default(false),
|
||||||
expiresAt: z.string().nullable().optional(), // ISO datetime
|
expiresAt: z.string().nullable().optional(), // ISO datetime
|
||||||
regenerateToken: z.boolean().default(true),
|
regenerateToken: z.boolean().default(true),
|
||||||
});
|
});
|
||||||
@@ -140,8 +148,6 @@ const settingsSchemaBase = z.object({
|
|||||||
shareMedicationOverview: z.boolean().default(false),
|
shareMedicationOverview: z.boolean().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
const exportSettingsSchema = settingsSchemaBase.optional();
|
|
||||||
|
|
||||||
const importSettingsSchema = settingsSchemaBase
|
const importSettingsSchema = settingsSchemaBase
|
||||||
.extend({
|
.extend({
|
||||||
// Accept the removed field from legacy exports so old backups still import,
|
// Accept the removed field from legacy exports so old backups still import,
|
||||||
@@ -197,7 +203,7 @@ const importBodyOpenApiSchema = {
|
|||||||
shareLinks: { type: "array", items: { type: "object", additionalProperties: true } },
|
shareLinks: { type: "array", items: { type: "object", additionalProperties: true } },
|
||||||
},
|
},
|
||||||
example: {
|
example: {
|
||||||
version: "1.8.0",
|
version: "1.6",
|
||||||
exportedAt: "2026-03-11T10:15:00.000Z",
|
exportedAt: "2026-03-11T10:15:00.000Z",
|
||||||
includeSensitiveData: true,
|
includeSensitiveData: true,
|
||||||
medications: [
|
medications: [
|
||||||
@@ -217,13 +223,72 @@ const importBodyOpenApiSchema = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
doseHistory: [{ doseId: "1:2026-03-11T08:00:00.000Z:Daniel", takenAt: 1773216000000 }],
|
doseHistory: [
|
||||||
|
{
|
||||||
|
medicationRef: "med-1",
|
||||||
|
scheduleIndex: 0,
|
||||||
|
scheduledTime: "2026-03-11T08:00:00.000Z",
|
||||||
|
takenAt: "2026-03-11T08:03:00.000Z",
|
||||||
|
markedBy: "Daniel",
|
||||||
|
takenSource: "manual",
|
||||||
|
dismissed: false,
|
||||||
|
takenByPerson: "Daniel",
|
||||||
|
journalNote: "Took after breakfast.",
|
||||||
|
journalUpdatedAt: "2026-03-11T08:05:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
refillHistory: [{ packsAdded: 1, loosePillsAdded: 4, quantityAdded: 34, refillDate: "2026-03-10T12:00:00.000Z" }],
|
refillHistory: [{ packsAdded: 1, loosePillsAdded: 4, quantityAdded: 34, refillDate: "2026-03-10T12:00:00.000Z" }],
|
||||||
settings: { language: "en", stockCalculationMode: "automatic" },
|
settings: { language: "en", stockCalculationMode: "automatic" },
|
||||||
shareLinks: [{ takenBy: "Daniel", scheduleDays: 14 }],
|
shareLinks: [{ takenBy: "Daniel", scheduleDays: 14 }],
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const importPreviewResponseSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
success: { type: "boolean" },
|
||||||
|
preview: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
version: { type: "string" },
|
||||||
|
exportedAt: { type: "string", format: "date-time" },
|
||||||
|
includeSensitiveData: { type: "boolean" },
|
||||||
|
incoming: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
medications: { type: "integer" },
|
||||||
|
doseHistory: { type: "integer" },
|
||||||
|
refillHistory: { type: "integer" },
|
||||||
|
shareLinks: { type: "integer" },
|
||||||
|
journalEntries: { type: "integer" },
|
||||||
|
imageCount: { type: "integer" },
|
||||||
|
hasSettings: { type: "boolean" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
current: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
medications: { type: "integer" },
|
||||||
|
doseHistory: { type: "integer" },
|
||||||
|
refillHistory: { type: "integer" },
|
||||||
|
shareLinks: { type: "integer" },
|
||||||
|
hasSettings: { type: "boolean" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
warnings: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
replacesExistingData: { type: "boolean" },
|
||||||
|
regeneratesShareLinks: { type: "boolean" },
|
||||||
|
containsImages: { type: "boolean" },
|
||||||
|
containsSensitiveData: { type: "boolean" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Helper Functions
|
// Helper Functions
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -297,7 +362,7 @@ function imageToBase64(imageUrl: string | null): string | null {
|
|||||||
|
|
||||||
// Save base64 image to file and return filename
|
// Save base64 image to file and return filename
|
||||||
function base64ToImage(base64: string, medicationId: number): string | null {
|
function base64ToImage(base64: string, medicationId: number): string | null {
|
||||||
if (!base64 || !base64.startsWith("data:")) return null;
|
if (!base64.startsWith("data:")) return null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Parse data URL: "data:image/jpeg;base64,/9j/4AAQ..."
|
// Parse data URL: "data:image/jpeg;base64,/9j/4AAQ..."
|
||||||
@@ -323,6 +388,64 @@ function base64ToImage(base64: string, medicationId: number): string | null {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeFileIfPresent(filePath: string): string | null {
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
unlinkSync(filePath);
|
||||||
|
return null;
|
||||||
|
} catch (error) {
|
||||||
|
return error instanceof Error ? error.message : "Unknown file removal error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildImportPreview(
|
||||||
|
importData: z.infer<typeof importDataSchema>,
|
||||||
|
currentData: {
|
||||||
|
medications: number;
|
||||||
|
doseHistory: number;
|
||||||
|
refillHistory: number;
|
||||||
|
shareLinks: number;
|
||||||
|
hasSettings: boolean;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const journalEntries = importData.doseHistory.filter(
|
||||||
|
(dose) => typeof dose.journalNote === "string" && dose.journalNote.trim()
|
||||||
|
).length;
|
||||||
|
const imageCount = importData.medications.filter(
|
||||||
|
(med) => typeof med.image === "string" && med.image.startsWith("data:")
|
||||||
|
).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: importData.version,
|
||||||
|
exportedAt: importData.exportedAt,
|
||||||
|
includeSensitiveData: importData.includeSensitiveData,
|
||||||
|
incoming: {
|
||||||
|
medications: importData.medications.length,
|
||||||
|
doseHistory: importData.doseHistory.length,
|
||||||
|
refillHistory: importData.refillHistory.length,
|
||||||
|
shareLinks: importData.shareLinks.length,
|
||||||
|
journalEntries,
|
||||||
|
imageCount,
|
||||||
|
hasSettings: Boolean(importData.settings),
|
||||||
|
},
|
||||||
|
current: currentData,
|
||||||
|
warnings: {
|
||||||
|
replacesExistingData:
|
||||||
|
currentData.medications > 0 ||
|
||||||
|
currentData.doseHistory > 0 ||
|
||||||
|
currentData.refillHistory > 0 ||
|
||||||
|
currentData.shareLinks > 0 ||
|
||||||
|
currentData.hasSettings,
|
||||||
|
regeneratesShareLinks: importData.shareLinks.length > 0,
|
||||||
|
containsImages: imageCount > 0,
|
||||||
|
containsSensitiveData: importData.includeSensitiveData,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Parse dose ID to extract medication ID and timestamp
|
// Parse dose ID to extract medication ID and timestamp
|
||||||
// Format: "{medicationId}-{blisterIndex}-{timestampMs}" or "{medicationId}-{blisterIndex}-{timestampMs}-{person}"
|
// Format: "{medicationId}-{blisterIndex}-{timestampMs}" or "{medicationId}-{blisterIndex}-{timestampMs}-{person}"
|
||||||
function parseDoseId(
|
function parseDoseId(
|
||||||
@@ -444,6 +567,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
// 2. Load all dose tracking entries
|
// 2. Load all dose tracking entries
|
||||||
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
|
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
|
||||||
|
const journalPayloadsByDoseTrackingId = await listIntakeJournalExportPayloadsForUser(userId);
|
||||||
|
|
||||||
const exportDoseHistory = doses
|
const exportDoseHistory = doses
|
||||||
.map((dose) => {
|
.map((dose) => {
|
||||||
@@ -486,6 +610,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
takenSource: dose.takenSource === "automatic" ? "automatic" : "manual",
|
takenSource: dose.takenSource === "automatic" ? "automatic" : "manual",
|
||||||
dismissed: dose.dismissed ?? false,
|
dismissed: dose.dismissed ?? false,
|
||||||
takenByPerson: parsed.person,
|
takenByPerson: parsed.person,
|
||||||
|
...journalPayloadsByDoseTrackingId.get(dose.id),
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((d): d is NonNullable<typeof d> => d !== null);
|
.filter((d): d is NonNullable<typeof d> => d !== null);
|
||||||
@@ -544,6 +669,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
return {
|
return {
|
||||||
takenBy: share.takenBy,
|
takenBy: share.takenBy,
|
||||||
scheduleDays: share.scheduleDays,
|
scheduleDays: share.scheduleDays,
|
||||||
|
allowJournalNotes: share.allowJournalNotes ?? false,
|
||||||
expiresAt: expiresAtIso,
|
expiresAt: expiresAtIso,
|
||||||
regenerateToken: true, // Always regenerate tokens on import for security
|
regenerateToken: true, // Always regenerate tokens on import for security
|
||||||
};
|
};
|
||||||
@@ -619,6 +745,58 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// POST /import/preview - Validate and summarize import data without writing
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.post(
|
||||||
|
"/import/preview",
|
||||||
|
{
|
||||||
|
config: {
|
||||||
|
rawBody: true,
|
||||||
|
},
|
||||||
|
bodyLimit: 50 * 1024 * 1024,
|
||||||
|
schema: {
|
||||||
|
body: importBodyOpenApiSchema,
|
||||||
|
response: {
|
||||||
|
200: importPreviewResponseSchema,
|
||||||
|
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||||
|
401: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = await getUserId(request, reply);
|
||||||
|
|
||||||
|
const parsed = importDataSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({
|
||||||
|
error: "Invalid import data format",
|
||||||
|
details: parsed.error.format(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const [existingMeds, existingDoseHistory, existingRefillHistory, existingShareLinks, existingSettings] =
|
||||||
|
await Promise.all([
|
||||||
|
db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId)),
|
||||||
|
db.select({ id: doseTracking.id }).from(doseTracking).where(eq(doseTracking.userId, userId)),
|
||||||
|
db.select({ id: refillHistory.id }).from(refillHistory).where(eq(refillHistory.userId, userId)),
|
||||||
|
db.select({ id: shareTokens.id }).from(shareTokens).where(eq(shareTokens.userId, userId)),
|
||||||
|
db.select({ id: userSettings.id }).from(userSettings).where(eq(userSettings.userId, userId)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
preview: buildImportPreview(parsed.data, {
|
||||||
|
medications: existingMeds.length,
|
||||||
|
doseHistory: existingDoseHistory.length,
|
||||||
|
refillHistory: existingRefillHistory.length,
|
||||||
|
shareLinks: existingShareLinks.length,
|
||||||
|
hasSettings: existingSettings.length > 0,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// POST /import - Import user data (replaces all existing data!)
|
// POST /import - Import user data (replaces all existing data!)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -651,6 +829,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||||
401: genericErrorSchema,
|
401: genericErrorSchema,
|
||||||
|
500: genericErrorSchema,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -668,193 +847,208 @@ export async function exportRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const importData = parsed.data;
|
const importData = parsed.data;
|
||||||
|
|
||||||
// 2. Delete all existing user data (in correct order to respect foreign keys)
|
// Existing image files are removed only after the DB import commits.
|
||||||
// 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));
|
const existingMeds = await db.select().from(medications).where(eq(medications.userId, userId));
|
||||||
for (const med of existingMeds) {
|
const oldImagePaths = existingMeds
|
||||||
if (med.imageUrl) {
|
.map((med) => (med.imageUrl ? resolve(IMAGES_DIR, med.imageUrl) : null))
|
||||||
const imagePath = resolve(IMAGES_DIR, med.imageUrl);
|
.filter((path): path is string => path !== null);
|
||||||
if (existsSync(imagePath)) {
|
const newImagePaths: string[] = [];
|
||||||
try {
|
|
||||||
unlinkSync(imagePath);
|
try {
|
||||||
} catch {
|
await db.transaction(async (tx) => {
|
||||||
/* ignore */
|
// Delete in order: journal entries, refill history, doses, share tokens, medications, settings.
|
||||||
|
await tx.delete(intakeJournal).where(eq(intakeJournal.userId, userId));
|
||||||
|
await tx.delete(refillHistory).where(eq(refillHistory.userId, userId));
|
||||||
|
await tx.delete(doseTracking).where(eq(doseTracking.userId, userId));
|
||||||
|
await tx.delete(shareTokens).where(eq(shareTokens.userId, userId));
|
||||||
|
await tx.delete(medications).where(eq(medications.userId, userId));
|
||||||
|
await tx.delete(userSettings).where(eq(userSettings.userId, userId));
|
||||||
|
|
||||||
|
const exportIdToNewId = new Map<string, number>();
|
||||||
|
|
||||||
|
for (const med of importData.medications) {
|
||||||
|
const normalizedSchedules = med.schedules.map((schedule) =>
|
||||||
|
normalizeIntake({
|
||||||
|
usage: schedule.usage,
|
||||||
|
every: schedule.every,
|
||||||
|
start: schedule.start,
|
||||||
|
scheduleMode: schedule.scheduleMode,
|
||||||
|
weekdays: schedule.weekdays,
|
||||||
|
intakeUnit: schedule.intakeUnit ?? null,
|
||||||
|
takenBy: schedule.takenBy || null,
|
||||||
|
intakeRemindersEnabled: schedule.remind ?? false,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const usageJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.usage));
|
||||||
|
const everyJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.every));
|
||||||
|
const startJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.start));
|
||||||
|
const takenByJson = JSON.stringify(med.takenBy);
|
||||||
|
const intakesJson = JSON.stringify(normalizedSchedules);
|
||||||
|
const intakeRemindersEnabled =
|
||||||
|
normalizedSchedules.some((schedule) => schedule.intakeRemindersEnabled) || med.intakeRemindersEnabled;
|
||||||
|
|
||||||
|
const [inserted] = await tx
|
||||||
|
.insert(medications)
|
||||||
|
.values({
|
||||||
|
userId,
|
||||||
|
name: med.name,
|
||||||
|
genericName: med.genericName || null,
|
||||||
|
takenByJson,
|
||||||
|
medicationForm: med.medicationForm ?? "tablet",
|
||||||
|
pillForm: med.pillForm || null,
|
||||||
|
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
|
||||||
|
packageType: normalizePackageType(med.inventory.packageType),
|
||||||
|
packageAmountValue: med.inventory.packageAmountValue ?? 0,
|
||||||
|
packageAmountUnit: med.inventory.packageAmountUnit ?? "ml",
|
||||||
|
packCount: med.inventory.packCount,
|
||||||
|
blistersPerPack: med.inventory.blistersPerPack,
|
||||||
|
pillsPerBlister: med.inventory.pillsPerBlister,
|
||||||
|
looseTablets: med.inventory.looseTablets,
|
||||||
|
totalPills: med.inventory.totalPills ?? null,
|
||||||
|
stockAdjustment: med.inventory.stockAdjustment ?? 0,
|
||||||
|
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
|
||||||
|
pillWeightMg: med.pillWeightMg || null,
|
||||||
|
doseUnit: med.doseUnit ?? "mg",
|
||||||
|
medicationStartDate: med.medicationStartDate || "",
|
||||||
|
medicationEndDate: med.medicationEndDate || null,
|
||||||
|
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
|
||||||
|
intakesJson,
|
||||||
|
usageJson,
|
||||||
|
everyJson,
|
||||||
|
startJson,
|
||||||
|
expiryDate: med.expiryDate || null,
|
||||||
|
notes: med.notes || null,
|
||||||
|
intakeRemindersEnabled,
|
||||||
|
isObsolete: med.isObsolete ?? false,
|
||||||
|
obsoleteAt: med.obsoleteAt ? new Date(med.obsoleteAt) : null,
|
||||||
|
prescriptionEnabled: med.prescriptionEnabled ?? false,
|
||||||
|
prescriptionAuthorizedRefills: med.prescriptionEnabled
|
||||||
|
? (med.prescriptionAuthorizedRefills ?? null)
|
||||||
|
: null,
|
||||||
|
prescriptionRemainingRefills: med.prescriptionEnabled
|
||||||
|
? (med.prescriptionRemainingRefills ?? null)
|
||||||
|
: null,
|
||||||
|
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
|
||||||
|
prescriptionExpiryDate: med.prescriptionExpiryDate || null,
|
||||||
|
dismissedUntil: med.dismissedUntil || null,
|
||||||
|
imageUrl: null,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
|
||||||
|
exportIdToNewId.set(med._exportId, inserted.id);
|
||||||
|
|
||||||
|
if (med.image) {
|
||||||
|
const imageUrl = base64ToImage(med.image, inserted.id);
|
||||||
|
if (imageUrl) {
|
||||||
|
newImagePaths.push(resolve(IMAGES_DIR, imageUrl));
|
||||||
|
await tx.update(medications).set({ imageUrl }).where(eq(medications.id, inserted.id));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete in order: refill history, doses, share tokens, medications, settings
|
for (const dose of importData.doseHistory) {
|
||||||
await db.delete(refillHistory).where(eq(refillHistory.userId, userId));
|
const newMedId = exportIdToNewId.get(dose.medicationRef);
|
||||||
await db.delete(doseTracking).where(eq(doseTracking.userId, userId));
|
if (!newMedId) continue;
|
||||||
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 scheduledFor = new Date(dose.scheduledTime);
|
||||||
const exportIdToNewId = new Map<string, number>();
|
const timestampMs = scheduledFor.getTime();
|
||||||
|
const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs, dose.takenByPerson);
|
||||||
|
|
||||||
for (const med of importData.medications) {
|
const [insertedDose] = await tx
|
||||||
const normalizedSchedules = med.schedules.map((schedule) =>
|
.insert(doseTracking)
|
||||||
normalizeIntake({
|
.values({
|
||||||
usage: schedule.usage,
|
userId,
|
||||||
every: schedule.every,
|
doseId,
|
||||||
start: schedule.start,
|
takenAt: new Date(dose.takenAt),
|
||||||
scheduleMode: schedule.scheduleMode,
|
markedBy: dose.markedBy || null,
|
||||||
weekdays: schedule.weekdays,
|
takenSource: dose.takenSource ?? "manual",
|
||||||
intakeUnit: schedule.intakeUnit ?? null,
|
dismissed: dose.dismissed ?? false,
|
||||||
takenBy: schedule.takenBy || null,
|
})
|
||||||
intakeRemindersEnabled: schedule.remind ?? false,
|
.returning({ id: doseTracking.id });
|
||||||
})
|
|
||||||
);
|
|
||||||
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);
|
await restoreIntakeJournalForImportedDose({
|
||||||
|
userId,
|
||||||
|
doseTrackingId: insertedDose.id,
|
||||||
|
medicationId: newMedId,
|
||||||
|
scheduledFor,
|
||||||
|
journalNote: dose.journalNote,
|
||||||
|
journalCreatedAt: dose.journalCreatedAt,
|
||||||
|
journalUpdatedAt: dose.journalUpdatedAt,
|
||||||
|
database: tx,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Check if any schedule has remind enabled
|
if (importData.settings) {
|
||||||
const intakeRemindersEnabled =
|
await tx.insert(userSettings).values({
|
||||||
normalizedSchedules.some((schedule) => schedule.intakeRemindersEnabled) || med.intakeRemindersEnabled;
|
userId,
|
||||||
|
emailEnabled: importData.settings.emailEnabled ?? false,
|
||||||
|
notificationEmail: importData.settings.notificationEmail || null,
|
||||||
|
emailStockReminders: importData.settings.emailStockReminders ?? true,
|
||||||
|
emailIntakeReminders: importData.settings.emailIntakeReminders ?? true,
|
||||||
|
emailPrescriptionReminders: importData.settings.emailPrescriptionReminders ?? true,
|
||||||
|
shoutrrrEnabled: importData.settings.shoutrrrEnabled ?? false,
|
||||||
|
shoutrrrUrl: importData.settings.shoutrrrUrl || null,
|
||||||
|
shoutrrrStockReminders: importData.settings.shoutrrrStockReminders ?? true,
|
||||||
|
shoutrrrIntakeReminders: importData.settings.shoutrrrIntakeReminders ?? true,
|
||||||
|
shoutrrrPrescriptionReminders: importData.settings.shoutrrrPrescriptionReminders ?? true,
|
||||||
|
reminderDaysBefore: importData.settings.reminderDaysBefore ?? 7,
|
||||||
|
repeatDailyReminders: importData.settings.repeatDailyReminders ?? false,
|
||||||
|
skipRemindersForTakenDoses: importData.settings.skipRemindersForTakenDoses ?? false,
|
||||||
|
repeatRemindersEnabled: importData.settings.repeatRemindersEnabled ?? false,
|
||||||
|
reminderRepeatIntervalMinutes: importData.settings.reminderRepeatIntervalMinutes ?? 30,
|
||||||
|
maxNaggingReminders: importData.settings.maxNaggingReminders ?? 5,
|
||||||
|
lowStockDays: importData.settings.lowStockDays ?? 30,
|
||||||
|
normalStockDays: importData.settings.normalStockDays ?? 90,
|
||||||
|
highStockDays: importData.settings.highStockDays ?? 180,
|
||||||
|
expiryWarningDays: importData.settings.expiryWarningDays ?? 90,
|
||||||
|
language: importData.settings.language ?? "en",
|
||||||
|
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
|
||||||
|
shareMedicationOverview: importData.settings.shareMedicationOverview ?? false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const [inserted] = await db
|
for (const share of importData.shareLinks) {
|
||||||
.insert(medications)
|
await tx.insert(shareTokens).values({
|
||||||
.values({
|
userId,
|
||||||
userId,
|
token: randomBytes(8).toString("hex"),
|
||||||
name: med.name,
|
takenBy: share.takenBy,
|
||||||
genericName: med.genericName || null,
|
scheduleDays: share.scheduleDays,
|
||||||
takenByJson,
|
allowJournalNotes: share.allowJournalNotes ?? false,
|
||||||
medicationForm: med.medicationForm ?? "tablet",
|
expiresAt: share.expiresAt ? new Date(share.expiresAt) : null,
|
||||||
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
|
for (const refill of importData.refillHistory) {
|
||||||
exportIdToNewId.set(med._exportId, inserted.id);
|
const newMedId = exportIdToNewId.get(refill.medicationRef);
|
||||||
|
if (!newMedId) continue;
|
||||||
|
|
||||||
// Save image if present
|
await tx.insert(refillHistory).values({
|
||||||
if (med.image) {
|
medicationId: newMedId,
|
||||||
const imageUrl = base64ToImage(med.image, inserted.id);
|
userId,
|
||||||
if (imageUrl) {
|
packsAdded: refill.packsAdded ?? 0,
|
||||||
await db.update(medications).set({ imageUrl }).where(eq(medications.id, inserted.id));
|
loosePillsAdded: refill.loosePillsAdded ?? refill.quantityAdded ?? 0,
|
||||||
|
usedPrescription: refill.usedPrescription ?? false,
|
||||||
|
refillDate: new Date(refill.refillDate),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
for (const imagePath of newImagePaths) {
|
||||||
|
const removalError = removeFileIfPresent(imagePath);
|
||||||
|
if (removalError) {
|
||||||
|
request.log.warn(`[Import] Failed to remove rolled-back image path=${imagePath}: ${removalError}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
request.log.error({ err: error }, "[Import] Failed to import data");
|
||||||
|
return reply.status(500).send({ error: "Import failed" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Import dose history with remapped medication IDs
|
for (const imagePath of oldImagePaths) {
|
||||||
for (const dose of importData.doseHistory) {
|
const removalError = removeFileIfPresent(imagePath);
|
||||||
const newMedId = exportIdToNewId.get(dose.medicationRef);
|
if (removalError) {
|
||||||
if (!newMedId) continue; // Skip orphaned doses
|
request.log.warn(`[Import] Failed to remove replaced image path=${imagePath}: ${removalError}`);
|
||||||
|
}
|
||||||
// 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 {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,373 @@
|
|||||||
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||||
|
import { env } from "../plugins/env.js";
|
||||||
|
import {
|
||||||
|
deleteIntakeJournalForDoseEvent,
|
||||||
|
getIntakeJournalForDoseEvent,
|
||||||
|
isTrackedDoseIdFormat,
|
||||||
|
listIntakeJournalEntriesForUser,
|
||||||
|
resolveTrackedDoseEventForUser,
|
||||||
|
upsertIntakeJournalForDoseEvent,
|
||||||
|
} from "../services/intake-journal-service.js";
|
||||||
|
import type { AuthUser } from "../types/fastify.js";
|
||||||
|
import { toLocalDateTimeOffsetString } from "../utils/local-date-time.js";
|
||||||
|
import {
|
||||||
|
applyOpenApiRouteStandards,
|
||||||
|
genericErrorSchema,
|
||||||
|
validationErrorSchema,
|
||||||
|
} from "../utils/openapi-route-standards.js";
|
||||||
|
|
||||||
|
const intakeJournalEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
|
||||||
|
{ bearerAuth: [] },
|
||||||
|
{ cookieAuth: [] },
|
||||||
|
];
|
||||||
|
|
||||||
|
const doseIdParamsSchema = {
|
||||||
|
type: "object",
|
||||||
|
required: ["doseId"],
|
||||||
|
properties: {
|
||||||
|
doseId: { type: "string", minLength: 1 },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const intakeJournalEntrySchema = {
|
||||||
|
type: "object",
|
||||||
|
required: [
|
||||||
|
"doseTrackingId",
|
||||||
|
"doseId",
|
||||||
|
"medicationId",
|
||||||
|
"medicationName",
|
||||||
|
"scheduledFor",
|
||||||
|
"dismissed",
|
||||||
|
"takenSource",
|
||||||
|
"note",
|
||||||
|
"updatedAt",
|
||||||
|
],
|
||||||
|
properties: {
|
||||||
|
doseTrackingId: { type: "integer" },
|
||||||
|
doseId: { type: "string" },
|
||||||
|
medicationId: { type: "integer" },
|
||||||
|
medicationName: { type: "string" },
|
||||||
|
scheduledFor: { type: "string", format: "date-time" },
|
||||||
|
takenAt: { type: ["string", "null"], format: "date-time" },
|
||||||
|
dismissed: { type: "boolean" },
|
||||||
|
takenSource: { type: "string", enum: ["manual", "automatic"] },
|
||||||
|
markedBy: { type: ["string", "null"] },
|
||||||
|
note: { type: ["string", "null"] },
|
||||||
|
updatedAt: { type: ["string", "null"], format: "date-time" },
|
||||||
|
createdAt: { type: ["string", "null"], format: "date-time" },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const intakeJournalEventResponseSchema = {
|
||||||
|
type: "object",
|
||||||
|
required: ["entry"],
|
||||||
|
properties: {
|
||||||
|
entry: intakeJournalEntrySchema,
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const intakeJournalHistoryResponseSchema = {
|
||||||
|
type: "object",
|
||||||
|
required: ["entries"],
|
||||||
|
properties: {
|
||||||
|
entries: {
|
||||||
|
type: "array",
|
||||||
|
items: intakeJournalEntrySchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const intakeJournalHistoryQuerySchema = z.object({
|
||||||
|
medicationId: z.coerce.number().int().positive().optional(),
|
||||||
|
from: z.string().trim().min(1).optional(),
|
||||||
|
to: z.string().trim().min(1).optional(),
|
||||||
|
limit: z.coerce.number().int().min(1).max(200).optional().default(100),
|
||||||
|
});
|
||||||
|
|
||||||
|
const intakeJournalUpsertSchema = z.object({
|
||||||
|
note: z.string().max(4000),
|
||||||
|
});
|
||||||
|
|
||||||
|
function getValidationErrorMessage(error: z.ZodError): string {
|
||||||
|
const issue = error.issues[0];
|
||||||
|
if (!issue) {
|
||||||
|
return "Invalid request payload";
|
||||||
|
}
|
||||||
|
|
||||||
|
return issue.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalDate(value: string | undefined): Date | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeTakenAt(value: Date | null, dismissed: boolean): string | null {
|
||||||
|
if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dismissed && value.getTime() <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildJournalEntryDto(input: {
|
||||||
|
event: Awaited<ReturnType<typeof resolveTrackedDoseEventForUser>> extends infer T
|
||||||
|
? T extends null
|
||||||
|
? never
|
||||||
|
: T
|
||||||
|
: never;
|
||||||
|
journalEntry: Awaited<ReturnType<typeof getIntakeJournalForDoseEvent>>;
|
||||||
|
}) {
|
||||||
|
const { event, journalEntry } = input;
|
||||||
|
|
||||||
|
return {
|
||||||
|
doseTrackingId: event.doseTrackingId,
|
||||||
|
doseId: event.doseId,
|
||||||
|
medicationId: event.medicationId,
|
||||||
|
medicationName: event.medicationName,
|
||||||
|
scheduledFor: toLocalDateTimeOffsetString(journalEntry?.scheduledFor ?? event.scheduledFor),
|
||||||
|
takenAt: serializeTakenAt(event.takenAt, event.dismissed),
|
||||||
|
dismissed: event.dismissed,
|
||||||
|
takenSource: event.takenSource,
|
||||||
|
markedBy: event.markedBy,
|
||||||
|
note: journalEntry?.note ?? null,
|
||||||
|
updatedAt: journalEntry?.updatedAt?.toISOString() ?? null,
|
||||||
|
createdAt: journalEntry?.createdAt?.toISOString() ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||||
|
if (!env.AUTH_ENABLED) {
|
||||||
|
return getAnonymousUserId();
|
||||||
|
}
|
||||||
|
|
||||||
|
const authUser = request.user as AuthUser | null;
|
||||||
|
if (!authUser) {
|
||||||
|
reply.status(401).send({ error: "Not authenticated" });
|
||||||
|
throw new Error("AUTH_REQUIRED");
|
||||||
|
}
|
||||||
|
|
||||||
|
return authUser.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function intakeJournalRoutes(app: FastifyInstance) {
|
||||||
|
app.addHook("preHandler", requireAuth);
|
||||||
|
applyOpenApiRouteStandards(app, { tag: "intake-journal", protectedByDefault: true });
|
||||||
|
|
||||||
|
app.get<{ Querystring: z.infer<typeof intakeJournalHistoryQuerySchema> }>(
|
||||||
|
"/intake-journal",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ["intake-journal"],
|
||||||
|
summary: "List intake journal history for the current owner",
|
||||||
|
security: intakeJournalEndpointSecurity,
|
||||||
|
querystring: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
medicationId: { type: "integer", minimum: 1 },
|
||||||
|
from: { type: "string", format: "date-time" },
|
||||||
|
to: { type: "string", format: "date-time" },
|
||||||
|
limit: { type: "integer", minimum: 1, maximum: 200 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: intakeJournalHistoryResponseSchema,
|
||||||
|
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||||
|
401: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = await getUserId(request, reply);
|
||||||
|
const parsed = intakeJournalHistoryQuerySchema.safeParse(request.query);
|
||||||
|
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = parseOptionalDate(parsed.data.from);
|
||||||
|
if (parsed.data.from && !from) {
|
||||||
|
return reply.status(400).send({ error: "Invalid 'from' date-time filter", code: "INVALID_FROM" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const to = parseOptionalDate(parsed.data.to);
|
||||||
|
if (parsed.data.to && !to) {
|
||||||
|
return reply.status(400).send({ error: "Invalid 'to' date-time filter", code: "INVALID_TO" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (from && to && from.getTime() > to.getTime()) {
|
||||||
|
return reply.status(400).send({ error: "'from' must be before or equal to 'to'", code: "INVALID_RANGE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = await listIntakeJournalEntriesForUser({
|
||||||
|
userId,
|
||||||
|
medicationId: parsed.data.medicationId,
|
||||||
|
from: from ?? undefined,
|
||||||
|
to: to ?? undefined,
|
||||||
|
limit: parsed.data.limit,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
entries: entries.map((entry) => ({
|
||||||
|
doseTrackingId: entry.doseTrackingId,
|
||||||
|
doseId: entry.doseId,
|
||||||
|
medicationId: entry.medicationId,
|
||||||
|
medicationName: entry.medicationName,
|
||||||
|
scheduledFor: toLocalDateTimeOffsetString(entry.scheduledFor),
|
||||||
|
takenAt: serializeTakenAt(entry.takenAt, entry.dismissed),
|
||||||
|
dismissed: entry.dismissed,
|
||||||
|
takenSource: entry.takenSource,
|
||||||
|
markedBy: entry.markedBy,
|
||||||
|
note: entry.note,
|
||||||
|
updatedAt: entry.updatedAt.toISOString(),
|
||||||
|
createdAt: entry.createdAt.toISOString(),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.get<{ Params: { doseId: string } }>(
|
||||||
|
"/intake-journal/event/:doseId",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ["intake-journal"],
|
||||||
|
summary: "Get intake journal context for a tracked dose event",
|
||||||
|
security: intakeJournalEndpointSecurity,
|
||||||
|
params: doseIdParamsSchema,
|
||||||
|
response: {
|
||||||
|
200: intakeJournalEventResponseSchema,
|
||||||
|
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||||
|
401: genericErrorSchema,
|
||||||
|
404: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = await getUserId(request, reply);
|
||||||
|
const { doseId } = request.params;
|
||||||
|
|
||||||
|
if (!isTrackedDoseIdFormat(doseId)) {
|
||||||
|
return reply.status(400).send({ error: "Invalid doseId format", code: "INVALID_DOSE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await resolveTrackedDoseEventForUser({ userId, doseId });
|
||||||
|
if (!event) {
|
||||||
|
return reply
|
||||||
|
.status(404)
|
||||||
|
.send({ error: "Tracked dose event not found for the current owner", code: "DOSE_NOT_FOUND" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const journalEntry = await getIntakeJournalForDoseEvent({ userId, doseId });
|
||||||
|
return { entry: buildJournalEntryDto({ event, journalEntry }) };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.put<{ Body: z.infer<typeof intakeJournalUpsertSchema>; Params: { doseId: string } }>(
|
||||||
|
"/intake-journal/event/:doseId",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ["intake-journal"],
|
||||||
|
summary: "Create or update an intake journal note for a tracked dose event",
|
||||||
|
security: intakeJournalEndpointSecurity,
|
||||||
|
params: doseIdParamsSchema,
|
||||||
|
body: {
|
||||||
|
type: "object",
|
||||||
|
required: ["note"],
|
||||||
|
properties: {
|
||||||
|
note: { type: "string", maxLength: 4000 },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
200: intakeJournalEventResponseSchema,
|
||||||
|
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||||
|
401: genericErrorSchema,
|
||||||
|
404: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = await getUserId(request, reply);
|
||||||
|
const { doseId } = request.params;
|
||||||
|
|
||||||
|
if (!isTrackedDoseIdFormat(doseId)) {
|
||||||
|
return reply.status(400).send({ error: "Invalid doseId format", code: "INVALID_DOSE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = intakeJournalUpsertSchema.safeParse(request.body);
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) });
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await resolveTrackedDoseEventForUser({ userId, doseId });
|
||||||
|
if (!event) {
|
||||||
|
return reply
|
||||||
|
.status(404)
|
||||||
|
.send({ error: "Tracked dose event not found for the current owner", code: "DOSE_NOT_FOUND" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const journalEntry = await upsertIntakeJournalForDoseEvent({
|
||||||
|
userId,
|
||||||
|
doseId,
|
||||||
|
note: parsed.data.note,
|
||||||
|
});
|
||||||
|
|
||||||
|
return { entry: buildJournalEntryDto({ event, journalEntry }) };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.delete<{ Params: { doseId: string } }>(
|
||||||
|
"/intake-journal/event/:doseId",
|
||||||
|
{
|
||||||
|
schema: {
|
||||||
|
tags: ["intake-journal"],
|
||||||
|
summary: "Delete an intake journal note for a tracked dose event",
|
||||||
|
security: intakeJournalEndpointSecurity,
|
||||||
|
params: doseIdParamsSchema,
|
||||||
|
response: {
|
||||||
|
200: {
|
||||||
|
type: "object",
|
||||||
|
required: ["success"],
|
||||||
|
properties: {
|
||||||
|
success: { type: "boolean" },
|
||||||
|
},
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||||
|
401: genericErrorSchema,
|
||||||
|
404: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = await getUserId(request, reply);
|
||||||
|
const { doseId } = request.params;
|
||||||
|
|
||||||
|
if (!isTrackedDoseIdFormat(doseId)) {
|
||||||
|
return reply.status(400).send({ error: "Invalid doseId format", code: "INVALID_DOSE" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleted = await deleteIntakeJournalForDoseEvent({ userId, doseId });
|
||||||
|
if (!deleted) {
|
||||||
|
return reply
|
||||||
|
.status(404)
|
||||||
|
.send({ error: "Tracked dose event not found for the current owner", code: "DOSE_NOT_FOUND" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -213,10 +213,10 @@ async function replaceNtfyNotificationSequence(options: {
|
|||||||
originalMessage: string;
|
originalMessage: string;
|
||||||
action: NotificationMutationAction;
|
action: NotificationMutationAction;
|
||||||
viewUrl: string | null;
|
viewUrl: string | null;
|
||||||
}): Promise<boolean> {
|
}): Promise<{ replaced: boolean; providerMessageId?: string }> {
|
||||||
const normalizedSequenceId = options.sequenceId.trim();
|
const normalizedSequenceId = options.sequenceId.trim();
|
||||||
if (normalizedSequenceId.length === 0) {
|
if (normalizedSequenceId.length === 0) {
|
||||||
return false;
|
return { replaced: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const [settings] = await db
|
const [settings] = await db
|
||||||
@@ -225,12 +225,12 @@ async function replaceNtfyNotificationSequence(options: {
|
|||||||
.where(eq(userSettings.userId, options.userId));
|
.where(eq(userSettings.userId, options.userId));
|
||||||
|
|
||||||
if (!settings?.shoutrrrEnabled || !settings.shoutrrrUrl) {
|
if (!settings?.shoutrrrEnabled || !settings.shoutrrrUrl) {
|
||||||
return false;
|
return { replaced: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const sanitized = sanitizeNotificationUrl(settings.shoutrrrUrl);
|
const sanitized = sanitizeNotificationUrl(settings.shoutrrrUrl);
|
||||||
if ("error" in sanitized || !sanitized.isNtfy) {
|
if ("error" in sanitized || !sanitized.isNtfy) {
|
||||||
return false;
|
return { replaced: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
const labels = getNotificationActionLabels(options.language);
|
const labels = getNotificationActionLabels(options.language);
|
||||||
@@ -247,7 +247,7 @@ async function replaceNtfyNotificationSequence(options: {
|
|||||||
throw new Error(result.error ?? "Failed to replace ntfy notification");
|
throw new Error(result.error ?? "Failed to replace ntfy notification");
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return { replaced: true, providerMessageId: result.providerMessageId };
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPage(options: {
|
function renderPage(options: {
|
||||||
@@ -585,9 +585,10 @@ export async function notificationActionRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
const recordedText = getActionRecordedText(language, action);
|
const recordedText = getActionRecordedText(language, action);
|
||||||
let replacedNtfyNotification = false;
|
let replacedNtfyNotification = false;
|
||||||
|
const previousNtfyMessageId = record.group.ntfyOriginalMessageId.trim();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
replacedNtfyNotification = await replaceNtfyNotificationSequence({
|
const replacementResult = await replaceNtfyNotificationSequence({
|
||||||
userId: record.group.userId,
|
userId: record.group.userId,
|
||||||
sequenceId: record.group.sequenceId,
|
sequenceId: record.group.sequenceId,
|
||||||
language,
|
language,
|
||||||
@@ -596,6 +597,33 @@ export async function notificationActionRoutes(app: FastifyInstance) {
|
|||||||
action,
|
action,
|
||||||
viewUrl: record.viewUrl,
|
viewUrl: record.viewUrl,
|
||||||
});
|
});
|
||||||
|
replacedNtfyNotification = replacementResult.replaced;
|
||||||
|
|
||||||
|
if (replacementResult.providerMessageId) {
|
||||||
|
await db
|
||||||
|
.update(notificationActionGroups)
|
||||||
|
.set({ ntfyOriginalMessageId: replacementResult.providerMessageId, updatedAt: new Date() })
|
||||||
|
.where(eq(notificationActionGroups.id, record.group.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
replacementResult.replaced &&
|
||||||
|
previousNtfyMessageId.length > 0 &&
|
||||||
|
previousNtfyMessageId !== replacementResult.providerMessageId
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
await deleteNtfyNotificationSequence(record.group.userId, previousNtfyMessageId);
|
||||||
|
} catch (error) {
|
||||||
|
request.log.warn(
|
||||||
|
buildNotificationActionLogContext(record, {
|
||||||
|
requestedAction: action,
|
||||||
|
originalMessageId: previousNtfyMessageId,
|
||||||
|
error,
|
||||||
|
}),
|
||||||
|
"[NotificationActions] Failed to delete original ntfy notification after replacement"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
request.log.warn(
|
request.log.warn(
|
||||||
buildNotificationActionLogContext(record, { requestedAction: action, error }),
|
buildNotificationActionLogContext(record, { requestedAction: action, error }),
|
||||||
|
|||||||
@@ -45,12 +45,24 @@ type PlannerRow = {
|
|||||||
|
|
||||||
type SendEmailBody = {
|
type SendEmailBody = {
|
||||||
email: string;
|
email: string;
|
||||||
from: string;
|
from?: string;
|
||||||
until: string;
|
until?: string;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
rows: PlannerRow[];
|
rows: PlannerRow[];
|
||||||
language?: Language; // Optional: passed from frontend for unauthenticated requests
|
language?: Language; // Optional: passed from frontend for unauthenticated requests
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function resolvePlannerDateRange(body: SendEmailBody): { startDate: string; endDate: string } | null {
|
||||||
|
const startDate = body.startDate ?? body.from;
|
||||||
|
const endDate = body.endDate ?? body.until;
|
||||||
|
if (!startDate || !endDate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { startDate, endDate };
|
||||||
|
}
|
||||||
|
|
||||||
type LowStockItem = {
|
type LowStockItem = {
|
||||||
name: string;
|
name: string;
|
||||||
medsLeft: number;
|
medsLeft: number;
|
||||||
@@ -165,11 +177,15 @@ export async function plannerRoutes(app: FastifyInstance) {
|
|||||||
email: { type: "string" },
|
email: { type: "string" },
|
||||||
from: { type: "string" },
|
from: { type: "string" },
|
||||||
until: { type: "string" },
|
until: { type: "string" },
|
||||||
|
startDate: { type: "string", format: "date-time" },
|
||||||
|
endDate: { type: "string", format: "date-time" },
|
||||||
language: { type: "string" },
|
language: { type: "string" },
|
||||||
rows: { type: "array", items: plannerRowSchema },
|
rows: { type: "array", items: plannerRowSchema },
|
||||||
},
|
},
|
||||||
example: {
|
example: {
|
||||||
email: "daniel@example.com",
|
email: "daniel@example.com",
|
||||||
|
startDate: "2026-03-11T00:00:00.000Z",
|
||||||
|
endDate: "2026-04-11T00:00:00.000Z",
|
||||||
from: "2026-03-11",
|
from: "2026-03-11",
|
||||||
until: "2026-04-11",
|
until: "2026-04-11",
|
||||||
language: "en",
|
language: "en",
|
||||||
@@ -198,13 +214,20 @@ export async function plannerRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const { email, from, until, rows, language: bodyLanguage } = request.body;
|
const { email, rows, language: bodyLanguage } = request.body;
|
||||||
|
const resolvedDateRange = resolvePlannerDateRange(request.body);
|
||||||
request.log.info({ email, rowCount: rows?.length ?? 0 }, "[Planner] Demand notification request received");
|
request.log.info({ email, rowCount: rows?.length ?? 0 }, "[Planner] Demand notification request received");
|
||||||
|
|
||||||
if (!rows || rows.length === 0) {
|
if (!rows || rows.length === 0) {
|
||||||
return reply.status(400).send({ error: "Missing planner data" });
|
return reply.status(400).send({ error: "Missing planner data" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!resolvedDateRange) {
|
||||||
|
return reply.status(400).send({ error: "Missing planner date range" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { startDate, endDate } = resolvedDateRange;
|
||||||
|
|
||||||
// Load user settings for notification channels
|
// Load user settings for notification channels
|
||||||
const userId = await getUserId(request);
|
const userId = await getUserId(request);
|
||||||
const activeMeds = await db
|
const activeMeds = await db
|
||||||
@@ -246,14 +269,14 @@ export async function plannerRoutes(app: FastifyInstance) {
|
|||||||
|
|
||||||
// Format dates for display - escape to prevent XSS even though toLocaleDateString should be safe
|
// Format dates for display - escape to prevent XSS even though toLocaleDateString should be safe
|
||||||
const fromDate = escapeHtml(
|
const fromDate = escapeHtml(
|
||||||
new Date(from).toLocaleDateString(locale, {
|
new Date(startDate).toLocaleDateString(locale, {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const untilDate = escapeHtml(
|
const untilDate = escapeHtml(
|
||||||
new Date(until).toLocaleDateString(locale, {
|
new Date(endDate).toLocaleDateString(locale, {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "long",
|
month: "long",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq, gte, lt } from "drizzle-orm";
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
@@ -12,10 +12,42 @@ import {
|
|||||||
validationErrorSchema,
|
validationErrorSchema,
|
||||||
} from "../utils/openapi-route-standards.js";
|
} from "../utils/openapi-route-standards.js";
|
||||||
|
|
||||||
const reportDataSchema = z.object({
|
const reportDataSchema = z
|
||||||
medicationIds: z.array(z.number().int().positive()).min(1).max(100),
|
.object({
|
||||||
takenByFilter: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
|
medicationIds: z.array(z.number().int().positive()).min(1).max(100),
|
||||||
});
|
startDate: z.string().datetime().optional(),
|
||||||
|
endDate: z.string().datetime().optional(),
|
||||||
|
takenByFilter: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
|
||||||
|
})
|
||||||
|
.superRefine((value, ctx) => {
|
||||||
|
const hasStartDate = typeof value.startDate === "string";
|
||||||
|
const hasEndDate = typeof value.endDate === "string";
|
||||||
|
|
||||||
|
if (hasStartDate !== hasEndDate) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "startDate and endDate must be provided together",
|
||||||
|
path: hasStartDate ? ["endDate"] : ["startDate"],
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasStartDate || !hasEndDate) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startDateValue = value.startDate!;
|
||||||
|
const endDateValue = value.endDate!;
|
||||||
|
const startDate = new Date(startDateValue);
|
||||||
|
const endDate = new Date(endDateValue);
|
||||||
|
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime()) || endDate <= startDate) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
message: "Invalid date range",
|
||||||
|
path: ["endDate"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const reportDataBodyOpenApiSchema = {
|
const reportDataBodyOpenApiSchema = {
|
||||||
type: "object",
|
type: "object",
|
||||||
@@ -27,6 +59,14 @@ const reportDataBodyOpenApiSchema = {
|
|||||||
maxItems: 100,
|
maxItems: 100,
|
||||||
items: { type: "integer", minimum: 1 },
|
items: { type: "integer", minimum: 1 },
|
||||||
},
|
},
|
||||||
|
startDate: {
|
||||||
|
type: "string",
|
||||||
|
format: "date-time",
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
type: "string",
|
||||||
|
format: "date-time",
|
||||||
|
},
|
||||||
takenByFilter: {
|
takenByFilter: {
|
||||||
type: "array",
|
type: "array",
|
||||||
maxItems: 50,
|
maxItems: 50,
|
||||||
@@ -35,17 +75,47 @@ const reportDataBodyOpenApiSchema = {
|
|||||||
},
|
},
|
||||||
example: {
|
example: {
|
||||||
medicationIds: [1, 3, 5],
|
medicationIds: [1, 3, 5],
|
||||||
|
startDate: "2026-05-01T00:00:00.000Z",
|
||||||
|
endDate: "2026-06-01T00:00:00.000Z",
|
||||||
takenByFilter: ["Daniel"],
|
takenByFilter: ["Daniel"],
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const trackedDoseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
|
||||||
|
|
||||||
|
function getPersonTagKey(value: string): string {
|
||||||
|
return value.trim().toLocaleLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
function matchesTakenByFilter(doseId: string, takenByFilter: Set<string> | null): boolean {
|
function matchesTakenByFilter(doseId: string, takenByFilter: Set<string> | null): boolean {
|
||||||
if (!takenByFilter) return true;
|
if (!takenByFilter) return true;
|
||||||
const parts = doseId.split("-");
|
const parts = doseId.split("-");
|
||||||
if (parts.length < 4) return false;
|
if (parts.length < 4) return false;
|
||||||
const takenBy = parts.at(-1)?.trim();
|
const takenBy = parts.at(-1)?.trim();
|
||||||
if (!takenBy) return false;
|
if (!takenBy) return false;
|
||||||
return takenByFilter.has(takenBy);
|
return takenByFilter.has(getPersonTagKey(takenBy));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDoseScheduledAtMs(doseId: string): number | null {
|
||||||
|
const match = trackedDoseIdPattern.exec(doseId);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduledAtMs = Number.parseInt(match[3], 10);
|
||||||
|
return Number.isNaN(scheduledAtMs) ? null : scheduledAtMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWithinDateRange(timestampMs: number | null, range: { startMs: number; endMs: number } | null): boolean {
|
||||||
|
if (!range) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (timestampMs === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return timestampMs >= range.startMs && timestampMs < range.endMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
const reportDataResponseSchema = {
|
const reportDataResponseSchema = {
|
||||||
@@ -110,10 +180,17 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
||||||
|
|
||||||
const userId = await getUserId(req, reply);
|
const userId = await getUserId(req, reply);
|
||||||
const { medicationIds, takenByFilter } = parsed.data;
|
const { medicationIds, startDate, endDate, takenByFilter } = parsed.data;
|
||||||
const normalizedTakenByFilter = takenByFilter?.length
|
const normalizedTakenByFilter = takenByFilter?.length
|
||||||
? new Set(takenByFilter.map((value) => value.trim()))
|
? new Set(takenByFilter.map((value) => getPersonTagKey(value)))
|
||||||
: null;
|
: null;
|
||||||
|
const dateRange =
|
||||||
|
startDate && endDate
|
||||||
|
? {
|
||||||
|
startMs: new Date(startDate).getTime(),
|
||||||
|
endMs: new Date(endDate).getTime(),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
// Verify all medications belong to this user
|
// Verify all medications belong to this user
|
||||||
const userMeds = await db
|
const userMeds = await db
|
||||||
@@ -152,6 +229,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
|
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
|
||||||
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
|
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
|
||||||
if (!matchesTakenByFilter(dose.doseId, normalizedTakenByFilter)) continue;
|
if (!matchesTakenByFilter(dose.doseId, normalizedTakenByFilter)) continue;
|
||||||
|
if (!isWithinDateRange(getDoseScheduledAtMs(dose.doseId), dateRange)) continue;
|
||||||
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
|
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
|
||||||
dosesByMed.get(medId)!.push({
|
dosesByMed.get(medId)!.push({
|
||||||
takenAt: dose.takenAt,
|
takenAt: dose.takenAt,
|
||||||
@@ -191,10 +269,15 @@ export async function reportRoutes(app: FastifyInstance) {
|
|||||||
const isAmountBased = medication?.packageType === "liquid_container" || medication?.packageType === "tube";
|
const isAmountBased = medication?.packageType === "liquid_container" || medication?.packageType === "tube";
|
||||||
|
|
||||||
// Get refills for this medication scoped to the authenticated user.
|
// Get refills for this medication scoped to the authenticated user.
|
||||||
|
const refillFilters = [eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)];
|
||||||
|
if (dateRange) {
|
||||||
|
refillFilters.push(gte(refillHistory.refillDate, new Date(dateRange.startMs)));
|
||||||
|
refillFilters.push(lt(refillHistory.refillDate, new Date(dateRange.endMs)));
|
||||||
|
}
|
||||||
const refills = await db
|
const refills = await db
|
||||||
.select()
|
.select()
|
||||||
.from(refillHistory)
|
.from(refillHistory)
|
||||||
.where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)));
|
.where(and(...refillFilters));
|
||||||
|
|
||||||
result[medId] = {
|
result[medId] = {
|
||||||
dosesTaken: takenDoses.length,
|
dosesTaken: takenDoses.length,
|
||||||
|
|||||||
+193
-13
@@ -1,5 +1,5 @@
|
|||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
import { and, eq } from "drizzle-orm";
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "../db/client.js";
|
import { db } from "../db/client.js";
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
validationErrorSchema,
|
validationErrorSchema,
|
||||||
} from "../utils/openapi-route-standards.js";
|
} from "../utils/openapi-route-standards.js";
|
||||||
import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
|
import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
|
||||||
|
import { redactTokenForLog } from "../utils/redaction.js";
|
||||||
import {
|
import {
|
||||||
getAllTakenByForMedication,
|
getAllTakenByForMedication,
|
||||||
parseIntakesJson,
|
parseIntakesJson,
|
||||||
@@ -28,6 +29,11 @@ import {
|
|||||||
const createShareSchema = z.object({
|
const createShareSchema = z.object({
|
||||||
takenBy: z.string().min(1, "takenBy is required"),
|
takenBy: z.string().min(1, "takenBy is required"),
|
||||||
scheduleDays: z.number().int().min(1).max(365).default(30),
|
scheduleDays: z.number().int().min(1).max(365).default(30),
|
||||||
|
expiryDays: z
|
||||||
|
.union([z.number().int().min(1).max(365), z.null()])
|
||||||
|
.optional()
|
||||||
|
.default(null),
|
||||||
|
allowJournalNotes: z.boolean().optional().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
|
const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
|
||||||
@@ -37,15 +43,59 @@ const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>
|
|||||||
|
|
||||||
const shareTokenPattern = /^[a-f0-9]{16}$/;
|
const shareTokenPattern = /^[a-f0-9]{16}$/;
|
||||||
|
|
||||||
|
function toIsoTimestamp(value: Date | string | number | null | undefined): string | null {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "number" || (typeof value === "string" && /^\d+$/.test(value))) {
|
||||||
|
const numericValue = typeof value === "number" ? value : Number(value);
|
||||||
|
const timestampMs = numericValue < 1_000_000_000_000 ? numericValue * 1000 : numericValue;
|
||||||
|
const date = new Date(timestampMs);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const date = new Date(value);
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveExpiryDate(expiryDays: number | null | undefined): Date | null {
|
||||||
|
if (expiryDays == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpiredTimestamp(value: Date | string | number | null | undefined): boolean {
|
||||||
|
const isoValue = toIsoTimestamp(value);
|
||||||
|
return isoValue != null && new Date(isoValue).getTime() < Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
const createShareBodyOpenApiSchema = {
|
const createShareBodyOpenApiSchema = {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
takenBy: { type: "string" },
|
takenBy: { type: "string" },
|
||||||
scheduleDays: { type: "integer", minimum: 1, maximum: 365, default: 30 },
|
scheduleDays: { type: "integer", minimum: 1, maximum: 365, default: 30 },
|
||||||
|
allowJournalNotes: { type: "boolean", default: false },
|
||||||
|
expiryDays: {
|
||||||
|
anyOf: [{ type: "integer", minimum: 1, maximum: 365 }, { type: "null" }],
|
||||||
|
default: null,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
example: {
|
example: {
|
||||||
takenBy: "Daniel",
|
takenBy: "Daniel",
|
||||||
scheduleDays: 14,
|
scheduleDays: 14,
|
||||||
|
allowJournalNotes: true,
|
||||||
|
expiryDays: 30,
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -64,6 +114,7 @@ const shareReadResponseSchema = {
|
|||||||
stockCalculationMode: { type: "string", enum: ["automatic", "manual"] },
|
stockCalculationMode: { type: "string", enum: ["automatic", "manual"] },
|
||||||
upcomingTodayOnly: { type: "boolean" },
|
upcomingTodayOnly: { type: "boolean" },
|
||||||
shareScheduleTodayOnly: { type: "boolean" },
|
shareScheduleTodayOnly: { type: "boolean" },
|
||||||
|
allowJournalNotes: { type: "boolean" },
|
||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
@@ -96,6 +147,37 @@ const shareOverviewResponseSchema = {
|
|||||||
},
|
},
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
const shareListResponseSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
shareLinks: {
|
||||||
|
type: "array",
|
||||||
|
items: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
token: { type: "string" },
|
||||||
|
takenBy: { type: "string" },
|
||||||
|
scheduleDays: { type: "integer" },
|
||||||
|
createdAt: { type: "string", format: "date-time" },
|
||||||
|
expiresAt: { type: ["string", "null"], format: "date-time" },
|
||||||
|
allowJournalNotes: { type: "boolean" },
|
||||||
|
shareUrl: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["token", "takenBy", "scheduleDays", "createdAt", "expiresAt", "allowJournalNotes", "shareUrl"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["shareLinks"],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const ownerTokenParamsSchema = {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
token: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["token"],
|
||||||
|
} as const;
|
||||||
|
|
||||||
// Helper to get user ID from request
|
// Helper to get user ID from request
|
||||||
// Returns anonymous user ID when auth is disabled
|
// Returns anonymous user ID when auth is disabled
|
||||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||||
@@ -146,11 +228,12 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
},
|
},
|
||||||
async (request, reply) => {
|
async (request, reply) => {
|
||||||
const { token } = request.params;
|
const { token } = request.params;
|
||||||
|
const tokenRef = redactTokenForLog(token);
|
||||||
|
|
||||||
// Find share token
|
// Find share token
|
||||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||||
if (!share) {
|
if (!share) {
|
||||||
request.log.warn(`[Share] Invalid share token requested: token=${token}`);
|
request.log.warn(`[Share] Invalid share token requested: tokenRef=${tokenRef}`);
|
||||||
return reply.status(404).send({
|
return reply.status(404).send({
|
||||||
error: "Share link not found",
|
error: "Share link not found",
|
||||||
code: "NOT_FOUND",
|
code: "NOT_FOUND",
|
||||||
@@ -160,7 +243,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
// Check if token has expired
|
// Check if token has expired
|
||||||
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
|
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
|
||||||
request.log.warn(
|
request.log.warn(
|
||||||
`[Share] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
|
`[Share] Expired token requested: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
|
||||||
);
|
);
|
||||||
// Get the username of the owner to show in the expired message
|
// Get the username of the owner to show in the expired message
|
||||||
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
|
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
|
||||||
@@ -255,6 +338,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
takenBy: share.takenBy,
|
takenBy: share.takenBy,
|
||||||
sharedBy: owner?.username ?? null,
|
sharedBy: owner?.username ?? null,
|
||||||
scheduleDays: share.scheduleDays,
|
scheduleDays: share.scheduleDays,
|
||||||
|
allowJournalNotes: share.allowJournalNotes ?? false,
|
||||||
medications: medicationsWithBlisters,
|
medications: medicationsWithBlisters,
|
||||||
shareMedicationOverview,
|
shareMedicationOverview,
|
||||||
medicationOverview,
|
medicationOverview,
|
||||||
@@ -298,20 +382,21 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
reply.header("Cache-Control", "no-store");
|
reply.header("Cache-Control", "no-store");
|
||||||
|
|
||||||
const { token } = request.params;
|
const { token } = request.params;
|
||||||
|
const tokenRef = redactTokenForLog(token);
|
||||||
if (!shareTokenPattern.test(token)) {
|
if (!shareTokenPattern.test(token)) {
|
||||||
request.log.warn(`[ShareOverview] Rejected invalid token format: token=${token}`);
|
request.log.warn(`[ShareOverview] Rejected invalid token format: tokenRef=${tokenRef}`);
|
||||||
return reply.status(404).send({ error: "not_found" });
|
return reply.status(404).send({ error: "not_found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||||
if (!share) {
|
if (!share) {
|
||||||
request.log.warn(`[ShareOverview] Unknown token requested: token=${token}`);
|
request.log.warn(`[ShareOverview] Unknown token requested: tokenRef=${tokenRef}`);
|
||||||
return reply.status(404).send({ error: "not_found" });
|
return reply.status(404).send({ error: "not_found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
|
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
|
||||||
request.log.warn(
|
request.log.warn(
|
||||||
`[ShareOverview] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
|
`[ShareOverview] Expired token requested: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
|
||||||
);
|
);
|
||||||
return reply.status(410).send({
|
return reply.status(410).send({
|
||||||
error: "expired",
|
error: "expired",
|
||||||
@@ -371,6 +456,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
reused: { type: "boolean" },
|
reused: { type: "boolean" },
|
||||||
token: { type: "string" },
|
token: { type: "string" },
|
||||||
shareUrl: { type: "string" },
|
shareUrl: { type: "string" },
|
||||||
|
allowJournalNotes: { type: "boolean" },
|
||||||
expiresAt: { type: ["string", "null"] },
|
expiresAt: { type: ["string", "null"] },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -390,7 +476,8 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const { takenBy, scheduleDays } = parsed.data;
|
const { takenBy, scheduleDays, expiryDays, allowJournalNotes } = parsed.data;
|
||||||
|
const expiresAt = resolveExpiryDate(expiryDays);
|
||||||
|
|
||||||
// Check if user has medications for this takenBy (search in both medication-level and intake-level)
|
// Check if user has medications for this takenBy (search in both medication-level and intake-level)
|
||||||
const allMeds = await db
|
const allMeds = await db
|
||||||
@@ -422,43 +509,136 @@ export async function shareRoutes(app: FastifyInstance) {
|
|||||||
.where(and(eq(shareTokens.userId, userId), eq(shareTokens.takenBy, takenBy)));
|
.where(and(eq(shareTokens.userId, userId), eq(shareTokens.takenBy, takenBy)));
|
||||||
|
|
||||||
if (existingShare) {
|
if (existingShare) {
|
||||||
await db.update(shareTokens).set({ scheduleDays, expiresAt: null }).where(eq(shareTokens.id, existingShare.id));
|
const existingTokenRef = redactTokenForLog(existingShare.token);
|
||||||
|
await db
|
||||||
|
.update(shareTokens)
|
||||||
|
.set({ scheduleDays, expiresAt, allowJournalNotes })
|
||||||
|
.where(eq(shareTokens.id, existingShare.id));
|
||||||
|
|
||||||
request.log.info(
|
request.log.info(
|
||||||
`[Share] Reused existing share token: token=${existingShare.token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}`
|
`[Share] Reused existing share token: tokenRef=${existingTokenRef}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}, allowJournalNotes=${allowJournalNotes}, expiresAt=${expiresAt?.toISOString() ?? "never"}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
reused: true,
|
reused: true,
|
||||||
token: existingShare.token,
|
token: existingShare.token,
|
||||||
shareUrl: `/share/${existingShare.token}`,
|
shareUrl: `/share/${existingShare.token}`,
|
||||||
expiresAt: null,
|
allowJournalNotes,
|
||||||
|
expiresAt: toIsoTimestamp(expiresAt),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = randomBytes(8).toString("hex");
|
const token = randomBytes(8).toString("hex");
|
||||||
|
const tokenRef = redactTokenForLog(token);
|
||||||
|
|
||||||
await db.insert(shareTokens).values({
|
await db.insert(shareTokens).values({
|
||||||
userId,
|
userId,
|
||||||
token,
|
token,
|
||||||
takenBy,
|
takenBy,
|
||||||
scheduleDays,
|
scheduleDays,
|
||||||
expiresAt: null,
|
allowJournalNotes,
|
||||||
|
expiresAt,
|
||||||
});
|
});
|
||||||
|
|
||||||
request.log.info(
|
request.log.info(
|
||||||
`[Share] Created new share token: token=${token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}`
|
`[Share] Created new share token: tokenRef=${tokenRef}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}, allowJournalNotes=${allowJournalNotes}, expiresAt=${expiresAt?.toISOString() ?? "never"}`
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
reused: false,
|
reused: false,
|
||||||
token,
|
token,
|
||||||
shareUrl: `/share/${token}`,
|
shareUrl: `/share/${token}`,
|
||||||
expiresAt: null,
|
allowJournalNotes,
|
||||||
|
expiresAt: toIsoTimestamp(expiresAt),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GET /share - PROTECTED: List active share links for current owner
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.get(
|
||||||
|
"/share",
|
||||||
|
{
|
||||||
|
preHandler: requireAuth,
|
||||||
|
schema: {
|
||||||
|
tags: ["share"],
|
||||||
|
security: protectedEndpointSecurity,
|
||||||
|
response: {
|
||||||
|
200: shareListResponseSchema,
|
||||||
|
401: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = await getUserId(request, reply);
|
||||||
|
const shares = await db
|
||||||
|
.select()
|
||||||
|
.from(shareTokens)
|
||||||
|
.where(eq(shareTokens.userId, userId))
|
||||||
|
.orderBy(desc(shareTokens.createdAt));
|
||||||
|
|
||||||
|
return {
|
||||||
|
shareLinks: shares
|
||||||
|
.filter((share) => !isExpiredTimestamp(share.expiresAt))
|
||||||
|
.map((share) => ({
|
||||||
|
token: share.token,
|
||||||
|
takenBy: share.takenBy,
|
||||||
|
scheduleDays: share.scheduleDays,
|
||||||
|
createdAt: toIsoTimestamp(share.createdAt) ?? new Date().toISOString(),
|
||||||
|
expiresAt: toIsoTimestamp(share.expiresAt),
|
||||||
|
allowJournalNotes: share.allowJournalNotes ?? false,
|
||||||
|
shareUrl: `/share/${share.token}`,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// DELETE /share/:token - PROTECTED: Revoke an existing share link
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
app.delete<{ Params: { token: string } }>(
|
||||||
|
"/share/:token",
|
||||||
|
{
|
||||||
|
preHandler: requireAuth,
|
||||||
|
schema: {
|
||||||
|
tags: ["share"],
|
||||||
|
security: protectedEndpointSecurity,
|
||||||
|
params: ownerTokenParamsSchema,
|
||||||
|
response: {
|
||||||
|
204: { type: "null" },
|
||||||
|
401: genericErrorSchema,
|
||||||
|
404: genericErrorSchema,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const userId = await getUserId(request, reply);
|
||||||
|
const { token } = request.params;
|
||||||
|
const tokenRef = redactTokenForLog(token);
|
||||||
|
|
||||||
|
const [share] = await db
|
||||||
|
.select()
|
||||||
|
.from(shareTokens)
|
||||||
|
.where(and(eq(shareTokens.userId, userId), eq(shareTokens.token, token)));
|
||||||
|
|
||||||
|
if (!share) {
|
||||||
|
return reply.status(404).send({
|
||||||
|
error: "Share link not found",
|
||||||
|
code: "NOT_FOUND",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.delete(shareTokens).where(eq(shareTokens.id, share.id));
|
||||||
|
|
||||||
|
request.log.info(
|
||||||
|
`[Share] Revoked share token: tokenRef=${tokenRef}, ownerUserId=${userId}, takenBy=${share.takenBy}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return reply.status(204).send();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// GET /share/people - PROTECTED: Get list of unique takenBy values
|
// GET /share/people - PROTECTED: Get list of unique takenBy values
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { intakeJournal } from "../db/schema.js";
|
||||||
|
|
||||||
|
type IntakeJournalWriteDatabase = Pick<typeof db, "insert">;
|
||||||
|
|
||||||
|
export type IntakeJournalExportPayload = {
|
||||||
|
journalNote: string;
|
||||||
|
journalCreatedAt?: string | null;
|
||||||
|
journalUpdatedAt?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toIsoStringOrNull(value: Date | string | number | null | undefined): string | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toDateOrFallback(value: string | null | undefined, fallback: Date): Date {
|
||||||
|
if (!value) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = new Date(value);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? fallback : parsed;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listIntakeJournalExportPayloadsForUser(
|
||||||
|
userId: number
|
||||||
|
): Promise<Map<number, IntakeJournalExportPayload>> {
|
||||||
|
const rows = await db.select().from(intakeJournal).where(eq(intakeJournal.userId, userId));
|
||||||
|
|
||||||
|
return new Map(
|
||||||
|
rows.map((row) => [
|
||||||
|
row.doseTrackingId,
|
||||||
|
{
|
||||||
|
journalNote: row.note,
|
||||||
|
journalCreatedAt: toIsoStringOrNull(row.createdAt),
|
||||||
|
journalUpdatedAt: toIsoStringOrNull(row.updatedAt),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreIntakeJournalForImportedDose(input: {
|
||||||
|
userId: number;
|
||||||
|
doseTrackingId: number;
|
||||||
|
medicationId: number;
|
||||||
|
scheduledFor: Date;
|
||||||
|
journalNote?: string | null;
|
||||||
|
journalCreatedAt?: string | null;
|
||||||
|
journalUpdatedAt?: string | null;
|
||||||
|
database?: IntakeJournalWriteDatabase;
|
||||||
|
}): Promise<boolean> {
|
||||||
|
const normalizedNote = input.journalNote?.trim() ?? "";
|
||||||
|
if (normalizedNote.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdAt = toDateOrFallback(input.journalCreatedAt, input.scheduledFor);
|
||||||
|
const updatedAt = toDateOrFallback(input.journalUpdatedAt, createdAt);
|
||||||
|
const database = input.database ?? db;
|
||||||
|
|
||||||
|
await database.insert(intakeJournal).values({
|
||||||
|
userId: input.userId,
|
||||||
|
doseTrackingId: input.doseTrackingId,
|
||||||
|
medicationId: input.medicationId,
|
||||||
|
scheduledFor: input.scheduledFor,
|
||||||
|
note: normalizedNote,
|
||||||
|
createdAt,
|
||||||
|
updatedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,332 @@
|
|||||||
|
import { and, desc, eq, gte, lte } from "drizzle-orm";
|
||||||
|
import { db } from "../db/client.js";
|
||||||
|
import { doseTracking, intakeJournal, medications } from "../db/schema.js";
|
||||||
|
import { parseIntakesJson, parseLocalDateTime } from "../utils/scheduler-utils.js";
|
||||||
|
import type { DoseTrackingSource } from "./dose-tracking-service.js";
|
||||||
|
|
||||||
|
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
|
||||||
|
|
||||||
|
type ParsedDoseId = {
|
||||||
|
medicationId: number;
|
||||||
|
intakeIndex: number;
|
||||||
|
timestampMs: number;
|
||||||
|
personSuffix: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MedicationTimingRow = {
|
||||||
|
id: number;
|
||||||
|
name: string | null;
|
||||||
|
genericName: string | null;
|
||||||
|
intakesJson: string;
|
||||||
|
usageJson: string;
|
||||||
|
everyJson: string;
|
||||||
|
startJson: string;
|
||||||
|
intakeRemindersEnabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ResolvedTrackedDoseEvent = {
|
||||||
|
doseTrackingId: number;
|
||||||
|
userId: number;
|
||||||
|
doseId: string;
|
||||||
|
medicationId: number;
|
||||||
|
medicationName: string;
|
||||||
|
scheduledFor: Date;
|
||||||
|
takenAt: Date;
|
||||||
|
markedBy: string | null;
|
||||||
|
takenSource: DoseTrackingSource;
|
||||||
|
dismissed: boolean;
|
||||||
|
personSuffix: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IntakeJournalEntry = typeof intakeJournal.$inferSelect;
|
||||||
|
|
||||||
|
export type IntakeJournalHistoryEntry = {
|
||||||
|
id: number;
|
||||||
|
doseTrackingId: number;
|
||||||
|
doseId: string;
|
||||||
|
medicationId: number;
|
||||||
|
medicationName: string;
|
||||||
|
scheduledFor: Date;
|
||||||
|
takenAt: Date;
|
||||||
|
markedBy: string | null;
|
||||||
|
takenSource: DoseTrackingSource;
|
||||||
|
dismissed: boolean;
|
||||||
|
note: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseDoseId(doseId: string): ParsedDoseId | null {
|
||||||
|
const match = doseIdPattern.exec(doseId);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const medicationId = Number.parseInt(match[1], 10);
|
||||||
|
const intakeIndex = Number.parseInt(match[2], 10);
|
||||||
|
const timestampMs = Number.parseInt(match[3], 10);
|
||||||
|
const personSuffix = match[4] ? match[4].trim() : null;
|
||||||
|
|
||||||
|
if (Number.isNaN(medicationId) || Number.isNaN(intakeIndex) || Number.isNaN(timestampMs) || intakeIndex < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
medicationId,
|
||||||
|
intakeIndex,
|
||||||
|
timestampMs,
|
||||||
|
personSuffix,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isTrackedDoseIdFormat(doseId: string): boolean {
|
||||||
|
return parseDoseId(doseId) !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMedicationDisplayName(medication: Pick<MedicationTimingRow, "id" | "name" | "genericName">): string {
|
||||||
|
const commercialName = medication.name?.trim() ?? "";
|
||||||
|
if (commercialName.length > 0) {
|
||||||
|
return commercialName;
|
||||||
|
}
|
||||||
|
|
||||||
|
const genericName = medication.genericName?.trim() ?? "";
|
||||||
|
if (genericName.length > 0) {
|
||||||
|
return genericName;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `Medication #${medication.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveScheduledFor(parsedDose: ParsedDoseId, medication: MedicationTimingRow): Date {
|
||||||
|
const intakes = parseIntakesJson(
|
||||||
|
medication.intakesJson,
|
||||||
|
{
|
||||||
|
usageJson: medication.usageJson,
|
||||||
|
everyJson: medication.everyJson,
|
||||||
|
startJson: medication.startJson,
|
||||||
|
},
|
||||||
|
medication.intakeRemindersEnabled
|
||||||
|
);
|
||||||
|
const intake = intakes[parsedDose.intakeIndex];
|
||||||
|
if (!intake) {
|
||||||
|
return new Date(parsedDose.timestampMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const doseDate = new Date(parsedDose.timestampMs);
|
||||||
|
const intakeStart = parseLocalDateTime(intake.start);
|
||||||
|
|
||||||
|
return new Date(
|
||||||
|
doseDate.getFullYear(),
|
||||||
|
doseDate.getMonth(),
|
||||||
|
doseDate.getDate(),
|
||||||
|
intakeStart.getHours(),
|
||||||
|
intakeStart.getMinutes(),
|
||||||
|
intakeStart.getSeconds(),
|
||||||
|
intakeStart.getMilliseconds()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resolveTrackedDoseEventForUser(input: {
|
||||||
|
userId: number;
|
||||||
|
doseId: string;
|
||||||
|
}): Promise<ResolvedTrackedDoseEvent | null> {
|
||||||
|
const parsedDose = parseDoseId(input.doseId);
|
||||||
|
if (!parsedDose) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [event] = await db
|
||||||
|
.select({
|
||||||
|
doseTrackingId: doseTracking.id,
|
||||||
|
userId: doseTracking.userId,
|
||||||
|
doseId: doseTracking.doseId,
|
||||||
|
takenAt: doseTracking.takenAt,
|
||||||
|
markedBy: doseTracking.markedBy,
|
||||||
|
takenSource: doseTracking.takenSource,
|
||||||
|
dismissed: doseTracking.dismissed,
|
||||||
|
medicationId: medications.id,
|
||||||
|
medicationName: medications.name,
|
||||||
|
medicationGenericName: medications.genericName,
|
||||||
|
intakesJson: medications.intakesJson,
|
||||||
|
usageJson: medications.usageJson,
|
||||||
|
everyJson: medications.everyJson,
|
||||||
|
startJson: medications.startJson,
|
||||||
|
intakeRemindersEnabled: medications.intakeRemindersEnabled,
|
||||||
|
})
|
||||||
|
.from(doseTracking)
|
||||||
|
.innerJoin(medications, and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, input.userId)))
|
||||||
|
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
if (!event) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduledFor = resolveScheduledFor(parsedDose, {
|
||||||
|
id: event.medicationId,
|
||||||
|
name: event.medicationName,
|
||||||
|
genericName: event.medicationGenericName,
|
||||||
|
intakesJson: event.intakesJson,
|
||||||
|
usageJson: event.usageJson,
|
||||||
|
everyJson: event.everyJson,
|
||||||
|
startJson: event.startJson,
|
||||||
|
intakeRemindersEnabled: event.intakeRemindersEnabled ?? false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
doseTrackingId: event.doseTrackingId,
|
||||||
|
userId: event.userId,
|
||||||
|
doseId: event.doseId,
|
||||||
|
medicationId: event.medicationId,
|
||||||
|
medicationName: getMedicationDisplayName({
|
||||||
|
id: event.medicationId,
|
||||||
|
name: event.medicationName,
|
||||||
|
genericName: event.medicationGenericName,
|
||||||
|
}),
|
||||||
|
scheduledFor,
|
||||||
|
takenAt: event.takenAt,
|
||||||
|
markedBy: event.markedBy,
|
||||||
|
takenSource: event.takenSource as DoseTrackingSource,
|
||||||
|
dismissed: event.dismissed ?? false,
|
||||||
|
personSuffix: parsedDose.personSuffix,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getIntakeJournalForDoseEvent(input: {
|
||||||
|
userId: number;
|
||||||
|
doseId: string;
|
||||||
|
}): Promise<IntakeJournalEntry | null> {
|
||||||
|
const event = await resolveTrackedDoseEventForUser(input);
|
||||||
|
if (!event) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [journalEntry] = await db
|
||||||
|
.select()
|
||||||
|
.from(intakeJournal)
|
||||||
|
.where(and(eq(intakeJournal.userId, input.userId), eq(intakeJournal.doseTrackingId, event.doseTrackingId)))
|
||||||
|
.limit(1);
|
||||||
|
|
||||||
|
return journalEntry ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function upsertIntakeJournalForDoseEvent(input: {
|
||||||
|
userId: number;
|
||||||
|
doseId: string;
|
||||||
|
note: string;
|
||||||
|
}): Promise<IntakeJournalEntry | null> {
|
||||||
|
const normalizedNote = input.note.trim();
|
||||||
|
if (normalizedNote.length === 0) {
|
||||||
|
await deleteIntakeJournalForDoseEvent({ userId: input.userId, doseId: input.doseId });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const event = await resolveTrackedDoseEventForUser({ userId: input.userId, doseId: input.doseId });
|
||||||
|
if (!event) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
await db
|
||||||
|
.insert(intakeJournal)
|
||||||
|
.values({
|
||||||
|
userId: input.userId,
|
||||||
|
doseTrackingId: event.doseTrackingId,
|
||||||
|
medicationId: event.medicationId,
|
||||||
|
scheduledFor: event.scheduledFor,
|
||||||
|
note: normalizedNote,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.onConflictDoUpdate({
|
||||||
|
target: intakeJournal.doseTrackingId,
|
||||||
|
set: {
|
||||||
|
userId: input.userId,
|
||||||
|
medicationId: event.medicationId,
|
||||||
|
note: normalizedNote,
|
||||||
|
updatedAt: now,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return getIntakeJournalForDoseEvent({ userId: input.userId, doseId: input.doseId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteIntakeJournalForDoseEvent(input: { userId: number; doseId: string }): Promise<boolean> {
|
||||||
|
const event = await resolveTrackedDoseEventForUser(input);
|
||||||
|
if (!event) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await db
|
||||||
|
.delete(intakeJournal)
|
||||||
|
.where(and(eq(intakeJournal.userId, input.userId), eq(intakeJournal.doseTrackingId, event.doseTrackingId)));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listIntakeJournalEntriesForUser(input: {
|
||||||
|
userId: number;
|
||||||
|
medicationId?: number;
|
||||||
|
from?: Date;
|
||||||
|
to?: Date;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<IntakeJournalHistoryEntry[]> {
|
||||||
|
const filters = [eq(intakeJournal.userId, input.userId)];
|
||||||
|
|
||||||
|
if (typeof input.medicationId === "number") {
|
||||||
|
filters.push(eq(intakeJournal.medicationId, input.medicationId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.from) {
|
||||||
|
filters.push(gte(intakeJournal.scheduledFor, input.from));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.to) {
|
||||||
|
filters.push(lte(intakeJournal.scheduledFor, input.to));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = await db
|
||||||
|
.select({
|
||||||
|
id: intakeJournal.id,
|
||||||
|
doseTrackingId: intakeJournal.doseTrackingId,
|
||||||
|
doseId: doseTracking.doseId,
|
||||||
|
medicationId: intakeJournal.medicationId,
|
||||||
|
medicationName: medications.name,
|
||||||
|
medicationGenericName: medications.genericName,
|
||||||
|
scheduledFor: intakeJournal.scheduledFor,
|
||||||
|
takenAt: doseTracking.takenAt,
|
||||||
|
markedBy: doseTracking.markedBy,
|
||||||
|
takenSource: doseTracking.takenSource,
|
||||||
|
dismissed: doseTracking.dismissed,
|
||||||
|
note: intakeJournal.note,
|
||||||
|
createdAt: intakeJournal.createdAt,
|
||||||
|
updatedAt: intakeJournal.updatedAt,
|
||||||
|
})
|
||||||
|
.from(intakeJournal)
|
||||||
|
.innerJoin(doseTracking, eq(doseTracking.id, intakeJournal.doseTrackingId))
|
||||||
|
.innerJoin(medications, eq(medications.id, intakeJournal.medicationId))
|
||||||
|
.where(and(...filters))
|
||||||
|
.orderBy(desc(intakeJournal.scheduledFor), desc(intakeJournal.updatedAt))
|
||||||
|
.limit(input.limit ?? 100);
|
||||||
|
|
||||||
|
return rows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
doseTrackingId: row.doseTrackingId,
|
||||||
|
doseId: row.doseId,
|
||||||
|
medicationId: row.medicationId,
|
||||||
|
medicationName: getMedicationDisplayName({
|
||||||
|
id: row.medicationId,
|
||||||
|
name: row.medicationName,
|
||||||
|
genericName: row.medicationGenericName,
|
||||||
|
}),
|
||||||
|
scheduledFor: row.scheduledFor,
|
||||||
|
takenAt: row.takenAt,
|
||||||
|
markedBy: row.markedBy,
|
||||||
|
takenSource: row.takenSource as DoseTrackingSource,
|
||||||
|
dismissed: row.dismissed ?? false,
|
||||||
|
note: row.note,
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -149,6 +149,10 @@ async function createActionTokens(groupId: number): Promise<Record<ActiveTokenKi
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function resetActionTokens(groupId: number): Promise<void> {
|
||||||
|
await db.delete(notificationActionTokens).where(eq(notificationActionTokens.groupId, groupId));
|
||||||
|
}
|
||||||
|
|
||||||
export async function createNotificationActionContext(input: {
|
export async function createNotificationActionContext(input: {
|
||||||
userId: number;
|
userId: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -198,21 +202,47 @@ export async function createNotificationActionContext(input: {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!group) {
|
if (!group) {
|
||||||
[group] = await db
|
const [existingGroup] = await db
|
||||||
.insert(notificationActionGroups)
|
.select()
|
||||||
.values({
|
.from(notificationActionGroups)
|
||||||
userId: input.userId,
|
.where(eq(notificationActionGroups.groupKey, groupKey));
|
||||||
groupKey,
|
|
||||||
sequenceId,
|
if (existingGroup) {
|
||||||
doseIdsJson: JSON.stringify(uniqueDoseIds),
|
await resetActionTokens(existingGroup.id);
|
||||||
title: input.title,
|
[group] = await db
|
||||||
message: input.message,
|
.update(notificationActionGroups)
|
||||||
language: input.language,
|
.set({
|
||||||
scheduledFor: input.scheduledFor,
|
sequenceId,
|
||||||
expiresAt,
|
ntfyOriginalMessageId: "",
|
||||||
updatedAt: now,
|
doseIdsJson: JSON.stringify(uniqueDoseIds),
|
||||||
})
|
title: input.title,
|
||||||
.returning();
|
message: input.message,
|
||||||
|
language: input.language,
|
||||||
|
scheduledFor: input.scheduledFor,
|
||||||
|
expiresAt,
|
||||||
|
resolvedAction: null,
|
||||||
|
resolvedAt: null,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.where(eq(notificationActionGroups.id, existingGroup.id))
|
||||||
|
.returning();
|
||||||
|
} else {
|
||||||
|
[group] = await db
|
||||||
|
.insert(notificationActionGroups)
|
||||||
|
.values({
|
||||||
|
userId: input.userId,
|
||||||
|
groupKey,
|
||||||
|
sequenceId,
|
||||||
|
doseIdsJson: JSON.stringify(uniqueDoseIds),
|
||||||
|
title: input.title,
|
||||||
|
message: input.message,
|
||||||
|
language: input.language,
|
||||||
|
scheduledFor: input.scheduledFor,
|
||||||
|
expiresAt,
|
||||||
|
updatedAt: now,
|
||||||
|
})
|
||||||
|
.returning();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokens = await createActionTokens(group.id);
|
const tokens = await createActionTokens(group.id);
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const __dirname = dirname(__filename);
|
|||||||
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||||
|
|
||||||
async function clearTables() {
|
async function clearTables() {
|
||||||
|
await testClient.execute("DELETE FROM intake_journal");
|
||||||
await testClient.execute("DELETE FROM dose_tracking");
|
await testClient.execute("DELETE FROM dose_tracking");
|
||||||
await testClient.execute("DELETE FROM share_tokens");
|
await testClient.execute("DELETE FROM share_tokens");
|
||||||
await testClient.execute("DELETE FROM api_keys");
|
await testClient.execute("DELETE FROM api_keys");
|
||||||
@@ -78,20 +79,30 @@ async function insertMedication(options: {
|
|||||||
start?: string;
|
start?: string;
|
||||||
}) {
|
}) {
|
||||||
const intakeStart = options.start ?? "2025-01-01T08:00:00.000Z";
|
const intakeStart = options.start ?? "2025-01-01T08:00:00.000Z";
|
||||||
|
const takenBy = options.takenBy ?? [];
|
||||||
|
const intakeTakenBy = takenBy[0] ?? null;
|
||||||
await testClient.execute({
|
await testClient.execute({
|
||||||
sql: `INSERT INTO medications (
|
sql: `INSERT INTO medications (
|
||||||
id, user_id, name, taken_by_json, medication_form, package_type,
|
id, user_id, name, taken_by_json, medication_form, package_type,
|
||||||
pack_count, blisters_per_pack, pills_per_blister, loose_tablets, stock_adjustment,
|
pack_count, blisters_per_pack, pills_per_blister, loose_tablets, stock_adjustment,
|
||||||
usage_json, every_json, start_json, intakes_json, intake_reminders_enabled
|
usage_json, every_json, start_json, intakes_json, intake_reminders_enabled
|
||||||
) VALUES (?, ?, 'Test Medication', ?, 'tablet', 'blister', ?, 1, 10, ?, 0, '[1]', '[1]', ?, '[]', 0)`,
|
) VALUES (?, ?, 'Test Medication', ?, 'tablet', 'blister', ?, 1, 10, ?, 0, '[1]', '[1]', ?, ?, 0)`,
|
||||||
args: [
|
args: [
|
||||||
options.id,
|
options.id,
|
||||||
options.userId,
|
options.userId,
|
||||||
JSON.stringify(options.takenBy ?? []),
|
JSON.stringify(takenBy),
|
||||||
options.packCount ?? 1,
|
options.packCount ?? 1,
|
||||||
options.looseTablets ?? 0,
|
options.looseTablets ?? 0,
|
||||||
intakeStart,
|
intakeStart,
|
||||||
"[]",
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
usage: 1,
|
||||||
|
every: 1,
|
||||||
|
start: intakeStart,
|
||||||
|
takenBy: intakeTakenBy,
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
},
|
||||||
|
]),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -103,13 +114,24 @@ async function insertUserSettings(userId: number, stockCalculationMode: "automat
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function _insertShareToken(userId: number, token: string, takenBy: string) {
|
async function _insertShareToken(userId: number, token: string, takenBy: string, allowJournalNotes = false) {
|
||||||
await testClient.execute({
|
await testClient.execute({
|
||||||
sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, 30)",
|
sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, allow_journal_notes) VALUES (?, ?, ?, 30, ?)",
|
||||||
args: [userId, token, takenBy],
|
args: [userId, token, takenBy, allowJournalNotes ? 1 : 0],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function buildLocalDoseStart(hours = 8): string {
|
||||||
|
const start = new Date();
|
||||||
|
start.setHours(hours, 0, 0, 0);
|
||||||
|
const year = start.getFullYear();
|
||||||
|
const month = String(start.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(start.getDate()).padStart(2, "0");
|
||||||
|
const hour = String(start.getHours()).padStart(2, "0");
|
||||||
|
|
||||||
|
return `${year}-${month}-${day}T${hour}:00:00.000`;
|
||||||
|
}
|
||||||
|
|
||||||
async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
|
async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
|
||||||
const token = await app.jwt.sign({ sub: userId, username });
|
const token = await app.jwt.sign({ sub: userId, username });
|
||||||
return `access_token=${token}`;
|
return `access_token=${token}`;
|
||||||
@@ -458,6 +480,48 @@ describe("Dose Tracking API", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("single-dose skip routes", () => {
|
||||||
|
it("marks a single owner dose as skipped through the frontend route", async () => {
|
||||||
|
const doseId = "1-0-1735344000000";
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/doses/skip",
|
||||||
|
headers: { cookie: cookieHeader },
|
||||||
|
payload: { doseId },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ success: true });
|
||||||
|
|
||||||
|
const result = await testClient.execute({
|
||||||
|
sql: "SELECT dose_id, marked_by, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||||
|
args: [userId, doseId],
|
||||||
|
});
|
||||||
|
expect(result.rows).toEqual([expect.objectContaining({ dose_id: doseId, marked_by: null, dismissed: 1 })]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("undoes a skipped-only owner dose through the frontend route", async () => {
|
||||||
|
const doseId = "1-0-1735344000000";
|
||||||
|
await insertDose({ userId, doseId, dismissed: true, takenAt: null });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/doses/skip/${encodeURIComponent(doseId)}`,
|
||||||
|
headers: { cookie: cookieHeader },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ success: true });
|
||||||
|
|
||||||
|
const result = await testClient.execute({
|
||||||
|
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||||
|
args: [userId, doseId],
|
||||||
|
});
|
||||||
|
expect(Number(result.rows[0].count)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("DELETE /doses/dismiss", () => {
|
describe("DELETE /doses/dismiss", () => {
|
||||||
it("clears dismissed-only records and removes the dismissed flag from taken doses", async () => {
|
it("clears dismissed-only records and removes the dismissed flag from taken doses", async () => {
|
||||||
await insertDose({ userId, doseId: "1-0-1735344000000", dismissed: true, takenAt: null });
|
await insertDose({ userId, doseId: "1-0-1735344000000", dismissed: true, takenAt: null });
|
||||||
@@ -481,4 +545,174 @@ describe("Dose Tracking API", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("shared single-dose skip routes", () => {
|
||||||
|
it("marks and undoes a visible shared dose as skipped", async () => {
|
||||||
|
const start = buildLocalDoseStart();
|
||||||
|
await insertMedication({
|
||||||
|
id: 6,
|
||||||
|
userId,
|
||||||
|
takenBy: ["Max"],
|
||||||
|
start,
|
||||||
|
});
|
||||||
|
await _insertShareToken(userId, "share-skip-token", "Max", false);
|
||||||
|
|
||||||
|
const doseId = `6-0-${new Date(start).getTime()}-Max`;
|
||||||
|
|
||||||
|
const skipResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/share/share-skip-token/doses/skip",
|
||||||
|
payload: { doseId },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(skipResponse.statusCode).toBe(200);
|
||||||
|
expect(skipResponse.json()).toEqual({ success: true });
|
||||||
|
|
||||||
|
const skippedRows = await testClient.execute({
|
||||||
|
sql: "SELECT dose_id, marked_by, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||||
|
args: [userId, doseId],
|
||||||
|
});
|
||||||
|
expect(skippedRows.rows).toEqual([expect.objectContaining({ dose_id: doseId, marked_by: null, dismissed: 1 })]);
|
||||||
|
|
||||||
|
const undoResponse = await app.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/share/share-skip-token/doses/skip/${encodeURIComponent(doseId)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(undoResponse.statusCode).toBe(200);
|
||||||
|
expect(undoResponse.json()).toEqual({ success: true });
|
||||||
|
|
||||||
|
const remainingRows = await testClient.execute({
|
||||||
|
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||||
|
args: [userId, doseId],
|
||||||
|
});
|
||||||
|
expect(Number(remainingRows.rows[0].count)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Shared journal notes", () => {
|
||||||
|
it("rejects shared journal access when the share link does not allow notes", async () => {
|
||||||
|
const start = buildLocalDoseStart();
|
||||||
|
await insertMedication({
|
||||||
|
id: 7,
|
||||||
|
userId,
|
||||||
|
takenBy: ["Max"],
|
||||||
|
start,
|
||||||
|
});
|
||||||
|
await _insertShareToken(userId, "token-no-notes", "Max", false);
|
||||||
|
|
||||||
|
const doseId = `7-0-${new Date(start).getTime()}-Max`;
|
||||||
|
await insertDose({ userId, doseId, markedBy: "Max" });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/share/token-no-notes/journal/event/${encodeURIComponent(doseId)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(403);
|
||||||
|
expect(response.json()).toEqual({
|
||||||
|
error: "Journal notes are not enabled for this share link",
|
||||||
|
code: "NOT_ENABLED",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports shared journal note read and save, but not implicit or explicit delete", async () => {
|
||||||
|
const start = buildLocalDoseStart();
|
||||||
|
await insertMedication({
|
||||||
|
id: 8,
|
||||||
|
userId,
|
||||||
|
takenBy: ["Max"],
|
||||||
|
start,
|
||||||
|
});
|
||||||
|
await _insertShareToken(userId, "token-with-notes", "Max", true);
|
||||||
|
|
||||||
|
const doseId = `8-0-${new Date(start).getTime()}-Max`;
|
||||||
|
await insertDose({ userId, doseId, markedBy: "Max" });
|
||||||
|
|
||||||
|
const initialResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(initialResponse.statusCode).toBe(200);
|
||||||
|
expect(initialResponse.json().entry).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
doseId,
|
||||||
|
markedBy: "Max",
|
||||||
|
note: null,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialDosesResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/share/token-with-notes/doses",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(initialDosesResponse.statusCode).toBe(200);
|
||||||
|
expect(initialDosesResponse.json().doses).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
doseId,
|
||||||
|
hasJournalNote: false,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const saveResponse = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`,
|
||||||
|
payload: { note: "Shared note from Max" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saveResponse.statusCode).toBe(200);
|
||||||
|
expect(saveResponse.json().entry).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
doseId,
|
||||||
|
note: "Shared note from Max",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const savedDosesResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/share/token-with-notes/doses",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(savedDosesResponse.statusCode).toBe(200);
|
||||||
|
expect(savedDosesResponse.json().doses).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
doseId,
|
||||||
|
hasJournalNote: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const blankSaveResponse = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`,
|
||||||
|
payload: { note: " " },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(blankSaveResponse.statusCode).toBe(400);
|
||||||
|
expect(blankSaveResponse.json()).toEqual({
|
||||||
|
error: "Journal note cannot be empty",
|
||||||
|
code: "EMPTY_NOTE",
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteResponse = await app.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deleteResponse.statusCode).toBe(403);
|
||||||
|
expect(deleteResponse.json()).toEqual({
|
||||||
|
error: "Shared links cannot delete journal notes",
|
||||||
|
code: "DELETE_NOT_ALLOWED",
|
||||||
|
});
|
||||||
|
|
||||||
|
const journalRows = await testClient.execute({
|
||||||
|
sql: "SELECT note FROM intake_journal WHERE user_id = ? AND medication_id = ?",
|
||||||
|
args: [userId, 8],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(journalRows.rows).toHaveLength(1);
|
||||||
|
expect(journalRows.rows[0].note).toBe("Shared note from Max");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* These tests import the actual route handlers for real coverage.
|
* These tests import the actual route handlers for real coverage.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { existsSync, unlinkSync } from "node:fs";
|
||||||
import cookie from "@fastify/cookie";
|
import cookie from "@fastify/cookie";
|
||||||
import fastifyMultipart from "@fastify/multipart";
|
import fastifyMultipart from "@fastify/multipart";
|
||||||
import sensible from "@fastify/sensible";
|
import sensible from "@fastify/sensible";
|
||||||
@@ -13,13 +14,16 @@ import { jwtPlugin } from "../plugins/jwt.js";
|
|||||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||||
|
|
||||||
// Use vi.hoisted to create the db BEFORE mocks are set up
|
// Use vi.hoisted to create the db BEFORE mocks are set up
|
||||||
const { testClient, testDb } = vi.hoisted(() => {
|
const { testClient, testDb, testDbPath } = vi.hoisted(() => {
|
||||||
// Dynamic import inside hoisted block
|
// Dynamic import inside hoisted block
|
||||||
const { createClient } = require("@libsql/client");
|
const { createClient } = require("@libsql/client");
|
||||||
const { drizzle } = require("drizzle-orm/libsql");
|
const { drizzle } = require("drizzle-orm/libsql");
|
||||||
const client = createClient({ url: ":memory:" });
|
const { tmpdir } = require("node:os");
|
||||||
|
const { join } = require("node:path");
|
||||||
|
const dbPath = join(tmpdir(), `medassist-e2e-routes-${process.pid}-${Date.now()}.db`);
|
||||||
|
const client = createClient({ url: `file:${dbPath}` });
|
||||||
const db = drizzle(client);
|
const db = drizzle(client);
|
||||||
return { testClient: client, testDb: db };
|
return { testClient: client, testDb: db, testDbPath: dbPath };
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock modules using the hoisted db
|
// Mock modules using the hoisted db
|
||||||
@@ -171,6 +175,7 @@ async function createSchema(client: Client) {
|
|||||||
token text NOT NULL UNIQUE,
|
token text NOT NULL UNIQUE,
|
||||||
taken_by text NOT NULL,
|
taken_by text NOT NULL,
|
||||||
schedule_days integer NOT NULL DEFAULT 30,
|
schedule_days integer NOT NULL DEFAULT 30,
|
||||||
|
allow_journal_notes integer NOT NULL DEFAULT 0,
|
||||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
expires_at integer,
|
expires_at integer,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
@@ -184,6 +189,19 @@ async function createSchema(client: Client) {
|
|||||||
taken_source text NOT NULL DEFAULT 'manual',
|
taken_source text NOT NULL DEFAULT 'manual',
|
||||||
dismissed integer NOT NULL DEFAULT 0,
|
dismissed integer NOT NULL DEFAULT 0,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS intake_journal (
|
||||||
|
id integer PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id integer NOT NULL,
|
||||||
|
dose_tracking_id integer NOT NULL UNIQUE,
|
||||||
|
medication_id integer NOT NULL,
|
||||||
|
scheduled_for integer NOT NULL,
|
||||||
|
note text NOT NULL,
|
||||||
|
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (dose_tracking_id) REFERENCES dose_tracking(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (medication_id) REFERENCES medications(id) ON DELETE CASCADE
|
||||||
)`,
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS refill_history (
|
`CREATE TABLE IF NOT EXISTS refill_history (
|
||||||
id integer PRIMARY KEY AUTOINCREMENT,
|
id integer PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -204,6 +222,7 @@ async function createSchema(client: Client) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function clearData(client: Client) {
|
async function clearData(client: Client) {
|
||||||
|
await client.execute("DELETE FROM intake_journal");
|
||||||
await client.execute("DELETE FROM refill_history");
|
await client.execute("DELETE FROM refill_history");
|
||||||
await client.execute("DELETE FROM dose_tracking");
|
await client.execute("DELETE FROM dose_tracking");
|
||||||
await client.execute("DELETE FROM share_tokens");
|
await client.execute("DELETE FROM share_tokens");
|
||||||
@@ -222,10 +241,11 @@ async function _createUser(client: Client, username: string): Promise<number> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createMedication(client: Client, userId: number, name: string, takenBy: string[]): Promise<number> {
|
async function createMedication(client: Client, userId: number, name: string, takenBy: string[]): Promise<number> {
|
||||||
|
const start = new Date(visibleDoseTimestampMs()).toISOString();
|
||||||
const result = await client.execute({
|
const result = await client.execute({
|
||||||
sql: `INSERT INTO medications (user_id, name, taken_by_json, usage_json, every_json, start_json)
|
sql: `INSERT INTO medications (user_id, name, taken_by_json, usage_json, every_json, start_json)
|
||||||
VALUES (?, ?, ?, '[1]', '[1]', '["2025-01-01T08:00:00.000Z"]') RETURNING id`,
|
VALUES (?, ?, ?, '[1]', '[1]', ?) RETURNING id`,
|
||||||
args: [userId, name, JSON.stringify(takenBy)],
|
args: [userId, name, JSON.stringify(takenBy), JSON.stringify([start])],
|
||||||
});
|
});
|
||||||
return result.rows[0].id as number;
|
return result.rows[0].id as number;
|
||||||
}
|
}
|
||||||
@@ -237,6 +257,12 @@ async function createShareToken(client: Client, userId: number, takenBy: string,
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function visibleDoseTimestampMs(): number {
|
||||||
|
const doseDate = new Date();
|
||||||
|
doseDate.setHours(8, 0, 0, 0);
|
||||||
|
return doseDate.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// E2E Tests with Real Routes
|
// E2E Tests with Real Routes
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -386,6 +412,11 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await app.close();
|
await app.close();
|
||||||
testClient.close();
|
testClient.close();
|
||||||
|
for (const path of [testDbPath, `${testDbPath}-shm`, `${testDbPath}-wal`]) {
|
||||||
|
if (existsSync(path)) {
|
||||||
|
unlinkSync(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -508,12 +539,12 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should mark dose via share link using real route", async () => {
|
it("should mark dose via share link using real route", async () => {
|
||||||
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
|
const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
|
||||||
|
|
||||||
const token = "test_share_token_456";
|
const token = "test_share_token_456";
|
||||||
await createShareToken(testClient, userId, "Daniel", token);
|
await createShareToken(testClient, userId, "Daniel", token);
|
||||||
|
|
||||||
const doseId = "1-0-1735344000000";
|
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: `/share/${token}/doses`,
|
url: `/share/${token}/doses`,
|
||||||
@@ -1039,13 +1070,13 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should unmark dose via share link", async () => {
|
it("should unmark dose via share link", async () => {
|
||||||
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
|
const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
|
||||||
|
|
||||||
const token = "test_delete_dose_token";
|
const token = "test_delete_dose_token";
|
||||||
await createShareToken(testClient, userId, "Daniel", token);
|
await createShareToken(testClient, userId, "Daniel", token);
|
||||||
|
|
||||||
// First mark the dose
|
// First mark the dose
|
||||||
const doseId = "1-0-1735344000000";
|
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
|
||||||
await testClient.execute({
|
await testClient.execute({
|
||||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`,
|
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`,
|
||||||
args: [userId, doseId, "Daniel"],
|
args: [userId, doseId, "Daniel"],
|
||||||
@@ -1089,12 +1120,12 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("should return already marked message for duplicate dose", async () => {
|
it("should return already marked message for duplicate dose", async () => {
|
||||||
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
|
const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
|
||||||
|
|
||||||
const token = "test_duplicate_token";
|
const token = "test_duplicate_token";
|
||||||
await createShareToken(testClient, userId, "Daniel", token);
|
await createShareToken(testClient, userId, "Daniel", token);
|
||||||
|
|
||||||
const doseId = "1-0-1735344000000";
|
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
|
||||||
|
|
||||||
// Mark the dose first time
|
// Mark the dose first time
|
||||||
await app.inject({
|
await app.inject({
|
||||||
@@ -1530,6 +1561,59 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
describe("Share token management", () => {
|
describe("Share token management", () => {
|
||||||
|
it("should list active share links for the owner", async () => {
|
||||||
|
await createMedication(testClient, userId, "Med1", ["Daniel"]);
|
||||||
|
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/share",
|
||||||
|
payload: {
|
||||||
|
takenBy: "Daniel",
|
||||||
|
scheduleDays: 90,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const listResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/share",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(listResponse.statusCode).toBe(200);
|
||||||
|
const data = listResponse.json();
|
||||||
|
expect(data.shareLinks).toHaveLength(1);
|
||||||
|
expect(data.shareLinks[0].takenBy).toBe("Daniel");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should revoke an active share link", async () => {
|
||||||
|
await createMedication(testClient, userId, "Med1", ["Daniel"]);
|
||||||
|
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/share",
|
||||||
|
payload: {
|
||||||
|
takenBy: "Daniel",
|
||||||
|
scheduleDays: 30,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { token } = createResponse.json();
|
||||||
|
const revokeResponse = await app.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/share/${token}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(revokeResponse.statusCode).toBe(204);
|
||||||
|
|
||||||
|
const publicResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/share/${token}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(publicResponse.statusCode).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
it("should create share token with custom scheduleDays", async () => {
|
it("should create share token with custom scheduleDays", async () => {
|
||||||
await createMedication(testClient, userId, "Med1", ["Daniel"]);
|
await createMedication(testClient, userId, "Med1", ["Daniel"]);
|
||||||
|
|
||||||
@@ -1548,6 +1632,34 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(data.expiresAt).toBeDefined();
|
expect(data.expiresAt).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should create a share token with an expiry and keep it in the active owner list", async () => {
|
||||||
|
await createMedication(testClient, userId, "Med1", ["Daniel"]);
|
||||||
|
|
||||||
|
const createResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/share",
|
||||||
|
payload: {
|
||||||
|
takenBy: "Daniel",
|
||||||
|
scheduleDays: 30,
|
||||||
|
expiryDays: 7,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(createResponse.statusCode).toBe(200);
|
||||||
|
const created = createResponse.json();
|
||||||
|
expect(created.expiresAt).toBeTruthy();
|
||||||
|
|
||||||
|
const listResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/share",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(listResponse.statusCode).toBe(200);
|
||||||
|
const listData = listResponse.json();
|
||||||
|
expect(listData.shareLinks).toHaveLength(1);
|
||||||
|
expect(listData.shareLinks[0].expiresAt).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
it("should return validation error for invalid scheduleDays", async () => {
|
it("should return validation error for invalid scheduleDays", async () => {
|
||||||
await createMedication(testClient, userId, "Med1", ["Daniel"]);
|
await createMedication(testClient, userId, "Med1", ["Daniel"]);
|
||||||
|
|
||||||
@@ -1685,14 +1797,15 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
|
|
||||||
describe("Share token dose routes", () => {
|
describe("Share token dose routes", () => {
|
||||||
it("should get taken doses via share link", async () => {
|
it("should get taken doses via share link", async () => {
|
||||||
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
|
const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
|
||||||
const token = "get-doses-token";
|
const token = "get-doses-token";
|
||||||
await createShareToken(testClient, userId, "Daniel", token);
|
await createShareToken(testClient, userId, "Daniel", token);
|
||||||
|
|
||||||
// Insert a dose directly
|
// Insert a dose directly
|
||||||
|
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
|
||||||
await testClient.execute({
|
await testClient.execute({
|
||||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`,
|
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`,
|
||||||
args: [userId, "1-0-1735344000000", "Daniel"],
|
args: [userId, doseId, "Daniel"],
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
@@ -1703,7 +1816,7 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
const data = response.json();
|
const data = response.json();
|
||||||
expect(data.doses).toHaveLength(1);
|
expect(data.doses).toHaveLength(1);
|
||||||
expect(data.doses[0].doseId).toBe("1-0-1735344000000");
|
expect(data.doses[0].doseId).toBe(doseId);
|
||||||
expect(data.doses[0].markedBy).toBe("Daniel");
|
expect(data.doses[0].markedBy).toBe("Daniel");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3000,6 +3113,78 @@ describe("E2E Tests with Real Routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("Real /import routes", () => {
|
describe("Real /import routes", () => {
|
||||||
|
it("should preview import data without mutating existing user data", async () => {
|
||||||
|
await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications",
|
||||||
|
payload: {
|
||||||
|
name: "Existing Med",
|
||||||
|
packCount: 2,
|
||||||
|
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewPayload = {
|
||||||
|
version: "1.6",
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
includeSensitiveData: true,
|
||||||
|
medications: [
|
||||||
|
{
|
||||||
|
_exportId: "med-1",
|
||||||
|
name: "Imported Med",
|
||||||
|
inventory: { packCount: 1, blistersPerPack: 2, pillsPerBlister: 14, looseTablets: 0 },
|
||||||
|
schedules: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
settings: { language: "en", stockCalculationMode: "automatic" },
|
||||||
|
shareLinks: [{ takenBy: "Person A", scheduleDays: 14 }],
|
||||||
|
doseHistory: [
|
||||||
|
{
|
||||||
|
medicationRef: "med-1",
|
||||||
|
scheduleIndex: 0,
|
||||||
|
scheduledTime: "2025-01-01T08:00:00.000Z",
|
||||||
|
takenAt: "2025-01-01T08:03:00.000Z",
|
||||||
|
journalNote: "after breakfast",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/import/preview",
|
||||||
|
payload: previewPayload,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(previewResponse.statusCode).toBe(200);
|
||||||
|
expect(previewResponse.json()).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
preview: {
|
||||||
|
version: "1.6",
|
||||||
|
includeSensitiveData: true,
|
||||||
|
incoming: {
|
||||||
|
medications: 1,
|
||||||
|
doseHistory: 1,
|
||||||
|
shareLinks: 1,
|
||||||
|
journalEntries: 1,
|
||||||
|
hasSettings: true,
|
||||||
|
},
|
||||||
|
current: {
|
||||||
|
medications: 1,
|
||||||
|
hasSettings: false,
|
||||||
|
},
|
||||||
|
warnings: {
|
||||||
|
replacesExistingData: true,
|
||||||
|
regeneratesShareLinks: true,
|
||||||
|
containsSensitiveData: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||||
|
expect(medsResponse.json()).toHaveLength(1);
|
||||||
|
expect(medsResponse.json()[0].name).toBe("Existing Med");
|
||||||
|
});
|
||||||
|
|
||||||
it("should import medications from export format", async () => {
|
it("should import medications from export format", async () => {
|
||||||
const importData = {
|
const importData = {
|
||||||
version: "1.0",
|
version: "1.0",
|
||||||
|
|||||||
@@ -0,0 +1,453 @@
|
|||||||
|
import { existsSync, unlinkSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import cookie from "@fastify/cookie";
|
||||||
|
import sensible from "@fastify/sensible";
|
||||||
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
|
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { runAlterMigrations } from "../db/db-utils.js";
|
||||||
|
import { jwtPlugin } from "../plugins/jwt.js";
|
||||||
|
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||||
|
|
||||||
|
const { testClient, testDb, testDbPath, mockedEnv } = vi.hoisted(() => {
|
||||||
|
const { createClient } = require("@libsql/client");
|
||||||
|
const { drizzle } = require("drizzle-orm/libsql");
|
||||||
|
const { tmpdir } = require("node:os");
|
||||||
|
const { join } = require("node:path");
|
||||||
|
const dbPath = join(tmpdir(), `medassist-intake-journal-routes-${process.pid}-${Date.now()}.db`);
|
||||||
|
const client = createClient({ url: `file:${dbPath}` });
|
||||||
|
const db = drizzle(client);
|
||||||
|
|
||||||
|
return {
|
||||||
|
testClient: client,
|
||||||
|
testDb: db,
|
||||||
|
testDbPath: dbPath,
|
||||||
|
mockedEnv: {
|
||||||
|
AUTH_ENABLED: true,
|
||||||
|
REGISTRATION_ENABLED: true,
|
||||||
|
FORM_LOGIN_ENABLED: true,
|
||||||
|
OIDC_ENABLED: false,
|
||||||
|
OIDC_PROVIDER_NAME: "SSO",
|
||||||
|
NODE_ENV: "test",
|
||||||
|
LOG_LEVEL: "silent",
|
||||||
|
PORT: 3000,
|
||||||
|
CORS_ORIGINS: "*",
|
||||||
|
JWT_SECRET: "test-jwt-secret",
|
||||||
|
REFRESH_SECRET: "test-refresh-secret",
|
||||||
|
COOKIE_SECRET: "test-cookie-secret",
|
||||||
|
ACCESS_TOKEN_TTL_MINUTES: 15,
|
||||||
|
REFRESH_TOKEN_TTL_DAYS: 7,
|
||||||
|
OPENAPI_DOCS_ENABLED: false,
|
||||||
|
PUBLIC_APP_URL: "https://app.example.com",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("../db/client.js", () => ({
|
||||||
|
db: testDb,
|
||||||
|
migrationsReady: Promise.resolve(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
|
||||||
|
|
||||||
|
const { exportRoutes } = await import("../routes/export.js");
|
||||||
|
const { intakeJournalRoutes } = await import("../routes/intake-journal.js");
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||||
|
|
||||||
|
async function clearTables() {
|
||||||
|
await testClient.execute("DELETE FROM intake_journal");
|
||||||
|
await testClient.execute("DELETE FROM refill_history");
|
||||||
|
await testClient.execute("DELETE FROM dose_tracking");
|
||||||
|
await testClient.execute("DELETE FROM share_tokens");
|
||||||
|
await testClient.execute("DELETE FROM user_settings");
|
||||||
|
await testClient.execute("DELETE FROM medications");
|
||||||
|
await testClient.execute("DELETE FROM api_keys");
|
||||||
|
await testClient.execute("DELETE FROM refresh_tokens");
|
||||||
|
await testClient.execute("DELETE FROM users");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(username: string) {
|
||||||
|
const result = await testClient.execute({
|
||||||
|
sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id",
|
||||||
|
args: [username],
|
||||||
|
});
|
||||||
|
|
||||||
|
return Number(result.rows[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
|
||||||
|
const token = await app.jwt.sign({ sub: userId, username });
|
||||||
|
return `access_token=${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedMedication(options: { userId: number; name: string; start?: string; takenBy?: string[] }) {
|
||||||
|
const start = options.start ?? "2026-02-01T08:00:00.000Z";
|
||||||
|
const takenBy = options.takenBy ?? ["Daniel"];
|
||||||
|
const result = await testClient.execute({
|
||||||
|
sql: `INSERT INTO medications (
|
||||||
|
user_id, name, generic_name, taken_by_json, medication_form, package_type,
|
||||||
|
pack_count, blisters_per_pack, pills_per_blister, loose_tablets,
|
||||||
|
usage_json, every_json, start_json, intakes_json,
|
||||||
|
stock_adjustment, intake_reminders_enabled
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id`,
|
||||||
|
args: [
|
||||||
|
options.userId,
|
||||||
|
options.name,
|
||||||
|
`${options.name} Generic`,
|
||||||
|
JSON.stringify(takenBy),
|
||||||
|
"tablet",
|
||||||
|
"blister",
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
10,
|
||||||
|
0,
|
||||||
|
JSON.stringify([1]),
|
||||||
|
JSON.stringify([1]),
|
||||||
|
JSON.stringify([start]),
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
usage: 1,
|
||||||
|
every: 1,
|
||||||
|
start,
|
||||||
|
takenBy: takenBy[0] ?? null,
|
||||||
|
intakeRemindersEnabled: true,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return Number(result.rows[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedTrackedDose(options: {
|
||||||
|
userId: number;
|
||||||
|
doseId: string;
|
||||||
|
takenAt: Date;
|
||||||
|
markedBy?: string | null;
|
||||||
|
dismissed?: boolean;
|
||||||
|
}) {
|
||||||
|
const result = await testClient.execute({
|
||||||
|
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, marked_by, taken_source, dismissed)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?) RETURNING id`,
|
||||||
|
args: [
|
||||||
|
options.userId,
|
||||||
|
options.doseId,
|
||||||
|
Math.floor(options.takenAt.getTime() / 1000),
|
||||||
|
options.markedBy ?? null,
|
||||||
|
"manual",
|
||||||
|
options.dismissed ? 1 : 0,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
return Number(result.rows[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("Intake journal routes", () => {
|
||||||
|
let app: FastifyInstance;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await migrate(testDb, { migrationsFolder });
|
||||||
|
await runAlterMigrations(testClient);
|
||||||
|
|
||||||
|
app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||||
|
await app.register(sensible);
|
||||||
|
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||||
|
await app.register(jwtPlugin, {
|
||||||
|
secret: "test-jwt-secret",
|
||||||
|
cookie: { cookieName: "access_token", signed: false },
|
||||||
|
});
|
||||||
|
await app.register(intakeJournalRoutes);
|
||||||
|
await app.register(exportRoutes);
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await app.close();
|
||||||
|
testClient.close();
|
||||||
|
for (const path of [testDbPath, `${testDbPath}-shm`, `${testDbPath}-wal`]) {
|
||||||
|
if (existsSync(path)) {
|
||||||
|
unlinkSync(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
await clearTables();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps journal CRUD/history owner-scoped across route access", async () => {
|
||||||
|
const ownerId = await createUser("journal-owner");
|
||||||
|
const otherId = await createUser("journal-other");
|
||||||
|
const ownerCookie = await buildSessionCookie(app, ownerId, "journal-owner");
|
||||||
|
const otherCookie = await buildSessionCookie(app, otherId, "journal-other");
|
||||||
|
|
||||||
|
const ownerStart = "2026-02-01T08:00:00.000Z";
|
||||||
|
const otherStart = "2026-02-02T09:00:00.000Z";
|
||||||
|
const ownerMedicationId = await seedMedication({ userId: ownerId, name: "Owner Med", start: ownerStart });
|
||||||
|
const otherMedicationId = await seedMedication({ userId: otherId, name: "Other Med", start: otherStart });
|
||||||
|
|
||||||
|
const ownerDoseId = `${ownerMedicationId}-0-${new Date(ownerStart).getTime()}-Daniel`;
|
||||||
|
const otherDoseId = `${otherMedicationId}-0-${new Date(otherStart).getTime()}-Maria`;
|
||||||
|
await seedTrackedDose({
|
||||||
|
userId: ownerId,
|
||||||
|
doseId: ownerDoseId,
|
||||||
|
takenAt: new Date("2026-02-01T08:05:00.000Z"),
|
||||||
|
markedBy: "Daniel",
|
||||||
|
});
|
||||||
|
await seedTrackedDose({
|
||||||
|
userId: otherId,
|
||||||
|
doseId: otherDoseId,
|
||||||
|
takenAt: new Date("2026-02-02T09:05:00.000Z"),
|
||||||
|
markedBy: "Maria",
|
||||||
|
});
|
||||||
|
|
||||||
|
const ownerPutResponse = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: `/intake-journal/event/${encodeURIComponent(ownerDoseId)}`,
|
||||||
|
headers: { cookie: ownerCookie },
|
||||||
|
payload: { note: "Took after breakfast." },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ownerPutResponse.statusCode).toBe(200);
|
||||||
|
expect(ownerPutResponse.json().entry).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
doseId: ownerDoseId,
|
||||||
|
medicationId: ownerMedicationId,
|
||||||
|
scheduledFor: expect.stringContaining("T08:00:00"),
|
||||||
|
note: "Took after breakfast.",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const otherPutResponse = await app.inject({
|
||||||
|
method: "PUT",
|
||||||
|
url: `/intake-journal/event/${encodeURIComponent(otherDoseId)}`,
|
||||||
|
headers: { cookie: otherCookie },
|
||||||
|
payload: { note: "Different owner note." },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(otherPutResponse.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const ownerHistoryResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/intake-journal?medicationId=${ownerMedicationId}&limit=25`,
|
||||||
|
headers: { cookie: ownerCookie },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ownerHistoryResponse.statusCode).toBe(200);
|
||||||
|
expect(ownerHistoryResponse.json().entries).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
doseId: ownerDoseId,
|
||||||
|
medicationId: ownerMedicationId,
|
||||||
|
note: "Took after breakfast.",
|
||||||
|
markedBy: "Daniel",
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const otherEventResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: `/intake-journal/event/${encodeURIComponent(otherDoseId)}`,
|
||||||
|
headers: { cookie: ownerCookie },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(otherEventResponse.statusCode).toBe(404);
|
||||||
|
expect(otherEventResponse.json()).toMatchObject({ code: "DOSE_NOT_FOUND" });
|
||||||
|
|
||||||
|
const deleteResponse = await app.inject({
|
||||||
|
method: "DELETE",
|
||||||
|
url: `/intake-journal/event/${encodeURIComponent(ownerDoseId)}`,
|
||||||
|
headers: { cookie: ownerCookie },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(deleteResponse.statusCode).toBe(200);
|
||||||
|
expect(deleteResponse.json()).toEqual({ success: true });
|
||||||
|
|
||||||
|
const emptyHistoryResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/intake-journal",
|
||||||
|
headers: { cookie: ownerCookie },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(emptyHistoryResponse.statusCode).toBe(200);
|
||||||
|
expect(emptyHistoryResponse.json().entries).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves journal metadata through authenticated export and import", async () => {
|
||||||
|
const userId = await createUser("journal-roundtrip");
|
||||||
|
const sessionCookie = await buildSessionCookie(app, userId, "journal-roundtrip");
|
||||||
|
const start = "2026-02-03T07:30:00.000Z";
|
||||||
|
const medicationId = await seedMedication({ userId, name: "Roundtrip Journal Med", start });
|
||||||
|
const doseId = `${medicationId}-0-${new Date(start).getTime()}-Daniel`;
|
||||||
|
const doseTrackingId = await seedTrackedDose({
|
||||||
|
userId,
|
||||||
|
doseId,
|
||||||
|
takenAt: new Date("2026-02-03T07:33:00.000Z"),
|
||||||
|
markedBy: "Daniel",
|
||||||
|
});
|
||||||
|
|
||||||
|
const createdAt = new Date("2026-02-03T07:40:00.000Z");
|
||||||
|
const updatedAt = new Date("2026-02-03T07:50:00.000Z");
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO intake_journal (
|
||||||
|
user_id, dose_tracking_id, medication_id, scheduled_for, note, created_at, updated_at
|
||||||
|
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
args: [
|
||||||
|
userId,
|
||||||
|
doseTrackingId,
|
||||||
|
medicationId,
|
||||||
|
Math.floor(new Date(start).getTime() / 1000),
|
||||||
|
"Roundtrip journal note",
|
||||||
|
Math.floor(createdAt.getTime() / 1000),
|
||||||
|
Math.floor(updatedAt.getTime() / 1000),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/export",
|
||||||
|
headers: { cookie: sessionCookie },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exportResponse.statusCode).toBe(200);
|
||||||
|
const exportBody = exportResponse.json();
|
||||||
|
expect(exportBody.doseHistory).toHaveLength(1);
|
||||||
|
expect(exportBody.doseHistory[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
journalNote: "Roundtrip journal note",
|
||||||
|
journalCreatedAt: createdAt.toISOString(),
|
||||||
|
journalUpdatedAt: updatedAt.toISOString(),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const importResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/import",
|
||||||
|
headers: { cookie: sessionCookie },
|
||||||
|
payload: exportBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(importResponse.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const reExportResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/export",
|
||||||
|
headers: { cookie: sessionCookie },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(reExportResponse.statusCode).toBe(200);
|
||||||
|
expect(reExportResponse.json().doseHistory).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
journalNote: "Roundtrip journal note",
|
||||||
|
journalCreatedAt: createdAt.toISOString(),
|
||||||
|
journalUpdatedAt: updatedAt.toISOString(),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const restoredJournalRows = await testClient.execute({
|
||||||
|
sql: "SELECT note FROM intake_journal WHERE user_id = ?",
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(restoredJournalRows.rows).toHaveLength(1);
|
||||||
|
expect(restoredJournalRows.rows[0].note).toBe("Roundtrip journal note");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the shared journal-note permission through authenticated export and import", async () => {
|
||||||
|
const userId = await createUser("share-journal-roundtrip");
|
||||||
|
const sessionCookie = await buildSessionCookie(app, userId, "share-journal-roundtrip");
|
||||||
|
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, allow_journal_notes, expires_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
args: [userId, "share-journal-token", "Daniel", 14, 1, null],
|
||||||
|
});
|
||||||
|
|
||||||
|
const exportResponse = await app.inject({
|
||||||
|
method: "GET",
|
||||||
|
url: "/export",
|
||||||
|
headers: { cookie: sessionCookie },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(exportResponse.statusCode).toBe(200);
|
||||||
|
const exportBody = exportResponse.json();
|
||||||
|
expect(exportBody.shareLinks).toEqual([
|
||||||
|
expect.objectContaining({
|
||||||
|
takenBy: "Daniel",
|
||||||
|
scheduleDays: 14,
|
||||||
|
allowJournalNotes: true,
|
||||||
|
regenerateToken: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const importResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/import",
|
||||||
|
headers: { cookie: sessionCookie },
|
||||||
|
payload: exportBody,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(importResponse.statusCode).toBe(200);
|
||||||
|
|
||||||
|
const shareRows = await testClient.execute({
|
||||||
|
sql: "SELECT token, taken_by, schedule_days, allow_journal_notes FROM share_tokens WHERE user_id = ?",
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(shareRows.rows).toHaveLength(1);
|
||||||
|
expect(shareRows.rows[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
taken_by: "Daniel",
|
||||||
|
schedule_days: 14,
|
||||||
|
allow_journal_notes: 1,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(shareRows.rows[0].token).not.toBe("share-journal-token");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps existing data when import fails inside the replacement transaction", async () => {
|
||||||
|
const userId = await createUser("import-rollback");
|
||||||
|
const sessionCookie = await buildSessionCookie(app, userId, "import-rollback");
|
||||||
|
await seedMedication({ userId, name: "Existing Rollback Med" });
|
||||||
|
|
||||||
|
const importResponse = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/import",
|
||||||
|
headers: { cookie: sessionCookie },
|
||||||
|
payload: {
|
||||||
|
version: "1.6",
|
||||||
|
exportedAt: new Date().toISOString(),
|
||||||
|
medications: [
|
||||||
|
{
|
||||||
|
_exportId: "med-1",
|
||||||
|
name: "Imported Rollback Med",
|
||||||
|
inventory: { packCount: 1, blistersPerPack: 1, pillsPerBlister: 10, looseTablets: 0 },
|
||||||
|
schedules: [{ usage: 1, every: 1, start: "2026-02-04T08:00:00.000Z" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
doseHistory: [
|
||||||
|
{
|
||||||
|
medicationRef: "med-1",
|
||||||
|
scheduleIndex: 0,
|
||||||
|
scheduledTime: "2026-02-04T08:00:00.000Z",
|
||||||
|
takenAt: "not-a-date",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(importResponse.statusCode).toBe(500);
|
||||||
|
|
||||||
|
const medicationRows = await testClient.execute({
|
||||||
|
sql: "SELECT name FROM medications WHERE user_id = ? ORDER BY name",
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(medicationRows.rows).toEqual([expect.objectContaining({ name: "Existing Rollback Med" })]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -165,6 +165,7 @@ async function createSchema(client: Client) {
|
|||||||
token text NOT NULL UNIQUE,
|
token text NOT NULL UNIQUE,
|
||||||
taken_by text NOT NULL,
|
taken_by text NOT NULL,
|
||||||
schedule_days integer NOT NULL DEFAULT 30,
|
schedule_days integer NOT NULL DEFAULT 30,
|
||||||
|
allow_journal_notes integer NOT NULL DEFAULT 0,
|
||||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||||
expires_at integer,
|
expires_at integer,
|
||||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||||
@@ -195,6 +196,16 @@ async function clearData(client: Client) {
|
|||||||
await client.execute("DELETE FROM sqlite_sequence");
|
await client.execute("DELETE FROM sqlite_sequence");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function visibleDoseTimestampMs(): number {
|
||||||
|
const doseDate = new Date();
|
||||||
|
doseDate.setHours(8, 0, 0, 0);
|
||||||
|
return doseDate.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
function visibleDoseStartIso(): string {
|
||||||
|
return new Date(visibleDoseTimestampMs()).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Tests
|
// Tests
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -259,9 +270,11 @@ describe("Integration Tests", () => {
|
|||||||
packCount: 1,
|
packCount: 1,
|
||||||
blistersPerPack: 1,
|
blistersPerPack: 1,
|
||||||
pillsPerBlister: 10,
|
pillsPerBlister: 10,
|
||||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
looseTablets: 10,
|
||||||
|
blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(createRes.statusCode, createRes.body).toBe(200);
|
||||||
const medId = createRes.json().id;
|
const medId = createRes.json().id;
|
||||||
|
|
||||||
// Mark some doses (Jan 1, Jan 2, Jan 5, Jan 10)
|
// Mark some doses (Jan 1, Jan 2, Jan 5, Jan 10)
|
||||||
@@ -617,9 +630,10 @@ describe("Integration Tests", () => {
|
|||||||
packCount: 1,
|
packCount: 1,
|
||||||
blistersPerPack: 1,
|
blistersPerPack: 1,
|
||||||
pillsPerBlister: 10,
|
pillsPerBlister: 10,
|
||||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(createRes.statusCode, createRes.body).toBe(200);
|
||||||
const medId = createRes.json().id;
|
const medId = createRes.json().id;
|
||||||
|
|
||||||
// Create share token for Daniel
|
// Create share token for Daniel
|
||||||
@@ -628,15 +642,17 @@ describe("Integration Tests", () => {
|
|||||||
url: "/share",
|
url: "/share",
|
||||||
payload: { takenBy: "Daniel", scheduleDays: 30 },
|
payload: { takenBy: "Daniel", scheduleDays: 30 },
|
||||||
});
|
});
|
||||||
|
expect(shareRes.statusCode, shareRes.body).toBe(200);
|
||||||
const token = shareRes.json().token;
|
const token = shareRes.json().token;
|
||||||
|
|
||||||
// Mark dose via share link
|
// Mark dose via share link
|
||||||
const doseId = `${medId}-0-${new Date("2025-01-01T08:00:00.000Z").getTime()}`;
|
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
|
||||||
await app.inject({
|
const markRes = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: `/share/${token}/doses`,
|
url: `/share/${token}/doses`,
|
||||||
payload: { doseId },
|
payload: { doseId },
|
||||||
});
|
});
|
||||||
|
expect(markRes.statusCode, markRes.body).toBe(200);
|
||||||
|
|
||||||
// Verify markedBy is "Daniel"
|
// Verify markedBy is "Daniel"
|
||||||
const result = await testClient.execute({
|
const result = await testClient.execute({
|
||||||
@@ -667,9 +683,10 @@ describe("Integration Tests", () => {
|
|||||||
payload: {
|
payload: {
|
||||||
name: "Vitamin D",
|
name: "Vitamin D",
|
||||||
takenBy: ["Anna"],
|
takenBy: ["Anna"],
|
||||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
expect(createRes.statusCode, createRes.body).toBe(200);
|
||||||
const medId = createRes.json().id;
|
const medId = createRes.json().id;
|
||||||
|
|
||||||
// Create share token
|
// Create share token
|
||||||
@@ -678,21 +695,24 @@ describe("Integration Tests", () => {
|
|||||||
url: "/share",
|
url: "/share",
|
||||||
payload: { takenBy: "Anna", scheduleDays: 30 },
|
payload: { takenBy: "Anna", scheduleDays: 30 },
|
||||||
});
|
});
|
||||||
|
expect(shareRes.statusCode, shareRes.body).toBe(200);
|
||||||
const token = shareRes.json().token;
|
const token = shareRes.json().token;
|
||||||
|
|
||||||
// Mark a dose
|
// Mark a dose
|
||||||
const doseId = `${medId}-0-${new Date("2025-01-05T08:00:00.000Z").getTime()}`;
|
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
|
||||||
await app.inject({
|
const markRes = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
url: `/share/${token}/doses`,
|
url: `/share/${token}/doses`,
|
||||||
payload: { doseId },
|
payload: { doseId },
|
||||||
});
|
});
|
||||||
|
expect(markRes.statusCode, markRes.body).toBe(200);
|
||||||
|
|
||||||
// Get shared schedule
|
// Get shared schedule
|
||||||
const scheduleRes = await app.inject({
|
const scheduleRes = await app.inject({
|
||||||
method: "GET",
|
method: "GET",
|
||||||
url: `/share/${token}`,
|
url: `/share/${token}`,
|
||||||
});
|
});
|
||||||
|
expect(scheduleRes.statusCode, scheduleRes.body).toBe(200);
|
||||||
|
|
||||||
const data = scheduleRes.json();
|
const data = scheduleRes.json();
|
||||||
expect(data.takenBy).toBe("Anna");
|
expect(data.takenBy).toBe("Anna");
|
||||||
@@ -781,7 +801,7 @@ describe("Integration Tests", () => {
|
|||||||
payload: {
|
payload: {
|
||||||
name: "Family Vitamins",
|
name: "Family Vitamins",
|
||||||
takenBy: ["Daniel", "Anna", "Max"],
|
takenBy: ["Daniel", "Anna", "Max"],
|
||||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -799,8 +819,8 @@ describe("Integration Tests", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Both should succeed with different tokens
|
// Both should succeed with different tokens
|
||||||
expect(danielShare.statusCode).toBe(200);
|
expect(danielShare.statusCode, danielShare.body).toBe(200);
|
||||||
expect(annaShare.statusCode).toBe(200);
|
expect(annaShare.statusCode, annaShare.body).toBe(200);
|
||||||
expect(danielShare.json().token).not.toBe(annaShare.json().token);
|
expect(danielShare.json().token).not.toBe(annaShare.json().token);
|
||||||
|
|
||||||
// Each share link should show correct person
|
// Each share link should show correct person
|
||||||
|
|||||||
@@ -350,7 +350,12 @@ describe("notification action routes", () => {
|
|||||||
shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist",
|
shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist",
|
||||||
});
|
});
|
||||||
const { takenToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
const { takenToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "UPDATE notification_action_groups SET ntfy_original_message_id = ? WHERE user_id = ?",
|
||||||
|
args: ["ntfy-msg-1", userId],
|
||||||
|
});
|
||||||
fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: "ntfy-msg-2" }) });
|
fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: "ntfy-msg-2" }) });
|
||||||
|
fetchMock.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve("") });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -358,7 +363,7 @@ describe("notification action routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
const [targetUrl, requestInit] = fetchMock.mock.calls[0] ?? [];
|
const [targetUrl, requestInit] = fetchMock.mock.calls[0] ?? [];
|
||||||
expect(targetUrl).toBe("https://ntfy.example.com/medassist");
|
expect(targetUrl).toBe("https://ntfy.example.com/medassist");
|
||||||
@@ -383,6 +388,21 @@ describe("notification action routes", () => {
|
|||||||
clear: false,
|
clear: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const [deleteUrl, deleteInit] = fetchMock.mock.calls[1] ?? [];
|
||||||
|
expect(deleteUrl).toBe("https://ntfy.example.com/medassist/ntfy-msg-1");
|
||||||
|
expect(deleteInit).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "DELETE",
|
||||||
|
headers: expect.objectContaining({ Authorization: expect.stringMatching(/^Basic /) }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupRow = await testClient.execute({
|
||||||
|
sql: "SELECT ntfy_original_message_id FROM notification_action_groups WHERE user_id = ?",
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
expect(groupRow.rows).toEqual([expect.objectContaining({ ntfy_original_message_id: "ntfy-msg-2" })]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("replaces the original ntfy notification after a skip action with a view-only confirmation", async () => {
|
it("replaces the original ntfy notification after a skip action with a view-only confirmation", async () => {
|
||||||
@@ -393,7 +413,12 @@ describe("notification action routes", () => {
|
|||||||
shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist",
|
shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist",
|
||||||
});
|
});
|
||||||
const { skipToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
const { skipToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "UPDATE notification_action_groups SET ntfy_original_message_id = ? WHERE user_id = ?",
|
||||||
|
args: ["ntfy-msg-7", userId],
|
||||||
|
});
|
||||||
fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: "ntfy-msg-3" }) });
|
fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: "ntfy-msg-3" }) });
|
||||||
|
fetchMock.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve("") });
|
||||||
|
|
||||||
const response = await app.inject({
|
const response = await app.inject({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -401,7 +426,7 @@ describe("notification action routes", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(response.statusCode).toBe(200);
|
expect(response.statusCode).toBe(200);
|
||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
const [targetUrl, requestInit] = fetchMock.mock.calls[0] ?? [];
|
const [targetUrl, requestInit] = fetchMock.mock.calls[0] ?? [];
|
||||||
expect(targetUrl).toBe("https://ntfy.example.com/medassist");
|
expect(targetUrl).toBe("https://ntfy.example.com/medassist");
|
||||||
@@ -416,6 +441,21 @@ describe("notification action routes", () => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [deleteUrl, deleteInit] = fetchMock.mock.calls[1] ?? [];
|
||||||
|
expect(deleteUrl).toBe("https://ntfy.example.com/medassist/ntfy-msg-7");
|
||||||
|
expect(deleteInit).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
method: "DELETE",
|
||||||
|
headers: expect.objectContaining({ Authorization: expect.stringMatching(/^Basic /) }),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const groupRow = await testClient.execute({
|
||||||
|
sql: "SELECT ntfy_original_message_id FROM notification_action_groups WHERE user_id = ?",
|
||||||
|
args: [userId],
|
||||||
|
});
|
||||||
|
expect(groupRow.rows).toEqual([expect.objectContaining({ ntfy_original_message_id: "ntfy-msg-3" })]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("warns when ntfy replacement, delete, and fallback clear all fail", async () => {
|
it("warns when ntfy replacement, delete, and fallback clear all fail", async () => {
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ function extractToken(url: string): string {
|
|||||||
return url.split("/").at(-1) ?? "";
|
return url.split("/").at(-1) ?? "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ActionTokenRow = {
|
||||||
|
kind: string | null;
|
||||||
|
token_hash: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
async function clearTables() {
|
async function clearTables() {
|
||||||
await testClient.execute("DELETE FROM notification_action_tokens");
|
await testClient.execute("DELETE FROM notification_action_tokens");
|
||||||
await testClient.execute("DELETE FROM notification_action_groups");
|
await testClient.execute("DELETE FROM notification_action_groups");
|
||||||
@@ -181,6 +186,97 @@ describe("notification-actions-service", () => {
|
|||||||
expect(Number(tokens.rows[0].count)).toBe(6);
|
expect(Number(tokens.rows[0].count)).toBe(6);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reactivates a resolved group with the same key instead of inserting a duplicate", async () => {
|
||||||
|
const userId = await createUser("notify-actions-reactivate");
|
||||||
|
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
|
||||||
|
const doseIds = ["9-1-1736064000000", "9-0-1736064000000"];
|
||||||
|
|
||||||
|
const first = await createNotificationActionContext({
|
||||||
|
userId,
|
||||||
|
title: "Reminder",
|
||||||
|
message: "Take your medication now",
|
||||||
|
doseIds,
|
||||||
|
scheduledFor,
|
||||||
|
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
|
||||||
|
language: "en",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(first).toMatchObject({
|
||||||
|
groupId: expect.any(Number),
|
||||||
|
respondUrl: expect.stringContaining("/api/notification-actions/"),
|
||||||
|
sequenceId: expect.stringMatching(/^medassist-/),
|
||||||
|
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=9-0-1736064000000",
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstGroupId = first!.groupId!;
|
||||||
|
const firstSequenceId = first!.sequenceId!;
|
||||||
|
const firstRespondToken = extractToken(first!.respondUrl!);
|
||||||
|
const firstTokenRows = await testClient.execute({
|
||||||
|
sql: "SELECT kind, token_hash FROM notification_action_tokens WHERE group_id = ? ORDER BY kind ASC",
|
||||||
|
args: [firstGroupId],
|
||||||
|
});
|
||||||
|
expect(firstTokenRows.rows).toHaveLength(3);
|
||||||
|
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "UPDATE notification_action_groups SET resolved_action = 'taken', resolved_at = ?, ntfy_original_message_id = 'old-message-id' WHERE id = ?",
|
||||||
|
args: [new Date(), firstGroupId],
|
||||||
|
});
|
||||||
|
|
||||||
|
const second = await createNotificationActionContext({
|
||||||
|
userId,
|
||||||
|
title: "Reminder",
|
||||||
|
message: "Take your medication now",
|
||||||
|
doseIds,
|
||||||
|
scheduledFor,
|
||||||
|
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
|
||||||
|
language: "en",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(second).toMatchObject({
|
||||||
|
groupId: firstGroupId,
|
||||||
|
respondUrl: expect.stringContaining("/api/notification-actions/"),
|
||||||
|
sequenceId: expect.stringMatching(/^medassist-/),
|
||||||
|
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=9-0-1736064000000",
|
||||||
|
});
|
||||||
|
expect(second?.sequenceId).toBe(firstSequenceId);
|
||||||
|
|
||||||
|
const groups = await testClient.execute(
|
||||||
|
"SELECT id, sequence_id, resolved_action, resolved_at, ntfy_original_message_id FROM notification_action_groups"
|
||||||
|
);
|
||||||
|
expect(groups.rows).toHaveLength(1);
|
||||||
|
expect(groups.rows[0]).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: firstGroupId,
|
||||||
|
sequence_id: second?.sequenceId,
|
||||||
|
resolved_action: null,
|
||||||
|
resolved_at: null,
|
||||||
|
ntfy_original_message_id: "",
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const secondTokenRows = await testClient.execute({
|
||||||
|
sql: "SELECT kind, token_hash FROM notification_action_tokens WHERE group_id = ? ORDER BY kind ASC",
|
||||||
|
args: [firstGroupId],
|
||||||
|
});
|
||||||
|
expect(secondTokenRows.rows).toHaveLength(3);
|
||||||
|
expect(secondTokenRows.rows.map((row: ActionTokenRow) => row.kind)).toEqual(["respond", "skip", "taken"]);
|
||||||
|
|
||||||
|
const firstTokenHashes = new Set(firstTokenRows.rows.map((row: ActionTokenRow) => String(row.token_hash)));
|
||||||
|
const secondTokenHashes = new Set(secondTokenRows.rows.map((row: ActionTokenRow) => String(row.token_hash)));
|
||||||
|
expect(secondTokenHashes.size).toBe(3);
|
||||||
|
expect([...secondTokenHashes].every((tokenHash) => !firstTokenHashes.has(tokenHash))).toBe(true);
|
||||||
|
|
||||||
|
expect(await getNotificationActionTokenRecord(firstRespondToken)).toBeNull();
|
||||||
|
|
||||||
|
const secondRespondToken = extractToken(second!.respondUrl!);
|
||||||
|
const secondRespondRecord = await getNotificationActionTokenRecord(secondRespondToken);
|
||||||
|
expect(secondRespondRecord).toMatchObject({
|
||||||
|
doseIds: ["9-0-1736064000000", "9-1-1736064000000"],
|
||||||
|
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=9-0-1736064000000",
|
||||||
|
});
|
||||||
|
expect(secondRespondRecord?.group.id).toBe(firstGroupId);
|
||||||
|
});
|
||||||
|
|
||||||
it("prefers a non-local CORS origin when PUBLIC_APP_URL points to localhost", async () => {
|
it("prefers a non-local CORS origin when PUBLIC_APP_URL points to localhost", async () => {
|
||||||
const userId = await createUser("notify-actions-mobile");
|
const userId = await createUser("notify-actions-mobile");
|
||||||
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
|
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
|
||||||
|
|||||||
@@ -248,6 +248,32 @@ describe("Planner Routes", () => {
|
|||||||
expect(response.json()).toEqual({ error: "Missing planner data" });
|
expect(response.json()).toEqual({ error: "Missing planner data" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should reject request when no planner date range can be resolved", async () => {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/planner/send-email",
|
||||||
|
payload: {
|
||||||
|
email: "test@example.com",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
medicationId: 1,
|
||||||
|
medicationName: "Aspirin",
|
||||||
|
totalPills: 30,
|
||||||
|
plannerUsage: 10,
|
||||||
|
blisterSize: 10,
|
||||||
|
blistersNeeded: 1,
|
||||||
|
fullBlisters: 3,
|
||||||
|
loosePills: 0,
|
||||||
|
enough: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(400);
|
||||||
|
expect(response.json()).toEqual({ error: "Missing planner date range" });
|
||||||
|
});
|
||||||
|
|
||||||
it("should return error when no notification channels configured", async () => {
|
it("should return error when no notification channels configured", async () => {
|
||||||
// User settings exist but email/shoutrrr disabled
|
// User settings exist but email/shoutrrr disabled
|
||||||
await testClient.execute({
|
await testClient.execute({
|
||||||
@@ -282,6 +308,51 @@ describe("Planner Routes", () => {
|
|||||||
expect(response.json()).toEqual({ error: "No notification channels configured" });
|
expect(response.json()).toEqual({ error: "No notification channels configured" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should accept startDate and endDate aliases for planner range", async () => {
|
||||||
|
process.env.SMTP_HOST = "smtp.test.com";
|
||||||
|
process.env.SMTP_USER = "user@test.com";
|
||||||
|
process.env.SMTP_PASS = "password";
|
||||||
|
|
||||||
|
await testClient.execute({
|
||||||
|
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`,
|
||||||
|
args: [999999999],
|
||||||
|
});
|
||||||
|
|
||||||
|
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/planner/send-email",
|
||||||
|
payload: {
|
||||||
|
email: "test@example.com",
|
||||||
|
startDate: "2025-01-01T00:00:00.000Z",
|
||||||
|
endDate: "2025-01-31T00:00:00.000Z",
|
||||||
|
language: "en",
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
medicationId: 1,
|
||||||
|
medicationName: "Aspirin",
|
||||||
|
totalPills: 30,
|
||||||
|
plannerUsage: 10,
|
||||||
|
blisterSize: 10,
|
||||||
|
blistersNeeded: 1,
|
||||||
|
fullBlisters: 3,
|
||||||
|
loosePills: 0,
|
||||||
|
enough: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
expect(response.json()).toEqual({ success: true, message: "Notification sent via email" });
|
||||||
|
expect(mockSendMail).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
delete process.env.SMTP_HOST;
|
||||||
|
delete process.env.SMTP_USER;
|
||||||
|
delete process.env.SMTP_PASS;
|
||||||
|
});
|
||||||
|
|
||||||
it("should send email successfully when SMTP is configured", async () => {
|
it("should send email successfully when SMTP is configured", async () => {
|
||||||
// Set SMTP env vars
|
// Set SMTP env vars
|
||||||
process.env.SMTP_HOST = "smtp.test.com";
|
process.env.SMTP_HOST = "smtp.test.com";
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { redactTokenForLog } from "../utils/redaction.js";
|
||||||
|
|
||||||
|
describe("redactTokenForLog", () => {
|
||||||
|
it("returns a stable short hash reference without exposing the raw token", () => {
|
||||||
|
const rawToken = "share-token-secret-value";
|
||||||
|
const tokenRef = redactTokenForLog(rawToken);
|
||||||
|
|
||||||
|
expect(tokenRef).toMatch(/^sha256:[a-f0-9]{12}$/);
|
||||||
|
expect(tokenRef).toBe(redactTokenForLog(rawToken));
|
||||||
|
expect(tokenRef).not.toContain(rawToken);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("normalizes empty tokens to a non-sensitive placeholder", () => {
|
||||||
|
expect(redactTokenForLog("")).toBe("missing");
|
||||||
|
expect(redactTokenForLog(" ")).toBe("missing");
|
||||||
|
expect(redactTokenForLog(null)).toBe("missing");
|
||||||
|
expect(redactTokenForLog(undefined)).toBe("missing");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { existsSync, unlinkSync } from "node:fs";
|
||||||
import { dirname, resolve } from "node:path";
|
import { dirname, resolve } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||||
@@ -6,10 +7,13 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites
|
|||||||
import { runAlterMigrations } from "../db/db-utils.js";
|
import { runAlterMigrations } from "../db/db-utils.js";
|
||||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||||
|
|
||||||
const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hoisted(() => {
|
const { testClient, testDb, testDbPath, mockedEnv, nodemailerSendMail, fetchMock } = vi.hoisted(() => {
|
||||||
const { createClient } = require("@libsql/client");
|
const { createClient } = require("@libsql/client");
|
||||||
const { drizzle } = require("drizzle-orm/libsql");
|
const { drizzle } = require("drizzle-orm/libsql");
|
||||||
const client = createClient({ url: ":memory:" });
|
const { tmpdir } = require("node:os");
|
||||||
|
const { join } = require("node:path");
|
||||||
|
const dbPath = join(tmpdir(), `medassist-routes-real-${process.pid}-${Date.now()}.db`);
|
||||||
|
const client = createClient({ url: `file:${dbPath}` });
|
||||||
const db = drizzle(client);
|
const db = drizzle(client);
|
||||||
const env = {
|
const env = {
|
||||||
AUTH_ENABLED: false,
|
AUTH_ENABLED: false,
|
||||||
@@ -22,6 +26,7 @@ const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hois
|
|||||||
return {
|
return {
|
||||||
testClient: client,
|
testClient: client,
|
||||||
testDb: db,
|
testDb: db,
|
||||||
|
testDbPath: dbPath,
|
||||||
mockedEnv: env,
|
mockedEnv: env,
|
||||||
nodemailerSendMail: vi.fn(),
|
nodemailerSendMail: vi.fn(),
|
||||||
fetchMock: vi.fn(),
|
fetchMock: vi.fn(),
|
||||||
@@ -121,6 +126,9 @@ describe("Real route coverage: settings/export/report", () => {
|
|||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await app.close();
|
await app.close();
|
||||||
testClient.close();
|
testClient.close();
|
||||||
|
if (existsSync(testDbPath)) {
|
||||||
|
unlinkSync(testDbPath);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -647,7 +655,7 @@ describe("Real route coverage: settings/export/report", () => {
|
|||||||
});
|
});
|
||||||
await testClient.execute({
|
await testClient.execute({
|
||||||
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
|
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
|
||||||
args: [1, `${medId}-0-1700000600000-Alice`, 1700000600, 1],
|
args: [1, `${medId}-0-1700000600000-alice`, 1700000600, 1],
|
||||||
});
|
});
|
||||||
await testClient.execute({
|
await testClient.execute({
|
||||||
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
|
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
|
||||||
@@ -665,6 +673,66 @@ describe("Real route coverage: settings/export/report", () => {
|
|||||||
expect(body[medId].dosesSkipped).toBe(1);
|
expect(body[medId].dosesSkipped).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("POST /medications/report-data filters doses by scheduled doseId timestamp and refills by the same date window", async () => {
|
||||||
|
const medId = await seedMedication("Report Date Range Med");
|
||||||
|
const windowStart = "2026-01-10T00:00:00.000Z";
|
||||||
|
const windowEnd = "2026-01-20T00:00:00.000Z";
|
||||||
|
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
|
||||||
|
args: [
|
||||||
|
1,
|
||||||
|
`${medId}-0-${Date.parse("2026-01-05T09:00:00.000Z")}-Daniel`,
|
||||||
|
Math.floor(Date.parse("2026-01-12T09:00:00.000Z") / 1000),
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
|
||||||
|
args: [
|
||||||
|
1,
|
||||||
|
`${medId}-0-${Date.parse("2026-01-15T09:00:00.000Z")}-Daniel`,
|
||||||
|
Math.floor(Date.parse("2026-01-25T09:00:00.000Z") / 1000),
|
||||||
|
0,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed) VALUES (?, ?, ?, ?)",
|
||||||
|
args: [
|
||||||
|
1,
|
||||||
|
`${medId}-0-${Date.parse("2026-01-18T09:00:00.000Z")}-Daniel`,
|
||||||
|
Math.floor(Date.parse("2026-01-18T09:30:00.000Z") / 1000),
|
||||||
|
1,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
args: [medId, 1, 1, 0, 0, Math.floor(Date.parse("2026-01-12T08:00:00.000Z") / 1000)],
|
||||||
|
});
|
||||||
|
await testClient.execute({
|
||||||
|
sql: "INSERT INTO refill_history (medication_id, user_id, packs_added, loose_pills_added, used_prescription, refill_date) VALUES (?, ?, ?, ?, ?, ?)",
|
||||||
|
args: [medId, 1, 9, 0, 1, Math.floor(Date.parse("2026-01-22T08:00:00.000Z") / 1000)],
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await app.inject({
|
||||||
|
method: "POST",
|
||||||
|
url: "/medications/report-data",
|
||||||
|
payload: { medicationIds: [medId], startDate: windowStart, endDate: windowEnd },
|
||||||
|
});
|
||||||
|
expect(response.statusCode).toBe(200);
|
||||||
|
const body = response.json();
|
||||||
|
expect(body[medId]).toMatchObject({
|
||||||
|
dosesTaken: 1,
|
||||||
|
dosesSkipped: 1,
|
||||||
|
});
|
||||||
|
expect(body[medId].refills).toHaveLength(1);
|
||||||
|
expect(body[medId].refills[0]).toMatchObject({
|
||||||
|
packsAdded: 1,
|
||||||
|
usedPrescription: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("GET /export includes medications, settings, doseHistory and refillHistory", async () => {
|
it("GET /export includes medications, settings, doseHistory and refillHistory", async () => {
|
||||||
const medId = await seedMedication("Export Med");
|
const medId = await seedMedication("Export Med");
|
||||||
await testClient.execute({
|
await testClient.execute({
|
||||||
|
|||||||
@@ -177,18 +177,26 @@ export interface CreateShareTokenOptions {
|
|||||||
token?: string;
|
token?: string;
|
||||||
scheduleDays?: number;
|
scheduleDays?: number;
|
||||||
expiresAt?: number | null;
|
expiresAt?: number | null;
|
||||||
|
allowJournalNotes?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a test share token and return the token string
|
* Create a test share token and return the token string
|
||||||
*/
|
*/
|
||||||
export async function createTestShareToken(client: Client, options: CreateShareTokenOptions): Promise<string> {
|
export async function createTestShareToken(client: Client, options: CreateShareTokenOptions): Promise<string> {
|
||||||
const { userId, takenBy, token = `test_token_${Date.now()}`, scheduleDays = 30, expiresAt = null } = options;
|
const {
|
||||||
|
userId,
|
||||||
|
takenBy,
|
||||||
|
token = `test_token_${Date.now()}`,
|
||||||
|
scheduleDays = 30,
|
||||||
|
expiresAt = null,
|
||||||
|
allowJournalNotes = false,
|
||||||
|
} = options;
|
||||||
|
|
||||||
await client.execute({
|
await client.execute({
|
||||||
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at)
|
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at, allow_journal_notes)
|
||||||
VALUES (?, ?, ?, ?, ?)`,
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
args: [userId, token, takenBy, scheduleDays, expiresAt],
|
args: [userId, token, takenBy, scheduleDays, expiresAt, allowJournalNotes ? 1 : 0],
|
||||||
});
|
});
|
||||||
|
|
||||||
return token;
|
return token;
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
function pad(value: number, size = 2): string {
|
||||||
|
return String(value).padStart(size, "0");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toLocalDateTimeOffsetString(value: Date): string {
|
||||||
|
const offsetMinutes = -value.getTimezoneOffset();
|
||||||
|
const sign = offsetMinutes >= 0 ? "+" : "-";
|
||||||
|
const absoluteOffsetMinutes = Math.abs(offsetMinutes);
|
||||||
|
const offsetHours = Math.floor(absoluteOffsetMinutes / 60);
|
||||||
|
const offsetRemainderMinutes = absoluteOffsetMinutes % 60;
|
||||||
|
|
||||||
|
return [
|
||||||
|
`${value.getFullYear()}-${pad(value.getMonth() + 1)}-${pad(value.getDate())}`,
|
||||||
|
`T${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}.${pad(value.getMilliseconds(), 3)}`,
|
||||||
|
`${sign}${pad(offsetHours)}:${pad(offsetRemainderMinutes)}`,
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
export function redactTokenForLog(token: string | null | undefined): string {
|
||||||
|
const normalizedToken = token?.trim();
|
||||||
|
if (!normalizedToken) {
|
||||||
|
return "missing";
|
||||||
|
}
|
||||||
|
|
||||||
|
return `sha256:${createHash("sha256").update(normalizedToken, "utf8").digest("hex").slice(0, 12)}`;
|
||||||
|
}
|
||||||
@@ -7,11 +7,8 @@ export default defineConfig({
|
|||||||
include: ["src/**/*.test.ts"],
|
include: ["src/**/*.test.ts"],
|
||||||
setupFiles: ["src/test/setup.ts"],
|
setupFiles: ["src/test/setup.ts"],
|
||||||
// Run tests sequentially to avoid DB conflicts
|
// Run tests sequentially to avoid DB conflicts
|
||||||
poolOptions: {
|
fileParallelism: false,
|
||||||
threads: {
|
maxWorkers: 1,
|
||||||
singleThread: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Timeout for longer integration tests
|
// Timeout for longer integration tests
|
||||||
testTimeout: 10000,
|
testTimeout: 10000,
|
||||||
coverage: {
|
coverage: {
|
||||||
|
|||||||
+14
-2
@@ -9,9 +9,9 @@ Configure MedAssist with environment variables in `.env`. Start from `.env.examp
|
|||||||
| `PUID` | `1000` | User ID for container file permissions |
|
| `PUID` | `1000` | User ID for container file permissions |
|
||||||
| `PGID` | `1000` | Group ID for container file permissions |
|
| `PGID` | `1000` | Group ID for container file permissions |
|
||||||
| `PORT` | `3000` | Backend API port |
|
| `PORT` | `3000` | Backend API port |
|
||||||
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS |
|
| `CORS_ORIGINS` | `http://localhost:4174` | Allowed origins for CORS in the Docker Compose quickstart; local Vite development commonly uses `http://localhost:5173` or `http://localhost:4173` |
|
||||||
| `TZ` | `Europe/Berlin` | Server default timezone for scheduled reminders |
|
| `TZ` | `Europe/Berlin` | Server default timezone for scheduled reminders |
|
||||||
| `PUBLIC_APP_URL` | — | Public base URL for notification action links |
|
| `PUBLIC_APP_URL` | — | Public base URL for notification action and share links. Strongly recommended for any deployment used from another device; do not point this to `localhost` or an internal Docker hostname. Local Vite development also allows this hostname automatically. |
|
||||||
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error`, or `silent` |
|
| `LOG_LEVEL` | `info` | Log level: `debug`, `info`, `warn`, `error`, or `silent` |
|
||||||
| `RATE_LIMIT_MAX` | `100` | Maximum requests per minute per IP |
|
| `RATE_LIMIT_MAX` | `100` | Maximum requests per minute per IP |
|
||||||
| `OPENAPI_DOCS_ENABLED` | `auto` | Explicitly enable or disable `/docs` and `/docs/json` |
|
| `OPENAPI_DOCS_ENABLED` | `auto` | Explicitly enable or disable `/docs` and `/docs/json` |
|
||||||
@@ -22,6 +22,12 @@ API docs behavior:
|
|||||||
- `OPENAPI_DOCS_ENABLED=true` enables `/docs` and `/docs/json`.
|
- `OPENAPI_DOCS_ENABLED=true` enables `/docs` and `/docs/json`.
|
||||||
- `OPENAPI_DOCS_ENABLED=false` disables the docs only.
|
- `OPENAPI_DOCS_ENABLED=false` disables the docs only.
|
||||||
|
|
||||||
|
`CORS_ORIGINS` note:
|
||||||
|
|
||||||
|
- The `.env.example` file is optimized for the Docker Compose quickstart, where the frontend runs on `http://localhost:4174`.
|
||||||
|
- Local frontend development uses the Vite dev server instead, so the backend schema defaults cover `http://localhost:5173` and `http://localhost:4173`.
|
||||||
|
- If you use a custom hostname or reverse proxy, include that origin in `CORS_ORIGINS`.
|
||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
@@ -102,12 +108,18 @@ API reference:
|
|||||||
|
|
||||||
Reminder timing uses IANA timezones. `TZ` is the server default. Users can override it in Settings.
|
Reminder timing uses IANA timezones. `TZ` is the server default. Users can override it in Settings.
|
||||||
|
|
||||||
|
These values are runtime defaults. User-specific settings can override reminder behavior after first save.
|
||||||
|
|
||||||
## Push Notifications
|
## Push Notifications
|
||||||
|
|
||||||
Push notification setup, provider support, and URL examples are documented in [PUSH_NOTIFICATIONS.md](PUSH_NOTIFICATIONS.md).
|
Push notification setup, provider support, and URL examples are documented in [PUSH_NOTIFICATIONS.md](PUSH_NOTIFICATIONS.md).
|
||||||
|
|
||||||
Recommended provider: `ntfy`, especially for intake reminders with direct actions.
|
Recommended provider: `ntfy`, especially for intake reminders with direct actions.
|
||||||
|
|
||||||
|
Notification action and share links should use `PUBLIC_APP_URL` as their reachable base URL. For self-hosted setups, this should normally be your externally reachable HTTPS address, for example `https://med.example.com`.
|
||||||
|
|
||||||
|
If `PUBLIC_APP_URL` is missing in a remote deployment, reminder links can still be generated from local origins that are unreachable from phones or external browsers.
|
||||||
|
|
||||||
## Default User Settings
|
## Default User Settings
|
||||||
|
|
||||||
Default values for newly created users are documented in [DEFAULT_USER_SETTINGS.md](DEFAULT_USER_SETTINGS.md).
|
Default values for newly created users are documented in [DEFAULT_USER_SETTINGS.md](DEFAULT_USER_SETTINGS.md).
|
||||||
|
|||||||
+22
-2
@@ -19,8 +19,17 @@ If the frontend dev server runs behind a reverse proxy or on a remote host, set
|
|||||||
|
|
||||||
These development overrides are documented here intentionally and are not part of the standard operator-focused `.env.example` surface.
|
These development overrides are documented here intentionally and are not part of the standard operator-focused `.env.example` surface.
|
||||||
|
|
||||||
|
## API Proxy Contract
|
||||||
|
|
||||||
|
- Frontend browser code should call `/api/*`, not hardcoded backend hostnames.
|
||||||
|
- Vite rewrites `/api/*` to the backend target configured by `BACKEND_URL` or the built-in default for the current environment.
|
||||||
|
- Default backend target:
|
||||||
|
- local dev outside Docker: `http://localhost:3000`
|
||||||
|
- dev stack inside Docker: `http://backend-dev:3000`
|
||||||
|
- If your backend runs on a different host or service name, set `BACKEND_URL` explicitly before starting Vite.
|
||||||
|
|
||||||
- `BACKEND_URL`: backend target used by the Vite `/api` proxy; default `http://localhost:3000` outside Docker and `http://backend-dev:3000` in Docker
|
- `BACKEND_URL`: backend target used by the Vite `/api` proxy; default `http://localhost:3000` outside Docker and `http://backend-dev:3000` in Docker
|
||||||
- `VITE_ALLOWED_HOSTS`: comma-separated hostnames allowed to connect to the dev server; default `localhost,127.0.0.1`
|
- `VITE_ALLOWED_HOSTS`: comma-separated hostnames allowed to connect to the dev server; default `localhost,127.0.0.1` plus the hostname from `PUBLIC_APP_URL` when configured
|
||||||
- `VITE_HMR_HOST`: public hostname for HMR websocket connections
|
- `VITE_HMR_HOST`: public hostname for HMR websocket connections
|
||||||
- `VITE_HMR_PROTOCOL`: websocket protocol override (`ws` or `wss`)
|
- `VITE_HMR_PROTOCOL`: websocket protocol override (`ws` or `wss`)
|
||||||
- `VITE_HMR_CLIENT_PORT`: public websocket port exposed to the browser
|
- `VITE_HMR_CLIENT_PORT`: public websocket port exposed to the browser
|
||||||
@@ -30,6 +39,17 @@ These development overrides are documented here intentionally and are not part o
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run lint
|
npm run lint
|
||||||
|
npm run check
|
||||||
|
npm run build
|
||||||
cd backend && npm run test:run
|
cd backend && npm run test:run
|
||||||
cd frontend && npm run test:run
|
cd frontend && npm run test:run
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Recommended local maintenance preflight before opening or updating a PR:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run check
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Use the root-level commands for full-stack validation when a change spans backend and frontend. Keep using the package-local commands when you are validating only one slice.
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notificatio
|
|||||||
|
|
||||||
Recommended provider: `ntfy`.
|
Recommended provider: `ntfy`.
|
||||||
|
|
||||||
Use `ntfy` when you want the best-supported MedAssist notification flow, especially for intake reminders with direct actions such as `Take`, `Skip`, and `View`.
|
Use `ntfy` when you want the best-supported MedAssist notification flow, especially for intake reminders with actions such as `Take`, `Skip`, and `View`.
|
||||||
|
|
||||||
|
For `ntfy`, MedAssist publishes native action buttons so `Take` and `Skip` are executed directly from the notification. The browser-based confirmation flow remains the fallback path for other Shoutrrr targets that do not support native action buttons.
|
||||||
|
|
||||||
|
When an ntfy intake action succeeds, MedAssist publishes the confirmation as the updated notification state and removes the outdated actionable ntfy entry using the original ntfy message ID when available, so duplicate reminder entries do not accumulate unnecessarily.
|
||||||
|
|
||||||
## Supported URL Schemes
|
## Supported URL Schemes
|
||||||
|
|
||||||
@@ -21,6 +25,23 @@ Use `ntfy` when you want the best-supported MedAssist notification flow, especia
|
|||||||
|
|
||||||
Configure push notifications in the app under `Settings -> Push`, or set defaults for new users with environment variables.
|
Configure push notifications in the app under `Settings -> Push`, or set defaults for new users with environment variables.
|
||||||
|
|
||||||
|
Notification action links such as `Take`, `Skip`, and `View` use `PUBLIC_APP_URL` as their base URL. Set this to the public MedAssist URL that the receiving device can actually reach.
|
||||||
|
|
||||||
|
Good examples:
|
||||||
|
|
||||||
|
```text
|
||||||
|
https://med.example.com
|
||||||
|
https://medtest.example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
Bad examples for notification actions:
|
||||||
|
|
||||||
|
```text
|
||||||
|
http://localhost:3000
|
||||||
|
http://backend-dev:3000
|
||||||
|
http://192.168.x.x:3000
|
||||||
|
```
|
||||||
|
|
||||||
Push-related default variables:
|
Push-related default variables:
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
@@ -68,4 +89,22 @@ telegram://TOKEN@telegram?chats=CHAT_ID
|
|||||||
telegram://TOKEN@telegram?chats=@your_channel,-1001234567890
|
telegram://TOKEN@telegram?chats=@your_channel,-1001234567890
|
||||||
```
|
```
|
||||||
|
|
||||||
For all supported services and options, see the [Shoutrrr documentation](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
|
For all supported services and options, see the [Shoutrrr documentation](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### ntfy `Take` / `Skip` fails with a connection timeout
|
||||||
|
|
||||||
|
If the ntfy client shows an error such as `failed to connect to ... port 443`, the failure usually happens before MedAssist can process the action token.
|
||||||
|
|
||||||
|
Check these points first:
|
||||||
|
|
||||||
|
1. `PUBLIC_APP_URL` points to your real public MedAssist URL, not to `localhost`, a Docker service name, or another internal-only address.
|
||||||
|
2. The same URL opens from the same phone and network outside the notification flow.
|
||||||
|
3. If the failure only happens on your home Wi-Fi, retry once on mobile data. That strongly helps distinguish an app issue from missing NAT loopback / hairpin routing on the local network.
|
||||||
|
|
||||||
|
### ntfy shows an old actionable entry after a successful action
|
||||||
|
|
||||||
|
MedAssist updates the notification state after a successful ntfy action and removes the stale actionable entry using the original ntfy message ID when available.
|
||||||
|
|
||||||
|
If an outdated actionable entry still remains visible, verify that the action actually reached MedAssist and that your ntfy server accepted both the confirmation publish and the follow-up delete of the original message.
|
||||||
+64
-13
@@ -1,10 +1,35 @@
|
|||||||
import * as fs from "node:fs";
|
import * as fs from "node:fs";
|
||||||
import * as path from "node:path";
|
import * as path from "node:path";
|
||||||
import { type APIResponse, type Cookie, expect, test as setup } from "@playwright/test";
|
import { type APIResponse, expect, type Page, test as setup } from "@playwright/test";
|
||||||
import { applyVideoSafetyMode, TEST_USER } from "./fixtures";
|
import { applyVideoSafetyMode, TEST_USER } from "./fixtures";
|
||||||
|
|
||||||
const authFile = path.join(import.meta.dirname, ".auth", "user.json");
|
const authFile = path.join(import.meta.dirname, ".auth", "user.json");
|
||||||
|
|
||||||
|
type StoredAuthCookie = {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
domain: string;
|
||||||
|
path: string;
|
||||||
|
expires: number;
|
||||||
|
httpOnly: boolean;
|
||||||
|
secure: boolean;
|
||||||
|
sameSite: "Strict" | "Lax" | "None";
|
||||||
|
};
|
||||||
|
|
||||||
|
type BrowserCookie = {
|
||||||
|
name: string;
|
||||||
|
value: string;
|
||||||
|
url: string;
|
||||||
|
expires?: number;
|
||||||
|
httpOnly: boolean;
|
||||||
|
secure: boolean;
|
||||||
|
sameSite: "Strict" | "Lax" | "None";
|
||||||
|
};
|
||||||
|
|
||||||
|
type StoredAuthState = {
|
||||||
|
cookies?: StoredAuthCookie[];
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a JWT token is still valid (not expired) without making a
|
* Check if a JWT token is still valid (not expired) without making a
|
||||||
* network request. Returns `true` when the token has at least 2 minutes
|
* network request. Returns `true` when the token has at least 2 minutes
|
||||||
@@ -21,7 +46,7 @@ function isTokenValid(token: string): boolean {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toBrowserCookie(setCookieHeader: string, baseURL: string): Cookie | null {
|
function toBrowserCookie(setCookieHeader: string, baseURL: string): BrowserCookie | null {
|
||||||
const segments = setCookieHeader
|
const segments = setCookieHeader
|
||||||
.split(";")
|
.split(";")
|
||||||
.map((segment) => segment.trim())
|
.map((segment) => segment.trim())
|
||||||
@@ -36,7 +61,7 @@ function toBrowserCookie(setCookieHeader: string, baseURL: string): Cookie | nul
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cookie: Cookie = {
|
const cookie: BrowserCookie = {
|
||||||
name: nameValue.slice(0, separatorIndex),
|
name: nameValue.slice(0, separatorIndex),
|
||||||
value: nameValue.slice(separatorIndex + 1),
|
value: nameValue.slice(separatorIndex + 1),
|
||||||
url: baseURL,
|
url: baseURL,
|
||||||
@@ -90,16 +115,12 @@ function toBrowserCookie(setCookieHeader: string, baseURL: string): Cookie | nul
|
|||||||
return cookie;
|
return cookie;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncResponseCookiesToBrowserContext(
|
async function syncResponseCookiesToBrowserContext(page: Page, baseURL: string, response: APIResponse): Promise<void> {
|
||||||
page: Parameters<Parameters<typeof setup>[0]>[0]["page"],
|
|
||||||
baseURL: string,
|
|
||||||
response: APIResponse
|
|
||||||
): Promise<void> {
|
|
||||||
const cookies = response
|
const cookies = response
|
||||||
.headersArray()
|
.headersArray()
|
||||||
.filter((header) => header.name.toLowerCase() === "set-cookie")
|
.filter((header) => header.name.toLowerCase() === "set-cookie")
|
||||||
.map((header) => toBrowserCookie(header.value, baseURL))
|
.map((header) => toBrowserCookie(header.value, baseURL))
|
||||||
.filter((cookie): cookie is Cookie => cookie !== null);
|
.filter((cookie): cookie is BrowserCookie => cookie !== null);
|
||||||
|
|
||||||
if (cookies.length > 0) {
|
if (cookies.length > 0) {
|
||||||
await page.context().addCookies(cookies);
|
await page.context().addCookies(cookies);
|
||||||
@@ -120,6 +141,7 @@ async function syncResponseCookiesToBrowserContext(
|
|||||||
setup("authenticate", async ({ page }) => {
|
setup("authenticate", async ({ page }) => {
|
||||||
setup.setTimeout(120000);
|
setup.setTimeout(120000);
|
||||||
await applyVideoSafetyMode(page);
|
await applyVideoSafetyMode(page);
|
||||||
|
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
||||||
|
|
||||||
// Create .auth directory if it doesn't exist
|
// Create .auth directory if it doesn't exist
|
||||||
const authDir = path.dirname(authFile);
|
const authDir = path.dirname(authFile);
|
||||||
@@ -130,11 +152,41 @@ setup("authenticate", async ({ page }) => {
|
|||||||
// ---- 1. Try to reuse an existing auth file (offline check only) ----
|
// ---- 1. Try to reuse an existing auth file (offline check only) ----
|
||||||
if (fs.existsSync(authFile)) {
|
if (fs.existsSync(authFile)) {
|
||||||
try {
|
try {
|
||||||
const saved = JSON.parse(fs.readFileSync(authFile, "utf-8"));
|
const saved = JSON.parse(fs.readFileSync(authFile, "utf-8")) as StoredAuthState;
|
||||||
const accessCookie = saved.cookies?.find((c: { name: string }) => c.name === "access_token");
|
const accessCookie = saved.cookies?.find((c: { name: string }) => c.name === "access_token");
|
||||||
|
const refreshCookie = saved.cookies?.find((c: { name: string }) => c.name === "refresh_token");
|
||||||
|
|
||||||
|
if (saved.cookies?.length) {
|
||||||
|
await page.context().addCookies(saved.cookies);
|
||||||
|
}
|
||||||
|
|
||||||
if (accessCookie?.value && isTokenValid(accessCookie.value)) {
|
if (accessCookie?.value && isTokenValid(accessCookie.value)) {
|
||||||
// Keep going and verify the session online. A JWT can be time-valid but
|
const hasSavedSession = await page.request
|
||||||
// still rejected by backend token rotation/restart.
|
.get(`${baseURL}/api/auth/me`)
|
||||||
|
.then((response) => response.ok())
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
if (hasSavedSession) {
|
||||||
|
await page.context().storageState({ path: authFile });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshCookie?.value) {
|
||||||
|
const refreshResponse = await page.request.post(`${baseURL}/api/auth/refresh`).catch(() => null);
|
||||||
|
if (refreshResponse?.ok()) {
|
||||||
|
await syncResponseCookiesToBrowserContext(page, baseURL, refreshResponse);
|
||||||
|
|
||||||
|
const refreshedSession = await page.request
|
||||||
|
.get(`${baseURL}/api/auth/me`)
|
||||||
|
.then((response) => response.ok())
|
||||||
|
.catch(() => false);
|
||||||
|
|
||||||
|
if (refreshedSession) {
|
||||||
|
await page.context().storageState({ path: authFile });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Invalid file — fall through to regular login
|
// Invalid file — fall through to regular login
|
||||||
@@ -143,7 +195,6 @@ setup("authenticate", async ({ page }) => {
|
|||||||
|
|
||||||
// ---- 2. Fast path: already authenticated session ----
|
// ---- 2. Fast path: already authenticated session ----
|
||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
|
||||||
let authEnabled = true;
|
let authEnabled = true;
|
||||||
let formLoginEnabled = true;
|
let formLoginEnabled = true;
|
||||||
let oidcEnabled = false;
|
let oidcEnabled = false;
|
||||||
|
|||||||
@@ -289,6 +289,7 @@ export interface TestShareToken {
|
|||||||
token: string;
|
token: string;
|
||||||
takenBy: string;
|
takenBy: string;
|
||||||
scheduleDays: number;
|
scheduleDays: number;
|
||||||
|
allowJournalNotes?: boolean;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,7 +304,7 @@ export async function createMedicationViaAPI(data: {
|
|||||||
takenBy?: string[];
|
takenBy?: string[];
|
||||||
notes?: string;
|
notes?: string;
|
||||||
expiryDate?: string;
|
expiryDate?: string;
|
||||||
packageType?: "blister" | "bottle" | "tube" | "liquid_container";
|
packageType?: "blister" | "bottle" | "tube" | "liquid_container" | "inhaler" | "injection";
|
||||||
medicationForm?: "capsule" | "tablet" | "liquid" | "topical";
|
medicationForm?: "capsule" | "tablet" | "liquid" | "topical";
|
||||||
packCount?: number;
|
packCount?: number;
|
||||||
blistersPerPack?: number;
|
blistersPerPack?: number;
|
||||||
@@ -323,7 +324,12 @@ export async function createMedicationViaAPI(data: {
|
|||||||
let token = await ensureAuthCookie();
|
let token = await ensureAuthCookie();
|
||||||
const apiBase = await getRuntimeApiBase();
|
const apiBase = await getRuntimeApiBase();
|
||||||
const packageType = data.packageType ?? "blister";
|
const packageType = data.packageType ?? "blister";
|
||||||
const isAmountBased = packageType === "bottle" || packageType === "tube" || packageType === "liquid_container";
|
const isAmountBased =
|
||||||
|
packageType === "bottle" ||
|
||||||
|
packageType === "tube" ||
|
||||||
|
packageType === "liquid_container" ||
|
||||||
|
packageType === "inhaler" ||
|
||||||
|
packageType === "injection";
|
||||||
let defaultMedicationForm: "capsule" | "tablet" | "liquid" | "topical" = "tablet";
|
let defaultMedicationForm: "capsule" | "tablet" | "liquid" | "topical" = "tablet";
|
||||||
if (packageType === "tube") {
|
if (packageType === "tube") {
|
||||||
defaultMedicationForm = "topical";
|
defaultMedicationForm = "topical";
|
||||||
@@ -455,7 +461,11 @@ export async function deleteAllMedicationsViaAPI(): Promise<void> {
|
|||||||
* Create a share token via the backend API.
|
* Create a share token via the backend API.
|
||||||
* Requires a medication with takenBy to exist first.
|
* Requires a medication with takenBy to exist first.
|
||||||
*/
|
*/
|
||||||
export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30): Promise<TestShareToken> {
|
export async function createShareTokenViaAPI(
|
||||||
|
takenBy: string,
|
||||||
|
scheduleDays = 30,
|
||||||
|
options: { allowJournalNotes?: boolean; expiryDays?: number | null } = {}
|
||||||
|
): Promise<TestShareToken> {
|
||||||
let token = await ensureAuthCookie();
|
let token = await ensureAuthCookie();
|
||||||
const apiBase = await getRuntimeApiBase();
|
const apiBase = await getRuntimeApiBase();
|
||||||
for (let attempt = 0; attempt < 5; attempt++) {
|
for (let attempt = 0; attempt < 5; attempt++) {
|
||||||
@@ -465,7 +475,12 @@ export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30)
|
|||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...(token ? { Cookie: `access_token=${token}` } : {}),
|
...(token ? { Cookie: `access_token=${token}` } : {}),
|
||||||
},
|
},
|
||||||
body: JSON.stringify({ takenBy, scheduleDays }),
|
body: JSON.stringify({
|
||||||
|
takenBy,
|
||||||
|
scheduleDays,
|
||||||
|
expiryDays: options.expiryDays ?? null,
|
||||||
|
allowJournalNotes: options.allowJournalNotes ?? false,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (res.status === 401) {
|
if (res.status === 401) {
|
||||||
token = await refreshAuthCookieViaLogin();
|
token = await refreshAuthCookieViaLogin();
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ async function fillAndSaveMedication(
|
|||||||
opts: {
|
opts: {
|
||||||
name: string;
|
name: string;
|
||||||
genericName?: string;
|
genericName?: string;
|
||||||
packageType?: "blister" | "bottle" | "tube" | "liquid_container";
|
packageType?: "blister" | "bottle" | "tube" | "liquid_container" | "inhaler" | "injection";
|
||||||
packs?: string;
|
packs?: string;
|
||||||
blistersPerPack?: string;
|
blistersPerPack?: string;
|
||||||
pillsPerBlister?: string;
|
pillsPerBlister?: string;
|
||||||
@@ -50,12 +50,17 @@ async function fillAndSaveMedication(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const packageTypeSelect = form.locator("select.package-type-select");
|
const packageTypeSelect = form.locator("select.package-type-select");
|
||||||
if (opts.packageType === "bottle") {
|
if (opts.packageType === "bottle" || opts.packageType === "inhaler" || opts.packageType === "injection") {
|
||||||
await packageTypeSelect.selectOption("bottle");
|
await packageTypeSelect.selectOption(opts.packageType ?? "bottle");
|
||||||
await page.getByRole("tab", { name: /Package/i }).click();
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
if (opts.totalCapacity)
|
if (opts.totalCapacity)
|
||||||
await form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i).fill(opts.totalCapacity);
|
await form
|
||||||
if (opts.currentPills) await form.getByLabel(/(Current Pills|form\.currentPills)/i).fill(opts.currentPills);
|
.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\)|Total \(count\)|form\.totalCount)/i)
|
||||||
|
.fill(opts.totalCapacity);
|
||||||
|
if (opts.currentPills)
|
||||||
|
await form
|
||||||
|
.getByLabel(/(Current Pills|form\.currentPills|Current Stock|form\.currentStockCount)/i)
|
||||||
|
.fill(opts.currentPills);
|
||||||
} else if (opts.packageType === "tube") {
|
} else if (opts.packageType === "tube") {
|
||||||
await packageTypeSelect.selectOption("tube");
|
await packageTypeSelect.selectOption("tube");
|
||||||
await page.getByRole("tab", { name: /Package/i }).click();
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
@@ -95,12 +100,12 @@ async function fillAndSaveMedication(
|
|||||||
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
||||||
}
|
}
|
||||||
const row = form.locator(".blister-row").nth(i);
|
const row = form.locator(".blister-row").nth(i);
|
||||||
await row
|
const usageField = row.getByRole("textbox", {
|
||||||
.getByLabel(
|
name: /(Usage|Tablets|Capsules|Applications|Puffs|Injections|Ml|form\.blisters\.usage|common\.(puffs|injections))/i,
|
||||||
/(Usage \((pills|tablets|capsules|ml|applications)\)|form\.blisters\.(usage|usageTablets|usageCapsules|usageMl|usageApplication))/i
|
});
|
||||||
)
|
const everyField = row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i);
|
||||||
.fill(intakes[i].usage);
|
await usageField.fill(intakes[i].usage);
|
||||||
await row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill(intakes[i].every);
|
await everyField.fill(intakes[i].every);
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.waitForLoadState("networkidle");
|
await page.waitForLoadState("networkidle");
|
||||||
@@ -195,6 +200,38 @@ test.describe("Medication CRUD", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should create an inhaler medication via the form", async ({ page }) => {
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
|
await fillAndSaveMedication(page, {
|
||||||
|
name: "Test Rescue Inhaler",
|
||||||
|
packageType: "inhaler",
|
||||||
|
totalCapacity: "200",
|
||||||
|
currentPills: "120",
|
||||||
|
intakes: [{ usage: "2", every: "1" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const medRow = page.locator(".med-row").filter({ hasText: "Test Rescue Inhaler" });
|
||||||
|
await expect(medRow.locator(".med-details")).toContainText(/Inhaler|form\.packageTypeInhaler/i);
|
||||||
|
await expect(medRow.locator(".med-total")).toContainText("120 / 200");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should create an injection medication via the form", async ({ page }) => {
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
|
await fillAndSaveMedication(page, {
|
||||||
|
name: "Test Weekly Injection",
|
||||||
|
packageType: "injection",
|
||||||
|
totalCapacity: "12",
|
||||||
|
currentPills: "4",
|
||||||
|
intakes: [{ usage: "1", every: "7" }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const medRow = page.locator(".med-row").filter({ hasText: "Test Weekly Injection" });
|
||||||
|
await expect(medRow.locator(".med-details")).toContainText(/Injection|form\.packageTypeInjection/i);
|
||||||
|
await expect(medRow.locator(".med-total")).toContainText("4 / 12");
|
||||||
|
});
|
||||||
|
|
||||||
test("should create medication with multiple intake schedules", async ({ page }) => {
|
test("should create medication with multiple intake schedules", async ({ page }) => {
|
||||||
await navigateTo(page, "/medications");
|
await navigateTo(page, "/medications");
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,28 @@ async function clickEditMed(page: Page, medName: string): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function openMedicationDetailFromDashboard(page: Page, medName: string) {
|
||||||
|
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||||
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
|
try {
|
||||||
|
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||||
|
const medRow = overviewTable.locator(".table-row").filter({ hasText: medName });
|
||||||
|
await expect(medRow).toBeVisible({ timeout: 10000 });
|
||||||
|
await medRow.click();
|
||||||
|
const modal = page.locator(".modal-content.med-detail-modal");
|
||||||
|
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||||
|
await expect(modal.getByText(medName)).toBeVisible({ timeout: 5000 });
|
||||||
|
return modal;
|
||||||
|
} catch {
|
||||||
|
if (attempt === 2) throw new Error(`Failed to open dashboard medication detail for ${medName}`);
|
||||||
|
await page.reload();
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Failed to open dashboard medication detail for ${medName}`);
|
||||||
|
}
|
||||||
|
|
||||||
/** Helper: save edit and verify success */
|
/** Helper: save edit and verify success */
|
||||||
async function saveEditAndVerify(page: Page, medName: string): Promise<void> {
|
async function saveEditAndVerify(page: Page, medName: string): Promise<void> {
|
||||||
const form = page.locator("form.form-grid:visible").first();
|
const form = page.locator("form.form-grid:visible").first();
|
||||||
@@ -310,24 +332,107 @@ test.describe("Medication Editing", () => {
|
|||||||
|
|
||||||
// Find the remind checkbox in the intake row
|
// Find the remind checkbox in the intake row
|
||||||
const intakeRow = page.locator(".blister-row").first();
|
const intakeRow = page.locator(".blister-row").first();
|
||||||
const remindCheckbox = intakeRow.locator('input[type="checkbox"]');
|
const remindToggle = intakeRow.locator(".toggle-switch");
|
||||||
|
const remindCheckbox = intakeRow.locator('.toggle-switch input[type="checkbox"]');
|
||||||
|
|
||||||
if (await remindCheckbox.isVisible().catch(() => false)) {
|
await expect(remindCheckbox).not.toBeChecked();
|
||||||
// Should be unchecked initially
|
await remindToggle.click();
|
||||||
|
await expect(remindCheckbox).toBeChecked();
|
||||||
|
|
||||||
|
await saveEditAndVerify(page, "Reminder Toggle Med");
|
||||||
|
|
||||||
|
// Verify reminder was saved
|
||||||
|
await clickEditMed(page, "Reminder Toggle Med");
|
||||||
|
const savedCheckbox = page.locator(".blister-row").first().locator('.toggle-switch input[type="checkbox"]');
|
||||||
|
await expect(savedCheckbox).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const scenario of [
|
||||||
|
{
|
||||||
|
name: "Inhaler Reminder Refill Med",
|
||||||
|
packageType: "inhaler" as const,
|
||||||
|
totalCapacity: 200,
|
||||||
|
currentStock: 120,
|
||||||
|
refillAmount: 30,
|
||||||
|
expectedStock: 150,
|
||||||
|
unitLabel: /puffs?|common\.puffs?/i,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Injection Reminder Refill Med",
|
||||||
|
packageType: "injection" as const,
|
||||||
|
totalCapacity: 12,
|
||||||
|
currentStock: 4,
|
||||||
|
refillAmount: 3,
|
||||||
|
expectedStock: 7,
|
||||||
|
unitLabel: /injections?|common\.injections?/i,
|
||||||
|
},
|
||||||
|
]) {
|
||||||
|
test(`should persist reminders and refill ${scenario.packageType} stock without drift`, async ({ page }) => {
|
||||||
|
createdMeds.push(
|
||||||
|
await createMedicationViaAPI({
|
||||||
|
name: scenario.name,
|
||||||
|
packageType: scenario.packageType,
|
||||||
|
totalPills: scenario.totalCapacity,
|
||||||
|
looseTablets: scenario.currentStock,
|
||||||
|
intakes: [
|
||||||
|
{
|
||||||
|
usage: 1,
|
||||||
|
every: 1,
|
||||||
|
start: new Date().toISOString().slice(0, 16),
|
||||||
|
intakeRemindersEnabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
await clickEditMed(page, scenario.name);
|
||||||
|
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||||
|
|
||||||
|
const intakeRow = page.locator(".blister-row").first();
|
||||||
|
const remindToggle = intakeRow.locator(".toggle-switch");
|
||||||
|
const remindCheckbox = intakeRow.locator('.toggle-switch input[type="checkbox"]');
|
||||||
await expect(remindCheckbox).not.toBeChecked();
|
await expect(remindCheckbox).not.toBeChecked();
|
||||||
|
await remindToggle.click();
|
||||||
// Enable it
|
|
||||||
await remindCheckbox.check();
|
|
||||||
await expect(remindCheckbox).toBeChecked();
|
await expect(remindCheckbox).toBeChecked();
|
||||||
|
|
||||||
await saveEditAndVerify(page, "Reminder Toggle Med");
|
await saveEditAndVerify(page, scenario.name);
|
||||||
|
|
||||||
// Verify reminder was saved
|
await clickEditMed(page, scenario.name);
|
||||||
await clickEditMed(page, "Reminder Toggle Med");
|
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||||
const savedCheckbox = page.locator(".blister-row").first().locator('input[type="checkbox"]');
|
await expect(page.locator(".blister-row").first().locator('.toggle-switch input[type="checkbox"]')).toBeChecked();
|
||||||
await expect(savedCheckbox).toBeChecked();
|
|
||||||
}
|
await navigateTo(page, "/dashboard");
|
||||||
});
|
const modal = await openMedicationDetailFromDashboard(page, scenario.name);
|
||||||
|
|
||||||
|
await modal.getByRole("button", { name: /Refill|refill\.button/i }).click();
|
||||||
|
const refillModal = page.locator(".modal-content.refill-modal");
|
||||||
|
await expect(refillModal).toBeVisible({ timeout: 5000 });
|
||||||
|
const refillInput = refillModal.locator('input[type="number"]').first();
|
||||||
|
await refillInput.fill(String(scenario.refillAmount));
|
||||||
|
await expect(refillModal.locator(".refill-preview")).toContainText(`+${scenario.refillAmount}`);
|
||||||
|
await expect(refillModal.locator(".refill-preview")).toContainText(scenario.unitLabel);
|
||||||
|
|
||||||
|
await refillModal.locator(".modal-footer .success").click();
|
||||||
|
await expect(refillModal).not.toBeVisible({ timeout: 10000 });
|
||||||
|
|
||||||
|
const refillHistoryHeader = modal.locator(".med-detail-section h3").filter({
|
||||||
|
hasText: /Refill History|refill\.history/i,
|
||||||
|
});
|
||||||
|
await expect(refillHistoryHeader).toBeVisible({ timeout: 10000 });
|
||||||
|
await refillHistoryHeader.click();
|
||||||
|
const refillAmount = modal.locator(".refill-history-item .refill-amount").first();
|
||||||
|
await expect(refillAmount).toContainText(`+${scenario.refillAmount}`);
|
||||||
|
await expect(refillAmount).toContainText(scenario.unitLabel);
|
||||||
|
|
||||||
|
await page.locator("button.modal-close").click();
|
||||||
|
await expect(modal).not.toBeVisible({ timeout: 5000 });
|
||||||
|
|
||||||
|
await navigateTo(page, "/medications");
|
||||||
|
const medRow = page.locator(".med-row").filter({ hasText: scenario.name });
|
||||||
|
await expect(medRow.locator(".med-total")).toContainText(`${scenario.expectedStock} / ${scenario.totalCapacity}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
test("should change package type across all supported profiles", async ({ page }) => {
|
test("should change package type across all supported profiles", async ({ page }) => {
|
||||||
createdMeds.push(
|
createdMeds.push(
|
||||||
@@ -369,12 +474,30 @@ test.describe("Medication Editing", () => {
|
|||||||
await packageSelect.selectOption("liquid_container");
|
await packageSelect.selectOption("liquid_container");
|
||||||
await page.getByRole("tab", { name: /Package/i }).click();
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
await expect(form.getByLabel(/(Package amount|form\.packageAmount)/i)).toBeVisible();
|
await expect(form.getByLabel(/(Package amount|form\.packageAmount)/i)).toBeVisible();
|
||||||
|
await page.getByRole("tab", { name: /General/i }).click();
|
||||||
|
|
||||||
|
// Switch to inhaler
|
||||||
|
await packageSelect.selectOption("inhaler");
|
||||||
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
await expect(
|
||||||
|
form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(count\)|form\.totalCount)/i)
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(form.getByLabel(/(Current Stock|form\.currentStockCount)/i)).toBeVisible();
|
||||||
|
await page.getByRole("tab", { name: /General/i }).click();
|
||||||
|
|
||||||
|
// Switch to injection and persist this final state
|
||||||
|
await packageSelect.selectOption("injection");
|
||||||
|
await page.getByRole("tab", { name: /Package/i }).click();
|
||||||
|
await expect(
|
||||||
|
form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(count\)|form\.totalCount)/i)
|
||||||
|
).toBeVisible();
|
||||||
|
await expect(form.getByLabel(/(Current Stock|form\.currentStockCount)/i)).toBeVisible();
|
||||||
|
|
||||||
await saveEditAndVerify(page, "PackType Change Med");
|
await saveEditAndVerify(page, "PackType Change Med");
|
||||||
|
|
||||||
// Verify final package type persisted
|
// Verify final package type persisted
|
||||||
await clickEditMed(page, "PackType Change Med");
|
await clickEditMed(page, "PackType Change Med");
|
||||||
await expect(page.locator("select.package-type-select")).toHaveValue("liquid_container");
|
await expect(page.locator("select.package-type-select")).toHaveValue("injection");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should edit multiple fields at once (name, notes, generic, taken-by)", async ({ page }) => {
|
test("should edit multiple fields at once (name, notes, generic, taken-by)", async ({ page }) => {
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import {
|
||||||
|
authFile,
|
||||||
|
createMedicationViaAPI,
|
||||||
|
createShareTokenViaAPI,
|
||||||
|
deleteAllMedicationsViaAPI,
|
||||||
|
expect,
|
||||||
|
navigateTo,
|
||||||
|
test,
|
||||||
|
} from "./fixtures";
|
||||||
|
|
||||||
|
test.describe("Mobile modal browser back", () => {
|
||||||
|
test.use({
|
||||||
|
storageState: authFile,
|
||||||
|
viewport: { width: 412, height: 915 },
|
||||||
|
isMobile: true,
|
||||||
|
hasTouch: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closes owner-side modals with browser back on a Pixel-width viewport", async ({ page }) => {
|
||||||
|
await navigateTo(page, "/dashboard");
|
||||||
|
|
||||||
|
const journalHistoryButton = page.locator(".journal-history-button").first();
|
||||||
|
await expect(journalHistoryButton).toBeVisible({ timeout: 10000 });
|
||||||
|
await journalHistoryButton.click();
|
||||||
|
|
||||||
|
const journalHistoryModal = page.locator(".journal-history-modal");
|
||||||
|
await expect(journalHistoryModal).toBeVisible({ timeout: 10000 });
|
||||||
|
await page.goBack();
|
||||||
|
await expect(journalHistoryModal).toBeHidden({ timeout: 10000 });
|
||||||
|
|
||||||
|
await navigateTo(page, "/settings");
|
||||||
|
|
||||||
|
const exportButton = page
|
||||||
|
.locator("button.secondary")
|
||||||
|
.filter({ hasText: /Export|Exportieren/i })
|
||||||
|
.first();
|
||||||
|
await expect(exportButton).toBeVisible({ timeout: 10000 });
|
||||||
|
await exportButton.click();
|
||||||
|
|
||||||
|
const exportModal = page.locator(".modal-content").filter({ hasText: /Export Options|Export-Optionen/i });
|
||||||
|
await expect(exportModal).toBeVisible({ timeout: 10000 });
|
||||||
|
await page.goBack();
|
||||||
|
await expect(exportModal).toBeHidden({ timeout: 10000 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("closes the shared intake journal modal with browser back on mobile", async ({ page }) => {
|
||||||
|
const uniqueSuffix = Date.now().toString(36);
|
||||||
|
const person = `Mobile Journal ${uniqueSuffix}`;
|
||||||
|
const medicationName = `Mobile Shared Journal ${uniqueSuffix}`;
|
||||||
|
const start = new Date();
|
||||||
|
start.setHours(8, 0, 0, 0);
|
||||||
|
const pad = (value: number) => value.toString().padStart(2, "0");
|
||||||
|
const startTime = `${start.getFullYear()}-${pad(start.getMonth() + 1)}-${pad(start.getDate())}T${pad(start.getHours())}:${pad(start.getMinutes())}`;
|
||||||
|
|
||||||
|
await deleteAllMedicationsViaAPI();
|
||||||
|
await createMedicationViaAPI({
|
||||||
|
name: medicationName,
|
||||||
|
takenBy: [person],
|
||||||
|
packageType: "blister",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: startTime, intakeRemindersEnabled: false, takenBy: person }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const shareToken = await createShareTokenViaAPI(person, 30, { allowJournalNotes: true });
|
||||||
|
|
||||||
|
await page.goto(`/share/${shareToken.token}`);
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
|
||||||
|
await expect(page.locator(".med-name-text").filter({ hasText: medicationName }).first()).toBeVisible({
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const doseItem = page.locator(".dose-item").first();
|
||||||
|
await expect(doseItem).toBeVisible({ timeout: 15000 });
|
||||||
|
await doseItem.locator(".dose-btn.take").click();
|
||||||
|
|
||||||
|
const collapsedTodayDivider = page.locator(".day-block.today.collapsed .day-divider.clickable").first();
|
||||||
|
if (await collapsedTodayDivider.isVisible().catch(() => false)) {
|
||||||
|
await collapsedTodayDivider.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
const noteButton = page.locator(".dose-item").first().locator(".dose-btn.journal");
|
||||||
|
await expect(noteButton).toBeEnabled({ timeout: 10000 });
|
||||||
|
await noteButton.click();
|
||||||
|
|
||||||
|
const journalModal = page.locator(".journal-modal");
|
||||||
|
await expect(journalModal).toBeVisible({ timeout: 10000 });
|
||||||
|
await page.goBack();
|
||||||
|
await expect(journalModal).toBeHidden({ timeout: 10000 });
|
||||||
|
await expect(page.locator(".shared-schedule-container")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
*/
|
*/
|
||||||
test.describe("Share Schedule", () => {
|
test.describe("Share Schedule", () => {
|
||||||
test.use({ storageState: authFile });
|
test.use({ storageState: authFile });
|
||||||
test.describe.configure({ timeout: 90000 });
|
test.describe.configure({ mode: "serial", timeout: 90000 });
|
||||||
|
|
||||||
const MED_ALICE = "ShareTest AliceMed";
|
const MED_ALICE = "ShareTest AliceMed";
|
||||||
const MED_BOB = "ShareTest BobMed";
|
const MED_BOB = "ShareTest BobMed";
|
||||||
@@ -300,4 +300,59 @@ test.describe("Share Schedule", () => {
|
|||||||
|
|
||||||
await page.locator("button.modal-close").click();
|
await page.locator("button.modal-close").click();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("should let a shared recipient add and reopen a journal note", async ({ page }) => {
|
||||||
|
const uniqueSuffix = Date.now().toString(36);
|
||||||
|
const person = `Journal E2E ${uniqueSuffix}`;
|
||||||
|
const medicationName = `Share Journal E2E ${uniqueSuffix}`;
|
||||||
|
const journalNote = `Shared E2E note ${uniqueSuffix}`;
|
||||||
|
|
||||||
|
await createMedicationViaAPI({
|
||||||
|
name: medicationName,
|
||||||
|
takenBy: [person],
|
||||||
|
packageType: "blister",
|
||||||
|
packCount: 1,
|
||||||
|
blistersPerPack: 1,
|
||||||
|
pillsPerBlister: 10,
|
||||||
|
intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false, takenBy: person }],
|
||||||
|
});
|
||||||
|
|
||||||
|
const shareToken = await createShareTokenViaAPI(person, 30, { allowJournalNotes: true });
|
||||||
|
|
||||||
|
await page.goto(`/share/${shareToken.token}`);
|
||||||
|
await page.waitForLoadState("networkidle");
|
||||||
|
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
|
||||||
|
|
||||||
|
await expect(page.locator(".med-name-text").filter({ hasText: medicationName }).first()).toBeVisible({
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const doseItem = page.locator(".dose-item").first();
|
||||||
|
await expect(doseItem).toBeVisible({ timeout: 15000 });
|
||||||
|
await expect(doseItem.locator(".dose-btn.journal")).toBeDisabled();
|
||||||
|
|
||||||
|
await doseItem.locator(".dose-btn.take").click();
|
||||||
|
|
||||||
|
const collapsedTodayDivider = page.locator(".day-block.today.collapsed .day-divider.clickable").first();
|
||||||
|
if (await collapsedTodayDivider.isVisible().catch(() => false)) {
|
||||||
|
await collapsedTodayDivider.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedDoseItem = page.locator(".dose-item").first();
|
||||||
|
const noteButton = updatedDoseItem.locator(".dose-btn.journal");
|
||||||
|
await expect(noteButton).toBeEnabled({ timeout: 10000 });
|
||||||
|
await noteButton.click();
|
||||||
|
|
||||||
|
const noteInput = page.locator("#journal-note-input");
|
||||||
|
await expect(noteInput).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(noteInput).toHaveValue("");
|
||||||
|
|
||||||
|
await noteInput.fill(journalNote);
|
||||||
|
await page.locator(".journal-modal-footer button.primary").click();
|
||||||
|
await expect(page.locator(".journal-modal")).toBeHidden({ timeout: 10000 });
|
||||||
|
|
||||||
|
await noteButton.click();
|
||||||
|
await expect(noteInput).toBeVisible({ timeout: 10000 });
|
||||||
|
await expect(noteInput).toHaveValue(journalNote, { timeout: 10000 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Generated
+175
-182
@@ -1,37 +1,37 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"version": "1.23.0",
|
"version": "1.25.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"version": "1.23.0",
|
"version": "1.25.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"i18next": "^26.1.0",
|
"i18next": "^26.2.0",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.16.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-i18next": "^17.0.7",
|
"react-i18next": "^17.0.8",
|
||||||
"react-router-dom": "^7.15.0",
|
"react-router-dom": "^7.15.1",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.15",
|
"@biomejs/biome": "^2.4.15",
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.60.0",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^25.6.2",
|
"@types/node": "^25.8.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"@vitest/coverage-v8": "^4.1.5",
|
"@vitest/coverage-v8": "^4.1.6",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.12",
|
"vite": "^8.0.13",
|
||||||
"vitest": "^4.1.0"
|
"vitest": "^4.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -594,9 +594,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.129.0",
|
"version": "0.130.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
|
||||||
"integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==",
|
"integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -604,13 +604,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@playwright/test": {
|
"node_modules/@playwright/test": {
|
||||||
"version": "1.59.1",
|
"version": "1.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright": "1.59.1"
|
"playwright": "1.60.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -620,9 +620,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
|
||||||
"integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==",
|
"integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -637,9 +637,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
|
||||||
"integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==",
|
"integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -654,9 +654,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-x64": {
|
"node_modules/@rolldown/binding-darwin-x64": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
|
||||||
"integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==",
|
"integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -671,9 +671,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
|
||||||
"integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==",
|
"integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -688,9 +688,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
|
||||||
"integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==",
|
"integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@@ -705,9 +705,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
|
||||||
"integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==",
|
"integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -722,9 +722,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
|
||||||
"integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==",
|
"integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -739,9 +739,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
|
||||||
"integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==",
|
"integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@@ -756,9 +756,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
|
||||||
"integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==",
|
"integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@@ -773,9 +773,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
|
||||||
"integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==",
|
"integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -790,9 +790,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
|
||||||
"integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==",
|
"integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -807,9 +807,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
|
||||||
"integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==",
|
"integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -824,9 +824,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
|
||||||
"integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==",
|
"integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"wasm32"
|
"wasm32"
|
||||||
],
|
],
|
||||||
@@ -843,9 +843,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
|
||||||
"integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==",
|
"integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@@ -860,9 +860,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
|
||||||
"integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==",
|
"integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@@ -877,9 +877,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.7",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||||
"integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==",
|
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -1032,13 +1032,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "25.6.2",
|
"version": "25.8.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
|
||||||
"integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==",
|
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"undici-types": "~7.19.0"
|
"undici-types": ">=7.24.0 <7.24.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
@@ -1085,13 +1085,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitejs/plugin-react": {
|
"node_modules/@vitejs/plugin-react": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz",
|
||||||
"integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==",
|
"integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rolldown/pluginutils": "1.0.0-rc.7"
|
"@rolldown/pluginutils": "^1.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
@@ -1111,14 +1111,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/coverage-v8": {
|
"node_modules/@vitest/coverage-v8": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz",
|
||||||
"integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==",
|
"integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@bcoe/v8-coverage": "^1.0.2",
|
"@bcoe/v8-coverage": "^1.0.2",
|
||||||
"@vitest/utils": "4.1.5",
|
"@vitest/utils": "4.1.6",
|
||||||
"ast-v8-to-istanbul": "^1.0.0",
|
"ast-v8-to-istanbul": "^1.0.0",
|
||||||
"istanbul-lib-coverage": "^3.2.2",
|
"istanbul-lib-coverage": "^3.2.2",
|
||||||
"istanbul-lib-report": "^3.0.1",
|
"istanbul-lib-report": "^3.0.1",
|
||||||
@@ -1132,8 +1132,8 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@vitest/browser": "4.1.5",
|
"@vitest/browser": "4.1.6",
|
||||||
"vitest": "4.1.5"
|
"vitest": "4.1.6"
|
||||||
},
|
},
|
||||||
"peerDependenciesMeta": {
|
"peerDependenciesMeta": {
|
||||||
"@vitest/browser": {
|
"@vitest/browser": {
|
||||||
@@ -1142,16 +1142,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
|
||||||
"integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
|
"integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@standard-schema/spec": "^1.1.0",
|
"@standard-schema/spec": "^1.1.0",
|
||||||
"@types/chai": "^5.2.2",
|
"@types/chai": "^5.2.2",
|
||||||
"@vitest/spy": "4.1.5",
|
"@vitest/spy": "4.1.6",
|
||||||
"@vitest/utils": "4.1.5",
|
"@vitest/utils": "4.1.6",
|
||||||
"chai": "^6.2.2",
|
"chai": "^6.2.2",
|
||||||
"tinyrainbow": "^3.1.0"
|
"tinyrainbow": "^3.1.0"
|
||||||
},
|
},
|
||||||
@@ -1160,13 +1160,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/mocker": {
|
"node_modules/@vitest/mocker": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
|
||||||
"integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
|
"integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/spy": "4.1.5",
|
"@vitest/spy": "4.1.6",
|
||||||
"estree-walker": "^3.0.3",
|
"estree-walker": "^3.0.3",
|
||||||
"magic-string": "^0.30.21"
|
"magic-string": "^0.30.21"
|
||||||
},
|
},
|
||||||
@@ -1187,9 +1187,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/pretty-format": {
|
"node_modules/@vitest/pretty-format": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz",
|
||||||
"integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
|
"integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1200,13 +1200,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/runner": {
|
"node_modules/@vitest/runner": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz",
|
||||||
"integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
|
"integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/utils": "4.1.5",
|
"@vitest/utils": "4.1.6",
|
||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -1214,14 +1214,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/snapshot": {
|
"node_modules/@vitest/snapshot": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz",
|
||||||
"integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
|
"integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/pretty-format": "4.1.5",
|
"@vitest/pretty-format": "4.1.6",
|
||||||
"@vitest/utils": "4.1.5",
|
"@vitest/utils": "4.1.6",
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
"pathe": "^2.0.3"
|
"pathe": "^2.0.3"
|
||||||
},
|
},
|
||||||
@@ -1230,9 +1230,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/spy": {
|
"node_modules/@vitest/spy": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz",
|
||||||
"integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
|
"integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
@@ -1240,13 +1240,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@vitest/utils": {
|
"node_modules/@vitest/utils": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz",
|
||||||
"integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
|
"integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/pretty-format": "4.1.5",
|
"@vitest/pretty-format": "4.1.6",
|
||||||
"convert-source-map": "^2.0.0",
|
"convert-source-map": "^2.0.0",
|
||||||
"tinyrainbow": "^3.1.0"
|
"tinyrainbow": "^3.1.0"
|
||||||
},
|
},
|
||||||
@@ -1548,9 +1548,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next": {
|
"node_modules/i18next": {
|
||||||
"version": "26.1.0",
|
"version": "26.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.2.0.tgz",
|
||||||
"integrity": "sha512-dIU6td04DvQuIqVst5S9g0GviTmhZ0DYD4b9ociVGJmuCa5vZ2de/t+Enf4olvj87mF8Y2lwjNQBwC9QZsvzKQ==",
|
"integrity": "sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "individual",
|
"type": "individual",
|
||||||
@@ -1961,9 +1961,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/lucide-react": {
|
"node_modules/lucide-react": {
|
||||||
"version": "1.14.0",
|
"version": "1.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz",
|
||||||
"integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==",
|
"integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
@@ -2106,13 +2106,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.59.1",
|
"version": "1.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"playwright-core": "1.59.1"
|
"playwright-core": "1.60.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"playwright": "cli.js"
|
"playwright": "cli.js"
|
||||||
@@ -2125,9 +2125,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/playwright-core": {
|
"node_modules/playwright-core": {
|
||||||
"version": "1.59.1",
|
"version": "1.60.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -2214,9 +2214,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-i18next": {
|
"node_modules/react-i18next": {
|
||||||
"version": "17.0.7",
|
"version": "17.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
|
||||||
"integrity": "sha512-rwtPXsb/zwzDafN+gytcjF5YnqGQQIRmCQ6DctBC1VSipRB8GD/MWEVrFP42vjMyuYydxWxM8CZRt+yiNuuoHg==",
|
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@babel/runtime": "^7.29.2",
|
"@babel/runtime": "^7.29.2",
|
||||||
@@ -2224,7 +2224,7 @@
|
|||||||
"use-sync-external-store": "^1.6.0"
|
"use-sync-external-store": "^1.6.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"i18next": ">= 26.0.10",
|
"i18next": ">= 26.2.0",
|
||||||
"react": ">= 16.8.0",
|
"react": ">= 16.8.0",
|
||||||
"typescript": "^5 || ^6"
|
"typescript": "^5 || ^6"
|
||||||
},
|
},
|
||||||
@@ -2249,9 +2249,9 @@
|
|||||||
"peer": true
|
"peer": true
|
||||||
},
|
},
|
||||||
"node_modules/react-router": {
|
"node_modules/react-router": {
|
||||||
"version": "7.15.0",
|
"version": "7.15.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz",
|
||||||
"integrity": "sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==",
|
"integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cookie": "^1.0.1",
|
"cookie": "^1.0.1",
|
||||||
@@ -2271,12 +2271,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-router-dom": {
|
"node_modules/react-router-dom": {
|
||||||
"version": "7.15.0",
|
"version": "7.15.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz",
|
||||||
"integrity": "sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==",
|
"integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-router": "7.15.0"
|
"react-router": "7.15.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20.0.0"
|
"node": ">=20.0.0"
|
||||||
@@ -2311,14 +2311,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
|
||||||
"integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==",
|
"integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "=0.129.0",
|
"@oxc-project/types": "=0.130.0",
|
||||||
"@rolldown/pluginutils": "1.0.0"
|
"@rolldown/pluginutils": "^1.0.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
"rolldown": "bin/cli.mjs"
|
"rolldown": "bin/cli.mjs"
|
||||||
@@ -2327,30 +2327,23 @@
|
|||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rolldown/binding-android-arm64": "1.0.0",
|
"@rolldown/binding-android-arm64": "1.0.1",
|
||||||
"@rolldown/binding-darwin-arm64": "1.0.0",
|
"@rolldown/binding-darwin-arm64": "1.0.1",
|
||||||
"@rolldown/binding-darwin-x64": "1.0.0",
|
"@rolldown/binding-darwin-x64": "1.0.1",
|
||||||
"@rolldown/binding-freebsd-x64": "1.0.0",
|
"@rolldown/binding-freebsd-x64": "1.0.1",
|
||||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0",
|
"@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
|
||||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0",
|
"@rolldown/binding-linux-arm64-gnu": "1.0.1",
|
||||||
"@rolldown/binding-linux-arm64-musl": "1.0.0",
|
"@rolldown/binding-linux-arm64-musl": "1.0.1",
|
||||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0",
|
"@rolldown/binding-linux-ppc64-gnu": "1.0.1",
|
||||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0",
|
"@rolldown/binding-linux-s390x-gnu": "1.0.1",
|
||||||
"@rolldown/binding-linux-x64-gnu": "1.0.0",
|
"@rolldown/binding-linux-x64-gnu": "1.0.1",
|
||||||
"@rolldown/binding-linux-x64-musl": "1.0.0",
|
"@rolldown/binding-linux-x64-musl": "1.0.1",
|
||||||
"@rolldown/binding-openharmony-arm64": "1.0.0",
|
"@rolldown/binding-openharmony-arm64": "1.0.1",
|
||||||
"@rolldown/binding-wasm32-wasi": "1.0.0",
|
"@rolldown/binding-wasm32-wasi": "1.0.1",
|
||||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0",
|
"@rolldown/binding-win32-arm64-msvc": "1.0.1",
|
||||||
"@rolldown/binding-win32-x64-msvc": "1.0.0"
|
"@rolldown/binding-win32-x64-msvc": "1.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rolldown/node_modules/@rolldown/pluginutils": {
|
|
||||||
"version": "1.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0.tgz",
|
|
||||||
"integrity": "sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/saxes": {
|
"node_modules/saxes": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||||
@@ -2576,9 +2569,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.19.2",
|
"version": "7.24.6",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
@@ -2592,16 +2585,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.0.12",
|
"version": "8.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
|
||||||
"integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==",
|
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.32.0",
|
"lightningcss": "^1.32.0",
|
||||||
"picomatch": "^4.0.4",
|
"picomatch": "^4.0.4",
|
||||||
"postcss": "^8.5.14",
|
"postcss": "^8.5.14",
|
||||||
"rolldown": "1.0.0",
|
"rolldown": "1.0.1",
|
||||||
"tinyglobby": "^0.2.16"
|
"tinyglobby": "^0.2.16"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@@ -2685,19 +2678,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vitest": {
|
"node_modules/vitest": {
|
||||||
"version": "4.1.5",
|
"version": "4.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
|
||||||
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
|
"integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitest/expect": "4.1.5",
|
"@vitest/expect": "4.1.6",
|
||||||
"@vitest/mocker": "4.1.5",
|
"@vitest/mocker": "4.1.6",
|
||||||
"@vitest/pretty-format": "4.1.5",
|
"@vitest/pretty-format": "4.1.6",
|
||||||
"@vitest/runner": "4.1.5",
|
"@vitest/runner": "4.1.6",
|
||||||
"@vitest/snapshot": "4.1.5",
|
"@vitest/snapshot": "4.1.6",
|
||||||
"@vitest/spy": "4.1.5",
|
"@vitest/spy": "4.1.6",
|
||||||
"@vitest/utils": "4.1.5",
|
"@vitest/utils": "4.1.6",
|
||||||
"es-module-lexer": "^2.0.0",
|
"es-module-lexer": "^2.0.0",
|
||||||
"expect-type": "^1.3.0",
|
"expect-type": "^1.3.0",
|
||||||
"magic-string": "^0.30.21",
|
"magic-string": "^0.30.21",
|
||||||
@@ -2725,12 +2718,12 @@
|
|||||||
"@edge-runtime/vm": "*",
|
"@edge-runtime/vm": "*",
|
||||||
"@opentelemetry/api": "^1.9.0",
|
"@opentelemetry/api": "^1.9.0",
|
||||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||||
"@vitest/browser-playwright": "4.1.5",
|
"@vitest/browser-playwright": "4.1.6",
|
||||||
"@vitest/browser-preview": "4.1.5",
|
"@vitest/browser-preview": "4.1.6",
|
||||||
"@vitest/browser-webdriverio": "4.1.5",
|
"@vitest/browser-webdriverio": "4.1.6",
|
||||||
"@vitest/coverage-istanbul": "4.1.5",
|
"@vitest/coverage-istanbul": "4.1.6",
|
||||||
"@vitest/coverage-v8": "4.1.5",
|
"@vitest/coverage-v8": "4.1.6",
|
||||||
"@vitest/ui": "4.1.5",
|
"@vitest/ui": "4.1.6",
|
||||||
"happy-dom": "*",
|
"happy-dom": "*",
|
||||||
"jsdom": "*",
|
"jsdom": "*",
|
||||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
|
|||||||
+10
-10
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "medassist-ng-frontend",
|
"name": "medassist-ng-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.25.0",
|
"version": "1.26.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
@@ -27,30 +27,30 @@
|
|||||||
"test:e2e:report": "playwright show-report"
|
"test:e2e:report": "playwright show-report"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"i18next": "^26.1.0",
|
"i18next": "^26.2.0",
|
||||||
"i18next-browser-languagedetector": "^8.2.1",
|
"i18next-browser-languagedetector": "^8.2.1",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.16.0",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-i18next": "^17.0.7",
|
"react-i18next": "^17.0.8",
|
||||||
"react-router-dom": "^7.15.0",
|
"react-router-dom": "^7.15.1",
|
||||||
"zod": "^4.4.3"
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "^2.4.15",
|
"@biomejs/biome": "^2.4.15",
|
||||||
"@playwright/test": "^1.59.1",
|
"@playwright/test": "^1.60.0",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"@testing-library/user-event": "^14.6.1",
|
"@testing-library/user-event": "^14.6.1",
|
||||||
"@types/node": "^25.6.2",
|
"@types/node": "^25.8.0",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@types/react-router-dom": "^5.3.3",
|
"@types/react-router-dom": "^5.3.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"@vitest/coverage-v8": "^4.1.5",
|
"@vitest/coverage-v8": "^4.1.6",
|
||||||
"jsdom": "^29.1.1",
|
"jsdom": "^29.1.1",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
"vite": "^8.0.12",
|
"vite": "^8.0.13",
|
||||||
"vitest": "^4.1.0"
|
"vitest": "^4.1.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+125
-88
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
AboutModal,
|
AboutModal,
|
||||||
@@ -11,9 +12,19 @@ import {
|
|||||||
} from "./components";
|
} from "./components";
|
||||||
import { AppHeader } from "./components/AppHeader";
|
import { AppHeader } from "./components/AppHeader";
|
||||||
import { AuthPage, AuthProvider, useAuth } from "./components/Auth";
|
import { AuthPage, AuthProvider, useAuth } from "./components/Auth";
|
||||||
import { AppProvider, UnsavedChangesProvider, useAppContext, useShareContext } from "./context";
|
import { AppProvider, FeedbackProvider, UnsavedChangesProvider, useAppContext, useShareContext } from "./context";
|
||||||
import { useScrollLock } from "./hooks/useScrollLock";
|
import { useScrollLock } from "./hooks/useScrollLock";
|
||||||
import { DashboardPage, MedicationsPage, PlannerPage, SchedulePage, SettingsPage, SharedOverviewPage } from "./pages";
|
|
||||||
|
const DashboardPage = lazy(() => import("./pages/DashboardPage").then((module) => ({ default: module.DashboardPage })));
|
||||||
|
const MedicationsPage = lazy(() =>
|
||||||
|
import("./pages/MedicationsPage").then((module) => ({ default: module.MedicationsPage }))
|
||||||
|
);
|
||||||
|
const PlannerPage = lazy(() => import("./pages/PlannerPage").then((module) => ({ default: module.PlannerPage })));
|
||||||
|
const SchedulePage = lazy(() => import("./pages/SchedulePage").then((module) => ({ default: module.SchedulePage })));
|
||||||
|
const SettingsPage = lazy(() => import("./pages/SettingsPage").then((module) => ({ default: module.SettingsPage })));
|
||||||
|
const SharedOverviewPage = lazy(() =>
|
||||||
|
import("./pages/SharedOverviewPage").then((module) => ({ default: module.SharedOverviewPage }))
|
||||||
|
);
|
||||||
|
|
||||||
// Vite injects this at build time from package.json
|
// Vite injects this at build time from package.json
|
||||||
declare const __APP_VERSION__: string;
|
declare const __APP_VERSION__: string;
|
||||||
@@ -21,19 +32,78 @@ export const FRONTEND_VERSION = typeof __APP_VERSION__ !== "undefined" ? __APP_V
|
|||||||
const GITHUB_REPO = "DanielVolz/medassist-ng";
|
const GITHUB_REPO = "DanielVolz/medassist-ng";
|
||||||
export const GITHUB_URL = `https://github.com/${GITHUB_REPO}`;
|
export const GITHUB_URL = `https://github.com/${GITHUB_REPO}`;
|
||||||
|
|
||||||
|
function RouteLoadingFallback() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return <div style={{ padding: "1rem", textAlign: "center" }}>{t("common.loading")}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function AuthStatusCard({ theme, children }: { theme: "light" | "dark"; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="auth-container" data-theme={theme}>
|
||||||
|
<div className="auth-card" style={{ textAlign: "center" }}>
|
||||||
|
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Main App Wrapper with Auth
|
// Main App Wrapper with Auth
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
// Close tooltips on scroll/touch (for mobile). Keep this in the public
|
||||||
|
// wrapper too so shared links get the same tooltip behavior as the app.
|
||||||
|
useEffect(() => {
|
||||||
|
const closeAllTooltips = () => {
|
||||||
|
document.querySelectorAll(".info-tooltip.tooltip-active, .tooltip-trigger.tooltip-active").forEach((el) => {
|
||||||
|
el.classList.remove("tooltip-active");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTooltipClick = (e: Event) => {
|
||||||
|
const target = e.target as HTMLElement;
|
||||||
|
const tooltipTrigger = target.closest(".info-tooltip, .tooltip-trigger") as HTMLElement | null;
|
||||||
|
if (tooltipTrigger) {
|
||||||
|
closeAllTooltips();
|
||||||
|
tooltipTrigger.classList.add("tooltip-active");
|
||||||
|
if (window.innerWidth <= 640) {
|
||||||
|
const rect = tooltipTrigger.getBoundingClientRect();
|
||||||
|
tooltipTrigger.style.setProperty("--tooltip-bottom", `${window.innerHeight - rect.top + 8}px`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
closeAllTooltips();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTouchMove = () => {
|
||||||
|
closeAllTooltips();
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("click", handleTooltipClick, { capture: true });
|
||||||
|
document.addEventListener("touchmove", handleTouchMove, { passive: true });
|
||||||
|
document.addEventListener("scroll", handleTouchMove, { passive: true });
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("click", handleTooltipClick, { capture: true });
|
||||||
|
document.removeEventListener("touchmove", handleTouchMove);
|
||||||
|
document.removeEventListener("scroll", handleTouchMove);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Routes>
|
<FeedbackProvider>
|
||||||
{/* Public share route - accessible without auth */}
|
<Suspense fallback={<RouteLoadingFallback />}>
|
||||||
<Route path="/share/:token/overview" element={<SharedOverviewPage />} />
|
<Routes>
|
||||||
<Route path="/share/:token" element={<SharedSchedule />} />
|
{/* Public share route - accessible without auth */}
|
||||||
{/* All other routes go through AppRouter */}
|
<Route path="/share/:token/overview" element={<SharedOverviewPage />} />
|
||||||
<Route path="*" element={<AppRouter />} />
|
<Route path="/share/:token" element={<SharedSchedule />} />
|
||||||
</Routes>
|
{/* All other routes go through AppRouter */}
|
||||||
|
<Route path="*" element={<AppRouter />} />
|
||||||
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
</FeedbackProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -54,52 +124,42 @@ function getInitialAuthTheme(): "light" | "dark" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AppRouter() {
|
function AppRouter() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const { user, authState, loading, authError } = useAuth();
|
const { user, authState, loading, authError } = useAuth();
|
||||||
const authTheme = getInitialAuthTheme();
|
const authTheme = getInitialAuthTheme();
|
||||||
|
|
||||||
// Show loading while checking auth state
|
// Show loading while checking auth state
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="auth-container" data-theme={authTheme}>
|
<AuthStatusCard theme={authTheme}>
|
||||||
<div className="auth-card" style={{ textAlign: "center" }}>
|
<p>{t("common.loading")}</p>
|
||||||
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
</AuthStatusCard>
|
||||||
<p>Loading...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show error if we couldn't connect to the server
|
// Show error if we couldn't connect to the server
|
||||||
if (authError) {
|
if (authError) {
|
||||||
return (
|
return (
|
||||||
<div className="auth-container" data-theme={authTheme}>
|
<AuthStatusCard theme={authTheme}>
|
||||||
<div className="auth-card" style={{ textAlign: "center" }}>
|
<div className="auth-error" style={{ marginBottom: "1rem" }}>
|
||||||
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
<strong>{t("auth.connectionErrorTitle")}</strong>
|
||||||
<div className="auth-error" style={{ marginBottom: "1rem" }}>
|
<br />
|
||||||
<strong>Connection Error</strong>
|
{authError}
|
||||||
<br />
|
|
||||||
{authError}
|
|
||||||
</div>
|
|
||||||
<p style={{ fontSize: "0.9rem", color: "var(--text-muted)" }}>
|
|
||||||
Please check if the server is running and try again.
|
|
||||||
</p>
|
|
||||||
<button className="btn btn-primary" onClick={() => window.location.reload()} style={{ marginTop: "1rem" }}>
|
|
||||||
Retry
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<p style={{ fontSize: "0.9rem", color: "var(--text-muted)" }}>{t("auth.connectionErrorHelp")}</p>
|
||||||
|
<button className="btn btn-primary" onClick={() => window.location.reload()} style={{ marginTop: "1rem" }}>
|
||||||
|
{t("common.retry")}
|
||||||
|
</button>
|
||||||
|
</AuthStatusCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If auth state is null (shouldn't happen after loading, but be safe)
|
// If auth state is null (shouldn't happen after loading, but be safe)
|
||||||
if (!authState) {
|
if (!authState) {
|
||||||
return (
|
return (
|
||||||
<div className="auth-container" data-theme={authTheme}>
|
<AuthStatusCard theme={authTheme}>
|
||||||
<div className="auth-card" style={{ textAlign: "center" }}>
|
<p>{t("common.initializing")}</p>
|
||||||
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
</AuthStatusCard>
|
||||||
<p>Initializing...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,12 +253,20 @@ function AppContent() {
|
|||||||
setShareSelectedPerson,
|
setShareSelectedPerson,
|
||||||
shareSelectedDays,
|
shareSelectedDays,
|
||||||
setShareSelectedDays,
|
setShareSelectedDays,
|
||||||
|
shareSelectedExpiryDays,
|
||||||
|
setShareSelectedExpiryDays,
|
||||||
|
shareAllowJournalNotes,
|
||||||
|
setShareAllowJournalNotes,
|
||||||
shareGenerating,
|
shareGenerating,
|
||||||
shareLink,
|
shareLink,
|
||||||
setShareLink,
|
setShareLink,
|
||||||
shareCopied,
|
shareCopied,
|
||||||
setShareCopied,
|
setShareCopied,
|
||||||
|
activeShareLinks,
|
||||||
|
activeSharesLoading,
|
||||||
|
revokingShareToken,
|
||||||
generateShareLink,
|
generateShareLink,
|
||||||
|
revokeShareLink,
|
||||||
copyShareLink,
|
copyShareLink,
|
||||||
closeShareDialog,
|
closeShareDialog,
|
||||||
resetShareDialogState,
|
resetShareDialogState,
|
||||||
@@ -272,47 +340,6 @@ function AppContent() {
|
|||||||
setShowRefillModal,
|
setShowRefillModal,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Close tooltips on scroll/touch (for mobile)
|
|
||||||
useEffect(() => {
|
|
||||||
const closeAllTooltips = () => {
|
|
||||||
document.querySelectorAll(".info-tooltip.tooltip-active, .tooltip-trigger.tooltip-active").forEach((el) => {
|
|
||||||
el.classList.remove("tooltip-active");
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTooltipClick = (e: Event) => {
|
|
||||||
const target = e.target as HTMLElement;
|
|
||||||
const tooltipTrigger = target.closest(".info-tooltip, .tooltip-trigger") as HTMLElement | null;
|
|
||||||
if (tooltipTrigger) {
|
|
||||||
// Close other tooltips first
|
|
||||||
closeAllTooltips();
|
|
||||||
// Toggle this one
|
|
||||||
tooltipTrigger.classList.add("tooltip-active");
|
|
||||||
// Position tooltip above the icon on mobile
|
|
||||||
if (window.innerWidth <= 640) {
|
|
||||||
const rect = tooltipTrigger.getBoundingClientRect();
|
|
||||||
// Place tooltip bottom edge just above the icon
|
|
||||||
tooltipTrigger.style.setProperty("--tooltip-bottom", `${window.innerHeight - rect.top + 8}px`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
closeAllTooltips();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTouchMove = () => {
|
|
||||||
closeAllTooltips();
|
|
||||||
};
|
|
||||||
|
|
||||||
document.addEventListener("click", handleTooltipClick, { capture: true });
|
|
||||||
document.addEventListener("touchmove", handleTouchMove, { passive: true });
|
|
||||||
document.addEventListener("scroll", handleTouchMove, { passive: true });
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("click", handleTooltipClick, { capture: true });
|
|
||||||
document.removeEventListener("touchmove", handleTouchMove);
|
|
||||||
document.removeEventListener("scroll", handleTouchMove);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Global Escape handling in priority order.
|
// Global Escape handling in priority order.
|
||||||
// This keeps behavior consistent even when child modals are mocked in tests.
|
// This keeps behavior consistent even when child modals are mocked in tests.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -505,20 +532,22 @@ function AppContent() {
|
|||||||
{/* About Modal */}
|
{/* About Modal */}
|
||||||
<AboutModal isOpen={showAbout} onClose={closeAbout} />
|
<AboutModal isOpen={showAbout} onClose={closeAbout} />
|
||||||
|
|
||||||
<Routes>
|
<Suspense fallback={<RouteLoadingFallback />}>
|
||||||
<Route path="/" element={<Navigate to={{ pathname: "/dashboard", search: location.search }} replace />} />
|
<Routes>
|
||||||
<Route path="/dashboard" element={<DashboardPage />} />
|
<Route path="/" element={<Navigate to={{ pathname: "/dashboard", search: location.search }} replace />} />
|
||||||
|
<Route path="/dashboard" element={<DashboardPage />} />
|
||||||
|
|
||||||
<Route path="/medications" element={<MedicationsPage />} />
|
<Route path="/medications" element={<MedicationsPage />} />
|
||||||
|
|
||||||
<Route path="/planner" element={<PlannerPage />} />
|
<Route path="/planner" element={<PlannerPage />} />
|
||||||
|
|
||||||
<Route path="/settings" element={<SettingsPage />} />
|
<Route path="/settings" element={<SettingsPage />} />
|
||||||
|
|
||||||
<Route path="/schedule" element={<SchedulePage />} />
|
<Route path="/schedule" element={<SchedulePage />} />
|
||||||
{/* Catch-all: redirect unknown routes to dashboard */}
|
{/* Catch-all: redirect unknown routes to dashboard */}
|
||||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</Suspense>
|
||||||
|
|
||||||
{/* Medication Detail Modal */}
|
{/* Medication Detail Modal */}
|
||||||
<MedDetailModal
|
<MedDetailModal
|
||||||
@@ -581,13 +610,21 @@ function AppContent() {
|
|||||||
onShareSelectedPersonChange={setShareSelectedPerson}
|
onShareSelectedPersonChange={setShareSelectedPerson}
|
||||||
shareSelectedDays={shareSelectedDays}
|
shareSelectedDays={shareSelectedDays}
|
||||||
onShareSelectedDaysChange={setShareSelectedDays}
|
onShareSelectedDaysChange={setShareSelectedDays}
|
||||||
|
shareSelectedExpiryDays={shareSelectedExpiryDays}
|
||||||
|
onShareSelectedExpiryDaysChange={setShareSelectedExpiryDays}
|
||||||
|
shareAllowJournalNotes={shareAllowJournalNotes}
|
||||||
|
onShareAllowJournalNotesChange={setShareAllowJournalNotes}
|
||||||
shareGenerating={shareGenerating}
|
shareGenerating={shareGenerating}
|
||||||
shareLink={shareLink}
|
shareLink={shareLink}
|
||||||
onShareLinkChange={setShareLink}
|
onShareLinkChange={setShareLink}
|
||||||
shareCopied={shareCopied}
|
shareCopied={shareCopied}
|
||||||
onShareCopiedChange={setShareCopied}
|
onShareCopiedChange={setShareCopied}
|
||||||
|
activeShareLinks={activeShareLinks}
|
||||||
|
activeSharesLoading={activeSharesLoading}
|
||||||
|
revokingShareToken={revokingShareToken}
|
||||||
onClose={closeShareDialog}
|
onClose={closeShareDialog}
|
||||||
onGenerateShareLink={generateShareLink}
|
onGenerateShareLink={generateShareLink}
|
||||||
|
onRevokeShareLink={revokeShareLink}
|
||||||
onCopyShareLink={copyShareLink}
|
onCopyShareLink={copyShareLink}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
|
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||||
|
import { useModalHistory } from "../hooks/useModalHistory";
|
||||||
import { withCorrelation } from "../utils/correlation";
|
import { withCorrelation } from "../utils/correlation";
|
||||||
import { MAX_IMAGE_UPLOAD_BYTES, resolveImageUploadError } from "../utils/image-upload";
|
import { MAX_IMAGE_UPLOAD_BYTES, resolveImageUploadError } from "../utils/image-upload";
|
||||||
import { log } from "../utils/logger";
|
import { log } from "../utils/logger";
|
||||||
@@ -32,6 +33,7 @@ interface AuthContextType {
|
|||||||
authState: AuthState | null;
|
authState: AuthState | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
authError: string | null;
|
authError: string | null;
|
||||||
|
sessionExpired: boolean;
|
||||||
login: (username: string, password: string, rememberMe?: boolean) => Promise<void>;
|
login: (username: string, password: string, rememberMe?: boolean) => Promise<void>;
|
||||||
register: (username: string, password: string) => Promise<void>;
|
register: (username: string, password: string) => Promise<void>;
|
||||||
logout: () => Promise<void>;
|
logout: () => Promise<void>;
|
||||||
@@ -64,6 +66,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const [authState, setAuthState] = useState<AuthState | null>(null);
|
const [authState, setAuthState] = useState<AuthState | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [authError, setAuthError] = useState<string | null>(null);
|
const [authError, setAuthError] = useState<string | null>(null);
|
||||||
|
const [sessionExpired, setSessionExpired] = useState(false);
|
||||||
// Track if initial fetch has been done to prevent duplicate calls
|
// Track if initial fetch has been done to prevent duplicate calls
|
||||||
const initialFetchDone = useRef(false);
|
const initialFetchDone = useRef(false);
|
||||||
|
|
||||||
@@ -113,6 +116,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
// If auth is enabled and we might be logged in, check session
|
// If auth is enabled and we might be logged in, check session
|
||||||
if (state.authEnabled) {
|
if (state.authEnabled) {
|
||||||
await refreshUser();
|
await refreshUser();
|
||||||
|
} else {
|
||||||
|
setSessionExpired(false);
|
||||||
}
|
}
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -138,6 +143,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const userData = await res.json();
|
const userData = await res.json();
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
|
setSessionExpired(false);
|
||||||
log.debug("[Auth] Session user loaded", { userId: userData.id, correlationId });
|
log.debug("[Auth] Session user loaded", { userId: userData.id, correlationId });
|
||||||
} else if (res.status === 401) {
|
} else if (res.status === 401) {
|
||||||
// Access token expired - try to refresh it
|
// Access token expired - try to refresh it
|
||||||
@@ -150,6 +156,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
if (retryRes.ok) {
|
if (retryRes.ok) {
|
||||||
const userData = await retryRes.json();
|
const userData = await retryRes.json();
|
||||||
setUser(userData);
|
setUser(userData);
|
||||||
|
setSessionExpired(false);
|
||||||
log.info("[Auth] Session restored after token refresh", {
|
log.info("[Auth] Session restored after token refresh", {
|
||||||
userId: userData.id,
|
userId: userData.id,
|
||||||
correlationId: retry.correlationId,
|
correlationId: retry.correlationId,
|
||||||
@@ -159,6 +166,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}
|
}
|
||||||
log.debug("[Auth] Session refresh unavailable, clearing local user state", { correlationId });
|
log.debug("[Auth] Session refresh unavailable, clearing local user state", { correlationId });
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
setSessionExpired(true);
|
||||||
} else {
|
} else {
|
||||||
log.warn("[Auth] Unexpected /auth/me response", { status: res.status, correlationId });
|
log.warn("[Auth] Unexpected /auth/me response", { status: res.status, correlationId });
|
||||||
setUser(null);
|
setUser(null);
|
||||||
@@ -215,6 +223,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setUser(data.user);
|
setUser(data.user);
|
||||||
|
setSessionExpired(false);
|
||||||
log.info("[Auth] Login successful", { userId: data.user?.id, username: data.user?.username, correlationId });
|
log.info("[Auth] Login successful", { userId: data.user?.id, username: data.user?.username, correlationId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,6 +242,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
// Auto-login after registration
|
// Auto-login after registration
|
||||||
await login(username, password);
|
await login(username, password);
|
||||||
|
setSessionExpired(false);
|
||||||
|
|
||||||
// Refresh auth state (registration might disable further registrations)
|
// Refresh auth state (registration might disable further registrations)
|
||||||
await fetchAuthState();
|
await fetchAuthState();
|
||||||
@@ -249,6 +259,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
log.info("[Auth] Logout requested", { userId: user?.id ?? null, correlationId });
|
log.info("[Auth] Logout requested", { userId: user?.id ?? null, correlationId });
|
||||||
await fetch("/api/auth/logout", init);
|
await fetch("/api/auth/logout", init);
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
setSessionExpired(false);
|
||||||
log.info("[Auth] Logout completed", { correlationId });
|
log.info("[Auth] Logout completed", { correlationId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,9 +352,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
if (refreshed) {
|
if (refreshed) {
|
||||||
// Retry the original request with new token
|
// Retry the original request with new token
|
||||||
res = await fetch(input, options);
|
res = await fetch(input, options);
|
||||||
|
if (res.ok) {
|
||||||
|
setSessionExpired(false);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Refresh failed - user needs to login again
|
// Refresh failed - user needs to login again
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
setSessionExpired(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,6 +374,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
authState,
|
authState,
|
||||||
loading,
|
loading,
|
||||||
authError,
|
authError,
|
||||||
|
sessionExpired,
|
||||||
login,
|
login,
|
||||||
register,
|
register,
|
||||||
logout,
|
logout,
|
||||||
@@ -386,7 +402,7 @@ export function LoginForm({
|
|||||||
onSwitchToRegister?: () => void;
|
onSwitchToRegister?: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { login, authState } = useAuth();
|
const { login, authState, sessionExpired } = useAuth();
|
||||||
const [username, setUsername] = useState("");
|
const [username, setUsername] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [rememberMe, setRememberMe] = useState(false);
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
@@ -440,6 +456,13 @@ export function LoginForm({
|
|||||||
{/* Local login form - only show if form login is enabled */}
|
{/* Local login form - only show if form login is enabled */}
|
||||||
{authState?.formLoginEnabled && (
|
{authState?.formLoginEnabled && (
|
||||||
<form onSubmit={handleSubmit} className="auth-form">
|
<form onSubmit={handleSubmit} className="auth-form">
|
||||||
|
{sessionExpired && (
|
||||||
|
<div className="auth-error">
|
||||||
|
<strong>{t("auth.sessionExpiredTitle")}</strong>
|
||||||
|
<br />
|
||||||
|
{t("auth.sessionExpiredHelp")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{error && <div className="auth-error">{error}</div>}
|
{error && <div className="auth-error">{error}</div>}
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
@@ -633,7 +656,14 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
|||||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const closeDeleteConfirm = useCallback(() => {
|
||||||
|
if (!deleteLoading) {
|
||||||
|
setShowDeleteConfirm(false);
|
||||||
|
}
|
||||||
|
}, [deleteLoading]);
|
||||||
|
|
||||||
useEscapeKey(!!onClose, onClose ?? (() => {}));
|
useEscapeKey(!!onClose, onClose ?? (() => {}));
|
||||||
|
useModalHistory(showDeleteConfirm, "profile-delete-account", closeDeleteConfirm);
|
||||||
|
|
||||||
async function handleAvatarUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
async function handleAvatarUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||||
const file = e.target.files?.[0];
|
const file = e.target.files?.[0];
|
||||||
@@ -842,7 +872,7 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
|||||||
confirmLabel={t("auth.deleteAccountButton", "Yes, delete my account")}
|
confirmLabel={t("auth.deleteAccountButton", "Yes, delete my account")}
|
||||||
cancelLabel={t("common.cancel", "Cancel")}
|
cancelLabel={t("common.cancel", "Cancel")}
|
||||||
onConfirm={handleDeleteAccount}
|
onConfirm={handleDeleteAccount}
|
||||||
onCancel={() => setShowDeleteConfirm(false)}
|
onCancel={closeDeleteConfirm}
|
||||||
isLoading={deleteLoading}
|
isLoading={deleteLoading}
|
||||||
confirmVariant="danger"
|
confirmVariant="danger"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { X } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import type { ImportPreview } from "../context/AppContext";
|
||||||
|
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||||
|
import { useScrollLock } from "../hooks/useScrollLock";
|
||||||
|
|
||||||
|
interface ImportReviewModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
importPreview: ImportPreview | null;
|
||||||
|
formattedExportedAt: string;
|
||||||
|
importing: boolean;
|
||||||
|
exporting: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onBackup: () => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportReviewModal({
|
||||||
|
isOpen,
|
||||||
|
importPreview,
|
||||||
|
formattedExportedAt,
|
||||||
|
importing,
|
||||||
|
exporting,
|
||||||
|
onClose,
|
||||||
|
onBackup,
|
||||||
|
onConfirm,
|
||||||
|
}: ImportReviewModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const titleId = "import-review-modal-title";
|
||||||
|
const hasExistingData = importPreview?.warnings.replacesExistingData ?? false;
|
||||||
|
const hasWarnings = Boolean(
|
||||||
|
importPreview?.warnings.replacesExistingData ||
|
||||||
|
importPreview?.warnings.regeneratesShareLinks ||
|
||||||
|
importPreview?.warnings.containsImages ||
|
||||||
|
importPreview?.warnings.containsSensitiveData
|
||||||
|
);
|
||||||
|
|
||||||
|
useScrollLock(isOpen);
|
||||||
|
useEscapeKey(isOpen, onClose);
|
||||||
|
|
||||||
|
if (!isOpen || !importPreview) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="modal-overlay"
|
||||||
|
onClick={onClose}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Escape") {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal-content confirm-modal import-review-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Escape") {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button className="modal-close" onClick={onClose} type="button" aria-label={t("common.close")}>
|
||||||
|
<X size={20} aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<h2 id={titleId}>{t(hasExistingData ? "exportImport.confirmImport" : "exportImport.confirmImportEmpty")}</h2>
|
||||||
|
<div className="import-review-body">
|
||||||
|
<p>{t(hasExistingData ? "exportImport.reviewDescription" : "exportImport.reviewDescriptionEmpty")}</p>
|
||||||
|
<div className="import-review-summary">
|
||||||
|
<div className="action-card">
|
||||||
|
<div className="action-card-content">
|
||||||
|
<span className="action-card-title">{t("exportImport.incomingData")}</span>
|
||||||
|
<span className="action-card-desc">
|
||||||
|
{t("exportImport.summaryCounts", {
|
||||||
|
medications: importPreview.incoming.medications,
|
||||||
|
doses: importPreview.incoming.doseHistory,
|
||||||
|
refills: importPreview.incoming.refillHistory,
|
||||||
|
shares: importPreview.incoming.shareLinks,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="import-review-meta">
|
||||||
|
<span>{t("exportImport.formatVersion", { version: importPreview.version })}</span>
|
||||||
|
<span>{t("exportImport.exportedAt", { date: formattedExportedAt })}</span>
|
||||||
|
{importPreview.incoming.hasSettings && <span>{t("exportImport.settingsIncluded")}</span>}
|
||||||
|
{importPreview.incoming.journalEntries > 0 && (
|
||||||
|
<span>{t("exportImport.journalEntries", { count: importPreview.incoming.journalEntries })}</span>
|
||||||
|
)}
|
||||||
|
{importPreview.incoming.imageCount > 0 && (
|
||||||
|
<span>{t("exportImport.imageCount", { count: importPreview.incoming.imageCount })}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="action-card">
|
||||||
|
<div className="action-card-content">
|
||||||
|
<span className="action-card-title">{t("exportImport.currentData")}</span>
|
||||||
|
<span className="action-card-desc">
|
||||||
|
{t("exportImport.summaryCounts", {
|
||||||
|
medications: importPreview.current.medications,
|
||||||
|
doses: importPreview.current.doseHistory,
|
||||||
|
refills: importPreview.current.refillHistory,
|
||||||
|
shares: importPreview.current.shareLinks,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{importPreview.current.hasSettings && (
|
||||||
|
<span className="import-review-meta">{t("exportImport.settingsConfigured")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasWarnings && (
|
||||||
|
<div className="import-review-warnings">
|
||||||
|
<strong>{t("exportImport.warningListTitle")}</strong>
|
||||||
|
<ul>
|
||||||
|
{importPreview.warnings.replacesExistingData && <li>{t("exportImport.warningReplaceData")}</li>}
|
||||||
|
{importPreview.warnings.regeneratesShareLinks && <li>{t("exportImport.warningShareLinks")}</li>}
|
||||||
|
{importPreview.warnings.containsImages && <li>{t("exportImport.warningImages")}</li>}
|
||||||
|
{importPreview.warnings.containsSensitiveData && <li>{t("exportImport.warningSensitive")}</li>}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasExistingData ? (
|
||||||
|
<p className="warning-text">{t("exportImport.confirmImportWarning")}</p>
|
||||||
|
) : (
|
||||||
|
<p className="hint-text">{t("exportImport.confirmImportEmptyMessage")}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="hint-text">{t("exportImport.backupHint")}</p>
|
||||||
|
</div>
|
||||||
|
<div className="modal-footer import-review-footer">
|
||||||
|
<button type="button" className="ghost" onClick={onClose} disabled={importing || exporting}>
|
||||||
|
{t("exportImport.cancelButton")}
|
||||||
|
</button>
|
||||||
|
<div className="import-review-actions">
|
||||||
|
{hasExistingData && (
|
||||||
|
<button type="button" className="secondary" onClick={onBackup} disabled={exporting || importing}>
|
||||||
|
{exporting ? t("exportImport.exporting") : t("exportImport.backupFirst")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={hasExistingData ? "danger" : "primary"}
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={importing}
|
||||||
|
>
|
||||||
|
{importing
|
||||||
|
? t("exportImport.importing")
|
||||||
|
: t(hasExistingData ? "exportImport.confirmButton" : "exportImport.confirmButtonEmpty")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -298,7 +298,12 @@ export function MedicationEnrichmentSection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const animationFrameId = window.requestAnimationFrame(() => {
|
const animationFrameId = window.requestAnimationFrame(() => {
|
||||||
resultRefs.current.get(expandedResultCode)?.scrollIntoView({
|
const expandedResultElement = resultRefs.current.get(expandedResultCode);
|
||||||
|
if (typeof expandedResultElement?.scrollIntoView !== "function") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
expandedResultElement.scrollIntoView({
|
||||||
block: "nearest",
|
block: "nearest",
|
||||||
inline: "nearest",
|
inline: "nearest",
|
||||||
behavior: "smooth",
|
behavior: "smooth",
|
||||||
|
|||||||
@@ -11,8 +11,11 @@ import {
|
|||||||
isLiquidContainerPackageType,
|
isLiquidContainerPackageType,
|
||||||
isTubePackageType,
|
isTubePackageType,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { formatDate, formatDateTime } from "../utils/formatters";
|
import { formatDate, formatDateTime, toInputValue } from "../utils/formatters";
|
||||||
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
|
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
|
||||||
|
import { mergePersonTags, personTagsMatch } from "../utils/person-tags";
|
||||||
|
import { useAuth } from "./Auth";
|
||||||
|
import { DateTimeInput } from "./DateTimeInput";
|
||||||
import { MedicationAvatar } from "./MedicationAvatar";
|
import { MedicationAvatar } from "./MedicationAvatar";
|
||||||
|
|
||||||
type ReportFormat = "txt" | "md" | "pdf";
|
type ReportFormat = "txt" | "md" | "pdf";
|
||||||
@@ -41,31 +44,53 @@ type ReportData = Record<
|
|||||||
}
|
}
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
type ReportDateRange = {
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ReportPreview = {
|
||||||
|
format: "txt" | "md";
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDefaultDateRange(): ReportDateRange {
|
||||||
|
const endDate = new Date();
|
||||||
|
const startDate = new Date(endDate);
|
||||||
|
startDate.setDate(startDate.getDate() - 30);
|
||||||
|
return {
|
||||||
|
startDate: toInputValue(startDate),
|
||||||
|
endDate: toInputValue(endDate),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function ReportModal({ isOpen, onClose, medications }: ReportModalProps) {
|
export function ReportModal({ isOpen, onClose, medications }: ReportModalProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { authFetch } = useAuth();
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
const [format, setFormat] = useState<ReportFormat>("pdf");
|
const [format, setFormat] = useState<ReportFormat>("pdf");
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [takenByFilter, setTakenByFilter] = useState<Set<string>>(new Set());
|
const [takenByFilter, setTakenByFilter] = useState<Set<string>>(new Set());
|
||||||
|
const [dateRange, setDateRange] = useState<ReportDateRange>(() => getDefaultDateRange());
|
||||||
|
const [preview, setPreview] = useState<ReportPreview | null>(null);
|
||||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
useScrollLock(isOpen);
|
useScrollLock(isOpen);
|
||||||
useEscapeKey(isOpen, onClose);
|
useEscapeKey(isOpen, onClose);
|
||||||
|
|
||||||
// Collect all unique "taken by" people across all medications
|
// Collect all unique "taken by" people across all medications
|
||||||
const allPeople = useMemo(() => {
|
const allPeople = useMemo(() => {
|
||||||
const people = new Set<string>();
|
return mergePersonTags(medications.flatMap((medication) => medication.takenBy || []));
|
||||||
for (const med of medications) {
|
|
||||||
if (med.takenBy) {
|
|
||||||
for (const p of med.takenBy) people.add(p);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Array.from(people).sort();
|
|
||||||
}, [medications]);
|
}, [medications]);
|
||||||
|
|
||||||
// Filtered medications based on takenBy filter
|
// Filtered medications based on takenBy filter
|
||||||
const filteredMeds = useMemo(() => {
|
const filteredMeds = useMemo(() => {
|
||||||
if (takenByFilter.size === 0) return medications;
|
if (takenByFilter.size === 0) return medications;
|
||||||
return medications.filter((m) => m.takenBy?.some((p) => takenByFilter.has(p)));
|
return medications.filter((medication) =>
|
||||||
|
medication.takenBy?.some((person) =>
|
||||||
|
Array.from(takenByFilter).some((filterValue) => personTagsMatch(person, filterValue))
|
||||||
|
)
|
||||||
|
);
|
||||||
}, [medications, takenByFilter]);
|
}, [medications, takenByFilter]);
|
||||||
|
|
||||||
const activeMeds = useMemo(() => filteredMeds.filter((m) => !m.isObsolete), [filteredMeds]);
|
const activeMeds = useMemo(() => filteredMeds.filter((m) => !m.isObsolete), [filteredMeds]);
|
||||||
@@ -97,9 +122,22 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
|
|||||||
setTakenByFilter(new Set());
|
setTakenByFilter(new Set());
|
||||||
setFormat("pdf");
|
setFormat("pdf");
|
||||||
setGenerating(false);
|
setGenerating(false);
|
||||||
|
setDateRange(getDefaultDateRange());
|
||||||
|
setPreview(null);
|
||||||
|
setErrorMessage(null);
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// biome-ignore lint/correctness/useExhaustiveDependencies: preview should reset when any report input changes while the modal is open
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPreview(null);
|
||||||
|
setErrorMessage(null);
|
||||||
|
}, [isOpen, selectedIds, takenByFilter, format, dateRange.startDate, dateRange.endDate]);
|
||||||
|
|
||||||
const toggleMed = useCallback((id: number) => {
|
const toggleMed = useCallback((id: number) => {
|
||||||
setSelectedIds((prev) => {
|
setSelectedIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -118,37 +156,59 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const selectedMeds = useMemo(() => filteredMeds.filter((m) => selectedIds.has(m.id)), [filteredMeds, selectedIds]);
|
const selectedMeds = useMemo(() => filteredMeds.filter((m) => selectedIds.has(m.id)), [filteredMeds, selectedIds]);
|
||||||
|
let generateButtonLabel = t("report.generate");
|
||||||
|
if (generating) {
|
||||||
|
generateButtonLabel = t("report.generating");
|
||||||
|
} else if (preview) {
|
||||||
|
generateButtonLabel = t("report.regenerate");
|
||||||
|
}
|
||||||
|
|
||||||
async function handleGenerate() {
|
async function handleGenerate() {
|
||||||
if (selectedIds.size === 0) return;
|
if (selectedIds.size === 0) return;
|
||||||
|
const startDate = new Date(dateRange.startDate);
|
||||||
|
const endDate = new Date(dateRange.endDate);
|
||||||
|
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime()) || endDate <= startDate) {
|
||||||
|
setErrorMessage(t("report.invalidDateRange"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setGenerating(true);
|
setGenerating(true);
|
||||||
|
setErrorMessage(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const resolvedDateRange = {
|
||||||
|
startDate: startDate.toISOString(),
|
||||||
|
endDate: endDate.toISOString(),
|
||||||
|
};
|
||||||
|
const filterArr = takenByFilter.size > 0 ? Array.from(takenByFilter) : null;
|
||||||
|
|
||||||
// Fetch report data from backend
|
// Fetch report data from backend
|
||||||
const res = await fetch("/api/medications/report-data", {
|
const res = await authFetch("/api/medications/report-data", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
medicationIds: Array.from(selectedIds),
|
medicationIds: Array.from(selectedIds),
|
||||||
|
startDate: resolvedDateRange.startDate,
|
||||||
|
endDate: resolvedDateRange.endDate,
|
||||||
takenByFilter: takenByFilter.size > 0 ? Array.from(takenByFilter) : undefined,
|
takenByFilter: takenByFilter.size > 0 ? Array.from(takenByFilter) : undefined,
|
||||||
}),
|
}),
|
||||||
credentials: "include",
|
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error("Failed to fetch report data");
|
if (!res.ok) throw new Error("Failed to fetch report data");
|
||||||
const reportData = (await res.json()) as ReportData;
|
const reportData = (await res.json()) as ReportData;
|
||||||
|
|
||||||
if (format === "pdf") {
|
if (format === "pdf") {
|
||||||
const imageMap = await fetchMedImages(selectedMeds);
|
const imageMap = await fetchMedImages(selectedMeds, authFetch);
|
||||||
const filterArr = takenByFilter.size > 0 ? Array.from(takenByFilter) : null;
|
openPrintView(selectedMeds, reportData, t, imageMap, filterArr, resolvedDateRange);
|
||||||
openPrintView(selectedMeds, reportData, t, imageMap, filterArr);
|
setPreview(null);
|
||||||
|
setErrorMessage(null);
|
||||||
|
onClose();
|
||||||
} else {
|
} else {
|
||||||
const filterArr = takenByFilter.size > 0 ? Array.from(takenByFilter) : null;
|
const content = generateTextReport(selectedMeds, reportData, format, t, filterArr, resolvedDateRange);
|
||||||
const content = generateTextReport(selectedMeds, reportData, format, t, filterArr);
|
setPreview({ format, content });
|
||||||
downloadFile(content, format);
|
|
||||||
}
|
}
|
||||||
onClose();
|
|
||||||
} catch {
|
} catch {
|
||||||
// Stay open on error so user can retry
|
// Stay open on error so user can retry
|
||||||
|
setErrorMessage(t("report.error"));
|
||||||
} finally {
|
} finally {
|
||||||
setGenerating(false);
|
setGenerating(false);
|
||||||
}
|
}
|
||||||
@@ -177,6 +237,28 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
|
|||||||
<h2 className="report-modal-title">{t("report.title")}</h2>
|
<h2 className="report-modal-title">{t("report.title")}</h2>
|
||||||
<p className="report-modal-desc">{t("report.description")}</p>
|
<p className="report-modal-desc">{t("report.description")}</p>
|
||||||
|
|
||||||
|
<div className="report-range">
|
||||||
|
<h4>{t("report.dateRange")}</h4>
|
||||||
|
<div className="report-range-grid">
|
||||||
|
<div className="report-range-field">
|
||||||
|
<span>{t("report.from")}</span>
|
||||||
|
<DateTimeInput
|
||||||
|
step="60"
|
||||||
|
value={dateRange.startDate}
|
||||||
|
onChange={(e) => setDateRange((prev) => ({ ...prev, startDate: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="report-range-field">
|
||||||
|
<span>{t("report.until")}</span>
|
||||||
|
<DateTimeInput
|
||||||
|
step="60"
|
||||||
|
value={dateRange.endDate}
|
||||||
|
onChange={(e) => setDateRange((prev) => ({ ...prev, endDate: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Person filter */}
|
{/* Person filter */}
|
||||||
{allPeople.length > 1 && (
|
{allPeople.length > 1 && (
|
||||||
<div className="report-person-filter">
|
<div className="report-person-filter">
|
||||||
@@ -279,6 +361,25 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{errorMessage && <p className="report-error">{errorMessage}</p>}
|
||||||
|
|
||||||
|
{preview && (
|
||||||
|
<div className="report-preview">
|
||||||
|
<div className="report-preview-header">
|
||||||
|
<h4>{t("report.preview")}</h4>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost small"
|
||||||
|
onClick={() => downloadFile(preview.content, preview.format)}
|
||||||
|
>
|
||||||
|
{t("report.download")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="report-preview-desc">{t("report.previewDescription")}</p>
|
||||||
|
<pre className="report-preview-content">{preview.content}</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="report-actions">
|
<div className="report-actions">
|
||||||
<button type="button" className="ghost" onClick={onClose}>
|
<button type="button" className="ghost" onClick={onClose}>
|
||||||
@@ -290,7 +391,7 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
|
|||||||
onClick={handleGenerate}
|
onClick={handleGenerate}
|
||||||
disabled={selectedIds.size === 0 || generating}
|
disabled={selectedIds.size === 0 || generating}
|
||||||
>
|
>
|
||||||
{generating ? t("report.generating") : t("report.generate")}
|
{generateButtonLabel}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -348,7 +449,8 @@ function generateTextReport(
|
|||||||
reportData: ReportData,
|
reportData: ReportData,
|
||||||
fmt: "txt" | "md",
|
fmt: "txt" | "md",
|
||||||
t: TFn,
|
t: TFn,
|
||||||
personFilter: string[] | null
|
personFilter: string[] | null,
|
||||||
|
dateRange: { startDate: string; endDate: string }
|
||||||
): string {
|
): string {
|
||||||
const lines: string[] = [];
|
const lines: string[] = [];
|
||||||
const sep = fmt === "md" ? "---" : "═".repeat(60);
|
const sep = fmt === "md" ? "---" : "═".repeat(60);
|
||||||
@@ -360,6 +462,7 @@ function generateTextReport(
|
|||||||
|
|
||||||
lines.push(h1(t("report.docTitle")));
|
lines.push(h1(t("report.docTitle")));
|
||||||
lines.push(`${t("report.docGenerated")}: ${formatDate(new Date().toISOString())}`);
|
lines.push(`${t("report.docGenerated")}: ${formatDate(new Date().toISOString())}`);
|
||||||
|
lines.push(`${t("report.docRange")}: ${formatDateTime(dateRange.startDate)} - ${formatDateTime(dateRange.endDate)}`);
|
||||||
lines.push("");
|
lines.push("");
|
||||||
|
|
||||||
for (const med of meds) {
|
for (const med of meds) {
|
||||||
@@ -483,13 +586,13 @@ function downloadFile(content: string, format: "txt" | "md") {
|
|||||||
|
|
||||||
type ImageMap = Record<number, string>;
|
type ImageMap = Record<number, string>;
|
||||||
|
|
||||||
async function fetchMedImages(meds: Medication[]): Promise<ImageMap> {
|
async function fetchMedImages(meds: Medication[], authFetch: typeof fetch): Promise<ImageMap> {
|
||||||
const map: ImageMap = {};
|
const map: ImageMap = {};
|
||||||
const fetches = meds
|
const fetches = meds
|
||||||
.filter((m) => m.imageUrl)
|
.filter((m) => m.imageUrl)
|
||||||
.map(async (m) => {
|
.map(async (m) => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/images/${m.imageUrl}`, { credentials: "include" });
|
const res = await authFetch(`/api/images/${m.imageUrl}`);
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
const dataUrl = await new Promise<string>((resolve) => {
|
const dataUrl = await new Promise<string>((resolve) => {
|
||||||
@@ -511,12 +614,13 @@ function openPrintView(
|
|||||||
reportData: ReportData,
|
reportData: ReportData,
|
||||||
t: TFn,
|
t: TFn,
|
||||||
imageMap: ImageMap,
|
imageMap: ImageMap,
|
||||||
personFilter: string[] | null
|
personFilter: string[] | null,
|
||||||
|
dateRange: { startDate: string; endDate: string }
|
||||||
) {
|
) {
|
||||||
const w = window.open("", "_blank");
|
const w = window.open("", "_blank");
|
||||||
if (!w) return;
|
if (!w) return;
|
||||||
|
|
||||||
const html = buildPrintHtml(meds, reportData, t, imageMap, personFilter);
|
const html = buildPrintHtml(meds, reportData, t, imageMap, personFilter, dateRange);
|
||||||
w.document.write(html);
|
w.document.write(html);
|
||||||
w.document.close();
|
w.document.close();
|
||||||
w.onload = () => setTimeout(() => w.print(), 300);
|
w.onload = () => setTimeout(() => w.print(), 300);
|
||||||
@@ -531,7 +635,8 @@ function buildPrintHtml(
|
|||||||
reportData: ReportData,
|
reportData: ReportData,
|
||||||
t: TFn,
|
t: TFn,
|
||||||
imageMap: ImageMap,
|
imageMap: ImageMap,
|
||||||
personFilter: string[] | null
|
personFilter: string[] | null,
|
||||||
|
dateRange: { startDate: string; endDate: string }
|
||||||
): string {
|
): string {
|
||||||
const sections: string[] = [];
|
const sections: string[] = [];
|
||||||
|
|
||||||
@@ -721,6 +826,7 @@ function buildPrintHtml(
|
|||||||
<div class="no-print print-hint">${escHtml(t("report.docPrintInstruction"))}</div>
|
<div class="no-print print-hint">${escHtml(t("report.docPrintInstruction"))}</div>
|
||||||
<h1>${escHtml(t("report.docTitle"))}</h1>
|
<h1>${escHtml(t("report.docTitle"))}</h1>
|
||||||
<p class="subtitle">${escHtml(t("report.docGenerated"))}: ${formatDate(new Date().toISOString())}</p>
|
<p class="subtitle">${escHtml(t("report.docGenerated"))}: ${formatDate(new Date().toISOString())}</p>
|
||||||
|
<p class="subtitle">${escHtml(t("report.docRange"))}: ${formatDateTime(dateRange.startDate)} - ${formatDateTime(dateRange.endDate)}</p>
|
||||||
${sections.join("\n")}
|
${sections.join("\n")}
|
||||||
</body>
|
</body>
|
||||||
</html>`;
|
</html>`;
|
||||||
|
|||||||
@@ -4,7 +4,11 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Check, Copy, Link2, X } from "lucide-react";
|
import { Check, Copy, Link2, X } from "lucide-react";
|
||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useModalHistory } from "../hooks";
|
||||||
|
import type { ActiveShareLink } from "../hooks/useShare";
|
||||||
|
import { ConfirmModal } from "./ConfirmModal";
|
||||||
|
|
||||||
export interface ShareDialogProps {
|
export interface ShareDialogProps {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
@@ -13,13 +17,21 @@ export interface ShareDialogProps {
|
|||||||
onShareSelectedPersonChange: (person: string) => void;
|
onShareSelectedPersonChange: (person: string) => void;
|
||||||
shareSelectedDays: number;
|
shareSelectedDays: number;
|
||||||
onShareSelectedDaysChange: (days: number) => void;
|
onShareSelectedDaysChange: (days: number) => void;
|
||||||
|
shareSelectedExpiryDays: number | null;
|
||||||
|
onShareSelectedExpiryDaysChange: (days: number | null) => void;
|
||||||
|
shareAllowJournalNotes: boolean;
|
||||||
|
onShareAllowJournalNotesChange: (enabled: boolean) => void;
|
||||||
shareGenerating: boolean;
|
shareGenerating: boolean;
|
||||||
shareLink: string | null;
|
shareLink: string | null;
|
||||||
onShareLinkChange: (link: string | null) => void;
|
onShareLinkChange: (link: string | null) => void;
|
||||||
shareCopied: boolean;
|
shareCopied: boolean;
|
||||||
onShareCopiedChange: (copied: boolean) => void;
|
onShareCopiedChange: (copied: boolean) => void;
|
||||||
|
activeShareLinks: ActiveShareLink[];
|
||||||
|
activeSharesLoading: boolean;
|
||||||
|
revokingShareToken: string | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onGenerateShareLink: () => Promise<void>;
|
onGenerateShareLink: () => Promise<void>;
|
||||||
|
onRevokeShareLink: (token: string) => Promise<boolean>;
|
||||||
onCopyShareLink: () => void;
|
onCopyShareLink: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,24 +42,116 @@ export function ShareDialog({
|
|||||||
onShareSelectedPersonChange,
|
onShareSelectedPersonChange,
|
||||||
shareSelectedDays,
|
shareSelectedDays,
|
||||||
onShareSelectedDaysChange,
|
onShareSelectedDaysChange,
|
||||||
|
shareSelectedExpiryDays,
|
||||||
|
onShareSelectedExpiryDaysChange,
|
||||||
|
shareAllowJournalNotes,
|
||||||
|
onShareAllowJournalNotesChange,
|
||||||
shareGenerating,
|
shareGenerating,
|
||||||
shareLink,
|
shareLink,
|
||||||
onShareLinkChange,
|
onShareLinkChange,
|
||||||
shareCopied,
|
shareCopied,
|
||||||
onShareCopiedChange,
|
onShareCopiedChange,
|
||||||
|
activeShareLinks,
|
||||||
|
activeSharesLoading,
|
||||||
|
revokingShareToken,
|
||||||
onClose,
|
onClose,
|
||||||
onGenerateShareLink,
|
onGenerateShareLink,
|
||||||
|
onRevokeShareLink,
|
||||||
onCopyShareLink,
|
onCopyShareLink,
|
||||||
}: ShareDialogProps) {
|
}: ShareDialogProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const [manageLinksOpen, setManageLinksOpen] = useState(false);
|
||||||
|
const [shareToRevoke, setShareToRevoke] = useState<ActiveShareLink | null>(null);
|
||||||
const closeLabel = t("common.close");
|
const closeLabel = t("common.close");
|
||||||
const copyLabel = shareCopied ? t("share.copied") : t("share.copyLink");
|
const copyLabel = shareCopied ? t("share.copied") : t("share.copyLink");
|
||||||
const getPersonLabel = (person: string) => (person === "all" ? t("share.allPeople") : person);
|
const getPersonLabel = (person: string) => (person === "all" ? t("share.allPeople") : person);
|
||||||
|
const closeRevokeConfirm = useCallback(() => {
|
||||||
|
if (shareToRevoke && revokingShareToken !== shareToRevoke.token) {
|
||||||
|
setShareToRevoke(null);
|
||||||
|
}
|
||||||
|
}, [revokingShareToken, shareToRevoke]);
|
||||||
|
|
||||||
|
useModalHistory(show && Boolean(shareToRevoke), "share-revoke", closeRevokeConfirm);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!show) {
|
||||||
|
setShareToRevoke(null);
|
||||||
|
}
|
||||||
|
}, [show]);
|
||||||
|
|
||||||
// ESC is handled by the global handler in App.tsx to avoid double history.back()
|
// ESC is handled by the global handler in App.tsx to avoid double history.back()
|
||||||
|
|
||||||
if (!show) return null;
|
if (!show) return null;
|
||||||
|
|
||||||
|
const renderActiveShares = () => {
|
||||||
|
if (activeSharesLoading) {
|
||||||
|
return <p>{t("share.loadingActiveLinks")}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeShareLinks.length === 0) {
|
||||||
|
return <p>{t("share.noActiveLinks")}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="share-active-list">
|
||||||
|
{activeShareLinks.map((share) => {
|
||||||
|
const personLabel = getPersonLabel(share.takenBy);
|
||||||
|
const createdAtLabel = new Date(share.createdAt).toLocaleDateString();
|
||||||
|
const expiresAtLabel = share.expiresAt ? new Date(share.expiresAt).toLocaleDateString() : null;
|
||||||
|
return (
|
||||||
|
<li key={share.token} className="share-active-item">
|
||||||
|
<div className="share-active-copy">
|
||||||
|
<a href={`${window.location.origin}${share.shareUrl}`} className="share-link-inline">
|
||||||
|
{personLabel}
|
||||||
|
</a>
|
||||||
|
<span className="hint-text">
|
||||||
|
{expiresAtLabel
|
||||||
|
? t("share.activeLinkMetaWithExpiry", {
|
||||||
|
person: personLabel,
|
||||||
|
days: share.scheduleDays,
|
||||||
|
createdAt: createdAtLabel,
|
||||||
|
expiresAt: expiresAtLabel,
|
||||||
|
})
|
||||||
|
: t("share.activeLinkMeta", {
|
||||||
|
person: personLabel,
|
||||||
|
days: share.scheduleDays,
|
||||||
|
createdAt: createdAtLabel,
|
||||||
|
})}
|
||||||
|
{share.allowJournalNotes ? ` · ${t("share.journalNotesEnabled")}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost"
|
||||||
|
disabled={revokingShareToken === share.token}
|
||||||
|
onClick={() => setShareToRevoke(share)}
|
||||||
|
>
|
||||||
|
{revokingShareToken === share.token ? t("share.revoking") : t("share.revoke")}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderManageLinks = () => (
|
||||||
|
<div className="share-dialog-manage">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="share-dialog-manage-summary"
|
||||||
|
onClick={() => setManageLinksOpen((current) => !current)}
|
||||||
|
aria-expanded={manageLinksOpen}
|
||||||
|
>
|
||||||
|
<span>{t("share.manageLinksSummary", { count: activeShareLinks.length })}</span>
|
||||||
|
<span className="share-dialog-manage-count">
|
||||||
|
{manageLinksOpen ? t("common.hide") : activeShareLinks.length}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{manageLinksOpen ? <div className="share-dialog-manage-content">{renderActiveShares()}</div> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="modal-overlay"
|
className="modal-overlay"
|
||||||
@@ -85,6 +189,7 @@ export function ShareDialog({
|
|||||||
return (
|
return (
|
||||||
<div className="share-dialog-empty">
|
<div className="share-dialog-empty">
|
||||||
<p>{t("share.noPeople")}</p>
|
<p>{t("share.noPeople")}</p>
|
||||||
|
<div className="share-dialog-active-links">{renderManageLinks()}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -124,6 +229,7 @@ export function ShareDialog({
|
|||||||
</button>
|
</button>
|
||||||
<button onClick={onClose}>{t("common.close")}</button>
|
<button onClick={onClose}>{t("common.close")}</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="share-dialog-active-links">{renderManageLinks()}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -159,6 +265,33 @@ export function ShareDialog({
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="form-group">
|
||||||
|
<label htmlFor="share-expiry-select">{t("share.selectExpiry")}</label>
|
||||||
|
<select
|
||||||
|
id="share-expiry-select"
|
||||||
|
className="select-field"
|
||||||
|
value={shareSelectedExpiryDays == null ? "never" : String(shareSelectedExpiryDays)}
|
||||||
|
onChange={(e) =>
|
||||||
|
onShareSelectedExpiryDaysChange(e.target.value === "never" ? null : Number(e.target.value))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="never">{t("share.expiryNever")}</option>
|
||||||
|
<option value="7">{t("share.expiry7Days")}</option>
|
||||||
|
<option value="30">{t("share.expiry30Days")}</option>
|
||||||
|
<option value="90">{t("share.expiry90Days")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="inline-checkbox" htmlFor="share-journal-notes-toggle">
|
||||||
|
<input
|
||||||
|
id="share-journal-notes-toggle"
|
||||||
|
type="checkbox"
|
||||||
|
checked={shareAllowJournalNotes}
|
||||||
|
onChange={(event) => onShareAllowJournalNotesChange(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>{t("share.allowJournalNotes")}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<div className="share-dialog-footer">
|
<div className="share-dialog-footer">
|
||||||
<button className="ghost" onClick={onClose}>
|
<button className="ghost" onClick={onClose}>
|
||||||
{t("common.close")}
|
{t("common.close")}
|
||||||
@@ -167,9 +300,28 @@ export function ShareDialog({
|
|||||||
{shareGenerating ? t("share.generating") : t("share.generateLink")}
|
{shareGenerating ? t("share.generating") : t("share.generateLink")}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="share-dialog-active-links">{renderManageLinks()}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})()}
|
})()}
|
||||||
|
{shareToRevoke && (
|
||||||
|
<ConfirmModal
|
||||||
|
title={t("share.revoke")}
|
||||||
|
message={t("share.revokeConfirm", { person: getPersonLabel(shareToRevoke.takenBy) })}
|
||||||
|
confirmLabel={revokingShareToken === shareToRevoke.token ? t("share.revoking") : t("share.revoke")}
|
||||||
|
cancelLabel={t("common.cancel")}
|
||||||
|
onConfirm={async () => {
|
||||||
|
const revoked = await onRevokeShareLink(shareToRevoke.token);
|
||||||
|
if (revoked) {
|
||||||
|
setShareToRevoke(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onCancel={closeRevokeConfirm}
|
||||||
|
isLoading={revokingShareToken === shareToRevoke.token}
|
||||||
|
confirmVariant="danger"
|
||||||
|
overlayClassName="nested-confirm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,14 +4,17 @@
|
|||||||
/* biome-ignore-all lint/style/noNestedTernary: rendering branches are intentionally explicit in schedule UI */
|
/* biome-ignore-all lint/style/noNestedTernary: rendering branches are intentionally explicit in schedule UI */
|
||||||
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: modal and helper callbacks are stable at runtime */
|
/* biome-ignore-all lint/correctness/useExhaustiveDependencies: modal and helper callbacks are stable at runtime */
|
||||||
|
|
||||||
|
import { NotebookPen } from "lucide-react";
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
|
import { useFeedback } from "../context/FeedbackContext";
|
||||||
import { ScheduleUsageTag } from "../features/schedule/components";
|
import { ScheduleUsageTag } from "../features/schedule/components";
|
||||||
import { formatScheduleDoseUsageLabel, formatScheduleTotalUsageLabel } from "../features/schedule/formatters";
|
import { formatScheduleDoseUsageLabel, formatScheduleTotalUsageLabel } from "../features/schedule/formatters";
|
||||||
import { toggleDateInSet } from "../features/schedule/interactions";
|
import { toggleDateInSet } from "../features/schedule/interactions";
|
||||||
import { loadScheduleCollapseState, saveCollapsedDaySet } from "../features/schedule/storage";
|
import { loadScheduleCollapseState, saveCollapsedDaySet } from "../features/schedule/storage";
|
||||||
import { useEscapeKey } from "../hooks";
|
import { useEscapeKey, useModalHistory } from "../hooks";
|
||||||
|
import type { IntakeJournalEntry } from "../hooks/useIntakeJournal";
|
||||||
import type { ExpiredLinkData, SharedScheduleData } from "../types";
|
import type { ExpiredLinkData, SharedScheduleData } from "../types";
|
||||||
import {
|
import {
|
||||||
allowsPillFormSelection,
|
allowsPillFormSelection,
|
||||||
@@ -26,12 +29,30 @@ import { getSystemLocale } from "../utils/formatters";
|
|||||||
import { getIntakeDailyRate, getMedicationIntakes, iterateIntakeOccurrences } from "../utils/intake-schedule";
|
import { getIntakeDailyRate, getMedicationIntakes, iterateIntakeOccurrences } from "../utils/intake-schedule";
|
||||||
import { convertLiquidUsageToMl } from "../utils/intake-units";
|
import { convertLiquidUsageToMl } from "../utils/intake-units";
|
||||||
import { getStockStatus, isDoseDismissed, parseLocalDateTime } from "../utils/schedule";
|
import { getStockStatus, isDoseDismissed, parseLocalDateTime } from "../utils/schedule";
|
||||||
|
import { IntakeJournalModal } from "./intake-journal/IntakeJournalModal";
|
||||||
import { MedicationAvatar } from "./MedicationAvatar";
|
import { MedicationAvatar } from "./MedicationAvatar";
|
||||||
import { SharedMedicationOverviewSection } from "./SharedMedicationOverviewSection";
|
import { SharedMedicationOverviewSection } from "./SharedMedicationOverviewSection";
|
||||||
|
|
||||||
|
async function readSharedJournalError(response: Response, fallbackMessage: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const data = (await response.json()) as { error?: string; code?: string };
|
||||||
|
if (typeof data.error === "string" && data.error.trim().length > 0) {
|
||||||
|
return data.error;
|
||||||
|
}
|
||||||
|
if (typeof data.code === "string" && data.code.trim().length > 0) {
|
||||||
|
return data.code;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to the supplied message when the response body is not JSON.
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackMessage;
|
||||||
|
}
|
||||||
|
|
||||||
export function SharedSchedule() {
|
export function SharedSchedule() {
|
||||||
const { token } = useParams<{ token: string }>();
|
const { token } = useParams<{ token: string }>();
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const { showFeedback } = useFeedback();
|
||||||
const [data, setData] = useState<SharedScheduleData | null>(null);
|
const [data, setData] = useState<SharedScheduleData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -39,8 +60,15 @@ export function SharedSchedule() {
|
|||||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||||||
const [automaticTakenDoses, setAutomaticTakenDoses] = useState<Set<string>>(new Set());
|
const [automaticTakenDoses, setAutomaticTakenDoses] = useState<Set<string>>(new Set());
|
||||||
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
|
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
|
||||||
|
const [sharedJournalDoseIdsWithNotes, setSharedJournalDoseIdsWithNotes] = useState<Set<string>>(new Set());
|
||||||
const mutationInFlightRef = useRef(0);
|
const mutationInFlightRef = useRef(0);
|
||||||
const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null);
|
const [lightboxImage, setLightboxImage] = useState<{ url: string; name: string } | null>(null);
|
||||||
|
const [sharedJournalOpen, setSharedJournalOpen] = useState(false);
|
||||||
|
const [sharedJournalDoseId, setSharedJournalDoseId] = useState<string | null>(null);
|
||||||
|
const [sharedJournalEntry, setSharedJournalEntry] = useState<IntakeJournalEntry | null>(null);
|
||||||
|
const [sharedJournalLoading, setSharedJournalLoading] = useState(false);
|
||||||
|
const [sharedJournalSaving, setSharedJournalSaving] = useState(false);
|
||||||
|
const [sharedJournalError, setSharedJournalError] = useState<string | null>(null);
|
||||||
const [showPastDays, setShowPastDays] = useState(false);
|
const [showPastDays, setShowPastDays] = useState(false);
|
||||||
const [showFutureDays, setShowFutureDays] = useState(false);
|
const [showFutureDays, setShowFutureDays] = useState(false);
|
||||||
|
|
||||||
@@ -169,6 +197,107 @@ export function SharedSchedule() {
|
|||||||
// Close lightbox on Escape key
|
// Close lightbox on Escape key
|
||||||
useEscapeKey(!!lightboxImage, closeLightbox);
|
useEscapeKey(!!lightboxImage, closeLightbox);
|
||||||
|
|
||||||
|
const closeSharedJournalEditor = useCallback(() => {
|
||||||
|
setSharedJournalOpen(false);
|
||||||
|
setSharedJournalDoseId(null);
|
||||||
|
setSharedJournalEntry(null);
|
||||||
|
setSharedJournalLoading(false);
|
||||||
|
setSharedJournalSaving(false);
|
||||||
|
setSharedJournalError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useModalHistory(sharedJournalOpen, "shared-intake-journal", closeSharedJournalEditor);
|
||||||
|
|
||||||
|
const openSharedJournalEditor = useCallback(
|
||||||
|
async (doseId: string) => {
|
||||||
|
if (!token || !data?.allowJournalNotes) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSharedJournalOpen(true);
|
||||||
|
setSharedJournalDoseId(doseId);
|
||||||
|
setSharedJournalEntry(null);
|
||||||
|
setSharedJournalLoading(true);
|
||||||
|
setSharedJournalError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/share/${token}/journal/event/${encodeURIComponent(doseId)}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
setSharedJournalEntry(null);
|
||||||
|
setSharedJournalError(await readSharedJournalError(response, t("journal.errors.loadFailed")));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as { entry: IntakeJournalEntry };
|
||||||
|
setSharedJournalEntry(payload.entry);
|
||||||
|
setSharedJournalDoseIdsWithNotes((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (payload.entry.note?.trim()) {
|
||||||
|
next.add(payload.entry.doseId);
|
||||||
|
} else {
|
||||||
|
next.delete(payload.entry.doseId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
setSharedJournalEntry(null);
|
||||||
|
setSharedJournalError(t("journal.errors.loadFailed"));
|
||||||
|
} finally {
|
||||||
|
setSharedJournalLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[data?.allowJournalNotes, t, token]
|
||||||
|
);
|
||||||
|
|
||||||
|
const saveSharedJournalNote = useCallback(
|
||||||
|
async (note: string) => {
|
||||||
|
if (!token || !sharedJournalDoseId) {
|
||||||
|
setSharedJournalError(t("journal.errors.noEventSelected"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (note.trim().length === 0) {
|
||||||
|
setSharedJournalError(t("journal.errors.emptySharedNote"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSharedJournalSaving(true);
|
||||||
|
setSharedJournalError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/share/${token}/journal/event/${encodeURIComponent(sharedJournalDoseId)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ note }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
setSharedJournalError(await readSharedJournalError(response, t("journal.errors.saveFailed")));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as { entry: IntakeJournalEntry };
|
||||||
|
setSharedJournalEntry(payload.entry);
|
||||||
|
setSharedJournalDoseIdsWithNotes((current) => {
|
||||||
|
const next = new Set(current);
|
||||||
|
if (payload.entry.note?.trim()) {
|
||||||
|
next.add(payload.entry.doseId);
|
||||||
|
} else {
|
||||||
|
next.delete(payload.entry.doseId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
setSharedJournalError(t("journal.errors.saveFailed"));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setSharedJournalSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[sharedJournalDoseId, t, token]
|
||||||
|
);
|
||||||
|
|
||||||
// Handle browser back button to close lightbox
|
// Handle browser back button to close lightbox
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handlePopState() {
|
function handlePopState() {
|
||||||
@@ -194,11 +323,13 @@ export function SharedSchedule() {
|
|||||||
const taken = new Set<string>();
|
const taken = new Set<string>();
|
||||||
const automatic = new Set<string>();
|
const automatic = new Set<string>();
|
||||||
const dismissed = new Set<string>();
|
const dismissed = new Set<string>();
|
||||||
|
const journalDoseIds = new Set<string>();
|
||||||
for (const d of data.doses as Array<{
|
for (const d of data.doses as Array<{
|
||||||
doseId: string;
|
doseId: string;
|
||||||
dismissed?: boolean;
|
dismissed?: boolean;
|
||||||
skipped?: boolean;
|
skipped?: boolean;
|
||||||
takenSource?: string;
|
takenSource?: string;
|
||||||
|
hasJournalNote?: boolean;
|
||||||
}>) {
|
}>) {
|
||||||
if (d.skipped === true || d.dismissed === true) {
|
if (d.skipped === true || d.dismissed === true) {
|
||||||
dismissed.add(d.doseId);
|
dismissed.add(d.doseId);
|
||||||
@@ -208,10 +339,14 @@ export function SharedSchedule() {
|
|||||||
automatic.add(d.doseId);
|
automatic.add(d.doseId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (d.hasJournalNote === true) {
|
||||||
|
journalDoseIds.add(d.doseId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setTakenDoses(taken);
|
setTakenDoses(taken);
|
||||||
setAutomaticTakenDoses(automatic);
|
setAutomaticTakenDoses(automatic);
|
||||||
setDismissedDoses(dismissed);
|
setDismissedDoses(dismissed);
|
||||||
|
setSharedJournalDoseIdsWithNotes(journalDoseIds);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Keep the current optimistic/shared state on transient read errors.
|
// Keep the current optimistic/shared state on transient read errors.
|
||||||
@@ -268,7 +403,7 @@ export function SharedSchedule() {
|
|||||||
try {
|
try {
|
||||||
const data = (await response.json()) as { code?: string };
|
const data = (await response.json()) as { code?: string };
|
||||||
if (data.code === "OUT_OF_STOCK") {
|
if (data.code === "OUT_OF_STOCK") {
|
||||||
alert(t("common.outOfStockTakeBlocked"));
|
showFeedback({ message: t("common.outOfStockTakeBlocked"), tone: "error" });
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore JSON parsing errors and fall back to the optimistic rollback only.
|
// Ignore JSON parsing errors and fall back to the optimistic rollback only.
|
||||||
@@ -448,6 +583,9 @@ export function SharedSchedule() {
|
|||||||
isAutomaticallyTaken: boolean;
|
isAutomaticallyTaken: boolean;
|
||||||
isEmpty: boolean;
|
isEmpty: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
|
const showSharedJournalAction = Boolean(data?.allowJournalNotes);
|
||||||
|
const canOpenSharedJournal = showSharedJournalAction && (options.isTaken || options.isSkipped);
|
||||||
|
const hasSharedJournalNote = sharedJournalDoseIdsWithNotes.has(options.doseId);
|
||||||
const takeButton = options.isTaken ? (
|
const takeButton = options.isTaken ? (
|
||||||
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
|
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
|
||||||
{options.isAutomaticallyTaken && (
|
{options.isAutomaticallyTaken && (
|
||||||
@@ -486,10 +624,33 @@ export function SharedSchedule() {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const journalButton = showSharedJournalAction ? (
|
||||||
|
<span
|
||||||
|
className={!canOpenSharedJournal ? "tooltip-trigger" : undefined}
|
||||||
|
data-tooltip={!canOpenSharedJournal ? t("journal.actions.noteTakenOnly") : undefined}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`dose-btn journal${hasSharedJournalNote ? " has-note" : ""}`}
|
||||||
|
onClick={() => {
|
||||||
|
if (canOpenSharedJournal) {
|
||||||
|
void openSharedJournalEditor(options.doseId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={!canOpenSharedJournal}
|
||||||
|
title={canOpenSharedJournal ? t("journal.actions.note") : undefined}
|
||||||
|
>
|
||||||
|
<NotebookPen size={14} aria-hidden="true" />
|
||||||
|
<span className="dose-btn-label">{t("journal.actions.note")}</span>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{takeButton}
|
{takeButton}
|
||||||
{skipButton}
|
{skipButton}
|
||||||
|
{journalButton}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -641,7 +802,10 @@ export function SharedSchedule() {
|
|||||||
}, [data, i18n.language]);
|
}, [data, i18n.language]);
|
||||||
|
|
||||||
// Split into past, today, and future - matches main app logic
|
// Split into past, today, and future - matches main app logic
|
||||||
const pastDays = useMemo(() => schedule.filter((d) => d.isPast), [schedule]);
|
const pastDays = useMemo(() => {
|
||||||
|
const visiblePastDays = Math.max(1, data?.scheduleDays ?? 30);
|
||||||
|
return schedule.filter((d) => d.isPast).slice(-visiblePastDays);
|
||||||
|
}, [schedule, data?.scheduleDays]);
|
||||||
|
|
||||||
// Separate today from future days
|
// Separate today from future days
|
||||||
const { todayDay, futureDays } = useMemo(() => {
|
const { todayDay, futureDays } = useMemo(() => {
|
||||||
@@ -901,6 +1065,7 @@ export function SharedSchedule() {
|
|||||||
<div className="shared-schedule-container">
|
<div className="shared-schedule-container">
|
||||||
<header className="shared-schedule-header">
|
<header className="shared-schedule-header">
|
||||||
<h1>{pageTitle}</h1>
|
<h1>{pageTitle}</h1>
|
||||||
|
<p className="shared-schedule-boundary">{t("share.publicAccessHelp")}</p>
|
||||||
<div className="shared-schedule-header-actions">
|
<div className="shared-schedule-header-actions">
|
||||||
<div className={`theme-menu ${themeMenuOpen ? "open" : ""}`} ref={themeMenuRef}>
|
<div className={`theme-menu ${themeMenuOpen ? "open" : ""}`} ref={themeMenuRef}>
|
||||||
<button className="icon-btn" onClick={() => setThemeMenuOpen(!themeMenuOpen)} title={t("theme.title")}>
|
<button className="icon-btn" onClick={() => setThemeMenuOpen(!themeMenuOpen)} title={t("theme.title")}>
|
||||||
@@ -1226,7 +1391,7 @@ export function SharedSchedule() {
|
|||||||
const hasAutomaticTakenDose = allDoseIds.some((id) => isDoseTakenAutomatically(id));
|
const hasAutomaticTakenDose = allDoseIds.some((id) => isDoseTakenAutomatically(id));
|
||||||
|
|
||||||
// Today: only collapse if manually collapsed or all taken
|
// Today: only collapse if manually collapsed or all taken
|
||||||
const isAutoCollapsed = allDayTaken && !hasAutomaticTakenDose;
|
const isAutoCollapsed = allDayTaken && !hasAutomaticTakenDose && !data.allowJournalNotes;
|
||||||
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
const isManuallyExpanded = manuallyExpandedDays.has(day.dateStr);
|
||||||
const isManuallyCollapsed = manuallyCollapsedDays.has(day.dateStr);
|
const isManuallyCollapsed = manuallyCollapsedDays.has(day.dateStr);
|
||||||
const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed;
|
const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed;
|
||||||
@@ -1582,6 +1747,19 @@ export function SharedSchedule() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<IntakeJournalModal
|
||||||
|
isOpen={sharedJournalOpen}
|
||||||
|
entry={sharedJournalEntry}
|
||||||
|
isLoading={sharedJournalLoading}
|
||||||
|
isSaving={sharedJournalSaving}
|
||||||
|
isDeleting={false}
|
||||||
|
error={sharedJournalError}
|
||||||
|
onClose={closeSharedJournalEditor}
|
||||||
|
onSave={saveSharedJournalNote}
|
||||||
|
onDelete={() => undefined}
|
||||||
|
allowDelete={false}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { formatNumber } from "../utils";
|
|||||||
import { getSystemLocale } from "../utils/formatters";
|
import { getSystemLocale } from "../utils/formatters";
|
||||||
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
|
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
|
||||||
import { getLiquidCountUnitLabel } from "../utils/intake-units";
|
import { getLiquidCountUnitLabel } from "../utils/intake-units";
|
||||||
|
import { personTagsMatch } from "../utils/person-tags";
|
||||||
import { getStockStatus } from "../utils/schedule";
|
import { getStockStatus } from "../utils/schedule";
|
||||||
|
|
||||||
export interface UserFilterModalProps {
|
export interface UserFilterModalProps {
|
||||||
@@ -72,7 +73,10 @@ export function UserFilterModal({
|
|||||||
|
|
||||||
if (!selectedUser) return null;
|
if (!selectedUser) return null;
|
||||||
|
|
||||||
const userMeds = meds.filter((m) => !m.isObsolete && (m.takenBy || []).includes(selectedUser));
|
const userMeds = meds.filter(
|
||||||
|
(medication) =>
|
||||||
|
!medication.isObsolete && (medication.takenBy || []).some((person) => personTagsMatch(person, selectedUser))
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -110,7 +114,7 @@ export function UserFilterModal({
|
|||||||
|
|
||||||
// Get intakes relevant to this person
|
// Get intakes relevant to this person
|
||||||
const personIntakes = getMedicationIntakes(med).filter(
|
const personIntakes = getMedicationIntakes(med).filter(
|
||||||
(intake) => intake.takenBy === null || intake.takenBy === selectedUser
|
(intake) => intake.takenBy === null || personTagsMatch(intake.takenBy, selectedUser)
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ export { DateInput } from "./DateInput";
|
|||||||
export { DateTimeInput } from "./DateTimeInput";
|
export { DateTimeInput } from "./DateTimeInput";
|
||||||
export { default as ExportModal } from "./ExportModal";
|
export { default as ExportModal } from "./ExportModal";
|
||||||
export { FormNumberStepper } from "./FormNumberStepper";
|
export { FormNumberStepper } from "./FormNumberStepper";
|
||||||
|
export { ImportReviewModal } from "./ImportReviewModal";
|
||||||
|
export { IntakeJournalHistoryModal } from "./intake-journal/IntakeJournalHistoryModal";
|
||||||
|
export { IntakeJournalModal } from "./intake-journal/IntakeJournalModal";
|
||||||
export type { LightboxProps } from "./Lightbox";
|
export type { LightboxProps } from "./Lightbox";
|
||||||
|
|
||||||
export { Lightbox } from "./Lightbox";
|
export { Lightbox } from "./Lightbox";
|
||||||
export type { MedDetailModalProps } from "./MedDetailModal";
|
export type { MedDetailModalProps } from "./MedDetailModal";
|
||||||
export { MedDetailModal } from "./MedDetailModal";
|
export { MedDetailModal } from "./MedDetailModal";
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useEscapeKey } from "../../hooks/useEscapeKey";
|
||||||
|
import type { IntakeJournalEntry, IntakeJournalHistoryFilters } from "../../hooks/useIntakeJournal";
|
||||||
|
import { useScrollLock } from "../../hooks/useScrollLock";
|
||||||
|
import type { Medication } from "../../types";
|
||||||
|
import { formatDateTime, getNumericLocale } from "../../utils/formatters";
|
||||||
|
import { DateTimeInput } from "../DateTimeInput";
|
||||||
|
import { MedicationAvatar } from "../MedicationAvatar";
|
||||||
|
|
||||||
|
interface IntakeJournalHistoryModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
entries: IntakeJournalEntry[];
|
||||||
|
filters: IntakeJournalHistoryFilters;
|
||||||
|
medications: Medication[];
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onFilterChange: (patch: Partial<IntakeJournalHistoryFilters>) => void;
|
||||||
|
onReload: () => Promise<void> | void;
|
||||||
|
onResetFilters: () => void;
|
||||||
|
onReopen: (doseId: string) => Promise<void> | void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDisplayDateTime(value: string | null): string | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatDateTime(value, getNumericLocale());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJournalSourceLabel(entry: IntakeJournalEntry, t: ReturnType<typeof useTranslation>["t"]): string {
|
||||||
|
if (entry.takenSource === "automatic") {
|
||||||
|
return t("journal.context.sourceAutomaticReminder");
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.markedBy ? t("journal.context.sourceSharedLink") : t("journal.context.sourceOwnerApp");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IntakeJournalHistoryModal({
|
||||||
|
isOpen,
|
||||||
|
entries,
|
||||||
|
filters,
|
||||||
|
medications,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
onClose,
|
||||||
|
onFilterChange,
|
||||||
|
onReload,
|
||||||
|
onResetFilters,
|
||||||
|
onReopen,
|
||||||
|
}: IntakeJournalHistoryModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
useScrollLock(isOpen);
|
||||||
|
useEscapeKey(isOpen, onClose);
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let listContent: React.ReactNode;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
listContent = <div className="journal-modal-state">{t("journal.history.loading")}</div>;
|
||||||
|
} else if (entries.length === 0) {
|
||||||
|
listContent = <div className="journal-modal-state">{t("journal.history.empty")}</div>;
|
||||||
|
} else {
|
||||||
|
listContent = entries.map((entry) => (
|
||||||
|
<article key={entry.doseTrackingId} className="journal-history-entry">
|
||||||
|
<div className="journal-history-entry-main">
|
||||||
|
<div className="journal-history-entry-header">
|
||||||
|
<MedicationAvatar name={entry.medicationName} size="sm" />
|
||||||
|
<div>
|
||||||
|
<strong>{entry.medicationName}</strong>
|
||||||
|
<p>{formatDisplayDateTime(entry.scheduledFor) ?? t("common.notAvailable")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="journal-history-note">{entry.note ?? t("journal.history.noNote")}</p>
|
||||||
|
<div className="journal-history-meta">
|
||||||
|
<span>{t(entry.dismissed ? "journal.context.statusSkipped" : "journal.context.statusTaken")}</span>
|
||||||
|
<span>{getJournalSourceLabel(entry, t)}</span>
|
||||||
|
{entry.updatedAt && (
|
||||||
|
<span>
|
||||||
|
{t("journal.history.updatedAt", {
|
||||||
|
date: formatDisplayDateTime(entry.updatedAt) ?? entry.updatedAt,
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="primary small" onClick={() => void onReopen(entry.doseId)}>
|
||||||
|
{t("journal.history.reopen")}
|
||||||
|
</button>
|
||||||
|
</article>
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="modal-overlay"
|
||||||
|
onClick={onClose}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Escape") {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal-content journal-history-modal"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Escape") {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button type="button" className="modal-close" onClick={onClose} aria-label={t("common.close")}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<div className="journal-modal-header">
|
||||||
|
<h2>{t("journal.history.title")}</h2>
|
||||||
|
<p>{t("journal.history.description")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="journal-history-filters">
|
||||||
|
<label className="journal-field" htmlFor="journal-history-medication">
|
||||||
|
<span>{t("journal.history.filters.medication")}</span>
|
||||||
|
<select
|
||||||
|
id="journal-history-medication"
|
||||||
|
className="select-field"
|
||||||
|
value={filters.medicationId ?? "all"}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
onFilterChange({ medicationId: value === "all" ? null : Number(value) });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="all">{t("journal.history.filters.allMedications")}</option>
|
||||||
|
{medications.map((medication) => (
|
||||||
|
<option key={medication.id} value={medication.id}>
|
||||||
|
{medication.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<div className="journal-field journal-date-filter">
|
||||||
|
<span>{t("journal.history.filters.from")}</span>
|
||||||
|
<DateTimeInput
|
||||||
|
value={filters.from}
|
||||||
|
onChange={(event) => onFilterChange({ from: event.target.value })}
|
||||||
|
step="60"
|
||||||
|
aria-label={t("journal.history.filters.from")}
|
||||||
|
placeholder={t("journal.history.filters.fromPlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="journal-field journal-date-filter">
|
||||||
|
<span>{t("journal.history.filters.to")}</span>
|
||||||
|
<DateTimeInput
|
||||||
|
value={filters.to}
|
||||||
|
onChange={(event) => onFilterChange({ to: event.target.value })}
|
||||||
|
step="60"
|
||||||
|
aria-label={t("journal.history.filters.to")}
|
||||||
|
placeholder={t("journal.history.filters.toPlaceholder")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="journal-history-toolbar">
|
||||||
|
<button type="button" className="ghost small" onClick={onResetFilters}>
|
||||||
|
{t("journal.history.resetFilters")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ghost small" onClick={() => void onReload()} disabled={isLoading}>
|
||||||
|
{t("journal.history.reload")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="journal-inline-error">{error}</div>}
|
||||||
|
|
||||||
|
<div className="journal-history-list">{listContent}</div>
|
||||||
|
|
||||||
|
<div className="modal-footer journal-modal-footer">
|
||||||
|
<div className="footer-right">
|
||||||
|
<button type="button" className="ghost" onClick={onClose}>
|
||||||
|
{t("common.close")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useEscapeKey } from "../../hooks/useEscapeKey";
|
||||||
|
import type { IntakeJournalEntry } from "../../hooks/useIntakeJournal";
|
||||||
|
import { useScrollLock } from "../../hooks/useScrollLock";
|
||||||
|
import { formatDateTime, getNumericLocale } from "../../utils/formatters";
|
||||||
|
import { MedicationAvatar } from "../MedicationAvatar";
|
||||||
|
|
||||||
|
interface IntakeJournalModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
entry: IntakeJournalEntry | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isSaving: boolean;
|
||||||
|
isDeleting: boolean;
|
||||||
|
error: string | null;
|
||||||
|
onClose: () => void;
|
||||||
|
onSave: (note: string) => Promise<boolean> | boolean;
|
||||||
|
onDelete: () => Promise<void> | void;
|
||||||
|
allowDelete?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDisplayDateTime(value: string | null): string | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatDateTime(value, getNumericLocale());
|
||||||
|
}
|
||||||
|
|
||||||
|
function getJournalSourceLabel(entry: IntakeJournalEntry, t: ReturnType<typeof useTranslation>["t"]): string {
|
||||||
|
if (entry.takenSource === "automatic") {
|
||||||
|
return t("journal.context.sourceAutomaticReminder");
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry.markedBy ? t("journal.context.sourceSharedLink") : t("journal.context.sourceOwnerApp");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IntakeJournalModal({
|
||||||
|
isOpen,
|
||||||
|
entry,
|
||||||
|
isLoading,
|
||||||
|
isSaving,
|
||||||
|
isDeleting,
|
||||||
|
error,
|
||||||
|
onClose,
|
||||||
|
onSave,
|
||||||
|
onDelete,
|
||||||
|
allowDelete = true,
|
||||||
|
}: IntakeJournalModalProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [note, setNote] = useState("");
|
||||||
|
const [showSavedState, setShowSavedState] = useState(false);
|
||||||
|
const activeDoseTrackingIdRef = useRef<number | null>(null);
|
||||||
|
const wasSavingRef = useRef(false);
|
||||||
|
|
||||||
|
useScrollLock(isOpen);
|
||||||
|
useEscapeKey(isOpen, onClose);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setNote("");
|
||||||
|
setShowSavedState(false);
|
||||||
|
activeDoseTrackingIdRef.current = null;
|
||||||
|
wasSavingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setNote(entry.note ?? "");
|
||||||
|
if (activeDoseTrackingIdRef.current !== entry.doseTrackingId) {
|
||||||
|
activeDoseTrackingIdRef.current = entry.doseTrackingId;
|
||||||
|
setShowSavedState(false);
|
||||||
|
}
|
||||||
|
}, [entry, isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
wasSavingRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSaving) {
|
||||||
|
setShowSavedState(false);
|
||||||
|
wasSavingRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (wasSavingRef.current) {
|
||||||
|
wasSavingRef.current = false;
|
||||||
|
if (entry && !error && note === (entry.note ?? "")) {
|
||||||
|
setShowSavedState(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [entry, error, isOpen, isSaving, note]);
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
const saved = await onSave(note);
|
||||||
|
if (saved) {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduledForLabel = formatDisplayDateTime(entry?.scheduledFor ?? null);
|
||||||
|
const takenAtLabel = formatDisplayDateTime(entry?.takenAt ?? null);
|
||||||
|
const title = entry?.note ? t("journal.editor.editTitle") : t("journal.editor.addTitle");
|
||||||
|
const saveLabel = showSavedState ? t("common.saved") : t("common.save");
|
||||||
|
let bodyContent: React.ReactNode;
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
bodyContent = <div className="journal-modal-state">{t("journal.editor.loading")}</div>;
|
||||||
|
} else if (entry) {
|
||||||
|
bodyContent = (
|
||||||
|
<>
|
||||||
|
<div className="journal-event-card">
|
||||||
|
<div className="journal-event-medication">
|
||||||
|
<MedicationAvatar name={entry.medicationName} size="sm" />
|
||||||
|
<div>
|
||||||
|
<strong>{entry.medicationName}</strong>
|
||||||
|
<p>{entry.dismissed ? t("journal.context.statusSkipped") : t("journal.context.statusTaken")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="journal-event-grid">
|
||||||
|
<div>
|
||||||
|
<span>{t("journal.context.scheduledFor")}</span>
|
||||||
|
<strong>{scheduledForLabel ?? t("common.notAvailable")}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>{t("journal.context.takenAt")}</span>
|
||||||
|
<strong>{takenAtLabel ?? t("journal.context.notRecorded")}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>{t("journal.context.markedBy")}</span>
|
||||||
|
<strong>{entry.markedBy ?? t("journal.context.self")}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span>{t("journal.context.source")}</span>
|
||||||
|
<strong>{getJournalSourceLabel(entry, t)}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="journal-field" htmlFor="journal-note-input">
|
||||||
|
<span>{t("journal.editor.noteLabel")}</span>
|
||||||
|
<textarea
|
||||||
|
id="journal-note-input"
|
||||||
|
className="journal-note-input"
|
||||||
|
rows={7}
|
||||||
|
value={note}
|
||||||
|
onChange={(event) => {
|
||||||
|
setNote(event.target.value);
|
||||||
|
setShowSavedState(false);
|
||||||
|
}}
|
||||||
|
placeholder={t("journal.editor.notePlaceholder")}
|
||||||
|
maxLength={4000}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{error && <div className="journal-inline-error">{error}</div>}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
bodyContent = <div className="journal-modal-state">{error ?? t("journal.errors.loadFailed")}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="modal-overlay"
|
||||||
|
onClick={onClose}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Escape") {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="modal-content journal-modal"
|
||||||
|
onClick={(event) => event.stopPropagation()}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (event.key !== "Escape") {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button type="button" className="modal-close" onClick={onClose} aria-label={t("common.close")}>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
<div className="journal-modal-header">
|
||||||
|
<h2>{title}</h2>
|
||||||
|
<p>{t("journal.editor.description")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bodyContent}
|
||||||
|
|
||||||
|
<div className="modal-footer journal-modal-footer">
|
||||||
|
<div className="footer-left">
|
||||||
|
{allowDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost"
|
||||||
|
onClick={() => void onDelete()}
|
||||||
|
disabled={isLoading || isSaving || isDeleting || !entry?.note}
|
||||||
|
>
|
||||||
|
{isDeleting ? t("journal.editor.deleting") : t("common.delete")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="footer-right">
|
||||||
|
<button type="button" className="ghost" onClick={onClose} disabled={isSaving || isDeleting}>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="primary"
|
||||||
|
onClick={() => void handleSave()}
|
||||||
|
disabled={isLoading || isSaving || isDeleting || !entry}
|
||||||
|
>
|
||||||
|
{saveLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,15 @@ import type React from "react";
|
|||||||
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useAuth } from "../components/Auth";
|
import { useAuth } from "../components/Auth";
|
||||||
import { useCollapsedDays, useDoses, useMedications, useRefill, useSettings, useShare } from "../hooks";
|
import {
|
||||||
|
useCollapsedDays,
|
||||||
|
useDoses,
|
||||||
|
useIntakeJournal,
|
||||||
|
useMedications,
|
||||||
|
useRefill,
|
||||||
|
useSettings,
|
||||||
|
useShare,
|
||||||
|
} from "../hooks";
|
||||||
import {
|
import {
|
||||||
type Coverage,
|
type Coverage,
|
||||||
type FormState,
|
type FormState,
|
||||||
@@ -13,7 +21,9 @@ import {
|
|||||||
} from "../types";
|
} from "../types";
|
||||||
import { getSystemLocale, setDefaultFormattingTimezone } from "../utils/formatters";
|
import { getSystemLocale, setDefaultFormattingTimezone } from "../utils/formatters";
|
||||||
import { log } from "../utils/logger";
|
import { log } from "../utils/logger";
|
||||||
|
import { mergePersonTags } from "../utils/person-tags";
|
||||||
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds, getStockStatus } from "../utils/schedule";
|
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds, getStockStatus } from "../utils/schedule";
|
||||||
|
import { useFeedback } from "./FeedbackContext";
|
||||||
import { ShareContextProvider } from "./ShareContext";
|
import { ShareContextProvider } from "./ShareContext";
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -44,6 +54,34 @@ export type GroupedDay = {
|
|||||||
meds: DayMedEntry[];
|
meds: DayMedEntry[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ImportPreview = {
|
||||||
|
version: string;
|
||||||
|
exportedAt: string;
|
||||||
|
includeSensitiveData: boolean;
|
||||||
|
incoming: {
|
||||||
|
medications: number;
|
||||||
|
doseHistory: number;
|
||||||
|
refillHistory: number;
|
||||||
|
shareLinks: number;
|
||||||
|
journalEntries: number;
|
||||||
|
imageCount: number;
|
||||||
|
hasSettings: boolean;
|
||||||
|
};
|
||||||
|
current: {
|
||||||
|
medications: number;
|
||||||
|
doseHistory: number;
|
||||||
|
refillHistory: number;
|
||||||
|
shareLinks: number;
|
||||||
|
hasSettings: boolean;
|
||||||
|
};
|
||||||
|
warnings: {
|
||||||
|
replacesExistingData: boolean;
|
||||||
|
regeneratesShareLinks: boolean;
|
||||||
|
containsImages: boolean;
|
||||||
|
containsSensitiveData: boolean;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export interface AppContextValue {
|
export interface AppContextValue {
|
||||||
// From useMedications
|
// From useMedications
|
||||||
meds: Medication[];
|
meds: Medication[];
|
||||||
@@ -87,6 +125,29 @@ export interface AppContextValue {
|
|||||||
undoDoseTaken: (doseId: string) => Promise<void>;
|
undoDoseTaken: (doseId: string) => Promise<void>;
|
||||||
undoDoseSkipped: (doseId: string) => Promise<void>;
|
undoDoseSkipped: (doseId: string) => Promise<void>;
|
||||||
|
|
||||||
|
// From useIntakeJournal
|
||||||
|
journalEditorOpen: boolean;
|
||||||
|
journalHistoryOpen: boolean;
|
||||||
|
journalTargetDoseId: string | null;
|
||||||
|
journalEvent: ReturnType<typeof useIntakeJournal>["journalEvent"];
|
||||||
|
journalEventLoading: boolean;
|
||||||
|
journalEventSaving: boolean;
|
||||||
|
journalEventDeleting: boolean;
|
||||||
|
journalEventError: string | null;
|
||||||
|
journalHistoryEntries: ReturnType<typeof useIntakeJournal>["journalHistoryEntries"];
|
||||||
|
journalHistoryFilters: ReturnType<typeof useIntakeJournal>["journalHistoryFilters"];
|
||||||
|
journalHistoryLoading: boolean;
|
||||||
|
journalHistoryError: string | null;
|
||||||
|
openJournalEditor: (doseId: string) => Promise<void>;
|
||||||
|
closeJournalEditor: () => void;
|
||||||
|
saveJournalNote: (note: string) => Promise<boolean>;
|
||||||
|
deleteJournalNote: () => Promise<boolean>;
|
||||||
|
openJournalHistory: () => void;
|
||||||
|
closeJournalHistory: () => void;
|
||||||
|
setJournalHistoryFilters: (patch: Partial<ReturnType<typeof useIntakeJournal>["journalHistoryFilters"]>) => void;
|
||||||
|
reloadJournalHistory: () => Promise<void>;
|
||||||
|
reopenJournalHistoryEntry: (doseId: string) => Promise<void>;
|
||||||
|
|
||||||
// From useCollapsedDays
|
// From useCollapsedDays
|
||||||
manuallyCollapsedDays: Set<string>;
|
manuallyCollapsedDays: Set<string>;
|
||||||
manuallyExpandedDays: Set<string>;
|
manuallyExpandedDays: Set<string>;
|
||||||
@@ -99,13 +160,21 @@ export interface AppContextValue {
|
|||||||
setShareSelectedPerson: React.Dispatch<React.SetStateAction<string>>;
|
setShareSelectedPerson: React.Dispatch<React.SetStateAction<string>>;
|
||||||
shareSelectedDays: number;
|
shareSelectedDays: number;
|
||||||
setShareSelectedDays: React.Dispatch<React.SetStateAction<number>>;
|
setShareSelectedDays: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
shareSelectedExpiryDays: number | null;
|
||||||
|
setShareSelectedExpiryDays: React.Dispatch<React.SetStateAction<number | null>>;
|
||||||
|
shareAllowJournalNotes: boolean;
|
||||||
|
setShareAllowJournalNotes: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
shareGenerating: boolean;
|
shareGenerating: boolean;
|
||||||
shareLink: string | null;
|
shareLink: string | null;
|
||||||
setShareLink: React.Dispatch<React.SetStateAction<string | null>>;
|
setShareLink: React.Dispatch<React.SetStateAction<string | null>>;
|
||||||
shareCopied: boolean;
|
shareCopied: boolean;
|
||||||
setShareCopied: React.Dispatch<React.SetStateAction<boolean>>;
|
setShareCopied: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
activeShareLinks: ReturnType<typeof useShare>["activeShareLinks"];
|
||||||
|
activeSharesLoading: boolean;
|
||||||
|
revokingShareToken: string | null;
|
||||||
openShareDialog: () => void;
|
openShareDialog: () => void;
|
||||||
generateShareLink: () => Promise<void>;
|
generateShareLink: () => Promise<void>;
|
||||||
|
revokeShareLink: (token: string) => Promise<boolean>;
|
||||||
copyShareLink: () => void;
|
copyShareLink: () => void;
|
||||||
closeShareDialog: () => void;
|
closeShareDialog: () => void;
|
||||||
resetShareDialogState: () => void;
|
resetShareDialogState: () => void;
|
||||||
@@ -188,6 +257,8 @@ export interface AppContextValue {
|
|||||||
setShowImportConfirm: React.Dispatch<React.SetStateAction<boolean>>;
|
setShowImportConfirm: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
pendingImportData: unknown;
|
pendingImportData: unknown;
|
||||||
setPendingImportData: React.Dispatch<React.SetStateAction<unknown>>;
|
setPendingImportData: React.Dispatch<React.SetStateAction<unknown>>;
|
||||||
|
importPreview: ImportPreview | null;
|
||||||
|
setImportPreview: React.Dispatch<React.SetStateAction<ImportPreview | null>>;
|
||||||
importResult: {
|
importResult: {
|
||||||
medications: number;
|
medications: number;
|
||||||
doses: number;
|
doses: number;
|
||||||
@@ -245,12 +316,14 @@ function userStorageKey(userId: number | undefined, key: string): string {
|
|||||||
|
|
||||||
export function AppProvider({ children }: { children: React.ReactNode }) {
|
export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
const { user } = useAuth();
|
const { user, authFetch } = useAuth();
|
||||||
|
const { showFeedback } = useFeedback();
|
||||||
|
|
||||||
// Compose hooks
|
// Compose hooks
|
||||||
const medications = useMedications();
|
const medications = useMedications();
|
||||||
const settingsHook = useSettings();
|
const settingsHook = useSettings();
|
||||||
const doses = useDoses();
|
const doses = useDoses();
|
||||||
|
const intakeJournal = useIntakeJournal();
|
||||||
const collapsed = useCollapsedDays(user?.id);
|
const collapsed = useCollapsedDays(user?.id);
|
||||||
const share = useShare();
|
const share = useShare();
|
||||||
const refill = useRefill();
|
const refill = useRefill();
|
||||||
@@ -295,6 +368,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [showExportModal, setShowExportModal] = useState(false);
|
const [showExportModal, setShowExportModal] = useState(false);
|
||||||
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
||||||
const [pendingImportData, setPendingImportData] = useState<unknown>(null);
|
const [pendingImportData, setPendingImportData] = useState<unknown>(null);
|
||||||
|
const [importPreview, setImportPreview] = useState<ImportPreview | null>(null);
|
||||||
const [importResult, setImportResult] = useState<{
|
const [importResult, setImportResult] = useState<{
|
||||||
medications: number;
|
medications: number;
|
||||||
doses: number;
|
doses: number;
|
||||||
@@ -326,6 +400,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
medications.clearMedicationsState();
|
medications.clearMedicationsState();
|
||||||
settingsHook.resetSettingsState();
|
settingsHook.resetSettingsState();
|
||||||
doses.clearDosesState();
|
doses.clearDosesState();
|
||||||
|
intakeJournal.resetJournalState();
|
||||||
refill.clearRefillState();
|
refill.clearRefillState();
|
||||||
share.resetShareDialogState();
|
share.resetShareDialogState();
|
||||||
|
|
||||||
@@ -351,6 +426,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
settingsHook.loadSettings,
|
settingsHook.loadSettings,
|
||||||
doses.clearDosesState,
|
doses.clearDosesState,
|
||||||
doses.loadTakenDoses,
|
doses.loadTakenDoses,
|
||||||
|
intakeJournal.resetJournalState,
|
||||||
refill.clearRefillState,
|
refill.clearRefillState,
|
||||||
share.resetShareDialogState,
|
share.resetShareDialogState,
|
||||||
]);
|
]);
|
||||||
@@ -442,8 +518,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const existingPeople = useMemo(() => {
|
const existingPeople = useMemo(() => {
|
||||||
const allPeople = medications.meds.flatMap((m) => m.takenBy || []);
|
return mergePersonTags(medications.meds.flatMap((medication) => medication.takenBy || []));
|
||||||
return [...new Set(allPeople)].filter(Boolean).sort();
|
|
||||||
}, [medications.meds]);
|
}, [medications.meds]);
|
||||||
|
|
||||||
// Get worst stock status for a day's medications
|
// Get worst stock status for a day's medications
|
||||||
@@ -658,9 +733,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
async (includeImages: boolean = true) => {
|
async (includeImages: boolean = true) => {
|
||||||
setExporting(true);
|
setExporting(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/export?includeSensitive=true&includeImages=${includeImages}`, {
|
const res = await authFetch(`/api/export?includeSensitive=true&includeImages=${includeImages}`);
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
if (!res.ok) throw new Error("Export failed");
|
if (!res.ok) throw new Error("Export failed");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
@@ -682,7 +755,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
}
|
}
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
},
|
},
|
||||||
[t, user?.username]
|
[authFetch, t, user?.username]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle file selection for import
|
// Handle file selection for import
|
||||||
@@ -692,24 +765,64 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
|
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (event) => {
|
reader.onload = async (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.target?.result as string);
|
const data = JSON.parse(event.target?.result as string);
|
||||||
if (!data.version || !data.exportedAt) {
|
if (!data.version || !data.exportedAt) {
|
||||||
alert(t("exportImport.invalidFile"));
|
setPendingImportData(null);
|
||||||
|
setImportPreview(null);
|
||||||
|
showFeedback({ message: t("exportImport.invalidFile"), tone: "error" });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const res = await authFetch("/api/import/preview", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
let previewResponse: { error?: string; preview?: ImportPreview } = {};
|
||||||
|
try {
|
||||||
|
previewResponse = text ? JSON.parse(text) : {};
|
||||||
|
} catch {
|
||||||
|
log.error("Import preview response parse error:", text);
|
||||||
|
showFeedback({
|
||||||
|
message: `${t("exportImport.importError")}: Server returned invalid response`,
|
||||||
|
tone: "error",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok || !previewResponse.preview) {
|
||||||
|
setPendingImportData(null);
|
||||||
|
setImportPreview(null);
|
||||||
|
if (previewResponse.error === "Invalid import data format") {
|
||||||
|
showFeedback({ message: t("exportImport.invalidFile"), tone: "error" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
showFeedback({
|
||||||
|
message: `${t("exportImport.importError")}: ${previewResponse.error || `HTTP ${res.status}`}`,
|
||||||
|
tone: "error",
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setImportResult(null);
|
||||||
setPendingImportData(data);
|
setPendingImportData(data);
|
||||||
|
setImportPreview(previewResponse.preview);
|
||||||
setShowImportConfirm(true);
|
setShowImportConfirm(true);
|
||||||
} catch {
|
} catch {
|
||||||
alert(t("exportImport.invalidFile"));
|
setPendingImportData(null);
|
||||||
|
setImportPreview(null);
|
||||||
|
showFeedback({ message: t("exportImport.invalidFile"), tone: "error" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
reader.readAsText(file);
|
reader.readAsText(file);
|
||||||
// Reset file input
|
// Reset file input
|
||||||
e.target.value = "";
|
e.target.value = "";
|
||||||
},
|
},
|
||||||
[t]
|
[authFetch, showFeedback, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Confirm and execute import
|
// Confirm and execute import
|
||||||
@@ -719,10 +832,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setShowImportConfirm(false);
|
setShowImportConfirm(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/import", {
|
const res = await authFetch("/api/import", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify(pendingImportData),
|
body: JSON.stringify(pendingImportData),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -744,12 +856,18 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
data = text ? JSON.parse(text) : {};
|
data = text ? JSON.parse(text) : {};
|
||||||
} catch {
|
} catch {
|
||||||
log.error("Import response parse error:", text);
|
log.error("Import response parse error:", text);
|
||||||
alert(`${t("exportImport.importError")}: Server returned invalid response`);
|
showFeedback({
|
||||||
|
message: `${t("exportImport.importError")}: Server returned invalid response`,
|
||||||
|
tone: "error",
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
alert(`${t("exportImport.importError")}: ${data.error || `HTTP ${res.status}`}`);
|
showFeedback({
|
||||||
|
message: `${t("exportImport.importError")}: ${data.error || `HTTP ${res.status}`}`,
|
||||||
|
tone: "error",
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -768,12 +886,13 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
doses.loadTakenDoses();
|
doses.loadTakenDoses();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error("Import error:", err);
|
log.error("Import error:", err);
|
||||||
alert(t("exportImport.importError"));
|
showFeedback({ message: t("exportImport.importError"), tone: "error" });
|
||||||
|
} finally {
|
||||||
|
setPendingImportData(null);
|
||||||
|
setImportPreview(null);
|
||||||
|
setImporting(false);
|
||||||
}
|
}
|
||||||
|
}, [authFetch, pendingImportData, t, medications, settingsHook, doses, showFeedback]);
|
||||||
setPendingImportData(null);
|
|
||||||
setImporting(false);
|
|
||||||
}, [pendingImportData, t, medications, settingsHook, doses]);
|
|
||||||
|
|
||||||
// Compute settingsChanged
|
// Compute settingsChanged
|
||||||
const settingsChanged = useMemo(() => {
|
const settingsChanged = useMemo(() => {
|
||||||
@@ -815,13 +934,21 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setShareSelectedPerson: share.setShareSelectedPerson,
|
setShareSelectedPerson: share.setShareSelectedPerson,
|
||||||
shareSelectedDays: share.shareSelectedDays,
|
shareSelectedDays: share.shareSelectedDays,
|
||||||
setShareSelectedDays: share.setShareSelectedDays,
|
setShareSelectedDays: share.setShareSelectedDays,
|
||||||
|
shareSelectedExpiryDays: share.shareSelectedExpiryDays,
|
||||||
|
setShareSelectedExpiryDays: share.setShareSelectedExpiryDays,
|
||||||
|
shareAllowJournalNotes: share.shareAllowJournalNotes,
|
||||||
|
setShareAllowJournalNotes: share.setShareAllowJournalNotes,
|
||||||
shareGenerating: share.shareGenerating,
|
shareGenerating: share.shareGenerating,
|
||||||
shareLink: share.shareLink,
|
shareLink: share.shareLink,
|
||||||
setShareLink: share.setShareLink,
|
setShareLink: share.setShareLink,
|
||||||
shareCopied: share.shareCopied,
|
shareCopied: share.shareCopied,
|
||||||
setShareCopied: share.setShareCopied,
|
setShareCopied: share.setShareCopied,
|
||||||
|
activeShareLinks: share.activeShareLinks,
|
||||||
|
activeSharesLoading: share.activeSharesLoading,
|
||||||
|
revokingShareToken: share.revokingShareToken,
|
||||||
openShareDialog,
|
openShareDialog,
|
||||||
generateShareLink: share.generateShareLink,
|
generateShareLink: share.generateShareLink,
|
||||||
|
revokeShareLink: share.revokeShareLink,
|
||||||
copyShareLink: share.copyShareLink,
|
copyShareLink: share.copyShareLink,
|
||||||
closeShareDialog: share.closeShareDialog,
|
closeShareDialog: share.closeShareDialog,
|
||||||
resetShareDialogState: share.resetShareDialogState,
|
resetShareDialogState: share.resetShareDialogState,
|
||||||
@@ -865,6 +992,29 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
undoDoseTaken: doses.undoDoseTaken,
|
undoDoseTaken: doses.undoDoseTaken,
|
||||||
undoDoseSkipped: doses.undoDoseSkipped,
|
undoDoseSkipped: doses.undoDoseSkipped,
|
||||||
|
|
||||||
|
// From useIntakeJournal
|
||||||
|
journalEditorOpen: intakeJournal.journalEditorOpen,
|
||||||
|
journalHistoryOpen: intakeJournal.journalHistoryOpen,
|
||||||
|
journalTargetDoseId: intakeJournal.journalTargetDoseId,
|
||||||
|
journalEvent: intakeJournal.journalEvent,
|
||||||
|
journalEventLoading: intakeJournal.journalEventLoading,
|
||||||
|
journalEventSaving: intakeJournal.journalEventSaving,
|
||||||
|
journalEventDeleting: intakeJournal.journalEventDeleting,
|
||||||
|
journalEventError: intakeJournal.journalEventError,
|
||||||
|
journalHistoryEntries: intakeJournal.journalHistoryEntries,
|
||||||
|
journalHistoryFilters: intakeJournal.journalHistoryFilters,
|
||||||
|
journalHistoryLoading: intakeJournal.journalHistoryLoading,
|
||||||
|
journalHistoryError: intakeJournal.journalHistoryError,
|
||||||
|
openJournalEditor: intakeJournal.openJournalEditor,
|
||||||
|
closeJournalEditor: intakeJournal.closeJournalEditor,
|
||||||
|
saveJournalNote: intakeJournal.saveJournalNote,
|
||||||
|
deleteJournalNote: intakeJournal.deleteJournalNote,
|
||||||
|
openJournalHistory: intakeJournal.openJournalHistory,
|
||||||
|
closeJournalHistory: intakeJournal.closeJournalHistory,
|
||||||
|
setJournalHistoryFilters: intakeJournal.setJournalHistoryFilters,
|
||||||
|
reloadJournalHistory: intakeJournal.reloadJournalHistory,
|
||||||
|
reopenJournalHistoryEntry: intakeJournal.reopenJournalHistoryEntry,
|
||||||
|
|
||||||
// From useCollapsedDays
|
// From useCollapsedDays
|
||||||
manuallyCollapsedDays: collapsed.manuallyCollapsedDays,
|
manuallyCollapsedDays: collapsed.manuallyCollapsedDays,
|
||||||
manuallyExpandedDays: collapsed.manuallyExpandedDays,
|
manuallyExpandedDays: collapsed.manuallyExpandedDays,
|
||||||
@@ -877,13 +1027,21 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setShareSelectedPerson: share.setShareSelectedPerson,
|
setShareSelectedPerson: share.setShareSelectedPerson,
|
||||||
shareSelectedDays: share.shareSelectedDays,
|
shareSelectedDays: share.shareSelectedDays,
|
||||||
setShareSelectedDays: share.setShareSelectedDays,
|
setShareSelectedDays: share.setShareSelectedDays,
|
||||||
|
shareSelectedExpiryDays: share.shareSelectedExpiryDays,
|
||||||
|
setShareSelectedExpiryDays: share.setShareSelectedExpiryDays,
|
||||||
|
shareAllowJournalNotes: share.shareAllowJournalNotes,
|
||||||
|
setShareAllowJournalNotes: share.setShareAllowJournalNotes,
|
||||||
shareGenerating: share.shareGenerating,
|
shareGenerating: share.shareGenerating,
|
||||||
shareLink: share.shareLink,
|
shareLink: share.shareLink,
|
||||||
setShareLink: share.setShareLink,
|
setShareLink: share.setShareLink,
|
||||||
shareCopied: share.shareCopied,
|
shareCopied: share.shareCopied,
|
||||||
setShareCopied: share.setShareCopied,
|
setShareCopied: share.setShareCopied,
|
||||||
|
activeShareLinks: share.activeShareLinks,
|
||||||
|
activeSharesLoading: share.activeSharesLoading,
|
||||||
|
revokingShareToken: share.revokingShareToken,
|
||||||
openShareDialog,
|
openShareDialog,
|
||||||
generateShareLink: share.generateShareLink,
|
generateShareLink: share.generateShareLink,
|
||||||
|
revokeShareLink: share.revokeShareLink,
|
||||||
copyShareLink: share.copyShareLink,
|
copyShareLink: share.copyShareLink,
|
||||||
closeShareDialog: share.closeShareDialog,
|
closeShareDialog: share.closeShareDialog,
|
||||||
resetShareDialogState: share.resetShareDialogState,
|
resetShareDialogState: share.resetShareDialogState,
|
||||||
@@ -970,6 +1128,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setShowImportConfirm,
|
setShowImportConfirm,
|
||||||
pendingImportData,
|
pendingImportData,
|
||||||
setPendingImportData,
|
setPendingImportData,
|
||||||
|
importPreview,
|
||||||
|
setImportPreview,
|
||||||
importResult,
|
importResult,
|
||||||
setImportResult,
|
setImportResult,
|
||||||
handleExport,
|
handleExport,
|
||||||
@@ -981,6 +1141,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
medications,
|
medications,
|
||||||
settingsHook,
|
settingsHook,
|
||||||
doses,
|
doses,
|
||||||
|
intakeJournal,
|
||||||
collapsed,
|
collapsed,
|
||||||
share,
|
share,
|
||||||
refill,
|
refill,
|
||||||
@@ -1017,6 +1178,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
|||||||
showExportModal,
|
showExportModal,
|
||||||
showImportConfirm,
|
showImportConfirm,
|
||||||
pendingImportData,
|
pendingImportData,
|
||||||
|
importPreview,
|
||||||
importResult,
|
importResult,
|
||||||
handleExport,
|
handleExport,
|
||||||
handleImportFileSelect,
|
handleImportFileSelect,
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
export type FeedbackTone = "info" | "success" | "warning" | "error";
|
||||||
|
|
||||||
|
type FeedbackNotice = {
|
||||||
|
id: number;
|
||||||
|
message: string;
|
||||||
|
tone: FeedbackTone;
|
||||||
|
durationMs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FeedbackContextValue = {
|
||||||
|
showFeedback: (options: { message: string; tone?: FeedbackTone; durationMs?: number }) => void;
|
||||||
|
dismissFeedback: (id: number) => void;
|
||||||
|
clearFeedback: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const noop = () => {};
|
||||||
|
|
||||||
|
const defaultValue: FeedbackContextValue = {
|
||||||
|
showFeedback: noop,
|
||||||
|
dismissFeedback: noop,
|
||||||
|
clearFeedback: noop,
|
||||||
|
};
|
||||||
|
|
||||||
|
const FeedbackContext = createContext<FeedbackContextValue>(defaultValue);
|
||||||
|
|
||||||
|
export function FeedbackProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [notices, setNotices] = useState<FeedbackNotice[]>([]);
|
||||||
|
const nextIdRef = useRef(1);
|
||||||
|
const timeoutMapRef = useRef<Map<number, number>>(new Map());
|
||||||
|
|
||||||
|
const dismissFeedback = useCallback((id: number) => {
|
||||||
|
const timeoutId = timeoutMapRef.current.get(id);
|
||||||
|
if (typeof timeoutId === "number") {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
timeoutMapRef.current.delete(id);
|
||||||
|
}
|
||||||
|
setNotices((current) => current.filter((notice) => notice.id !== id));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearFeedback = useCallback(() => {
|
||||||
|
for (const timeoutId of timeoutMapRef.current.values()) {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
}
|
||||||
|
timeoutMapRef.current.clear();
|
||||||
|
setNotices([]);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showFeedback = useCallback(
|
||||||
|
({ message, tone = "info", durationMs = 5000 }: { message: string; tone?: FeedbackTone; durationMs?: number }) => {
|
||||||
|
const id = nextIdRef.current++;
|
||||||
|
setNotices((current) => [...current, { id, message, tone, durationMs }].slice(-3));
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
dismissFeedback(id);
|
||||||
|
}, durationMs);
|
||||||
|
timeoutMapRef.current.set(id, timeoutId);
|
||||||
|
},
|
||||||
|
[dismissFeedback]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => () => clearFeedback(), [clearFeedback]);
|
||||||
|
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({
|
||||||
|
showFeedback,
|
||||||
|
dismissFeedback,
|
||||||
|
clearFeedback,
|
||||||
|
}),
|
||||||
|
[showFeedback, dismissFeedback, clearFeedback]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FeedbackContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
<div className="app-feedback-stack" aria-live="polite" aria-atomic="false">
|
||||||
|
{notices.map((notice) => (
|
||||||
|
<div
|
||||||
|
key={notice.id}
|
||||||
|
className={`app-feedback app-feedback-${notice.tone}`}
|
||||||
|
role={notice.tone === "error" ? "alert" : "status"}
|
||||||
|
>
|
||||||
|
<div className="app-feedback-message">{notice.message}</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="app-feedback-close"
|
||||||
|
onClick={() => dismissFeedback(notice.id)}
|
||||||
|
aria-label={t("common.close")}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FeedbackContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFeedback() {
|
||||||
|
return useContext(FeedbackContext);
|
||||||
|
}
|
||||||
@@ -7,13 +7,21 @@ type ShareContextValue = {
|
|||||||
setShareSelectedPerson: React.Dispatch<React.SetStateAction<string>>;
|
setShareSelectedPerson: React.Dispatch<React.SetStateAction<string>>;
|
||||||
shareSelectedDays: number;
|
shareSelectedDays: number;
|
||||||
setShareSelectedDays: React.Dispatch<React.SetStateAction<number>>;
|
setShareSelectedDays: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
shareSelectedExpiryDays: number | null;
|
||||||
|
setShareSelectedExpiryDays: React.Dispatch<React.SetStateAction<number | null>>;
|
||||||
|
shareAllowJournalNotes: boolean;
|
||||||
|
setShareAllowJournalNotes: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
shareGenerating: boolean;
|
shareGenerating: boolean;
|
||||||
shareLink: string | null;
|
shareLink: string | null;
|
||||||
setShareLink: React.Dispatch<React.SetStateAction<string | null>>;
|
setShareLink: React.Dispatch<React.SetStateAction<string | null>>;
|
||||||
shareCopied: boolean;
|
shareCopied: boolean;
|
||||||
setShareCopied: React.Dispatch<React.SetStateAction<boolean>>;
|
setShareCopied: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
activeShareLinks: import("../hooks/useShare").ActiveShareLink[];
|
||||||
|
activeSharesLoading: boolean;
|
||||||
|
revokingShareToken: string | null;
|
||||||
openShareDialog: () => void;
|
openShareDialog: () => void;
|
||||||
generateShareLink: () => Promise<void>;
|
generateShareLink: () => Promise<void>;
|
||||||
|
revokeShareLink: (token: string) => Promise<boolean>;
|
||||||
copyShareLink: () => void;
|
copyShareLink: () => void;
|
||||||
closeShareDialog: () => void;
|
closeShareDialog: () => void;
|
||||||
resetShareDialogState: () => void;
|
resetShareDialogState: () => void;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
export type { AppContextValue, DayMedEntry, DoseInfo, GroupedDay } from "./AppContext";
|
export type { AppContextValue, DayMedEntry, DoseInfo, GroupedDay } from "./AppContext";
|
||||||
export { AppProvider, useAppContext } from "./AppContext";
|
export { AppProvider, useAppContext } from "./AppContext";
|
||||||
|
export type { FeedbackTone } from "./FeedbackContext";
|
||||||
|
export { FeedbackProvider, useFeedback } from "./FeedbackContext";
|
||||||
export type { ShareContextValue } from "./ShareContext";
|
export type { ShareContextValue } from "./ShareContext";
|
||||||
export { ShareContextProvider, useShareContext } from "./ShareContext";
|
export { ShareContextProvider, useShareContext } from "./ShareContext";
|
||||||
export { UnsavedChangesProvider, useUnsavedChanges } from "./UnsavedChangesContext";
|
export { UnsavedChangesProvider, useUnsavedChanges } from "./UnsavedChangesContext";
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export { useCollapsedDays } from "./useCollapsedDays";
|
|||||||
export type { UseDosesReturn } from "./useDoses";
|
export type { UseDosesReturn } from "./useDoses";
|
||||||
export { useDoses } from "./useDoses";
|
export { useDoses } from "./useDoses";
|
||||||
export { useEscapeKey } from "./useEscapeKey";
|
export { useEscapeKey } from "./useEscapeKey";
|
||||||
|
export type { IntakeJournalEntry, IntakeJournalHistoryFilters, UseIntakeJournalReturn } from "./useIntakeJournal";
|
||||||
|
export { useIntakeJournal } from "./useIntakeJournal";
|
||||||
export {
|
export {
|
||||||
createMedicationEnrichmentState,
|
createMedicationEnrichmentState,
|
||||||
MEDICATION_ENRICHMENT_INITIAL_LIMIT,
|
MEDICATION_ENRICHMENT_INITIAL_LIMIT,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
|
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAuth } from "../components/Auth";
|
||||||
|
import { useFeedback } from "../context/FeedbackContext";
|
||||||
|
|
||||||
export interface UseDosesReturn {
|
export interface UseDosesReturn {
|
||||||
takenDoses: Set<string>;
|
takenDoses: Set<string>;
|
||||||
@@ -25,6 +27,8 @@ export interface UseDosesReturn {
|
|||||||
|
|
||||||
export function useDoses(): UseDosesReturn {
|
export function useDoses(): UseDosesReturn {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const { authFetch } = useAuth();
|
||||||
|
const { showFeedback } = useFeedback();
|
||||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||||||
const [takenDoseTimestamps, setTakenDoseTimestamps] = useState<Map<string, number>>(new Map());
|
const [takenDoseTimestamps, setTakenDoseTimestamps] = useState<Map<string, number>>(new Map());
|
||||||
const [takenDoseSources, setTakenDoseSources] = useState<Map<string, "manual" | "automatic">>(new Map());
|
const [takenDoseSources, setTakenDoseSources] = useState<Map<string, "manual" | "automatic">>(new Map());
|
||||||
@@ -48,7 +52,7 @@ export function useDoses(): UseDosesReturn {
|
|||||||
if (mutationInFlightRef.current > 0) return;
|
if (mutationInFlightRef.current > 0) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/doses/taken", { credentials: "include" });
|
const res = await authFetch("/api/doses/taken");
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
// Double-check no mutation started while we were fetching
|
// Double-check no mutation started while we were fetching
|
||||||
if (mutationInFlightRef.current > 0) return;
|
if (mutationInFlightRef.current > 0) return;
|
||||||
@@ -79,7 +83,7 @@ export function useDoses(): UseDosesReturn {
|
|||||||
} catch {
|
} catch {
|
||||||
// Don't reset on error - keep current state
|
// Don't reset on error - keep current state
|
||||||
}
|
}
|
||||||
}, [clearDosesState]);
|
}, [authFetch, clearDosesState]);
|
||||||
|
|
||||||
// Poll for taken doses from server (works with or without auth)
|
// Poll for taken doses from server (works with or without auth)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -164,15 +168,14 @@ export function useDoses(): UseDosesReturn {
|
|||||||
|
|
||||||
// Send to server
|
// Send to server
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/doses/taken", {
|
const response = await authFetch("/api/doses/taken", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({ doseId }),
|
body: JSON.stringify({ doseId }),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
if ((await getErrorCode(response)) === "OUT_OF_STOCK") {
|
if ((await getErrorCode(response)) === "OUT_OF_STOCK") {
|
||||||
alert(t("common.outOfStockTakeBlocked"));
|
showFeedback({ message: t("common.outOfStockTakeBlocked"), tone: "error" });
|
||||||
}
|
}
|
||||||
throw new Error("Failed to mark dose as taken");
|
throw new Error("Failed to mark dose as taken");
|
||||||
}
|
}
|
||||||
@@ -220,7 +223,17 @@ export function useDoses(): UseDosesReturn {
|
|||||||
loadTakenDoses();
|
loadTakenDoses();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dismissedDoses, getErrorCode, loadTakenDoses, t, takenDoseSources, takenDoseTimestamps, takenDoses]
|
[
|
||||||
|
authFetch,
|
||||||
|
dismissedDoses,
|
||||||
|
getErrorCode,
|
||||||
|
loadTakenDoses,
|
||||||
|
showFeedback,
|
||||||
|
t,
|
||||||
|
takenDoseSources,
|
||||||
|
takenDoseTimestamps,
|
||||||
|
takenDoses,
|
||||||
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
const markDoseSkipped = useCallback(
|
const markDoseSkipped = useCallback(
|
||||||
@@ -257,10 +270,9 @@ export function useDoses(): UseDosesReturn {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/doses/skip", {
|
const response = await authFetch("/api/doses/skip", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({ doseId }),
|
body: JSON.stringify({ doseId }),
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -302,7 +314,7 @@ export function useDoses(): UseDosesReturn {
|
|||||||
loadTakenDoses();
|
loadTakenDoses();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dismissedDoses, loadTakenDoses, takenDoseSources, takenDoseTimestamps, takenDoses]
|
[authFetch, dismissedDoses, loadTakenDoses, takenDoseSources, takenDoseTimestamps, takenDoses]
|
||||||
);
|
);
|
||||||
|
|
||||||
const undoDoseTaken = useCallback(
|
const undoDoseTaken = useCallback(
|
||||||
@@ -330,9 +342,8 @@ export function useDoses(): UseDosesReturn {
|
|||||||
|
|
||||||
// Send to server
|
// Send to server
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/doses/taken/${encodeURIComponent(doseId)}`, {
|
await authFetch(`/api/doses/taken/${encodeURIComponent(doseId)}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
credentials: "include",
|
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Revert on error
|
// Revert on error
|
||||||
@@ -361,7 +372,7 @@ export function useDoses(): UseDosesReturn {
|
|||||||
loadTakenDoses();
|
loadTakenDoses();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loadTakenDoses, takenDoseSources, takenDoseTimestamps]
|
[authFetch, loadTakenDoses, takenDoseSources, takenDoseTimestamps]
|
||||||
);
|
);
|
||||||
|
|
||||||
const undoDoseSkipped = useCallback(
|
const undoDoseSkipped = useCallback(
|
||||||
@@ -376,9 +387,8 @@ export function useDoses(): UseDosesReturn {
|
|||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/doses/skip/${encodeURIComponent(doseId)}`, {
|
await authFetch(`/api/doses/skip/${encodeURIComponent(doseId)}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
credentials: "include",
|
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
setDismissedDoses((prev) => {
|
setDismissedDoses((prev) => {
|
||||||
@@ -393,7 +403,7 @@ export function useDoses(): UseDosesReturn {
|
|||||||
loadTakenDoses();
|
loadTakenDoses();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dismissedDoses, loadTakenDoses]
|
[authFetch, dismissedDoses, loadTakenDoses]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -0,0 +1,339 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAuth } from "../components/Auth";
|
||||||
|
import { useModalHistory } from "./useModalHistory";
|
||||||
|
|
||||||
|
export type IntakeJournalEntry = {
|
||||||
|
doseTrackingId: number;
|
||||||
|
doseId: string;
|
||||||
|
medicationId: number;
|
||||||
|
medicationName: string;
|
||||||
|
scheduledFor: string;
|
||||||
|
takenAt: string | null;
|
||||||
|
dismissed: boolean;
|
||||||
|
takenSource: "manual" | "automatic";
|
||||||
|
markedBy: string | null;
|
||||||
|
note: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type IntakeJournalHistoryFilters = {
|
||||||
|
medicationId: number | null;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
limit: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface UseIntakeJournalReturn {
|
||||||
|
journalEditorOpen: boolean;
|
||||||
|
journalHistoryOpen: boolean;
|
||||||
|
journalTargetDoseId: string | null;
|
||||||
|
journalEvent: IntakeJournalEntry | null;
|
||||||
|
journalEventLoading: boolean;
|
||||||
|
journalEventSaving: boolean;
|
||||||
|
journalEventDeleting: boolean;
|
||||||
|
journalEventError: string | null;
|
||||||
|
journalHistoryEntries: IntakeJournalEntry[];
|
||||||
|
journalHistoryFilters: IntakeJournalHistoryFilters;
|
||||||
|
journalHistoryLoading: boolean;
|
||||||
|
journalHistoryError: string | null;
|
||||||
|
resetJournalState: () => void;
|
||||||
|
openJournalEditor: (doseId: string) => Promise<void>;
|
||||||
|
closeJournalEditor: () => void;
|
||||||
|
saveJournalNote: (note: string) => Promise<boolean>;
|
||||||
|
deleteJournalNote: () => Promise<boolean>;
|
||||||
|
openJournalHistory: () => void;
|
||||||
|
closeJournalHistory: () => void;
|
||||||
|
setJournalHistoryFilters: (patch: Partial<IntakeJournalHistoryFilters>) => void;
|
||||||
|
reloadJournalHistory: () => Promise<void>;
|
||||||
|
reopenJournalHistoryEntry: (doseId: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_HISTORY_FILTERS: IntakeJournalHistoryFilters = {
|
||||||
|
medicationId: null,
|
||||||
|
from: "",
|
||||||
|
to: "",
|
||||||
|
limit: 100,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function readErrorMessage(response: Response, fallbackMessage: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const data = (await response.json()) as { error?: string; code?: string };
|
||||||
|
if (typeof data.error === "string" && data.error.trim().length > 0) {
|
||||||
|
return data.error;
|
||||||
|
}
|
||||||
|
if (typeof data.code === "string" && data.code.trim().length > 0) {
|
||||||
|
return data.code;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Fall back to the supplied message when the response body is not JSON.
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallbackMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHistoryQuery(filters: IntakeJournalHistoryFilters): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (typeof filters.medicationId === "number") {
|
||||||
|
params.set("medicationId", String(filters.medicationId));
|
||||||
|
}
|
||||||
|
if (filters.from.trim().length > 0) {
|
||||||
|
params.set("from", filters.from.trim());
|
||||||
|
}
|
||||||
|
if (filters.to.trim().length > 0) {
|
||||||
|
params.set("to", filters.to.trim());
|
||||||
|
}
|
||||||
|
params.set("limit", String(filters.limit));
|
||||||
|
|
||||||
|
const query = params.toString();
|
||||||
|
return query.length > 0 ? `?${query}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useIntakeJournal(): UseIntakeJournalReturn {
|
||||||
|
const { authFetch } = useAuth();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [journalEditorOpen, setJournalEditorOpen] = useState(false);
|
||||||
|
const [journalHistoryOpen, setJournalHistoryOpen] = useState(false);
|
||||||
|
const [journalTargetDoseId, setJournalTargetDoseId] = useState<string | null>(null);
|
||||||
|
const [journalEvent, setJournalEvent] = useState<IntakeJournalEntry | null>(null);
|
||||||
|
const [journalEventLoading, setJournalEventLoading] = useState(false);
|
||||||
|
const [journalEventSaving, setJournalEventSaving] = useState(false);
|
||||||
|
const [journalEventDeleting, setJournalEventDeleting] = useState(false);
|
||||||
|
const [journalEventError, setJournalEventError] = useState<string | null>(null);
|
||||||
|
const [journalHistoryEntries, setJournalHistoryEntries] = useState<IntakeJournalEntry[]>([]);
|
||||||
|
const [journalHistoryFilters, setJournalHistoryFiltersState] =
|
||||||
|
useState<IntakeJournalHistoryFilters>(DEFAULT_HISTORY_FILTERS);
|
||||||
|
const [journalHistoryLoading, setJournalHistoryLoading] = useState(false);
|
||||||
|
const [journalHistoryError, setJournalHistoryError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const resetJournalState = useCallback(() => {
|
||||||
|
setJournalEditorOpen(false);
|
||||||
|
setJournalHistoryOpen(false);
|
||||||
|
setJournalTargetDoseId(null);
|
||||||
|
setJournalEvent(null);
|
||||||
|
setJournalEventLoading(false);
|
||||||
|
setJournalEventSaving(false);
|
||||||
|
setJournalEventDeleting(false);
|
||||||
|
setJournalEventError(null);
|
||||||
|
setJournalHistoryEntries([]);
|
||||||
|
setJournalHistoryFiltersState(DEFAULT_HISTORY_FILTERS);
|
||||||
|
setJournalHistoryLoading(false);
|
||||||
|
setJournalHistoryError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadJournalEvent = useCallback(
|
||||||
|
async (doseId: string) => {
|
||||||
|
setJournalEventLoading(true);
|
||||||
|
setJournalEventError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`/api/intake-journal/event/${encodeURIComponent(doseId)}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await readErrorMessage(response, t("journal.errors.loadFailed"));
|
||||||
|
setJournalEvent(null);
|
||||||
|
setJournalEventError(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { entry: IntakeJournalEntry };
|
||||||
|
setJournalEvent(data.entry);
|
||||||
|
} catch {
|
||||||
|
setJournalEvent(null);
|
||||||
|
setJournalEventError(t("journal.errors.loadFailed"));
|
||||||
|
} finally {
|
||||||
|
setJournalEventLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[authFetch, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const loadJournalHistory = useCallback(
|
||||||
|
async (filters: IntakeJournalHistoryFilters) => {
|
||||||
|
setJournalHistoryLoading(true);
|
||||||
|
setJournalHistoryError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`/api/intake-journal${buildHistoryQuery(filters)}`);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await readErrorMessage(response, t("journal.errors.historyFailed"));
|
||||||
|
setJournalHistoryEntries([]);
|
||||||
|
setJournalHistoryError(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { entries: IntakeJournalEntry[] };
|
||||||
|
setJournalHistoryEntries(Array.isArray(data.entries) ? data.entries : []);
|
||||||
|
} catch {
|
||||||
|
setJournalHistoryEntries([]);
|
||||||
|
setJournalHistoryError(t("journal.errors.historyFailed"));
|
||||||
|
} finally {
|
||||||
|
setJournalHistoryLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[authFetch, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!journalHistoryOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void loadJournalHistory(journalHistoryFilters);
|
||||||
|
}, [journalHistoryFilters, journalHistoryOpen, loadJournalHistory]);
|
||||||
|
|
||||||
|
const openJournalEditor = useCallback(
|
||||||
|
async (doseId: string) => {
|
||||||
|
setJournalHistoryOpen(false);
|
||||||
|
setJournalEditorOpen(true);
|
||||||
|
setJournalTargetDoseId(doseId);
|
||||||
|
setJournalEvent(null);
|
||||||
|
await loadJournalEvent(doseId);
|
||||||
|
},
|
||||||
|
[loadJournalEvent]
|
||||||
|
);
|
||||||
|
|
||||||
|
const closeJournalEditor = useCallback(() => {
|
||||||
|
setJournalEditorOpen(false);
|
||||||
|
setJournalTargetDoseId(null);
|
||||||
|
setJournalEvent(null);
|
||||||
|
setJournalEventError(null);
|
||||||
|
setJournalEventLoading(false);
|
||||||
|
setJournalEventSaving(false);
|
||||||
|
setJournalEventDeleting(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const saveJournalNote = useCallback(
|
||||||
|
async (note: string) => {
|
||||||
|
if (!journalTargetDoseId) {
|
||||||
|
setJournalEventError(t("journal.errors.noEventSelected"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setJournalEventSaving(true);
|
||||||
|
setJournalEventError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`/api/intake-journal/event/${encodeURIComponent(journalTargetDoseId)}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ note }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await readErrorMessage(response, t("journal.errors.saveFailed"));
|
||||||
|
setJournalEventError(message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { entry: IntakeJournalEntry };
|
||||||
|
setJournalEvent(data.entry);
|
||||||
|
if (journalHistoryOpen) {
|
||||||
|
void loadJournalHistory(journalHistoryFilters);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
setJournalEventError(t("journal.errors.saveFailed"));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setJournalEventSaving(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[authFetch, journalHistoryFilters, journalHistoryOpen, journalTargetDoseId, loadJournalHistory, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteJournalNote = useCallback(async () => {
|
||||||
|
if (!journalTargetDoseId) {
|
||||||
|
setJournalEventError(t("journal.errors.noEventSelected"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setJournalEventDeleting(true);
|
||||||
|
setJournalEventError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`/api/intake-journal/event/${encodeURIComponent(journalTargetDoseId)}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const message = await readErrorMessage(response, t("journal.errors.deleteFailed"));
|
||||||
|
setJournalEventError(message);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setJournalEvent((previous) =>
|
||||||
|
previous ? { ...previous, note: null, updatedAt: null, createdAt: null } : previous
|
||||||
|
);
|
||||||
|
if (journalHistoryOpen) {
|
||||||
|
void loadJournalHistory(journalHistoryFilters);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
setJournalEventError(t("journal.errors.deleteFailed"));
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setJournalEventDeleting(false);
|
||||||
|
}
|
||||||
|
}, [authFetch, journalHistoryFilters, journalHistoryOpen, journalTargetDoseId, loadJournalHistory, t]);
|
||||||
|
|
||||||
|
const openJournalHistory = useCallback(() => {
|
||||||
|
setJournalEditorOpen(false);
|
||||||
|
setJournalHistoryOpen(true);
|
||||||
|
setJournalHistoryError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeJournalHistory = useCallback(() => {
|
||||||
|
setJournalHistoryOpen(false);
|
||||||
|
setJournalHistoryError(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useModalHistory(journalEditorOpen, "intake-journal-editor", closeJournalEditor);
|
||||||
|
useModalHistory(journalHistoryOpen, "intake-journal-history", closeJournalHistory);
|
||||||
|
|
||||||
|
const updateJournalHistoryFilters = useCallback((patch: Partial<IntakeJournalHistoryFilters>) => {
|
||||||
|
setJournalHistoryFiltersState((previous) => ({
|
||||||
|
...previous,
|
||||||
|
...patch,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const reloadJournalHistory = useCallback(async () => {
|
||||||
|
await loadJournalHistory(journalHistoryFilters);
|
||||||
|
}, [journalHistoryFilters, loadJournalHistory]);
|
||||||
|
|
||||||
|
const reopenJournalHistoryEntry = useCallback(
|
||||||
|
async (doseId: string) => {
|
||||||
|
setJournalHistoryOpen(false);
|
||||||
|
await openJournalEditor(doseId);
|
||||||
|
},
|
||||||
|
[openJournalEditor]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
journalEditorOpen,
|
||||||
|
journalHistoryOpen,
|
||||||
|
journalTargetDoseId,
|
||||||
|
journalEvent,
|
||||||
|
journalEventLoading,
|
||||||
|
journalEventSaving,
|
||||||
|
journalEventDeleting,
|
||||||
|
journalEventError,
|
||||||
|
journalHistoryEntries,
|
||||||
|
journalHistoryFilters,
|
||||||
|
journalHistoryLoading,
|
||||||
|
journalHistoryError,
|
||||||
|
resetJournalState,
|
||||||
|
openJournalEditor,
|
||||||
|
closeJournalEditor,
|
||||||
|
saveJournalNote,
|
||||||
|
deleteJournalNote,
|
||||||
|
openJournalHistory,
|
||||||
|
closeJournalHistory,
|
||||||
|
setJournalHistoryFilters: updateJournalHistoryFilters,
|
||||||
|
reloadJournalHistory,
|
||||||
|
reopenJournalHistoryEntry,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
} from "../types";
|
} from "../types";
|
||||||
import { toDateValue, toTimeValue } from "../utils/formatters";
|
import { toDateValue, toTimeValue } from "../utils/formatters";
|
||||||
import { normalizeWeekdays } from "../utils/intake-schedule";
|
import { normalizeWeekdays } from "../utils/intake-schedule";
|
||||||
|
import { personTagsMatch } from "../utils/person-tags";
|
||||||
|
|
||||||
export const defaultBlister = (): FormBlister => {
|
export const defaultBlister = (): FormBlister => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -488,7 +489,8 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
|||||||
const addTakenByPerson = useCallback(
|
const addTakenByPerson = useCallback(
|
||||||
(name: string) => {
|
(name: string) => {
|
||||||
const trimmed = name.trim();
|
const trimmed = name.trim();
|
||||||
if (trimmed && trimmed.length <= FIELD_LIMITS.takenBy.max && !form.takenBy.includes(trimmed)) {
|
const alreadyExists = form.takenBy.some((person) => personTagsMatch(person, trimmed));
|
||||||
|
if (trimmed && trimmed.length <= FIELD_LIMITS.takenBy.max && !alreadyExists) {
|
||||||
setForm((prev) => ({ ...prev, takenBy: [...prev.takenBy, trimmed] }));
|
setForm((prev) => ({ ...prev, takenBy: [...prev.takenBy, trimmed] }));
|
||||||
}
|
}
|
||||||
setTakenByInput("");
|
setTakenByInput("");
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
import { useAuth } from "../components/Auth";
|
||||||
import type { Medication } from "../types";
|
import type { Medication } from "../types";
|
||||||
|
|
||||||
export interface UseMedicationsReturn {
|
export interface UseMedicationsReturn {
|
||||||
@@ -16,6 +17,7 @@ export interface UseMedicationsReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useMedications(): UseMedicationsReturn {
|
export function useMedications(): UseMedicationsReturn {
|
||||||
|
const { authFetch } = useAuth();
|
||||||
const [meds, setMeds] = useState<Medication[]>([]);
|
const [meds, setMeds] = useState<Medication[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -30,20 +32,20 @@ export function useMedications(): UseMedicationsReturn {
|
|||||||
|
|
||||||
const loadMeds = useCallback(() => {
|
const loadMeds = useCallback(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetch("/api/medications?includeObsolete=true", { credentials: "include" })
|
authFetch("/api/medications?includeObsolete=true")
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((data) => setMeds(Array.isArray(data) ? data : []))
|
.then((data) => setMeds(Array.isArray(data) ? data : []))
|
||||||
.catch(() => setMeds([]))
|
.catch(() => setMeds([]))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, [authFetch]);
|
||||||
|
|
||||||
const deleteMed = useCallback(
|
const deleteMed = useCallback(
|
||||||
async (id: number, editingId: number | null, resetForm: () => void) => {
|
async (id: number, editingId: number | null, resetForm: () => void) => {
|
||||||
await fetch(`/api/medications/${id}`, { method: "DELETE", credentials: "include" }).catch(() => null);
|
await authFetch(`/api/medications/${id}`, { method: "DELETE" }).catch(() => null);
|
||||||
if (editingId === id) resetForm();
|
if (editingId === id) resetForm();
|
||||||
loadMeds();
|
loadMeds();
|
||||||
},
|
},
|
||||||
[loadMeds]
|
[authFetch, loadMeds]
|
||||||
);
|
);
|
||||||
|
|
||||||
const uploadMedImage = useCallback(
|
const uploadMedImage = useCallback(
|
||||||
@@ -53,10 +55,9 @@ export function useMedications(): UseMedicationsReturn {
|
|||||||
formData.append("file", file);
|
formData.append("file", file);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/medications/${medId}/image`, {
|
const res = await authFetch(`/api/medications/${medId}/image`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: formData,
|
body: formData,
|
||||||
credentials: "include",
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let code = "UNKNOWN";
|
let code = "UNKNOWN";
|
||||||
@@ -86,15 +87,15 @@ export function useMedications(): UseMedicationsReturn {
|
|||||||
setUploadingImage(false);
|
setUploadingImage(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[loadMeds]
|
[authFetch, loadMeds]
|
||||||
);
|
);
|
||||||
|
|
||||||
const deleteMedImage = useCallback(
|
const deleteMedImage = useCallback(
|
||||||
async (medId: number) => {
|
async (medId: number) => {
|
||||||
await fetch(`/api/medications/${medId}/image`, { method: "DELETE", credentials: "include" }).catch(() => null);
|
await authFetch(`/api/medications/${medId}/image`, { method: "DELETE" }).catch(() => null);
|
||||||
loadMeds();
|
loadMeds();
|
||||||
},
|
},
|
||||||
[loadMeds]
|
[authFetch, loadMeds]
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -19,14 +19,15 @@ export function useModalHistory(isOpen: boolean, modalKey: string, onClose: () =
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isOpen) return;
|
if (!isOpen) return;
|
||||||
|
|
||||||
const handlePopState = () => {
|
const handlePopState = (event: PopStateEvent) => {
|
||||||
if (pushedRef.current) {
|
if (pushedRef.current) {
|
||||||
pushedRef.current = false;
|
pushedRef.current = false;
|
||||||
onClose();
|
onClose();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("popstate", handlePopState);
|
window.addEventListener("popstate", handlePopState, { capture: true });
|
||||||
return () => window.removeEventListener("popstate", handlePopState);
|
return () => window.removeEventListener("popstate", handlePopState, true);
|
||||||
}, [isOpen, onClose]);
|
}, [isOpen, onClose]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { useAuth } from "../components/Auth";
|
||||||
import type { Coverage, FormState, Medication, RefillEntry } from "../types";
|
import type { Coverage, FormState, Medication, RefillEntry } from "../types";
|
||||||
import {
|
import {
|
||||||
getMedTotal,
|
getMedTotal,
|
||||||
@@ -55,6 +56,7 @@ export interface UseRefillReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useRefill(): UseRefillReturn {
|
export function useRefill(): UseRefillReturn {
|
||||||
|
const { authFetch } = useAuth();
|
||||||
// Refill state
|
// Refill state
|
||||||
const [showRefillModal, setShowRefillModal] = useState(false);
|
const [showRefillModal, setShowRefillModal] = useState(false);
|
||||||
const [refillPacks, setRefillPacks] = useState(1);
|
const [refillPacks, setRefillPacks] = useState(1);
|
||||||
@@ -93,19 +95,22 @@ export function useRefill(): UseRefillReturn {
|
|||||||
}, [resetRefillForm]);
|
}, [resetRefillForm]);
|
||||||
|
|
||||||
// Load refill history for a medication
|
// Load refill history for a medication
|
||||||
const loadRefillHistory = useCallback(async (medId: number) => {
|
const loadRefillHistory = useCallback(
|
||||||
try {
|
async (medId: number) => {
|
||||||
const res = await fetch(`/api/medications/${medId}/refills`, { credentials: "include" });
|
try {
|
||||||
if (res.ok) {
|
const res = await authFetch(`/api/medications/${medId}/refills`);
|
||||||
const data = await res.json();
|
if (res.ok) {
|
||||||
setRefillHistory(Array.isArray(data) ? data : data.refills || []);
|
const data = await res.json();
|
||||||
} else {
|
setRefillHistory(Array.isArray(data) ? data : data.refills || []);
|
||||||
|
} else {
|
||||||
|
setRefillHistory([]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
setRefillHistory([]);
|
setRefillHistory([]);
|
||||||
}
|
}
|
||||||
} catch {
|
},
|
||||||
setRefillHistory([]);
|
[authFetch]
|
||||||
}
|
);
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Submit a refill
|
// Submit a refill
|
||||||
const submitRefill = useCallback(
|
const submitRefill = useCallback(
|
||||||
@@ -119,10 +124,9 @@ export function useRefill(): UseRefillReturn {
|
|||||||
if (refillPacks < 1 && refillLoose < 1) return;
|
if (refillPacks < 1 && refillLoose < 1) return;
|
||||||
setRefillSaving(true);
|
setRefillSaving(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/medications/${medId}/refill`, {
|
const res = await authFetch(`/api/medications/${medId}/refill`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
packsAdded: refillPacks,
|
packsAdded: refillPacks,
|
||||||
loosePillsAdded: refillLoose,
|
loosePillsAdded: refillLoose,
|
||||||
@@ -162,7 +166,7 @@ export function useRefill(): UseRefillReturn {
|
|||||||
}
|
}
|
||||||
setRefillSaving(false);
|
setRefillSaving(false);
|
||||||
},
|
},
|
||||||
[refillPacks, refillLoose, showRefillModal, loadRefillHistory]
|
[authFetch, refillPacks, refillLoose, showRefillModal, loadRefillHistory]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Submit a stock correction - user says how many pills they have RIGHT NOW
|
// Submit a stock correction - user says how many pills they have RIGHT NOW
|
||||||
@@ -282,10 +286,9 @@ export function useRefill(): UseRefillReturn {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use the PATCH endpoint - it sets stockAdjustment, looseTablets, AND lastStockCorrectionAt
|
// Use the PATCH endpoint - it sets stockAdjustment, looseTablets, AND lastStockCorrectionAt
|
||||||
const res = await fetch(`/api/medications/${medId}/stock-adjustment`, {
|
const res = await authFetch(`/api/medications/${medId}/stock-adjustment`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify(patchBody),
|
body: JSON.stringify(patchBody),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@@ -301,7 +304,7 @@ export function useRefill(): UseRefillReturn {
|
|||||||
}
|
}
|
||||||
setEditStockSaving(false);
|
setEditStockSaving(false);
|
||||||
},
|
},
|
||||||
[editStockFullBlisters, editStockPartialBlisterPills, editStockLoosePills, showEditStockModal]
|
[authFetch, editStockFullBlisters, editStockPartialBlisterPills, editStockLoosePills, showEditStockModal]
|
||||||
);
|
);
|
||||||
|
|
||||||
const openRefillModal = useCallback(() => {
|
const openRefillModal = useCallback(() => {
|
||||||
|
|||||||
@@ -28,6 +28,27 @@ export function useScheduleController() {
|
|||||||
markDoseSkipped: ctx.markDoseSkipped,
|
markDoseSkipped: ctx.markDoseSkipped,
|
||||||
undoDoseTaken: ctx.undoDoseTaken,
|
undoDoseTaken: ctx.undoDoseTaken,
|
||||||
undoDoseSkipped: ctx.undoDoseSkipped,
|
undoDoseSkipped: ctx.undoDoseSkipped,
|
||||||
|
journalEditorOpen: ctx.journalEditorOpen,
|
||||||
|
journalHistoryOpen: ctx.journalHistoryOpen,
|
||||||
|
journalTargetDoseId: ctx.journalTargetDoseId,
|
||||||
|
journalEvent: ctx.journalEvent,
|
||||||
|
journalEventLoading: ctx.journalEventLoading,
|
||||||
|
journalEventSaving: ctx.journalEventSaving,
|
||||||
|
journalEventDeleting: ctx.journalEventDeleting,
|
||||||
|
journalEventError: ctx.journalEventError,
|
||||||
|
journalHistoryEntries: ctx.journalHistoryEntries,
|
||||||
|
journalHistoryFilters: ctx.journalHistoryFilters,
|
||||||
|
journalHistoryLoading: ctx.journalHistoryLoading,
|
||||||
|
journalHistoryError: ctx.journalHistoryError,
|
||||||
|
openJournalEditor: ctx.openJournalEditor,
|
||||||
|
closeJournalEditor: ctx.closeJournalEditor,
|
||||||
|
saveJournalNote: ctx.saveJournalNote,
|
||||||
|
deleteJournalNote: ctx.deleteJournalNote,
|
||||||
|
openJournalHistory: ctx.openJournalHistory,
|
||||||
|
closeJournalHistory: ctx.closeJournalHistory,
|
||||||
|
setJournalHistoryFilters: ctx.setJournalHistoryFilters,
|
||||||
|
reloadJournalHistory: ctx.reloadJournalHistory,
|
||||||
|
reopenJournalHistoryEntry: ctx.reopenJournalHistoryEntry,
|
||||||
manuallyCollapsedDays: ctx.manuallyCollapsedDays,
|
manuallyCollapsedDays: ctx.manuallyCollapsedDays,
|
||||||
manuallyExpandedDays: ctx.manuallyExpandedDays,
|
manuallyExpandedDays: ctx.manuallyExpandedDays,
|
||||||
toggleDayCollapse: ctx.toggleDayCollapse,
|
toggleDayCollapse: ctx.toggleDayCollapse,
|
||||||
|
|||||||
+144
-29
@@ -3,12 +3,25 @@
|
|||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useAuth } from "../components/Auth";
|
||||||
|
import { useFeedback } from "../context/FeedbackContext";
|
||||||
import type { Medication } from "../types";
|
import type { Medication } from "../types";
|
||||||
import { withCorrelation } from "../utils/correlation";
|
import { withCorrelation } from "../utils/correlation";
|
||||||
import { log } from "../utils/logger";
|
import { log } from "../utils/logger";
|
||||||
|
|
||||||
const SHARE_ALL_VALUE = "all";
|
const SHARE_ALL_VALUE = "all";
|
||||||
|
|
||||||
|
export interface ActiveShareLink {
|
||||||
|
token: string;
|
||||||
|
takenBy: string;
|
||||||
|
scheduleDays: number;
|
||||||
|
createdAt: string;
|
||||||
|
expiresAt: string | null;
|
||||||
|
allowJournalNotes: boolean;
|
||||||
|
shareUrl: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UseShareReturn {
|
export interface UseShareReturn {
|
||||||
showShareDialog: boolean;
|
showShareDialog: boolean;
|
||||||
sharePeople: string[];
|
sharePeople: string[];
|
||||||
@@ -16,54 +29,96 @@ export interface UseShareReturn {
|
|||||||
setShareSelectedPerson: React.Dispatch<React.SetStateAction<string>>;
|
setShareSelectedPerson: React.Dispatch<React.SetStateAction<string>>;
|
||||||
shareSelectedDays: number;
|
shareSelectedDays: number;
|
||||||
setShareSelectedDays: React.Dispatch<React.SetStateAction<number>>;
|
setShareSelectedDays: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
shareSelectedExpiryDays: number | null;
|
||||||
|
setShareSelectedExpiryDays: React.Dispatch<React.SetStateAction<number | null>>;
|
||||||
|
shareAllowJournalNotes: boolean;
|
||||||
|
setShareAllowJournalNotes: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
shareGenerating: boolean;
|
shareGenerating: boolean;
|
||||||
shareLink: string | null;
|
shareLink: string | null;
|
||||||
setShareLink: React.Dispatch<React.SetStateAction<string | null>>;
|
setShareLink: React.Dispatch<React.SetStateAction<string | null>>;
|
||||||
shareCopied: boolean;
|
shareCopied: boolean;
|
||||||
setShareCopied: React.Dispatch<React.SetStateAction<boolean>>;
|
setShareCopied: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
activeShareLinks: ActiveShareLink[];
|
||||||
|
activeSharesLoading: boolean;
|
||||||
|
revokingShareToken: string | null;
|
||||||
openShareDialog: (meds: Medication[]) => void;
|
openShareDialog: (meds: Medication[]) => void;
|
||||||
generateShareLink: () => Promise<void>;
|
generateShareLink: () => Promise<void>;
|
||||||
|
revokeShareLink: (token: string) => Promise<boolean>;
|
||||||
copyShareLink: () => void;
|
copyShareLink: () => void;
|
||||||
closeShareDialog: () => void;
|
closeShareDialog: () => void;
|
||||||
resetShareDialogState: () => void;
|
resetShareDialogState: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useShare(): UseShareReturn {
|
export function useShare(): UseShareReturn {
|
||||||
|
const { authFetch } = useAuth();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { showFeedback } = useFeedback();
|
||||||
const [showShareDialog, setShowShareDialog] = useState(false);
|
const [showShareDialog, setShowShareDialog] = useState(false);
|
||||||
const [sharePeople, setSharePeople] = useState<string[]>([]);
|
const [sharePeople, setSharePeople] = useState<string[]>([]);
|
||||||
const [shareSelectedPerson, setShareSelectedPerson] = useState<string>("");
|
const [shareSelectedPerson, setShareSelectedPerson] = useState<string>("");
|
||||||
const [shareSelectedDays, setShareSelectedDays] = useState<number>(30);
|
const [shareSelectedDays, setShareSelectedDays] = useState<number>(30);
|
||||||
|
const [shareSelectedExpiryDays, setShareSelectedExpiryDays] = useState<number | null>(null);
|
||||||
|
const [shareAllowJournalNotes, setShareAllowJournalNotes] = useState(false);
|
||||||
const [shareGenerating, setShareGenerating] = useState(false);
|
const [shareGenerating, setShareGenerating] = useState(false);
|
||||||
const [shareLink, setShareLink] = useState<string | null>(null);
|
const [shareLink, setShareLink] = useState<string | null>(null);
|
||||||
const [shareCopied, setShareCopied] = useState(false);
|
const [shareCopied, setShareCopied] = useState(false);
|
||||||
|
const [activeShareLinks, setActiveShareLinks] = useState<ActiveShareLink[]>([]);
|
||||||
|
const [activeSharesLoading, setActiveSharesLoading] = useState(false);
|
||||||
|
const [revokingShareToken, setRevokingShareToken] = useState<string | null>(null);
|
||||||
|
|
||||||
const openShareDialog = useCallback((meds: Medication[]) => {
|
const loadActiveShareLinks = useCallback(async () => {
|
||||||
setShowShareDialog(true);
|
setActiveSharesLoading(true);
|
||||||
window.history.pushState({ modal: "share" }, "");
|
try {
|
||||||
setShareLink(null);
|
const response = await authFetch("/api/share");
|
||||||
setShareCopied(false);
|
const data = await response.json().catch(() => ({}));
|
||||||
setShareSelectedPerson("");
|
if (!response.ok || !Array.isArray(data?.shareLinks)) {
|
||||||
setShareSelectedDays(30);
|
setActiveShareLinks([]);
|
||||||
|
log.warn("[ShareDialog] Failed to load active share links", { status: response.status });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Include both per-intake assignments and legacy medication-level assignments.
|
setActiveShareLinks(data.shareLinks);
|
||||||
const uniquePeople = [
|
} catch (error) {
|
||||||
...new Set(
|
setActiveShareLinks([]);
|
||||||
meds.flatMap((medication) => [
|
log.error("[ShareDialog] Active share list request threw error", { error });
|
||||||
...(medication.intakes
|
} finally {
|
||||||
?.map((intake) => intake.takenBy)
|
setActiveSharesLoading(false);
|
||||||
.filter((person): person is string => Boolean(person)) ?? []),
|
|
||||||
...(medication.takenBy || []),
|
|
||||||
])
|
|
||||||
),
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.sort();
|
|
||||||
setSharePeople(uniquePeople.length > 0 ? [SHARE_ALL_VALUE, ...uniquePeople] : []);
|
|
||||||
log.info("[ShareDialog] Opened", { medicationCount: meds.length, personCount: uniquePeople.length });
|
|
||||||
if (uniquePeople.length > 0) {
|
|
||||||
setShareSelectedPerson(uniquePeople[0]);
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, [authFetch]);
|
||||||
|
|
||||||
|
const openShareDialog = useCallback(
|
||||||
|
(meds: Medication[]) => {
|
||||||
|
setShowShareDialog(true);
|
||||||
|
window.history.pushState({ modal: "share" }, "");
|
||||||
|
setShareLink(null);
|
||||||
|
setShareCopied(false);
|
||||||
|
setShareSelectedPerson("");
|
||||||
|
setShareSelectedDays(30);
|
||||||
|
setShareSelectedExpiryDays(null);
|
||||||
|
setShareAllowJournalNotes(false);
|
||||||
|
void loadActiveShareLinks();
|
||||||
|
|
||||||
|
// Include both per-intake assignments and legacy medication-level assignments.
|
||||||
|
const uniquePeople = [
|
||||||
|
...new Set(
|
||||||
|
meds.flatMap((medication) => [
|
||||||
|
...(medication.intakes
|
||||||
|
?.map((intake) => intake.takenBy)
|
||||||
|
.filter((person): person is string => Boolean(person)) ?? []),
|
||||||
|
...(medication.takenBy || []),
|
||||||
|
])
|
||||||
|
),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort();
|
||||||
|
setSharePeople(uniquePeople.length > 0 ? [SHARE_ALL_VALUE, ...uniquePeople] : []);
|
||||||
|
log.info("[ShareDialog] Opened", { medicationCount: meds.length, personCount: uniquePeople.length });
|
||||||
|
if (uniquePeople.length > 0) {
|
||||||
|
setShareSelectedPerson(uniquePeople[0]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[loadActiveShareLinks]
|
||||||
|
);
|
||||||
|
|
||||||
const generateShareLink = useCallback(async () => {
|
const generateShareLink = useCallback(async () => {
|
||||||
if (!shareSelectedPerson) {
|
if (!shareSelectedPerson) {
|
||||||
@@ -82,19 +137,24 @@ export function useShare(): UseShareReturn {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
takenBy: shareSelectedPerson,
|
takenBy: shareSelectedPerson,
|
||||||
scheduleDays: shareSelectedDays,
|
scheduleDays: shareSelectedDays,
|
||||||
|
expiryDays: shareSelectedExpiryDays,
|
||||||
|
allowJournalNotes: shareAllowJournalNotes,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
"fe-share"
|
"fe-share"
|
||||||
);
|
);
|
||||||
const res = await fetch("/api/share", init);
|
const res = await authFetch("/api/share", init);
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
const fullUrl = `${window.location.origin}/share/${data.token}`;
|
const fullUrl = `${window.location.origin}/share/${data.token}`;
|
||||||
setShareLink(fullUrl);
|
setShareLink(fullUrl);
|
||||||
|
void loadActiveShareLinks();
|
||||||
log.info("[ShareDialog] Share link ready", {
|
log.info("[ShareDialog] Share link ready", {
|
||||||
person: shareSelectedPerson,
|
person: shareSelectedPerson,
|
||||||
days: shareSelectedDays,
|
days: shareSelectedDays,
|
||||||
|
expiryDays: shareSelectedExpiryDays,
|
||||||
|
allowJournalNotes: shareAllowJournalNotes,
|
||||||
reused: Boolean(data.reused),
|
reused: Boolean(data.reused),
|
||||||
correlationId,
|
correlationId,
|
||||||
});
|
});
|
||||||
@@ -106,15 +166,57 @@ export function useShare(): UseShareReturn {
|
|||||||
error: err.error,
|
error: err.error,
|
||||||
correlationId,
|
correlationId,
|
||||||
});
|
});
|
||||||
alert(err.error || "Failed to generate share link");
|
showFeedback({
|
||||||
|
message: err.error || t("share.generateFailed"),
|
||||||
|
tone: "error",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error("[ShareDialog] Share link request threw error", { person: shareSelectedPerson, error });
|
log.error("[ShareDialog] Share link request threw error", { person: shareSelectedPerson, error });
|
||||||
alert("Failed to generate share link");
|
showFeedback({ message: t("share.generateFailed"), tone: "error" });
|
||||||
} finally {
|
} finally {
|
||||||
setShareGenerating(false);
|
setShareGenerating(false);
|
||||||
}
|
}
|
||||||
}, [shareSelectedPerson, shareSelectedDays]);
|
}, [
|
||||||
|
authFetch,
|
||||||
|
loadActiveShareLinks,
|
||||||
|
shareAllowJournalNotes,
|
||||||
|
shareSelectedExpiryDays,
|
||||||
|
shareSelectedPerson,
|
||||||
|
shareSelectedDays,
|
||||||
|
showFeedback,
|
||||||
|
t,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const revokeShareLink = useCallback(
|
||||||
|
async (token: string) => {
|
||||||
|
setRevokingShareToken(token);
|
||||||
|
try {
|
||||||
|
const response = await authFetch(`/api/share/${token}`, { method: "DELETE" });
|
||||||
|
if (!response.ok) {
|
||||||
|
const data = await response.json().catch(() => ({}));
|
||||||
|
showFeedback({
|
||||||
|
message: data.error || t("share.revokeFailed"),
|
||||||
|
tone: "error",
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveShareLinks((current) => current.filter((share) => share.token !== token));
|
||||||
|
if (shareLink?.endsWith(`/share/${token}`)) {
|
||||||
|
setShareLink(null);
|
||||||
|
setShareCopied(false);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
showFeedback({ message: t("share.revokeFailed"), tone: "error" });
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
setRevokingShareToken(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[authFetch, shareLink, showFeedback, t]
|
||||||
|
);
|
||||||
|
|
||||||
const copyShareLink = useCallback(() => {
|
const copyShareLink = useCallback(() => {
|
||||||
if (shareLink) {
|
if (shareLink) {
|
||||||
@@ -168,6 +270,11 @@ export function useShare(): UseShareReturn {
|
|||||||
setShowShareDialog(false);
|
setShowShareDialog(false);
|
||||||
setShareLink(null);
|
setShareLink(null);
|
||||||
setShareCopied(false);
|
setShareCopied(false);
|
||||||
|
setShareSelectedExpiryDays(null);
|
||||||
|
setShareAllowJournalNotes(false);
|
||||||
|
setActiveShareLinks([]);
|
||||||
|
setActiveSharesLoading(false);
|
||||||
|
setRevokingShareToken(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -177,13 +284,21 @@ export function useShare(): UseShareReturn {
|
|||||||
setShareSelectedPerson,
|
setShareSelectedPerson,
|
||||||
shareSelectedDays,
|
shareSelectedDays,
|
||||||
setShareSelectedDays,
|
setShareSelectedDays,
|
||||||
|
shareSelectedExpiryDays,
|
||||||
|
setShareSelectedExpiryDays,
|
||||||
|
shareAllowJournalNotes,
|
||||||
|
setShareAllowJournalNotes,
|
||||||
shareGenerating,
|
shareGenerating,
|
||||||
shareLink,
|
shareLink,
|
||||||
setShareLink,
|
setShareLink,
|
||||||
shareCopied,
|
shareCopied,
|
||||||
setShareCopied,
|
setShareCopied,
|
||||||
|
activeShareLinks,
|
||||||
|
activeSharesLoading,
|
||||||
|
revokingShareToken,
|
||||||
openShareDialog,
|
openShareDialog,
|
||||||
generateShareLink,
|
generateShareLink,
|
||||||
|
revokeShareLink,
|
||||||
copyShareLink,
|
copyShareLink,
|
||||||
closeShareDialog,
|
closeShareDialog,
|
||||||
resetShareDialogState,
|
resetShareDialogState,
|
||||||
|
|||||||
+113
-1
@@ -102,6 +102,64 @@
|
|||||||
"needsRefill": "Nachfüllen nötig"
|
"needsRefill": "Nachfüllen nötig"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"journal": {
|
||||||
|
"actions": {
|
||||||
|
"note": "Notiz",
|
||||||
|
"noteTakenOnly": "Notizen funktionieren nur für genommene oder uebersprungene Dosen.",
|
||||||
|
"history": "Journal-Verlauf",
|
||||||
|
"historyShort": "Journal"
|
||||||
|
},
|
||||||
|
"editor": {
|
||||||
|
"addTitle": "Journal-Notiz hinzufügen",
|
||||||
|
"editTitle": "Journal-Notiz bearbeiten",
|
||||||
|
"description": "Halte fest, was bei dieser Einnahme passiert ist, ohne den bestehenden Einnahme- oder Überspringen-Status zu ändern.",
|
||||||
|
"loading": "Journal-Eintrag wird geladen...",
|
||||||
|
"noteLabel": "Journal-Notiz",
|
||||||
|
"notePlaceholder": "Was möchtest du zu dieser Einnahme festhalten?",
|
||||||
|
"saving": "Speichern...",
|
||||||
|
"deleting": "Löschen..."
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"title": "Journal-Verlauf",
|
||||||
|
"description": "Durchsuche gespeicherte Einnahme-Notizen nach Medikament oder Zeitraum und öffne einen Eintrag erneut im Bearbeitungsmodus.",
|
||||||
|
"loading": "Journal-Verlauf wird geladen...",
|
||||||
|
"empty": "Keine Journal-Einträge passen zu den aktuellen Filtern.",
|
||||||
|
"noNote": "Keine Notiz gespeichert.",
|
||||||
|
"reload": "Neu laden",
|
||||||
|
"resetFilters": "Filter zurücksetzen",
|
||||||
|
"reopen": "Notiz erneut öffnen",
|
||||||
|
"updatedAt": "Aktualisiert {{date}}",
|
||||||
|
"filters": {
|
||||||
|
"medication": "Medikament",
|
||||||
|
"allMedications": "Alle Medikamente",
|
||||||
|
"from": "Von",
|
||||||
|
"to": "Bis",
|
||||||
|
"fromPlaceholder": "Startdatum",
|
||||||
|
"toPlaceholder": "Enddatum"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"scheduledFor": "Geplant für",
|
||||||
|
"takenAt": "Eingenommen um",
|
||||||
|
"markedBy": "Markiert von",
|
||||||
|
"source": "Markiert ueber",
|
||||||
|
"sourceOwnerApp": "Haupt-App",
|
||||||
|
"sourceSharedLink": "Geteilter Einnahme-Link",
|
||||||
|
"sourceAutomaticReminder": "Automatische Erinnerungslogik",
|
||||||
|
"statusTaken": "Eingenommen",
|
||||||
|
"statusSkipped": "Übersprungen",
|
||||||
|
"notRecorded": "Nicht erfasst",
|
||||||
|
"self": "Du"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"loadFailed": "Der Journal-Eintrag konnte nicht geladen werden.",
|
||||||
|
"historyFailed": "Der Journal-Verlauf konnte nicht geladen werden.",
|
||||||
|
"saveFailed": "Die Journal-Notiz konnte nicht gespeichert werden.",
|
||||||
|
"deleteFailed": "Die Journal-Notiz konnte nicht gelöscht werden.",
|
||||||
|
"emptySharedNote": "Geteilte Links koennen Journal-Notizen nicht leeren. Gib eine Notiz ein oder schliesse den Dialog.",
|
||||||
|
"noEventSelected": "Es ist kein Journal-Eintrag ausgewählt."
|
||||||
|
}
|
||||||
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"pills": "Tabletten",
|
"pills": "Tabletten",
|
||||||
@@ -604,10 +662,16 @@
|
|||||||
"deleteAccount": "Konto löschen",
|
"deleteAccount": "Konto löschen",
|
||||||
"deleteAccountConfirmTitle": "Konto löschen?",
|
"deleteAccountConfirmTitle": "Konto löschen?",
|
||||||
"deleteAccountConfirmText": "Dadurch werden dein Konto und alle deine Daten (Medikamente, Einstellungen, Verlauf) dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
|
"deleteAccountConfirmText": "Dadurch werden dein Konto und alle deine Daten (Medikamente, Einstellungen, Verlauf) dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
"deleteAccountButton": "Ja, mein Konto löschen"
|
"deleteAccountButton": "Ja, mein Konto löschen",
|
||||||
|
"connectionErrorTitle": "Verbindungsfehler",
|
||||||
|
"connectionErrorHelp": "Bitte prüfe, ob der Server läuft, und versuche es erneut.",
|
||||||
|
"sessionExpiredTitle": "Sitzung abgelaufen",
|
||||||
|
"sessionExpiredHelp": "Bitte melde dich erneut an, um mit deiner Besitzersitzung fortzufahren."
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"loading": "Wird geladen...",
|
"loading": "Wird geladen...",
|
||||||
|
"initializing": "Initialisierung...",
|
||||||
|
"retry": "Erneut versuchen",
|
||||||
"sending": "Wird gesendet...",
|
"sending": "Wird gesendet...",
|
||||||
"sent": "Gesendet!",
|
"sent": "Gesendet!",
|
||||||
"sendFailed": "Senden fehlgeschlagen",
|
"sendFailed": "Senden fehlgeschlagen",
|
||||||
@@ -632,6 +696,7 @@
|
|||||||
"back": "Zurück",
|
"back": "Zurück",
|
||||||
"cancel": "Abbrechen",
|
"cancel": "Abbrechen",
|
||||||
"close": "Schließen",
|
"close": "Schließen",
|
||||||
|
"hide": "Ausblenden",
|
||||||
"edit": "Bearbeiten",
|
"edit": "Bearbeiten",
|
||||||
"view": "Ansehen",
|
"view": "Ansehen",
|
||||||
"delete": "Löschen",
|
"delete": "Löschen",
|
||||||
@@ -676,6 +741,13 @@
|
|||||||
"allPeople": "Alle",
|
"allPeople": "Alle",
|
||||||
"selectPerson": "Person auswählen",
|
"selectPerson": "Person auswählen",
|
||||||
"selectPeriod": "Zeitraum auswählen",
|
"selectPeriod": "Zeitraum auswählen",
|
||||||
|
"selectExpiry": "Link-Ablauf",
|
||||||
|
"allowJournalNotes": "Diesem geteilten Link das Anzeigen und Bearbeiten von Journal-Notizen erlauben",
|
||||||
|
"journalNotesEnabled": "Journal anzeigen/bearbeiten erlaubt",
|
||||||
|
"expiryNever": "Laeuft nicht ab",
|
||||||
|
"expiry7Days": "Laeuft in 7 Tagen ab",
|
||||||
|
"expiry30Days": "Laeuft in 30 Tagen ab",
|
||||||
|
"expiry90Days": "Laeuft in 90 Tagen ab",
|
||||||
"generateLink": "Link generieren",
|
"generateLink": "Link generieren",
|
||||||
"generating": "Wird generiert...",
|
"generating": "Wird generiert...",
|
||||||
"generateAnother": "Weiteren Link generieren",
|
"generateAnother": "Weiteren Link generieren",
|
||||||
@@ -685,9 +757,21 @@
|
|||||||
"copyLink": "Link kopieren",
|
"copyLink": "Link kopieren",
|
||||||
"copyOverviewLink": "Übersichts-Link kopieren",
|
"copyOverviewLink": "Übersichts-Link kopieren",
|
||||||
"copied": "In Zwischenablage kopiert!",
|
"copied": "In Zwischenablage kopiert!",
|
||||||
|
"activeLinksTitle": "Aktive Teilen-Links",
|
||||||
|
"loadingActiveLinks": "Aktive Teilen-Links werden geladen...",
|
||||||
|
"noActiveLinks": "Noch keine aktiven Teilen-Links.",
|
||||||
|
"manageLinksSummary": "Aktive Teilen-Links verwalten",
|
||||||
|
"generateFailed": "Freigabelink konnte nicht erstellt werden",
|
||||||
|
"revokeFailed": "Freigabelink konnte nicht widerrufen werden",
|
||||||
|
"activeLinkMeta": "{{days}} Tage, erstellt {{createdAt}}",
|
||||||
|
"activeLinkMetaWithExpiry": "{{days}} Tage, erstellt {{createdAt}}, Ablauf {{expiresAt}}",
|
||||||
|
"revoke": "Widerrufen",
|
||||||
|
"revoking": "Wird widerrufen...",
|
||||||
|
"revokeConfirm": "Den aktiven Teilen-Link fuer {{person}} widerrufen?",
|
||||||
"noPeople": "Keine Medikamente mit 'Eingenommen von' zugewiesen. Füge zuerst eine Person zu einem Medikament hinzu.",
|
"noPeople": "Keine Medikamente mit 'Eingenommen von' zugewiesen. Füge zuerst eine Person zu einem Medikament hinzu.",
|
||||||
"scheduleFor": "Zeitplan für",
|
"scheduleFor": "Zeitplan für",
|
||||||
"period": "Zeitraum",
|
"period": "Zeitraum",
|
||||||
|
"publicAccessHelp": "Dieser Teilen-Link zeigt nur den ausgewaehlten Zeitplan und geteilte Dosisaktionen. Einstellungen und voller Kontozugriff bleiben in der Haupt-App.",
|
||||||
"noSchedule": "Keine geplanten Einnahmen gefunden.",
|
"noSchedule": "Keine geplanten Einnahmen gefunden.",
|
||||||
"generatedBy": "Erstellt von",
|
"generatedBy": "Erstellt von",
|
||||||
"notFound": "Teilen-Link nicht gefunden",
|
"notFound": "Teilen-Link nicht gefunden",
|
||||||
@@ -755,6 +839,24 @@
|
|||||||
"confirmImportEmpty": "Daten importieren?",
|
"confirmImportEmpty": "Daten importieren?",
|
||||||
"confirmImportEmptyMessage": "Alle Medikamente, Einnahmehistorie, Einstellungen und Teilen-Links aus der ausgewählten Datei werden importiert.",
|
"confirmImportEmptyMessage": "Alle Medikamente, Einnahmehistorie, Einstellungen und Teilen-Links aus der ausgewählten Datei werden importiert.",
|
||||||
"confirmButtonEmpty": "Importieren",
|
"confirmButtonEmpty": "Importieren",
|
||||||
|
"reviewDescription": "Prüfe den validierten Sicherungsinhalt, bevor deine aktuellen Installationsdaten ersetzt werden.",
|
||||||
|
"reviewDescriptionEmpty": "Prüfe den validierten Sicherungsinhalt, bevor er in diese Installation importiert wird.",
|
||||||
|
"incomingData": "Importdatei",
|
||||||
|
"currentData": "Aktuelle Daten",
|
||||||
|
"summaryCounts": "{{medications}} Medikamente, {{doses}} Dosen, {{refills}} Nachfüllungen, {{shares}} Teilen-Links",
|
||||||
|
"formatVersion": "Formatversion: {{version}}",
|
||||||
|
"exportedAt": "Exportiert am: {{date}}",
|
||||||
|
"settingsIncluded": "Einstellungen enthalten",
|
||||||
|
"settingsConfigured": "Einstellungen aktuell konfiguriert",
|
||||||
|
"journalEntries": "{{count}} Journaleinträge",
|
||||||
|
"imageCount": "{{count}} eingebettete Bilder",
|
||||||
|
"warningListTitle": "Warnungen",
|
||||||
|
"warningReplaceData": "Deine aktuellen Medikamente, die Einnahmehistorie, Einstellungen und Teilen-Links werden ersetzt.",
|
||||||
|
"warningShareLinks": "Importierte Teilen-Links erhalten beim Wiederherstellen aus Sicherheitsgründen neue Tokens.",
|
||||||
|
"warningImages": "Eingebettete Bilder vergrößern den Import und können die Wiederherstellung verlängern.",
|
||||||
|
"warningSensitive": "Diese Sicherung enthält sensible Benachrichtigungsdaten.",
|
||||||
|
"backupFirst": "Aktuelle Sicherung zuerst herunterladen",
|
||||||
|
"backupHint": "Empfohlen: exportiere zuerst deine aktuellen Daten, bevor du den Import bestätigst.",
|
||||||
"cancelButton": "Abbrechen",
|
"cancelButton": "Abbrechen",
|
||||||
"exportSuccess": "Daten erfolgreich exportiert",
|
"exportSuccess": "Daten erfolgreich exportiert",
|
||||||
"importSuccess": "Daten erfolgreich importiert",
|
"importSuccess": "Daten erfolgreich importiert",
|
||||||
@@ -836,6 +938,9 @@
|
|||||||
"button": "Bericht",
|
"button": "Bericht",
|
||||||
"title": "Medikamentenbericht",
|
"title": "Medikamentenbericht",
|
||||||
"description": "Erstelle ein Dokument mit detaillierten Medikamenteninformationen für deinen Arzt oder deine persönlichen Unterlagen.",
|
"description": "Erstelle ein Dokument mit detaillierten Medikamenteninformationen für deinen Arzt oder deine persönlichen Unterlagen.",
|
||||||
|
"dateRange": "Zeitraum",
|
||||||
|
"from": "Von",
|
||||||
|
"until": "Bis",
|
||||||
"selectAll": "Alle auswählen",
|
"selectAll": "Alle auswählen",
|
||||||
"deselectAll": "Alle abwählen",
|
"deselectAll": "Alle abwählen",
|
||||||
"activeMeds": "Aktive Medikamente",
|
"activeMeds": "Aktive Medikamente",
|
||||||
@@ -845,12 +950,19 @@
|
|||||||
"formatMd": "Markdown (.md)",
|
"formatMd": "Markdown (.md)",
|
||||||
"formatPdf": "PDF (Drucken)",
|
"formatPdf": "PDF (Drucken)",
|
||||||
"generate": "Erstellen",
|
"generate": "Erstellen",
|
||||||
|
"regenerate": "Vorschau aktualisieren",
|
||||||
"generating": "Wird erstellt...",
|
"generating": "Wird erstellt...",
|
||||||
|
"download": "Herunterladen",
|
||||||
|
"preview": "Vorschau",
|
||||||
|
"previewDescription": "Prüfe den generierten Bericht vor dem Export.",
|
||||||
|
"invalidDateRange": "Wähle einen gültigen Zeitraum.",
|
||||||
|
"error": "Der Bericht konnte nicht erstellt werden. Bitte versuche es erneut.",
|
||||||
"noSelection": "Wähle mindestens ein Medikament aus",
|
"noSelection": "Wähle mindestens ein Medikament aus",
|
||||||
"filterByPerson": "Bericht für",
|
"filterByPerson": "Bericht für",
|
||||||
"allPeople": "Alle Personen",
|
"allPeople": "Alle Personen",
|
||||||
"docTitle": "Medikamentenbericht",
|
"docTitle": "Medikamentenbericht",
|
||||||
"docGenerated": "Erstellt am",
|
"docGenerated": "Erstellt am",
|
||||||
|
"docRange": "Berichtszeitraum",
|
||||||
"docGeneral": "Allgemein",
|
"docGeneral": "Allgemein",
|
||||||
"docCommercialName": "Handelsname",
|
"docCommercialName": "Handelsname",
|
||||||
"docGenericName": "Wirkstoff",
|
"docGenericName": "Wirkstoff",
|
||||||
|
|||||||
+113
-1
@@ -102,6 +102,64 @@
|
|||||||
"needsRefill": "Needs refill"
|
"needsRefill": "Needs refill"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"journal": {
|
||||||
|
"actions": {
|
||||||
|
"note": "Note",
|
||||||
|
"noteTakenOnly": "Notes are only available for taken or skipped doses.",
|
||||||
|
"history": "Journal history",
|
||||||
|
"historyShort": "Journal"
|
||||||
|
},
|
||||||
|
"editor": {
|
||||||
|
"addTitle": "Add journal note",
|
||||||
|
"editTitle": "Edit journal note",
|
||||||
|
"description": "Capture what happened for this intake without changing the existing take or skip status.",
|
||||||
|
"loading": "Loading journal entry...",
|
||||||
|
"noteLabel": "Journal note",
|
||||||
|
"notePlaceholder": "What should you remember about this intake?",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"deleting": "Deleting..."
|
||||||
|
},
|
||||||
|
"history": {
|
||||||
|
"title": "Journal history",
|
||||||
|
"description": "Browse saved intake notes by medication or date, then reopen an entry in edit mode.",
|
||||||
|
"loading": "Loading journal history...",
|
||||||
|
"empty": "No journal entries match the current filters.",
|
||||||
|
"noNote": "No note saved.",
|
||||||
|
"reload": "Reload",
|
||||||
|
"resetFilters": "Reset filters",
|
||||||
|
"reopen": "Reopen note",
|
||||||
|
"updatedAt": "Updated {{date}}",
|
||||||
|
"filters": {
|
||||||
|
"medication": "Medication",
|
||||||
|
"allMedications": "All medications",
|
||||||
|
"from": "From",
|
||||||
|
"to": "To",
|
||||||
|
"fromPlaceholder": "Start date",
|
||||||
|
"toPlaceholder": "End date"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"context": {
|
||||||
|
"scheduledFor": "Scheduled for",
|
||||||
|
"takenAt": "Taken at",
|
||||||
|
"markedBy": "Marked by",
|
||||||
|
"source": "Marked via",
|
||||||
|
"sourceOwnerApp": "Main app",
|
||||||
|
"sourceSharedLink": "Shared intake link",
|
||||||
|
"sourceAutomaticReminder": "Automatic reminder logic",
|
||||||
|
"statusTaken": "Taken",
|
||||||
|
"statusSkipped": "Skipped",
|
||||||
|
"notRecorded": "Not recorded",
|
||||||
|
"self": "You"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"loadFailed": "Journal entry could not be loaded.",
|
||||||
|
"historyFailed": "Journal history could not be loaded.",
|
||||||
|
"saveFailed": "Journal note could not be saved.",
|
||||||
|
"deleteFailed": "Journal note could not be deleted.",
|
||||||
|
"emptySharedNote": "Shared links cannot clear journal notes. Enter a note or close the dialog.",
|
||||||
|
"noEventSelected": "No journal entry is selected."
|
||||||
|
}
|
||||||
|
},
|
||||||
"table": {
|
"table": {
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
"pills": "Pills",
|
"pills": "Pills",
|
||||||
@@ -604,10 +662,16 @@
|
|||||||
"deleteAccount": "Delete Account",
|
"deleteAccount": "Delete Account",
|
||||||
"deleteAccountConfirmTitle": "Delete Account?",
|
"deleteAccountConfirmTitle": "Delete Account?",
|
||||||
"deleteAccountConfirmText": "This will permanently delete your account and all your data (medications, settings, history). This action cannot be undone.",
|
"deleteAccountConfirmText": "This will permanently delete your account and all your data (medications, settings, history). This action cannot be undone.",
|
||||||
"deleteAccountButton": "Yes, delete my account"
|
"deleteAccountButton": "Yes, delete my account",
|
||||||
|
"connectionErrorTitle": "Connection Error",
|
||||||
|
"connectionErrorHelp": "Please check if the server is running and try again.",
|
||||||
|
"sessionExpiredTitle": "Session expired",
|
||||||
|
"sessionExpiredHelp": "Please sign in again to continue your owner session."
|
||||||
},
|
},
|
||||||
"common": {
|
"common": {
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
|
"initializing": "Initializing...",
|
||||||
|
"retry": "Retry",
|
||||||
"sending": "Sending...",
|
"sending": "Sending...",
|
||||||
"sent": "Sent!",
|
"sent": "Sent!",
|
||||||
"sendFailed": "Failed to send",
|
"sendFailed": "Failed to send",
|
||||||
@@ -632,6 +696,7 @@
|
|||||||
"back": "Back",
|
"back": "Back",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
|
"hide": "Hide",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"view": "View",
|
"view": "View",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
@@ -676,6 +741,13 @@
|
|||||||
"allPeople": "Everyone",
|
"allPeople": "Everyone",
|
||||||
"selectPerson": "Select person",
|
"selectPerson": "Select person",
|
||||||
"selectPeriod": "Select time period",
|
"selectPeriod": "Select time period",
|
||||||
|
"selectExpiry": "Link expiry",
|
||||||
|
"allowJournalNotes": "Allow this shared link to view and edit journal notes",
|
||||||
|
"journalNotesEnabled": "Journal view/edit enabled",
|
||||||
|
"expiryNever": "Never expires",
|
||||||
|
"expiry7Days": "Expires in 7 days",
|
||||||
|
"expiry30Days": "Expires in 30 days",
|
||||||
|
"expiry90Days": "Expires in 90 days",
|
||||||
"generateLink": "Generate Link",
|
"generateLink": "Generate Link",
|
||||||
"generating": "Generating...",
|
"generating": "Generating...",
|
||||||
"generateAnother": "Generate another link",
|
"generateAnother": "Generate another link",
|
||||||
@@ -685,9 +757,21 @@
|
|||||||
"copyLink": "Copy Link",
|
"copyLink": "Copy Link",
|
||||||
"copyOverviewLink": "Copy Overview Link",
|
"copyOverviewLink": "Copy Overview Link",
|
||||||
"copied": "Copied to clipboard!",
|
"copied": "Copied to clipboard!",
|
||||||
|
"activeLinksTitle": "Active share links",
|
||||||
|
"loadingActiveLinks": "Loading active share links...",
|
||||||
|
"noActiveLinks": "No active share links yet.",
|
||||||
|
"manageLinksSummary": "Manage active share links",
|
||||||
|
"generateFailed": "Failed to generate share link",
|
||||||
|
"revokeFailed": "Failed to revoke share link",
|
||||||
|
"activeLinkMeta": "{{days}} days, created {{createdAt}}",
|
||||||
|
"activeLinkMetaWithExpiry": "{{days}} days, created {{createdAt}}, expires {{expiresAt}}",
|
||||||
|
"revoke": "Revoke",
|
||||||
|
"revoking": "Revoking...",
|
||||||
|
"revokeConfirm": "Revoke the active share link for {{person}}?",
|
||||||
"noPeople": "No medications with 'Taken by' assigned. Add a person to a medication first.",
|
"noPeople": "No medications with 'Taken by' assigned. Add a person to a medication first.",
|
||||||
"scheduleFor": "Schedule for",
|
"scheduleFor": "Schedule for",
|
||||||
"period": "Period",
|
"period": "Period",
|
||||||
|
"publicAccessHelp": "This shared link only exposes the selected schedule and shared dose actions. Owner settings and full account access stay in the main app.",
|
||||||
"noSchedule": "No scheduled doses found.",
|
"noSchedule": "No scheduled doses found.",
|
||||||
"generatedBy": "Generated by",
|
"generatedBy": "Generated by",
|
||||||
"notFound": "Share link not found",
|
"notFound": "Share link not found",
|
||||||
@@ -755,6 +839,24 @@
|
|||||||
"confirmImportEmpty": "Import Data?",
|
"confirmImportEmpty": "Import Data?",
|
||||||
"confirmImportEmptyMessage": "This will import all medications, dose history, settings, and share links from the selected file.",
|
"confirmImportEmptyMessage": "This will import all medications, dose history, settings, and share links from the selected file.",
|
||||||
"confirmButtonEmpty": "Import",
|
"confirmButtonEmpty": "Import",
|
||||||
|
"reviewDescription": "Review the validated backup contents before replacing your current installation data.",
|
||||||
|
"reviewDescriptionEmpty": "Review the validated backup contents before importing them into this installation.",
|
||||||
|
"incomingData": "Import file",
|
||||||
|
"currentData": "Current data",
|
||||||
|
"summaryCounts": "{{medications}} medications, {{doses}} doses, {{refills}} refills, {{shares}} share links",
|
||||||
|
"formatVersion": "Format version: {{version}}",
|
||||||
|
"exportedAt": "Exported at: {{date}}",
|
||||||
|
"settingsIncluded": "Settings included",
|
||||||
|
"settingsConfigured": "Settings currently configured",
|
||||||
|
"journalEntries": "{{count}} journal entries",
|
||||||
|
"imageCount": "{{count}} embedded images",
|
||||||
|
"warningListTitle": "Warnings",
|
||||||
|
"warningReplaceData": "Your current medications, dose history, settings, and share links will be replaced.",
|
||||||
|
"warningShareLinks": "Imported share links will get new tokens during restore for security.",
|
||||||
|
"warningImages": "Embedded images increase import size and may take longer to restore.",
|
||||||
|
"warningSensitive": "This backup includes sensitive notification data.",
|
||||||
|
"backupFirst": "Download current backup first",
|
||||||
|
"backupHint": "Recommended: export your current data before confirming the import.",
|
||||||
"cancelButton": "Cancel",
|
"cancelButton": "Cancel",
|
||||||
"exportSuccess": "Data exported successfully",
|
"exportSuccess": "Data exported successfully",
|
||||||
"importSuccess": "Data imported successfully",
|
"importSuccess": "Data imported successfully",
|
||||||
@@ -836,6 +938,9 @@
|
|||||||
"button": "Report",
|
"button": "Report",
|
||||||
"title": "Medication Report",
|
"title": "Medication Report",
|
||||||
"description": "Generate a document with detailed medication information for your doctor or personal records.",
|
"description": "Generate a document with detailed medication information for your doctor or personal records.",
|
||||||
|
"dateRange": "Date range",
|
||||||
|
"from": "From",
|
||||||
|
"until": "Until",
|
||||||
"selectAll": "Select all",
|
"selectAll": "Select all",
|
||||||
"deselectAll": "Deselect all",
|
"deselectAll": "Deselect all",
|
||||||
"activeMeds": "Active Medications",
|
"activeMeds": "Active Medications",
|
||||||
@@ -845,12 +950,19 @@
|
|||||||
"formatMd": "Markdown (.md)",
|
"formatMd": "Markdown (.md)",
|
||||||
"formatPdf": "PDF (Print)",
|
"formatPdf": "PDF (Print)",
|
||||||
"generate": "Generate",
|
"generate": "Generate",
|
||||||
|
"regenerate": "Refresh preview",
|
||||||
"generating": "Generating...",
|
"generating": "Generating...",
|
||||||
|
"download": "Download",
|
||||||
|
"preview": "Preview",
|
||||||
|
"previewDescription": "Review the generated report before exporting it.",
|
||||||
|
"invalidDateRange": "Choose a valid date range.",
|
||||||
|
"error": "Could not generate the report. Please try again.",
|
||||||
"noSelection": "Select at least one medication",
|
"noSelection": "Select at least one medication",
|
||||||
"filterByPerson": "Report for",
|
"filterByPerson": "Report for",
|
||||||
"allPeople": "Everyone",
|
"allPeople": "Everyone",
|
||||||
"docTitle": "Medication Report",
|
"docTitle": "Medication Report",
|
||||||
"docGenerated": "Generated on",
|
"docGenerated": "Generated on",
|
||||||
|
"docRange": "Report range",
|
||||||
"docGeneral": "General",
|
"docGeneral": "General",
|
||||||
"docCommercialName": "Commercial Name",
|
"docCommercialName": "Commercial Name",
|
||||||
"docGenericName": "Generic Name",
|
"docGenericName": "Generic Name",
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
|
|||||||
import { BrowserRouter } from "react-router-dom";
|
import { BrowserRouter } from "react-router-dom";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import "./styles.css";
|
import "./styles.css";
|
||||||
|
import "./styles/intake-journal.css";
|
||||||
import "./styles/modals-base.css";
|
import "./styles/modals-base.css";
|
||||||
import "./styles/share-dialog.css";
|
import "./styles/share-dialog.css";
|
||||||
import "./styles/medication-workflows.css";
|
import "./styles/medication-workflows.css";
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { Archive, Bell, ClipboardList, NotebookPen, Share2 } from "lucide-react"
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import { ConfirmModal, MedicationAvatar } from "../components";
|
import { ConfirmModal, IntakeJournalHistoryModal, IntakeJournalModal, MedicationAvatar } from "../components";
|
||||||
import { useAuth } from "../components/Auth";
|
import { useAuth } from "../components/Auth";
|
||||||
import { DashboardReminderSection } from "../components/dashboard/DashboardReminderSection";
|
import { DashboardReminderSection } from "../components/dashboard/DashboardReminderSection";
|
||||||
import { DashboardStatusSection } from "../components/dashboard/DashboardStatusSection";
|
import { DashboardStatusSection } from "../components/dashboard/DashboardStatusSection";
|
||||||
import { useAppContext } from "../context";
|
import { useAppContext } from "../context";
|
||||||
|
import { useFeedback } from "../context/FeedbackContext";
|
||||||
|
import { useModalHistory } from "../hooks";
|
||||||
import {
|
import {
|
||||||
allowsPillFormSelection,
|
allowsPillFormSelection,
|
||||||
getMedDisplayName,
|
getMedDisplayName,
|
||||||
@@ -75,7 +77,8 @@ const EMPTY_DOSE_SET = new Set<string>();
|
|||||||
|
|
||||||
export function DashboardPage() {
|
export function DashboardPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { user } = useAuth();
|
const { user, authFetch } = useAuth();
|
||||||
|
const { showFeedback } = useFeedback();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const {
|
const {
|
||||||
meds,
|
meds,
|
||||||
@@ -112,6 +115,26 @@ export function DashboardPage() {
|
|||||||
openUserFilter,
|
openUserFilter,
|
||||||
openShareDialog,
|
openShareDialog,
|
||||||
openScheduleLightbox,
|
openScheduleLightbox,
|
||||||
|
journalEditorOpen,
|
||||||
|
journalHistoryOpen,
|
||||||
|
journalEvent,
|
||||||
|
journalEventLoading,
|
||||||
|
journalEventSaving,
|
||||||
|
journalEventDeleting,
|
||||||
|
journalEventError,
|
||||||
|
journalHistoryEntries,
|
||||||
|
journalHistoryFilters,
|
||||||
|
journalHistoryLoading,
|
||||||
|
journalHistoryError,
|
||||||
|
openJournalEditor,
|
||||||
|
closeJournalEditor,
|
||||||
|
saveJournalNote,
|
||||||
|
deleteJournalNote,
|
||||||
|
openJournalHistory,
|
||||||
|
closeJournalHistory,
|
||||||
|
setJournalHistoryFilters,
|
||||||
|
reloadJournalHistory,
|
||||||
|
reopenJournalHistoryEntry,
|
||||||
stockThresholds,
|
stockThresholds,
|
||||||
loadMeds,
|
loadMeds,
|
||||||
loadSettings,
|
loadSettings,
|
||||||
@@ -121,6 +144,21 @@ export function DashboardPage() {
|
|||||||
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
|
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
|
||||||
const [obsoleteCandidate, setObsoleteCandidate] = useState<{ id: number; name: string } | null>(null);
|
const [obsoleteCandidate, setObsoleteCandidate] = useState<{ id: number; name: string } | null>(null);
|
||||||
const notificationFocusAppliedRef = useRef<string | null>(null);
|
const notificationFocusAppliedRef = useRef<string | null>(null);
|
||||||
|
|
||||||
|
const closeClearMissedConfirm = useCallback(() => {
|
||||||
|
if (!clearingMissed) {
|
||||||
|
setShowClearMissedConfirm(false);
|
||||||
|
}
|
||||||
|
}, [clearingMissed]);
|
||||||
|
|
||||||
|
const closeObsoleteConfirm = useCallback(() => {
|
||||||
|
setShowObsoleteConfirm(false);
|
||||||
|
setObsoleteCandidate(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useModalHistory(showClearMissedConfirm, "dashboard-clear-missed", closeClearMissedConfirm);
|
||||||
|
useModalHistory(showObsoleteConfirm, "dashboard-obsolete", closeObsoleteConfirm);
|
||||||
|
|
||||||
const effectiveSkippedDoses =
|
const effectiveSkippedDoses =
|
||||||
skippedDoses instanceof Set ? skippedDoses : dismissedDoses instanceof Set ? dismissedDoses : EMPTY_DOSE_SET;
|
skippedDoses instanceof Set ? skippedDoses : dismissedDoses instanceof Set ? dismissedDoses : EMPTY_DOSE_SET;
|
||||||
const canManageSkippedDoses = typeof markDoseSkipped === "function" && typeof undoDoseSkipped === "function";
|
const canManageSkippedDoses = typeof markDoseSkipped === "function" && typeof undoDoseSkipped === "function";
|
||||||
@@ -333,9 +371,8 @@ export function DashboardPage() {
|
|||||||
|
|
||||||
setClearingMissed(true);
|
setClearingMissed(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/medications/dismiss-until", {
|
const res = await authFetch("/api/medications/dismiss-until", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
@@ -344,14 +381,37 @@ export function DashboardPage() {
|
|||||||
}
|
}
|
||||||
await loadMeds();
|
await loadMeds();
|
||||||
setShowClearMissedConfirm(false);
|
setShowClearMissedConfirm(false);
|
||||||
alert(t("dashboard.schedules.clearMissedSuccess", { count: missedCount }));
|
showFeedback({
|
||||||
|
message: t("dashboard.schedules.clearMissedSuccess", { count: missedCount }),
|
||||||
|
tone: "success",
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
alert(t("common.saveFailed"));
|
showFeedback({ message: t("common.saveFailed"), tone: "error" });
|
||||||
} finally {
|
} finally {
|
||||||
setClearingMissed(false);
|
setClearingMissed(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveJournalNote = async (note: string) => {
|
||||||
|
return saveJournalNote(note);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteJournalNote = async () => {
|
||||||
|
const deleted = await deleteJournalNote();
|
||||||
|
if (deleted) {
|
||||||
|
closeJournalEditor();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetJournalFilters = () => {
|
||||||
|
setJournalHistoryFilters({
|
||||||
|
medicationId: null,
|
||||||
|
from: "",
|
||||||
|
to: "",
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const renderDoseActionButtons = (options: {
|
const renderDoseActionButtons = (options: {
|
||||||
doseId: string;
|
doseId: string;
|
||||||
isTaken: boolean;
|
isTaken: boolean;
|
||||||
@@ -359,6 +419,7 @@ export function DashboardPage() {
|
|||||||
isAutomaticallyTaken: boolean;
|
isAutomaticallyTaken: boolean;
|
||||||
isEmpty: boolean;
|
isEmpty: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
|
const journalUnavailable = !(options.isTaken || options.isSkipped);
|
||||||
const takeButton = options.isTaken ? (
|
const takeButton = options.isTaken ? (
|
||||||
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
|
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
|
||||||
{options.isAutomaticallyTaken && (
|
{options.isAutomaticallyTaken && (
|
||||||
@@ -381,8 +442,35 @@ export function DashboardPage() {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const journalButton = (
|
||||||
|
<span
|
||||||
|
className={journalUnavailable ? "tooltip-trigger" : undefined}
|
||||||
|
data-tooltip={journalUnavailable ? t("journal.actions.noteTakenOnly") : undefined}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dose-btn journal"
|
||||||
|
onClick={() => {
|
||||||
|
if (!journalUnavailable) {
|
||||||
|
void openJournalEditor(options.doseId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={!journalUnavailable ? t("journal.actions.note") : undefined}
|
||||||
|
disabled={journalUnavailable}
|
||||||
|
>
|
||||||
|
<NotebookPen size={14} aria-hidden="true" />
|
||||||
|
<span className="dose-btn-label">{t("journal.actions.note")}</span>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
if (!canManageSkippedDoses) {
|
if (!canManageSkippedDoses) {
|
||||||
return takeButton;
|
return (
|
||||||
|
<>
|
||||||
|
{takeButton}
|
||||||
|
{journalButton}
|
||||||
|
</>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const skipButton = options.isSkipped ? (
|
const skipButton = options.isSkipped ? (
|
||||||
@@ -405,6 +493,7 @@ export function DashboardPage() {
|
|||||||
<>
|
<>
|
||||||
{takeButton}
|
{takeButton}
|
||||||
{skipButton}
|
{skipButton}
|
||||||
|
{journalButton}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -417,22 +506,20 @@ export function DashboardPage() {
|
|||||||
const handleConfirmMarkObsolete = async () => {
|
const handleConfirmMarkObsolete = async () => {
|
||||||
if (!obsoleteCandidate) return;
|
if (!obsoleteCandidate) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/medications/${obsoleteCandidate.id}/obsolete`, {
|
const res = await authFetch(`/api/medications/${obsoleteCandidate.id}/obsolete`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
await loadMeds();
|
await loadMeds();
|
||||||
setShowObsoleteConfirm(false);
|
setShowObsoleteConfirm(false);
|
||||||
setObsoleteCandidate(null);
|
setObsoleteCandidate(null);
|
||||||
} catch {
|
} catch {
|
||||||
alert(t("common.saveFailed"));
|
showFeedback({ message: t("common.saveFailed"), tone: "error" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelMarkObsolete = () => {
|
const handleCancelMarkObsolete = () => {
|
||||||
setShowObsoleteConfirm(false);
|
closeObsoleteConfirm();
|
||||||
setObsoleteCandidate(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const getDiscreteUnitLabel = (packageType: string | undefined, count: number) => {
|
const getDiscreteUnitLabel = (packageType: string | undefined, count: number) => {
|
||||||
@@ -619,10 +706,9 @@ export function DashboardPage() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const stockRes = await fetch("/api/reminder/send-email", {
|
const stockRes = await authFetch("/api/reminder/send-email", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email: settings.notificationEmail,
|
email: settings.notificationEmail,
|
||||||
lowStock,
|
lowStock,
|
||||||
@@ -647,10 +733,9 @@ export function DashboardPage() {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const prescriptionRes = await fetch("/api/reminder/send-prescription", {
|
const prescriptionRes = await authFetch("/api/reminder/send-prescription", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email: settings.notificationEmail,
|
email: settings.notificationEmail,
|
||||||
prescriptionLow,
|
prescriptionLow,
|
||||||
@@ -913,6 +998,17 @@ export function DashboardPage() {
|
|||||||
<option value={90}>{t("dashboard.schedules.3months")}</option>
|
<option value={90}>{t("dashboard.schedules.3months")}</option>
|
||||||
<option value={180}>{t("dashboard.schedules.6months")}</option>
|
<option value={180}>{t("dashboard.schedules.6months")}</option>
|
||||||
</select>
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost journal-history-button"
|
||||||
|
onClick={openJournalHistory}
|
||||||
|
aria-label={t("journal.actions.history")}
|
||||||
|
title={t("journal.actions.history")}
|
||||||
|
>
|
||||||
|
<ClipboardList size={16} aria-hidden="true" />
|
||||||
|
<span className="journal-history-label-full">{t("journal.actions.history")}</span>
|
||||||
|
<span className="journal-history-label-short">{t("journal.actions.historyShort")}</span>
|
||||||
|
</button>
|
||||||
{meds.some((m) => m.takenBy && m.takenBy.length > 0) && (
|
{meds.some((m) => m.takenBy && m.takenBy.length > 0) && (
|
||||||
<button
|
<button
|
||||||
className="ghost share-btn icon-only tooltip-trigger"
|
className="ghost share-btn icon-only tooltip-trigger"
|
||||||
@@ -1229,9 +1325,7 @@ export function DashboardPage() {
|
|||||||
confirmLabel={t("dashboard.schedules.clearMissedConfirm")}
|
confirmLabel={t("dashboard.schedules.clearMissedConfirm")}
|
||||||
cancelLabel={t("dashboard.schedules.clearMissedCancel")}
|
cancelLabel={t("dashboard.schedules.clearMissedCancel")}
|
||||||
onConfirm={() => void clearMissedDoses(missedPastDoseIds.length)}
|
onConfirm={() => void clearMissedDoses(missedPastDoseIds.length)}
|
||||||
onCancel={() => {
|
onCancel={closeClearMissedConfirm}
|
||||||
if (!clearingMissed) setShowClearMissedConfirm(false);
|
|
||||||
}}
|
|
||||||
isLoading={clearingMissed}
|
isLoading={clearingMissed}
|
||||||
confirmVariant="warning"
|
confirmVariant="warning"
|
||||||
/>
|
/>
|
||||||
@@ -1741,6 +1835,30 @@ export function DashboardPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<IntakeJournalModal
|
||||||
|
isOpen={journalEditorOpen}
|
||||||
|
entry={journalEvent}
|
||||||
|
isLoading={journalEventLoading}
|
||||||
|
isSaving={journalEventSaving}
|
||||||
|
isDeleting={journalEventDeleting}
|
||||||
|
error={journalEventError}
|
||||||
|
onClose={closeJournalEditor}
|
||||||
|
onSave={handleSaveJournalNote}
|
||||||
|
onDelete={handleDeleteJournalNote}
|
||||||
|
/>
|
||||||
|
<IntakeJournalHistoryModal
|
||||||
|
isOpen={journalHistoryOpen}
|
||||||
|
entries={journalHistoryEntries}
|
||||||
|
filters={journalHistoryFilters}
|
||||||
|
medications={meds}
|
||||||
|
isLoading={journalHistoryLoading}
|
||||||
|
error={journalHistoryError}
|
||||||
|
onClose={closeJournalHistory}
|
||||||
|
onFilterChange={setJournalHistoryFilters}
|
||||||
|
onReload={reloadJournalHistory}
|
||||||
|
onResetFilters={handleResetJournalFilters}
|
||||||
|
onReopen={reopenJournalHistoryEntry}
|
||||||
|
/>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { MedicationDialogs } from "../components/medications/MedicationDialogs";
|
|||||||
import { MedicationEditCoordinator } from "../components/medications/MedicationEditCoordinator";
|
import { MedicationEditCoordinator } from "../components/medications/MedicationEditCoordinator";
|
||||||
import { MedicationListSection } from "../components/medications/MedicationListSection";
|
import { MedicationListSection } from "../components/medications/MedicationListSection";
|
||||||
import { useAppContext, useUnsavedChanges } from "../context";
|
import { useAppContext, useUnsavedChanges } from "../context";
|
||||||
|
import { useFeedback } from "../context/FeedbackContext";
|
||||||
import {
|
import {
|
||||||
MEDICATION_ENRICHMENT_INITIAL_LIMIT,
|
MEDICATION_ENRICHMENT_INITIAL_LIMIT,
|
||||||
MEDICATION_ENRICHMENT_LIMIT_STEP,
|
MEDICATION_ENRICHMENT_LIMIT_STEP,
|
||||||
@@ -222,7 +223,8 @@ async function getMedicationEnrichmentErrorMessage(
|
|||||||
export function MedicationsPage() {
|
export function MedicationsPage() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user } = useAuth();
|
const { user, authFetch } = useAuth();
|
||||||
|
const { showFeedback } = useFeedback();
|
||||||
const {
|
const {
|
||||||
meds,
|
meds,
|
||||||
saving,
|
saving,
|
||||||
@@ -274,6 +276,7 @@ export function MedicationsPage() {
|
|||||||
);
|
);
|
||||||
const [viewMode, setViewMode] = useState<"grid" | "form">(pendingEditTransition ? "form" : "grid");
|
const [viewMode, setViewMode] = useState<"grid" | "form">(pendingEditTransition ? "form" : "grid");
|
||||||
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null);
|
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null);
|
||||||
|
const closeLightbox = useCallback(() => setLightboxImage(null), []);
|
||||||
const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general");
|
const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general");
|
||||||
|
|
||||||
// Mobile modal state (declared early because it's used in useEffect below)
|
// Mobile modal state (declared early because it's used in useEffect below)
|
||||||
@@ -394,9 +397,7 @@ export function MedicationsPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const params = new URLSearchParams({ q: trimmedQuery, limit: String(limit) });
|
const params = new URLSearchParams({ q: trimmedQuery, limit: String(limit) });
|
||||||
const response = await fetch(`/api/medication-enrichment/search?${params.toString()}`, {
|
const response = await authFetch(`/api/medication-enrichment/search?${params.toString()}`);
|
||||||
credentials: "include",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -458,7 +459,7 @@ export function MedicationsPage() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[medicationEnrichment.query, medicationEnrichment.results, t]
|
[authFetch, medicationEnrichment.query, medicationEnrichment.results, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handlePendingMedicationImageSelection = useCallback(
|
const handlePendingMedicationImageSelection = useCallback(
|
||||||
@@ -489,6 +490,8 @@ export function MedicationsPage() {
|
|||||||
const [readOnlyView, setReadOnlyView] = useState(false);
|
const [readOnlyView, setReadOnlyView] = useState(false);
|
||||||
const [showReportModal, setShowReportModal] = useState(false);
|
const [showReportModal, setShowReportModal] = useState(false);
|
||||||
useModalHistory(showReportModal, "report", () => setShowReportModal(false));
|
useModalHistory(showReportModal, "report", () => setShowReportModal(false));
|
||||||
|
useModalHistory(!!lightboxImage, "medication-image-lightbox", closeLightbox);
|
||||||
|
useModalHistory(showUnsavedConfirm, "medication-unsaved-confirm", handleCancelClose);
|
||||||
const [showNameValidation, setShowNameValidation] = useState(false);
|
const [showNameValidation, setShowNameValidation] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -517,13 +520,13 @@ export function MedicationsPage() {
|
|||||||
|
|
||||||
const loadAllMeds = useCallback(async () => {
|
const loadAllMeds = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/medications?includeObsolete=true", { credentials: "include" });
|
const res = await authFetch("/api/medications?includeObsolete=true");
|
||||||
const data = (await res.json()) as unknown;
|
const data = (await res.json()) as unknown;
|
||||||
setAllMeds(Array.isArray(data) ? (data as Medication[]) : []);
|
setAllMeds(Array.isArray(data) ? (data as Medication[]) : []);
|
||||||
} catch {
|
} catch {
|
||||||
setAllMeds([]);
|
setAllMeds([]);
|
||||||
}
|
}
|
||||||
}, []);
|
}, [authFetch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadAllMeds();
|
void loadAllMeds();
|
||||||
@@ -617,7 +620,7 @@ export function MedicationsPage() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/medication-enrichment/enrich", {
|
const response = await authFetch("/api/medication-enrichment/enrich", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -627,7 +630,6 @@ export function MedicationsPage() {
|
|||||||
code: result.code,
|
code: result.code,
|
||||||
source: result.source,
|
source: result.source,
|
||||||
}),
|
}),
|
||||||
credentials: "include",
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -699,7 +701,7 @@ export function MedicationsPage() {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[form, medicationEnrichment.query, setForm, t]
|
[authFetch, form, medicationEnrichment.query, setForm, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMedicationEnrichmentStrengthApply = useCallback(
|
const handleMedicationEnrichmentStrengthApply = useCallback(
|
||||||
@@ -1018,7 +1020,7 @@ export function MedicationsPage() {
|
|||||||
|
|
||||||
async function markMedicationObsolete(id: number) {
|
async function markMedicationObsolete(id: number) {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/medications/${id}/obsolete`, { method: "POST", credentials: "include" });
|
await authFetch(`/api/medications/${id}/obsolete`, { method: "POST" });
|
||||||
if (editingId === id) {
|
if (editingId === id) {
|
||||||
handleResetForm();
|
handleResetForm();
|
||||||
}
|
}
|
||||||
@@ -1031,7 +1033,7 @@ export function MedicationsPage() {
|
|||||||
|
|
||||||
async function reactivateMedication(id: number) {
|
async function reactivateMedication(id: number) {
|
||||||
try {
|
try {
|
||||||
await fetch(`/api/medications/${id}/reactivate`, { method: "POST", credentials: "include" });
|
await authFetch(`/api/medications/${id}/reactivate`, { method: "POST" });
|
||||||
loadMeds();
|
loadMeds();
|
||||||
await loadAllMeds();
|
await loadAllMeds();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1229,7 +1231,10 @@ export function MedicationsPage() {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
log.error("Save error:", err);
|
log.error("Save error:", err);
|
||||||
alert(err instanceof Error && err.message ? err.message : t("common.saveFailed"));
|
showFeedback({
|
||||||
|
message: err instanceof Error && err.message ? err.message : t("common.saveFailed"),
|
||||||
|
tone: "error",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
@@ -2314,7 +2319,7 @@ export function MedicationsPage() {
|
|||||||
onCancelDelete={handleCancelDelete}
|
onCancelDelete={handleCancelDelete}
|
||||||
showEditModal={showEditModal}
|
showEditModal={showEditModal}
|
||||||
lightboxImage={lightboxImage}
|
lightboxImage={lightboxImage}
|
||||||
onCloseLightbox={() => setLightboxImage(null)}
|
onCloseLightbox={closeLightbox}
|
||||||
showReportModal={showReportModal}
|
showReportModal={showReportModal}
|
||||||
onCloseReportModal={() => setShowReportModal(false)}
|
onCloseReportModal={() => setShowReportModal(false)}
|
||||||
medications={allMeds}
|
medications={allMeds}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ function userStorageKey(userId: number | undefined, key: string): string {
|
|||||||
|
|
||||||
export function PlannerPage() {
|
export function PlannerPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user } = useAuth();
|
const { user, authFetch } = useAuth();
|
||||||
const { meds, settings, openMedDetail } = useAppContext();
|
const { meds, settings, openMedDetail } = useAppContext();
|
||||||
|
|
||||||
// Local state for planner
|
// Local state for planner
|
||||||
@@ -90,10 +90,9 @@ export function PlannerPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setPlannerLoading(true);
|
setPlannerLoading(true);
|
||||||
const body = { startDate: toIsoString(range.start), endDate: toIsoString(range.end), includeUntilStart };
|
const body = { startDate: toIsoString(range.start), endDate: toIsoString(range.end), includeUntilStart };
|
||||||
const rows = (await fetch("/api/medications/usage", {
|
const rows = (await authFetch("/api/medications/usage", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
})
|
})
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
@@ -158,10 +157,9 @@ export function PlannerPage() {
|
|||||||
setPlannerEmailResult(null);
|
setPlannerEmailResult(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/planner/send-email", {
|
const res = await authFetch("/api/planner/send-email", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email: settings.notificationEmail,
|
email: settings.notificationEmail,
|
||||||
from: range.start,
|
from: range.start,
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
/* biome-ignore-all lint/style/noNestedTernary: schedule timeline branches are intentionally explicit */
|
/* biome-ignore-all lint/style/noNestedTernary: schedule timeline branches are intentionally explicit */
|
||||||
import { Archive, Bell } from "lucide-react";
|
import { Archive, Bell, ClipboardList, NotebookPen } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useCallback, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ConfirmModal, MedicationAvatar } from "../components";
|
import { ConfirmModal, IntakeJournalHistoryModal, IntakeJournalModal, MedicationAvatar } from "../components";
|
||||||
import { useAuth } from "../components/Auth";
|
import { useAuth } from "../components/Auth";
|
||||||
|
import { useFeedback } from "../context/FeedbackContext";
|
||||||
import { ScheduleUsageTag } from "../features/schedule/components";
|
import { ScheduleUsageTag } from "../features/schedule/components";
|
||||||
import { formatScheduleDoseUsageLabel, formatScheduleTotalUsageLabel } from "../features/schedule/formatters";
|
import { formatScheduleDoseUsageLabel, formatScheduleTotalUsageLabel } from "../features/schedule/formatters";
|
||||||
import { useScheduleController } from "../hooks";
|
import { useModalHistory, useScheduleController } from "../hooks";
|
||||||
import type { Coverage, IntakeUnit } from "../types";
|
import type { Coverage, IntakeUnit } from "../types";
|
||||||
import { getMedDisplayName, isLiquidContainerPackageType, isTubePackageType } from "../types";
|
import { getMedDisplayName, isLiquidContainerPackageType, isTubePackageType } from "../types";
|
||||||
import { buildClearMissedPayload, isDoseDismissed } from "../utils/schedule";
|
import { buildClearMissedPayload, isDoseDismissed } from "../utils/schedule";
|
||||||
@@ -71,7 +72,8 @@ function getDoseId(baseId: string, person: string | null): string {
|
|||||||
|
|
||||||
export function SchedulePage() {
|
export function SchedulePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { user } = useAuth();
|
const { user, authFetch } = useAuth();
|
||||||
|
const { showFeedback } = useFeedback();
|
||||||
const {
|
const {
|
||||||
meds,
|
meds,
|
||||||
settings,
|
settings,
|
||||||
@@ -96,12 +98,46 @@ export function SchedulePage() {
|
|||||||
openUserFilter,
|
openUserFilter,
|
||||||
missedPastDoseIds,
|
missedPastDoseIds,
|
||||||
loadMeds,
|
loadMeds,
|
||||||
|
journalEditorOpen,
|
||||||
|
journalHistoryOpen,
|
||||||
|
journalEvent,
|
||||||
|
journalEventLoading,
|
||||||
|
journalEventSaving,
|
||||||
|
journalEventDeleting,
|
||||||
|
journalEventError,
|
||||||
|
journalHistoryEntries,
|
||||||
|
journalHistoryFilters,
|
||||||
|
journalHistoryLoading,
|
||||||
|
journalHistoryError,
|
||||||
|
openJournalEditor,
|
||||||
|
closeJournalEditor,
|
||||||
|
saveJournalNote,
|
||||||
|
deleteJournalNote,
|
||||||
|
openJournalHistory,
|
||||||
|
closeJournalHistory,
|
||||||
|
setJournalHistoryFilters,
|
||||||
|
reloadJournalHistory,
|
||||||
|
reopenJournalHistoryEntry,
|
||||||
} = useScheduleController();
|
} = useScheduleController();
|
||||||
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
|
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
|
||||||
const [clearingMissed, setClearingMissed] = useState(false);
|
const [clearingMissed, setClearingMissed] = useState(false);
|
||||||
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
|
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
|
||||||
const [obsoleteCandidate, setObsoleteCandidate] = useState<{ id: number; name: string } | null>(null);
|
const [obsoleteCandidate, setObsoleteCandidate] = useState<{ id: number; name: string } | null>(null);
|
||||||
|
|
||||||
|
const closeClearMissedConfirm = useCallback(() => {
|
||||||
|
if (!clearingMissed) {
|
||||||
|
setShowClearMissedConfirm(false);
|
||||||
|
}
|
||||||
|
}, [clearingMissed]);
|
||||||
|
|
||||||
|
const closeObsoleteConfirm = useCallback(() => {
|
||||||
|
setShowObsoleteConfirm(false);
|
||||||
|
setObsoleteCandidate(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useModalHistory(showClearMissedConfirm, "schedule-clear-missed", closeClearMissedConfirm);
|
||||||
|
useModalHistory(showObsoleteConfirm, "schedule-obsolete", closeObsoleteConfirm);
|
||||||
|
|
||||||
const isDoseTakenForDisplay = (doseId: string) => takenDoses.has(doseId);
|
const isDoseTakenForDisplay = (doseId: string) => takenDoses.has(doseId);
|
||||||
|
|
||||||
const shouldHideNoScheduleStatusForTube = (
|
const shouldHideNoScheduleStatusForTube = (
|
||||||
@@ -118,9 +154,8 @@ export function SchedulePage() {
|
|||||||
|
|
||||||
setClearingMissed(true);
|
setClearingMissed(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch("/api/medications/dismiss-until", {
|
const res = await authFetch("/api/medications/dismiss-until", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
@@ -129,14 +164,37 @@ export function SchedulePage() {
|
|||||||
}
|
}
|
||||||
await loadMeds();
|
await loadMeds();
|
||||||
setShowClearMissedConfirm(false);
|
setShowClearMissedConfirm(false);
|
||||||
alert(t("dashboard.schedules.clearMissedSuccess", { count: missedCount }));
|
showFeedback({
|
||||||
|
message: t("dashboard.schedules.clearMissedSuccess", { count: missedCount }),
|
||||||
|
tone: "success",
|
||||||
|
});
|
||||||
} catch {
|
} catch {
|
||||||
alert(t("common.saveFailed"));
|
showFeedback({ message: t("common.saveFailed"), tone: "error" });
|
||||||
} finally {
|
} finally {
|
||||||
setClearingMissed(false);
|
setClearingMissed(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSaveJournalNote = async (note: string) => {
|
||||||
|
return saveJournalNote(note);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteJournalNote = async () => {
|
||||||
|
const deleted = await deleteJournalNote();
|
||||||
|
if (deleted) {
|
||||||
|
closeJournalEditor();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetJournalFilters = () => {
|
||||||
|
setJournalHistoryFilters({
|
||||||
|
medicationId: null,
|
||||||
|
from: "",
|
||||||
|
to: "",
|
||||||
|
limit: 100,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const requestMarkObsolete = (med: { id: number; name: string }) => {
|
const requestMarkObsolete = (med: { id: number; name: string }) => {
|
||||||
setObsoleteCandidate(med);
|
setObsoleteCandidate(med);
|
||||||
setShowObsoleteConfirm(true);
|
setShowObsoleteConfirm(true);
|
||||||
@@ -145,22 +203,20 @@ export function SchedulePage() {
|
|||||||
const handleConfirmMarkObsolete = async () => {
|
const handleConfirmMarkObsolete = async () => {
|
||||||
if (!obsoleteCandidate) return;
|
if (!obsoleteCandidate) return;
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/medications/${obsoleteCandidate.id}/obsolete`, {
|
const res = await authFetch(`/api/medications/${obsoleteCandidate.id}/obsolete`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
credentials: "include",
|
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||||
await loadMeds();
|
await loadMeds();
|
||||||
setShowObsoleteConfirm(false);
|
setShowObsoleteConfirm(false);
|
||||||
setObsoleteCandidate(null);
|
setObsoleteCandidate(null);
|
||||||
} catch {
|
} catch {
|
||||||
alert(t("common.saveFailed"));
|
showFeedback({ message: t("common.saveFailed"), tone: "error" });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancelMarkObsolete = () => {
|
const handleCancelMarkObsolete = () => {
|
||||||
setShowObsoleteConfirm(false);
|
closeObsoleteConfirm();
|
||||||
setObsoleteCandidate(null);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDoseUsageLabel = (
|
const formatDoseUsageLabel = (
|
||||||
@@ -182,6 +238,7 @@ export function SchedulePage() {
|
|||||||
isAutomaticallyTaken: boolean;
|
isAutomaticallyTaken: boolean;
|
||||||
isEmpty: boolean;
|
isEmpty: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
|
const journalUnavailable = !(options.isTaken || options.isSkipped);
|
||||||
const takeButton = options.isTaken ? (
|
const takeButton = options.isTaken ? (
|
||||||
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
|
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
|
||||||
{options.isAutomaticallyTaken && (
|
{options.isAutomaticallyTaken && (
|
||||||
@@ -220,10 +277,33 @@ export function SchedulePage() {
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const journalButton = (
|
||||||
|
<span
|
||||||
|
className={journalUnavailable ? "tooltip-trigger" : undefined}
|
||||||
|
data-tooltip={journalUnavailable ? t("journal.actions.noteTakenOnly") : undefined}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="dose-btn journal"
|
||||||
|
onClick={() => {
|
||||||
|
if (!journalUnavailable) {
|
||||||
|
void openJournalEditor(options.doseId);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={!journalUnavailable ? t("journal.actions.note") : undefined}
|
||||||
|
disabled={journalUnavailable}
|
||||||
|
>
|
||||||
|
<NotebookPen size={14} aria-hidden="true" />
|
||||||
|
<span className="dose-btn-label">{t("journal.actions.note")}</span>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{takeButton}
|
{takeButton}
|
||||||
{skipButton}
|
{skipButton}
|
||||||
|
{journalButton}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -233,19 +313,32 @@ export function SchedulePage() {
|
|||||||
<article className="card schedule-full">
|
<article className="card schedule-full">
|
||||||
<div className="card-head">
|
<div className="card-head">
|
||||||
<h2>{t("dashboard.schedules.title")}</h2>
|
<h2>{t("dashboard.schedules.title")}</h2>
|
||||||
<select
|
<div className="card-head-actions">
|
||||||
className="select-field schedule-days-select"
|
<select
|
||||||
value={scheduleDays}
|
className="select-field schedule-days-select"
|
||||||
onChange={(e) => {
|
value={scheduleDays}
|
||||||
const val = Number(e.target.value);
|
onChange={(e) => {
|
||||||
setScheduleDays(val);
|
const val = Number(e.target.value);
|
||||||
if (user?.id) localStorage.setItem(userStorageKey(user.id, "scheduleDays"), String(val));
|
setScheduleDays(val);
|
||||||
}}
|
if (user?.id) localStorage.setItem(userStorageKey(user.id, "scheduleDays"), String(val));
|
||||||
>
|
}}
|
||||||
<option value={30}>{t("dashboard.schedules.1month")}</option>
|
>
|
||||||
<option value={90}>{t("dashboard.schedules.3months")}</option>
|
<option value={30}>{t("dashboard.schedules.1month")}</option>
|
||||||
<option value={180}>{t("dashboard.schedules.6months")}</option>
|
<option value={90}>{t("dashboard.schedules.3months")}</option>
|
||||||
</select>
|
<option value={180}>{t("dashboard.schedules.6months")}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="ghost journal-history-button"
|
||||||
|
onClick={openJournalHistory}
|
||||||
|
aria-label={t("journal.actions.history")}
|
||||||
|
title={t("journal.actions.history")}
|
||||||
|
>
|
||||||
|
<ClipboardList size={16} aria-hidden="true" />
|
||||||
|
<span className="journal-history-label-full">{t("journal.actions.history")}</span>
|
||||||
|
<span className="journal-history-label-short">{t("journal.actions.historyShort")}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="timeline">
|
<div className="timeline">
|
||||||
{/* Past days (when expanded) — rendered above toggle */}
|
{/* Past days (when expanded) — rendered above toggle */}
|
||||||
@@ -482,9 +575,7 @@ export function SchedulePage() {
|
|||||||
confirmLabel={t("dashboard.schedules.clearMissedConfirm")}
|
confirmLabel={t("dashboard.schedules.clearMissedConfirm")}
|
||||||
cancelLabel={t("dashboard.schedules.clearMissedCancel")}
|
cancelLabel={t("dashboard.schedules.clearMissedCancel")}
|
||||||
onConfirm={() => void clearMissedDoses(missedPastDoseIds.length)}
|
onConfirm={() => void clearMissedDoses(missedPastDoseIds.length)}
|
||||||
onCancel={() => {
|
onCancel={closeClearMissedConfirm}
|
||||||
if (!clearingMissed) setShowClearMissedConfirm(false);
|
|
||||||
}}
|
|
||||||
isLoading={clearingMissed}
|
isLoading={clearingMissed}
|
||||||
confirmVariant="warning"
|
confirmVariant="warning"
|
||||||
/>
|
/>
|
||||||
@@ -630,6 +721,30 @@ export function SchedulePage() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
<IntakeJournalModal
|
||||||
|
isOpen={journalEditorOpen}
|
||||||
|
entry={journalEvent}
|
||||||
|
isLoading={journalEventLoading}
|
||||||
|
isSaving={journalEventSaving}
|
||||||
|
isDeleting={journalEventDeleting}
|
||||||
|
error={journalEventError}
|
||||||
|
onClose={closeJournalEditor}
|
||||||
|
onSave={handleSaveJournalNote}
|
||||||
|
onDelete={handleDeleteJournalNote}
|
||||||
|
/>
|
||||||
|
<IntakeJournalHistoryModal
|
||||||
|
isOpen={journalHistoryOpen}
|
||||||
|
entries={journalHistoryEntries}
|
||||||
|
filters={journalHistoryFilters}
|
||||||
|
medications={meds}
|
||||||
|
isLoading={journalHistoryLoading}
|
||||||
|
error={journalHistoryError}
|
||||||
|
onClose={closeJournalHistory}
|
||||||
|
onFilterChange={setJournalHistoryFilters}
|
||||||
|
onReload={reloadJournalHistory}
|
||||||
|
onResetFilters={handleResetJournalFilters}
|
||||||
|
onReopen={reopenJournalHistoryEntry}
|
||||||
|
/>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
/* biome-ignore-all lint/a11y/noLabelWithoutControl: settings rows use label-styled text with adjacent custom toggle controls */
|
/* biome-ignore-all lint/a11y/noLabelWithoutControl: settings rows use label-styled text with adjacent custom toggle controls */
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ConfirmModal, ExportModal } from "../components";
|
import { ExportModal, ImportReviewModal } from "../components";
|
||||||
|
import { useAuth } from "../components/Auth";
|
||||||
import { useAppContext } from "../context";
|
import { useAppContext } from "../context";
|
||||||
|
import { useModalHistory } from "../hooks";
|
||||||
import { getSystemLocale, withFormattingTimezone } from "../utils/formatters";
|
import { getSystemLocale, withFormattingTimezone } from "../utils/formatters";
|
||||||
|
|
||||||
export function SettingsPage() {
|
export function SettingsPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const { authFetch } = useAuth();
|
||||||
const [apiKeyToken, setApiKeyToken] = useState("");
|
const [apiKeyToken, setApiKeyToken] = useState("");
|
||||||
const [apiKeyGenerating, setApiKeyGenerating] = useState(false);
|
const [apiKeyGenerating, setApiKeyGenerating] = useState(false);
|
||||||
const [apiKeyCopied, setApiKeyCopied] = useState(false);
|
const [apiKeyCopied, setApiKeyCopied] = useState(false);
|
||||||
@@ -37,15 +40,32 @@ export function SettingsPage() {
|
|||||||
showImportConfirm,
|
showImportConfirm,
|
||||||
setShowImportConfirm,
|
setShowImportConfirm,
|
||||||
setPendingImportData,
|
setPendingImportData,
|
||||||
|
importPreview,
|
||||||
|
setImportPreview,
|
||||||
handleImportConfirm,
|
handleImportConfirm,
|
||||||
importResult,
|
importResult,
|
||||||
setImportResult,
|
setImportResult,
|
||||||
meds,
|
|
||||||
} = useAppContext();
|
} = useAppContext();
|
||||||
const [timezoneTouched, setTimezoneTouched] = useState(false);
|
const [timezoneTouched, setTimezoneTouched] = useState(false);
|
||||||
const [timezoneDraft, setTimezoneDraft] = useState("");
|
const [timezoneDraft, setTimezoneDraft] = useState("");
|
||||||
|
|
||||||
const hasExistingData = meds.length > 0;
|
const formattedImportPreviewDate = importPreview
|
||||||
|
? new Date(importPreview.exportedAt).toLocaleString(getSystemLocale(i18n.language))
|
||||||
|
: "";
|
||||||
|
|
||||||
|
const closeExportModal = useCallback(() => {
|
||||||
|
setShowExportModal(false);
|
||||||
|
}, [setShowExportModal]);
|
||||||
|
|
||||||
|
const closeImportReview = useCallback(() => {
|
||||||
|
setShowImportConfirm(false);
|
||||||
|
setPendingImportData(null);
|
||||||
|
setImportPreview(null);
|
||||||
|
}, [setImportPreview, setPendingImportData, setShowImportConfirm]);
|
||||||
|
|
||||||
|
useModalHistory(showExportModal, "export-options", closeExportModal);
|
||||||
|
useModalHistory(showImportConfirm, "import-review", closeImportReview);
|
||||||
|
|
||||||
let emailUnavailableReason: string | null = null;
|
let emailUnavailableReason: string | null = null;
|
||||||
if (settingsLoadError === "auth") {
|
if (settingsLoadError === "auth") {
|
||||||
emailUnavailableReason = t("settings.email.loadErrorAuth");
|
emailUnavailableReason = t("settings.email.loadErrorAuth");
|
||||||
@@ -63,10 +83,9 @@ export function SettingsPage() {
|
|||||||
setApiKeyCopied(false);
|
setApiKeyCopied(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch("/api/auth/api-keys", {
|
const response = await authFetch("/api/auth/api-keys", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: "Default API Key",
|
name: "Default API Key",
|
||||||
scope: "write",
|
scope: "write",
|
||||||
@@ -195,10 +214,9 @@ export function SettingsPage() {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const lang = e.target.value;
|
const lang = e.target.value;
|
||||||
i18n.changeLanguage(lang);
|
i18n.changeLanguage(lang);
|
||||||
fetch("/api/settings/language", {
|
authFetch("/api/settings/language", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
credentials: "include",
|
|
||||||
body: JSON.stringify({ language: lang }),
|
body: JSON.stringify({ language: lang }),
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -1142,38 +1160,19 @@ export function SettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Import Confirmation Modal */}
|
<ImportReviewModal
|
||||||
{showImportConfirm && (
|
isOpen={showImportConfirm}
|
||||||
<ConfirmModal
|
importPreview={importPreview}
|
||||||
title={t(hasExistingData ? "exportImport.confirmImport" : "exportImport.confirmImportEmpty")}
|
formattedExportedAt={formattedImportPreviewDate}
|
||||||
message={
|
importing={importing}
|
||||||
hasExistingData ? (
|
exporting={exporting}
|
||||||
<>
|
onClose={closeImportReview}
|
||||||
<p style={{ marginBottom: "12px" }}>{t("exportImport.confirmImportMessage")}</p>
|
onBackup={() => handleExport(true)}
|
||||||
<p className="warning-text">⚠️ {t("exportImport.confirmImportWarning")}</p>
|
onConfirm={handleImportConfirm}
|
||||||
</>
|
/>
|
||||||
) : (
|
|
||||||
<p>{t("exportImport.confirmImportEmptyMessage")}</p>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
confirmLabel={t(hasExistingData ? "exportImport.confirmButton" : "exportImport.confirmButtonEmpty")}
|
|
||||||
cancelLabel={t("exportImport.cancelButton")}
|
|
||||||
onConfirm={handleImportConfirm}
|
|
||||||
onCancel={() => {
|
|
||||||
setShowImportConfirm(false);
|
|
||||||
setPendingImportData(null);
|
|
||||||
}}
|
|
||||||
confirmVariant={hasExistingData ? "danger" : "primary"}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Export Options Modal */}
|
{/* Export Options Modal */}
|
||||||
<ExportModal
|
<ExportModal isOpen={showExportModal} onClose={closeExportModal} onExport={handleExport} exporting={exporting} />
|
||||||
isOpen={showExportModal}
|
|
||||||
onClose={() => setShowExportModal(false)}
|
|
||||||
onExport={handleExport}
|
|
||||||
exporting={exporting}
|
|
||||||
/>
|
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
Add new shared styles to the focused partial that owns the relevant domain.
|
Add new shared styles to the focused partial that owns the relevant domain.
|
||||||
============================================================================= */
|
============================================================================= */
|
||||||
@import url("./styles/foundation.css");
|
@import url("./styles/foundation.css");
|
||||||
|
@import url("./styles/feedback.css");
|
||||||
@import url("./styles/app-surfaces.css");
|
@import url("./styles/app-surfaces.css");
|
||||||
@import url("./styles/settings-surfaces.css");
|
@import url("./styles/settings-surfaces.css");
|
||||||
@import url("./styles/modal-detail.css");
|
@import url("./styles/modal-detail.css");
|
||||||
|
|||||||
@@ -284,6 +284,37 @@ a.about-version-link:hover {
|
|||||||
margin: 0 0 1.25rem;
|
margin: 0 0 1.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.report-range {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-range h4 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0 0 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-range-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-range-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-range-field .date-input-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
/* Person filter */
|
/* Person filter */
|
||||||
.report-person-filter {
|
.report-person-filter {
|
||||||
margin-bottom: 1.25rem;
|
margin-bottom: 1.25rem;
|
||||||
@@ -448,6 +479,60 @@ a.about-version-link:hover {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.report-error {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
padding: 0.75rem 0.9rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--danger-bg, #fee2e2) 75%, transparent);
|
||||||
|
color: var(--danger-text, #b91c1c);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-preview {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
padding: 0.9rem;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: color-mix(in srgb, var(--bg-secondary) 75%, var(--bg-tertiary));
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-preview-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-preview-header h4 {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-preview-desc {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.report-preview-content {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.85rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
border: 1px solid var(--border-secondary);
|
||||||
|
max-height: 280px;
|
||||||
|
overflow: auto;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
/* Actions */
|
/* Actions */
|
||||||
.report-actions {
|
.report-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -456,3 +541,9 @@ a.about-version-link:hover {
|
|||||||
padding-top: 0.75rem;
|
padding-top: 0.75rem;
|
||||||
border-top: 1px solid var(--border-primary);
|
border-top: 1px solid var(--border-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.report-range-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2919,48 +2919,74 @@ button.has-validation-error {
|
|||||||
.time-row {
|
.time-row {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.doses-col {
|
.doses-col {
|
||||||
flex-direction: row;
|
flex-direction: column;
|
||||||
flex-wrap: wrap;
|
flex-wrap: nowrap;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dose-item {
|
.dose-item {
|
||||||
flex: 1 1 auto;
|
display: grid;
|
||||||
min-width: 140px;
|
grid-template-columns: minmax(3.75rem, auto) minmax(0, 1fr) auto;
|
||||||
gap: 0.35rem;
|
align-items: center;
|
||||||
padding: 0.35rem 0.3rem;
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.55rem 0.6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dose-time {
|
.dose-time {
|
||||||
min-width: 42px;
|
min-width: 0;
|
||||||
padding-left: 0.2rem;
|
padding-left: 0;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dose-usage {
|
.dose-usage {
|
||||||
line-height: 1.15;
|
line-height: 1.15;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dose-checks {
|
.dose-checks {
|
||||||
gap: 2px;
|
grid-column: 1 / -1;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: 0;
|
||||||
|
gap: 0.3rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dose-item .reminder-icon {
|
||||||
|
justify-self: end;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dose-person {
|
.dose-person {
|
||||||
gap: 4px;
|
width: 100%;
|
||||||
padding: 1px 4px;
|
min-width: 0;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.35rem;
|
||||||
|
padding: 0.28rem 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dose-person .person-name {
|
.dose-person .person-name {
|
||||||
|
flex: 1 1 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
max-width: 5.6rem;
|
max-width: none;
|
||||||
margin-right: 0.35rem;
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dose-person > .tooltip-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dose-person .dose-btn {
|
.dose-person .dose-btn {
|
||||||
height: 22px;
|
height: 26px;
|
||||||
min-height: 22px;
|
min-height: 26px;
|
||||||
padding: 0 5px;
|
padding: 0 0.5rem;
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2975,31 +3001,172 @@ button.has-validation-error {
|
|||||||
|
|
||||||
.day-block {
|
.day-block {
|
||||||
padding: 0.75rem;
|
padding: 0.75rem;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Use more horizontal space for schedule cards on phones */
|
.timeline,
|
||||||
.dashboard-schedules-section > .card {
|
.time-main,
|
||||||
padding-inline: 0.35rem;
|
.time-main .med-name,
|
||||||
overflow: visible;
|
.tag-row {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Keep header controls aligned like other dashboard cards */
|
.time-main .med-name {
|
||||||
.dashboard-schedules-section .card-head {
|
overflow-wrap: anywhere;
|
||||||
flex-direction: row;
|
}
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
/* Keep schedule controls readable without exceeding phone width. */
|
||||||
gap: 0.6rem;
|
.dashboard-schedules-section > .card,
|
||||||
padding-inline: 0.65rem;
|
.schedule-full {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-schedules-section .card-head,
|
||||||
|
.schedule-full .card-head {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-schedules-section .card-head h2,
|
||||||
|
.schedule-full .card-head h2 {
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-schedules-section .card-head-actions {
|
.dashboard-schedules-section .card-head-actions {
|
||||||
margin-left: auto;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: 0;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.schedule-full .card-head-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-schedules-section .schedule-days-select,
|
||||||
|
.schedule-full .schedule-days-select {
|
||||||
|
flex: 1 1 7.5rem;
|
||||||
|
width: auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-schedules-section .journal-history-button,
|
||||||
|
.schedule-full .journal-history-button {
|
||||||
|
flex: 1 1 7.5rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
min-width: 0;
|
||||||
|
justify-content: center;
|
||||||
|
padding-block: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-schedules-section .journal-history-button span,
|
||||||
|
.schedule-full .journal-history-button span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-schedules-section .journal-history-label-full,
|
||||||
|
.schedule-full .journal-history-label-full {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-schedules-section .journal-history-label-short,
|
||||||
|
.schedule-full .journal-history-label-short {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-schedules-section .share-btn.icon-only {
|
||||||
|
flex: 0 0 2.75rem;
|
||||||
|
width: 2.75rem;
|
||||||
|
height: 2.75rem;
|
||||||
|
min-width: 2.75rem;
|
||||||
|
min-height: 2.75rem;
|
||||||
|
padding: 0;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 380px) {
|
||||||
|
.dashboard-schedules-section .schedule-days-select,
|
||||||
|
.schedule-full .schedule-days-select {
|
||||||
|
flex-basis: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-schedules-section .journal-history-button {
|
||||||
|
flex-basis: calc(100% - 3.25rem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-schedules-section .day-block,
|
||||||
|
.schedule-full .day-block {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-divider {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-date {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.day-summary {
|
||||||
|
flex-shrink: 0;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.past-days-header,
|
||||||
|
.future-days-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 0.5rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.past-days-toggle,
|
||||||
|
.future-days-toggle,
|
||||||
|
.clear-missed-btn {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.past-days-label,
|
||||||
|
.future-days-label {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.past-days-warning,
|
||||||
|
.past-days-complete,
|
||||||
|
.future-days-progress {
|
||||||
|
margin-left: auto;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-schedules-section .day-block {
|
.clear-missed-btn {
|
||||||
margin-inline: -0.1rem;
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-chip {
|
.status-chip {
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
.app-feedback-stack {
|
||||||
|
position: fixed;
|
||||||
|
right: 1rem;
|
||||||
|
bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: min(24rem, calc(100vw - 2rem));
|
||||||
|
z-index: 2100;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-feedback {
|
||||||
|
pointer-events: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.9rem 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
box-shadow: 0 16px 32px rgba(0, 0, 0, 0.24);
|
||||||
|
color: var(--text-primary);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-feedback-info {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: color-mix(in srgb, var(--bg-secondary) 82%, var(--accent-bg));
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-feedback-success {
|
||||||
|
border-color: var(--success);
|
||||||
|
background: color-mix(in srgb, var(--bg-secondary) 82%, var(--success-bg));
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-feedback-warning {
|
||||||
|
border-color: var(--warning);
|
||||||
|
background: color-mix(in srgb, var(--bg-secondary) 82%, var(--warning-bg));
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-feedback-error {
|
||||||
|
border-color: var(--danger);
|
||||||
|
background: color-mix(in srgb, var(--bg-secondary) 82%, var(--danger-bg));
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-feedback-message {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-feedback-close {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-feedback-close:hover {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.app-feedback-stack {
|
||||||
|
right: 0.75rem;
|
||||||
|
left: 0.75rem;
|
||||||
|
bottom: 0.75rem;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+1
-1
@@ -108,7 +108,7 @@ body.modal-open {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.page {
|
.page {
|
||||||
max-width: 1200px;
|
max-width: 1440px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 2.5rem 1.5rem 1.5rem;
|
padding: 2.5rem 1.5rem 1.5rem;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
/* =============================================================================
|
||||||
|
Intake Journal Modals
|
||||||
|
Owns the focused owner-only journal editor and history overlays.
|
||||||
|
============================================================================= */
|
||||||
|
|
||||||
|
.journal-modal,
|
||||||
|
.journal-history-modal {
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-history-button {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-history-label-short {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dose-btn.journal {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
background: #ffffff;
|
||||||
|
color: #1e293b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dose-btn.journal:hover:not(:disabled) {
|
||||||
|
background: #f4f7fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dose-btn.journal:disabled {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-color: var(--border-primary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-modal-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-modal-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-modal-header p {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.92rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-modal-state {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px dashed var(--border-primary);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-event-card {
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-event-medication {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-event-medication p {
|
||||||
|
margin: 0.2rem 0 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-event-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-event-grid span,
|
||||||
|
.journal-field span {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 0.35rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-event-grid strong {
|
||||||
|
display: block;
|
||||||
|
font-size: 0.92rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-field {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-note-input,
|
||||||
|
.journal-history-modal .select-field {
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-input);
|
||||||
|
color: var(--text-primary);
|
||||||
|
padding: 0.8rem 0.9rem;
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-note-input {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 10rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-inline-error {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.8rem 0.9rem;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||||
|
background: rgba(127, 29, 29, 0.18);
|
||||||
|
color: var(--danger);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-history-filters {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-date-filter .date-input-wrapper,
|
||||||
|
.journal-date-filter .date-input-display {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-history-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-history-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-history-entry {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
background: var(--bg-primary);
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-history-entry-main {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-history-entry-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-history-entry-header p,
|
||||||
|
.journal-history-meta {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.84rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-history-note {
|
||||||
|
margin: 0;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-history-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-modal-footer {
|
||||||
|
padding: 1rem 0 0;
|
||||||
|
border-top: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.journal-history-filters,
|
||||||
|
.journal-event-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-history-entry {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.journal-history-entry > button {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.shared-schedule-container {
|
.shared-schedule-container {
|
||||||
max-width: 800px;
|
max-width: 1180px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +97,14 @@
|
|||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shared-schedule-boundary {
|
||||||
|
max-width: 34rem;
|
||||||
|
margin: 0 auto;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
.shared-schedule-period {
|
.shared-schedule-period {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
@@ -127,6 +135,10 @@
|
|||||||
padding: 0.5rem 0;
|
padding: 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shared-schedule-page .tooltip-trigger > .dose-btn:disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.med-name-stack {
|
.med-name-stack {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -326,17 +338,127 @@
|
|||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
.shared-schedule-page {
|
.shared-schedule-page {
|
||||||
padding: 1rem;
|
padding: 0.75rem;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-schedule-container,
|
||||||
|
.shared-schedule-section,
|
||||||
|
.shared-schedule-section .timeline,
|
||||||
|
.shared-schedule-section .day-block,
|
||||||
|
.shared-schedule-section .time-row,
|
||||||
|
.shared-schedule-section .time-main,
|
||||||
|
.shared-schedule-section .doses-col,
|
||||||
|
.shared-schedule-section .dose-item,
|
||||||
|
.shared-schedule-section .dose-checks,
|
||||||
|
.shared-schedule-section .dose-person {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-schedule-header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding-top: 0.25rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-schedule-header-actions {
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shared-schedule-header h1 {
|
.shared-schedule-header h1 {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.18;
|
||||||
|
padding-right: 3rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-schedule-boundary {
|
||||||
|
margin-inline: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-schedule-period {
|
||||||
|
margin: 0.75rem 0 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.shared-timeline {
|
.shared-timeline {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.shared-schedule-section .timeline {
|
||||||
|
gap: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-schedule-section .day-block {
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-schedule-section .time-row {
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-schedule-section .time-main .med-name {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-schedule-section .doses-col {
|
||||||
|
gap: 0.55rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-schedule-section .dose-item {
|
||||||
|
grid-template-columns: minmax(3.5rem, auto) minmax(0, 1fr);
|
||||||
|
gap: 0.45rem 0.6rem;
|
||||||
|
padding: 0.55rem 0.6rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-schedule-section .dose-checks {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-schedule-section .dose-person {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
padding: 0.4rem;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-schedule-section .dose-person .person-name {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
justify-self: stretch;
|
||||||
|
max-width: 100%;
|
||||||
|
margin-right: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-schedule-section .dose-person > .dose-btn,
|
||||||
|
.shared-schedule-section .dose-person > .tooltip-trigger {
|
||||||
|
min-width: 0;
|
||||||
|
margin-left: 0;
|
||||||
|
justify-self: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-schedule-section .dose-person > .tooltip-trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
justify-self: end;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-schedule-section .dose-person .dose-btn {
|
||||||
|
height: 28px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding-inline: 0.55rem;
|
||||||
|
}
|
||||||
|
|
||||||
.shared-overview-table-wrap {
|
.shared-overview-table-wrap {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -346,6 +468,28 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.shared-schedule-page .tooltip-trigger[data-tooltip]::after,
|
||||||
|
.shared-schedule-page .tooltip-trigger[data-tooltip]::before {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shared-schedule-page .tooltip-trigger.tooltip-active[data-tooltip]::after {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
top: auto;
|
||||||
|
bottom: var(--tooltip-bottom, 50%);
|
||||||
|
left: 16px;
|
||||||
|
right: 16px;
|
||||||
|
transform: none;
|
||||||
|
width: auto;
|
||||||
|
max-width: none;
|
||||||
|
white-space: normal;
|
||||||
|
text-align: center;
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Desktop Edit Panel (two-column layout) ── */
|
/* ── Desktop Edit Panel (two-column layout) ── */
|
||||||
.edit-sidebar {
|
.edit-sidebar {
|
||||||
display: none;
|
display: none;
|
||||||
|
|||||||
@@ -1290,4 +1290,66 @@
|
|||||||
border: 1px solid var(--border-primary);
|
border: 1px solid var(--border-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.import-review-modal {
|
||||||
|
max-width: 560px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-review-modal h2 {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding-right: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-review-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-review-summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-review-summary .action-card {
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-review-meta {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-review-warnings {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-review-warnings ul {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
padding-left: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-review-footer {
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 1rem 0 0;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.import-review-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.import-review-summary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Modal base styles moved to styles/modals-base.css */
|
/* Modal base styles moved to styles/modals-base.css */
|
||||||
|
|||||||
@@ -66,6 +66,82 @@
|
|||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.share-dialog-active-links {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-dialog-manage {
|
||||||
|
border: 1px solid var(--border-primary);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-dialog-manage-summary {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.85rem 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-dialog-manage-count {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-dialog-manage-content {
|
||||||
|
padding: 0 1rem 1rem;
|
||||||
|
border-top: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-active-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-active-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-active-item + .share-active-item {
|
||||||
|
border-top: 1px solid var(--border-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-active-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.25rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-link-inline {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--accent);
|
||||||
|
text-decoration: none;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-link-inline:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.share-dialog-footer {
|
.share-dialog-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
|
|||||||
@@ -3,11 +3,34 @@ import { MemoryRouter, useLocation } from "react-router-dom";
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import App from "../App";
|
import App from "../App";
|
||||||
|
|
||||||
|
const appTranslations: Record<string, string> = {
|
||||||
|
"auth.connectionErrorTitle": "Connection Error",
|
||||||
|
"auth.connectionErrorHelp": "Please check if the server is running and try again.",
|
||||||
|
"common.initializing": "Initializing...",
|
||||||
|
"common.loading": "Loading...",
|
||||||
|
"common.retry": "Retry",
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("react-i18next")>("react-i18next");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => appTranslations[key] ?? key,
|
||||||
|
i18n: {
|
||||||
|
language: "en",
|
||||||
|
changeLanguage: vi.fn(),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
type AuthStateMock = {
|
type AuthStateMock = {
|
||||||
user: { id: number; username: string } | null;
|
user: { id: number; username: string } | null;
|
||||||
authState: { authEnabled: boolean; needsSetup: boolean } | null;
|
authState: { authEnabled: boolean; needsSetup: boolean } | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
authError: string | null;
|
authError: string | null;
|
||||||
|
sessionExpired?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
let authMock: AuthStateMock = {
|
let authMock: AuthStateMock = {
|
||||||
@@ -15,6 +38,7 @@ let authMock: AuthStateMock = {
|
|||||||
authState: { authEnabled: false, needsSetup: false },
|
authState: { authEnabled: false, needsSetup: false },
|
||||||
loading: false,
|
loading: false,
|
||||||
authError: null,
|
authError: null,
|
||||||
|
sessionExpired: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
let appContextMock: Record<string, unknown>;
|
let appContextMock: Record<string, unknown>;
|
||||||
@@ -58,7 +82,7 @@ vi.mock("../context", async () => {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("../pages", () => ({
|
vi.mock("../pages/DashboardPage", () => ({
|
||||||
DashboardPage: () => {
|
DashboardPage: () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
return (
|
return (
|
||||||
@@ -68,10 +92,25 @@ vi.mock("../pages", () => ({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../pages/MedicationsPage", () => ({
|
||||||
MedicationsPage: () => <div>medications-page</div>,
|
MedicationsPage: () => <div>medications-page</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../pages/PlannerPage", () => ({
|
||||||
PlannerPage: () => <div>planner-page</div>,
|
PlannerPage: () => <div>planner-page</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../pages/SchedulePage", () => ({
|
||||||
SchedulePage: () => <div>schedule-page</div>,
|
SchedulePage: () => <div>schedule-page</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../pages/SettingsPage", () => ({
|
||||||
SettingsPage: () => <div>settings-page</div>,
|
SettingsPage: () => <div>settings-page</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../pages/SharedOverviewPage", () => ({
|
||||||
SharedOverviewPage: () => <div>shared-overview-page</div>,
|
SharedOverviewPage: () => <div>shared-overview-page</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -141,12 +180,20 @@ describe("App", () => {
|
|||||||
setShareSelectedPerson: vi.fn(),
|
setShareSelectedPerson: vi.fn(),
|
||||||
shareSelectedDays: 7,
|
shareSelectedDays: 7,
|
||||||
setShareSelectedDays: vi.fn(),
|
setShareSelectedDays: vi.fn(),
|
||||||
|
shareSelectedExpiryDays: null,
|
||||||
|
setShareSelectedExpiryDays: vi.fn(),
|
||||||
|
shareAllowJournalNotes: false,
|
||||||
|
setShareAllowJournalNotes: vi.fn(),
|
||||||
shareGenerating: false,
|
shareGenerating: false,
|
||||||
shareLink: null,
|
shareLink: null,
|
||||||
setShareLink: vi.fn(),
|
setShareLink: vi.fn(),
|
||||||
shareCopied: false,
|
shareCopied: false,
|
||||||
setShareCopied: vi.fn(),
|
setShareCopied: vi.fn(),
|
||||||
|
activeShareLinks: [],
|
||||||
|
activeSharesLoading: false,
|
||||||
|
revokingShareToken: null,
|
||||||
generateShareLink: vi.fn(),
|
generateShareLink: vi.fn(),
|
||||||
|
revokeShareLink: vi.fn(),
|
||||||
copyShareLink: vi.fn(),
|
copyShareLink: vi.fn(),
|
||||||
closeShareDialog: vi.fn(),
|
closeShareDialog: vi.fn(),
|
||||||
resetShareDialogState: vi.fn(),
|
resetShareDialogState: vi.fn(),
|
||||||
@@ -200,6 +247,7 @@ describe("App", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("Connection Error")).toBeInTheDocument();
|
expect(screen.getByText("Connection Error")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Please check if the server is running and try again.")).toBeInTheDocument();
|
||||||
expect(screen.getByText("Backend is unreachable")).toBeInTheDocument();
|
expect(screen.getByText("Backend is unreachable")).toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -262,7 +310,7 @@ describe("App", () => {
|
|||||||
expect(screen.getByText("auth-page")).toBeInTheDocument();
|
expect(screen.getByText("auth-page")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders app shell when auth is disabled", () => {
|
it("renders app shell when auth is disabled", async () => {
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
<MemoryRouter initialEntries={["/dashboard"]}>
|
||||||
<App />
|
<App />
|
||||||
@@ -270,10 +318,10 @@ describe("App", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("app-header")).toBeInTheDocument();
|
expect(screen.getByText("app-header")).toBeInTheDocument();
|
||||||
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
|
expect(await screen.findByText("dashboard-page")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves notification query params when redirecting root to dashboard", () => {
|
it("preserves notification query params when redirecting root to dashboard", async () => {
|
||||||
const search = "?date=2026-05-06&medId=4332&doseId=4332-0-1778104500000";
|
const search = "?date=2026-05-06&medId=4332&doseId=4332-0-1778104500000";
|
||||||
|
|
||||||
render(
|
render(
|
||||||
@@ -282,8 +330,8 @@ describe("App", () => {
|
|||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
|
expect(await screen.findByText("dashboard-page")).toBeInTheDocument();
|
||||||
expect(screen.getByTestId("dashboard-location-search")).toHaveTextContent(search);
|
expect(await screen.findByTestId("dashboard-location-search")).toHaveTextContent(search);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders initializing state when auth state is missing", () => {
|
it("renders initializing state when auth state is missing", () => {
|
||||||
@@ -370,14 +418,14 @@ describe("App", () => {
|
|||||||
expect(shareContextMock.resetShareDialogState).toHaveBeenCalled();
|
expect(shareContextMock.resetShareDialogState).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("redirects unknown routes to dashboard", () => {
|
it("redirects unknown routes to dashboard", async () => {
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={["/unknown-route"]}>
|
<MemoryRouter initialEntries={["/unknown-route"]}>
|
||||||
<App />
|
<App />
|
||||||
</MemoryRouter>
|
</MemoryRouter>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
|
expect(await screen.findByText("dashboard-page")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("popstate closes image lightbox before other modals", () => {
|
it("popstate closes image lightbox before other modals", () => {
|
||||||
|
|||||||
@@ -132,6 +132,7 @@ describe("AuthProvider", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(result.current.user).toBeNull();
|
expect(result.current.user).toBeNull();
|
||||||
|
expect(result.current.sessionExpired).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -865,6 +866,28 @@ describe("AuthProvider methods", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.current.user).toBeNull();
|
expect(result.current.user).toBeNull();
|
||||||
|
expect(result.current.sessionExpired).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the session as expired when refreshUser cannot recover from 401", async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
(global.fetch as ReturnType<typeof vi.fn>)
|
||||||
|
.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ authEnabled: false, formLoginEnabled: true }) })
|
||||||
|
.mockResolvedValueOnce({ ok: false, status: 401 })
|
||||||
|
.mockResolvedValueOnce({ ok: false, status: 401 });
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useAuth(), { wrapper });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.loading).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await result.current.refreshUser();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current.user).toBeNull();
|
||||||
|
expect(result.current.sessionExpired).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("updateProfile throws default message when backend has no error field", async () => {
|
it("updateProfile throws default message when backend has no error field", async () => {
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { ImportReviewModal } from "../../components/ImportReviewModal";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("react-i18next")>("react-i18next");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const importPreview = {
|
||||||
|
version: "1.6",
|
||||||
|
exportedAt: "2026-05-21T10:00:00.000Z",
|
||||||
|
includeSensitiveData: true,
|
||||||
|
incoming: {
|
||||||
|
medications: 1,
|
||||||
|
doseHistory: 2,
|
||||||
|
refillHistory: 3,
|
||||||
|
shareLinks: 4,
|
||||||
|
journalEntries: 1,
|
||||||
|
imageCount: 1,
|
||||||
|
hasSettings: true,
|
||||||
|
},
|
||||||
|
current: {
|
||||||
|
medications: 5,
|
||||||
|
doseHistory: 6,
|
||||||
|
refillHistory: 7,
|
||||||
|
shareLinks: 8,
|
||||||
|
hasSettings: true,
|
||||||
|
},
|
||||||
|
warnings: {
|
||||||
|
replacesExistingData: true,
|
||||||
|
regeneratesShareLinks: true,
|
||||||
|
containsImages: true,
|
||||||
|
containsSensitiveData: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("ImportReviewModal", () => {
|
||||||
|
it("stays closed without an open preview", () => {
|
||||||
|
const { container } = render(
|
||||||
|
<ImportReviewModal
|
||||||
|
isOpen={false}
|
||||||
|
importPreview={importPreview}
|
||||||
|
formattedExportedAt="May 21, 2026"
|
||||||
|
importing={false}
|
||||||
|
exporting={false}
|
||||||
|
onClose={vi.fn()}
|
||||||
|
onBackup={vi.fn()}
|
||||||
|
onConfirm={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.firstChild).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("supports overlay, Escape, backup, and confirm actions", () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const onBackup = vi.fn();
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
const { container } = render(
|
||||||
|
<ImportReviewModal
|
||||||
|
isOpen={true}
|
||||||
|
importPreview={importPreview}
|
||||||
|
formattedExportedAt="May 21, 2026"
|
||||||
|
importing={false}
|
||||||
|
exporting={false}
|
||||||
|
onClose={onClose}
|
||||||
|
onBackup={onBackup}
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("exportImport.confirmImport")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(container.querySelector(".modal-content") as Element);
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("exportImport.backupFirst"));
|
||||||
|
expect(onBackup).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("exportImport.confirmButton"));
|
||||||
|
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
fireEvent.keyDown(document, { key: "Escape" });
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
fireEvent.click(container.querySelector(".modal-overlay") as Element);
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { IntakeJournalModal } from "../../components/intake-journal/IntakeJournalModal";
|
||||||
|
import type { IntakeJournalEntry } from "../../hooks/useIntakeJournal";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", () => ({
|
||||||
|
useTranslation: () => ({
|
||||||
|
t: (key: string) => key,
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../components/MedicationAvatar", () => ({
|
||||||
|
MedicationAvatar: ({ name }: { name: string }) => <div>{name}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
function buildEntry(overrides: Partial<IntakeJournalEntry> = {}): IntakeJournalEntry {
|
||||||
|
return {
|
||||||
|
doseTrackingId: 1,
|
||||||
|
doseId: "1-0-1760000000000-pillamn",
|
||||||
|
medicationId: 1,
|
||||||
|
medicationName: "Liquid Container",
|
||||||
|
scheduledFor: "2026-05-17T11:55:00.000Z",
|
||||||
|
takenAt: "2026-05-17T19:23:00.000Z",
|
||||||
|
dismissed: false,
|
||||||
|
takenSource: "manual",
|
||||||
|
markedBy: "pillamn",
|
||||||
|
note: "",
|
||||||
|
updatedAt: null,
|
||||||
|
createdAt: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("IntakeJournalModal", () => {
|
||||||
|
it("closes after a successful save", async () => {
|
||||||
|
const onSave = vi.fn(async () => true);
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const onDelete = vi.fn();
|
||||||
|
const entry = buildEntry();
|
||||||
|
render(
|
||||||
|
<IntakeJournalModal
|
||||||
|
isOpen
|
||||||
|
entry={entry}
|
||||||
|
isLoading={false}
|
||||||
|
isSaving={false}
|
||||||
|
isDeleting={false}
|
||||||
|
error={null}
|
||||||
|
onClose={onClose}
|
||||||
|
onSave={onSave}
|
||||||
|
onDelete={onDelete}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("journal.editor.noteLabel"), {
|
||||||
|
target: { value: "Shared note" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
|
||||||
|
|
||||||
|
expect(onSave).toHaveBeenCalledWith("Shared note");
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the modal open when save fails", async () => {
|
||||||
|
const onSave = vi.fn(async () => false);
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const entry = buildEntry();
|
||||||
|
render(
|
||||||
|
<IntakeJournalModal
|
||||||
|
isOpen
|
||||||
|
entry={entry}
|
||||||
|
isLoading={false}
|
||||||
|
isSaving={false}
|
||||||
|
isDeleting={false}
|
||||||
|
error={null}
|
||||||
|
onClose={onClose}
|
||||||
|
onSave={onSave}
|
||||||
|
onDelete={vi.fn()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("journal.editor.noteLabel"), {
|
||||||
|
target: { value: "Shared note" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(onSave).toHaveBeenCalledWith("Shared note");
|
||||||
|
});
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,28 @@ import ReportModal from "../../components/ReportModal";
|
|||||||
import type { Medication } from "../../types";
|
import type { Medication } from "../../types";
|
||||||
import { formatDate, formatDateTime } from "../../utils/formatters";
|
import { formatDate, formatDateTime } from "../../utils/formatters";
|
||||||
|
|
||||||
|
const authFetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
|
||||||
|
|
||||||
|
vi.mock("../../components/Auth", () => ({
|
||||||
|
useAuth: () => ({ authFetch: authFetchMock }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
function getPreviewContent() {
|
||||||
|
const preview = document.querySelector(".report-preview-content");
|
||||||
|
if (!(preview instanceof HTMLElement)) {
|
||||||
|
throw new Error("Expected report preview content to be rendered");
|
||||||
|
}
|
||||||
|
return preview.textContent ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectPreviewToBeVisible() {
|
||||||
|
const preview = document.querySelector(".report-preview");
|
||||||
|
if (!(preview instanceof HTMLElement)) {
|
||||||
|
throw new Error("Expected report preview to be rendered");
|
||||||
|
}
|
||||||
|
expect(preview).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
|
||||||
function createMedication(overrides: Partial<Medication> = {}): Medication {
|
function createMedication(overrides: Partial<Medication> = {}): Medication {
|
||||||
return {
|
return {
|
||||||
id: 1,
|
id: 1,
|
||||||
@@ -24,6 +46,7 @@ function createMedication(overrides: Partial<Medication> = {}): Medication {
|
|||||||
describe("ReportModal", () => {
|
describe("ReportModal", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders and closes when cancel is clicked", () => {
|
it("renders and closes when cancel is clicked", () => {
|
||||||
@@ -35,35 +58,41 @@ describe("ReportModal", () => {
|
|||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("generates text report and closes modal", async () => {
|
it("generates txt and md previews in-app without closing the modal", async () => {
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
for (const format of ["txt", "md"] as const) {
|
||||||
ok: true,
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
json: async () => ({
|
ok: true,
|
||||||
1: {
|
json: async () => ({
|
||||||
dosesTaken: 2,
|
1: {
|
||||||
dosesSkipped: 0,
|
dosesTaken: 2,
|
||||||
firstDoseAt: "2026-01-01T08:00:00.000Z",
|
automaticDosesTaken: 0,
|
||||||
lastDoseAt: "2026-01-02T08:00:00.000Z",
|
dosesSkipped: 0,
|
||||||
refills: [],
|
firstDoseAt: "2026-01-01T08:00:00.000Z",
|
||||||
},
|
lastDoseAt: "2026-01-02T08:00:00.000Z",
|
||||||
}),
|
refills: [],
|
||||||
});
|
},
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
render(<ReportModal isOpen={true} onClose={onClose} medications={[createMedication()]} />);
|
const view = render(<ReportModal isOpen={true} onClose={onClose} medications={[createMedication()]} />);
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("radio", { name: /report\.formatTxt/i }));
|
fireEvent.click(
|
||||||
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
screen.getByRole("radio", { name: new RegExp(`report\\.format${format === "txt" ? "Txt" : "Md"}`, "i") })
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(global.fetch).toHaveBeenCalledWith(
|
|
||||||
"/api/medications/report-data",
|
|
||||||
expect.objectContaining({ method: "POST" })
|
|
||||||
);
|
);
|
||||||
});
|
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||||
|
|
||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
await waitFor(() => {
|
||||||
expect(URL.createObjectURL).toHaveBeenCalled();
|
expectPreviewToBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /report\.download/i })).toBeInTheDocument();
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
expect(URL.createObjectURL).not.toHaveBeenCalled();
|
||||||
|
expect(getPreviewContent()).toContain("report.docTitle");
|
||||||
|
|
||||||
|
view.unmount();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders shared formatter output in exported text reports", async () => {
|
it("renders shared formatter output in exported text reports", async () => {
|
||||||
@@ -99,18 +128,15 @@ describe("ReportModal", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(URL.createObjectURL).toHaveBeenCalled();
|
expectPreviewToBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
const [blob] = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.calls.at(-1) ?? [];
|
const content = getPreviewContent();
|
||||||
expect(blob).toBeInstanceOf(Blob);
|
|
||||||
|
|
||||||
const content = await (blob as Blob).text();
|
|
||||||
|
|
||||||
expect(content).toContain(formatDate("2026-02-01"));
|
expect(content).toContain(formatDate("2026-02-01"));
|
||||||
expect(content).toContain(formatDateTime("2026-02-02T08:30:00.000Z"));
|
expect(content).toContain(formatDateTime("2026-02-02T08:30:00.000Z"));
|
||||||
expect(content).toContain(formatDate("2026-02-03T12:00:00.000Z"));
|
expect(content).toContain(formatDate("2026-02-03T12:00:00.000Z"));
|
||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exports bottle current stock separately from configured capacity", async () => {
|
it("exports bottle current stock separately from configured capacity", async () => {
|
||||||
@@ -151,16 +177,15 @@ describe("ReportModal", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(URL.createObjectURL).toHaveBeenCalled();
|
expectPreviewToBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
const [blob] = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.calls.at(-1) ?? [];
|
const content = getPreviewContent();
|
||||||
const content = await (blob as Blob).text();
|
|
||||||
|
|
||||||
expect(content).toContain("report.docTotalCapacity: 100");
|
expect(content).toContain("report.docTotalCapacity: 100");
|
||||||
expect(content).toContain("report.docCurrentStock: 70 common.pills");
|
expect(content).toContain("report.docCurrentStock: 70 common.pills");
|
||||||
expect(content).not.toContain("report.docCurrentStock: 100 common.pills");
|
expect(content).not.toContain("report.docCurrentStock: 100 common.pills");
|
||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exports injection refill history with injection unit wording", async () => {
|
it("exports injection refill history with injection unit wording", async () => {
|
||||||
@@ -205,15 +230,14 @@ describe("ReportModal", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(URL.createObjectURL).toHaveBeenCalled();
|
expectPreviewToBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
const [blob] = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.calls.at(-1) ?? [];
|
const content = getPreviewContent();
|
||||||
const content = await (blob as Blob).text();
|
|
||||||
|
|
||||||
expect(content).toContain("report.docCurrentStock: 6 common.injections");
|
expect(content).toContain("report.docCurrentStock: 6 common.injections");
|
||||||
expect(content).toContain("+3 common.injections");
|
expect(content).toContain("+3 common.injections");
|
||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("generates printable report when PDF format is selected", async () => {
|
it("generates printable report when PDF format is selected", async () => {
|
||||||
@@ -288,14 +312,17 @@ describe("ReportModal", () => {
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
medications={[
|
medications={[
|
||||||
createMedication({ id: 1, name: "Alice Med", takenBy: ["Alice"] }),
|
createMedication({ id: 1, name: "Alice Med", takenBy: ["Alice"] }),
|
||||||
createMedication({ id: 2, name: "Bob Med", takenBy: ["Bob"] }),
|
createMedication({ id: 2, name: "Alice Lower", takenBy: ["alice"] }),
|
||||||
|
createMedication({ id: 3, name: "Bob Med", takenBy: ["Bob"] }),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByText(/report\.filterByPerson/i)).toBeInTheDocument();
|
expect(screen.getByText(/report\.filterByPerson/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getAllByRole("checkbox", { name: "Alice" })).toHaveLength(1);
|
||||||
fireEvent.click(screen.getByRole("checkbox", { name: "Alice" }));
|
fireEvent.click(screen.getByRole("checkbox", { name: "Alice" }));
|
||||||
expect(screen.getByText("Alice Med")).toBeInTheDocument();
|
expect(screen.getByText("Alice Med")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Alice Lower")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("Bob Med")).not.toBeInTheDocument();
|
expect(screen.queryByText("Bob Med")).not.toBeInTheDocument();
|
||||||
|
|
||||||
fireEvent.click(screen.getByRole("button", { name: /report\.deselectAll/i }));
|
fireEvent.click(screen.getByRole("button", { name: /report\.deselectAll/i }));
|
||||||
@@ -335,7 +362,8 @@ describe("ReportModal", () => {
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
medications={[
|
medications={[
|
||||||
createMedication({ id: 1, name: "Alice Med", takenBy: ["Alice"] }),
|
createMedication({ id: 1, name: "Alice Med", takenBy: ["Alice"] }),
|
||||||
createMedication({ id: 2, name: "Bob Med", takenBy: ["Bob"] }),
|
createMedication({ id: 2, name: "Alice Lower", takenBy: ["alice"] }),
|
||||||
|
createMedication({ id: 3, name: "Bob Med", takenBy: ["Bob"] }),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -345,15 +373,14 @@ describe("ReportModal", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(global.fetch).toHaveBeenCalledWith(
|
const [, requestInit] = authFetchMock.mock.calls[0] ?? [];
|
||||||
"/api/medications/report-data",
|
const body = JSON.parse((requestInit?.body as string) ?? "{}");
|
||||||
expect.objectContaining({
|
expect(body).toMatchObject({ medicationIds: [1, 2], takenByFilter: ["Alice"] });
|
||||||
method: "POST",
|
expect(typeof body.startDate).toBe("string");
|
||||||
body: JSON.stringify({ medicationIds: [1], takenByFilter: ["Alice"] }),
|
expect(typeof body.endDate).toBe("string");
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
authFetchMock.mockClear();
|
||||||
(global.fetch as ReturnType<typeof vi.fn>).mockClear();
|
(global.fetch as ReturnType<typeof vi.fn>).mockClear();
|
||||||
firstRender.unmount();
|
firstRender.unmount();
|
||||||
render(
|
render(
|
||||||
@@ -362,7 +389,8 @@ describe("ReportModal", () => {
|
|||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
medications={[
|
medications={[
|
||||||
createMedication({ id: 1, name: "Alice Med", takenBy: ["Alice"] }),
|
createMedication({ id: 1, name: "Alice Med", takenBy: ["Alice"] }),
|
||||||
createMedication({ id: 2, name: "Bob Med", takenBy: ["Bob"] }),
|
createMedication({ id: 2, name: "Alice Lower", takenBy: ["alice"] }),
|
||||||
|
createMedication({ id: 3, name: "Bob Med", takenBy: ["Bob"] }),
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -370,17 +398,16 @@ describe("ReportModal", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(global.fetch).toHaveBeenCalledWith(
|
const [, requestInit] = authFetchMock.mock.calls[0] ?? [];
|
||||||
"/api/medications/report-data",
|
const body = JSON.parse((requestInit?.body as string) ?? "{}");
|
||||||
expect.objectContaining({
|
expect(body).toMatchObject({ medicationIds: [1, 2, 3] });
|
||||||
method: "POST",
|
expect(body).not.toHaveProperty("takenByFilter");
|
||||||
body: JSON.stringify({ medicationIds: [1, 2], takenByFilter: undefined }),
|
expect(typeof body.startDate).toBe("string");
|
||||||
})
|
expect(typeof body.endDate).toBe("string");
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("generates markdown report and keeps modal open on fetch error", async () => {
|
it("shows a localized fetch error and keeps the modal open when preview generation fails", async () => {
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: false });
|
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({ ok: false });
|
||||||
|
|
||||||
@@ -390,9 +417,35 @@ describe("ReportModal", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(global.fetch).toHaveBeenCalled();
|
expect(authFetchMock).toHaveBeenCalledWith(
|
||||||
|
"/api/medications/report-data",
|
||||||
|
expect.objectContaining({ method: "POST" })
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(onClose).not.toHaveBeenCalled();
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText(/report\.error/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/report\.preview/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a localized error and skips the request when the date range is invalid", async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(<ReportModal isOpen={true} onClose={onClose} medications={[createMedication()]} />);
|
||||||
|
|
||||||
|
const inputs = screen.getAllByDisplayValue(/\d{2}\.\d{2}\.\d{4}|\d{1,2}\/\d{1,2}\/\d{4}|\d{4}-\d{2}-\d{2}/i);
|
||||||
|
const startInput = inputs[0] as HTMLInputElement;
|
||||||
|
const endInput = inputs[1] as HTMLInputElement;
|
||||||
|
|
||||||
|
fireEvent.change(startInput.parentElement?.querySelector("input") ?? startInput, {
|
||||||
|
target: { value: "2026-02-10T10:00" },
|
||||||
|
});
|
||||||
|
fireEvent.change(endInput.parentElement?.querySelector("input") ?? endInput, {
|
||||||
|
target: { value: "2026-02-10T09:00" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||||
|
|
||||||
|
expect(authFetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText(/report\.invalidDateRange/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,13 +10,21 @@ describe("ShareDialog", () => {
|
|||||||
onShareSelectedPersonChange: vi.fn(),
|
onShareSelectedPersonChange: vi.fn(),
|
||||||
shareSelectedDays: 30,
|
shareSelectedDays: 30,
|
||||||
onShareSelectedDaysChange: vi.fn(),
|
onShareSelectedDaysChange: vi.fn(),
|
||||||
|
shareSelectedExpiryDays: null,
|
||||||
|
onShareSelectedExpiryDaysChange: vi.fn(),
|
||||||
|
shareAllowJournalNotes: false,
|
||||||
|
onShareAllowJournalNotesChange: vi.fn(),
|
||||||
shareGenerating: false,
|
shareGenerating: false,
|
||||||
shareLink: null,
|
shareLink: null,
|
||||||
onShareLinkChange: vi.fn(),
|
onShareLinkChange: vi.fn(),
|
||||||
shareCopied: false,
|
shareCopied: false,
|
||||||
onShareCopiedChange: vi.fn(),
|
onShareCopiedChange: vi.fn(),
|
||||||
|
activeShareLinks: [],
|
||||||
|
activeSharesLoading: false,
|
||||||
|
revokingShareToken: null,
|
||||||
onClose: vi.fn(),
|
onClose: vi.fn(),
|
||||||
onGenerateShareLink: vi.fn(),
|
onGenerateShareLink: vi.fn(),
|
||||||
|
onRevokeShareLink: vi.fn().mockResolvedValue(true),
|
||||||
onCopyShareLink: vi.fn(),
|
onCopyShareLink: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -105,9 +113,13 @@ describe("ShareDialog", () => {
|
|||||||
const selects = screen.getAllByRole("combobox");
|
const selects = screen.getAllByRole("combobox");
|
||||||
fireEvent.change(selects[0], { target: { value: "Bob" } });
|
fireEvent.change(selects[0], { target: { value: "Bob" } });
|
||||||
fireEvent.change(selects[1], { target: { value: "90" } });
|
fireEvent.change(selects[1], { target: { value: "90" } });
|
||||||
|
fireEvent.change(selects[2], { target: { value: "30" } });
|
||||||
|
fireEvent.click(screen.getByLabelText(/share\.allowJournalNotes/i));
|
||||||
|
|
||||||
expect(defaultProps.onShareSelectedPersonChange).toHaveBeenCalledWith("Bob");
|
expect(defaultProps.onShareSelectedPersonChange).toHaveBeenCalledWith("Bob");
|
||||||
expect(defaultProps.onShareSelectedDaysChange).toHaveBeenCalledWith(90);
|
expect(defaultProps.onShareSelectedDaysChange).toHaveBeenCalledWith(90);
|
||||||
|
expect(defaultProps.onShareSelectedExpiryDaysChange).toHaveBeenCalledWith(30);
|
||||||
|
expect(defaultProps.onShareAllowJournalNotesChange).toHaveBeenCalledWith(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("disables generate button when no person is selected", () => {
|
it("disables generate button when no person is selected", () => {
|
||||||
@@ -116,4 +128,58 @@ describe("ShareDialog", () => {
|
|||||||
const generateButton = screen.getByRole("button", { name: /share\.generateLink/i });
|
const generateButton = screen.getByRole("button", { name: /share\.generateLink/i });
|
||||||
expect(generateButton).toBeDisabled();
|
expect(generateButton).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps active share management collapsed until opened", () => {
|
||||||
|
render(
|
||||||
|
<ShareDialog
|
||||||
|
{...defaultProps}
|
||||||
|
activeShareLinks={[
|
||||||
|
{
|
||||||
|
token: "abcdef0123456789",
|
||||||
|
takenBy: "Alice",
|
||||||
|
scheduleDays: 30,
|
||||||
|
createdAt: "2026-05-17T12:00:00.000Z",
|
||||||
|
expiresAt: null,
|
||||||
|
allowJournalNotes: true,
|
||||||
|
shareUrl: "/share/abcdef0123456789",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText(/share\.manageLinksSummary/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: /share\.revoke/i })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText(/share\.manageLinksSummary/i));
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /share\.revoke/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses an in-app confirm modal before revoking an active share link", async () => {
|
||||||
|
render(
|
||||||
|
<ShareDialog
|
||||||
|
{...defaultProps}
|
||||||
|
activeShareLinks={[
|
||||||
|
{
|
||||||
|
token: "abcdef0123456789",
|
||||||
|
takenBy: "Alice",
|
||||||
|
scheduleDays: 30,
|
||||||
|
createdAt: "2026-05-17T12:00:00.000Z",
|
||||||
|
expiresAt: null,
|
||||||
|
allowJournalNotes: true,
|
||||||
|
shareUrl: "/share/abcdef0123456789",
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText(/share\.manageLinksSummary/i));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /share\.revoke/i }));
|
||||||
|
|
||||||
|
expect(screen.getByText(/share\.revokeConfirm/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getAllByRole("button", { name: /share\.revoke/i })[1]);
|
||||||
|
|
||||||
|
expect(defaultProps.onRevokeShareLink).toHaveBeenCalledWith("abcdef0123456789");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ function createSharedDataWithTodayDose(referenceNow: Date) {
|
|||||||
sharedBy: "Owner",
|
sharedBy: "Owner",
|
||||||
takenBy: "Max",
|
takenBy: "Max",
|
||||||
scheduleDays: 30,
|
scheduleDays: 30,
|
||||||
|
allowJournalNotes: false,
|
||||||
automaticDoseId: `1-0-${dateOnlyMs}`,
|
automaticDoseId: `1-0-${dateOnlyMs}`,
|
||||||
medications: [
|
medications: [
|
||||||
{
|
{
|
||||||
@@ -171,17 +172,24 @@ function createSharedDataWithTodayDose(referenceNow: Date) {
|
|||||||
function createSharedDoseFetchMock(options: {
|
function createSharedDoseFetchMock(options: {
|
||||||
token?: string;
|
token?: string;
|
||||||
sharedData: ReturnType<typeof createSharedDataWithTodayDose>;
|
sharedData: ReturnType<typeof createSharedDataWithTodayDose>;
|
||||||
initialDoses?: Array<{ doseId: string; skipped?: boolean; dismissed?: boolean; takenSource?: string }>;
|
initialDoses?: Array<{
|
||||||
|
doseId: string;
|
||||||
|
skipped?: boolean;
|
||||||
|
dismissed?: boolean;
|
||||||
|
takenSource?: string;
|
||||||
|
hasJournalNote?: boolean;
|
||||||
|
}>;
|
||||||
}) {
|
}) {
|
||||||
const token = options.token ?? "token-123";
|
const token = options.token ?? "token-123";
|
||||||
const doseState = new Map((options.initialDoses ?? []).map((dose) => [dose.doseId, { ...dose }]));
|
const doseState = new Map((options.initialDoses ?? []).map((dose) => [dose.doseId, { ...dose }]));
|
||||||
|
const journalState = new Map<string, { note: string | null; createdAt: string | null; updatedAt: string | null }>();
|
||||||
const requests: Array<{ url: string; method: string; body?: unknown }> = [];
|
const requests: Array<{ url: string; method: string; body?: unknown }> = [];
|
||||||
|
|
||||||
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
||||||
const method = init?.method ?? "GET";
|
const method = init?.method ?? "GET";
|
||||||
const body =
|
const body =
|
||||||
typeof init?.body === "string" && init.body.length > 0
|
typeof init?.body === "string" && init.body.length > 0
|
||||||
? (JSON.parse(init.body) as { doseId: string })
|
? (JSON.parse(init.body) as { doseId?: string; note?: string | null })
|
||||||
: undefined;
|
: undefined;
|
||||||
requests.push({ url, method, body });
|
requests.push({ url, method, body });
|
||||||
|
|
||||||
@@ -190,7 +198,11 @@ function createSharedDoseFetchMock(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (url === `/api/share/${token}/doses` && method === "GET") {
|
if (url === `/api/share/${token}/doses` && method === "GET") {
|
||||||
return { ok: true, json: async () => ({ doses: Array.from(doseState.values()) }) };
|
const doses = Array.from(doseState.values()).map((dose) => ({
|
||||||
|
...dose,
|
||||||
|
hasJournalNote: dose.hasJournalNote === true || Boolean(journalState.get(dose.doseId)?.note?.trim()),
|
||||||
|
}));
|
||||||
|
return { ok: true, json: async () => ({ doses }) };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (url === `/api/share/${token}/doses/skip` && method === "POST" && body?.doseId) {
|
if (url === `/api/share/${token}/doses/skip` && method === "POST" && body?.doseId) {
|
||||||
@@ -203,6 +215,61 @@ function createSharedDoseFetchMock(options: {
|
|||||||
return { ok: true, json: async () => ({}) };
|
return { ok: true, json: async () => ({}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (url.startsWith(`/api/share/${token}/journal/event/`) && method === "GET") {
|
||||||
|
const doseId = decodeURIComponent(url.split("/").at(-1) ?? "");
|
||||||
|
const journal = journalState.get(doseId) ?? { note: null, createdAt: null, updatedAt: null };
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
entry: {
|
||||||
|
doseTrackingId: 1,
|
||||||
|
doseId,
|
||||||
|
medicationId: 1,
|
||||||
|
medicationName: "Ibuprofen",
|
||||||
|
scheduledFor: new Date().toISOString(),
|
||||||
|
takenAt: new Date().toISOString(),
|
||||||
|
dismissed: false,
|
||||||
|
takenSource: "manual",
|
||||||
|
markedBy: "Max",
|
||||||
|
note: journal.note,
|
||||||
|
createdAt: journal.createdAt,
|
||||||
|
updatedAt: journal.updatedAt,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.startsWith(`/api/share/${token}/journal/event/`) && method === "PUT") {
|
||||||
|
const doseId = decodeURIComponent(url.split("/").at(-1) ?? "");
|
||||||
|
const timestamp = new Date().toISOString();
|
||||||
|
journalState.set(doseId, { note: body?.note ?? null, createdAt: timestamp, updatedAt: timestamp });
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
entry: {
|
||||||
|
doseTrackingId: 1,
|
||||||
|
doseId,
|
||||||
|
medicationId: 1,
|
||||||
|
medicationName: "Ibuprofen",
|
||||||
|
scheduledFor: new Date().toISOString(),
|
||||||
|
takenAt: new Date().toISOString(),
|
||||||
|
dismissed: false,
|
||||||
|
takenSource: "manual",
|
||||||
|
markedBy: "Max",
|
||||||
|
note: body?.note ?? null,
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.startsWith(`/api/share/${token}/journal/event/`) && method === "DELETE") {
|
||||||
|
const doseId = decodeURIComponent(url.split("/").at(-1) ?? "");
|
||||||
|
journalState.delete(doseId);
|
||||||
|
return { ok: true, json: async () => ({ success: true }) };
|
||||||
|
}
|
||||||
|
|
||||||
if (url.startsWith(`/api/share/${token}/doses/skip/`) && method === "DELETE") {
|
if (url.startsWith(`/api/share/${token}/doses/skip/`) && method === "DELETE") {
|
||||||
const doseId = decodeURIComponent(url.split("/").at(-1) ?? "");
|
const doseId = decodeURIComponent(url.split("/").at(-1) ?? "");
|
||||||
doseState.delete(doseId);
|
doseState.delete(doseId);
|
||||||
@@ -244,10 +311,109 @@ describe("SharedSchedule", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument();
|
expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("share.publicAccessHelp")).toBeInTheDocument();
|
||||||
expect(screen.getByText("share.noSchedule")).toBeInTheDocument();
|
expect(screen.getByText("share.noSchedule")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens and saves a shared journal note when the share link allows notes", async () => {
|
||||||
|
const referenceNow = new Date();
|
||||||
|
referenceNow.setHours(12, 0, 0, 0);
|
||||||
|
vi.spyOn(Date, "now").mockReturnValue(referenceNow.getTime());
|
||||||
|
const sharedData = {
|
||||||
|
...createSharedDataWithTodayDose(referenceNow),
|
||||||
|
allowJournalNotes: true,
|
||||||
|
};
|
||||||
|
const { fetchMock, requests } = createSharedDoseFetchMock({
|
||||||
|
sharedData,
|
||||||
|
});
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||||
|
|
||||||
|
renderSharedSchedule("/share/token-123");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(document.querySelector(".dose-btn.take")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const unavailableJournalButton = document.querySelector(".dose-btn.journal") as HTMLButtonElement;
|
||||||
|
expect(unavailableJournalButton).toBeDisabled();
|
||||||
|
expect(unavailableJournalButton).not.toHaveClass("has-note");
|
||||||
|
expect(unavailableJournalButton.closest("span")).toHaveAttribute("data-tooltip", "journal.actions.noteTakenOnly");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByText("dose.take"));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(requests).toContainEqual({
|
||||||
|
url: "/api/share/token-123/doses",
|
||||||
|
method: "POST",
|
||||||
|
body: { doseId: sharedData.automaticDoseId },
|
||||||
|
});
|
||||||
|
expect(document.querySelector(".day-block.today")).not.toHaveClass("collapsed");
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const availableJournalButton = document.querySelector(".dose-btn.journal") as HTMLButtonElement;
|
||||||
|
expect(availableJournalButton).not.toBeDisabled();
|
||||||
|
expect(availableJournalButton).not.toHaveClass("has-note");
|
||||||
|
expect(availableJournalButton.closest("span")).not.toHaveAttribute("data-tooltip");
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(document.querySelector(".dose-btn.journal") as Element);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(requests).toContainEqual({
|
||||||
|
url: `/api/share/token-123/journal/event/${sharedData.automaticDoseId}`,
|
||||||
|
method: "GET",
|
||||||
|
body: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByLabelText("journal.editor.noteLabel")).toHaveValue("");
|
||||||
|
});
|
||||||
|
expect(screen.queryByRole("button", { name: "common.delete" })).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("journal.editor.noteLabel"), { target: { value: "Shared note" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "common.save" }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(requests).toContainEqual({
|
||||||
|
url: `/api/share/token-123/journal/event/${sharedData.automaticDoseId}`,
|
||||||
|
method: "PUT",
|
||||||
|
body: { note: "Shared note" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByLabelText("journal.editor.noteLabel")).not.toBeInTheDocument();
|
||||||
|
const savedJournalButton = document.querySelector(".dose-btn.journal") as HTMLButtonElement;
|
||||||
|
expect(savedJournalButton).toHaveClass("has-note");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks shared journal notes from the shared dose read state", async () => {
|
||||||
|
const referenceNow = new Date();
|
||||||
|
referenceNow.setHours(12, 0, 0, 0);
|
||||||
|
vi.spyOn(Date, "now").mockReturnValue(referenceNow.getTime());
|
||||||
|
const sharedData = {
|
||||||
|
...createSharedDataWithTodayDose(referenceNow),
|
||||||
|
allowJournalNotes: true,
|
||||||
|
};
|
||||||
|
const { fetchMock } = createSharedDoseFetchMock({
|
||||||
|
sharedData,
|
||||||
|
initialDoses: [{ doseId: sharedData.automaticDoseId, takenSource: "manual", hasJournalNote: true }],
|
||||||
|
});
|
||||||
|
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||||
|
|
||||||
|
renderSharedSchedule("/share/token-123");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const journalButton = document.querySelector(".dose-btn.journal") as HTMLButtonElement;
|
||||||
|
expect(journalButton).not.toBeDisabled();
|
||||||
|
expect(journalButton).toHaveClass("has-note");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("renders not found state for missing share link", async () => {
|
it("renders not found state for missing share link", async () => {
|
||||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
|
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
|
||||||
if (url === "/api/share/token-123/doses") {
|
if (url === "/api/share/token-123/doses") {
|
||||||
|
|||||||
@@ -275,7 +275,7 @@ describe("UserFilterModal", () => {
|
|||||||
const meds: Medication[] = [
|
const meds: Medication[] = [
|
||||||
{ ...mockMedication, id: 1, name: "Med1", takenBy: ["John"] },
|
{ ...mockMedication, id: 1, name: "Med1", takenBy: ["John"] },
|
||||||
{ ...mockMedication, id: 2, name: "Med2", takenBy: ["Jane"] },
|
{ ...mockMedication, id: 2, name: "Med2", takenBy: ["Jane"] },
|
||||||
{ ...mockMedication, id: 3, name: "Med3", takenBy: ["John", "Jane"] },
|
{ ...mockMedication, id: 3, name: "Med3", takenBy: ["john", "Jane"] },
|
||||||
];
|
];
|
||||||
|
|
||||||
render(
|
render(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user