Compare commits
16 Commits
v1.25.0
...
cedd62223c
| Author | SHA1 | Date | |
|---|---|---|---|
| cedd62223c | |||
| 47d230ace2 | |||
| 812b14df03 | |||
| c78fc43083 | |||
| e4a1b449c6 | |||
| 767ae23843 | |||
| 3eb56885f9 | |||
| c5b08b28c1 | |||
| 1eb7579706 | |||
| e69e46f9fc | |||
| 1f5dd36b5c | |||
| 545793fdd2 | |||
| 2f5fc2d9e9 | |||
| 4212469cd5 | |||
| db602d8360 | |||
| a95c6e3657 |
@@ -10,6 +10,8 @@ PUID=1000
|
||||
PGID=1000
|
||||
|
||||
PORT=3000
|
||||
# Docker Compose quickstart serves the frontend on http://localhost:4174.
|
||||
# Local Vite development usually uses http://localhost:5173 or http://localhost:4173 instead.
|
||||
CORS_ORIGINS=http://localhost:4174
|
||||
|
||||
# Server default timezone for scheduled reminders.
|
||||
@@ -18,8 +20,11 @@ TZ=Europe/Berlin
|
||||
|
||||
# Public base URL used for notification action links.
|
||||
# Required for intake reminder action buttons.
|
||||
# Use an externally reachable HTTPS URL for remote/self-hosted access.
|
||||
# PUBLIC_APP_URL=https://medassist.example.com
|
||||
# If this uses a non-local host, include that origin in CORS_ORIGINS.
|
||||
# Local Vite development automatically allows this hostname; set
|
||||
# VITE_ALLOWED_HOSTS only when you need additional development hostnames.
|
||||
|
||||
# Log level: debug, info, warn, error, silent
|
||||
LOG_LEVEL=info
|
||||
|
||||
@@ -1,26 +1,13 @@
|
||||
# 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**
|
||||
- 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.
|
||||
If rules differ between files, follow `AGENTS.md`.
|
||||
|
||||
## Required Startup Steps
|
||||
|
||||
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.
|
||||
3. Follow delegation boundaries exactly (`@testing-manager` for testing, `@release-manager` for release orchestration).
|
||||
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.
|
||||
|
||||
## Scope
|
||||
|
||||
This file intentionally stays minimal to prevent duplicated or conflicting instructions.
|
||||
2. Ensure `doku/memory_notes.md` and `doku/report.md` exist and keep them updated during meaningful work. These files are local-only and must not be staged or committed unless explicitly requested.
|
||||
3. Identify triggered skills from `AGENTS.md` and read only the matching `SKILL.md` files before making changes.
|
||||
4. Follow delegation boundaries from `AGENTS.md`: `@testing-manager` for testing work and `@release-manager` for release orchestration, including the documented fallback protocol when a required specialist is unavailable.
|
||||
5. Keep all non-canonical instruction files brief and aligned with `AGENTS.md`; do not duplicate full governance here.
|
||||
|
||||
@@ -24,6 +24,8 @@ on:
|
||||
|
||||
concurrency:
|
||||
group: docker-build-${{ github.ref }}
|
||||
# Cancel older runs on the same ref so the shared branch tag stays aligned
|
||||
# with the newest commit instead of racing older builds against newer ones.
|
||||
cancel-in-progress: true
|
||||
|
||||
# Default minimal permissions
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
</p>
|
||||
|
||||
<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/Frontend_Tests-919%2F919-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||
<img src="https://img.shields.io/badge/Backend_Tests-715%2F715-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||
<img src="https://img.shields.io/badge/Frontend_Tests-949%2F949-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||
</p>
|
||||
|
||||
### 🤖 AI-Generated Code
|
||||
@@ -157,10 +157,13 @@ Share your medication schedule with others via a public link.
|
||||
### Multi-Person Support
|
||||
- Manage medications for multiple people
|
||||
- Share schedules via link. Recipients can mark doses as taken, you see it live
|
||||
- Optionally allow shared links to view and edit intake journal notes for their visible schedule window
|
||||
- Optionally embed the medication overview directly on shared links via a settings toggle
|
||||
|
||||
### Data Export & Import
|
||||
- Export all your data (medications, dose history, settings) as JSON
|
||||
- Export all your data (medications, dose history, intake journal notes, settings) as JSON
|
||||
- Review validated import contents before replacing current data
|
||||
- Optionally download a fresh backup before confirming import
|
||||
- Import previously exported data with automatic ID remapping
|
||||
- Choose whether to include sensitive data in exports
|
||||
|
||||
@@ -188,6 +191,16 @@ docker compose -p medassist-ng up -d
|
||||
|
||||
Open `http://localhost:4174` and start tracking your medications.
|
||||
|
||||
### Verify Deployment
|
||||
|
||||
After the containers start, confirm the stack is actually healthy:
|
||||
|
||||
1. Run `docker compose ps` and confirm the `backend` service is `healthy` and the `frontend` service is running.
|
||||
2. Open `http://localhost:3000/health` and confirm the backend responds with JSON that includes `"status":"ok"`.
|
||||
3. Open `http://localhost:4174` and confirm the app shell loads and can reach the API.
|
||||
|
||||
If the frontend loads but API requests fail, check the backend health endpoint first and confirm `CORS_ORIGINS` includes the frontend origin you are using. If you plan to open reminder or share links from another device, set `PUBLIC_APP_URL` to the externally reachable app URL instead of relying on `localhost`.
|
||||
|
||||
# Configuration
|
||||
|
||||
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 |
|
||||
|----------|---------|-------------|
|
||||
| `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:
|
||||
|
||||
@@ -218,6 +231,13 @@ Detailed configuration references:
|
||||
|
||||
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
|
||||
|
||||
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,
|
||||
"tag": "0014_add_user_settings_timezone",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "6",
|
||||
"when": 1778962021119,
|
||||
"tag": "0015_add_intake_journal",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 16,
|
||||
"version": "6",
|
||||
"when": 1779044316043,
|
||||
"tag": "0016_add_share_allow_journal_notes",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+161
-162
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.23.0",
|
||||
"version": "1.26.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.23.0",
|
||||
"version": "1.26.0",
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
@@ -32,14 +32,14 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.15",
|
||||
"@types/node": "^25.6.2",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"supertest": "^7.2.2",
|
||||
"tsx": "^4.19.0",
|
||||
"tsx": "^4.22.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.0.16"
|
||||
}
|
||||
@@ -1862,9 +1862,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.127.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
|
||||
"integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
|
||||
"version": "0.130.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
|
||||
"integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -1897,9 +1897,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
|
||||
"integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1914,9 +1914,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
|
||||
"integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1931,9 +1931,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
|
||||
"integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1948,9 +1948,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
|
||||
"integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -1965,9 +1965,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
|
||||
"integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -1982,9 +1982,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
|
||||
"integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -1999,9 +1999,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
|
||||
"integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2016,9 +2016,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
|
||||
"integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -2033,9 +2033,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
|
||||
"integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -2050,9 +2050,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
|
||||
"integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2067,9 +2067,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
|
||||
"integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2084,9 +2084,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
|
||||
"integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2101,9 +2101,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
|
||||
"integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -2120,9 +2120,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
|
||||
"integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -2137,9 +2137,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
|
||||
"integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -2154,9 +2154,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -2168,9 +2168,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -2218,12 +2218,12 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz",
|
||||
"integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==",
|
||||
"version": "25.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
|
||||
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/nodemailer": {
|
||||
@@ -2270,14 +2270,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz",
|
||||
"integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz",
|
||||
"integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.5",
|
||||
"@vitest/utils": "4.1.6",
|
||||
"ast-v8-to-istanbul": "^1.0.0",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
@@ -2291,8 +2291,8 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.5",
|
||||
"vitest": "4.1.5"
|
||||
"@vitest/browser": "4.1.6",
|
||||
"vitest": "4.1.6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
@@ -2301,16 +2301,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
|
||||
"integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
|
||||
"integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.5",
|
||||
"@vitest/utils": "4.1.5",
|
||||
"@vitest/spy": "4.1.6",
|
||||
"@vitest/utils": "4.1.6",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -2319,13 +2319,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
|
||||
"integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
|
||||
"integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.5",
|
||||
"@vitest/spy": "4.1.6",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -2346,9 +2346,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
|
||||
"integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz",
|
||||
"integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2359,13 +2359,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
|
||||
"integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz",
|
||||
"integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.5",
|
||||
"@vitest/utils": "4.1.6",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -2373,14 +2373,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
|
||||
"integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz",
|
||||
"integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.5",
|
||||
"@vitest/utils": "4.1.5",
|
||||
"@vitest/pretty-format": "4.1.6",
|
||||
"@vitest/utils": "4.1.6",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -2389,9 +2389,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
|
||||
"integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz",
|
||||
"integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -2399,13 +2399,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
|
||||
"integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz",
|
||||
"integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.5",
|
||||
"@vitest/pretty-format": "4.1.6",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -2536,9 +2536,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
@@ -4168,9 +4168,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -4411,9 +4411,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.12",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.12.tgz",
|
||||
"integrity": "sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==",
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -4473,9 +4473,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
@@ -4548,14 +4548,14 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
|
||||
"integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.127.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.17"
|
||||
"@oxc-project/types": "=0.130.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
@@ -4564,21 +4564,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0-rc.17",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0-rc.17",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0-rc.17",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0-rc.17",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.17",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0-rc.17"
|
||||
"@rolldown/binding-android-arm64": "1.0.1",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.1",
|
||||
"@rolldown/binding-darwin-x64": "1.0.1",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.1",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.1",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.1",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.1",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.1",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.1",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.1",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.1",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.1",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.1",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-regex2": {
|
||||
@@ -5032,14 +5032,13 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.1.tgz",
|
||||
"integrity": "sha512-TvncJykhxAzFCk0VQZKBTClall4Pm7qXDSodb6uxi8QFa8X8mT6ABjxxsQ2opDRYxG7AzcRWXaFtruz5HJKuWg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
@@ -5080,9 +5079,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.19.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vary": {
|
||||
@@ -5095,16 +5094,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.10",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
|
||||
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
|
||||
"version": "8.0.13",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
|
||||
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.10",
|
||||
"rolldown": "1.0.0-rc.17",
|
||||
"postcss": "^8.5.14",
|
||||
"rolldown": "1.0.1",
|
||||
"tinyglobby": "^0.2.16"
|
||||
},
|
||||
"bin": {
|
||||
@@ -5121,7 +5120,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@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",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
@@ -5173,19 +5172,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
|
||||
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
|
||||
"integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.5",
|
||||
"@vitest/mocker": "4.1.5",
|
||||
"@vitest/pretty-format": "4.1.5",
|
||||
"@vitest/runner": "4.1.5",
|
||||
"@vitest/snapshot": "4.1.5",
|
||||
"@vitest/spy": "4.1.5",
|
||||
"@vitest/utils": "4.1.5",
|
||||
"@vitest/expect": "4.1.6",
|
||||
"@vitest/mocker": "4.1.6",
|
||||
"@vitest/pretty-format": "4.1.6",
|
||||
"@vitest/runner": "4.1.6",
|
||||
"@vitest/snapshot": "4.1.6",
|
||||
"@vitest/spy": "4.1.6",
|
||||
"@vitest/utils": "4.1.6",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -5213,12 +5212,12 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.5",
|
||||
"@vitest/browser-preview": "4.1.5",
|
||||
"@vitest/browser-webdriverio": "4.1.5",
|
||||
"@vitest/coverage-istanbul": "4.1.5",
|
||||
"@vitest/coverage-v8": "4.1.5",
|
||||
"@vitest/ui": "4.1.5",
|
||||
"@vitest/browser-playwright": "4.1.6",
|
||||
"@vitest/browser-preview": "4.1.6",
|
||||
"@vitest/browser-webdriverio": "4.1.6",
|
||||
"@vitest/coverage-istanbul": "4.1.6",
|
||||
"@vitest/coverage-v8": "4.1.6",
|
||||
"@vitest/ui": "4.1.6",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
@@ -5302,9 +5301,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"version": "8.20.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.25.0",
|
||||
"version": "1.26.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -41,14 +41,14 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.15",
|
||||
"@types/node": "^25.6.2",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"drizzle-kit": "^0.31.10",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"supertest": "^7.2.2",
|
||||
"tsx": "^4.19.0",
|
||||
"tsx": "^4.22.1",
|
||||
"typescript": "^6.0.3",
|
||||
"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_med_names text`,
|
||||
`ALTER TABLE refill_history ADD COLUMN used_prescription integer NOT NULL DEFAULT 0`,
|
||||
`ALTER TABLE share_tokens ADD COLUMN allow_journal_notes integer NOT NULL DEFAULT 0`,
|
||||
];
|
||||
|
||||
for (const sql of alterMigrations) {
|
||||
@@ -97,6 +98,16 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
||||
loose_pills_added INTEGER NOT NULL DEFAULT 0,
|
||||
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS intake_journal (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
dose_tracking_id INTEGER NOT NULL REFERENCES dose_tracking(id) ON DELETE CASCADE,
|
||||
medication_id INTEGER NOT NULL REFERENCES medications(id) ON DELETE CASCADE,
|
||||
scheduled_for INTEGER NOT NULL,
|
||||
note TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS notification_action_groups (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -164,6 +175,7 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
||||
const createIndexMigrations = [
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS intake_journal_dose_tracking_id_unique ON intake_journal(dose_tracking_id)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS notification_action_groups_group_key_unique ON notification_action_groups(group_key)`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS notification_action_tokens_token_hash_unique ON notification_action_tokens(token_hash)`,
|
||||
`CREATE INDEX IF NOT EXISTS api_keys_user_id_idx ON api_keys(user_id)`,
|
||||
|
||||
@@ -100,6 +100,7 @@ export function getTableCreationSQL(): string[] {
|
||||
token text NOT NULL UNIQUE,
|
||||
taken_by text NOT NULL,
|
||||
schedule_days integer NOT NULL DEFAULT 30,
|
||||
allow_journal_notes integer NOT NULL DEFAULT 0,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
expires_at integer,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
|
||||
@@ -180,6 +180,7 @@ export const shareTokens = sqliteTable("share_tokens", {
|
||||
token: text("token", { length: 64 }).notNull().unique(),
|
||||
takenBy: text("taken_by", { length: 100 }).notNull(),
|
||||
scheduleDays: integer("schedule_days").notNull().default(30),
|
||||
allowJournalNotes: integer("allow_journal_notes", { mode: "boolean" }).notNull().default(false),
|
||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
||||
expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// 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
|
||||
// =============================================================================
|
||||
|
||||
@@ -21,6 +21,7 @@ import { authRoutes } from "./routes/auth.js";
|
||||
import { doseRoutes } from "./routes/doses.js";
|
||||
import { exportRoutes } from "./routes/export.js";
|
||||
import { healthRoutes } from "./routes/health.js";
|
||||
import { intakeJournalRoutes } from "./routes/intake-journal.js";
|
||||
import { medicationEnrichmentRoutes } from "./routes/medication-enrichment.js";
|
||||
import { medicationRoutes } from "./routes/medications.js";
|
||||
import { notificationActionRoutes } from "./routes/notification-actions.js";
|
||||
@@ -109,6 +110,7 @@ async function registerApiDocs(app: FastifyInstance, enabled: boolean) {
|
||||
{ name: "health", description: "Service health endpoints" },
|
||||
{ name: "auth", description: "Authentication and profile endpoints" },
|
||||
{ name: "api-keys", description: "Programmatic API key management" },
|
||||
{ name: "intake-journal", description: "Owner-only intake journal CRUD and history endpoints" },
|
||||
{ name: "medication-enrichment", description: "Medication search and enrichment endpoints" },
|
||||
{ name: "settings", description: "User settings and notification test endpoints" },
|
||||
],
|
||||
@@ -248,6 +250,7 @@ export async function createApp(options?: {
|
||||
await app.register(notificationActionRoutes);
|
||||
await app.register(shareRoutes);
|
||||
await app.register(doseRoutes);
|
||||
await app.register(intakeJournalRoutes);
|
||||
await app.register(exportRoutes);
|
||||
await app.register(refillRoutes);
|
||||
await app.register(reportRoutes);
|
||||
@@ -349,6 +352,7 @@ await app.register(plannerRoutes);
|
||||
await app.register(notificationActionRoutes);
|
||||
await app.register(shareRoutes);
|
||||
await app.register(doseRoutes);
|
||||
await app.register(intakeJournalRoutes);
|
||||
await app.register(exportRoutes);
|
||||
await app.register(refillRoutes);
|
||||
await app.register(reportRoutes);
|
||||
|
||||
@@ -136,7 +136,7 @@ async function tryApiKeyAuth(request: FastifyRequest, reply: FastifyReply): Prom
|
||||
}
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.id, keyRow.userId));
|
||||
if (!user || !user.isActive) {
|
||||
if (!user?.isActive) {
|
||||
reply.status(401).send({ error: "User not found", code: "USER_NOT_FOUND" });
|
||||
throw new Error("USER_NOT_FOUND");
|
||||
}
|
||||
|
||||
@@ -438,7 +438,7 @@ export async function authRoutes(app: FastifyInstance) {
|
||||
|
||||
// Get user
|
||||
const [user] = await db.select().from(users).where(eq(users.id, decoded.sub));
|
||||
if (!user || !user.isActive) {
|
||||
if (!user?.isActive) {
|
||||
return reply.status(401).send({ error: "User not found or disabled", code: "USER_INVALID" });
|
||||
}
|
||||
|
||||
|
||||
+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 { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { doseTracking, medications, shareTokens, userSettings } from "../db/schema.js";
|
||||
import { doseTracking, intakeJournal, medications, shareTokens, userSettings } from "../db/schema.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import { computeMedicationCurrentStock } from "../services/current-stock.js";
|
||||
import { 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 { toLocalDateTimeOffsetString } from "../utils/local-date-time.js";
|
||||
import {
|
||||
applyOpenApiRouteStandards,
|
||||
genericErrorSchema,
|
||||
tokenParamsSchema,
|
||||
validationErrorSchema,
|
||||
} from "../utils/openapi-route-standards.js";
|
||||
import { redactTokenForLog } from "../utils/redaction.js";
|
||||
import {
|
||||
parseIntakesJson,
|
||||
parseLocalDateTime,
|
||||
@@ -32,6 +39,10 @@ const shareDoseSchema = z.object({
|
||||
doseId: z.string().min(1, "doseId is required"),
|
||||
});
|
||||
|
||||
const shareJournalUpsertSchema = z.object({
|
||||
note: z.string().max(4000),
|
||||
});
|
||||
|
||||
const dismissDosesSchema = z.object({
|
||||
doseIds: z.array(z.string().min(1)).min(1, "At least one doseId is required"),
|
||||
});
|
||||
@@ -56,12 +67,52 @@ const doseReadResponseSchema = {
|
||||
markedBy: { type: ["string", "null"] },
|
||||
takenSource: { type: "string" },
|
||||
dismissed: { type: "boolean" },
|
||||
hasJournalNote: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
const shareJournalEntrySchema = {
|
||||
type: "object",
|
||||
required: [
|
||||
"doseTrackingId",
|
||||
"doseId",
|
||||
"medicationId",
|
||||
"medicationName",
|
||||
"scheduledFor",
|
||||
"dismissed",
|
||||
"takenSource",
|
||||
"note",
|
||||
"updatedAt",
|
||||
],
|
||||
properties: {
|
||||
doseTrackingId: { type: "integer" },
|
||||
doseId: { type: "string" },
|
||||
medicationId: { type: "integer" },
|
||||
medicationName: { type: "string" },
|
||||
scheduledFor: { type: "string", format: "date-time" },
|
||||
takenAt: { type: ["string", "null"], format: "date-time" },
|
||||
dismissed: { type: "boolean" },
|
||||
takenSource: { type: "string", enum: ["manual", "automatic"] },
|
||||
markedBy: { type: ["string", "null"] },
|
||||
note: { type: ["string", "null"] },
|
||||
updatedAt: { type: ["string", "null"], format: "date-time" },
|
||||
createdAt: { type: ["string", "null"], format: "date-time" },
|
||||
},
|
||||
additionalProperties: false,
|
||||
} as const;
|
||||
|
||||
const shareJournalResponseSchema = {
|
||||
type: "object",
|
||||
required: ["entry"],
|
||||
properties: {
|
||||
entry: shareJournalEntrySchema,
|
||||
},
|
||||
additionalProperties: false,
|
||||
} as const;
|
||||
|
||||
function getValidationErrorMessage(error: z.ZodError): string {
|
||||
const firstIssue = error.issues[0];
|
||||
if (!firstIssue) {
|
||||
@@ -71,6 +122,18 @@ function getValidationErrorMessage(error: z.ZodError): string {
|
||||
return firstIssue.code === "invalid_type" && firstIssue.input === undefined ? "Required" : firstIssue.message;
|
||||
}
|
||||
|
||||
function serializeJournalTakenAt(value: Date | null, dismissed: boolean): string | null {
|
||||
if (!(value instanceof Date) || Number.isNaN(value.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (dismissed && value.getTime() <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.toISOString();
|
||||
}
|
||||
|
||||
// Helper to get user ID from request
|
||||
// Returns anonymous user ID when auth is disabled
|
||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||
@@ -135,6 +198,10 @@ async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseI
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isDoseInsideShareScheduleWindow(share, parsedDose)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [medication] = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
@@ -172,6 +239,24 @@ async function validateShareDoseId(share: typeof shareTokens.$inferSelect, doseI
|
||||
return expectedPersons.includes(parsedDose.personSuffix);
|
||||
}
|
||||
|
||||
function getLocalDayStartMs(value: Date | number): number {
|
||||
const date = typeof value === "number" ? new Date(value) : new Date(value.getTime());
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date.getTime();
|
||||
}
|
||||
|
||||
function isDoseInsideShareScheduleWindow(share: typeof shareTokens.$inferSelect, parsedDose: ParsedDoseId): boolean {
|
||||
const scheduleDays = Math.max(1, share.scheduleDays ?? 30);
|
||||
const todayStart = getLocalDayStartMs(new Date());
|
||||
const earliestVisible = new Date(todayStart);
|
||||
earliestVisible.setDate(earliestVisible.getDate() - (scheduleDays - 1));
|
||||
const latestVisibleExclusive = new Date(todayStart);
|
||||
latestVisibleExclusive.setDate(latestVisibleExclusive.getDate() + scheduleDays);
|
||||
const doseDayStart = getLocalDayStartMs(parsedDose.timestampMs);
|
||||
|
||||
return doseDayStart >= earliestVisible.getTime() && doseDayStart < latestVisibleExclusive.getTime();
|
||||
}
|
||||
|
||||
async function isDoseOutOfStock(options: {
|
||||
userId: number;
|
||||
doseId: string;
|
||||
@@ -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
|
||||
// =============================================================================
|
||||
@@ -233,7 +393,13 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
applyOpenApiRouteStandards(app, {
|
||||
tag: "doses",
|
||||
protectedByDefault: false,
|
||||
protectedPaths: [/^\/doses\/taken$/, /^\/doses\/taken\/:doseId$/, /^\/doses\/dismiss$/],
|
||||
protectedPaths: [
|
||||
/^\/doses\/taken$/,
|
||||
/^\/doses\/taken\/:doseId$/,
|
||||
/^\/doses\/dismiss$/,
|
||||
/^\/doses\/skip$/,
|
||||
/^\/doses\/skip\/:doseId$/,
|
||||
],
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -431,27 +674,8 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
// becomes dismissed, regardless of whether it already has a taken timestamp.
|
||||
let dismissedCount = 0;
|
||||
for (const doseId of doseIds) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.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,
|
||||
});
|
||||
const status = await markDoseSkippedForUser({ userId, doseId });
|
||||
if (status !== "already_skipped") {
|
||||
dismissedCount++;
|
||||
}
|
||||
}
|
||||
@@ -533,28 +757,332 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { token } = request.params;
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareDose] Rejected read: token=${token}, reason=${reason}`);
|
||||
request.log.warn(`[ShareDose] Rejected read: tokenRef=${tokenRef}, reason=${reason}`);
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
// Get all taken doses for this user (no time limit)
|
||||
// Keep public dose reads scoped to the selected share person and visible schedule window.
|
||||
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, share.userId));
|
||||
const visibleDoses: (typeof doseTracking.$inferSelect)[] = [];
|
||||
for (const dose of doses) {
|
||||
if (await validateShareDoseId(share, dose.doseId)) {
|
||||
visibleDoses.push(dose);
|
||||
}
|
||||
}
|
||||
|
||||
const journalDoseTrackingIds = new Set<number>();
|
||||
if ((share.allowJournalNotes ?? false) && visibleDoses.length > 0) {
|
||||
const journalRows = await db
|
||||
.select({ doseTrackingId: intakeJournal.doseTrackingId })
|
||||
.from(intakeJournal)
|
||||
.where(
|
||||
and(
|
||||
eq(intakeJournal.userId, share.userId),
|
||||
inArray(
|
||||
intakeJournal.doseTrackingId,
|
||||
visibleDoses.map((dose) => dose.id)
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
for (const row of journalRows) {
|
||||
journalDoseTrackingIds.add(row.doseTrackingId);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
doses: doses.map((d) => ({
|
||||
doses: visibleDoses.map((d) => ({
|
||||
doseId: d.doseId,
|
||||
takenAt: d.takenAt?.getTime() ?? Date.now(),
|
||||
markedBy: d.markedBy,
|
||||
takenSource: d.takenSource ?? "manual",
|
||||
dismissed: d.dismissed ?? false,
|
||||
hasJournalNote: journalDoseTrackingIds.has(d.id),
|
||||
})),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
app.get<{ Params: { token: string; doseId: string } }>(
|
||||
"/share/:token/journal/event/:doseId",
|
||||
{
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["token", "doseId"],
|
||||
properties: {
|
||||
token: tokenParamsSchema.properties.token,
|
||||
doseId: { type: "string", minLength: 1 },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: shareJournalResponseSchema,
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
403: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { token, doseId } = request.params;
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareJournal] Rejected read: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
if (!(share.allowJournalNotes ?? false)) {
|
||||
return reply
|
||||
.status(403)
|
||||
.send({ error: "Journal notes are not enabled for this share link", code: "NOT_ENABLED" });
|
||||
}
|
||||
|
||||
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
||||
if (!isValidShareDoseId) {
|
||||
return reply.status(400).send({ error: "Invalid or unauthorized doseId", code: "INVALID_DOSE" });
|
||||
}
|
||||
|
||||
const event = await resolveTrackedDoseEventForUser({ userId: share.userId, doseId });
|
||||
if (!event) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ error: "Tracked dose event not found for this share link", code: "DOSE_NOT_FOUND" });
|
||||
}
|
||||
|
||||
const journalEntry = await getIntakeJournalForDoseEvent({ userId: share.userId, doseId });
|
||||
return { entry: buildSharedJournalEntryDto({ event, journalEntry }) };
|
||||
}
|
||||
);
|
||||
|
||||
app.put<{ Params: { token: string; doseId: string }; Body: z.infer<typeof shareJournalUpsertSchema> }>(
|
||||
"/share/:token/journal/event/:doseId",
|
||||
{
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["token", "doseId"],
|
||||
properties: {
|
||||
token: tokenParamsSchema.properties.token,
|
||||
doseId: { type: "string", minLength: 1 },
|
||||
},
|
||||
},
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["note"],
|
||||
properties: {
|
||||
note: { type: "string", maxLength: 4000 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
response: {
|
||||
200: shareJournalResponseSchema,
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
403: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { token, doseId } = request.params;
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
|
||||
const parsed = shareJournalUpsertSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error), code: "VALIDATION_ERROR" });
|
||||
}
|
||||
|
||||
const normalizedNote = parsed.data.note.trim();
|
||||
if (normalizedNote.length === 0) {
|
||||
return reply.status(400).send({ error: "Journal note cannot be empty", code: "EMPTY_NOTE" });
|
||||
}
|
||||
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareJournal] Rejected save: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
if (!(share.allowJournalNotes ?? false)) {
|
||||
return reply
|
||||
.status(403)
|
||||
.send({ error: "Journal notes are not enabled for this share link", code: "NOT_ENABLED" });
|
||||
}
|
||||
|
||||
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
||||
if (!isValidShareDoseId) {
|
||||
return reply.status(400).send({ error: "Invalid or unauthorized doseId", code: "INVALID_DOSE" });
|
||||
}
|
||||
|
||||
const event = await resolveTrackedDoseEventForUser({ userId: share.userId, doseId });
|
||||
if (!event) {
|
||||
return reply
|
||||
.status(404)
|
||||
.send({ error: "Tracked dose event not found for this share link", code: "DOSE_NOT_FOUND" });
|
||||
}
|
||||
|
||||
const journalEntry = await upsertIntakeJournalForDoseEvent({
|
||||
userId: share.userId,
|
||||
doseId,
|
||||
note: normalizedNote,
|
||||
});
|
||||
|
||||
return { entry: buildSharedJournalEntryDto({ event, journalEntry }) };
|
||||
}
|
||||
);
|
||||
|
||||
app.delete<{ Params: { token: string; doseId: string } }>(
|
||||
"/share/:token/journal/event/:doseId",
|
||||
{
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["token", "doseId"],
|
||||
properties: {
|
||||
token: tokenParamsSchema.properties.token,
|
||||
doseId: { type: "string", minLength: 1 },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
403: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { token, doseId } = request.params;
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareJournal] Rejected delete: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
if (!(share.allowJournalNotes ?? false)) {
|
||||
return reply
|
||||
.status(403)
|
||||
.send({ error: "Journal notes are not enabled for this share link", code: "NOT_ENABLED" });
|
||||
}
|
||||
|
||||
return reply.status(403).send({ error: "Shared links cannot delete journal notes", code: "DELETE_NOT_ALLOWED" });
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /share/:token/doses/skip - PUBLIC: Mark a dose as skipped via share link
|
||||
// ---------------------------------------------------------------------------
|
||||
app.post<{ Params: { token: string }; Body: z.infer<typeof shareDoseSchema> }>(
|
||||
"/share/:token/doses/skip",
|
||||
{
|
||||
schema: {
|
||||
params: tokenParamsSchema,
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["doseId"],
|
||||
properties: {
|
||||
doseId: { type: "string", minLength: 1 },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
404: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { token } = request.params;
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
|
||||
const parsed = shareDoseSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
return reply.status(400).send({ error: getValidationErrorMessage(parsed.error) });
|
||||
}
|
||||
|
||||
const { doseId } = parsed.data;
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareDose] Rejected skip: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
||||
if (!isValidShareDoseId) {
|
||||
request.log.warn(
|
||||
`[ShareDose] Rejected invalid doseId in skip request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
|
||||
}
|
||||
|
||||
const status = await markDoseSkippedForUser({ userId: share.userId, doseId });
|
||||
if (status === "already_skipped") {
|
||||
return { success: true, message: "Already skipped" };
|
||||
}
|
||||
|
||||
request.log.info(
|
||||
`[ShareDose] Dose skipped via share link: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /share/:token/doses/skip/:doseId - PUBLIC: Undo a skipped dose via share link
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete<{ Params: { token: string; doseId: string } }>(
|
||||
"/share/:token/doses/skip/:doseId",
|
||||
{
|
||||
schema: {
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["token", "doseId"],
|
||||
properties: {
|
||||
token: tokenParamsSchema.properties.token,
|
||||
doseId: { type: "string", minLength: 1 },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: { type: "object", properties: { success: { type: "boolean" } } },
|
||||
400: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { token, doseId } = request.params;
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareDose] Rejected undo skip: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
||||
if (!isValidShareDoseId) {
|
||||
request.log.warn(
|
||||
`[ShareDose] Rejected invalid doseId in undo skip request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
|
||||
}
|
||||
|
||||
await undoDoseSkippedForUser({ userId: share.userId, doseId });
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// POST /share/:token/doses - PUBLIC: Mark a dose as taken via share link
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -582,6 +1110,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { token } = request.params;
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
|
||||
const parsed = shareDoseSchema.safeParse(request.body);
|
||||
if (!parsed.success) {
|
||||
@@ -594,14 +1123,14 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareDose] Rejected mark: token=${token}, doseId=${doseId}, reason=${reason}`);
|
||||
request.log.warn(`[ShareDose] Rejected mark: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
||||
if (!isValidShareDoseId) {
|
||||
request.log.warn(
|
||||
`[ShareDose] Rejected invalid doseId in mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
`[ShareDose] Rejected invalid doseId in mark request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
|
||||
}
|
||||
@@ -614,7 +1143,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
|
||||
if (existing) {
|
||||
request.log.debug(
|
||||
`[ShareDose] Duplicate mark ignored: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
`[ShareDose] Duplicate mark ignored: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
return { success: true, message: "Already marked" };
|
||||
}
|
||||
@@ -627,7 +1156,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
});
|
||||
if (outOfStock) {
|
||||
request.log.info(
|
||||
`[ShareDose] Rejected out-of-stock mark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
`[ShareDose] Rejected out-of-stock mark request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
return reply.status(409).send({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
|
||||
}
|
||||
@@ -644,7 +1173,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
});
|
||||
|
||||
request.log.info(
|
||||
`[ShareDose] Dose marked via share link: token=${token}, ownerUserId=${share.userId}, shareTakenBy=${share.takenBy}, markedBy=${markedBy}, doseId=${doseId}`
|
||||
`[ShareDose] Dose marked via share link: tokenRef=${tokenRef}, ownerUserId=${share.userId}, shareTakenBy=${share.takenBy}, markedBy=${markedBy}, doseId=${doseId}`
|
||||
);
|
||||
|
||||
return { success: true };
|
||||
@@ -675,17 +1204,18 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { token, doseId } = request.params;
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
|
||||
const { share, reason } = await getActiveShareToken(token);
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareDose] Rejected unmark: token=${token}, doseId=${doseId}, reason=${reason}`);
|
||||
request.log.warn(`[ShareDose] Rejected unmark: tokenRef=${tokenRef}, doseId=${doseId}, reason=${reason}`);
|
||||
return reply.notFound("Share link not found");
|
||||
}
|
||||
|
||||
const isValidShareDoseId = await validateShareDoseId(share, doseId);
|
||||
if (!isValidShareDoseId) {
|
||||
request.log.warn(
|
||||
`[ShareDose] Rejected invalid doseId in unmark request: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
`[ShareDose] Rejected invalid doseId in unmark request: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
return reply.status(400).send({ error: "Invalid or unauthorized doseId" });
|
||||
}
|
||||
@@ -699,7 +1229,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
if (existing?.dismissed) {
|
||||
// Already dismissed - keep the record as-is
|
||||
request.log.debug(
|
||||
`[ShareDose] Unmark ignored for dismissed dose: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
`[ShareDose] Unmark ignored for dismissed dose: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
} else {
|
||||
// Not dismissed - delete the record entirely
|
||||
@@ -707,7 +1237,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
.delete(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, share.userId), eq(doseTracking.doseId, doseId)));
|
||||
request.log.info(
|
||||
`[ShareDose] Dose unmarked via share link: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
`[ShareDose] Dose unmarked via share link: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}, doseId=${doseId}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+373
-179
@@ -6,9 +6,13 @@ import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { getDataDir } from "../db/path-utils.js";
|
||||
import { doseTracking, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js";
|
||||
import { doseTracking, intakeJournal, medications, refillHistory, shareTokens, userSettings } from "../db/schema.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import {
|
||||
listIntakeJournalExportPayloadsForUser,
|
||||
restoreIntakeJournalForImportedDose,
|
||||
} from "../services/intake-journal-export.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import {
|
||||
applyOpenApiRouteStandards,
|
||||
@@ -23,7 +27,7 @@ const IMAGES_DIR = resolve(getDataDir(), "images");
|
||||
// =============================================================================
|
||||
// Export Format Version (bump this when format changes)
|
||||
// =============================================================================
|
||||
const EXPORT_VERSION = "1.5";
|
||||
const EXPORT_VERSION = "1.6";
|
||||
|
||||
// =============================================================================
|
||||
// Zod Schemas for Import Validation
|
||||
@@ -91,6 +95,9 @@ const doseHistorySchema = z.object({
|
||||
takenSource: z.enum(["manual", "automatic"]).default("manual"),
|
||||
dismissed: z.boolean().default(false),
|
||||
takenByPerson: z.string().nullable().optional(), // Person suffix from dose ID (e.g., "Daniel")
|
||||
journalNote: z.string().nullable().optional(),
|
||||
journalCreatedAt: z.string().nullable().optional(),
|
||||
journalUpdatedAt: z.string().nullable().optional(),
|
||||
});
|
||||
|
||||
const refillHistoryExportSchema = z.object({
|
||||
@@ -105,6 +112,7 @@ const refillHistoryExportSchema = z.object({
|
||||
const shareLinkSchema = z.object({
|
||||
takenBy: z.string().min(1),
|
||||
scheduleDays: z.number().int().min(1).default(30),
|
||||
allowJournalNotes: z.boolean().default(false),
|
||||
expiresAt: z.string().nullable().optional(), // ISO datetime
|
||||
regenerateToken: z.boolean().default(true),
|
||||
});
|
||||
@@ -140,8 +148,6 @@ const settingsSchemaBase = z.object({
|
||||
shareMedicationOverview: z.boolean().default(false),
|
||||
});
|
||||
|
||||
const exportSettingsSchema = settingsSchemaBase.optional();
|
||||
|
||||
const importSettingsSchema = settingsSchemaBase
|
||||
.extend({
|
||||
// Accept the removed field from legacy exports so old backups still import,
|
||||
@@ -197,7 +203,7 @@ const importBodyOpenApiSchema = {
|
||||
shareLinks: { type: "array", items: { type: "object", additionalProperties: true } },
|
||||
},
|
||||
example: {
|
||||
version: "1.8.0",
|
||||
version: "1.6",
|
||||
exportedAt: "2026-03-11T10:15:00.000Z",
|
||||
includeSensitiveData: true,
|
||||
medications: [
|
||||
@@ -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" }],
|
||||
settings: { language: "en", stockCalculationMode: "automatic" },
|
||||
shareLinks: [{ takenBy: "Daniel", scheduleDays: 14 }],
|
||||
},
|
||||
} as const;
|
||||
|
||||
const importPreviewResponseSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
preview: {
|
||||
type: "object",
|
||||
properties: {
|
||||
version: { type: "string" },
|
||||
exportedAt: { type: "string", format: "date-time" },
|
||||
includeSensitiveData: { type: "boolean" },
|
||||
incoming: {
|
||||
type: "object",
|
||||
properties: {
|
||||
medications: { type: "integer" },
|
||||
doseHistory: { type: "integer" },
|
||||
refillHistory: { type: "integer" },
|
||||
shareLinks: { type: "integer" },
|
||||
journalEntries: { type: "integer" },
|
||||
imageCount: { type: "integer" },
|
||||
hasSettings: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
current: {
|
||||
type: "object",
|
||||
properties: {
|
||||
medications: { type: "integer" },
|
||||
doseHistory: { type: "integer" },
|
||||
refillHistory: { type: "integer" },
|
||||
shareLinks: { type: "integer" },
|
||||
hasSettings: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
warnings: {
|
||||
type: "object",
|
||||
properties: {
|
||||
replacesExistingData: { type: "boolean" },
|
||||
regeneratesShareLinks: { type: "boolean" },
|
||||
containsImages: { type: "boolean" },
|
||||
containsSensitiveData: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
// =============================================================================
|
||||
// Helper Functions
|
||||
// =============================================================================
|
||||
@@ -297,7 +362,7 @@ function imageToBase64(imageUrl: string | null): string | null {
|
||||
|
||||
// Save base64 image to file and return filename
|
||||
function base64ToImage(base64: string, medicationId: number): string | null {
|
||||
if (!base64 || !base64.startsWith("data:")) return null;
|
||||
if (!base64.startsWith("data:")) return null;
|
||||
|
||||
try {
|
||||
// Parse data URL: "data:image/jpeg;base64,/9j/4AAQ..."
|
||||
@@ -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
|
||||
// Format: "{medicationId}-{blisterIndex}-{timestampMs}" or "{medicationId}-{blisterIndex}-{timestampMs}-{person}"
|
||||
function parseDoseId(
|
||||
@@ -444,6 +567,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
|
||||
// 2. Load all dose tracking entries
|
||||
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
|
||||
const journalPayloadsByDoseTrackingId = await listIntakeJournalExportPayloadsForUser(userId);
|
||||
|
||||
const exportDoseHistory = doses
|
||||
.map((dose) => {
|
||||
@@ -486,6 +610,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
takenSource: dose.takenSource === "automatic" ? "automatic" : "manual",
|
||||
dismissed: dose.dismissed ?? false,
|
||||
takenByPerson: parsed.person,
|
||||
...journalPayloadsByDoseTrackingId.get(dose.id),
|
||||
};
|
||||
})
|
||||
.filter((d): d is NonNullable<typeof d> => d !== null);
|
||||
@@ -544,6 +669,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
return {
|
||||
takenBy: share.takenBy,
|
||||
scheduleDays: share.scheduleDays,
|
||||
allowJournalNotes: share.allowJournalNotes ?? false,
|
||||
expiresAt: expiresAtIso,
|
||||
regenerateToken: true, // Always regenerate tokens on import for security
|
||||
};
|
||||
@@ -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!)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -651,6 +829,7 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
},
|
||||
400: { anyOf: [genericErrorSchema, validationErrorSchema] },
|
||||
401: genericErrorSchema,
|
||||
500: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -668,193 +847,208 @@ export async function exportRoutes(app: FastifyInstance) {
|
||||
|
||||
const importData = parsed.data;
|
||||
|
||||
// 2. Delete all existing user data (in correct order to respect foreign keys)
|
||||
// Note: CASCADE delete should handle this, but let's be explicit
|
||||
|
||||
// First, delete images for existing medications
|
||||
// Existing image files are removed only after the DB import commits.
|
||||
const existingMeds = await db.select().from(medications).where(eq(medications.userId, userId));
|
||||
for (const med of existingMeds) {
|
||||
if (med.imageUrl) {
|
||||
const imagePath = resolve(IMAGES_DIR, med.imageUrl);
|
||||
if (existsSync(imagePath)) {
|
||||
try {
|
||||
unlinkSync(imagePath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
const oldImagePaths = existingMeds
|
||||
.map((med) => (med.imageUrl ? resolve(IMAGES_DIR, med.imageUrl) : null))
|
||||
.filter((path): path is string => path !== null);
|
||||
const newImagePaths: string[] = [];
|
||||
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
// Delete in order: journal entries, refill history, doses, share tokens, medications, settings.
|
||||
await tx.delete(intakeJournal).where(eq(intakeJournal.userId, userId));
|
||||
await tx.delete(refillHistory).where(eq(refillHistory.userId, userId));
|
||||
await tx.delete(doseTracking).where(eq(doseTracking.userId, userId));
|
||||
await tx.delete(shareTokens).where(eq(shareTokens.userId, userId));
|
||||
await tx.delete(medications).where(eq(medications.userId, userId));
|
||||
await tx.delete(userSettings).where(eq(userSettings.userId, userId));
|
||||
|
||||
const exportIdToNewId = new Map<string, number>();
|
||||
|
||||
for (const med of importData.medications) {
|
||||
const normalizedSchedules = med.schedules.map((schedule) =>
|
||||
normalizeIntake({
|
||||
usage: schedule.usage,
|
||||
every: schedule.every,
|
||||
start: schedule.start,
|
||||
scheduleMode: schedule.scheduleMode,
|
||||
weekdays: schedule.weekdays,
|
||||
intakeUnit: schedule.intakeUnit ?? null,
|
||||
takenBy: schedule.takenBy || null,
|
||||
intakeRemindersEnabled: schedule.remind ?? false,
|
||||
})
|
||||
);
|
||||
const usageJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.usage));
|
||||
const everyJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.every));
|
||||
const startJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.start));
|
||||
const takenByJson = JSON.stringify(med.takenBy);
|
||||
const intakesJson = JSON.stringify(normalizedSchedules);
|
||||
const intakeRemindersEnabled =
|
||||
normalizedSchedules.some((schedule) => schedule.intakeRemindersEnabled) || med.intakeRemindersEnabled;
|
||||
|
||||
const [inserted] = await tx
|
||||
.insert(medications)
|
||||
.values({
|
||||
userId,
|
||||
name: med.name,
|
||||
genericName: med.genericName || null,
|
||||
takenByJson,
|
||||
medicationForm: med.medicationForm ?? "tablet",
|
||||
pillForm: med.pillForm || null,
|
||||
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
|
||||
packageType: normalizePackageType(med.inventory.packageType),
|
||||
packageAmountValue: med.inventory.packageAmountValue ?? 0,
|
||||
packageAmountUnit: med.inventory.packageAmountUnit ?? "ml",
|
||||
packCount: med.inventory.packCount,
|
||||
blistersPerPack: med.inventory.blistersPerPack,
|
||||
pillsPerBlister: med.inventory.pillsPerBlister,
|
||||
looseTablets: med.inventory.looseTablets,
|
||||
totalPills: med.inventory.totalPills ?? null,
|
||||
stockAdjustment: med.inventory.stockAdjustment ?? 0,
|
||||
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
|
||||
pillWeightMg: med.pillWeightMg || null,
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
medicationStartDate: med.medicationStartDate || "",
|
||||
medicationEndDate: med.medicationEndDate || null,
|
||||
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
|
||||
intakesJson,
|
||||
usageJson,
|
||||
everyJson,
|
||||
startJson,
|
||||
expiryDate: med.expiryDate || null,
|
||||
notes: med.notes || null,
|
||||
intakeRemindersEnabled,
|
||||
isObsolete: med.isObsolete ?? false,
|
||||
obsoleteAt: med.obsoleteAt ? new Date(med.obsoleteAt) : null,
|
||||
prescriptionEnabled: med.prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: med.prescriptionEnabled
|
||||
? (med.prescriptionAuthorizedRefills ?? null)
|
||||
: null,
|
||||
prescriptionRemainingRefills: med.prescriptionEnabled
|
||||
? (med.prescriptionRemainingRefills ?? null)
|
||||
: null,
|
||||
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
|
||||
prescriptionExpiryDate: med.prescriptionExpiryDate || null,
|
||||
dismissedUntil: med.dismissedUntil || null,
|
||||
imageUrl: null,
|
||||
})
|
||||
.returning();
|
||||
|
||||
exportIdToNewId.set(med._exportId, inserted.id);
|
||||
|
||||
if (med.image) {
|
||||
const imageUrl = base64ToImage(med.image, inserted.id);
|
||||
if (imageUrl) {
|
||||
newImagePaths.push(resolve(IMAGES_DIR, imageUrl));
|
||||
await tx.update(medications).set({ imageUrl }).where(eq(medications.id, inserted.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete in order: refill history, doses, share tokens, medications, settings
|
||||
await db.delete(refillHistory).where(eq(refillHistory.userId, userId));
|
||||
await db.delete(doseTracking).where(eq(doseTracking.userId, userId));
|
||||
await db.delete(shareTokens).where(eq(shareTokens.userId, userId));
|
||||
await db.delete(medications).where(eq(medications.userId, userId));
|
||||
await db.delete(userSettings).where(eq(userSettings.userId, userId));
|
||||
for (const dose of importData.doseHistory) {
|
||||
const newMedId = exportIdToNewId.get(dose.medicationRef);
|
||||
if (!newMedId) continue;
|
||||
|
||||
// 3. Import medications and build ID mapping
|
||||
const exportIdToNewId = new Map<string, number>();
|
||||
const scheduledFor = new Date(dose.scheduledTime);
|
||||
const timestampMs = scheduledFor.getTime();
|
||||
const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs, dose.takenByPerson);
|
||||
|
||||
for (const med of importData.medications) {
|
||||
const normalizedSchedules = med.schedules.map((schedule) =>
|
||||
normalizeIntake({
|
||||
usage: schedule.usage,
|
||||
every: schedule.every,
|
||||
start: schedule.start,
|
||||
scheduleMode: schedule.scheduleMode,
|
||||
weekdays: schedule.weekdays,
|
||||
intakeUnit: schedule.intakeUnit ?? null,
|
||||
takenBy: schedule.takenBy || null,
|
||||
intakeRemindersEnabled: schedule.remind ?? false,
|
||||
})
|
||||
);
|
||||
const usageJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.usage));
|
||||
const everyJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.every));
|
||||
const startJson = JSON.stringify(normalizedSchedules.map((schedule) => schedule.start));
|
||||
const takenByJson = JSON.stringify(med.takenBy);
|
||||
const [insertedDose] = await tx
|
||||
.insert(doseTracking)
|
||||
.values({
|
||||
userId,
|
||||
doseId,
|
||||
takenAt: new Date(dose.takenAt),
|
||||
markedBy: dose.markedBy || null,
|
||||
takenSource: dose.takenSource ?? "manual",
|
||||
dismissed: dose.dismissed ?? false,
|
||||
})
|
||||
.returning({ id: doseTracking.id });
|
||||
|
||||
const intakesJson = JSON.stringify(normalizedSchedules);
|
||||
await restoreIntakeJournalForImportedDose({
|
||||
userId,
|
||||
doseTrackingId: insertedDose.id,
|
||||
medicationId: newMedId,
|
||||
scheduledFor,
|
||||
journalNote: dose.journalNote,
|
||||
journalCreatedAt: dose.journalCreatedAt,
|
||||
journalUpdatedAt: dose.journalUpdatedAt,
|
||||
database: tx,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if any schedule has remind enabled
|
||||
const intakeRemindersEnabled =
|
||||
normalizedSchedules.some((schedule) => schedule.intakeRemindersEnabled) || med.intakeRemindersEnabled;
|
||||
if (importData.settings) {
|
||||
await tx.insert(userSettings).values({
|
||||
userId,
|
||||
emailEnabled: importData.settings.emailEnabled ?? false,
|
||||
notificationEmail: importData.settings.notificationEmail || null,
|
||||
emailStockReminders: importData.settings.emailStockReminders ?? true,
|
||||
emailIntakeReminders: importData.settings.emailIntakeReminders ?? true,
|
||||
emailPrescriptionReminders: importData.settings.emailPrescriptionReminders ?? true,
|
||||
shoutrrrEnabled: importData.settings.shoutrrrEnabled ?? false,
|
||||
shoutrrrUrl: importData.settings.shoutrrrUrl || null,
|
||||
shoutrrrStockReminders: importData.settings.shoutrrrStockReminders ?? true,
|
||||
shoutrrrIntakeReminders: importData.settings.shoutrrrIntakeReminders ?? true,
|
||||
shoutrrrPrescriptionReminders: importData.settings.shoutrrrPrescriptionReminders ?? true,
|
||||
reminderDaysBefore: importData.settings.reminderDaysBefore ?? 7,
|
||||
repeatDailyReminders: importData.settings.repeatDailyReminders ?? false,
|
||||
skipRemindersForTakenDoses: importData.settings.skipRemindersForTakenDoses ?? false,
|
||||
repeatRemindersEnabled: importData.settings.repeatRemindersEnabled ?? false,
|
||||
reminderRepeatIntervalMinutes: importData.settings.reminderRepeatIntervalMinutes ?? 30,
|
||||
maxNaggingReminders: importData.settings.maxNaggingReminders ?? 5,
|
||||
lowStockDays: importData.settings.lowStockDays ?? 30,
|
||||
normalStockDays: importData.settings.normalStockDays ?? 90,
|
||||
highStockDays: importData.settings.highStockDays ?? 180,
|
||||
expiryWarningDays: importData.settings.expiryWarningDays ?? 90,
|
||||
language: importData.settings.language ?? "en",
|
||||
stockCalculationMode: importData.settings.stockCalculationMode ?? "automatic",
|
||||
shareMedicationOverview: importData.settings.shareMedicationOverview ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
const [inserted] = await db
|
||||
.insert(medications)
|
||||
.values({
|
||||
userId,
|
||||
name: med.name,
|
||||
genericName: med.genericName || null,
|
||||
takenByJson,
|
||||
medicationForm: med.medicationForm ?? "tablet",
|
||||
pillForm: med.pillForm || null,
|
||||
lifecycleCategory: med.lifecycleCategory ?? "refill_when_empty",
|
||||
packageType: normalizePackageType(med.inventory.packageType),
|
||||
packageAmountValue: med.inventory.packageAmountValue ?? 0,
|
||||
packageAmountUnit: med.inventory.packageAmountUnit ?? "ml",
|
||||
packCount: med.inventory.packCount,
|
||||
blistersPerPack: med.inventory.blistersPerPack,
|
||||
pillsPerBlister: med.inventory.pillsPerBlister,
|
||||
looseTablets: med.inventory.looseTablets,
|
||||
totalPills: med.inventory.totalPills ?? null,
|
||||
stockAdjustment: med.inventory.stockAdjustment ?? 0,
|
||||
lastStockCorrectionAt: med.lastStockCorrectionAt ? new Date(med.lastStockCorrectionAt) : null,
|
||||
pillWeightMg: med.pillWeightMg || null,
|
||||
doseUnit: med.doseUnit ?? "mg",
|
||||
medicationStartDate: med.medicationStartDate || "",
|
||||
medicationEndDate: med.medicationEndDate || null,
|
||||
autoMarkObsoleteAfterEndDate: med.autoMarkObsoleteAfterEndDate ?? true,
|
||||
intakesJson,
|
||||
usageJson,
|
||||
everyJson,
|
||||
startJson,
|
||||
expiryDate: med.expiryDate || null,
|
||||
notes: med.notes || null,
|
||||
intakeRemindersEnabled,
|
||||
isObsolete: med.isObsolete ?? false,
|
||||
obsoleteAt: med.obsoleteAt ? new Date(med.obsoleteAt) : null,
|
||||
prescriptionEnabled: med.prescriptionEnabled ?? false,
|
||||
prescriptionAuthorizedRefills: med.prescriptionEnabled ? (med.prescriptionAuthorizedRefills ?? null) : null,
|
||||
prescriptionRemainingRefills: med.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? null) : null,
|
||||
prescriptionLowRefillThreshold: med.prescriptionLowRefillThreshold ?? 1,
|
||||
prescriptionExpiryDate: med.prescriptionExpiryDate || null,
|
||||
dismissedUntil: med.dismissedUntil || null,
|
||||
imageUrl: null, // Will be set after image is saved
|
||||
})
|
||||
.returning();
|
||||
for (const share of importData.shareLinks) {
|
||||
await tx.insert(shareTokens).values({
|
||||
userId,
|
||||
token: randomBytes(8).toString("hex"),
|
||||
takenBy: share.takenBy,
|
||||
scheduleDays: share.scheduleDays,
|
||||
allowJournalNotes: share.allowJournalNotes ?? false,
|
||||
expiresAt: share.expiresAt ? new Date(share.expiresAt) : null,
|
||||
});
|
||||
}
|
||||
|
||||
// Save mapping
|
||||
exportIdToNewId.set(med._exportId, inserted.id);
|
||||
for (const refill of importData.refillHistory) {
|
||||
const newMedId = exportIdToNewId.get(refill.medicationRef);
|
||||
if (!newMedId) continue;
|
||||
|
||||
// Save image if present
|
||||
if (med.image) {
|
||||
const imageUrl = base64ToImage(med.image, inserted.id);
|
||||
if (imageUrl) {
|
||||
await db.update(medications).set({ imageUrl }).where(eq(medications.id, inserted.id));
|
||||
await tx.insert(refillHistory).values({
|
||||
medicationId: newMedId,
|
||||
userId,
|
||||
packsAdded: refill.packsAdded ?? 0,
|
||||
loosePillsAdded: refill.loosePillsAdded ?? refill.quantityAdded ?? 0,
|
||||
usedPrescription: refill.usedPrescription ?? false,
|
||||
refillDate: new Date(refill.refillDate),
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
for (const imagePath of newImagePaths) {
|
||||
const removalError = removeFileIfPresent(imagePath);
|
||||
if (removalError) {
|
||||
request.log.warn(`[Import] Failed to remove rolled-back image path=${imagePath}: ${removalError}`);
|
||||
}
|
||||
}
|
||||
|
||||
request.log.error({ err: error }, "[Import] Failed to import data");
|
||||
return reply.status(500).send({ error: "Import failed" });
|
||||
}
|
||||
|
||||
// 4. Import dose history with remapped medication IDs
|
||||
for (const dose of importData.doseHistory) {
|
||||
const newMedId = exportIdToNewId.get(dose.medicationRef);
|
||||
if (!newMedId) continue; // Skip orphaned doses
|
||||
|
||||
// Convert ISO timestamp back to milliseconds for dose ID
|
||||
const timestampMs = new Date(dose.scheduledTime).getTime();
|
||||
// Rebuild dose ID with optional person suffix
|
||||
const doseId = buildDoseId(newMedId, dose.scheduleIndex, timestampMs, dose.takenByPerson);
|
||||
|
||||
await db.insert(doseTracking).values({
|
||||
userId,
|
||||
doseId,
|
||||
takenAt: new Date(dose.takenAt),
|
||||
markedBy: dose.markedBy || null,
|
||||
takenSource: dose.takenSource ?? "manual",
|
||||
dismissed: dose.dismissed ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
// 5. Import settings
|
||||
if (importData.settings) {
|
||||
// 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),
|
||||
});
|
||||
for (const imagePath of oldImagePaths) {
|
||||
const removalError = removeFileIfPresent(imagePath);
|
||||
if (removalError) {
|
||||
request.log.warn(`[Import] Failed to remove replaced image path=${imagePath}: ${removalError}`);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
action: NotificationMutationAction;
|
||||
viewUrl: string | null;
|
||||
}): Promise<boolean> {
|
||||
}): Promise<{ replaced: boolean; providerMessageId?: string }> {
|
||||
const normalizedSequenceId = options.sequenceId.trim();
|
||||
if (normalizedSequenceId.length === 0) {
|
||||
return false;
|
||||
return { replaced: false };
|
||||
}
|
||||
|
||||
const [settings] = await db
|
||||
@@ -225,12 +225,12 @@ async function replaceNtfyNotificationSequence(options: {
|
||||
.where(eq(userSettings.userId, options.userId));
|
||||
|
||||
if (!settings?.shoutrrrEnabled || !settings.shoutrrrUrl) {
|
||||
return false;
|
||||
return { replaced: false };
|
||||
}
|
||||
|
||||
const sanitized = sanitizeNotificationUrl(settings.shoutrrrUrl);
|
||||
if ("error" in sanitized || !sanitized.isNtfy) {
|
||||
return false;
|
||||
return { replaced: false };
|
||||
}
|
||||
|
||||
const labels = getNotificationActionLabels(options.language);
|
||||
@@ -247,7 +247,7 @@ async function replaceNtfyNotificationSequence(options: {
|
||||
throw new Error(result.error ?? "Failed to replace ntfy notification");
|
||||
}
|
||||
|
||||
return true;
|
||||
return { replaced: true, providerMessageId: result.providerMessageId };
|
||||
}
|
||||
|
||||
function renderPage(options: {
|
||||
@@ -585,9 +585,10 @@ export async function notificationActionRoutes(app: FastifyInstance) {
|
||||
|
||||
const recordedText = getActionRecordedText(language, action);
|
||||
let replacedNtfyNotification = false;
|
||||
const previousNtfyMessageId = record.group.ntfyOriginalMessageId.trim();
|
||||
|
||||
try {
|
||||
replacedNtfyNotification = await replaceNtfyNotificationSequence({
|
||||
const replacementResult = await replaceNtfyNotificationSequence({
|
||||
userId: record.group.userId,
|
||||
sequenceId: record.group.sequenceId,
|
||||
language,
|
||||
@@ -596,6 +597,33 @@ export async function notificationActionRoutes(app: FastifyInstance) {
|
||||
action,
|
||||
viewUrl: record.viewUrl,
|
||||
});
|
||||
replacedNtfyNotification = replacementResult.replaced;
|
||||
|
||||
if (replacementResult.providerMessageId) {
|
||||
await db
|
||||
.update(notificationActionGroups)
|
||||
.set({ ntfyOriginalMessageId: replacementResult.providerMessageId, updatedAt: new Date() })
|
||||
.where(eq(notificationActionGroups.id, record.group.id));
|
||||
}
|
||||
|
||||
if (
|
||||
replacementResult.replaced &&
|
||||
previousNtfyMessageId.length > 0 &&
|
||||
previousNtfyMessageId !== replacementResult.providerMessageId
|
||||
) {
|
||||
try {
|
||||
await deleteNtfyNotificationSequence(record.group.userId, previousNtfyMessageId);
|
||||
} catch (error) {
|
||||
request.log.warn(
|
||||
buildNotificationActionLogContext(record, {
|
||||
requestedAction: action,
|
||||
originalMessageId: previousNtfyMessageId,
|
||||
error,
|
||||
}),
|
||||
"[NotificationActions] Failed to delete original ntfy notification after replacement"
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
request.log.warn(
|
||||
buildNotificationActionLogContext(record, { requestedAction: action, error }),
|
||||
|
||||
@@ -45,12 +45,24 @@ type PlannerRow = {
|
||||
|
||||
type SendEmailBody = {
|
||||
email: string;
|
||||
from: string;
|
||||
until: string;
|
||||
from?: string;
|
||||
until?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
rows: PlannerRow[];
|
||||
language?: Language; // Optional: passed from frontend for unauthenticated requests
|
||||
};
|
||||
|
||||
function resolvePlannerDateRange(body: SendEmailBody): { startDate: string; endDate: string } | null {
|
||||
const startDate = body.startDate ?? body.from;
|
||||
const endDate = body.endDate ?? body.until;
|
||||
if (!startDate || !endDate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { startDate, endDate };
|
||||
}
|
||||
|
||||
type LowStockItem = {
|
||||
name: string;
|
||||
medsLeft: number;
|
||||
@@ -165,11 +177,15 @@ export async function plannerRoutes(app: FastifyInstance) {
|
||||
email: { type: "string" },
|
||||
from: { type: "string" },
|
||||
until: { type: "string" },
|
||||
startDate: { type: "string", format: "date-time" },
|
||||
endDate: { type: "string", format: "date-time" },
|
||||
language: { type: "string" },
|
||||
rows: { type: "array", items: plannerRowSchema },
|
||||
},
|
||||
example: {
|
||||
email: "daniel@example.com",
|
||||
startDate: "2026-03-11T00:00:00.000Z",
|
||||
endDate: "2026-04-11T00:00:00.000Z",
|
||||
from: "2026-03-11",
|
||||
until: "2026-04-11",
|
||||
language: "en",
|
||||
@@ -198,13 +214,20 @@ export async function plannerRoutes(app: FastifyInstance) {
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { email, from, until, rows, language: bodyLanguage } = request.body;
|
||||
const { email, rows, language: bodyLanguage } = request.body;
|
||||
const resolvedDateRange = resolvePlannerDateRange(request.body);
|
||||
request.log.info({ email, rowCount: rows?.length ?? 0 }, "[Planner] Demand notification request received");
|
||||
|
||||
if (!rows || rows.length === 0) {
|
||||
return reply.status(400).send({ error: "Missing planner data" });
|
||||
}
|
||||
|
||||
if (!resolvedDateRange) {
|
||||
return reply.status(400).send({ error: "Missing planner date range" });
|
||||
}
|
||||
|
||||
const { startDate, endDate } = resolvedDateRange;
|
||||
|
||||
// Load user settings for notification channels
|
||||
const userId = await getUserId(request);
|
||||
const activeMeds = await db
|
||||
@@ -246,14 +269,14 @@ export async function plannerRoutes(app: FastifyInstance) {
|
||||
|
||||
// Format dates for display - escape to prevent XSS even though toLocaleDateString should be safe
|
||||
const fromDate = escapeHtml(
|
||||
new Date(from).toLocaleDateString(locale, {
|
||||
new Date(startDate).toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
})
|
||||
);
|
||||
const untilDate = escapeHtml(
|
||||
new Date(until).toLocaleDateString(locale, {
|
||||
new Date(endDate).toLocaleDateString(locale, {
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, eq, gte, lt } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
@@ -12,10 +12,42 @@ import {
|
||||
validationErrorSchema,
|
||||
} from "../utils/openapi-route-standards.js";
|
||||
|
||||
const reportDataSchema = z.object({
|
||||
medicationIds: z.array(z.number().int().positive()).min(1).max(100),
|
||||
takenByFilter: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
|
||||
});
|
||||
const reportDataSchema = z
|
||||
.object({
|
||||
medicationIds: z.array(z.number().int().positive()).min(1).max(100),
|
||||
startDate: z.string().datetime().optional(),
|
||||
endDate: z.string().datetime().optional(),
|
||||
takenByFilter: z.array(z.string().trim().min(1).max(100)).max(50).optional(),
|
||||
})
|
||||
.superRefine((value, ctx) => {
|
||||
const hasStartDate = typeof value.startDate === "string";
|
||||
const hasEndDate = typeof value.endDate === "string";
|
||||
|
||||
if (hasStartDate !== hasEndDate) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "startDate and endDate must be provided together",
|
||||
path: hasStartDate ? ["endDate"] : ["startDate"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!hasStartDate || !hasEndDate) {
|
||||
return;
|
||||
}
|
||||
|
||||
const startDateValue = value.startDate!;
|
||||
const endDateValue = value.endDate!;
|
||||
const startDate = new Date(startDateValue);
|
||||
const endDate = new Date(endDateValue);
|
||||
if (Number.isNaN(startDate.getTime()) || Number.isNaN(endDate.getTime()) || endDate <= startDate) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Invalid date range",
|
||||
path: ["endDate"],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const reportDataBodyOpenApiSchema = {
|
||||
type: "object",
|
||||
@@ -27,6 +59,14 @@ const reportDataBodyOpenApiSchema = {
|
||||
maxItems: 100,
|
||||
items: { type: "integer", minimum: 1 },
|
||||
},
|
||||
startDate: {
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
},
|
||||
endDate: {
|
||||
type: "string",
|
||||
format: "date-time",
|
||||
},
|
||||
takenByFilter: {
|
||||
type: "array",
|
||||
maxItems: 50,
|
||||
@@ -35,17 +75,47 @@ const reportDataBodyOpenApiSchema = {
|
||||
},
|
||||
example: {
|
||||
medicationIds: [1, 3, 5],
|
||||
startDate: "2026-05-01T00:00:00.000Z",
|
||||
endDate: "2026-06-01T00:00:00.000Z",
|
||||
takenByFilter: ["Daniel"],
|
||||
},
|
||||
} as const;
|
||||
|
||||
const trackedDoseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
|
||||
|
||||
function getPersonTagKey(value: string): string {
|
||||
return value.trim().toLocaleLowerCase();
|
||||
}
|
||||
|
||||
function matchesTakenByFilter(doseId: string, takenByFilter: Set<string> | null): boolean {
|
||||
if (!takenByFilter) return true;
|
||||
const parts = doseId.split("-");
|
||||
if (parts.length < 4) return false;
|
||||
const takenBy = parts.at(-1)?.trim();
|
||||
if (!takenBy) return false;
|
||||
return takenByFilter.has(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 = {
|
||||
@@ -110,10 +180,17 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
if (!parsed.success) return reply.status(400).send(parsed.error.format());
|
||||
|
||||
const userId = await getUserId(req, reply);
|
||||
const { medicationIds, takenByFilter } = parsed.data;
|
||||
const { medicationIds, startDate, endDate, takenByFilter } = parsed.data;
|
||||
const normalizedTakenByFilter = takenByFilter?.length
|
||||
? new Set(takenByFilter.map((value) => value.trim()))
|
||||
? new Set(takenByFilter.map((value) => getPersonTagKey(value)))
|
||||
: null;
|
||||
const dateRange =
|
||||
startDate && endDate
|
||||
? {
|
||||
startMs: new Date(startDate).getTime(),
|
||||
endMs: new Date(endDate).getTime(),
|
||||
}
|
||||
: null;
|
||||
|
||||
// Verify all medications belong to this user
|
||||
const userMeds = await db
|
||||
@@ -152,6 +229,7 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
const medId = Number.parseInt(dose.doseId.split("-")[0], 10);
|
||||
if (Number.isNaN(medId) || !medicationIds.includes(medId)) continue;
|
||||
if (!matchesTakenByFilter(dose.doseId, normalizedTakenByFilter)) continue;
|
||||
if (!isWithinDateRange(getDoseScheduledAtMs(dose.doseId), dateRange)) continue;
|
||||
if (!dosesByMed.has(medId)) dosesByMed.set(medId, []);
|
||||
dosesByMed.get(medId)!.push({
|
||||
takenAt: dose.takenAt,
|
||||
@@ -191,10 +269,15 @@ export async function reportRoutes(app: FastifyInstance) {
|
||||
const isAmountBased = medication?.packageType === "liquid_container" || medication?.packageType === "tube";
|
||||
|
||||
// Get refills for this medication scoped to the authenticated user.
|
||||
const refillFilters = [eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)];
|
||||
if (dateRange) {
|
||||
refillFilters.push(gte(refillHistory.refillDate, new Date(dateRange.startMs)));
|
||||
refillFilters.push(lt(refillHistory.refillDate, new Date(dateRange.endMs)));
|
||||
}
|
||||
const refills = await db
|
||||
.select()
|
||||
.from(refillHistory)
|
||||
.where(and(eq(refillHistory.medicationId, medId), eq(refillHistory.userId, userId)));
|
||||
.where(and(...refillFilters));
|
||||
|
||||
result[medId] = {
|
||||
dosesTaken: takenDoses.length,
|
||||
|
||||
+193
-13
@@ -1,5 +1,5 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { and, desc, eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
validationErrorSchema,
|
||||
} from "../utils/openapi-route-standards.js";
|
||||
import { isAmountBasedPackageType, normalizePackageType } from "../utils/package-profiles.js";
|
||||
import { redactTokenForLog } from "../utils/redaction.js";
|
||||
import {
|
||||
getAllTakenByForMedication,
|
||||
parseIntakesJson,
|
||||
@@ -28,6 +29,11 @@ import {
|
||||
const createShareSchema = z.object({
|
||||
takenBy: z.string().min(1, "takenBy is required"),
|
||||
scheduleDays: z.number().int().min(1).max(365).default(30),
|
||||
expiryDays: z
|
||||
.union([z.number().int().min(1).max(365), z.null()])
|
||||
.optional()
|
||||
.default(null),
|
||||
allowJournalNotes: z.boolean().optional().default(false),
|
||||
});
|
||||
|
||||
const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>> = [
|
||||
@@ -37,15 +43,59 @@ const protectedEndpointSecurity: ReadonlyArray<Record<string, readonly string[]>
|
||||
|
||||
const shareTokenPattern = /^[a-f0-9]{16}$/;
|
||||
|
||||
function toIsoTimestamp(value: Date | string | number | null | undefined): string | null {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (value instanceof Date) {
|
||||
return Number.isNaN(value.getTime()) ? null : value.toISOString();
|
||||
}
|
||||
|
||||
if (typeof value === "number" || (typeof value === "string" && /^\d+$/.test(value))) {
|
||||
const numericValue = typeof value === "number" ? value : Number(value);
|
||||
const timestampMs = numericValue < 1_000_000_000_000 ? numericValue * 1000 : numericValue;
|
||||
const date = new Date(timestampMs);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date.toISOString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveExpiryDate(expiryDays: number | null | undefined): Date | null {
|
||||
if (expiryDays == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Date(Date.now() + expiryDays * 24 * 60 * 60 * 1000);
|
||||
}
|
||||
|
||||
function isExpiredTimestamp(value: Date | string | number | null | undefined): boolean {
|
||||
const isoValue = toIsoTimestamp(value);
|
||||
return isoValue != null && new Date(isoValue).getTime() < Date.now();
|
||||
}
|
||||
|
||||
const createShareBodyOpenApiSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
takenBy: { type: "string" },
|
||||
scheduleDays: { type: "integer", minimum: 1, maximum: 365, default: 30 },
|
||||
allowJournalNotes: { type: "boolean", default: false },
|
||||
expiryDays: {
|
||||
anyOf: [{ type: "integer", minimum: 1, maximum: 365 }, { type: "null" }],
|
||||
default: null,
|
||||
},
|
||||
},
|
||||
example: {
|
||||
takenBy: "Daniel",
|
||||
scheduleDays: 14,
|
||||
allowJournalNotes: true,
|
||||
expiryDays: 30,
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -64,6 +114,7 @@ const shareReadResponseSchema = {
|
||||
stockCalculationMode: { type: "string", enum: ["automatic", "manual"] },
|
||||
upcomingTodayOnly: { type: "boolean" },
|
||||
shareScheduleTodayOnly: { type: "boolean" },
|
||||
allowJournalNotes: { type: "boolean" },
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -96,6 +147,37 @@ const shareOverviewResponseSchema = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
const shareListResponseSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
shareLinks: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
token: { type: "string" },
|
||||
takenBy: { type: "string" },
|
||||
scheduleDays: { type: "integer" },
|
||||
createdAt: { type: "string", format: "date-time" },
|
||||
expiresAt: { type: ["string", "null"], format: "date-time" },
|
||||
allowJournalNotes: { type: "boolean" },
|
||||
shareUrl: { type: "string" },
|
||||
},
|
||||
required: ["token", "takenBy", "scheduleDays", "createdAt", "expiresAt", "allowJournalNotes", "shareUrl"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["shareLinks"],
|
||||
} as const;
|
||||
|
||||
const ownerTokenParamsSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
token: { type: "string" },
|
||||
},
|
||||
required: ["token"],
|
||||
} as const;
|
||||
|
||||
// Helper to get user ID from request
|
||||
// Returns anonymous user ID when auth is disabled
|
||||
async function getUserId(request: FastifyRequest, reply: FastifyReply): Promise<number> {
|
||||
@@ -146,11 +228,12 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { token } = request.params;
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
|
||||
// Find share token
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
if (!share) {
|
||||
request.log.warn(`[Share] Invalid share token requested: token=${token}`);
|
||||
request.log.warn(`[Share] Invalid share token requested: tokenRef=${tokenRef}`);
|
||||
return reply.status(404).send({
|
||||
error: "Share link not found",
|
||||
code: "NOT_FOUND",
|
||||
@@ -160,7 +243,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
// Check if token has expired
|
||||
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
|
||||
request.log.warn(
|
||||
`[Share] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
|
||||
`[Share] Expired token requested: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
|
||||
);
|
||||
// Get the username of the owner to show in the expired message
|
||||
const [owner] = await db.select({ username: users.username }).from(users).where(eq(users.id, share.userId));
|
||||
@@ -255,6 +338,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
takenBy: share.takenBy,
|
||||
sharedBy: owner?.username ?? null,
|
||||
scheduleDays: share.scheduleDays,
|
||||
allowJournalNotes: share.allowJournalNotes ?? false,
|
||||
medications: medicationsWithBlisters,
|
||||
shareMedicationOverview,
|
||||
medicationOverview,
|
||||
@@ -298,20 +382,21 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
reply.header("Cache-Control", "no-store");
|
||||
|
||||
const { token } = request.params;
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
if (!shareTokenPattern.test(token)) {
|
||||
request.log.warn(`[ShareOverview] Rejected invalid token format: token=${token}`);
|
||||
request.log.warn(`[ShareOverview] Rejected invalid token format: tokenRef=${tokenRef}`);
|
||||
return reply.status(404).send({ error: "not_found" });
|
||||
}
|
||||
|
||||
const [share] = await db.select().from(shareTokens).where(eq(shareTokens.token, token));
|
||||
if (!share) {
|
||||
request.log.warn(`[ShareOverview] Unknown token requested: token=${token}`);
|
||||
request.log.warn(`[ShareOverview] Unknown token requested: tokenRef=${tokenRef}`);
|
||||
return reply.status(404).send({ error: "not_found" });
|
||||
}
|
||||
|
||||
if (share.expiresAt && share.expiresAt.getTime() < Date.now()) {
|
||||
request.log.warn(
|
||||
`[ShareOverview] Expired token requested: token=${token}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
|
||||
`[ShareOverview] Expired token requested: tokenRef=${tokenRef}, ownerUserId=${share.userId}, takenBy=${share.takenBy}`
|
||||
);
|
||||
return reply.status(410).send({
|
||||
error: "expired",
|
||||
@@ -371,6 +456,7 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
reused: { type: "boolean" },
|
||||
token: { type: "string" },
|
||||
shareUrl: { type: "string" },
|
||||
allowJournalNotes: { type: "boolean" },
|
||||
expiresAt: { type: ["string", "null"] },
|
||||
},
|
||||
},
|
||||
@@ -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)
|
||||
const allMeds = await db
|
||||
@@ -422,43 +509,136 @@ export async function shareRoutes(app: FastifyInstance) {
|
||||
.where(and(eq(shareTokens.userId, userId), eq(shareTokens.takenBy, takenBy)));
|
||||
|
||||
if (existingShare) {
|
||||
await db.update(shareTokens).set({ scheduleDays, expiresAt: null }).where(eq(shareTokens.id, existingShare.id));
|
||||
const existingTokenRef = redactTokenForLog(existingShare.token);
|
||||
await db
|
||||
.update(shareTokens)
|
||||
.set({ scheduleDays, expiresAt, allowJournalNotes })
|
||||
.where(eq(shareTokens.id, existingShare.id));
|
||||
|
||||
request.log.info(
|
||||
`[Share] Reused existing share token: token=${existingShare.token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}`
|
||||
`[Share] Reused existing share token: tokenRef=${existingTokenRef}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}, allowJournalNotes=${allowJournalNotes}, expiresAt=${expiresAt?.toISOString() ?? "never"}`
|
||||
);
|
||||
|
||||
return {
|
||||
reused: true,
|
||||
token: existingShare.token,
|
||||
shareUrl: `/share/${existingShare.token}`,
|
||||
expiresAt: null,
|
||||
allowJournalNotes,
|
||||
expiresAt: toIsoTimestamp(expiresAt),
|
||||
};
|
||||
}
|
||||
|
||||
const token = randomBytes(8).toString("hex");
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
|
||||
await db.insert(shareTokens).values({
|
||||
userId,
|
||||
token,
|
||||
takenBy,
|
||||
scheduleDays,
|
||||
expiresAt: null,
|
||||
allowJournalNotes,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
request.log.info(
|
||||
`[Share] Created new share token: token=${token}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}`
|
||||
`[Share] Created new share token: tokenRef=${tokenRef}, ownerUserId=${userId}, takenBy=${takenBy}, scheduleDays=${scheduleDays}, allowJournalNotes=${allowJournalNotes}, expiresAt=${expiresAt?.toISOString() ?? "never"}`
|
||||
);
|
||||
|
||||
return {
|
||||
reused: false,
|
||||
token,
|
||||
shareUrl: `/share/${token}`,
|
||||
expiresAt: null,
|
||||
allowJournalNotes,
|
||||
expiresAt: toIsoTimestamp(expiresAt),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /share - PROTECTED: List active share links for current owner
|
||||
// ---------------------------------------------------------------------------
|
||||
app.get(
|
||||
"/share",
|
||||
{
|
||||
preHandler: requireAuth,
|
||||
schema: {
|
||||
tags: ["share"],
|
||||
security: protectedEndpointSecurity,
|
||||
response: {
|
||||
200: shareListResponseSchema,
|
||||
401: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
const shares = await db
|
||||
.select()
|
||||
.from(shareTokens)
|
||||
.where(eq(shareTokens.userId, userId))
|
||||
.orderBy(desc(shareTokens.createdAt));
|
||||
|
||||
return {
|
||||
shareLinks: shares
|
||||
.filter((share) => !isExpiredTimestamp(share.expiresAt))
|
||||
.map((share) => ({
|
||||
token: share.token,
|
||||
takenBy: share.takenBy,
|
||||
scheduleDays: share.scheduleDays,
|
||||
createdAt: toIsoTimestamp(share.createdAt) ?? new Date().toISOString(),
|
||||
expiresAt: toIsoTimestamp(share.expiresAt),
|
||||
allowJournalNotes: share.allowJournalNotes ?? false,
|
||||
shareUrl: `/share/${share.token}`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DELETE /share/:token - PROTECTED: Revoke an existing share link
|
||||
// ---------------------------------------------------------------------------
|
||||
app.delete<{ Params: { token: string } }>(
|
||||
"/share/:token",
|
||||
{
|
||||
preHandler: requireAuth,
|
||||
schema: {
|
||||
tags: ["share"],
|
||||
security: protectedEndpointSecurity,
|
||||
params: ownerTokenParamsSchema,
|
||||
response: {
|
||||
204: { type: "null" },
|
||||
401: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const userId = await getUserId(request, reply);
|
||||
const { token } = request.params;
|
||||
const tokenRef = redactTokenForLog(token);
|
||||
|
||||
const [share] = await db
|
||||
.select()
|
||||
.from(shareTokens)
|
||||
.where(and(eq(shareTokens.userId, userId), eq(shareTokens.token, token)));
|
||||
|
||||
if (!share) {
|
||||
return reply.status(404).send({
|
||||
error: "Share link not found",
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
}
|
||||
|
||||
await db.delete(shareTokens).where(eq(shareTokens.id, share.id));
|
||||
|
||||
request.log.info(
|
||||
`[Share] Revoked share token: tokenRef=${tokenRef}, ownerUserId=${userId}, takenBy=${share.takenBy}`
|
||||
);
|
||||
|
||||
return reply.status(204).send();
|
||||
}
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GET /share/people - PROTECTED: Get list of unique takenBy values
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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: {
|
||||
userId: number;
|
||||
title: string;
|
||||
@@ -198,21 +202,47 @@ export async function createNotificationActionContext(input: {
|
||||
);
|
||||
|
||||
if (!group) {
|
||||
[group] = await db
|
||||
.insert(notificationActionGroups)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
groupKey,
|
||||
sequenceId,
|
||||
doseIdsJson: JSON.stringify(uniqueDoseIds),
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
language: input.language,
|
||||
scheduledFor: input.scheduledFor,
|
||||
expiresAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
const [existingGroup] = await db
|
||||
.select()
|
||||
.from(notificationActionGroups)
|
||||
.where(eq(notificationActionGroups.groupKey, groupKey));
|
||||
|
||||
if (existingGroup) {
|
||||
await resetActionTokens(existingGroup.id);
|
||||
[group] = await db
|
||||
.update(notificationActionGroups)
|
||||
.set({
|
||||
sequenceId,
|
||||
ntfyOriginalMessageId: "",
|
||||
doseIdsJson: JSON.stringify(uniqueDoseIds),
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
language: input.language,
|
||||
scheduledFor: input.scheduledFor,
|
||||
expiresAt,
|
||||
resolvedAction: null,
|
||||
resolvedAt: null,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(notificationActionGroups.id, existingGroup.id))
|
||||
.returning();
|
||||
} else {
|
||||
[group] = await db
|
||||
.insert(notificationActionGroups)
|
||||
.values({
|
||||
userId: input.userId,
|
||||
groupKey,
|
||||
sequenceId,
|
||||
doseIdsJson: JSON.stringify(uniqueDoseIds),
|
||||
title: input.title,
|
||||
message: input.message,
|
||||
language: input.language,
|
||||
scheduledFor: input.scheduledFor,
|
||||
expiresAt,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
}
|
||||
}
|
||||
|
||||
const tokens = await createActionTokens(group.id);
|
||||
|
||||
@@ -51,6 +51,7 @@ const __dirname = dirname(__filename);
|
||||
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||
|
||||
async function clearTables() {
|
||||
await testClient.execute("DELETE FROM intake_journal");
|
||||
await testClient.execute("DELETE FROM dose_tracking");
|
||||
await testClient.execute("DELETE FROM share_tokens");
|
||||
await testClient.execute("DELETE FROM api_keys");
|
||||
@@ -78,20 +79,30 @@ async function insertMedication(options: {
|
||||
start?: string;
|
||||
}) {
|
||||
const intakeStart = options.start ?? "2025-01-01T08:00:00.000Z";
|
||||
const takenBy = options.takenBy ?? [];
|
||||
const intakeTakenBy = takenBy[0] ?? null;
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO medications (
|
||||
id, user_id, name, taken_by_json, medication_form, package_type,
|
||||
pack_count, blisters_per_pack, pills_per_blister, loose_tablets, stock_adjustment,
|
||||
usage_json, every_json, start_json, intakes_json, intake_reminders_enabled
|
||||
) VALUES (?, ?, 'Test Medication', ?, 'tablet', 'blister', ?, 1, 10, ?, 0, '[1]', '[1]', ?, '[]', 0)`,
|
||||
) VALUES (?, ?, 'Test Medication', ?, 'tablet', 'blister', ?, 1, 10, ?, 0, '[1]', '[1]', ?, ?, 0)`,
|
||||
args: [
|
||||
options.id,
|
||||
options.userId,
|
||||
JSON.stringify(options.takenBy ?? []),
|
||||
JSON.stringify(takenBy),
|
||||
options.packCount ?? 1,
|
||||
options.looseTablets ?? 0,
|
||||
intakeStart,
|
||||
"[]",
|
||||
JSON.stringify([
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: intakeStart,
|
||||
takenBy: intakeTakenBy,
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
]),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -103,13 +114,24 @@ async function insertUserSettings(userId: number, stockCalculationMode: "automat
|
||||
});
|
||||
}
|
||||
|
||||
async function _insertShareToken(userId: number, token: string, takenBy: string) {
|
||||
async function _insertShareToken(userId: number, token: string, takenBy: string, allowJournalNotes = false) {
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days) VALUES (?, ?, ?, 30)",
|
||||
args: [userId, token, takenBy],
|
||||
sql: "INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, allow_journal_notes) VALUES (?, ?, ?, 30, ?)",
|
||||
args: [userId, token, takenBy, allowJournalNotes ? 1 : 0],
|
||||
});
|
||||
}
|
||||
|
||||
function buildLocalDoseStart(hours = 8): string {
|
||||
const start = new Date();
|
||||
start.setHours(hours, 0, 0, 0);
|
||||
const year = start.getFullYear();
|
||||
const month = String(start.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(start.getDate()).padStart(2, "0");
|
||||
const hour = String(start.getHours()).padStart(2, "0");
|
||||
|
||||
return `${year}-${month}-${day}T${hour}:00:00.000`;
|
||||
}
|
||||
|
||||
async function buildSessionCookie(app: FastifyInstance, userId: number, username: string) {
|
||||
const token = await app.jwt.sign({ sub: userId, username });
|
||||
return `access_token=${token}`;
|
||||
@@ -458,6 +480,48 @@ describe("Dose Tracking API", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("single-dose skip routes", () => {
|
||||
it("marks a single owner dose as skipped through the frontend route", async () => {
|
||||
const doseId = "1-0-1735344000000";
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/doses/skip",
|
||||
headers: { cookie: cookieHeader },
|
||||
payload: { doseId },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
|
||||
const result = await testClient.execute({
|
||||
sql: "SELECT dose_id, marked_by, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||
args: [userId, doseId],
|
||||
});
|
||||
expect(result.rows).toEqual([expect.objectContaining({ dose_id: doseId, marked_by: null, dismissed: 1 })]);
|
||||
});
|
||||
|
||||
it("undoes a skipped-only owner dose through the frontend route", async () => {
|
||||
const doseId = "1-0-1735344000000";
|
||||
await insertDose({ userId, doseId, dismissed: true, takenAt: null });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/doses/skip/${encodeURIComponent(doseId)}`,
|
||||
headers: { cookie: cookieHeader },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true });
|
||||
|
||||
const result = await testClient.execute({
|
||||
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||
args: [userId, doseId],
|
||||
});
|
||||
expect(Number(result.rows[0].count)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /doses/dismiss", () => {
|
||||
it("clears dismissed-only records and removes the dismissed flag from taken doses", async () => {
|
||||
await insertDose({ userId, doseId: "1-0-1735344000000", dismissed: true, takenAt: null });
|
||||
@@ -481,4 +545,174 @@ describe("Dose Tracking API", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared single-dose skip routes", () => {
|
||||
it("marks and undoes a visible shared dose as skipped", async () => {
|
||||
const start = buildLocalDoseStart();
|
||||
await insertMedication({
|
||||
id: 6,
|
||||
userId,
|
||||
takenBy: ["Max"],
|
||||
start,
|
||||
});
|
||||
await _insertShareToken(userId, "share-skip-token", "Max", false);
|
||||
|
||||
const doseId = `6-0-${new Date(start).getTime()}-Max`;
|
||||
|
||||
const skipResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/share/share-skip-token/doses/skip",
|
||||
payload: { doseId },
|
||||
});
|
||||
|
||||
expect(skipResponse.statusCode).toBe(200);
|
||||
expect(skipResponse.json()).toEqual({ success: true });
|
||||
|
||||
const skippedRows = await testClient.execute({
|
||||
sql: "SELECT dose_id, marked_by, dismissed FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||
args: [userId, doseId],
|
||||
});
|
||||
expect(skippedRows.rows).toEqual([expect.objectContaining({ dose_id: doseId, marked_by: null, dismissed: 1 })]);
|
||||
|
||||
const undoResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/share/share-skip-token/doses/skip/${encodeURIComponent(doseId)}`,
|
||||
});
|
||||
|
||||
expect(undoResponse.statusCode).toBe(200);
|
||||
expect(undoResponse.json()).toEqual({ success: true });
|
||||
|
||||
const remainingRows = await testClient.execute({
|
||||
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||
args: [userId, doseId],
|
||||
});
|
||||
expect(Number(remainingRows.rows[0].count)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Shared journal notes", () => {
|
||||
it("rejects shared journal access when the share link does not allow notes", async () => {
|
||||
const start = buildLocalDoseStart();
|
||||
await insertMedication({
|
||||
id: 7,
|
||||
userId,
|
||||
takenBy: ["Max"],
|
||||
start,
|
||||
});
|
||||
await _insertShareToken(userId, "token-no-notes", "Max", false);
|
||||
|
||||
const doseId = `7-0-${new Date(start).getTime()}-Max`;
|
||||
await insertDose({ userId, doseId, markedBy: "Max" });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/share/token-no-notes/journal/event/${encodeURIComponent(doseId)}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(response.json()).toEqual({
|
||||
error: "Journal notes are not enabled for this share link",
|
||||
code: "NOT_ENABLED",
|
||||
});
|
||||
});
|
||||
|
||||
it("supports shared journal note read and save, but not implicit or explicit delete", async () => {
|
||||
const start = buildLocalDoseStart();
|
||||
await insertMedication({
|
||||
id: 8,
|
||||
userId,
|
||||
takenBy: ["Max"],
|
||||
start,
|
||||
});
|
||||
await _insertShareToken(userId, "token-with-notes", "Max", true);
|
||||
|
||||
const doseId = `8-0-${new Date(start).getTime()}-Max`;
|
||||
await insertDose({ userId, doseId, markedBy: "Max" });
|
||||
|
||||
const initialResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`,
|
||||
});
|
||||
|
||||
expect(initialResponse.statusCode).toBe(200);
|
||||
expect(initialResponse.json().entry).toEqual(
|
||||
expect.objectContaining({
|
||||
doseId,
|
||||
markedBy: "Max",
|
||||
note: null,
|
||||
})
|
||||
);
|
||||
|
||||
const initialDosesResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/share/token-with-notes/doses",
|
||||
});
|
||||
|
||||
expect(initialDosesResponse.statusCode).toBe(200);
|
||||
expect(initialDosesResponse.json().doses).toEqual([
|
||||
expect.objectContaining({
|
||||
doseId,
|
||||
hasJournalNote: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
const saveResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`,
|
||||
payload: { note: "Shared note from Max" },
|
||||
});
|
||||
|
||||
expect(saveResponse.statusCode).toBe(200);
|
||||
expect(saveResponse.json().entry).toEqual(
|
||||
expect.objectContaining({
|
||||
doseId,
|
||||
note: "Shared note from Max",
|
||||
})
|
||||
);
|
||||
|
||||
const savedDosesResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/share/token-with-notes/doses",
|
||||
});
|
||||
|
||||
expect(savedDosesResponse.statusCode).toBe(200);
|
||||
expect(savedDosesResponse.json().doses).toEqual([
|
||||
expect.objectContaining({
|
||||
doseId,
|
||||
hasJournalNote: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const blankSaveResponse = await app.inject({
|
||||
method: "PUT",
|
||||
url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`,
|
||||
payload: { note: " " },
|
||||
});
|
||||
|
||||
expect(blankSaveResponse.statusCode).toBe(400);
|
||||
expect(blankSaveResponse.json()).toEqual({
|
||||
error: "Journal note cannot be empty",
|
||||
code: "EMPTY_NOTE",
|
||||
});
|
||||
|
||||
const deleteResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/share/token-with-notes/journal/event/${encodeURIComponent(doseId)}`,
|
||||
});
|
||||
|
||||
expect(deleteResponse.statusCode).toBe(403);
|
||||
expect(deleteResponse.json()).toEqual({
|
||||
error: "Shared links cannot delete journal notes",
|
||||
code: "DELETE_NOT_ALLOWED",
|
||||
});
|
||||
|
||||
const journalRows = await testClient.execute({
|
||||
sql: "SELECT note FROM intake_journal WHERE user_id = ? AND medication_id = ?",
|
||||
args: [userId, 8],
|
||||
});
|
||||
|
||||
expect(journalRows.rows).toHaveLength(1);
|
||||
expect(journalRows.rows[0].note).toBe("Shared note from Max");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
* These tests import the actual route handlers for real coverage.
|
||||
*/
|
||||
|
||||
import { existsSync, unlinkSync } from "node:fs";
|
||||
import cookie from "@fastify/cookie";
|
||||
import fastifyMultipart from "@fastify/multipart";
|
||||
import sensible from "@fastify/sensible";
|
||||
@@ -13,13 +14,16 @@ import { jwtPlugin } from "../plugins/jwt.js";
|
||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||
|
||||
// Use vi.hoisted to create the db BEFORE mocks are set up
|
||||
const { testClient, testDb } = vi.hoisted(() => {
|
||||
const { testClient, testDb, testDbPath } = vi.hoisted(() => {
|
||||
// Dynamic import inside hoisted block
|
||||
const { createClient } = require("@libsql/client");
|
||||
const { drizzle } = require("drizzle-orm/libsql");
|
||||
const client = createClient({ url: ":memory:" });
|
||||
const { tmpdir } = require("node:os");
|
||||
const { join } = require("node:path");
|
||||
const dbPath = join(tmpdir(), `medassist-e2e-routes-${process.pid}-${Date.now()}.db`);
|
||||
const client = createClient({ url: `file:${dbPath}` });
|
||||
const db = drizzle(client);
|
||||
return { testClient: client, testDb: db };
|
||||
return { testClient: client, testDb: db, testDbPath: dbPath };
|
||||
});
|
||||
|
||||
// Mock modules using the hoisted db
|
||||
@@ -171,6 +175,7 @@ async function createSchema(client: Client) {
|
||||
token text NOT NULL UNIQUE,
|
||||
taken_by text NOT NULL,
|
||||
schedule_days integer NOT NULL DEFAULT 30,
|
||||
allow_journal_notes integer NOT NULL DEFAULT 0,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
expires_at integer,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
@@ -184,6 +189,19 @@ async function createSchema(client: Client) {
|
||||
taken_source text NOT NULL DEFAULT 'manual',
|
||||
dismissed integer NOT NULL DEFAULT 0,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS intake_journal (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
user_id integer NOT NULL,
|
||||
dose_tracking_id integer NOT NULL UNIQUE,
|
||||
medication_id integer NOT NULL,
|
||||
scheduled_for integer NOT NULL,
|
||||
note text NOT NULL,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
updated_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (dose_tracking_id) REFERENCES dose_tracking(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (medication_id) REFERENCES medications(id) ON DELETE CASCADE
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS refill_history (
|
||||
id integer PRIMARY KEY AUTOINCREMENT,
|
||||
@@ -204,6 +222,7 @@ async function createSchema(client: Client) {
|
||||
}
|
||||
|
||||
async function clearData(client: Client) {
|
||||
await client.execute("DELETE FROM intake_journal");
|
||||
await client.execute("DELETE FROM refill_history");
|
||||
await client.execute("DELETE FROM dose_tracking");
|
||||
await client.execute("DELETE FROM share_tokens");
|
||||
@@ -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> {
|
||||
const start = new Date(visibleDoseTimestampMs()).toISOString();
|
||||
const result = await client.execute({
|
||||
sql: `INSERT INTO medications (user_id, name, taken_by_json, usage_json, every_json, start_json)
|
||||
VALUES (?, ?, ?, '[1]', '[1]', '["2025-01-01T08:00:00.000Z"]') RETURNING id`,
|
||||
args: [userId, name, JSON.stringify(takenBy)],
|
||||
VALUES (?, ?, ?, '[1]', '[1]', ?) RETURNING id`,
|
||||
args: [userId, name, JSON.stringify(takenBy), JSON.stringify([start])],
|
||||
});
|
||||
return result.rows[0].id as number;
|
||||
}
|
||||
@@ -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
|
||||
// =============================================================================
|
||||
@@ -386,6 +412,11 @@ describe("E2E Tests with Real Routes", () => {
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
testClient.close();
|
||||
for (const path of [testDbPath, `${testDbPath}-shm`, `${testDbPath}-wal`]) {
|
||||
if (existsSync(path)) {
|
||||
unlinkSync(path);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -508,12 +539,12 @@ describe("E2E Tests with Real Routes", () => {
|
||||
});
|
||||
|
||||
it("should mark dose via share link using real route", async () => {
|
||||
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
|
||||
const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
|
||||
|
||||
const token = "test_share_token_456";
|
||||
await createShareToken(testClient, userId, "Daniel", token);
|
||||
|
||||
const doseId = "1-0-1735344000000";
|
||||
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/share/${token}/doses`,
|
||||
@@ -1039,13 +1070,13 @@ describe("E2E Tests with Real Routes", () => {
|
||||
});
|
||||
|
||||
it("should unmark dose via share link", async () => {
|
||||
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
|
||||
const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
|
||||
|
||||
const token = "test_delete_dose_token";
|
||||
await createShareToken(testClient, userId, "Daniel", token);
|
||||
|
||||
// First mark the dose
|
||||
const doseId = "1-0-1735344000000";
|
||||
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`,
|
||||
args: [userId, doseId, "Daniel"],
|
||||
@@ -1089,12 +1120,12 @@ describe("E2E Tests with Real Routes", () => {
|
||||
});
|
||||
|
||||
it("should return already marked message for duplicate dose", async () => {
|
||||
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
|
||||
const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
|
||||
|
||||
const token = "test_duplicate_token";
|
||||
await createShareToken(testClient, userId, "Daniel", token);
|
||||
|
||||
const doseId = "1-0-1735344000000";
|
||||
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
|
||||
|
||||
// Mark the dose first time
|
||||
await app.inject({
|
||||
@@ -1530,6 +1561,59 @@ describe("E2E Tests with Real Routes", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("Share token management", () => {
|
||||
it("should list active share links for the owner", async () => {
|
||||
await createMedication(testClient, userId, "Med1", ["Daniel"]);
|
||||
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/share",
|
||||
payload: {
|
||||
takenBy: "Daniel",
|
||||
scheduleDays: 90,
|
||||
},
|
||||
});
|
||||
|
||||
expect(createResponse.statusCode).toBe(200);
|
||||
|
||||
const listResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/share",
|
||||
});
|
||||
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
const data = listResponse.json();
|
||||
expect(data.shareLinks).toHaveLength(1);
|
||||
expect(data.shareLinks[0].takenBy).toBe("Daniel");
|
||||
});
|
||||
|
||||
it("should revoke an active share link", async () => {
|
||||
await createMedication(testClient, userId, "Med1", ["Daniel"]);
|
||||
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/share",
|
||||
payload: {
|
||||
takenBy: "Daniel",
|
||||
scheduleDays: 30,
|
||||
},
|
||||
});
|
||||
|
||||
const { token } = createResponse.json();
|
||||
const revokeResponse = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/share/${token}`,
|
||||
});
|
||||
|
||||
expect(revokeResponse.statusCode).toBe(204);
|
||||
|
||||
const publicResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `/share/${token}`,
|
||||
});
|
||||
|
||||
expect(publicResponse.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("should create share token with custom scheduleDays", async () => {
|
||||
await createMedication(testClient, userId, "Med1", ["Daniel"]);
|
||||
|
||||
@@ -1548,6 +1632,34 @@ describe("E2E Tests with Real Routes", () => {
|
||||
expect(data.expiresAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("should create a share token with an expiry and keep it in the active owner list", async () => {
|
||||
await createMedication(testClient, userId, "Med1", ["Daniel"]);
|
||||
|
||||
const createResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/share",
|
||||
payload: {
|
||||
takenBy: "Daniel",
|
||||
scheduleDays: 30,
|
||||
expiryDays: 7,
|
||||
},
|
||||
});
|
||||
|
||||
expect(createResponse.statusCode).toBe(200);
|
||||
const created = createResponse.json();
|
||||
expect(created.expiresAt).toBeTruthy();
|
||||
|
||||
const listResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: "/share",
|
||||
});
|
||||
|
||||
expect(listResponse.statusCode).toBe(200);
|
||||
const listData = listResponse.json();
|
||||
expect(listData.shareLinks).toHaveLength(1);
|
||||
expect(listData.shareLinks[0].expiresAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it("should return validation error for invalid scheduleDays", async () => {
|
||||
await createMedication(testClient, userId, "Med1", ["Daniel"]);
|
||||
|
||||
@@ -1685,14 +1797,15 @@ describe("E2E Tests with Real Routes", () => {
|
||||
|
||||
describe("Share token dose routes", () => {
|
||||
it("should get taken doses via share link", async () => {
|
||||
await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
|
||||
const medId = await createMedication(testClient, userId, "Aspirin", ["Daniel"]);
|
||||
const token = "get-doses-token";
|
||||
await createShareToken(testClient, userId, "Daniel", token);
|
||||
|
||||
// Insert a dose directly
|
||||
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, marked_by) VALUES (?, ?, ?)`,
|
||||
args: [userId, "1-0-1735344000000", "Daniel"],
|
||||
args: [userId, doseId, "Daniel"],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
@@ -1703,7 +1816,7 @@ describe("E2E Tests with Real Routes", () => {
|
||||
expect(response.statusCode).toBe(200);
|
||||
const data = response.json();
|
||||
expect(data.doses).toHaveLength(1);
|
||||
expect(data.doses[0].doseId).toBe("1-0-1735344000000");
|
||||
expect(data.doses[0].doseId).toBe(doseId);
|
||||
expect(data.doses[0].markedBy).toBe("Daniel");
|
||||
});
|
||||
|
||||
@@ -3000,6 +3113,78 @@ describe("E2E Tests with Real Routes", () => {
|
||||
});
|
||||
|
||||
describe("Real /import routes", () => {
|
||||
it("should preview import data without mutating existing user data", async () => {
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/medications",
|
||||
payload: {
|
||||
name: "Existing Med",
|
||||
packCount: 2,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
});
|
||||
|
||||
const previewPayload = {
|
||||
version: "1.6",
|
||||
exportedAt: new Date().toISOString(),
|
||||
includeSensitiveData: true,
|
||||
medications: [
|
||||
{
|
||||
_exportId: "med-1",
|
||||
name: "Imported Med",
|
||||
inventory: { packCount: 1, blistersPerPack: 2, pillsPerBlister: 14, looseTablets: 0 },
|
||||
schedules: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
],
|
||||
settings: { language: "en", stockCalculationMode: "automatic" },
|
||||
shareLinks: [{ takenBy: "Person A", scheduleDays: 14 }],
|
||||
doseHistory: [
|
||||
{
|
||||
medicationRef: "med-1",
|
||||
scheduleIndex: 0,
|
||||
scheduledTime: "2025-01-01T08:00:00.000Z",
|
||||
takenAt: "2025-01-01T08:03:00.000Z",
|
||||
journalNote: "after breakfast",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const previewResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: "/import/preview",
|
||||
payload: previewPayload,
|
||||
});
|
||||
|
||||
expect(previewResponse.statusCode).toBe(200);
|
||||
expect(previewResponse.json()).toMatchObject({
|
||||
success: true,
|
||||
preview: {
|
||||
version: "1.6",
|
||||
includeSensitiveData: true,
|
||||
incoming: {
|
||||
medications: 1,
|
||||
doseHistory: 1,
|
||||
shareLinks: 1,
|
||||
journalEntries: 1,
|
||||
hasSettings: true,
|
||||
},
|
||||
current: {
|
||||
medications: 1,
|
||||
hasSettings: false,
|
||||
},
|
||||
warnings: {
|
||||
replacesExistingData: true,
|
||||
regeneratesShareLinks: true,
|
||||
containsSensitiveData: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
|
||||
expect(medsResponse.json()).toHaveLength(1);
|
||||
expect(medsResponse.json()[0].name).toBe("Existing Med");
|
||||
});
|
||||
|
||||
it("should import medications from export format", async () => {
|
||||
const importData = {
|
||||
version: "1.0",
|
||||
|
||||
@@ -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,
|
||||
taken_by text NOT NULL,
|
||||
schedule_days integer NOT NULL DEFAULT 30,
|
||||
allow_journal_notes integer NOT NULL DEFAULT 0,
|
||||
created_at integer NOT NULL DEFAULT (strftime('%s','now')),
|
||||
expires_at integer,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
@@ -195,6 +196,16 @@ async function clearData(client: Client) {
|
||||
await client.execute("DELETE FROM sqlite_sequence");
|
||||
}
|
||||
|
||||
function visibleDoseTimestampMs(): number {
|
||||
const doseDate = new Date();
|
||||
doseDate.setHours(8, 0, 0, 0);
|
||||
return doseDate.getTime();
|
||||
}
|
||||
|
||||
function visibleDoseStartIso(): string {
|
||||
return new Date(visibleDoseTimestampMs()).toISOString();
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
@@ -259,9 +270,11 @@ describe("Integration Tests", () => {
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
looseTablets: 10,
|
||||
blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }],
|
||||
},
|
||||
});
|
||||
expect(createRes.statusCode, createRes.body).toBe(200);
|
||||
const medId = createRes.json().id;
|
||||
|
||||
// Mark some doses (Jan 1, Jan 2, Jan 5, Jan 10)
|
||||
@@ -617,9 +630,10 @@ describe("Integration Tests", () => {
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }],
|
||||
},
|
||||
});
|
||||
expect(createRes.statusCode, createRes.body).toBe(200);
|
||||
const medId = createRes.json().id;
|
||||
|
||||
// Create share token for Daniel
|
||||
@@ -628,15 +642,17 @@ describe("Integration Tests", () => {
|
||||
url: "/share",
|
||||
payload: { takenBy: "Daniel", scheduleDays: 30 },
|
||||
});
|
||||
expect(shareRes.statusCode, shareRes.body).toBe(200);
|
||||
const token = shareRes.json().token;
|
||||
|
||||
// Mark dose via share link
|
||||
const doseId = `${medId}-0-${new Date("2025-01-01T08:00:00.000Z").getTime()}`;
|
||||
await app.inject({
|
||||
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
|
||||
const markRes = await app.inject({
|
||||
method: "POST",
|
||||
url: `/share/${token}/doses`,
|
||||
payload: { doseId },
|
||||
});
|
||||
expect(markRes.statusCode, markRes.body).toBe(200);
|
||||
|
||||
// Verify markedBy is "Daniel"
|
||||
const result = await testClient.execute({
|
||||
@@ -667,9 +683,10 @@ describe("Integration Tests", () => {
|
||||
payload: {
|
||||
name: "Vitamin D",
|
||||
takenBy: ["Anna"],
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }],
|
||||
},
|
||||
});
|
||||
expect(createRes.statusCode, createRes.body).toBe(200);
|
||||
const medId = createRes.json().id;
|
||||
|
||||
// Create share token
|
||||
@@ -678,21 +695,24 @@ describe("Integration Tests", () => {
|
||||
url: "/share",
|
||||
payload: { takenBy: "Anna", scheduleDays: 30 },
|
||||
});
|
||||
expect(shareRes.statusCode, shareRes.body).toBe(200);
|
||||
const token = shareRes.json().token;
|
||||
|
||||
// Mark a dose
|
||||
const doseId = `${medId}-0-${new Date("2025-01-05T08:00:00.000Z").getTime()}`;
|
||||
await app.inject({
|
||||
const doseId = `${medId}-0-${visibleDoseTimestampMs()}`;
|
||||
const markRes = await app.inject({
|
||||
method: "POST",
|
||||
url: `/share/${token}/doses`,
|
||||
payload: { doseId },
|
||||
});
|
||||
expect(markRes.statusCode, markRes.body).toBe(200);
|
||||
|
||||
// Get shared schedule
|
||||
const scheduleRes = await app.inject({
|
||||
method: "GET",
|
||||
url: `/share/${token}`,
|
||||
});
|
||||
expect(scheduleRes.statusCode, scheduleRes.body).toBe(200);
|
||||
|
||||
const data = scheduleRes.json();
|
||||
expect(data.takenBy).toBe("Anna");
|
||||
@@ -781,7 +801,7 @@ describe("Integration Tests", () => {
|
||||
payload: {
|
||||
name: "Family Vitamins",
|
||||
takenBy: ["Daniel", "Anna", "Max"],
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
blisters: [{ usage: 1, every: 1, start: visibleDoseStartIso() }],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -799,8 +819,8 @@ describe("Integration Tests", () => {
|
||||
});
|
||||
|
||||
// Both should succeed with different tokens
|
||||
expect(danielShare.statusCode).toBe(200);
|
||||
expect(annaShare.statusCode).toBe(200);
|
||||
expect(danielShare.statusCode, danielShare.body).toBe(200);
|
||||
expect(annaShare.statusCode, annaShare.body).toBe(200);
|
||||
expect(danielShare.json().token).not.toBe(annaShare.json().token);
|
||||
|
||||
// Each share link should show correct person
|
||||
|
||||
@@ -350,7 +350,12 @@ describe("notification action routes", () => {
|
||||
shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist",
|
||||
});
|
||||
const { takenToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
await testClient.execute({
|
||||
sql: "UPDATE notification_action_groups SET ntfy_original_message_id = ? WHERE user_id = ?",
|
||||
args: ["ntfy-msg-1", userId],
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: "ntfy-msg-2" }) });
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve("") });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -358,7 +363,7 @@ describe("notification action routes", () => {
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
const [targetUrl, requestInit] = fetchMock.mock.calls[0] ?? [];
|
||||
expect(targetUrl).toBe("https://ntfy.example.com/medassist");
|
||||
@@ -383,6 +388,21 @@ describe("notification action routes", () => {
|
||||
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 () => {
|
||||
@@ -393,7 +413,12 @@ describe("notification action routes", () => {
|
||||
shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist",
|
||||
});
|
||||
const { skipToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
await testClient.execute({
|
||||
sql: "UPDATE notification_action_groups SET ntfy_original_message_id = ? WHERE user_id = ?",
|
||||
args: ["ntfy-msg-7", userId],
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: "ntfy-msg-3" }) });
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve("") });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
@@ -401,7 +426,7 @@ describe("notification action routes", () => {
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
const [targetUrl, requestInit] = fetchMock.mock.calls[0] ?? [];
|
||||
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 () => {
|
||||
|
||||
@@ -39,6 +39,11 @@ function extractToken(url: string): string {
|
||||
return url.split("/").at(-1) ?? "";
|
||||
}
|
||||
|
||||
type ActionTokenRow = {
|
||||
kind: string | null;
|
||||
token_hash: string | null;
|
||||
};
|
||||
|
||||
async function clearTables() {
|
||||
await testClient.execute("DELETE FROM notification_action_tokens");
|
||||
await testClient.execute("DELETE FROM notification_action_groups");
|
||||
@@ -181,6 +186,97 @@ describe("notification-actions-service", () => {
|
||||
expect(Number(tokens.rows[0].count)).toBe(6);
|
||||
});
|
||||
|
||||
it("reactivates a resolved group with the same key instead of inserting a duplicate", async () => {
|
||||
const userId = await createUser("notify-actions-reactivate");
|
||||
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
|
||||
const doseIds = ["9-1-1736064000000", "9-0-1736064000000"];
|
||||
|
||||
const first = await createNotificationActionContext({
|
||||
userId,
|
||||
title: "Reminder",
|
||||
message: "Take your medication now",
|
||||
doseIds,
|
||||
scheduledFor,
|
||||
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
|
||||
language: "en",
|
||||
});
|
||||
|
||||
expect(first).toMatchObject({
|
||||
groupId: expect.any(Number),
|
||||
respondUrl: expect.stringContaining("/api/notification-actions/"),
|
||||
sequenceId: expect.stringMatching(/^medassist-/),
|
||||
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=9-0-1736064000000",
|
||||
});
|
||||
|
||||
const firstGroupId = first!.groupId!;
|
||||
const firstSequenceId = first!.sequenceId!;
|
||||
const firstRespondToken = extractToken(first!.respondUrl!);
|
||||
const firstTokenRows = await testClient.execute({
|
||||
sql: "SELECT kind, token_hash FROM notification_action_tokens WHERE group_id = ? ORDER BY kind ASC",
|
||||
args: [firstGroupId],
|
||||
});
|
||||
expect(firstTokenRows.rows).toHaveLength(3);
|
||||
|
||||
await testClient.execute({
|
||||
sql: "UPDATE notification_action_groups SET resolved_action = 'taken', resolved_at = ?, ntfy_original_message_id = 'old-message-id' WHERE id = ?",
|
||||
args: [new Date(), firstGroupId],
|
||||
});
|
||||
|
||||
const second = await createNotificationActionContext({
|
||||
userId,
|
||||
title: "Reminder",
|
||||
message: "Take your medication now",
|
||||
doseIds,
|
||||
scheduledFor,
|
||||
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
|
||||
language: "en",
|
||||
});
|
||||
|
||||
expect(second).toMatchObject({
|
||||
groupId: firstGroupId,
|
||||
respondUrl: expect.stringContaining("/api/notification-actions/"),
|
||||
sequenceId: expect.stringMatching(/^medassist-/),
|
||||
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=9-0-1736064000000",
|
||||
});
|
||||
expect(second?.sequenceId).toBe(firstSequenceId);
|
||||
|
||||
const groups = await testClient.execute(
|
||||
"SELECT id, sequence_id, resolved_action, resolved_at, ntfy_original_message_id FROM notification_action_groups"
|
||||
);
|
||||
expect(groups.rows).toHaveLength(1);
|
||||
expect(groups.rows[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
id: firstGroupId,
|
||||
sequence_id: second?.sequenceId,
|
||||
resolved_action: null,
|
||||
resolved_at: null,
|
||||
ntfy_original_message_id: "",
|
||||
})
|
||||
);
|
||||
|
||||
const secondTokenRows = await testClient.execute({
|
||||
sql: "SELECT kind, token_hash FROM notification_action_tokens WHERE group_id = ? ORDER BY kind ASC",
|
||||
args: [firstGroupId],
|
||||
});
|
||||
expect(secondTokenRows.rows).toHaveLength(3);
|
||||
expect(secondTokenRows.rows.map((row: ActionTokenRow) => row.kind)).toEqual(["respond", "skip", "taken"]);
|
||||
|
||||
const firstTokenHashes = new Set(firstTokenRows.rows.map((row: ActionTokenRow) => String(row.token_hash)));
|
||||
const secondTokenHashes = new Set(secondTokenRows.rows.map((row: ActionTokenRow) => String(row.token_hash)));
|
||||
expect(secondTokenHashes.size).toBe(3);
|
||||
expect([...secondTokenHashes].every((tokenHash) => !firstTokenHashes.has(tokenHash))).toBe(true);
|
||||
|
||||
expect(await getNotificationActionTokenRecord(firstRespondToken)).toBeNull();
|
||||
|
||||
const secondRespondToken = extractToken(second!.respondUrl!);
|
||||
const secondRespondRecord = await getNotificationActionTokenRecord(secondRespondToken);
|
||||
expect(secondRespondRecord).toMatchObject({
|
||||
doseIds: ["9-0-1736064000000", "9-1-1736064000000"],
|
||||
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=9-0-1736064000000",
|
||||
});
|
||||
expect(secondRespondRecord?.group.id).toBe(firstGroupId);
|
||||
});
|
||||
|
||||
it("prefers a non-local CORS origin when PUBLIC_APP_URL points to localhost", async () => {
|
||||
const userId = await createUser("notify-actions-mobile");
|
||||
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
|
||||
|
||||
@@ -248,6 +248,32 @@ describe("Planner Routes", () => {
|
||||
expect(response.json()).toEqual({ error: "Missing planner data" });
|
||||
});
|
||||
|
||||
it("should reject request when no planner date range can be resolved", async () => {
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/planner/send-email",
|
||||
payload: {
|
||||
email: "test@example.com",
|
||||
rows: [
|
||||
{
|
||||
medicationId: 1,
|
||||
medicationName: "Aspirin",
|
||||
totalPills: 30,
|
||||
plannerUsage: 10,
|
||||
blisterSize: 10,
|
||||
blistersNeeded: 1,
|
||||
fullBlisters: 3,
|
||||
loosePills: 0,
|
||||
enough: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "Missing planner date range" });
|
||||
});
|
||||
|
||||
it("should return error when no notification channels configured", async () => {
|
||||
// User settings exist but email/shoutrrr disabled
|
||||
await testClient.execute({
|
||||
@@ -282,6 +308,51 @@ describe("Planner Routes", () => {
|
||||
expect(response.json()).toEqual({ error: "No notification channels configured" });
|
||||
});
|
||||
|
||||
it("should accept startDate and endDate aliases for planner range", async () => {
|
||||
process.env.SMTP_HOST = "smtp.test.com";
|
||||
process.env.SMTP_USER = "user@test.com";
|
||||
process.env.SMTP_PASS = "password";
|
||||
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO user_settings (user_id, email_enabled, shoutrrr_enabled, language) VALUES (?, 1, 0, 'en')`,
|
||||
args: [999999999],
|
||||
});
|
||||
|
||||
mockSendMail.mockResolvedValueOnce({ messageId: "123", accepted: ["test.com"], rejected: [] });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: "/planner/send-email",
|
||||
payload: {
|
||||
email: "test@example.com",
|
||||
startDate: "2025-01-01T00:00:00.000Z",
|
||||
endDate: "2025-01-31T00:00:00.000Z",
|
||||
language: "en",
|
||||
rows: [
|
||||
{
|
||||
medicationId: 1,
|
||||
medicationName: "Aspirin",
|
||||
totalPills: 30,
|
||||
plannerUsage: 10,
|
||||
blisterSize: 10,
|
||||
blistersNeeded: 1,
|
||||
fullBlisters: 3,
|
||||
loosePills: 0,
|
||||
enough: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true, message: "Notification sent via email" });
|
||||
expect(mockSendMail).toHaveBeenCalledTimes(1);
|
||||
|
||||
delete process.env.SMTP_HOST;
|
||||
delete process.env.SMTP_USER;
|
||||
delete process.env.SMTP_PASS;
|
||||
});
|
||||
|
||||
it("should send email successfully when SMTP is configured", async () => {
|
||||
// Set SMTP env vars
|
||||
process.env.SMTP_HOST = "smtp.test.com";
|
||||
|
||||
@@ -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 { fileURLToPath } from "node:url";
|
||||
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 { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||
|
||||
const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hoisted(() => {
|
||||
const { testClient, testDb, testDbPath, mockedEnv, nodemailerSendMail, fetchMock } = vi.hoisted(() => {
|
||||
const { createClient } = require("@libsql/client");
|
||||
const { drizzle } = require("drizzle-orm/libsql");
|
||||
const client = createClient({ url: ":memory:" });
|
||||
const { tmpdir } = require("node:os");
|
||||
const { join } = require("node:path");
|
||||
const dbPath = join(tmpdir(), `medassist-routes-real-${process.pid}-${Date.now()}.db`);
|
||||
const client = createClient({ url: `file:${dbPath}` });
|
||||
const db = drizzle(client);
|
||||
const env = {
|
||||
AUTH_ENABLED: false,
|
||||
@@ -22,6 +26,7 @@ const { testClient, testDb, mockedEnv, nodemailerSendMail, fetchMock } = vi.hois
|
||||
return {
|
||||
testClient: client,
|
||||
testDb: db,
|
||||
testDbPath: dbPath,
|
||||
mockedEnv: env,
|
||||
nodemailerSendMail: vi.fn(),
|
||||
fetchMock: vi.fn(),
|
||||
@@ -121,6 +126,9 @@ describe("Real route coverage: settings/export/report", () => {
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
testClient.close();
|
||||
if (existsSync(testDbPath)) {
|
||||
unlinkSync(testDbPath);
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -647,7 +655,7 @@ describe("Real route coverage: settings/export/report", () => {
|
||||
});
|
||||
await testClient.execute({
|
||||
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({
|
||||
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);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const medId = await seedMedication("Export Med");
|
||||
await testClient.execute({
|
||||
|
||||
@@ -177,18 +177,26 @@ export interface CreateShareTokenOptions {
|
||||
token?: string;
|
||||
scheduleDays?: number;
|
||||
expiresAt?: number | null;
|
||||
allowJournalNotes?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a test share token and return the token string
|
||||
*/
|
||||
export async function createTestShareToken(client: Client, options: CreateShareTokenOptions): Promise<string> {
|
||||
const { userId, takenBy, token = `test_token_${Date.now()}`, scheduleDays = 30, expiresAt = null } = options;
|
||||
const {
|
||||
userId,
|
||||
takenBy,
|
||||
token = `test_token_${Date.now()}`,
|
||||
scheduleDays = 30,
|
||||
expiresAt = null,
|
||||
allowJournalNotes = false,
|
||||
} = options;
|
||||
|
||||
await client.execute({
|
||||
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
args: [userId, token, takenBy, scheduleDays, expiresAt],
|
||||
sql: `INSERT INTO share_tokens (user_id, token, taken_by, schedule_days, expires_at, allow_journal_notes)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
args: [userId, token, takenBy, scheduleDays, expiresAt, allowJournalNotes ? 1 : 0],
|
||||
});
|
||||
|
||||
return token;
|
||||
|
||||
@@ -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"],
|
||||
setupFiles: ["src/test/setup.ts"],
|
||||
// Run tests sequentially to avoid DB conflicts
|
||||
poolOptions: {
|
||||
threads: {
|
||||
singleThread: true,
|
||||
},
|
||||
},
|
||||
fileParallelism: false,
|
||||
maxWorkers: 1,
|
||||
// Timeout for longer integration tests
|
||||
testTimeout: 10000,
|
||||
coverage: {
|
||||
|
||||
+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 |
|
||||
| `PGID` | `1000` | Group ID for container file permissions |
|
||||
| `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 |
|
||||
| `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` |
|
||||
| `RATE_LIMIT_MAX` | `100` | Maximum requests per minute per IP |
|
||||
| `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=false` disables the docs only.
|
||||
|
||||
`CORS_ORIGINS` note:
|
||||
|
||||
- The `.env.example` file is optimized for the Docker Compose quickstart, where the frontend runs on `http://localhost:4174`.
|
||||
- Local frontend development uses the Vite dev server instead, so the backend schema defaults cover `http://localhost:5173` and `http://localhost:4173`.
|
||||
- If you use a custom hostname or reverse proxy, include that origin in `CORS_ORIGINS`.
|
||||
|
||||
## Authentication
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -102,12 +108,18 @@ API reference:
|
||||
|
||||
Reminder timing uses IANA timezones. `TZ` is the server default. Users can override it in Settings.
|
||||
|
||||
These values are runtime defaults. User-specific settings can override reminder behavior after first save.
|
||||
|
||||
## Push Notifications
|
||||
|
||||
Push notification setup, provider support, and URL examples are documented in [PUSH_NOTIFICATIONS.md](PUSH_NOTIFICATIONS.md).
|
||||
|
||||
Recommended provider: `ntfy`, especially for intake reminders with direct actions.
|
||||
|
||||
Notification action and share links should use `PUBLIC_APP_URL` as their reachable base URL. For self-hosted setups, this should normally be your externally reachable HTTPS address, for example `https://med.example.com`.
|
||||
|
||||
If `PUBLIC_APP_URL` is missing in a remote deployment, reminder links can still be generated from local origins that are unreachable from phones or external browsers.
|
||||
|
||||
## Default User Settings
|
||||
|
||||
Default values for newly created users are documented in [DEFAULT_USER_SETTINGS.md](DEFAULT_USER_SETTINGS.md).
|
||||
|
||||
+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.
|
||||
|
||||
## API Proxy Contract
|
||||
|
||||
- Frontend browser code should call `/api/*`, not hardcoded backend hostnames.
|
||||
- Vite rewrites `/api/*` to the backend target configured by `BACKEND_URL` or the built-in default for the current environment.
|
||||
- Default backend target:
|
||||
- local dev outside Docker: `http://localhost:3000`
|
||||
- dev stack inside Docker: `http://backend-dev:3000`
|
||||
- If your backend runs on a different host or service name, set `BACKEND_URL` explicitly before starting Vite.
|
||||
|
||||
- `BACKEND_URL`: backend target used by the Vite `/api` proxy; default `http://localhost:3000` outside Docker and `http://backend-dev:3000` in Docker
|
||||
- `VITE_ALLOWED_HOSTS`: comma-separated hostnames allowed to connect to the dev server; default `localhost,127.0.0.1`
|
||||
- `VITE_ALLOWED_HOSTS`: comma-separated hostnames allowed to connect to the dev server; default `localhost,127.0.0.1` plus the hostname from `PUBLIC_APP_URL` when configured
|
||||
- `VITE_HMR_HOST`: public hostname for HMR websocket connections
|
||||
- `VITE_HMR_PROTOCOL`: websocket protocol override (`ws` or `wss`)
|
||||
- `VITE_HMR_CLIENT_PORT`: public websocket port exposed to the browser
|
||||
@@ -30,6 +39,17 @@ These development overrides are documented here intentionally and are not part o
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
npm run check
|
||||
npm run build
|
||||
cd backend && npm run test:run
|
||||
cd frontend && npm run test:run
|
||||
```
|
||||
```
|
||||
|
||||
Recommended local maintenance preflight before opening or updating a PR:
|
||||
|
||||
```bash
|
||||
npm run check
|
||||
npm run build
|
||||
```
|
||||
|
||||
Use the root-level commands for full-stack validation when a change spans backend and frontend. Keep using the package-local commands when you are validating only one slice.
|
||||
|
||||
@@ -6,7 +6,11 @@ MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notificatio
|
||||
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Notification action links such as `Take`, `Skip`, and `View` use `PUBLIC_APP_URL` as their base URL. Set this to the public MedAssist URL that the receiving device can actually reach.
|
||||
|
||||
Good examples:
|
||||
|
||||
```text
|
||||
https://med.example.com
|
||||
https://medtest.example.com
|
||||
```
|
||||
|
||||
Bad examples for notification actions:
|
||||
|
||||
```text
|
||||
http://localhost:3000
|
||||
http://backend-dev:3000
|
||||
http://192.168.x.x:3000
|
||||
```
|
||||
|
||||
Push-related default variables:
|
||||
|
||||
| Variable | Default | Description |
|
||||
@@ -68,4 +89,22 @@ telegram://TOKEN@telegram?chats=CHAT_ID
|
||||
telegram://TOKEN@telegram?chats=@your_channel,-1001234567890
|
||||
```
|
||||
|
||||
For all supported services and options, see the [Shoutrrr documentation](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
|
||||
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 path from "node:path";
|
||||
import { type APIResponse, type Cookie, expect, test as setup } from "@playwright/test";
|
||||
import { type APIResponse, expect, type Page, test as setup } from "@playwright/test";
|
||||
import { applyVideoSafetyMode, TEST_USER } from "./fixtures";
|
||||
|
||||
const authFile = path.join(import.meta.dirname, ".auth", "user.json");
|
||||
|
||||
type StoredAuthCookie = {
|
||||
name: string;
|
||||
value: string;
|
||||
domain: string;
|
||||
path: string;
|
||||
expires: number;
|
||||
httpOnly: boolean;
|
||||
secure: boolean;
|
||||
sameSite: "Strict" | "Lax" | "None";
|
||||
};
|
||||
|
||||
type BrowserCookie = {
|
||||
name: string;
|
||||
value: string;
|
||||
url: string;
|
||||
expires?: number;
|
||||
httpOnly: boolean;
|
||||
secure: boolean;
|
||||
sameSite: "Strict" | "Lax" | "None";
|
||||
};
|
||||
|
||||
type StoredAuthState = {
|
||||
cookies?: StoredAuthCookie[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a JWT token is still valid (not expired) without making a
|
||||
* network request. Returns `true` when the token has at least 2 minutes
|
||||
@@ -21,7 +46,7 @@ function isTokenValid(token: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function toBrowserCookie(setCookieHeader: string, baseURL: string): Cookie | null {
|
||||
function toBrowserCookie(setCookieHeader: string, baseURL: string): BrowserCookie | null {
|
||||
const segments = setCookieHeader
|
||||
.split(";")
|
||||
.map((segment) => segment.trim())
|
||||
@@ -36,7 +61,7 @@ function toBrowserCookie(setCookieHeader: string, baseURL: string): Cookie | nul
|
||||
return null;
|
||||
}
|
||||
|
||||
const cookie: Cookie = {
|
||||
const cookie: BrowserCookie = {
|
||||
name: nameValue.slice(0, separatorIndex),
|
||||
value: nameValue.slice(separatorIndex + 1),
|
||||
url: baseURL,
|
||||
@@ -90,16 +115,12 @@ function toBrowserCookie(setCookieHeader: string, baseURL: string): Cookie | nul
|
||||
return cookie;
|
||||
}
|
||||
|
||||
async function syncResponseCookiesToBrowserContext(
|
||||
page: Parameters<Parameters<typeof setup>[0]>[0]["page"],
|
||||
baseURL: string,
|
||||
response: APIResponse
|
||||
): Promise<void> {
|
||||
async function syncResponseCookiesToBrowserContext(page: Page, baseURL: string, response: APIResponse): Promise<void> {
|
||||
const cookies = response
|
||||
.headersArray()
|
||||
.filter((header) => header.name.toLowerCase() === "set-cookie")
|
||||
.map((header) => toBrowserCookie(header.value, baseURL))
|
||||
.filter((cookie): cookie is Cookie => cookie !== null);
|
||||
.filter((cookie): cookie is BrowserCookie => cookie !== null);
|
||||
|
||||
if (cookies.length > 0) {
|
||||
await page.context().addCookies(cookies);
|
||||
@@ -120,6 +141,7 @@ async function syncResponseCookiesToBrowserContext(
|
||||
setup("authenticate", async ({ page }) => {
|
||||
setup.setTimeout(120000);
|
||||
await applyVideoSafetyMode(page);
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
||||
|
||||
// Create .auth directory if it doesn't exist
|
||||
const authDir = path.dirname(authFile);
|
||||
@@ -130,11 +152,41 @@ setup("authenticate", async ({ page }) => {
|
||||
// ---- 1. Try to reuse an existing auth file (offline check only) ----
|
||||
if (fs.existsSync(authFile)) {
|
||||
try {
|
||||
const saved = JSON.parse(fs.readFileSync(authFile, "utf-8"));
|
||||
const saved = JSON.parse(fs.readFileSync(authFile, "utf-8")) as StoredAuthState;
|
||||
const accessCookie = saved.cookies?.find((c: { name: string }) => c.name === "access_token");
|
||||
const refreshCookie = saved.cookies?.find((c: { name: string }) => c.name === "refresh_token");
|
||||
|
||||
if (saved.cookies?.length) {
|
||||
await page.context().addCookies(saved.cookies);
|
||||
}
|
||||
|
||||
if (accessCookie?.value && isTokenValid(accessCookie.value)) {
|
||||
// Keep going and verify the session online. A JWT can be time-valid but
|
||||
// still rejected by backend token rotation/restart.
|
||||
const hasSavedSession = await page.request
|
||||
.get(`${baseURL}/api/auth/me`)
|
||||
.then((response) => response.ok())
|
||||
.catch(() => false);
|
||||
|
||||
if (hasSavedSession) {
|
||||
await page.context().storageState({ path: authFile });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (refreshCookie?.value) {
|
||||
const refreshResponse = await page.request.post(`${baseURL}/api/auth/refresh`).catch(() => null);
|
||||
if (refreshResponse?.ok()) {
|
||||
await syncResponseCookiesToBrowserContext(page, baseURL, refreshResponse);
|
||||
|
||||
const refreshedSession = await page.request
|
||||
.get(`${baseURL}/api/auth/me`)
|
||||
.then((response) => response.ok())
|
||||
.catch(() => false);
|
||||
|
||||
if (refreshedSession) {
|
||||
await page.context().storageState({ path: authFile });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Invalid file — fall through to regular login
|
||||
@@ -143,7 +195,6 @@ setup("authenticate", async ({ page }) => {
|
||||
|
||||
// ---- 2. Fast path: already authenticated session ----
|
||||
await page.goto("/");
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
|
||||
let authEnabled = true;
|
||||
let formLoginEnabled = true;
|
||||
let oidcEnabled = false;
|
||||
|
||||
@@ -289,6 +289,7 @@ export interface TestShareToken {
|
||||
token: string;
|
||||
takenBy: string;
|
||||
scheduleDays: number;
|
||||
allowJournalNotes?: boolean;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
@@ -303,7 +304,7 @@ export async function createMedicationViaAPI(data: {
|
||||
takenBy?: string[];
|
||||
notes?: string;
|
||||
expiryDate?: string;
|
||||
packageType?: "blister" | "bottle" | "tube" | "liquid_container";
|
||||
packageType?: "blister" | "bottle" | "tube" | "liquid_container" | "inhaler" | "injection";
|
||||
medicationForm?: "capsule" | "tablet" | "liquid" | "topical";
|
||||
packCount?: number;
|
||||
blistersPerPack?: number;
|
||||
@@ -323,7 +324,12 @@ export async function createMedicationViaAPI(data: {
|
||||
let token = await ensureAuthCookie();
|
||||
const apiBase = await getRuntimeApiBase();
|
||||
const packageType = data.packageType ?? "blister";
|
||||
const isAmountBased = packageType === "bottle" || packageType === "tube" || packageType === "liquid_container";
|
||||
const isAmountBased =
|
||||
packageType === "bottle" ||
|
||||
packageType === "tube" ||
|
||||
packageType === "liquid_container" ||
|
||||
packageType === "inhaler" ||
|
||||
packageType === "injection";
|
||||
let defaultMedicationForm: "capsule" | "tablet" | "liquid" | "topical" = "tablet";
|
||||
if (packageType === "tube") {
|
||||
defaultMedicationForm = "topical";
|
||||
@@ -455,7 +461,11 @@ export async function deleteAllMedicationsViaAPI(): Promise<void> {
|
||||
* Create a share token via the backend API.
|
||||
* Requires a medication with takenBy to exist first.
|
||||
*/
|
||||
export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30): Promise<TestShareToken> {
|
||||
export async function createShareTokenViaAPI(
|
||||
takenBy: string,
|
||||
scheduleDays = 30,
|
||||
options: { allowJournalNotes?: boolean; expiryDays?: number | null } = {}
|
||||
): Promise<TestShareToken> {
|
||||
let token = await ensureAuthCookie();
|
||||
const apiBase = await getRuntimeApiBase();
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
@@ -465,7 +475,12 @@ export async function createShareTokenViaAPI(takenBy: string, scheduleDays = 30)
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Cookie: `access_token=${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ takenBy, scheduleDays }),
|
||||
body: JSON.stringify({
|
||||
takenBy,
|
||||
scheduleDays,
|
||||
expiryDays: options.expiryDays ?? null,
|
||||
allowJournalNotes: options.allowJournalNotes ?? false,
|
||||
}),
|
||||
});
|
||||
if (res.status === 401) {
|
||||
token = await refreshAuthCookieViaLogin();
|
||||
|
||||
@@ -26,7 +26,7 @@ async function fillAndSaveMedication(
|
||||
opts: {
|
||||
name: string;
|
||||
genericName?: string;
|
||||
packageType?: "blister" | "bottle" | "tube" | "liquid_container";
|
||||
packageType?: "blister" | "bottle" | "tube" | "liquid_container" | "inhaler" | "injection";
|
||||
packs?: string;
|
||||
blistersPerPack?: string;
|
||||
pillsPerBlister?: string;
|
||||
@@ -50,12 +50,17 @@ async function fillAndSaveMedication(
|
||||
}
|
||||
|
||||
const packageTypeSelect = form.locator("select.package-type-select");
|
||||
if (opts.packageType === "bottle") {
|
||||
await packageTypeSelect.selectOption("bottle");
|
||||
if (opts.packageType === "bottle" || opts.packageType === "inhaler" || opts.packageType === "injection") {
|
||||
await packageTypeSelect.selectOption(opts.packageType ?? "bottle");
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
if (opts.totalCapacity)
|
||||
await form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\))/i).fill(opts.totalCapacity);
|
||||
if (opts.currentPills) await form.getByLabel(/(Current Pills|form\.currentPills)/i).fill(opts.currentPills);
|
||||
await form
|
||||
.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(pills\)|Total \(count\)|form\.totalCount)/i)
|
||||
.fill(opts.totalCapacity);
|
||||
if (opts.currentPills)
|
||||
await form
|
||||
.getByLabel(/(Current Pills|form\.currentPills|Current Stock|form\.currentStockCount)/i)
|
||||
.fill(opts.currentPills);
|
||||
} else if (opts.packageType === "tube") {
|
||||
await packageTypeSelect.selectOption("tube");
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
@@ -95,12 +100,12 @@ async function fillAndSaveMedication(
|
||||
await form.getByRole("button", { name: /(Intake|form\.blisters\.addIntake)/i }).click();
|
||||
}
|
||||
const row = form.locator(".blister-row").nth(i);
|
||||
await row
|
||||
.getByLabel(
|
||||
/(Usage \((pills|tablets|capsules|ml|applications)\)|form\.blisters\.(usage|usageTablets|usageCapsules|usageMl|usageApplication))/i
|
||||
)
|
||||
.fill(intakes[i].usage);
|
||||
await row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i).fill(intakes[i].every);
|
||||
const usageField = row.getByRole("textbox", {
|
||||
name: /(Usage|Tablets|Capsules|Applications|Puffs|Injections|Ml|form\.blisters\.usage|common\.(puffs|injections))/i,
|
||||
});
|
||||
const everyField = row.getByLabel(/(Every \(days\)|form\.blisters\.everyDays)/i);
|
||||
await usageField.fill(intakes[i].usage);
|
||||
await everyField.fill(intakes[i].every);
|
||||
}
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
@@ -195,6 +200,38 @@ test.describe("Medication CRUD", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("should create an inhaler medication via the form", async ({ page }) => {
|
||||
await navigateTo(page, "/medications");
|
||||
|
||||
await fillAndSaveMedication(page, {
|
||||
name: "Test Rescue Inhaler",
|
||||
packageType: "inhaler",
|
||||
totalCapacity: "200",
|
||||
currentPills: "120",
|
||||
intakes: [{ usage: "2", every: "1" }],
|
||||
});
|
||||
|
||||
const medRow = page.locator(".med-row").filter({ hasText: "Test Rescue Inhaler" });
|
||||
await expect(medRow.locator(".med-details")).toContainText(/Inhaler|form\.packageTypeInhaler/i);
|
||||
await expect(medRow.locator(".med-total")).toContainText("120 / 200");
|
||||
});
|
||||
|
||||
test("should create an injection medication via the form", async ({ page }) => {
|
||||
await navigateTo(page, "/medications");
|
||||
|
||||
await fillAndSaveMedication(page, {
|
||||
name: "Test Weekly Injection",
|
||||
packageType: "injection",
|
||||
totalCapacity: "12",
|
||||
currentPills: "4",
|
||||
intakes: [{ usage: "1", every: "7" }],
|
||||
});
|
||||
|
||||
const medRow = page.locator(".med-row").filter({ hasText: "Test Weekly Injection" });
|
||||
await expect(medRow.locator(".med-details")).toContainText(/Injection|form\.packageTypeInjection/i);
|
||||
await expect(medRow.locator(".med-total")).toContainText("4 / 12");
|
||||
});
|
||||
|
||||
test("should create medication with multiple intake schedules", async ({ page }) => {
|
||||
await navigateTo(page, "/medications");
|
||||
|
||||
|
||||
@@ -33,6 +33,28 @@ async function clickEditMed(page: Page, medName: string): Promise<void> {
|
||||
});
|
||||
}
|
||||
|
||||
async function openMedicationDetailFromDashboard(page: Page, medName: string) {
|
||||
const overviewTable = page.locator(".dashboard-overview-section .table").first();
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
await expect(overviewTable).toBeVisible({ timeout: 10000 });
|
||||
const medRow = overviewTable.locator(".table-row").filter({ hasText: medName });
|
||||
await expect(medRow).toBeVisible({ timeout: 10000 });
|
||||
await medRow.click();
|
||||
const modal = page.locator(".modal-content.med-detail-modal");
|
||||
await expect(modal).toBeVisible({ timeout: 5000 });
|
||||
await expect(modal.getByText(medName)).toBeVisible({ timeout: 5000 });
|
||||
return modal;
|
||||
} catch {
|
||||
if (attempt === 2) throw new Error(`Failed to open dashboard medication detail for ${medName}`);
|
||||
await page.reload();
|
||||
await page.waitForLoadState("networkidle");
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`Failed to open dashboard medication detail for ${medName}`);
|
||||
}
|
||||
|
||||
/** Helper: save edit and verify success */
|
||||
async function saveEditAndVerify(page: Page, medName: string): Promise<void> {
|
||||
const form = page.locator("form.form-grid:visible").first();
|
||||
@@ -310,24 +332,107 @@ test.describe("Medication Editing", () => {
|
||||
|
||||
// Find the remind checkbox in the intake row
|
||||
const intakeRow = page.locator(".blister-row").first();
|
||||
const remindCheckbox = intakeRow.locator('input[type="checkbox"]');
|
||||
const remindToggle = intakeRow.locator(".toggle-switch");
|
||||
const remindCheckbox = intakeRow.locator('.toggle-switch input[type="checkbox"]');
|
||||
|
||||
if (await remindCheckbox.isVisible().catch(() => false)) {
|
||||
// Should be unchecked initially
|
||||
await expect(remindCheckbox).not.toBeChecked();
|
||||
await remindToggle.click();
|
||||
await expect(remindCheckbox).toBeChecked();
|
||||
|
||||
await saveEditAndVerify(page, "Reminder Toggle Med");
|
||||
|
||||
// Verify reminder was saved
|
||||
await clickEditMed(page, "Reminder Toggle Med");
|
||||
const savedCheckbox = page.locator(".blister-row").first().locator('.toggle-switch input[type="checkbox"]');
|
||||
await expect(savedCheckbox).toBeChecked();
|
||||
});
|
||||
|
||||
for (const scenario of [
|
||||
{
|
||||
name: "Inhaler Reminder Refill Med",
|
||||
packageType: "inhaler" as const,
|
||||
totalCapacity: 200,
|
||||
currentStock: 120,
|
||||
refillAmount: 30,
|
||||
expectedStock: 150,
|
||||
unitLabel: /puffs?|common\.puffs?/i,
|
||||
},
|
||||
{
|
||||
name: "Injection Reminder Refill Med",
|
||||
packageType: "injection" as const,
|
||||
totalCapacity: 12,
|
||||
currentStock: 4,
|
||||
refillAmount: 3,
|
||||
expectedStock: 7,
|
||||
unitLabel: /injections?|common\.injections?/i,
|
||||
},
|
||||
]) {
|
||||
test(`should persist reminders and refill ${scenario.packageType} stock without drift`, async ({ page }) => {
|
||||
createdMeds.push(
|
||||
await createMedicationViaAPI({
|
||||
name: scenario.name,
|
||||
packageType: scenario.packageType,
|
||||
totalPills: scenario.totalCapacity,
|
||||
looseTablets: scenario.currentStock,
|
||||
intakes: [
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: new Date().toISOString().slice(0, 16),
|
||||
intakeRemindersEnabled: false,
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
await navigateTo(page, "/medications");
|
||||
await clickEditMed(page, scenario.name);
|
||||
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||
|
||||
const intakeRow = page.locator(".blister-row").first();
|
||||
const remindToggle = intakeRow.locator(".toggle-switch");
|
||||
const remindCheckbox = intakeRow.locator('.toggle-switch input[type="checkbox"]');
|
||||
await expect(remindCheckbox).not.toBeChecked();
|
||||
|
||||
// Enable it
|
||||
await remindCheckbox.check();
|
||||
await remindToggle.click();
|
||||
await expect(remindCheckbox).toBeChecked();
|
||||
|
||||
await saveEditAndVerify(page, "Reminder Toggle Med");
|
||||
await saveEditAndVerify(page, scenario.name);
|
||||
|
||||
// Verify reminder was saved
|
||||
await clickEditMed(page, "Reminder Toggle Med");
|
||||
const savedCheckbox = page.locator(".blister-row").first().locator('input[type="checkbox"]');
|
||||
await expect(savedCheckbox).toBeChecked();
|
||||
}
|
||||
});
|
||||
await clickEditMed(page, scenario.name);
|
||||
await page.getByRole("tab", { name: /Schedule/i }).click();
|
||||
await expect(page.locator(".blister-row").first().locator('.toggle-switch input[type="checkbox"]')).toBeChecked();
|
||||
|
||||
await navigateTo(page, "/dashboard");
|
||||
const modal = await openMedicationDetailFromDashboard(page, scenario.name);
|
||||
|
||||
await modal.getByRole("button", { name: /Refill|refill\.button/i }).click();
|
||||
const refillModal = page.locator(".modal-content.refill-modal");
|
||||
await expect(refillModal).toBeVisible({ timeout: 5000 });
|
||||
const refillInput = refillModal.locator('input[type="number"]').first();
|
||||
await refillInput.fill(String(scenario.refillAmount));
|
||||
await expect(refillModal.locator(".refill-preview")).toContainText(`+${scenario.refillAmount}`);
|
||||
await expect(refillModal.locator(".refill-preview")).toContainText(scenario.unitLabel);
|
||||
|
||||
await refillModal.locator(".modal-footer .success").click();
|
||||
await expect(refillModal).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
const refillHistoryHeader = modal.locator(".med-detail-section h3").filter({
|
||||
hasText: /Refill History|refill\.history/i,
|
||||
});
|
||||
await expect(refillHistoryHeader).toBeVisible({ timeout: 10000 });
|
||||
await refillHistoryHeader.click();
|
||||
const refillAmount = modal.locator(".refill-history-item .refill-amount").first();
|
||||
await expect(refillAmount).toContainText(`+${scenario.refillAmount}`);
|
||||
await expect(refillAmount).toContainText(scenario.unitLabel);
|
||||
|
||||
await page.locator("button.modal-close").click();
|
||||
await expect(modal).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
await navigateTo(page, "/medications");
|
||||
const medRow = page.locator(".med-row").filter({ hasText: scenario.name });
|
||||
await expect(medRow.locator(".med-total")).toContainText(`${scenario.expectedStock} / ${scenario.totalCapacity}`);
|
||||
});
|
||||
}
|
||||
|
||||
test("should change package type across all supported profiles", async ({ page }) => {
|
||||
createdMeds.push(
|
||||
@@ -369,12 +474,30 @@ test.describe("Medication Editing", () => {
|
||||
await packageSelect.selectOption("liquid_container");
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
await expect(form.getByLabel(/(Package amount|form\.packageAmount)/i)).toBeVisible();
|
||||
await page.getByRole("tab", { name: /General/i }).click();
|
||||
|
||||
// Switch to inhaler
|
||||
await packageSelect.selectOption("inhaler");
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
await expect(
|
||||
form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(count\)|form\.totalCount)/i)
|
||||
).toBeVisible();
|
||||
await expect(form.getByLabel(/(Current Stock|form\.currentStockCount)/i)).toBeVisible();
|
||||
await page.getByRole("tab", { name: /General/i }).click();
|
||||
|
||||
// Switch to injection and persist this final state
|
||||
await packageSelect.selectOption("injection");
|
||||
await page.getByRole("tab", { name: /Package/i }).click();
|
||||
await expect(
|
||||
form.getByLabel(/(Total Capacity|form\.totalCapacity|Total \(count\)|form\.totalCount)/i)
|
||||
).toBeVisible();
|
||||
await expect(form.getByLabel(/(Current Stock|form\.currentStockCount)/i)).toBeVisible();
|
||||
|
||||
await saveEditAndVerify(page, "PackType Change Med");
|
||||
|
||||
// Verify final package type persisted
|
||||
await clickEditMed(page, "PackType Change Med");
|
||||
await expect(page.locator("select.package-type-select")).toHaveValue("liquid_container");
|
||||
await expect(page.locator("select.package-type-select")).toHaveValue("injection");
|
||||
});
|
||||
|
||||
test("should edit multiple fields at once (name, notes, generic, taken-by)", async ({ page }) => {
|
||||
|
||||
@@ -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.use({ storageState: authFile });
|
||||
test.describe.configure({ timeout: 90000 });
|
||||
test.describe.configure({ mode: "serial", timeout: 90000 });
|
||||
|
||||
const MED_ALICE = "ShareTest AliceMed";
|
||||
const MED_BOB = "ShareTest BobMed";
|
||||
@@ -300,4 +300,59 @@ test.describe("Share Schedule", () => {
|
||||
|
||||
await page.locator("button.modal-close").click();
|
||||
});
|
||||
|
||||
test("should let a shared recipient add and reopen a journal note", async ({ page }) => {
|
||||
const uniqueSuffix = Date.now().toString(36);
|
||||
const person = `Journal E2E ${uniqueSuffix}`;
|
||||
const medicationName = `Share Journal E2E ${uniqueSuffix}`;
|
||||
const journalNote = `Shared E2E note ${uniqueSuffix}`;
|
||||
|
||||
await createMedicationViaAPI({
|
||||
name: medicationName,
|
||||
takenBy: [person],
|
||||
packageType: "blister",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
intakes: [{ usage: 1, every: 1, start: todayMorning, intakeRemindersEnabled: false, takenBy: person }],
|
||||
});
|
||||
|
||||
const shareToken = await createShareTokenViaAPI(person, 30, { allowJournalNotes: true });
|
||||
|
||||
await page.goto(`/share/${shareToken.token}`);
|
||||
await page.waitForLoadState("networkidle");
|
||||
await expect(page.locator(".shared-schedule-loading-skeleton")).toBeHidden({ timeout: 10000 });
|
||||
|
||||
await expect(page.locator(".med-name-text").filter({ hasText: medicationName }).first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
const doseItem = page.locator(".dose-item").first();
|
||||
await expect(doseItem).toBeVisible({ timeout: 15000 });
|
||||
await expect(doseItem.locator(".dose-btn.journal")).toBeDisabled();
|
||||
|
||||
await doseItem.locator(".dose-btn.take").click();
|
||||
|
||||
const collapsedTodayDivider = page.locator(".day-block.today.collapsed .day-divider.clickable").first();
|
||||
if (await collapsedTodayDivider.isVisible().catch(() => false)) {
|
||||
await collapsedTodayDivider.click();
|
||||
}
|
||||
|
||||
const updatedDoseItem = page.locator(".dose-item").first();
|
||||
const noteButton = updatedDoseItem.locator(".dose-btn.journal");
|
||||
await expect(noteButton).toBeEnabled({ timeout: 10000 });
|
||||
await noteButton.click();
|
||||
|
||||
const noteInput = page.locator("#journal-note-input");
|
||||
await expect(noteInput).toBeVisible({ timeout: 10000 });
|
||||
await expect(noteInput).toHaveValue("");
|
||||
|
||||
await noteInput.fill(journalNote);
|
||||
await page.locator(".journal-modal-footer button.primary").click();
|
||||
await expect(page.locator(".journal-modal")).toBeHidden({ timeout: 10000 });
|
||||
|
||||
await noteButton.click();
|
||||
await expect(noteInput).toBeVisible({ timeout: 10000 });
|
||||
await expect(noteInput).toHaveValue(journalNote, { timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
Generated
+175
-182
@@ -1,37 +1,37 @@
|
||||
{
|
||||
"name": "medassist-ng-frontend",
|
||||
"version": "1.23.0",
|
||||
"version": "1.25.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "medassist-ng-frontend",
|
||||
"version": "1.23.0",
|
||||
"version": "1.25.1",
|
||||
"dependencies": {
|
||||
"i18next": "^26.1.0",
|
||||
"i18next": "^26.2.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.7",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-router-dom": "^7.15.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.15",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^25.6.2",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.12",
|
||||
"vite": "^8.0.13",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
},
|
||||
@@ -594,9 +594,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.129.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.129.0.tgz",
|
||||
"integrity": "sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==",
|
||||
"version": "0.130.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.130.0.tgz",
|
||||
"integrity": "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -604,13 +604,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz",
|
||||
"integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==",
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz",
|
||||
"integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.59.1"
|
||||
"playwright": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -620,9 +620,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0.tgz",
|
||||
"integrity": "sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.1.tgz",
|
||||
"integrity": "sha512-fJI3I0r3C3Oj/zdBCpaCmBRZYf07xpaq4yCfDDoSFm+beWNzbIl26puW8RraUdugoJw/95zerNOn6jasAhzSmg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -637,9 +637,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0.tgz",
|
||||
"integrity": "sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.1.tgz",
|
||||
"integrity": "sha512-cKnAhWEsV7TPcA/5EAteDp6KcJZBQ2G+BqE7zayMMi7kMvwRsbv7WT9aOnn0WNl4SKEIf43vjS31iUPu80nzXg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -654,9 +654,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0.tgz",
|
||||
"integrity": "sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.1.tgz",
|
||||
"integrity": "sha512-YKrVwQjIRBPo+5G/u03wGjbdy4q7pyzCe93DK9VJ7zkVmeg8LJ7GbgsiHWdR4xSoe4CAXRD7Bcjgbtr64bkXNg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -671,9 +671,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0.tgz",
|
||||
"integrity": "sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.1.tgz",
|
||||
"integrity": "sha512-z/oBsREo46SsFqBwYtFe0kpJeBijAT48O/WXLI4suiCLBkr03RTtTJMCzSdDd2znlh8VJizL09XVkQgk8IZonw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -688,9 +688,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0.tgz",
|
||||
"integrity": "sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.1.tgz",
|
||||
"integrity": "sha512-ik8q7GM11zxvYxFc2PeDcT6TBvhCQMaUxfph/M5l9sKuTs/Sjg3L+Byw0F7w0ZVLBZmx30P+gG0ECzzN+MFcmQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -705,9 +705,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0.tgz",
|
||||
"integrity": "sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.1.tgz",
|
||||
"integrity": "sha512-QoSx2EkyrrdZ6kcyE8stqZ62t0Yra8Fs5ia9lOxJrh6TMQJK7gQKmscdTHf7pOXKREKrVwOtJcQG3qVSfc866A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -722,9 +722,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0.tgz",
|
||||
"integrity": "sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.1.tgz",
|
||||
"integrity": "sha512-uwNwFpwKeNiZawfAWBgg0VIztPTV3ihhh1vV334h9ivnNLorxnQMU6Fz8wG1Zb4Qh9LC1/MkcyT3YlDXG3Rsgg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -739,9 +739,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0.tgz",
|
||||
"integrity": "sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.1.tgz",
|
||||
"integrity": "sha512-zY1bul7OWr7DFBiJ++wofXvnr8B45ce3QsQUhKrIhXsygAh7bTkwyeM1bi1a2g5C/yC/N8TZyGDEoMfm/l9mpg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -756,9 +756,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0.tgz",
|
||||
"integrity": "sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.1.tgz",
|
||||
"integrity": "sha512-0frlsT/f4Ft6I7SMESTKnF3cZsdicQn1dCMkF/jT9wDLE+gGoiQfv1nmT9e+s7s/fekvvy6tZM2jHvI2tkbJDQ==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -773,9 +773,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0.tgz",
|
||||
"integrity": "sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.1.tgz",
|
||||
"integrity": "sha512-XABVmGp9Tg0WspTVvwduTc4fpqy6JnAUrSQe6OuyqD/03nI7r0O9OWUkMIwFrjKAIqolvqoA4ZrJppgwE0Gxmw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -790,9 +790,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0.tgz",
|
||||
"integrity": "sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.1.tgz",
|
||||
"integrity": "sha512-bV4fzswuzVcKD90o/VM6QqKxnxlDq0g2BISDLNVmxrnhpv1DDbyPhCIjYfvzYLV+MvkKKnQt2Q6AO86SEBULUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -807,9 +807,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0.tgz",
|
||||
"integrity": "sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.1.tgz",
|
||||
"integrity": "sha512-/Mh0Zhq3OP7fVs0kcQHZP6lZEthMGTaSf8UBQYSFEZDWGXXlEC+nJ6EqenaK2t4LBXMe3A+K/G2BVXXdtOr4PQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -824,9 +824,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0.tgz",
|
||||
"integrity": "sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.1.tgz",
|
||||
"integrity": "sha512-+1xc9X45l8ufsBAm6Gjvx2qDRIY9lTVt0cgWNcJ+1gdhXvkbxePA60yRTwSTuXL09CMhyJmjpV7E3NoyxbqFQQ==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
@@ -843,9 +843,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0.tgz",
|
||||
"integrity": "sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.1.tgz",
|
||||
"integrity": "sha512-1D+UqZdfnuR+Jy1GgMJwi85bD40H21uNmOPRWQhw4oRSuolZ/B5rixZ45DK2KXOTCvmVCecauWgEhbw8bI7tOw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -860,9 +860,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0.tgz",
|
||||
"integrity": "sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.1.tgz",
|
||||
"integrity": "sha512-INAycaWuhlOK3wk4mRHGsdgwYWmd9cChdPdE9bwWmy6rn9VqVNYNFGhOdXrofXUxwHIncSiPNb8tNm8knDVIeQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -877,9 +877,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.7",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz",
|
||||
"integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -1032,13 +1032,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.6.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.2.tgz",
|
||||
"integrity": "sha512-sokuT28dxf9JT5Kady1fsXOvI4HVpjZa95NKT5y9PNTIrs2AsobR4GFAA90ZG8M+nxVRLysCXsVj6eGC7Vbrlw==",
|
||||
"version": "25.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
|
||||
"integrity": "sha512-TCFSk8IZh+iLX1xtksoBVtdmgL+1IX0fC9BeU4QqFSuNdN/K+HUlhqOzEmSYYpZUVsLYcPqc9KX+60iDuninSQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
@@ -1085,13 +1085,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz",
|
||||
"integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==",
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz",
|
||||
"integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@rolldown/pluginutils": "1.0.0-rc.7"
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
@@ -1111,14 +1111,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/coverage-v8": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.5.tgz",
|
||||
"integrity": "sha512-38C0/Ddb7HcRG0Z4/DUem8x57d2p9jYgp18mkaYswEOQBGsI1CG4f/hjm0ZCeaJfWhSZ4k7jgs29V1Zom7Ki9A==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.6.tgz",
|
||||
"integrity": "sha512-36l628fQ/9a/8ihy97eOtEnvWQEdqULQOJtcaxtoNq0G1w3Mxd4szSahOaMM9/NGyZ+hyKcMtIW/WIxq0XQViQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@bcoe/v8-coverage": "^1.0.2",
|
||||
"@vitest/utils": "4.1.5",
|
||||
"@vitest/utils": "4.1.6",
|
||||
"ast-v8-to-istanbul": "^1.0.0",
|
||||
"istanbul-lib-coverage": "^3.2.2",
|
||||
"istanbul-lib-report": "^3.0.1",
|
||||
@@ -1132,8 +1132,8 @@
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@vitest/browser": "4.1.5",
|
||||
"vitest": "4.1.5"
|
||||
"@vitest/browser": "4.1.6",
|
||||
"vitest": "4.1.6"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@vitest/browser": {
|
||||
@@ -1142,16 +1142,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
|
||||
"integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.6.tgz",
|
||||
"integrity": "sha512-7EHDquPthALSV0jhhjgEW8FXaviMx7rSqu8W6oqCoAuOhKov814P99QDV1pxMA3QPv21YudvJngIhjrNI4opLg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.5",
|
||||
"@vitest/utils": "4.1.5",
|
||||
"@vitest/spy": "4.1.6",
|
||||
"@vitest/utils": "4.1.6",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -1160,13 +1160,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
|
||||
"integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.6.tgz",
|
||||
"integrity": "sha512-MCFc63czMjEInOlcY2cpQCvCN+KgbAn+60xu9cMgP4sKaLC5JNAKw7JH8QdAnoAC88hW1IiSNZ+GgVXlN1UcMQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.5",
|
||||
"@vitest/spy": "4.1.6",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -1187,9 +1187,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
|
||||
"integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.6.tgz",
|
||||
"integrity": "sha512-h5SxD/IzNhZYnrSZRsUZQIC+vD0GY8cUvq0iwsmkFKixRCKLLWqCXa/FIQ4S1R+sI+PGoojkHsdNrbZiM9Qpgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1200,13 +1200,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
|
||||
"integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.6.tgz",
|
||||
"integrity": "sha512-nOPCmn2+yD0ZNmKdsXGv/UxMMWbMuKeD6GyYncNwdkYDxpQvrPSKYj2rWuDjC2Y4b6w6hjip5dBKFzEUuZe3vA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.5",
|
||||
"@vitest/utils": "4.1.6",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -1214,14 +1214,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
|
||||
"integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.6.tgz",
|
||||
"integrity": "sha512-YhsdE6xAVfTDmzjxL2ZDUvjj+ZsgyOKe+TdQzqkD72wIOmHka8NuGQ6NpTNZv9D2Z63fbwWKJPeVpEw4EQgYxw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.5",
|
||||
"@vitest/utils": "4.1.5",
|
||||
"@vitest/pretty-format": "4.1.6",
|
||||
"@vitest/utils": "4.1.6",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -1230,9 +1230,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
|
||||
"integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.6.tgz",
|
||||
"integrity": "sha512-JFKxMx6udhwKh/Ldo270e17QX710vgunMkuPAvXjHSvC6oqLWAHhVhjg/I71q0u0CBSErIODV1Kjv0FQNSWjdg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -1240,13 +1240,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
|
||||
"integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.6.tgz",
|
||||
"integrity": "sha512-FxIY+U81R3LGKCxaHHFRQ5+g6/iRgGLmeHWdp2Amj4ljQRrEIWHmZyDfDYBRZlpyqA7qKxtS9DD1dhk8RnRIVQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.5",
|
||||
"@vitest/pretty-format": "4.1.6",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
@@ -1548,9 +1548,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/i18next": {
|
||||
"version": "26.1.0",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.1.0.tgz",
|
||||
"integrity": "sha512-dIU6td04DvQuIqVst5S9g0GviTmhZ0DYD4b9ociVGJmuCa5vZ2de/t+Enf4olvj87mF8Y2lwjNQBwC9QZsvzKQ==",
|
||||
"version": "26.2.0",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.2.0.tgz",
|
||||
"integrity": "sha512-zwBHldHdTmwN7r6UNc7lC6GWNN+YYg3DrRSeHR5PRRBf5QnJZcYHrQc0uaU26qZeYxR7iFZD+Y315dPnKP47wA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -1961,9 +1961,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.14.0.tgz",
|
||||
"integrity": "sha512-+1mdWcfSJVUsaTIjN9zoezmUhfXo5l0vP7ekBMPo3jcS/aIkxHnXqAPsByszMZx/Y8oQBRJxJx5xg+RH3urzxA==",
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.16.0.tgz",
|
||||
"integrity": "sha512-dYwyPzb4MEKpGUmNYk3WKWPnMrHs3FKM+q94kAnJrcDIqqn1hq2xY8scaS2ovsOCM5D51ey2gaRG3PBb1vgoYQ==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
@@ -2106,13 +2106,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
|
||||
"integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
|
||||
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.59.1"
|
||||
"playwright-core": "1.60.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
@@ -2125,9 +2125,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.59.1",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
|
||||
"integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
|
||||
"version": "1.60.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
|
||||
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -2214,9 +2214,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-i18next": {
|
||||
"version": "17.0.7",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.7.tgz",
|
||||
"integrity": "sha512-rwtPXsb/zwzDafN+gytcjF5YnqGQQIRmCQ6DctBC1VSipRB8GD/MWEVrFP42vjMyuYydxWxM8CZRt+yiNuuoHg==",
|
||||
"version": "17.0.8",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
|
||||
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.29.2",
|
||||
@@ -2224,7 +2224,7 @@
|
||||
"use-sync-external-store": "^1.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"i18next": ">= 26.0.10",
|
||||
"i18next": ">= 26.2.0",
|
||||
"react": ">= 16.8.0",
|
||||
"typescript": "^5 || ^6"
|
||||
},
|
||||
@@ -2249,9 +2249,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "7.15.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.0.tgz",
|
||||
"integrity": "sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==",
|
||||
"version": "7.15.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.15.1.tgz",
|
||||
"integrity": "sha512-R8rl9HhgikFYoPJymnUtPXWbnDb3oget6lQnfIoupbt61aT9aOhRkDsY2XRhZRyX1Z/8a5sL74fXmFNm3NRK5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cookie": "^1.0.1",
|
||||
@@ -2271,12 +2271,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "7.15.0",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.0.tgz",
|
||||
"integrity": "sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==",
|
||||
"version": "7.15.1",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.15.1.tgz",
|
||||
"integrity": "sha512-AzF62gjY6U9rkMq4RfP/r2EVtQ7DMfNMjyOp/flLTCrtRylLiK4wT4pSq6O8rOXZ2eXdZYJPEYe+ifomiv+Igg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.15.0"
|
||||
"react-router": "7.15.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
@@ -2311,14 +2311,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0.tgz",
|
||||
"integrity": "sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==",
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
|
||||
"integrity": "sha512-X0KQHljNnEkWNqqiz9zJrGunh1B0HgOxLXvnFpCOcadzcy5qohZ3tqMEUg00vncoRovXuK3ZqCT9KnnKzoInFQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.129.0",
|
||||
"@rolldown/pluginutils": "1.0.0"
|
||||
"@oxc-project/types": "=0.130.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
@@ -2327,30 +2327,23 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.0",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.0",
|
||||
"@rolldown/binding-darwin-x64": "1.0.0",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.0",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.0",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.0",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.0",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.0",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.0",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.0",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.0",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.0",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.0",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.0",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.0"
|
||||
"@rolldown/binding-android-arm64": "1.0.1",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.1",
|
||||
"@rolldown/binding-darwin-x64": "1.0.1",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.1",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.1",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.1",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.1",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.1",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.1",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.1",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.1",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.1",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.1",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.1",
|
||||
"@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": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||
@@ -2576,9 +2569,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.19.2",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -2592,16 +2585,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.12",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.12.tgz",
|
||||
"integrity": "sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==",
|
||||
"version": "8.0.13",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.13.tgz",
|
||||
"integrity": "sha512-MFtjBYgzmSxmgA4RAfjIyXWpGe1oALnjgUTzzV7QLx/TKxCzjtMH6Fd9/eVK+5Fg1qNoz5VAwsmMs/NofrmJvw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.14",
|
||||
"rolldown": "1.0.0",
|
||||
"rolldown": "1.0.1",
|
||||
"tinyglobby": "^0.2.16"
|
||||
},
|
||||
"bin": {
|
||||
@@ -2685,19 +2678,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
|
||||
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
|
||||
"version": "4.1.6",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.6.tgz",
|
||||
"integrity": "sha512-6lvjbS3p9b4CrdCmguzbh2/4uoXhGE2q71R4OX5sqF9R1bo9Xd6fGrMAfvp5wnCzlBnFVdCOp6onuTQVbo8iUQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.5",
|
||||
"@vitest/mocker": "4.1.5",
|
||||
"@vitest/pretty-format": "4.1.5",
|
||||
"@vitest/runner": "4.1.5",
|
||||
"@vitest/snapshot": "4.1.5",
|
||||
"@vitest/spy": "4.1.5",
|
||||
"@vitest/utils": "4.1.5",
|
||||
"@vitest/expect": "4.1.6",
|
||||
"@vitest/mocker": "4.1.6",
|
||||
"@vitest/pretty-format": "4.1.6",
|
||||
"@vitest/runner": "4.1.6",
|
||||
"@vitest/snapshot": "4.1.6",
|
||||
"@vitest/spy": "4.1.6",
|
||||
"@vitest/utils": "4.1.6",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -2725,12 +2718,12 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.5",
|
||||
"@vitest/browser-preview": "4.1.5",
|
||||
"@vitest/browser-webdriverio": "4.1.5",
|
||||
"@vitest/coverage-istanbul": "4.1.5",
|
||||
"@vitest/coverage-v8": "4.1.5",
|
||||
"@vitest/ui": "4.1.5",
|
||||
"@vitest/browser-playwright": "4.1.6",
|
||||
"@vitest/browser-preview": "4.1.6",
|
||||
"@vitest/browser-webdriverio": "4.1.6",
|
||||
"@vitest/coverage-istanbul": "4.1.6",
|
||||
"@vitest/coverage-v8": "4.1.6",
|
||||
"@vitest/ui": "4.1.6",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
|
||||
+10
-10
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "medassist-ng-frontend",
|
||||
"private": true,
|
||||
"version": "1.25.0",
|
||||
"version": "1.26.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -27,30 +27,30 @@
|
||||
"test:e2e:report": "playwright show-report"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18next": "^26.1.0",
|
||||
"i18next": "^26.2.0",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.7",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-router-dom": "^7.15.1",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.15",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^25.6.2",
|
||||
"@types/node": "^25.8.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"@vitest/coverage-v8": "^4.1.6",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.12",
|
||||
"vite": "^8.0.13",
|
||||
"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 {
|
||||
AboutModal,
|
||||
@@ -11,9 +12,19 @@ import {
|
||||
} from "./components";
|
||||
import { AppHeader } from "./components/AppHeader";
|
||||
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 { 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
|
||||
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";
|
||||
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
|
||||
// =============================================================================
|
||||
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 (
|
||||
<AuthProvider>
|
||||
<Routes>
|
||||
{/* Public share route - accessible without auth */}
|
||||
<Route path="/share/:token/overview" element={<SharedOverviewPage />} />
|
||||
<Route path="/share/:token" element={<SharedSchedule />} />
|
||||
{/* All other routes go through AppRouter */}
|
||||
<Route path="*" element={<AppRouter />} />
|
||||
</Routes>
|
||||
<FeedbackProvider>
|
||||
<Suspense fallback={<RouteLoadingFallback />}>
|
||||
<Routes>
|
||||
{/* Public share route - accessible without auth */}
|
||||
<Route path="/share/:token/overview" element={<SharedOverviewPage />} />
|
||||
<Route path="/share/:token" element={<SharedSchedule />} />
|
||||
{/* All other routes go through AppRouter */}
|
||||
<Route path="*" element={<AppRouter />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</FeedbackProvider>
|
||||
</AuthProvider>
|
||||
);
|
||||
}
|
||||
@@ -54,52 +124,42 @@ function getInitialAuthTheme(): "light" | "dark" {
|
||||
}
|
||||
|
||||
function AppRouter() {
|
||||
const { t } = useTranslation();
|
||||
const { user, authState, loading, authError } = useAuth();
|
||||
const authTheme = getInitialAuthTheme();
|
||||
|
||||
// Show loading while checking auth state
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="auth-container" data-theme={authTheme}>
|
||||
<div className="auth-card" style={{ textAlign: "center" }}>
|
||||
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
||||
<p>Loading...</p>
|
||||
</div>
|
||||
</div>
|
||||
<AuthStatusCard theme={authTheme}>
|
||||
<p>{t("common.loading")}</p>
|
||||
</AuthStatusCard>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error if we couldn't connect to the server
|
||||
if (authError) {
|
||||
return (
|
||||
<div className="auth-container" data-theme={authTheme}>
|
||||
<div className="auth-card" style={{ textAlign: "center" }}>
|
||||
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
||||
<div className="auth-error" style={{ marginBottom: "1rem" }}>
|
||||
<strong>Connection Error</strong>
|
||||
<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>
|
||||
<AuthStatusCard theme={authTheme}>
|
||||
<div className="auth-error" style={{ marginBottom: "1rem" }}>
|
||||
<strong>{t("auth.connectionErrorTitle")}</strong>
|
||||
<br />
|
||||
{authError}
|
||||
</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 (!authState) {
|
||||
return (
|
||||
<div className="auth-container" data-theme={authTheme}>
|
||||
<div className="auth-card" style={{ textAlign: "center" }}>
|
||||
<h1 className="auth-title">💊 MedAssist-ng</h1>
|
||||
<p>Initializing...</p>
|
||||
</div>
|
||||
</div>
|
||||
<AuthStatusCard theme={authTheme}>
|
||||
<p>{t("common.initializing")}</p>
|
||||
</AuthStatusCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -193,12 +253,20 @@ function AppContent() {
|
||||
setShareSelectedPerson,
|
||||
shareSelectedDays,
|
||||
setShareSelectedDays,
|
||||
shareSelectedExpiryDays,
|
||||
setShareSelectedExpiryDays,
|
||||
shareAllowJournalNotes,
|
||||
setShareAllowJournalNotes,
|
||||
shareGenerating,
|
||||
shareLink,
|
||||
setShareLink,
|
||||
shareCopied,
|
||||
setShareCopied,
|
||||
activeShareLinks,
|
||||
activeSharesLoading,
|
||||
revokingShareToken,
|
||||
generateShareLink,
|
||||
revokeShareLink,
|
||||
copyShareLink,
|
||||
closeShareDialog,
|
||||
resetShareDialogState,
|
||||
@@ -272,47 +340,6 @@ function AppContent() {
|
||||
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.
|
||||
// This keeps behavior consistent even when child modals are mocked in tests.
|
||||
useEffect(() => {
|
||||
@@ -505,20 +532,22 @@ function AppContent() {
|
||||
{/* About Modal */}
|
||||
<AboutModal isOpen={showAbout} onClose={closeAbout} />
|
||||
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to={{ pathname: "/dashboard", search: location.search }} replace />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Suspense fallback={<RouteLoadingFallback />}>
|
||||
<Routes>
|
||||
<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 />} />
|
||||
{/* Catch-all: redirect unknown routes to dashboard */}
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
<Route path="/schedule" element={<SchedulePage />} />
|
||||
{/* Catch-all: redirect unknown routes to dashboard */}
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
|
||||
{/* Medication Detail Modal */}
|
||||
<MedDetailModal
|
||||
@@ -581,13 +610,21 @@ function AppContent() {
|
||||
onShareSelectedPersonChange={setShareSelectedPerson}
|
||||
shareSelectedDays={shareSelectedDays}
|
||||
onShareSelectedDaysChange={setShareSelectedDays}
|
||||
shareSelectedExpiryDays={shareSelectedExpiryDays}
|
||||
onShareSelectedExpiryDaysChange={setShareSelectedExpiryDays}
|
||||
shareAllowJournalNotes={shareAllowJournalNotes}
|
||||
onShareAllowJournalNotesChange={setShareAllowJournalNotes}
|
||||
shareGenerating={shareGenerating}
|
||||
shareLink={shareLink}
|
||||
onShareLinkChange={setShareLink}
|
||||
shareCopied={shareCopied}
|
||||
onShareCopiedChange={setShareCopied}
|
||||
activeShareLinks={activeShareLinks}
|
||||
activeSharesLoading={activeSharesLoading}
|
||||
revokingShareToken={revokingShareToken}
|
||||
onClose={closeShareDialog}
|
||||
onGenerateShareLink={generateShareLink}
|
||||
onRevokeShareLink={revokeShareLink}
|
||||
onCopyShareLink={copyShareLink}
|
||||
/>
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { createContext, type ReactNode, useCallback, useContext, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useEscapeKey } from "../hooks/useEscapeKey";
|
||||
import { useModalHistory } from "../hooks/useModalHistory";
|
||||
import { withCorrelation } from "../utils/correlation";
|
||||
import { MAX_IMAGE_UPLOAD_BYTES, resolveImageUploadError } from "../utils/image-upload";
|
||||
import { log } from "../utils/logger";
|
||||
@@ -32,6 +33,7 @@ interface AuthContextType {
|
||||
authState: AuthState | null;
|
||||
loading: boolean;
|
||||
authError: string | null;
|
||||
sessionExpired: boolean;
|
||||
login: (username: string, password: string, rememberMe?: boolean) => Promise<void>;
|
||||
register: (username: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
@@ -64,6 +66,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [authState, setAuthState] = useState<AuthState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [authError, setAuthError] = useState<string | null>(null);
|
||||
const [sessionExpired, setSessionExpired] = useState(false);
|
||||
// Track if initial fetch has been done to prevent duplicate calls
|
||||
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 (state.authEnabled) {
|
||||
await refreshUser();
|
||||
} else {
|
||||
setSessionExpired(false);
|
||||
}
|
||||
setLoading(false);
|
||||
} catch (err) {
|
||||
@@ -138,6 +143,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
if (res.ok) {
|
||||
const userData = await res.json();
|
||||
setUser(userData);
|
||||
setSessionExpired(false);
|
||||
log.debug("[Auth] Session user loaded", { userId: userData.id, correlationId });
|
||||
} else if (res.status === 401) {
|
||||
// Access token expired - try to refresh it
|
||||
@@ -150,6 +156,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
if (retryRes.ok) {
|
||||
const userData = await retryRes.json();
|
||||
setUser(userData);
|
||||
setSessionExpired(false);
|
||||
log.info("[Auth] Session restored after token refresh", {
|
||||
userId: userData.id,
|
||||
correlationId: retry.correlationId,
|
||||
@@ -159,6 +166,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
log.debug("[Auth] Session refresh unavailable, clearing local user state", { correlationId });
|
||||
setUser(null);
|
||||
setSessionExpired(true);
|
||||
} else {
|
||||
log.warn("[Auth] Unexpected /auth/me response", { status: res.status, correlationId });
|
||||
setUser(null);
|
||||
@@ -215,6 +223,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const data = await res.json();
|
||||
setUser(data.user);
|
||||
setSessionExpired(false);
|
||||
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
|
||||
await login(username, password);
|
||||
setSessionExpired(false);
|
||||
|
||||
// Refresh auth state (registration might disable further registrations)
|
||||
await fetchAuthState();
|
||||
@@ -249,6 +259,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
log.info("[Auth] Logout requested", { userId: user?.id ?? null, correlationId });
|
||||
await fetch("/api/auth/logout", init);
|
||||
setUser(null);
|
||||
setSessionExpired(false);
|
||||
log.info("[Auth] Logout completed", { correlationId });
|
||||
}
|
||||
|
||||
@@ -341,9 +352,13 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
if (refreshed) {
|
||||
// Retry the original request with new token
|
||||
res = await fetch(input, options);
|
||||
if (res.ok) {
|
||||
setSessionExpired(false);
|
||||
}
|
||||
} else {
|
||||
// Refresh failed - user needs to login again
|
||||
setUser(null);
|
||||
setSessionExpired(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -359,6 +374,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
authState,
|
||||
loading,
|
||||
authError,
|
||||
sessionExpired,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
@@ -386,7 +402,7 @@ export function LoginForm({
|
||||
onSwitchToRegister?: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { login, authState } = useAuth();
|
||||
const { login, authState, sessionExpired } = useAuth();
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
@@ -440,6 +456,13 @@ export function LoginForm({
|
||||
{/* Local login form - only show if form login is enabled */}
|
||||
{authState?.formLoginEnabled && (
|
||||
<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>}
|
||||
|
||||
<div className="form-group">
|
||||
@@ -633,7 +656,14 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const closeDeleteConfirm = useCallback(() => {
|
||||
if (!deleteLoading) {
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
}, [deleteLoading]);
|
||||
|
||||
useEscapeKey(!!onClose, onClose ?? (() => {}));
|
||||
useModalHistory(showDeleteConfirm, "profile-delete-account", closeDeleteConfirm);
|
||||
|
||||
async function handleAvatarUpload(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -842,7 +872,7 @@ export function UserProfile({ onClose }: { onClose?: () => void }) {
|
||||
confirmLabel={t("auth.deleteAccountButton", "Yes, delete my account")}
|
||||
cancelLabel={t("common.cancel", "Cancel")}
|
||||
onConfirm={handleDeleteAccount}
|
||||
onCancel={() => setShowDeleteConfirm(false)}
|
||||
onCancel={closeDeleteConfirm}
|
||||
isLoading={deleteLoading}
|
||||
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(() => {
|
||||
resultRefs.current.get(expandedResultCode)?.scrollIntoView({
|
||||
const expandedResultElement = resultRefs.current.get(expandedResultCode);
|
||||
if (typeof expandedResultElement?.scrollIntoView !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
expandedResultElement.scrollIntoView({
|
||||
block: "nearest",
|
||||
inline: "nearest",
|
||||
behavior: "smooth",
|
||||
|
||||
@@ -11,8 +11,11 @@ import {
|
||||
isLiquidContainerPackageType,
|
||||
isTubePackageType,
|
||||
} from "../types";
|
||||
import { formatDate, formatDateTime } from "../utils/formatters";
|
||||
import { formatDate, formatDateTime, toInputValue } from "../utils/formatters";
|
||||
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";
|
||||
|
||||
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) {
|
||||
const { t } = useTranslation();
|
||||
const { authFetch } = useAuth();
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const [format, setFormat] = useState<ReportFormat>("pdf");
|
||||
const [generating, setGenerating] = useState(false);
|
||||
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);
|
||||
useEscapeKey(isOpen, onClose);
|
||||
|
||||
// Collect all unique "taken by" people across all medications
|
||||
const allPeople = useMemo(() => {
|
||||
const people = new Set<string>();
|
||||
for (const med of medications) {
|
||||
if (med.takenBy) {
|
||||
for (const p of med.takenBy) people.add(p);
|
||||
}
|
||||
}
|
||||
return Array.from(people).sort();
|
||||
return mergePersonTags(medications.flatMap((medication) => medication.takenBy || []));
|
||||
}, [medications]);
|
||||
|
||||
// Filtered medications based on takenBy filter
|
||||
const filteredMeds = useMemo(() => {
|
||||
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]);
|
||||
|
||||
const activeMeds = useMemo(() => filteredMeds.filter((m) => !m.isObsolete), [filteredMeds]);
|
||||
@@ -97,9 +122,22 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
|
||||
setTakenByFilter(new Set());
|
||||
setFormat("pdf");
|
||||
setGenerating(false);
|
||||
setDateRange(getDefaultDateRange());
|
||||
setPreview(null);
|
||||
setErrorMessage(null);
|
||||
}
|
||||
}, [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) => {
|
||||
setSelectedIds((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]);
|
||||
let generateButtonLabel = t("report.generate");
|
||||
if (generating) {
|
||||
generateButtonLabel = t("report.generating");
|
||||
} else if (preview) {
|
||||
generateButtonLabel = t("report.regenerate");
|
||||
}
|
||||
|
||||
async function handleGenerate() {
|
||||
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);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const resolvedDateRange = {
|
||||
startDate: startDate.toISOString(),
|
||||
endDate: endDate.toISOString(),
|
||||
};
|
||||
const filterArr = takenByFilter.size > 0 ? Array.from(takenByFilter) : null;
|
||||
|
||||
// Fetch report data from backend
|
||||
const res = await fetch("/api/medications/report-data", {
|
||||
const res = await authFetch("/api/medications/report-data", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
medicationIds: Array.from(selectedIds),
|
||||
startDate: resolvedDateRange.startDate,
|
||||
endDate: resolvedDateRange.endDate,
|
||||
takenByFilter: takenByFilter.size > 0 ? Array.from(takenByFilter) : undefined,
|
||||
}),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to fetch report data");
|
||||
const reportData = (await res.json()) as ReportData;
|
||||
|
||||
if (format === "pdf") {
|
||||
const imageMap = await fetchMedImages(selectedMeds);
|
||||
const filterArr = takenByFilter.size > 0 ? Array.from(takenByFilter) : null;
|
||||
openPrintView(selectedMeds, reportData, t, imageMap, filterArr);
|
||||
const imageMap = await fetchMedImages(selectedMeds, authFetch);
|
||||
openPrintView(selectedMeds, reportData, t, imageMap, filterArr, resolvedDateRange);
|
||||
setPreview(null);
|
||||
setErrorMessage(null);
|
||||
onClose();
|
||||
} else {
|
||||
const filterArr = takenByFilter.size > 0 ? Array.from(takenByFilter) : null;
|
||||
const content = generateTextReport(selectedMeds, reportData, format, t, filterArr);
|
||||
downloadFile(content, format);
|
||||
const content = generateTextReport(selectedMeds, reportData, format, t, filterArr, resolvedDateRange);
|
||||
setPreview({ format, content });
|
||||
}
|
||||
onClose();
|
||||
} catch {
|
||||
// Stay open on error so user can retry
|
||||
setErrorMessage(t("report.error"));
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
@@ -177,6 +237,28 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
|
||||
<h2 className="report-modal-title">{t("report.title")}</h2>
|
||||
<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 */}
|
||||
{allPeople.length > 1 && (
|
||||
<div className="report-person-filter">
|
||||
@@ -279,6 +361,25 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
|
||||
</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 */}
|
||||
<div className="report-actions">
|
||||
<button type="button" className="ghost" onClick={onClose}>
|
||||
@@ -290,7 +391,7 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
|
||||
onClick={handleGenerate}
|
||||
disabled={selectedIds.size === 0 || generating}
|
||||
>
|
||||
{generating ? t("report.generating") : t("report.generate")}
|
||||
{generateButtonLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -348,7 +449,8 @@ function generateTextReport(
|
||||
reportData: ReportData,
|
||||
fmt: "txt" | "md",
|
||||
t: TFn,
|
||||
personFilter: string[] | null
|
||||
personFilter: string[] | null,
|
||||
dateRange: { startDate: string; endDate: string }
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
const sep = fmt === "md" ? "---" : "═".repeat(60);
|
||||
@@ -360,6 +462,7 @@ function generateTextReport(
|
||||
|
||||
lines.push(h1(t("report.docTitle")));
|
||||
lines.push(`${t("report.docGenerated")}: ${formatDate(new Date().toISOString())}`);
|
||||
lines.push(`${t("report.docRange")}: ${formatDateTime(dateRange.startDate)} - ${formatDateTime(dateRange.endDate)}`);
|
||||
lines.push("");
|
||||
|
||||
for (const med of meds) {
|
||||
@@ -483,13 +586,13 @@ function downloadFile(content: string, format: "txt" | "md") {
|
||||
|
||||
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 fetches = meds
|
||||
.filter((m) => m.imageUrl)
|
||||
.map(async (m) => {
|
||||
try {
|
||||
const res = await fetch(`/api/images/${m.imageUrl}`, { credentials: "include" });
|
||||
const res = await authFetch(`/api/images/${m.imageUrl}`);
|
||||
if (!res.ok) return;
|
||||
const blob = await res.blob();
|
||||
const dataUrl = await new Promise<string>((resolve) => {
|
||||
@@ -511,12 +614,13 @@ function openPrintView(
|
||||
reportData: ReportData,
|
||||
t: TFn,
|
||||
imageMap: ImageMap,
|
||||
personFilter: string[] | null
|
||||
personFilter: string[] | null,
|
||||
dateRange: { startDate: string; endDate: string }
|
||||
) {
|
||||
const w = window.open("", "_blank");
|
||||
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.close();
|
||||
w.onload = () => setTimeout(() => w.print(), 300);
|
||||
@@ -531,7 +635,8 @@ function buildPrintHtml(
|
||||
reportData: ReportData,
|
||||
t: TFn,
|
||||
imageMap: ImageMap,
|
||||
personFilter: string[] | null
|
||||
personFilter: string[] | null,
|
||||
dateRange: { startDate: string; endDate: string }
|
||||
): string {
|
||||
const sections: string[] = [];
|
||||
|
||||
@@ -721,6 +826,7 @@ function buildPrintHtml(
|
||||
<div class="no-print print-hint">${escHtml(t("report.docPrintInstruction"))}</div>
|
||||
<h1>${escHtml(t("report.docTitle"))}</h1>
|
||||
<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")}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
@@ -4,7 +4,11 @@
|
||||
*/
|
||||
|
||||
import { Check, Copy, Link2, X } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useModalHistory } from "../hooks";
|
||||
import type { ActiveShareLink } from "../hooks/useShare";
|
||||
import { ConfirmModal } from "./ConfirmModal";
|
||||
|
||||
export interface ShareDialogProps {
|
||||
show: boolean;
|
||||
@@ -13,13 +17,21 @@ export interface ShareDialogProps {
|
||||
onShareSelectedPersonChange: (person: string) => void;
|
||||
shareSelectedDays: number;
|
||||
onShareSelectedDaysChange: (days: number) => void;
|
||||
shareSelectedExpiryDays: number | null;
|
||||
onShareSelectedExpiryDaysChange: (days: number | null) => void;
|
||||
shareAllowJournalNotes: boolean;
|
||||
onShareAllowJournalNotesChange: (enabled: boolean) => void;
|
||||
shareGenerating: boolean;
|
||||
shareLink: string | null;
|
||||
onShareLinkChange: (link: string | null) => void;
|
||||
shareCopied: boolean;
|
||||
onShareCopiedChange: (copied: boolean) => void;
|
||||
activeShareLinks: ActiveShareLink[];
|
||||
activeSharesLoading: boolean;
|
||||
revokingShareToken: string | null;
|
||||
onClose: () => void;
|
||||
onGenerateShareLink: () => Promise<void>;
|
||||
onRevokeShareLink: (token: string) => Promise<boolean>;
|
||||
onCopyShareLink: () => void;
|
||||
}
|
||||
|
||||
@@ -30,24 +42,116 @@ export function ShareDialog({
|
||||
onShareSelectedPersonChange,
|
||||
shareSelectedDays,
|
||||
onShareSelectedDaysChange,
|
||||
shareSelectedExpiryDays,
|
||||
onShareSelectedExpiryDaysChange,
|
||||
shareAllowJournalNotes,
|
||||
onShareAllowJournalNotesChange,
|
||||
shareGenerating,
|
||||
shareLink,
|
||||
onShareLinkChange,
|
||||
shareCopied,
|
||||
onShareCopiedChange,
|
||||
activeShareLinks,
|
||||
activeSharesLoading,
|
||||
revokingShareToken,
|
||||
onClose,
|
||||
onGenerateShareLink,
|
||||
onRevokeShareLink,
|
||||
onCopyShareLink,
|
||||
}: ShareDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [manageLinksOpen, setManageLinksOpen] = useState(false);
|
||||
const [shareToRevoke, setShareToRevoke] = useState<ActiveShareLink | null>(null);
|
||||
const closeLabel = t("common.close");
|
||||
const copyLabel = shareCopied ? t("share.copied") : t("share.copyLink");
|
||||
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()
|
||||
|
||||
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 (
|
||||
<div
|
||||
className="modal-overlay"
|
||||
@@ -85,6 +189,7 @@ export function ShareDialog({
|
||||
return (
|
||||
<div className="share-dialog-empty">
|
||||
<p>{t("share.noPeople")}</p>
|
||||
<div className="share-dialog-active-links">{renderManageLinks()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -124,6 +229,7 @@ export function ShareDialog({
|
||||
</button>
|
||||
<button onClick={onClose}>{t("common.close")}</button>
|
||||
</div>
|
||||
<div className="share-dialog-active-links">{renderManageLinks()}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -159,6 +265,33 @@ export function ShareDialog({
|
||||
</select>
|
||||
</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">
|
||||
<button className="ghost" onClick={onClose}>
|
||||
{t("common.close")}
|
||||
@@ -167,9 +300,28 @@ export function ShareDialog({
|
||||
{shareGenerating ? t("share.generating") : t("share.generateLink")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="share-dialog-active-links">{renderManageLinks()}</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>
|
||||
);
|
||||
|
||||
@@ -4,14 +4,17 @@
|
||||
/* 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 */
|
||||
|
||||
import { NotebookPen } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useFeedback } from "../context/FeedbackContext";
|
||||
import { ScheduleUsageTag } from "../features/schedule/components";
|
||||
import { formatScheduleDoseUsageLabel, formatScheduleTotalUsageLabel } from "../features/schedule/formatters";
|
||||
import { toggleDateInSet } from "../features/schedule/interactions";
|
||||
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 {
|
||||
allowsPillFormSelection,
|
||||
@@ -26,12 +29,30 @@ import { getSystemLocale } from "../utils/formatters";
|
||||
import { getIntakeDailyRate, getMedicationIntakes, iterateIntakeOccurrences } from "../utils/intake-schedule";
|
||||
import { convertLiquidUsageToMl } from "../utils/intake-units";
|
||||
import { getStockStatus, isDoseDismissed, parseLocalDateTime } from "../utils/schedule";
|
||||
import { IntakeJournalModal } from "./intake-journal/IntakeJournalModal";
|
||||
import { MedicationAvatar } from "./MedicationAvatar";
|
||||
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() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { showFeedback } = useFeedback();
|
||||
const [data, setData] = useState<SharedScheduleData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -39,8 +60,15 @@ export function SharedSchedule() {
|
||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||||
const [automaticTakenDoses, setAutomaticTakenDoses] = useState<Set<string>>(new Set());
|
||||
const [dismissedDoses, setDismissedDoses] = useState<Set<string>>(new Set());
|
||||
const [sharedJournalDoseIdsWithNotes, setSharedJournalDoseIdsWithNotes] = useState<Set<string>>(new Set());
|
||||
const mutationInFlightRef = useRef(0);
|
||||
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 [showFutureDays, setShowFutureDays] = useState(false);
|
||||
|
||||
@@ -169,6 +197,107 @@ export function SharedSchedule() {
|
||||
// Close lightbox on Escape key
|
||||
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
|
||||
useEffect(() => {
|
||||
function handlePopState() {
|
||||
@@ -194,11 +323,13 @@ export function SharedSchedule() {
|
||||
const taken = new Set<string>();
|
||||
const automatic = new Set<string>();
|
||||
const dismissed = new Set<string>();
|
||||
const journalDoseIds = new Set<string>();
|
||||
for (const d of data.doses as Array<{
|
||||
doseId: string;
|
||||
dismissed?: boolean;
|
||||
skipped?: boolean;
|
||||
takenSource?: string;
|
||||
hasJournalNote?: boolean;
|
||||
}>) {
|
||||
if (d.skipped === true || d.dismissed === true) {
|
||||
dismissed.add(d.doseId);
|
||||
@@ -208,10 +339,14 @@ export function SharedSchedule() {
|
||||
automatic.add(d.doseId);
|
||||
}
|
||||
}
|
||||
if (d.hasJournalNote === true) {
|
||||
journalDoseIds.add(d.doseId);
|
||||
}
|
||||
}
|
||||
setTakenDoses(taken);
|
||||
setAutomaticTakenDoses(automatic);
|
||||
setDismissedDoses(dismissed);
|
||||
setSharedJournalDoseIdsWithNotes(journalDoseIds);
|
||||
}
|
||||
} catch {
|
||||
// Keep the current optimistic/shared state on transient read errors.
|
||||
@@ -268,7 +403,7 @@ export function SharedSchedule() {
|
||||
try {
|
||||
const data = (await response.json()) as { code?: string };
|
||||
if (data.code === "OUT_OF_STOCK") {
|
||||
alert(t("common.outOfStockTakeBlocked"));
|
||||
showFeedback({ message: t("common.outOfStockTakeBlocked"), tone: "error" });
|
||||
}
|
||||
} catch {
|
||||
// Ignore JSON parsing errors and fall back to the optimistic rollback only.
|
||||
@@ -448,6 +583,9 @@ export function SharedSchedule() {
|
||||
isAutomaticallyTaken: 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 ? (
|
||||
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
|
||||
{options.isAutomaticallyTaken && (
|
||||
@@ -486,10 +624,33 @@ export function SharedSchedule() {
|
||||
</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 (
|
||||
<>
|
||||
{takeButton}
|
||||
{skipButton}
|
||||
{journalButton}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -641,7 +802,10 @@ export function SharedSchedule() {
|
||||
}, [data, i18n.language]);
|
||||
|
||||
// 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
|
||||
const { todayDay, futureDays } = useMemo(() => {
|
||||
@@ -901,6 +1065,7 @@ export function SharedSchedule() {
|
||||
<div className="shared-schedule-container">
|
||||
<header className="shared-schedule-header">
|
||||
<h1>{pageTitle}</h1>
|
||||
<p className="shared-schedule-boundary">{t("share.publicAccessHelp")}</p>
|
||||
<div className="shared-schedule-header-actions">
|
||||
<div className={`theme-menu ${themeMenuOpen ? "open" : ""}`} ref={themeMenuRef}>
|
||||
<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));
|
||||
|
||||
// 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 isManuallyCollapsed = manuallyCollapsedDays.has(day.dateStr);
|
||||
const isCollapsed = isAutoCollapsed ? !isManuallyExpanded : isManuallyCollapsed;
|
||||
@@ -1582,6 +1747,19 @@ export function SharedSchedule() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<IntakeJournalModal
|
||||
isOpen={sharedJournalOpen}
|
||||
entry={sharedJournalEntry}
|
||||
isLoading={sharedJournalLoading}
|
||||
isSaving={sharedJournalSaving}
|
||||
isDeleting={false}
|
||||
error={sharedJournalError}
|
||||
onClose={closeSharedJournalEditor}
|
||||
onSave={saveSharedJournalNote}
|
||||
onDelete={() => undefined}
|
||||
allowDelete={false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { formatNumber } from "../utils";
|
||||
import { getSystemLocale } from "../utils/formatters";
|
||||
import { getIntakeFrequencyText, getMedicationIntakes } from "../utils/intake-schedule";
|
||||
import { getLiquidCountUnitLabel } from "../utils/intake-units";
|
||||
import { personTagsMatch } from "../utils/person-tags";
|
||||
import { getStockStatus } from "../utils/schedule";
|
||||
|
||||
export interface UserFilterModalProps {
|
||||
@@ -72,7 +73,10 @@ export function UserFilterModal({
|
||||
|
||||
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 (
|
||||
<div
|
||||
@@ -110,7 +114,7 @@ export function UserFilterModal({
|
||||
|
||||
// Get intakes relevant to this person
|
||||
const personIntakes = getMedicationIntakes(med).filter(
|
||||
(intake) => intake.takenBy === null || intake.takenBy === selectedUser
|
||||
(intake) => intake.takenBy === null || personTagsMatch(intake.takenBy, selectedUser)
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -7,8 +7,10 @@ export { DateInput } from "./DateInput";
|
||||
export { DateTimeInput } from "./DateTimeInput";
|
||||
export { default as ExportModal } from "./ExportModal";
|
||||
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 { Lightbox } from "./Lightbox";
|
||||
export type { MedDetailModalProps } 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 { useTranslation } from "react-i18next";
|
||||
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 {
|
||||
type Coverage,
|
||||
type FormState,
|
||||
@@ -13,7 +21,9 @@ import {
|
||||
} from "../types";
|
||||
import { getSystemLocale, setDefaultFormattingTimezone } from "../utils/formatters";
|
||||
import { log } from "../utils/logger";
|
||||
import { mergePersonTags } from "../utils/person-tags";
|
||||
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds, getStockStatus } from "../utils/schedule";
|
||||
import { useFeedback } from "./FeedbackContext";
|
||||
import { ShareContextProvider } from "./ShareContext";
|
||||
|
||||
// =============================================================================
|
||||
@@ -44,6 +54,34 @@ export type GroupedDay = {
|
||||
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 {
|
||||
// From useMedications
|
||||
meds: Medication[];
|
||||
@@ -87,6 +125,29 @@ export interface AppContextValue {
|
||||
undoDoseTaken: (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
|
||||
manuallyCollapsedDays: Set<string>;
|
||||
manuallyExpandedDays: Set<string>;
|
||||
@@ -99,13 +160,21 @@ export interface AppContextValue {
|
||||
setShareSelectedPerson: React.Dispatch<React.SetStateAction<string>>;
|
||||
shareSelectedDays: 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;
|
||||
shareLink: string | null;
|
||||
setShareLink: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
shareCopied: boolean;
|
||||
setShareCopied: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
activeShareLinks: ReturnType<typeof useShare>["activeShareLinks"];
|
||||
activeSharesLoading: boolean;
|
||||
revokingShareToken: string | null;
|
||||
openShareDialog: () => void;
|
||||
generateShareLink: () => Promise<void>;
|
||||
revokeShareLink: (token: string) => Promise<boolean>;
|
||||
copyShareLink: () => void;
|
||||
closeShareDialog: () => void;
|
||||
resetShareDialogState: () => void;
|
||||
@@ -188,6 +257,8 @@ export interface AppContextValue {
|
||||
setShowImportConfirm: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
pendingImportData: unknown;
|
||||
setPendingImportData: React.Dispatch<React.SetStateAction<unknown>>;
|
||||
importPreview: ImportPreview | null;
|
||||
setImportPreview: React.Dispatch<React.SetStateAction<ImportPreview | null>>;
|
||||
importResult: {
|
||||
medications: number;
|
||||
doses: number;
|
||||
@@ -245,12 +316,14 @@ function userStorageKey(userId: number | undefined, key: string): string {
|
||||
|
||||
export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
const { i18n } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const { user, authFetch } = useAuth();
|
||||
const { showFeedback } = useFeedback();
|
||||
|
||||
// Compose hooks
|
||||
const medications = useMedications();
|
||||
const settingsHook = useSettings();
|
||||
const doses = useDoses();
|
||||
const intakeJournal = useIntakeJournal();
|
||||
const collapsed = useCollapsedDays(user?.id);
|
||||
const share = useShare();
|
||||
const refill = useRefill();
|
||||
@@ -295,6 +368,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
const [showImportConfirm, setShowImportConfirm] = useState(false);
|
||||
const [pendingImportData, setPendingImportData] = useState<unknown>(null);
|
||||
const [importPreview, setImportPreview] = useState<ImportPreview | null>(null);
|
||||
const [importResult, setImportResult] = useState<{
|
||||
medications: number;
|
||||
doses: number;
|
||||
@@ -326,6 +400,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
medications.clearMedicationsState();
|
||||
settingsHook.resetSettingsState();
|
||||
doses.clearDosesState();
|
||||
intakeJournal.resetJournalState();
|
||||
refill.clearRefillState();
|
||||
share.resetShareDialogState();
|
||||
|
||||
@@ -351,6 +426,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
settingsHook.loadSettings,
|
||||
doses.clearDosesState,
|
||||
doses.loadTakenDoses,
|
||||
intakeJournal.resetJournalState,
|
||||
refill.clearRefillState,
|
||||
share.resetShareDialogState,
|
||||
]);
|
||||
@@ -442,8 +518,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
);
|
||||
|
||||
const existingPeople = useMemo(() => {
|
||||
const allPeople = medications.meds.flatMap((m) => m.takenBy || []);
|
||||
return [...new Set(allPeople)].filter(Boolean).sort();
|
||||
return mergePersonTags(medications.meds.flatMap((medication) => medication.takenBy || []));
|
||||
}, [medications.meds]);
|
||||
|
||||
// Get worst stock status for a day's medications
|
||||
@@ -658,9 +733,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
async (includeImages: boolean = true) => {
|
||||
setExporting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/export?includeSensitive=true&includeImages=${includeImages}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
const res = await authFetch(`/api/export?includeSensitive=true&includeImages=${includeImages}`);
|
||||
if (!res.ok) throw new Error("Export failed");
|
||||
const data = await res.json();
|
||||
|
||||
@@ -682,7 +755,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
}
|
||||
setExporting(false);
|
||||
},
|
||||
[t, user?.username]
|
||||
[authFetch, t, user?.username]
|
||||
);
|
||||
|
||||
// Handle file selection for import
|
||||
@@ -692,24 +765,64 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
reader.onload = async (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.target?.result as string);
|
||||
if (!data.version || !data.exportedAt) {
|
||||
alert(t("exportImport.invalidFile"));
|
||||
setPendingImportData(null);
|
||||
setImportPreview(null);
|
||||
showFeedback({ message: t("exportImport.invalidFile"), tone: "error" });
|
||||
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);
|
||||
setImportPreview(previewResponse.preview);
|
||||
setShowImportConfirm(true);
|
||||
} catch {
|
||||
alert(t("exportImport.invalidFile"));
|
||||
setPendingImportData(null);
|
||||
setImportPreview(null);
|
||||
showFeedback({ message: t("exportImport.invalidFile"), tone: "error" });
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
// Reset file input
|
||||
e.target.value = "";
|
||||
},
|
||||
[t]
|
||||
[authFetch, showFeedback, t]
|
||||
);
|
||||
|
||||
// Confirm and execute import
|
||||
@@ -719,10 +832,9 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
setShowImportConfirm(false);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/import", {
|
||||
const res = await authFetch("/api/import", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(pendingImportData),
|
||||
});
|
||||
|
||||
@@ -744,12 +856,18 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
data = text ? JSON.parse(text) : {};
|
||||
} catch {
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -768,12 +886,13 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
doses.loadTakenDoses();
|
||||
} catch (err) {
|
||||
log.error("Import error:", err);
|
||||
alert(t("exportImport.importError"));
|
||||
showFeedback({ message: t("exportImport.importError"), tone: "error" });
|
||||
} finally {
|
||||
setPendingImportData(null);
|
||||
setImportPreview(null);
|
||||
setImporting(false);
|
||||
}
|
||||
|
||||
setPendingImportData(null);
|
||||
setImporting(false);
|
||||
}, [pendingImportData, t, medications, settingsHook, doses]);
|
||||
}, [authFetch, pendingImportData, t, medications, settingsHook, doses, showFeedback]);
|
||||
|
||||
// Compute settingsChanged
|
||||
const settingsChanged = useMemo(() => {
|
||||
@@ -815,13 +934,21 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
setShareSelectedPerson: share.setShareSelectedPerson,
|
||||
shareSelectedDays: share.shareSelectedDays,
|
||||
setShareSelectedDays: share.setShareSelectedDays,
|
||||
shareSelectedExpiryDays: share.shareSelectedExpiryDays,
|
||||
setShareSelectedExpiryDays: share.setShareSelectedExpiryDays,
|
||||
shareAllowJournalNotes: share.shareAllowJournalNotes,
|
||||
setShareAllowJournalNotes: share.setShareAllowJournalNotes,
|
||||
shareGenerating: share.shareGenerating,
|
||||
shareLink: share.shareLink,
|
||||
setShareLink: share.setShareLink,
|
||||
shareCopied: share.shareCopied,
|
||||
setShareCopied: share.setShareCopied,
|
||||
activeShareLinks: share.activeShareLinks,
|
||||
activeSharesLoading: share.activeSharesLoading,
|
||||
revokingShareToken: share.revokingShareToken,
|
||||
openShareDialog,
|
||||
generateShareLink: share.generateShareLink,
|
||||
revokeShareLink: share.revokeShareLink,
|
||||
copyShareLink: share.copyShareLink,
|
||||
closeShareDialog: share.closeShareDialog,
|
||||
resetShareDialogState: share.resetShareDialogState,
|
||||
@@ -865,6 +992,29 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
undoDoseTaken: doses.undoDoseTaken,
|
||||
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
|
||||
manuallyCollapsedDays: collapsed.manuallyCollapsedDays,
|
||||
manuallyExpandedDays: collapsed.manuallyExpandedDays,
|
||||
@@ -877,13 +1027,21 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
setShareSelectedPerson: share.setShareSelectedPerson,
|
||||
shareSelectedDays: share.shareSelectedDays,
|
||||
setShareSelectedDays: share.setShareSelectedDays,
|
||||
shareSelectedExpiryDays: share.shareSelectedExpiryDays,
|
||||
setShareSelectedExpiryDays: share.setShareSelectedExpiryDays,
|
||||
shareAllowJournalNotes: share.shareAllowJournalNotes,
|
||||
setShareAllowJournalNotes: share.setShareAllowJournalNotes,
|
||||
shareGenerating: share.shareGenerating,
|
||||
shareLink: share.shareLink,
|
||||
setShareLink: share.setShareLink,
|
||||
shareCopied: share.shareCopied,
|
||||
setShareCopied: share.setShareCopied,
|
||||
activeShareLinks: share.activeShareLinks,
|
||||
activeSharesLoading: share.activeSharesLoading,
|
||||
revokingShareToken: share.revokingShareToken,
|
||||
openShareDialog,
|
||||
generateShareLink: share.generateShareLink,
|
||||
revokeShareLink: share.revokeShareLink,
|
||||
copyShareLink: share.copyShareLink,
|
||||
closeShareDialog: share.closeShareDialog,
|
||||
resetShareDialogState: share.resetShareDialogState,
|
||||
@@ -970,6 +1128,8 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
setShowImportConfirm,
|
||||
pendingImportData,
|
||||
setPendingImportData,
|
||||
importPreview,
|
||||
setImportPreview,
|
||||
importResult,
|
||||
setImportResult,
|
||||
handleExport,
|
||||
@@ -981,6 +1141,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
medications,
|
||||
settingsHook,
|
||||
doses,
|
||||
intakeJournal,
|
||||
collapsed,
|
||||
share,
|
||||
refill,
|
||||
@@ -1017,6 +1178,7 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
showExportModal,
|
||||
showImportConfirm,
|
||||
pendingImportData,
|
||||
importPreview,
|
||||
importResult,
|
||||
handleExport,
|
||||
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>>;
|
||||
shareSelectedDays: 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;
|
||||
shareLink: string | null;
|
||||
setShareLink: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
shareCopied: boolean;
|
||||
setShareCopied: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
activeShareLinks: import("../hooks/useShare").ActiveShareLink[];
|
||||
activeSharesLoading: boolean;
|
||||
revokingShareToken: string | null;
|
||||
openShareDialog: () => void;
|
||||
generateShareLink: () => Promise<void>;
|
||||
revokeShareLink: (token: string) => Promise<boolean>;
|
||||
copyShareLink: () => void;
|
||||
closeShareDialog: () => void;
|
||||
resetShareDialogState: () => void;
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
export type { AppContextValue, DayMedEntry, DoseInfo, GroupedDay } from "./AppContext";
|
||||
export { AppProvider, useAppContext } from "./AppContext";
|
||||
export type { FeedbackTone } from "./FeedbackContext";
|
||||
export { FeedbackProvider, useFeedback } from "./FeedbackContext";
|
||||
export type { ShareContextValue } from "./ShareContext";
|
||||
export { ShareContextProvider, useShareContext } from "./ShareContext";
|
||||
export { UnsavedChangesProvider, useUnsavedChanges } from "./UnsavedChangesContext";
|
||||
|
||||
@@ -5,6 +5,8 @@ export { useCollapsedDays } from "./useCollapsedDays";
|
||||
export type { UseDosesReturn } from "./useDoses";
|
||||
export { useDoses } from "./useDoses";
|
||||
export { useEscapeKey } from "./useEscapeKey";
|
||||
export type { IntakeJournalEntry, IntakeJournalHistoryFilters, UseIntakeJournalReturn } from "./useIntakeJournal";
|
||||
export { useIntakeJournal } from "./useIntakeJournal";
|
||||
export {
|
||||
createMedicationEnrichmentState,
|
||||
MEDICATION_ENRICHMENT_INITIAL_LIMIT,
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useFeedback } from "../context/FeedbackContext";
|
||||
|
||||
export interface UseDosesReturn {
|
||||
takenDoses: Set<string>;
|
||||
@@ -25,6 +27,8 @@ export interface UseDosesReturn {
|
||||
|
||||
export function useDoses(): UseDosesReturn {
|
||||
const { t } = useTranslation();
|
||||
const { authFetch } = useAuth();
|
||||
const { showFeedback } = useFeedback();
|
||||
const [takenDoses, setTakenDoses] = useState<Set<string>>(new Set());
|
||||
const [takenDoseTimestamps, setTakenDoseTimestamps] = useState<Map<string, number>>(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;
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/doses/taken", { credentials: "include" });
|
||||
const res = await authFetch("/api/doses/taken");
|
||||
if (res.ok) {
|
||||
// Double-check no mutation started while we were fetching
|
||||
if (mutationInFlightRef.current > 0) return;
|
||||
@@ -79,7 +83,7 @@ export function useDoses(): UseDosesReturn {
|
||||
} catch {
|
||||
// Don't reset on error - keep current state
|
||||
}
|
||||
}, [clearDosesState]);
|
||||
}, [authFetch, clearDosesState]);
|
||||
|
||||
// Poll for taken doses from server (works with or without auth)
|
||||
useEffect(() => {
|
||||
@@ -164,15 +168,14 @@ export function useDoses(): UseDosesReturn {
|
||||
|
||||
// Send to server
|
||||
try {
|
||||
const response = await fetch("/api/doses/taken", {
|
||||
const response = await authFetch("/api/doses/taken", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ doseId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
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");
|
||||
}
|
||||
@@ -220,7 +223,17 @@ export function useDoses(): UseDosesReturn {
|
||||
loadTakenDoses();
|
||||
}
|
||||
},
|
||||
[dismissedDoses, getErrorCode, loadTakenDoses, t, takenDoseSources, takenDoseTimestamps, takenDoses]
|
||||
[
|
||||
authFetch,
|
||||
dismissedDoses,
|
||||
getErrorCode,
|
||||
loadTakenDoses,
|
||||
showFeedback,
|
||||
t,
|
||||
takenDoseSources,
|
||||
takenDoseTimestamps,
|
||||
takenDoses,
|
||||
]
|
||||
);
|
||||
|
||||
const markDoseSkipped = useCallback(
|
||||
@@ -257,10 +270,9 @@ export function useDoses(): UseDosesReturn {
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/doses/skip", {
|
||||
const response = await authFetch("/api/doses/skip", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ doseId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
@@ -302,7 +314,7 @@ export function useDoses(): UseDosesReturn {
|
||||
loadTakenDoses();
|
||||
}
|
||||
},
|
||||
[dismissedDoses, loadTakenDoses, takenDoseSources, takenDoseTimestamps, takenDoses]
|
||||
[authFetch, dismissedDoses, loadTakenDoses, takenDoseSources, takenDoseTimestamps, takenDoses]
|
||||
);
|
||||
|
||||
const undoDoseTaken = useCallback(
|
||||
@@ -330,9 +342,8 @@ export function useDoses(): UseDosesReturn {
|
||||
|
||||
// Send to server
|
||||
try {
|
||||
await fetch(`/api/doses/taken/${encodeURIComponent(doseId)}`, {
|
||||
await authFetch(`/api/doses/taken/${encodeURIComponent(doseId)}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
} catch {
|
||||
// Revert on error
|
||||
@@ -361,7 +372,7 @@ export function useDoses(): UseDosesReturn {
|
||||
loadTakenDoses();
|
||||
}
|
||||
},
|
||||
[loadTakenDoses, takenDoseSources, takenDoseTimestamps]
|
||||
[authFetch, loadTakenDoses, takenDoseSources, takenDoseTimestamps]
|
||||
);
|
||||
|
||||
const undoDoseSkipped = useCallback(
|
||||
@@ -376,9 +387,8 @@ export function useDoses(): UseDosesReturn {
|
||||
});
|
||||
|
||||
try {
|
||||
await fetch(`/api/doses/skip/${encodeURIComponent(doseId)}`, {
|
||||
await authFetch(`/api/doses/skip/${encodeURIComponent(doseId)}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
} catch {
|
||||
setDismissedDoses((prev) => {
|
||||
@@ -393,7 +403,7 @@ export function useDoses(): UseDosesReturn {
|
||||
loadTakenDoses();
|
||||
}
|
||||
},
|
||||
[dismissedDoses, loadTakenDoses]
|
||||
[authFetch, dismissedDoses, loadTakenDoses]
|
||||
);
|
||||
|
||||
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";
|
||||
import { toDateValue, toTimeValue } from "../utils/formatters";
|
||||
import { normalizeWeekdays } from "../utils/intake-schedule";
|
||||
import { personTagsMatch } from "../utils/person-tags";
|
||||
|
||||
export const defaultBlister = (): FormBlister => {
|
||||
const now = new Date();
|
||||
@@ -488,7 +489,8 @@ export function useMedicationForm(): UseMedicationFormReturn {
|
||||
const addTakenByPerson = useCallback(
|
||||
(name: string) => {
|
||||
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] }));
|
||||
}
|
||||
setTakenByInput("");
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import type { Medication } from "../types";
|
||||
|
||||
export interface UseMedicationsReturn {
|
||||
@@ -16,6 +17,7 @@ export interface UseMedicationsReturn {
|
||||
}
|
||||
|
||||
export function useMedications(): UseMedicationsReturn {
|
||||
const { authFetch } = useAuth();
|
||||
const [meds, setMeds] = useState<Medication[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -30,20 +32,20 @@ export function useMedications(): UseMedicationsReturn {
|
||||
|
||||
const loadMeds = useCallback(() => {
|
||||
setLoading(true);
|
||||
fetch("/api/medications?includeObsolete=true", { credentials: "include" })
|
||||
authFetch("/api/medications?includeObsolete=true")
|
||||
.then((res) => res.json())
|
||||
.then((data) => setMeds(Array.isArray(data) ? data : []))
|
||||
.catch(() => setMeds([]))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
}, [authFetch]);
|
||||
|
||||
const deleteMed = useCallback(
|
||||
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();
|
||||
loadMeds();
|
||||
},
|
||||
[loadMeds]
|
||||
[authFetch, loadMeds]
|
||||
);
|
||||
|
||||
const uploadMedImage = useCallback(
|
||||
@@ -53,10 +55,9 @@ export function useMedications(): UseMedicationsReturn {
|
||||
formData.append("file", file);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/medications/${medId}/image`, {
|
||||
const res = await authFetch(`/api/medications/${medId}/image`, {
|
||||
method: "POST",
|
||||
body: formData,
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) {
|
||||
let code = "UNKNOWN";
|
||||
@@ -86,15 +87,15 @@ export function useMedications(): UseMedicationsReturn {
|
||||
setUploadingImage(false);
|
||||
}
|
||||
},
|
||||
[loadMeds]
|
||||
[authFetch, loadMeds]
|
||||
);
|
||||
|
||||
const deleteMedImage = useCallback(
|
||||
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]
|
||||
[authFetch, loadMeds]
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -19,14 +19,15 @@ export function useModalHistory(isOpen: boolean, modalKey: string, onClose: () =
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handlePopState = () => {
|
||||
const handlePopState = (event: PopStateEvent) => {
|
||||
if (pushedRef.current) {
|
||||
pushedRef.current = false;
|
||||
onClose();
|
||||
event.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
return () => window.removeEventListener("popstate", handlePopState);
|
||||
window.addEventListener("popstate", handlePopState, { capture: true });
|
||||
return () => window.removeEventListener("popstate", handlePopState, true);
|
||||
}, [isOpen, onClose]);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import type { Coverage, FormState, Medication, RefillEntry } from "../types";
|
||||
import {
|
||||
getMedTotal,
|
||||
@@ -55,6 +56,7 @@ export interface UseRefillReturn {
|
||||
}
|
||||
|
||||
export function useRefill(): UseRefillReturn {
|
||||
const { authFetch } = useAuth();
|
||||
// Refill state
|
||||
const [showRefillModal, setShowRefillModal] = useState(false);
|
||||
const [refillPacks, setRefillPacks] = useState(1);
|
||||
@@ -93,19 +95,22 @@ export function useRefill(): UseRefillReturn {
|
||||
}, [resetRefillForm]);
|
||||
|
||||
// Load refill history for a medication
|
||||
const loadRefillHistory = useCallback(async (medId: number) => {
|
||||
try {
|
||||
const res = await fetch(`/api/medications/${medId}/refills`, { credentials: "include" });
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setRefillHistory(Array.isArray(data) ? data : data.refills || []);
|
||||
} else {
|
||||
const loadRefillHistory = useCallback(
|
||||
async (medId: number) => {
|
||||
try {
|
||||
const res = await authFetch(`/api/medications/${medId}/refills`);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
setRefillHistory(Array.isArray(data) ? data : data.refills || []);
|
||||
} else {
|
||||
setRefillHistory([]);
|
||||
}
|
||||
} catch {
|
||||
setRefillHistory([]);
|
||||
}
|
||||
} catch {
|
||||
setRefillHistory([]);
|
||||
}
|
||||
}, []);
|
||||
},
|
||||
[authFetch]
|
||||
);
|
||||
|
||||
// Submit a refill
|
||||
const submitRefill = useCallback(
|
||||
@@ -119,10 +124,9 @@ export function useRefill(): UseRefillReturn {
|
||||
if (refillPacks < 1 && refillLoose < 1) return;
|
||||
setRefillSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/medications/${medId}/refill`, {
|
||||
const res = await authFetch(`/api/medications/${medId}/refill`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
packsAdded: refillPacks,
|
||||
loosePillsAdded: refillLoose,
|
||||
@@ -162,7 +166,7 @@ export function useRefill(): UseRefillReturn {
|
||||
}
|
||||
setRefillSaving(false);
|
||||
},
|
||||
[refillPacks, refillLoose, showRefillModal, loadRefillHistory]
|
||||
[authFetch, refillPacks, refillLoose, showRefillModal, loadRefillHistory]
|
||||
);
|
||||
|
||||
// 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
|
||||
const res = await fetch(`/api/medications/${medId}/stock-adjustment`, {
|
||||
const res = await authFetch(`/api/medications/${medId}/stock-adjustment`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(patchBody),
|
||||
});
|
||||
if (res.ok) {
|
||||
@@ -301,7 +304,7 @@ export function useRefill(): UseRefillReturn {
|
||||
}
|
||||
setEditStockSaving(false);
|
||||
},
|
||||
[editStockFullBlisters, editStockPartialBlisterPills, editStockLoosePills, showEditStockModal]
|
||||
[authFetch, editStockFullBlisters, editStockPartialBlisterPills, editStockLoosePills, showEditStockModal]
|
||||
);
|
||||
|
||||
const openRefillModal = useCallback(() => {
|
||||
|
||||
@@ -28,6 +28,27 @@ export function useScheduleController() {
|
||||
markDoseSkipped: ctx.markDoseSkipped,
|
||||
undoDoseTaken: ctx.undoDoseTaken,
|
||||
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,
|
||||
manuallyExpandedDays: ctx.manuallyExpandedDays,
|
||||
toggleDayCollapse: ctx.toggleDayCollapse,
|
||||
|
||||
+144
-29
@@ -3,12 +3,25 @@
|
||||
// =============================================================================
|
||||
|
||||
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 { withCorrelation } from "../utils/correlation";
|
||||
import { log } from "../utils/logger";
|
||||
|
||||
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 {
|
||||
showShareDialog: boolean;
|
||||
sharePeople: string[];
|
||||
@@ -16,54 +29,96 @@ export interface UseShareReturn {
|
||||
setShareSelectedPerson: React.Dispatch<React.SetStateAction<string>>;
|
||||
shareSelectedDays: 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;
|
||||
shareLink: string | null;
|
||||
setShareLink: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
shareCopied: boolean;
|
||||
setShareCopied: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
activeShareLinks: ActiveShareLink[];
|
||||
activeSharesLoading: boolean;
|
||||
revokingShareToken: string | null;
|
||||
openShareDialog: (meds: Medication[]) => void;
|
||||
generateShareLink: () => Promise<void>;
|
||||
revokeShareLink: (token: string) => Promise<boolean>;
|
||||
copyShareLink: () => void;
|
||||
closeShareDialog: () => void;
|
||||
resetShareDialogState: () => void;
|
||||
}
|
||||
|
||||
export function useShare(): UseShareReturn {
|
||||
const { authFetch } = useAuth();
|
||||
const { t } = useTranslation();
|
||||
const { showFeedback } = useFeedback();
|
||||
const [showShareDialog, setShowShareDialog] = useState(false);
|
||||
const [sharePeople, setSharePeople] = useState<string[]>([]);
|
||||
const [shareSelectedPerson, setShareSelectedPerson] = useState<string>("");
|
||||
const [shareSelectedDays, setShareSelectedDays] = useState<number>(30);
|
||||
const [shareSelectedExpiryDays, setShareSelectedExpiryDays] = useState<number | null>(null);
|
||||
const [shareAllowJournalNotes, setShareAllowJournalNotes] = useState(false);
|
||||
const [shareGenerating, setShareGenerating] = useState(false);
|
||||
const [shareLink, setShareLink] = useState<string | null>(null);
|
||||
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[]) => {
|
||||
setShowShareDialog(true);
|
||||
window.history.pushState({ modal: "share" }, "");
|
||||
setShareLink(null);
|
||||
setShareCopied(false);
|
||||
setShareSelectedPerson("");
|
||||
setShareSelectedDays(30);
|
||||
const loadActiveShareLinks = useCallback(async () => {
|
||||
setActiveSharesLoading(true);
|
||||
try {
|
||||
const response = await authFetch("/api/share");
|
||||
const data = await response.json().catch(() => ({}));
|
||||
if (!response.ok || !Array.isArray(data?.shareLinks)) {
|
||||
setActiveShareLinks([]);
|
||||
log.warn("[ShareDialog] Failed to load active share links", { status: response.status });
|
||||
return;
|
||||
}
|
||||
|
||||
// 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]);
|
||||
setActiveShareLinks(data.shareLinks);
|
||||
} catch (error) {
|
||||
setActiveShareLinks([]);
|
||||
log.error("[ShareDialog] Active share list request threw error", { error });
|
||||
} finally {
|
||||
setActiveSharesLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [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 () => {
|
||||
if (!shareSelectedPerson) {
|
||||
@@ -82,19 +137,24 @@ export function useShare(): UseShareReturn {
|
||||
body: JSON.stringify({
|
||||
takenBy: shareSelectedPerson,
|
||||
scheduleDays: shareSelectedDays,
|
||||
expiryDays: shareSelectedExpiryDays,
|
||||
allowJournalNotes: shareAllowJournalNotes,
|
||||
}),
|
||||
},
|
||||
"fe-share"
|
||||
);
|
||||
const res = await fetch("/api/share", init);
|
||||
const res = await authFetch("/api/share", init);
|
||||
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
const fullUrl = `${window.location.origin}/share/${data.token}`;
|
||||
setShareLink(fullUrl);
|
||||
void loadActiveShareLinks();
|
||||
log.info("[ShareDialog] Share link ready", {
|
||||
person: shareSelectedPerson,
|
||||
days: shareSelectedDays,
|
||||
expiryDays: shareSelectedExpiryDays,
|
||||
allowJournalNotes: shareAllowJournalNotes,
|
||||
reused: Boolean(data.reused),
|
||||
correlationId,
|
||||
});
|
||||
@@ -106,15 +166,57 @@ export function useShare(): UseShareReturn {
|
||||
error: err.error,
|
||||
correlationId,
|
||||
});
|
||||
alert(err.error || "Failed to generate share link");
|
||||
showFeedback({
|
||||
message: err.error || t("share.generateFailed"),
|
||||
tone: "error",
|
||||
});
|
||||
}
|
||||
} catch (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 {
|
||||
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(() => {
|
||||
if (shareLink) {
|
||||
@@ -168,6 +270,11 @@ export function useShare(): UseShareReturn {
|
||||
setShowShareDialog(false);
|
||||
setShareLink(null);
|
||||
setShareCopied(false);
|
||||
setShareSelectedExpiryDays(null);
|
||||
setShareAllowJournalNotes(false);
|
||||
setActiveShareLinks([]);
|
||||
setActiveSharesLoading(false);
|
||||
setRevokingShareToken(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
@@ -177,13 +284,21 @@ export function useShare(): UseShareReturn {
|
||||
setShareSelectedPerson,
|
||||
shareSelectedDays,
|
||||
setShareSelectedDays,
|
||||
shareSelectedExpiryDays,
|
||||
setShareSelectedExpiryDays,
|
||||
shareAllowJournalNotes,
|
||||
setShareAllowJournalNotes,
|
||||
shareGenerating,
|
||||
shareLink,
|
||||
setShareLink,
|
||||
shareCopied,
|
||||
setShareCopied,
|
||||
activeShareLinks,
|
||||
activeSharesLoading,
|
||||
revokingShareToken,
|
||||
openShareDialog,
|
||||
generateShareLink,
|
||||
revokeShareLink,
|
||||
copyShareLink,
|
||||
closeShareDialog,
|
||||
resetShareDialogState,
|
||||
|
||||
+113
-1
@@ -102,6 +102,64 @@
|
||||
"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": {
|
||||
"name": "Name",
|
||||
"pills": "Tabletten",
|
||||
@@ -604,10 +662,16 @@
|
||||
"deleteAccount": "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.",
|
||||
"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": {
|
||||
"loading": "Wird geladen...",
|
||||
"initializing": "Initialisierung...",
|
||||
"retry": "Erneut versuchen",
|
||||
"sending": "Wird gesendet...",
|
||||
"sent": "Gesendet!",
|
||||
"sendFailed": "Senden fehlgeschlagen",
|
||||
@@ -632,6 +696,7 @@
|
||||
"back": "Zurück",
|
||||
"cancel": "Abbrechen",
|
||||
"close": "Schließen",
|
||||
"hide": "Ausblenden",
|
||||
"edit": "Bearbeiten",
|
||||
"view": "Ansehen",
|
||||
"delete": "Löschen",
|
||||
@@ -676,6 +741,13 @@
|
||||
"allPeople": "Alle",
|
||||
"selectPerson": "Person 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",
|
||||
"generating": "Wird generiert...",
|
||||
"generateAnother": "Weiteren Link generieren",
|
||||
@@ -685,9 +757,21 @@
|
||||
"copyLink": "Link kopieren",
|
||||
"copyOverviewLink": "Übersichts-Link kopieren",
|
||||
"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.",
|
||||
"scheduleFor": "Zeitplan für",
|
||||
"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.",
|
||||
"generatedBy": "Erstellt von",
|
||||
"notFound": "Teilen-Link nicht gefunden",
|
||||
@@ -755,6 +839,24 @@
|
||||
"confirmImportEmpty": "Daten importieren?",
|
||||
"confirmImportEmptyMessage": "Alle Medikamente, Einnahmehistorie, Einstellungen und Teilen-Links aus der ausgewählten Datei werden importiert.",
|
||||
"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",
|
||||
"exportSuccess": "Daten erfolgreich exportiert",
|
||||
"importSuccess": "Daten erfolgreich importiert",
|
||||
@@ -836,6 +938,9 @@
|
||||
"button": "Bericht",
|
||||
"title": "Medikamentenbericht",
|
||||
"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",
|
||||
"deselectAll": "Alle abwählen",
|
||||
"activeMeds": "Aktive Medikamente",
|
||||
@@ -845,12 +950,19 @@
|
||||
"formatMd": "Markdown (.md)",
|
||||
"formatPdf": "PDF (Drucken)",
|
||||
"generate": "Erstellen",
|
||||
"regenerate": "Vorschau aktualisieren",
|
||||
"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",
|
||||
"filterByPerson": "Bericht für",
|
||||
"allPeople": "Alle Personen",
|
||||
"docTitle": "Medikamentenbericht",
|
||||
"docGenerated": "Erstellt am",
|
||||
"docRange": "Berichtszeitraum",
|
||||
"docGeneral": "Allgemein",
|
||||
"docCommercialName": "Handelsname",
|
||||
"docGenericName": "Wirkstoff",
|
||||
|
||||
+113
-1
@@ -102,6 +102,64 @@
|
||||
"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": {
|
||||
"name": "Name",
|
||||
"pills": "Pills",
|
||||
@@ -604,10 +662,16 @@
|
||||
"deleteAccount": "Delete Account",
|
||||
"deleteAccountConfirmTitle": "Delete Account?",
|
||||
"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": {
|
||||
"loading": "Loading...",
|
||||
"initializing": "Initializing...",
|
||||
"retry": "Retry",
|
||||
"sending": "Sending...",
|
||||
"sent": "Sent!",
|
||||
"sendFailed": "Failed to send",
|
||||
@@ -632,6 +696,7 @@
|
||||
"back": "Back",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"hide": "Hide",
|
||||
"edit": "Edit",
|
||||
"view": "View",
|
||||
"delete": "Delete",
|
||||
@@ -676,6 +741,13 @@
|
||||
"allPeople": "Everyone",
|
||||
"selectPerson": "Select person",
|
||||
"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",
|
||||
"generating": "Generating...",
|
||||
"generateAnother": "Generate another link",
|
||||
@@ -685,9 +757,21 @@
|
||||
"copyLink": "Copy Link",
|
||||
"copyOverviewLink": "Copy Overview Link",
|
||||
"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.",
|
||||
"scheduleFor": "Schedule for",
|
||||
"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.",
|
||||
"generatedBy": "Generated by",
|
||||
"notFound": "Share link not found",
|
||||
@@ -755,6 +839,24 @@
|
||||
"confirmImportEmpty": "Import Data?",
|
||||
"confirmImportEmptyMessage": "This will import all medications, dose history, settings, and share links from the selected file.",
|
||||
"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",
|
||||
"exportSuccess": "Data exported successfully",
|
||||
"importSuccess": "Data imported successfully",
|
||||
@@ -836,6 +938,9 @@
|
||||
"button": "Report",
|
||||
"title": "Medication Report",
|
||||
"description": "Generate a document with detailed medication information for your doctor or personal records.",
|
||||
"dateRange": "Date range",
|
||||
"from": "From",
|
||||
"until": "Until",
|
||||
"selectAll": "Select all",
|
||||
"deselectAll": "Deselect all",
|
||||
"activeMeds": "Active Medications",
|
||||
@@ -845,12 +950,19 @@
|
||||
"formatMd": "Markdown (.md)",
|
||||
"formatPdf": "PDF (Print)",
|
||||
"generate": "Generate",
|
||||
"regenerate": "Refresh preview",
|
||||
"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",
|
||||
"filterByPerson": "Report for",
|
||||
"allPeople": "Everyone",
|
||||
"docTitle": "Medication Report",
|
||||
"docGenerated": "Generated on",
|
||||
"docRange": "Report range",
|
||||
"docGeneral": "General",
|
||||
"docCommercialName": "Commercial Name",
|
||||
"docGenericName": "Generic Name",
|
||||
|
||||
@@ -3,6 +3,7 @@ import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
import "./styles/intake-journal.css";
|
||||
import "./styles/modals-base.css";
|
||||
import "./styles/share-dialog.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 { useTranslation } from "react-i18next";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { ConfirmModal, MedicationAvatar } from "../components";
|
||||
import { ConfirmModal, IntakeJournalHistoryModal, IntakeJournalModal, MedicationAvatar } from "../components";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { DashboardReminderSection } from "../components/dashboard/DashboardReminderSection";
|
||||
import { DashboardStatusSection } from "../components/dashboard/DashboardStatusSection";
|
||||
import { useAppContext } from "../context";
|
||||
import { useFeedback } from "../context/FeedbackContext";
|
||||
import { useModalHistory } from "../hooks";
|
||||
import {
|
||||
allowsPillFormSelection,
|
||||
getMedDisplayName,
|
||||
@@ -75,7 +77,8 @@ const EMPTY_DOSE_SET = new Set<string>();
|
||||
|
||||
export function DashboardPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const { user, authFetch } = useAuth();
|
||||
const { showFeedback } = useFeedback();
|
||||
const location = useLocation();
|
||||
const {
|
||||
meds,
|
||||
@@ -112,6 +115,26 @@ export function DashboardPage() {
|
||||
openUserFilter,
|
||||
openShareDialog,
|
||||
openScheduleLightbox,
|
||||
journalEditorOpen,
|
||||
journalHistoryOpen,
|
||||
journalEvent,
|
||||
journalEventLoading,
|
||||
journalEventSaving,
|
||||
journalEventDeleting,
|
||||
journalEventError,
|
||||
journalHistoryEntries,
|
||||
journalHistoryFilters,
|
||||
journalHistoryLoading,
|
||||
journalHistoryError,
|
||||
openJournalEditor,
|
||||
closeJournalEditor,
|
||||
saveJournalNote,
|
||||
deleteJournalNote,
|
||||
openJournalHistory,
|
||||
closeJournalHistory,
|
||||
setJournalHistoryFilters,
|
||||
reloadJournalHistory,
|
||||
reopenJournalHistoryEntry,
|
||||
stockThresholds,
|
||||
loadMeds,
|
||||
loadSettings,
|
||||
@@ -121,6 +144,21 @@ export function DashboardPage() {
|
||||
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
|
||||
const [obsoleteCandidate, setObsoleteCandidate] = useState<{ id: number; name: 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 =
|
||||
skippedDoses instanceof Set ? skippedDoses : dismissedDoses instanceof Set ? dismissedDoses : EMPTY_DOSE_SET;
|
||||
const canManageSkippedDoses = typeof markDoseSkipped === "function" && typeof undoDoseSkipped === "function";
|
||||
@@ -333,9 +371,8 @@ export function DashboardPage() {
|
||||
|
||||
setClearingMissed(true);
|
||||
try {
|
||||
const res = await fetch("/api/medications/dismiss-until", {
|
||||
const res = await authFetch("/api/medications/dismiss-until", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
@@ -344,14 +381,37 @@ export function DashboardPage() {
|
||||
}
|
||||
await loadMeds();
|
||||
setShowClearMissedConfirm(false);
|
||||
alert(t("dashboard.schedules.clearMissedSuccess", { count: missedCount }));
|
||||
showFeedback({
|
||||
message: t("dashboard.schedules.clearMissedSuccess", { count: missedCount }),
|
||||
tone: "success",
|
||||
});
|
||||
} catch {
|
||||
alert(t("common.saveFailed"));
|
||||
showFeedback({ message: t("common.saveFailed"), tone: "error" });
|
||||
} finally {
|
||||
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: {
|
||||
doseId: string;
|
||||
isTaken: boolean;
|
||||
@@ -359,6 +419,7 @@ export function DashboardPage() {
|
||||
isAutomaticallyTaken: boolean;
|
||||
isEmpty: boolean;
|
||||
}) => {
|
||||
const journalUnavailable = !(options.isTaken || options.isSkipped);
|
||||
const takeButton = options.isTaken ? (
|
||||
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
|
||||
{options.isAutomaticallyTaken && (
|
||||
@@ -381,8 +442,35 @@ export function DashboardPage() {
|
||||
</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) {
|
||||
return takeButton;
|
||||
return (
|
||||
<>
|
||||
{takeButton}
|
||||
{journalButton}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const skipButton = options.isSkipped ? (
|
||||
@@ -405,6 +493,7 @@ export function DashboardPage() {
|
||||
<>
|
||||
{takeButton}
|
||||
{skipButton}
|
||||
{journalButton}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -417,22 +506,20 @@ export function DashboardPage() {
|
||||
const handleConfirmMarkObsolete = async () => {
|
||||
if (!obsoleteCandidate) return;
|
||||
try {
|
||||
const res = await fetch(`/api/medications/${obsoleteCandidate.id}/obsolete`, {
|
||||
const res = await authFetch(`/api/medications/${obsoleteCandidate.id}/obsolete`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
await loadMeds();
|
||||
setShowObsoleteConfirm(false);
|
||||
setObsoleteCandidate(null);
|
||||
} catch {
|
||||
alert(t("common.saveFailed"));
|
||||
showFeedback({ message: t("common.saveFailed"), tone: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelMarkObsolete = () => {
|
||||
setShowObsoleteConfirm(false);
|
||||
setObsoleteCandidate(null);
|
||||
closeObsoleteConfirm();
|
||||
};
|
||||
|
||||
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",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
email: settings.notificationEmail,
|
||||
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",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
email: settings.notificationEmail,
|
||||
prescriptionLow,
|
||||
@@ -913,6 +998,17 @@ export function DashboardPage() {
|
||||
<option value={90}>{t("dashboard.schedules.3months")}</option>
|
||||
<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>
|
||||
{meds.some((m) => m.takenBy && m.takenBy.length > 0) && (
|
||||
<button
|
||||
className="ghost share-btn icon-only tooltip-trigger"
|
||||
@@ -1229,9 +1325,7 @@ export function DashboardPage() {
|
||||
confirmLabel={t("dashboard.schedules.clearMissedConfirm")}
|
||||
cancelLabel={t("dashboard.schedules.clearMissedCancel")}
|
||||
onConfirm={() => void clearMissedDoses(missedPastDoseIds.length)}
|
||||
onCancel={() => {
|
||||
if (!clearingMissed) setShowClearMissedConfirm(false);
|
||||
}}
|
||||
onCancel={closeClearMissedConfirm}
|
||||
isLoading={clearingMissed}
|
||||
confirmVariant="warning"
|
||||
/>
|
||||
@@ -1741,6 +1835,30 @@ export function DashboardPage() {
|
||||
})}
|
||||
</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>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@ import { MedicationDialogs } from "../components/medications/MedicationDialogs";
|
||||
import { MedicationEditCoordinator } from "../components/medications/MedicationEditCoordinator";
|
||||
import { MedicationListSection } from "../components/medications/MedicationListSection";
|
||||
import { useAppContext, useUnsavedChanges } from "../context";
|
||||
import { useFeedback } from "../context/FeedbackContext";
|
||||
import {
|
||||
MEDICATION_ENRICHMENT_INITIAL_LIMIT,
|
||||
MEDICATION_ENRICHMENT_LIMIT_STEP,
|
||||
@@ -222,7 +223,8 @@ async function getMedicationEnrichmentErrorMessage(
|
||||
export function MedicationsPage() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const { user, authFetch } = useAuth();
|
||||
const { showFeedback } = useFeedback();
|
||||
const {
|
||||
meds,
|
||||
saving,
|
||||
@@ -274,6 +276,7 @@ export function MedicationsPage() {
|
||||
);
|
||||
const [viewMode, setViewMode] = useState<"grid" | "form">(pendingEditTransition ? "form" : "grid");
|
||||
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null);
|
||||
const closeLightbox = useCallback(() => setLightboxImage(null), []);
|
||||
const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general");
|
||||
|
||||
// Mobile modal state (declared early because it's used in useEffect below)
|
||||
@@ -394,9 +397,7 @@ export function MedicationsPage() {
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({ q: trimmedQuery, limit: String(limit) });
|
||||
const response = await fetch(`/api/medication-enrichment/search?${params.toString()}`, {
|
||||
credentials: "include",
|
||||
});
|
||||
const response = await authFetch(`/api/medication-enrichment/search?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
@@ -458,7 +459,7 @@ export function MedicationsPage() {
|
||||
}));
|
||||
}
|
||||
},
|
||||
[medicationEnrichment.query, medicationEnrichment.results, t]
|
||||
[authFetch, medicationEnrichment.query, medicationEnrichment.results, t]
|
||||
);
|
||||
|
||||
const handlePendingMedicationImageSelection = useCallback(
|
||||
@@ -489,6 +490,8 @@ export function MedicationsPage() {
|
||||
const [readOnlyView, setReadOnlyView] = useState(false);
|
||||
const [showReportModal, setShowReportModal] = useState(false);
|
||||
useModalHistory(showReportModal, "report", () => setShowReportModal(false));
|
||||
useModalHistory(!!lightboxImage, "medication-image-lightbox", closeLightbox);
|
||||
useModalHistory(showUnsavedConfirm, "medication-unsaved-confirm", handleCancelClose);
|
||||
const [showNameValidation, setShowNameValidation] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -517,13 +520,13 @@ export function MedicationsPage() {
|
||||
|
||||
const loadAllMeds = useCallback(async () => {
|
||||
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;
|
||||
setAllMeds(Array.isArray(data) ? (data as Medication[]) : []);
|
||||
} catch {
|
||||
setAllMeds([]);
|
||||
}
|
||||
}, []);
|
||||
}, [authFetch]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadAllMeds();
|
||||
@@ -617,7 +620,7 @@ export function MedicationsPage() {
|
||||
}));
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/medication-enrichment/enrich", {
|
||||
const response = await authFetch("/api/medication-enrichment/enrich", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -627,7 +630,6 @@ export function MedicationsPage() {
|
||||
code: result.code,
|
||||
source: result.source,
|
||||
}),
|
||||
credentials: "include",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -699,7 +701,7 @@ export function MedicationsPage() {
|
||||
}));
|
||||
}
|
||||
},
|
||||
[form, medicationEnrichment.query, setForm, t]
|
||||
[authFetch, form, medicationEnrichment.query, setForm, t]
|
||||
);
|
||||
|
||||
const handleMedicationEnrichmentStrengthApply = useCallback(
|
||||
@@ -1018,7 +1020,7 @@ export function MedicationsPage() {
|
||||
|
||||
async function markMedicationObsolete(id: number) {
|
||||
try {
|
||||
await fetch(`/api/medications/${id}/obsolete`, { method: "POST", credentials: "include" });
|
||||
await authFetch(`/api/medications/${id}/obsolete`, { method: "POST" });
|
||||
if (editingId === id) {
|
||||
handleResetForm();
|
||||
}
|
||||
@@ -1031,7 +1033,7 @@ export function MedicationsPage() {
|
||||
|
||||
async function reactivateMedication(id: number) {
|
||||
try {
|
||||
await fetch(`/api/medications/${id}/reactivate`, { method: "POST", credentials: "include" });
|
||||
await authFetch(`/api/medications/${id}/reactivate`, { method: "POST" });
|
||||
loadMeds();
|
||||
await loadAllMeds();
|
||||
} catch {
|
||||
@@ -1229,7 +1231,10 @@ export function MedicationsPage() {
|
||||
}
|
||||
} catch (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);
|
||||
@@ -2314,7 +2319,7 @@ export function MedicationsPage() {
|
||||
onCancelDelete={handleCancelDelete}
|
||||
showEditModal={showEditModal}
|
||||
lightboxImage={lightboxImage}
|
||||
onCloseLightbox={() => setLightboxImage(null)}
|
||||
onCloseLightbox={closeLightbox}
|
||||
showReportModal={showReportModal}
|
||||
onCloseReportModal={() => setShowReportModal(false)}
|
||||
medications={allMeds}
|
||||
|
||||
@@ -33,7 +33,7 @@ function userStorageKey(userId: number | undefined, key: string): string {
|
||||
|
||||
export function PlannerPage() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const { user, authFetch } = useAuth();
|
||||
const { meds, settings, openMedDetail } = useAppContext();
|
||||
|
||||
// Local state for planner
|
||||
@@ -90,10 +90,9 @@ export function PlannerPage() {
|
||||
e.preventDefault();
|
||||
setPlannerLoading(true);
|
||||
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",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
.then((res) => res.json())
|
||||
@@ -158,10 +157,9 @@ export function PlannerPage() {
|
||||
setPlannerEmailResult(null);
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/planner/send-email", {
|
||||
const res = await authFetch("/api/planner/send-email", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
email: settings.notificationEmail,
|
||||
from: range.start,
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/* biome-ignore-all lint/style/noNestedTernary: schedule timeline branches are intentionally explicit */
|
||||
import { Archive, Bell } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Archive, Bell, ClipboardList, NotebookPen } from "lucide-react";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfirmModal, MedicationAvatar } from "../components";
|
||||
import { ConfirmModal, IntakeJournalHistoryModal, IntakeJournalModal, MedicationAvatar } from "../components";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useFeedback } from "../context/FeedbackContext";
|
||||
import { ScheduleUsageTag } from "../features/schedule/components";
|
||||
import { formatScheduleDoseUsageLabel, formatScheduleTotalUsageLabel } from "../features/schedule/formatters";
|
||||
import { useScheduleController } from "../hooks";
|
||||
import { useModalHistory, useScheduleController } from "../hooks";
|
||||
import type { Coverage, IntakeUnit } from "../types";
|
||||
import { getMedDisplayName, isLiquidContainerPackageType, isTubePackageType } from "../types";
|
||||
import { buildClearMissedPayload, isDoseDismissed } from "../utils/schedule";
|
||||
@@ -71,7 +72,8 @@ function getDoseId(baseId: string, person: string | null): string {
|
||||
|
||||
export function SchedulePage() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const { user, authFetch } = useAuth();
|
||||
const { showFeedback } = useFeedback();
|
||||
const {
|
||||
meds,
|
||||
settings,
|
||||
@@ -96,12 +98,46 @@ export function SchedulePage() {
|
||||
openUserFilter,
|
||||
missedPastDoseIds,
|
||||
loadMeds,
|
||||
journalEditorOpen,
|
||||
journalHistoryOpen,
|
||||
journalEvent,
|
||||
journalEventLoading,
|
||||
journalEventSaving,
|
||||
journalEventDeleting,
|
||||
journalEventError,
|
||||
journalHistoryEntries,
|
||||
journalHistoryFilters,
|
||||
journalHistoryLoading,
|
||||
journalHistoryError,
|
||||
openJournalEditor,
|
||||
closeJournalEditor,
|
||||
saveJournalNote,
|
||||
deleteJournalNote,
|
||||
openJournalHistory,
|
||||
closeJournalHistory,
|
||||
setJournalHistoryFilters,
|
||||
reloadJournalHistory,
|
||||
reopenJournalHistoryEntry,
|
||||
} = useScheduleController();
|
||||
const [showClearMissedConfirm, setShowClearMissedConfirm] = useState(false);
|
||||
const [clearingMissed, setClearingMissed] = useState(false);
|
||||
const [showObsoleteConfirm, setShowObsoleteConfirm] = useState(false);
|
||||
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 shouldHideNoScheduleStatusForTube = (
|
||||
@@ -118,9 +154,8 @@ export function SchedulePage() {
|
||||
|
||||
setClearingMissed(true);
|
||||
try {
|
||||
const res = await fetch("/api/medications/dismiss-until", {
|
||||
const res = await authFetch("/api/medications/dismiss-until", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
@@ -129,14 +164,37 @@ export function SchedulePage() {
|
||||
}
|
||||
await loadMeds();
|
||||
setShowClearMissedConfirm(false);
|
||||
alert(t("dashboard.schedules.clearMissedSuccess", { count: missedCount }));
|
||||
showFeedback({
|
||||
message: t("dashboard.schedules.clearMissedSuccess", { count: missedCount }),
|
||||
tone: "success",
|
||||
});
|
||||
} catch {
|
||||
alert(t("common.saveFailed"));
|
||||
showFeedback({ message: t("common.saveFailed"), tone: "error" });
|
||||
} finally {
|
||||
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 }) => {
|
||||
setObsoleteCandidate(med);
|
||||
setShowObsoleteConfirm(true);
|
||||
@@ -145,22 +203,20 @@ export function SchedulePage() {
|
||||
const handleConfirmMarkObsolete = async () => {
|
||||
if (!obsoleteCandidate) return;
|
||||
try {
|
||||
const res = await fetch(`/api/medications/${obsoleteCandidate.id}/obsolete`, {
|
||||
const res = await authFetch(`/api/medications/${obsoleteCandidate.id}/obsolete`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
await loadMeds();
|
||||
setShowObsoleteConfirm(false);
|
||||
setObsoleteCandidate(null);
|
||||
} catch {
|
||||
alert(t("common.saveFailed"));
|
||||
showFeedback({ message: t("common.saveFailed"), tone: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelMarkObsolete = () => {
|
||||
setShowObsoleteConfirm(false);
|
||||
setObsoleteCandidate(null);
|
||||
closeObsoleteConfirm();
|
||||
};
|
||||
|
||||
const formatDoseUsageLabel = (
|
||||
@@ -182,6 +238,7 @@ export function SchedulePage() {
|
||||
isAutomaticallyTaken: boolean;
|
||||
isEmpty: boolean;
|
||||
}) => {
|
||||
const journalUnavailable = !(options.isTaken || options.isSkipped);
|
||||
const takeButton = options.isTaken ? (
|
||||
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
|
||||
{options.isAutomaticallyTaken && (
|
||||
@@ -220,10 +277,33 @@ export function SchedulePage() {
|
||||
</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 (
|
||||
<>
|
||||
{takeButton}
|
||||
{skipButton}
|
||||
{journalButton}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -233,19 +313,32 @@ export function SchedulePage() {
|
||||
<article className="card schedule-full">
|
||||
<div className="card-head">
|
||||
<h2>{t("dashboard.schedules.title")}</h2>
|
||||
<select
|
||||
className="select-field schedule-days-select"
|
||||
value={scheduleDays}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
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={180}>{t("dashboard.schedules.6months")}</option>
|
||||
</select>
|
||||
<div className="card-head-actions">
|
||||
<select
|
||||
className="select-field schedule-days-select"
|
||||
value={scheduleDays}
|
||||
onChange={(e) => {
|
||||
const val = Number(e.target.value);
|
||||
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={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 className="timeline">
|
||||
{/* Past days (when expanded) — rendered above toggle */}
|
||||
@@ -482,9 +575,7 @@ export function SchedulePage() {
|
||||
confirmLabel={t("dashboard.schedules.clearMissedConfirm")}
|
||||
cancelLabel={t("dashboard.schedules.clearMissedCancel")}
|
||||
onConfirm={() => void clearMissedDoses(missedPastDoseIds.length)}
|
||||
onCancel={() => {
|
||||
if (!clearingMissed) setShowClearMissedConfirm(false);
|
||||
}}
|
||||
onCancel={closeClearMissedConfirm}
|
||||
isLoading={clearingMissed}
|
||||
confirmVariant="warning"
|
||||
/>
|
||||
@@ -630,6 +721,30 @@ export function SchedulePage() {
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
/* 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 { ConfirmModal, ExportModal } from "../components";
|
||||
import { ExportModal, ImportReviewModal } from "../components";
|
||||
import { useAuth } from "../components/Auth";
|
||||
import { useAppContext } from "../context";
|
||||
import { useModalHistory } from "../hooks";
|
||||
import { getSystemLocale, withFormattingTimezone } from "../utils/formatters";
|
||||
|
||||
export function SettingsPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { authFetch } = useAuth();
|
||||
const [apiKeyToken, setApiKeyToken] = useState("");
|
||||
const [apiKeyGenerating, setApiKeyGenerating] = useState(false);
|
||||
const [apiKeyCopied, setApiKeyCopied] = useState(false);
|
||||
@@ -37,15 +40,32 @@ export function SettingsPage() {
|
||||
showImportConfirm,
|
||||
setShowImportConfirm,
|
||||
setPendingImportData,
|
||||
importPreview,
|
||||
setImportPreview,
|
||||
handleImportConfirm,
|
||||
importResult,
|
||||
setImportResult,
|
||||
meds,
|
||||
} = useAppContext();
|
||||
const [timezoneTouched, setTimezoneTouched] = useState(false);
|
||||
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;
|
||||
if (settingsLoadError === "auth") {
|
||||
emailUnavailableReason = t("settings.email.loadErrorAuth");
|
||||
@@ -63,10 +83,9 @@ export function SettingsPage() {
|
||||
setApiKeyCopied(false);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/auth/api-keys", {
|
||||
const response = await authFetch("/api/auth/api-keys", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({
|
||||
name: "Default API Key",
|
||||
scope: "write",
|
||||
@@ -195,10 +214,9 @@ export function SettingsPage() {
|
||||
onChange={(e) => {
|
||||
const lang = e.target.value;
|
||||
i18n.changeLanguage(lang);
|
||||
fetch("/api/settings/language", {
|
||||
authFetch("/api/settings/language", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ language: lang }),
|
||||
});
|
||||
}}
|
||||
@@ -1142,38 +1160,19 @@ export function SettingsPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Confirmation Modal */}
|
||||
{showImportConfirm && (
|
||||
<ConfirmModal
|
||||
title={t(hasExistingData ? "exportImport.confirmImport" : "exportImport.confirmImportEmpty")}
|
||||
message={
|
||||
hasExistingData ? (
|
||||
<>
|
||||
<p style={{ marginBottom: "12px" }}>{t("exportImport.confirmImportMessage")}</p>
|
||||
<p className="warning-text">⚠️ {t("exportImport.confirmImportWarning")}</p>
|
||||
</>
|
||||
) : (
|
||||
<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"}
|
||||
/>
|
||||
)}
|
||||
<ImportReviewModal
|
||||
isOpen={showImportConfirm}
|
||||
importPreview={importPreview}
|
||||
formattedExportedAt={formattedImportPreviewDate}
|
||||
importing={importing}
|
||||
exporting={exporting}
|
||||
onClose={closeImportReview}
|
||||
onBackup={() => handleExport(true)}
|
||||
onConfirm={handleImportConfirm}
|
||||
/>
|
||||
|
||||
{/* Export Options Modal */}
|
||||
<ExportModal
|
||||
isOpen={showExportModal}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
onExport={handleExport}
|
||||
exporting={exporting}
|
||||
/>
|
||||
<ExportModal isOpen={showExportModal} onClose={closeExportModal} onExport={handleExport} exporting={exporting} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
Add new shared styles to the focused partial that owns the relevant domain.
|
||||
============================================================================= */
|
||||
@import url("./styles/foundation.css");
|
||||
@import url("./styles/feedback.css");
|
||||
@import url("./styles/app-surfaces.css");
|
||||
@import url("./styles/settings-surfaces.css");
|
||||
@import url("./styles/modal-detail.css");
|
||||
|
||||
@@ -284,6 +284,37 @@ a.about-version-link:hover {
|
||||
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 */
|
||||
.report-person-filter {
|
||||
margin-bottom: 1.25rem;
|
||||
@@ -448,6 +479,60 @@ a.about-version-link:hover {
|
||||
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 */
|
||||
.report-actions {
|
||||
display: flex;
|
||||
@@ -456,3 +541,9 @@ a.about-version-link:hover {
|
||||
padding-top: 0.75rem;
|
||||
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 {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.doses-col {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
flex-direction: column;
|
||||
flex-wrap: nowrap;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dose-item {
|
||||
flex: 1 1 auto;
|
||||
min-width: 140px;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.3rem;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(3.75rem, auto) minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
gap: 0.45rem;
|
||||
padding: 0.55rem 0.6rem;
|
||||
}
|
||||
|
||||
.dose-time {
|
||||
min-width: 42px;
|
||||
padding-left: 0.2rem;
|
||||
min-width: 0;
|
||||
padding-left: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dose-usage {
|
||||
line-height: 1.15;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.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 {
|
||||
gap: 4px;
|
||||
padding: 1px 4px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
justify-content: flex-end;
|
||||
gap: 0.35rem;
|
||||
padding: 0.28rem 0.35rem;
|
||||
}
|
||||
|
||||
.dose-person .person-name {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
max-width: 5.6rem;
|
||||
margin-right: 0.35rem;
|
||||
max-width: none;
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.dose-person > .tooltip-trigger {
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dose-person .dose-btn {
|
||||
height: 22px;
|
||||
min-height: 22px;
|
||||
padding: 0 5px;
|
||||
height: 26px;
|
||||
min-height: 26px;
|
||||
padding: 0 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
@@ -2975,31 +3001,172 @@ button.has-validation-error {
|
||||
|
||||
.day-block {
|
||||
padding: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Use more horizontal space for schedule cards on phones */
|
||||
.dashboard-schedules-section > .card {
|
||||
padding-inline: 0.35rem;
|
||||
overflow: visible;
|
||||
.timeline,
|
||||
.time-main,
|
||||
.time-main .med-name,
|
||||
.tag-row {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
/* Keep header controls aligned like other dashboard cards */
|
||||
.dashboard-schedules-section .card-head {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.6rem;
|
||||
padding-inline: 0.65rem;
|
||||
.time-main .med-name {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Keep schedule controls readable without exceeding phone width. */
|
||||
.dashboard-schedules-section > .card,
|
||||
.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 {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
margin-left: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
.dashboard-schedules-section .day-block {
|
||||
margin-inline: -0.1rem;
|
||||
.clear-missed-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.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 {
|
||||
max-width: 1200px;
|
||||
max-width: 1440px;
|
||||
margin: 0 auto;
|
||||
padding: 2.5rem 1.5rem 1.5rem;
|
||||
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 {
|
||||
max-width: 800px;
|
||||
max-width: 1180px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -97,6 +97,14 @@
|
||||
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 {
|
||||
color: var(--text-secondary);
|
||||
font-size: 1rem;
|
||||
@@ -127,6 +135,10 @@
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.shared-schedule-page .tooltip-trigger > .dose-btn:disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.med-name-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -326,17 +338,127 @@
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.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 {
|
||||
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 {
|
||||
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 {
|
||||
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) ── */
|
||||
.edit-sidebar {
|
||||
display: none;
|
||||
|
||||
@@ -1290,4 +1290,66 @@
|
||||
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 */
|
||||
|
||||
@@ -66,6 +66,82 @@
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
|
||||
@@ -3,11 +3,34 @@ import { MemoryRouter, useLocation } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
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 = {
|
||||
user: { id: number; username: string } | null;
|
||||
authState: { authEnabled: boolean; needsSetup: boolean } | null;
|
||||
loading: boolean;
|
||||
authError: string | null;
|
||||
sessionExpired?: boolean;
|
||||
};
|
||||
|
||||
let authMock: AuthStateMock = {
|
||||
@@ -15,6 +38,7 @@ let authMock: AuthStateMock = {
|
||||
authState: { authEnabled: false, needsSetup: false },
|
||||
loading: false,
|
||||
authError: null,
|
||||
sessionExpired: false,
|
||||
};
|
||||
|
||||
let appContextMock: Record<string, unknown>;
|
||||
@@ -58,7 +82,7 @@ vi.mock("../context", async () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../pages", () => ({
|
||||
vi.mock("../pages/DashboardPage", () => ({
|
||||
DashboardPage: () => {
|
||||
const location = useLocation();
|
||||
return (
|
||||
@@ -68,10 +92,25 @@ vi.mock("../pages", () => ({
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../pages/MedicationsPage", () => ({
|
||||
MedicationsPage: () => <div>medications-page</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../pages/PlannerPage", () => ({
|
||||
PlannerPage: () => <div>planner-page</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../pages/SchedulePage", () => ({
|
||||
SchedulePage: () => <div>schedule-page</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../pages/SettingsPage", () => ({
|
||||
SettingsPage: () => <div>settings-page</div>,
|
||||
}));
|
||||
|
||||
vi.mock("../pages/SharedOverviewPage", () => ({
|
||||
SharedOverviewPage: () => <div>shared-overview-page</div>,
|
||||
}));
|
||||
|
||||
@@ -141,12 +180,20 @@ describe("App", () => {
|
||||
setShareSelectedPerson: vi.fn(),
|
||||
shareSelectedDays: 7,
|
||||
setShareSelectedDays: vi.fn(),
|
||||
shareSelectedExpiryDays: null,
|
||||
setShareSelectedExpiryDays: vi.fn(),
|
||||
shareAllowJournalNotes: false,
|
||||
setShareAllowJournalNotes: vi.fn(),
|
||||
shareGenerating: false,
|
||||
shareLink: null,
|
||||
setShareLink: vi.fn(),
|
||||
shareCopied: false,
|
||||
setShareCopied: vi.fn(),
|
||||
activeShareLinks: [],
|
||||
activeSharesLoading: false,
|
||||
revokingShareToken: null,
|
||||
generateShareLink: vi.fn(),
|
||||
revokeShareLink: vi.fn(),
|
||||
copyShareLink: vi.fn(),
|
||||
closeShareDialog: vi.fn(),
|
||||
resetShareDialogState: vi.fn(),
|
||||
@@ -200,6 +247,7 @@ describe("App", () => {
|
||||
);
|
||||
|
||||
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.getByRole("button", { name: "Retry" })).toBeInTheDocument();
|
||||
});
|
||||
@@ -262,7 +310,7 @@ describe("App", () => {
|
||||
expect(screen.getByText("auth-page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders app shell when auth is disabled", () => {
|
||||
it("renders app shell when auth is disabled", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/dashboard"]}>
|
||||
<App />
|
||||
@@ -270,10 +318,10 @@ describe("App", () => {
|
||||
);
|
||||
|
||||
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";
|
||||
|
||||
render(
|
||||
@@ -282,8 +330,8 @@ describe("App", () => {
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dashboard-location-search")).toHaveTextContent(search);
|
||||
expect(await screen.findByText("dashboard-page")).toBeInTheDocument();
|
||||
expect(await screen.findByTestId("dashboard-location-search")).toHaveTextContent(search);
|
||||
});
|
||||
|
||||
it("renders initializing state when auth state is missing", () => {
|
||||
@@ -370,14 +418,14 @@ describe("App", () => {
|
||||
expect(shareContextMock.resetShareDialogState).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("redirects unknown routes to dashboard", () => {
|
||||
it("redirects unknown routes to dashboard", async () => {
|
||||
render(
|
||||
<MemoryRouter initialEntries={["/unknown-route"]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
|
||||
expect(await screen.findByText("dashboard-page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("popstate closes image lightbox before other modals", () => {
|
||||
|
||||
@@ -132,6 +132,7 @@ describe("AuthProvider", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
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.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 () => {
|
||||
|
||||
@@ -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 { 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 {
|
||||
return {
|
||||
id: 1,
|
||||
@@ -24,6 +46,7 @@ function createMedication(overrides: Partial<Medication> = {}): Medication {
|
||||
describe("ReportModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
authFetchMock.mockImplementation((input: RequestInfo | URL, init?: RequestInit) => fetch(input, init));
|
||||
});
|
||||
|
||||
it("renders and closes when cancel is clicked", () => {
|
||||
@@ -35,35 +58,41 @@ describe("ReportModal", () => {
|
||||
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();
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
1: {
|
||||
dosesTaken: 2,
|
||||
dosesSkipped: 0,
|
||||
firstDoseAt: "2026-01-01T08:00:00.000Z",
|
||||
lastDoseAt: "2026-01-02T08:00:00.000Z",
|
||||
refills: [],
|
||||
},
|
||||
}),
|
||||
});
|
||||
for (const format of ["txt", "md"] as const) {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
1: {
|
||||
dosesTaken: 2,
|
||||
automaticDosesTaken: 0,
|
||||
dosesSkipped: 0,
|
||||
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(screen.getByRole("button", { name: /report\.generate/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/medications/report-data",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
fireEvent.click(
|
||||
screen.getByRole("radio", { name: new RegExp(`report\\.format${format === "txt" ? "Txt" : "Md"}`, "i") })
|
||||
);
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(URL.createObjectURL).toHaveBeenCalled();
|
||||
await waitFor(() => {
|
||||
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 () => {
|
||||
@@ -99,18 +128,15 @@ describe("ReportModal", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(URL.createObjectURL).toHaveBeenCalled();
|
||||
expectPreviewToBeVisible();
|
||||
});
|
||||
|
||||
const [blob] = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.calls.at(-1) ?? [];
|
||||
expect(blob).toBeInstanceOf(Blob);
|
||||
|
||||
const content = await (blob as Blob).text();
|
||||
const content = getPreviewContent();
|
||||
|
||||
expect(content).toContain(formatDate("2026-02-01"));
|
||||
expect(content).toContain(formatDateTime("2026-02-02T08:30: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 () => {
|
||||
@@ -151,16 +177,15 @@ describe("ReportModal", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(URL.createObjectURL).toHaveBeenCalled();
|
||||
expectPreviewToBeVisible();
|
||||
});
|
||||
|
||||
const [blob] = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.calls.at(-1) ?? [];
|
||||
const content = await (blob as Blob).text();
|
||||
const content = getPreviewContent();
|
||||
|
||||
expect(content).toContain("report.docTotalCapacity: 100");
|
||||
expect(content).toContain("report.docCurrentStock: 70 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 () => {
|
||||
@@ -205,15 +230,14 @@ describe("ReportModal", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: /report\.generate/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(URL.createObjectURL).toHaveBeenCalled();
|
||||
expectPreviewToBeVisible();
|
||||
});
|
||||
|
||||
const [blob] = (URL.createObjectURL as ReturnType<typeof vi.fn>).mock.calls.at(-1) ?? [];
|
||||
const content = await (blob as Blob).text();
|
||||
const content = getPreviewContent();
|
||||
|
||||
expect(content).toContain("report.docCurrentStock: 6 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 () => {
|
||||
@@ -288,14 +312,17 @@ describe("ReportModal", () => {
|
||||
onClose={onClose}
|
||||
medications={[
|
||||
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.getAllByRole("checkbox", { name: "Alice" })).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole("checkbox", { name: "Alice" }));
|
||||
expect(screen.getByText("Alice Med")).toBeInTheDocument();
|
||||
expect(screen.getByText("Alice Lower")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Bob Med")).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /report\.deselectAll/i }));
|
||||
@@ -335,7 +362,8 @@ describe("ReportModal", () => {
|
||||
onClose={onClose}
|
||||
medications={[
|
||||
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 }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/medications/report-data",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ medicationIds: [1], takenByFilter: ["Alice"] }),
|
||||
})
|
||||
);
|
||||
const [, requestInit] = authFetchMock.mock.calls[0] ?? [];
|
||||
const body = JSON.parse((requestInit?.body as string) ?? "{}");
|
||||
expect(body).toMatchObject({ medicationIds: [1, 2], takenByFilter: ["Alice"] });
|
||||
expect(typeof body.startDate).toBe("string");
|
||||
expect(typeof body.endDate).toBe("string");
|
||||
});
|
||||
|
||||
authFetchMock.mockClear();
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockClear();
|
||||
firstRender.unmount();
|
||||
render(
|
||||
@@ -362,7 +389,8 @@ describe("ReportModal", () => {
|
||||
onClose={onClose}
|
||||
medications={[
|
||||
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 }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"/api/medications/report-data",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ medicationIds: [1, 2], takenByFilter: undefined }),
|
||||
})
|
||||
);
|
||||
const [, requestInit] = authFetchMock.mock.calls[0] ?? [];
|
||||
const body = JSON.parse((requestInit?.body as string) ?? "{}");
|
||||
expect(body).toMatchObject({ medicationIds: [1, 2, 3] });
|
||||
expect(body).not.toHaveProperty("takenByFilter");
|
||||
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();
|
||||
(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 }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalled();
|
||||
expect(authFetchMock).toHaveBeenCalledWith(
|
||||
"/api/medications/report-data",
|
||||
expect.objectContaining({ method: "POST" })
|
||||
);
|
||||
});
|
||||
|
||||
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(),
|
||||
shareSelectedDays: 30,
|
||||
onShareSelectedDaysChange: vi.fn(),
|
||||
shareSelectedExpiryDays: null,
|
||||
onShareSelectedExpiryDaysChange: vi.fn(),
|
||||
shareAllowJournalNotes: false,
|
||||
onShareAllowJournalNotesChange: vi.fn(),
|
||||
shareGenerating: false,
|
||||
shareLink: null,
|
||||
onShareLinkChange: vi.fn(),
|
||||
shareCopied: false,
|
||||
onShareCopiedChange: vi.fn(),
|
||||
activeShareLinks: [],
|
||||
activeSharesLoading: false,
|
||||
revokingShareToken: null,
|
||||
onClose: vi.fn(),
|
||||
onGenerateShareLink: vi.fn(),
|
||||
onRevokeShareLink: vi.fn().mockResolvedValue(true),
|
||||
onCopyShareLink: vi.fn(),
|
||||
};
|
||||
|
||||
@@ -105,9 +113,13 @@ describe("ShareDialog", () => {
|
||||
const selects = screen.getAllByRole("combobox");
|
||||
fireEvent.change(selects[0], { target: { value: "Bob" } });
|
||||
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.onShareSelectedDaysChange).toHaveBeenCalledWith(90);
|
||||
expect(defaultProps.onShareSelectedExpiryDaysChange).toHaveBeenCalledWith(30);
|
||||
expect(defaultProps.onShareAllowJournalNotesChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("disables generate button when no person is selected", () => {
|
||||
@@ -116,4 +128,58 @@ describe("ShareDialog", () => {
|
||||
const generateButton = screen.getByRole("button", { name: /share\.generateLink/i });
|
||||
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",
|
||||
takenBy: "Max",
|
||||
scheduleDays: 30,
|
||||
allowJournalNotes: false,
|
||||
automaticDoseId: `1-0-${dateOnlyMs}`,
|
||||
medications: [
|
||||
{
|
||||
@@ -171,17 +172,24 @@ function createSharedDataWithTodayDose(referenceNow: Date) {
|
||||
function createSharedDoseFetchMock(options: {
|
||||
token?: string;
|
||||
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 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 fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const method = init?.method ?? "GET";
|
||||
const body =
|
||||
typeof init?.body === "string" && init.body.length > 0
|
||||
? (JSON.parse(init.body) as { doseId: string })
|
||||
? (JSON.parse(init.body) as { doseId?: string; note?: string | null })
|
||||
: undefined;
|
||||
requests.push({ url, method, body });
|
||||
|
||||
@@ -190,7 +198,11 @@ function createSharedDoseFetchMock(options: {
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -203,6 +215,61 @@ function createSharedDoseFetchMock(options: {
|
||||
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") {
|
||||
const doseId = decodeURIComponent(url.split("/").at(-1) ?? "");
|
||||
doseState.delete(doseId);
|
||||
@@ -244,10 +311,109 @@ describe("SharedSchedule", () => {
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/share\.scheduleFor/i)).toBeInTheDocument();
|
||||
expect(screen.getByText("share.publicAccessHelp")).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 () => {
|
||||
(globalThis.fetch as ReturnType<typeof vi.fn>).mockImplementation((url: string) => {
|
||||
if (url === "/api/share/token-123/doses") {
|
||||
|
||||
@@ -275,7 +275,7 @@ describe("UserFilterModal", () => {
|
||||
const meds: Medication[] = [
|
||||
{ ...mockMedication, id: 1, name: "Med1", takenBy: ["John"] },
|
||||
{ ...mockMedication, id: 2, name: "Med2", takenBy: ["Jane"] },
|
||||
{ ...mockMedication, id: 3, name: "Med3", takenBy: ["John", "Jane"] },
|
||||
{ ...mockMedication, id: 3, name: "Med3", takenBy: ["john", "Jane"] },
|
||||
];
|
||||
|
||||
render(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user