Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ae5aba29ad | |||
| de31ac7eb7 | |||
| e2ed25059a |
@@ -1,17 +1,11 @@
|
||||
# MedAssist-ng - Copilot Entry Point
|
||||
|
||||
## VERY IMPORTANT - Prioritized Constraints
|
||||
## VERY IMPORTANT
|
||||
|
||||
**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 all governance, workflow, and skill rules.
|
||||
|
||||
Use `AGENTS.md` as the single source of truth for all governance, workflow, and skill rules.
|
||||
|
||||
## Required Startup Steps
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
name: Add issue to project
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/add-to-project@v2.0.0
|
||||
- uses: actions/add-to-project@v1.0.2
|
||||
with:
|
||||
project-url: ${{ vars.PROJECT_URL }}
|
||||
github-token: ${{ secrets.ADD_TO_PROJECT_PAT }}
|
||||
|
||||
+1
-15
@@ -107,18 +107,4 @@ docs/SPEC_KIT.md
|
||||
.github/skills/nodejs-backend-patterns/
|
||||
.github/skills/nodejs-best-practices/
|
||||
.github/skills/seo/
|
||||
.playwright-mcp
|
||||
|
||||
# Local GSD/copilot generated workspace artifacts (not for upstream)
|
||||
.github/agents/copilot-instructions.md
|
||||
.github/agents/gsd-*.agent.md
|
||||
.github/agents/medassist-feature-orchestrator.agent.md
|
||||
.github/agents/speckit.*.agent.md
|
||||
.github/get-shit-done/
|
||||
.github/gsd-file-manifest.json
|
||||
.github/prompts/speckit.*.prompt.md
|
||||
.github/skills/gsd-*/
|
||||
.planning/
|
||||
doku/memory_notes.md
|
||||
doku/report.md
|
||||
ops/medtest/
|
||||
.playwright-mcp
|
||||
@@ -18,8 +18,8 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/Backend_Tests-692%2F692-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||
<img src="https://img.shields.io/badge/Frontend_Tests-911%2F911-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||
<img src="https://img.shields.io/badge/Backend_Tests-644%2F644-brightgreen?logo=vitest" alt="Backend Tests 454/454" />
|
||||
<img src="https://img.shields.io/badge/Frontend_Tests-891%2F891-brightgreen?logo=vitest" alt="Frontend Tests 611/611" />
|
||||
</p>
|
||||
|
||||
### 🤖 AI-Generated Code
|
||||
@@ -378,14 +378,6 @@ docker compose -p medassist-dev -f docker-compose.dev.yml up
|
||||
- API docs UI: `http://localhost:3000/docs` (when docs are enabled)
|
||||
- OpenAPI JSON: `http://localhost:3000/docs/json` (when docs are enabled)
|
||||
|
||||
If you run the frontend dev server behind a reverse proxy or on a remote host, you can optionally set these frontend-only environment variables before starting Vite:
|
||||
|
||||
- `VITE_ALLOWED_HOSTS`: comma-separated hostnames allowed to connect to the dev server; defaults to `localhost,127.0.0.1`
|
||||
- `VITE_HMR_HOST`: public hostname used for HMR websocket connections
|
||||
- `VITE_HMR_PROTOCOL`: optional websocket protocol override (`ws` or `wss`)
|
||||
- `VITE_HMR_CLIENT_PORT`: optional public websocket port exposed to the browser
|
||||
- `VITE_HMR_PORT`: optional server-side websocket port for the Vite process
|
||||
|
||||
Useful local commands:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
CREATE TABLE `notification_action_groups` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`user_id` integer NOT NULL,
|
||||
`group_key` text(255) NOT NULL,
|
||||
`sequence_id` text(255) NOT NULL,
|
||||
`dose_ids_json` text NOT NULL,
|
||||
`title` text(255) NOT NULL,
|
||||
`message` text NOT NULL,
|
||||
`language` text(10) DEFAULT 'en' NOT NULL,
|
||||
`scheduled_for` integer,
|
||||
`expires_at` integer NOT NULL,
|
||||
`resolved_action` text(20),
|
||||
`resolved_at` integer,
|
||||
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `notification_action_groups_group_key_unique` ON `notification_action_groups` (`group_key`);--> statement-breakpoint
|
||||
CREATE TABLE `notification_action_tokens` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`group_id` integer NOT NULL,
|
||||
`token_hash` text(128) NOT NULL,
|
||||
`kind` text(20) NOT NULL,
|
||||
`used_at` integer,
|
||||
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
FOREIGN KEY (`group_id`) REFERENCES `notification_action_groups`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `notification_action_tokens_token_hash_unique` ON `notification_action_tokens` (`token_hash`);
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE `notification_action_groups` ADD `ntfy_original_message_id` text(255) DEFAULT '' NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
Generated
+40
-61
@@ -10,7 +10,6 @@
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/formbody": "^8.0.2",
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
@@ -31,8 +30,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.15",
|
||||
"@types/node": "^25.6.2",
|
||||
"@biomejs/biome": "^2.4.14",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
@@ -105,9 +104,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/biome": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.15.tgz",
|
||||
"integrity": "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.14.tgz",
|
||||
"integrity": "sha512-TmAvxOEgrpLypzVGJ8FulIZnlyA9TxrO1hyqYrCz9r+bwma9xXxuLA5IuYnj55XQneFx460KjRbx6SWGLkg3bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"bin": {
|
||||
@@ -121,20 +120,20 @@
|
||||
"url": "https://opencollective.com/biome"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@biomejs/cli-darwin-arm64": "2.4.15",
|
||||
"@biomejs/cli-darwin-x64": "2.4.15",
|
||||
"@biomejs/cli-linux-arm64": "2.4.15",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.4.15",
|
||||
"@biomejs/cli-linux-x64": "2.4.15",
|
||||
"@biomejs/cli-linux-x64-musl": "2.4.15",
|
||||
"@biomejs/cli-win32-arm64": "2.4.15",
|
||||
"@biomejs/cli-win32-x64": "2.4.15"
|
||||
"@biomejs/cli-darwin-arm64": "2.4.14",
|
||||
"@biomejs/cli-darwin-x64": "2.4.14",
|
||||
"@biomejs/cli-linux-arm64": "2.4.14",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.4.14",
|
||||
"@biomejs/cli-linux-x64": "2.4.14",
|
||||
"@biomejs/cli-linux-x64-musl": "2.4.14",
|
||||
"@biomejs/cli-win32-arm64": "2.4.14",
|
||||
"@biomejs/cli-win32-x64": "2.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.15.tgz",
|
||||
"integrity": "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.14.tgz",
|
||||
"integrity": "sha512-XvgoE9XOawUOQPdmvs4J7wPhi/DLwSCGks3AlPJDmh34O0awRTqCED1HRcRDdpf1Zrp4us4MGOOdIxNpbqNF5Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -149,9 +148,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-x64": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.15.tgz",
|
||||
"integrity": "sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.14.tgz",
|
||||
"integrity": "sha512-jE7hKBCFhOx3uUh+ZkWBfOHxAcILPfhFplNkuID/eZeSTLHzfZzoZxW8fbqY9xXRnPi7jGNAf1iPVR+0yWsM/Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -166,9 +165,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.15.tgz",
|
||||
"integrity": "sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.14.tgz",
|
||||
"integrity": "sha512-2TELhZnW5RSLL063l9rc5xLpA0ZIw0Ccwy/0q384rvNAgFw3yI76bd59547yxowdQr5MNPET/xDLrLuvgSeeWQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -183,9 +182,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.15.tgz",
|
||||
"integrity": "sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.14.tgz",
|
||||
"integrity": "sha512-/z+6gqAqqUQTHazwStxSXKHg9b8UvqBmDFRp+c4wYbq2KXhELQDon9EoC9RpmQ8JWkqQx/lIUy/cs+MhzDZp6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -200,9 +199,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.15.tgz",
|
||||
"integrity": "sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.14.tgz",
|
||||
"integrity": "sha512-zHrlQZDBDUz4OLAraYpWKcnLS6HOewBFWYOzY91d1ZjdqZwibOyb6BEu6WuWLugyo0P3riCmsbV9UqV1cSXwQg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -217,9 +216,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.15.tgz",
|
||||
"integrity": "sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.14.tgz",
|
||||
"integrity": "sha512-R6BWgJdQOwW9ulJatuTVrQkjnODjqHZkKNOqb1sz++3Noe5LYd0i3PchnOBUCYAPHoPWHhjJqbdZlHEu0hpjdA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -234,9 +233,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-arm64": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.15.tgz",
|
||||
"integrity": "sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.14.tgz",
|
||||
"integrity": "sha512-M3EH5hqOI/F/FUA2u4xcLoUgmxd218mvuj/6JL7Hv2toQvr2/AdOvKSpGkoRuWFCtQPVa+ZqkEV3Q5xBA9+XSA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -251,9 +250,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-x64": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.15.tgz",
|
||||
"integrity": "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.14.tgz",
|
||||
"integrity": "sha512-WL0EG5qE+EAKomGXbf2g6VnSKJhTL3tXC0QRzWRwA5VpjxNYa6H4P7ZWfymbGE4IhZZQi1KXQ2R0YjwInmz2fA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -896,26 +895,6 @@
|
||||
"fast-json-stringify": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/formbody": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/formbody/-/formbody-8.0.2.tgz",
|
||||
"integrity": "sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-querystring": "^1.1.2",
|
||||
"fastify-plugin": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/forwarded": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz",
|
||||
@@ -2218,9 +2197,9 @@
|
||||
"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.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.19.0"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.24.0",
|
||||
"version": "1.23.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -19,7 +19,6 @@
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/formbody": "^8.0.2",
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
@@ -40,8 +39,8 @@
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.15",
|
||||
"@types/node": "^25.6.2",
|
||||
"@biomejs/biome": "^2.4.14",
|
||||
"@types/node": "^25.6.0",
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
|
||||
@@ -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 notification_action_groups ADD COLUMN ntfy_original_message_id text NOT NULL DEFAULT ''`,
|
||||
];
|
||||
|
||||
for (const sql of alterMigrations) {
|
||||
@@ -147,20 +148,6 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
||||
}
|
||||
}
|
||||
|
||||
const postCreateAlterMigrations = [
|
||||
`ALTER TABLE notification_action_groups ADD COLUMN ntfy_original_message_id text NOT NULL DEFAULT ''`,
|
||||
];
|
||||
|
||||
for (const sql of postCreateAlterMigrations) {
|
||||
try {
|
||||
await client.execute(sql);
|
||||
} catch (e: unknown) {
|
||||
if (!(e as Error).message?.includes("duplicate column")) {
|
||||
errors.push((e as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const createIndexMigrations = [
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`,
|
||||
`CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`,
|
||||
|
||||
@@ -109,8 +109,6 @@ type TranslationKeys = {
|
||||
stockTitle: string;
|
||||
stockTitleMultiple: string;
|
||||
intakeTitle: string;
|
||||
intakeTakenConfirmation: string;
|
||||
intakeSkippedConfirmation: string;
|
||||
pillsLeft: string;
|
||||
daysLeft: string;
|
||||
pillsAt: string;
|
||||
@@ -236,8 +234,6 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
stockTitle: "MedAssist-ng: 1 Medication Running Critically Low",
|
||||
stockTitleMultiple: "MedAssist-ng: {count} Medications Running Critically Low",
|
||||
intakeTitle: "💊 Reminder: Medication intake in {minutes} min",
|
||||
intakeTakenConfirmation: "✅ This dose was marked as taken.",
|
||||
intakeSkippedConfirmation: "⏭️ This intake was marked as skipped.",
|
||||
pillsLeft: "{count} pills",
|
||||
daysLeft: "{count} days left",
|
||||
pillsAt: "{count} pills at {time}",
|
||||
@@ -359,8 +355,6 @@ const translations: Record<Language, TranslationKeys> = {
|
||||
stockTitle: "MedAssist-ng: 1 Medikament kritisch niedrig",
|
||||
stockTitleMultiple: "MedAssist-ng: {count} Medikamente kritisch niedrig",
|
||||
intakeTitle: "💊 Erinnerung: Medikamenteneinnahme in {minutes} Min.",
|
||||
intakeTakenConfirmation: "✅ Diese Einnahme wurde als genommen markiert.",
|
||||
intakeSkippedConfirmation: "⏭️ Diese Einnahme wurde als übersprungen markiert.",
|
||||
pillsLeft: "{count} Tabletten",
|
||||
daysLeft: "{count} Tage übrig",
|
||||
pillsAt: "{count} Tabletten um {time}",
|
||||
|
||||
+4
-59
@@ -23,7 +23,6 @@ import { exportRoutes } from "./routes/export.js";
|
||||
import { healthRoutes } from "./routes/health.js";
|
||||
import { medicationEnrichmentRoutes } from "./routes/medication-enrichment.js";
|
||||
import { medicationRoutes } from "./routes/medications.js";
|
||||
import { notificationActionRoutes } from "./routes/notification-actions.js";
|
||||
import { oidcRoutes } from "./routes/oidc.js";
|
||||
import { plannerRoutes } from "./routes/planner.js";
|
||||
import { refillRoutes } from "./routes/refills.js";
|
||||
@@ -80,19 +79,6 @@ function buildLoggerOptions(level: string) {
|
||||
return base;
|
||||
}
|
||||
|
||||
function buildHelmetOptions(_isProduction: boolean) {
|
||||
return {};
|
||||
}
|
||||
|
||||
function isPublicNotificationActionPath(url: string | undefined): boolean {
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedUrl = url.split("?")[0]?.toLowerCase() ?? "";
|
||||
return /(^|\/)(api\/)?notification-actions(\/|$)/.test(normalizedUrl);
|
||||
}
|
||||
|
||||
async function registerApiDocs(app: FastifyInstance, enabled: boolean) {
|
||||
if (!enabled) return;
|
||||
|
||||
@@ -180,7 +166,6 @@ export async function createApp(options?: {
|
||||
app.addHook("onRequest", (request, reply, done) => {
|
||||
request.correlationId = request.id;
|
||||
reply.header("x-correlation-id", request.id);
|
||||
|
||||
done();
|
||||
});
|
||||
|
||||
@@ -197,26 +182,8 @@ export async function createApp(options?: {
|
||||
|
||||
// Register plugins
|
||||
await app.register(sensible);
|
||||
await app.register(helmet, buildHelmetOptions(opts.isProduction));
|
||||
await app.register(cors, {
|
||||
hook: "preHandler",
|
||||
delegator: (request, callback) => {
|
||||
if (isPublicNotificationActionPath(request.raw.url)) {
|
||||
callback(null, {
|
||||
origin: true,
|
||||
credentials: false,
|
||||
methods: ["GET", "HEAD", "POST", "OPTIONS"],
|
||||
preflightContinue: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
callback(null, {
|
||||
origin: opts.corsOrigins,
|
||||
credentials: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
await app.register(helmet);
|
||||
await app.register(cors, { origin: opts.corsOrigins, credentials: true });
|
||||
await app.register(rateLimit, { max: 300, timeWindow: "1 minute" });
|
||||
await app.register(cookie, { secret: opts.cookieSecret });
|
||||
|
||||
@@ -245,7 +212,6 @@ export async function createApp(options?: {
|
||||
await app.register(medicationEnrichmentRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(plannerRoutes);
|
||||
await app.register(notificationActionRoutes);
|
||||
await app.register(shareRoutes);
|
||||
await app.register(doseRoutes);
|
||||
await app.register(exportRoutes);
|
||||
@@ -300,26 +266,8 @@ app.decorate("config", {
|
||||
});
|
||||
|
||||
await app.register(sensible);
|
||||
await app.register(helmet, buildHelmetOptions(env.NODE_ENV === "production"));
|
||||
await app.register(cors, {
|
||||
hook: "preHandler",
|
||||
delegator: (request, callback) => {
|
||||
if (isPublicNotificationActionPath(request.raw.url)) {
|
||||
callback(null, {
|
||||
origin: true,
|
||||
credentials: false,
|
||||
methods: ["GET", "HEAD", "POST", "OPTIONS"],
|
||||
preflightContinue: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
callback(null, {
|
||||
origin: origins,
|
||||
credentials: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
await app.register(helmet);
|
||||
await app.register(cors, { origin: origins, credentials: true });
|
||||
await app.register(rateLimit, {
|
||||
max: Number(process.env.RATE_LIMIT_MAX) || 100,
|
||||
timeWindow: "1 minute",
|
||||
@@ -346,7 +294,6 @@ await app.register(medicationRoutes);
|
||||
await app.register(medicationEnrichmentRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(plannerRoutes);
|
||||
await app.register(notificationActionRoutes);
|
||||
await app.register(shareRoutes);
|
||||
await app.register(doseRoutes);
|
||||
await app.register(exportRoutes);
|
||||
@@ -362,7 +309,6 @@ const start = async () => {
|
||||
startReminderScheduler({
|
||||
info: (msg) => app.log.info(msg),
|
||||
debug: (msg) => app.log.debug(msg),
|
||||
warn: (msg) => app.log.warn(msg),
|
||||
error: (msg) => app.log.error(msg),
|
||||
});
|
||||
|
||||
@@ -377,7 +323,6 @@ const start = async () => {
|
||||
startIntakeReminderScheduler({
|
||||
info: (msg) => app.log.info(msg),
|
||||
debug: (msg) => app.log.debug(msg),
|
||||
warn: (msg) => app.log.warn(msg),
|
||||
error: (msg) => app.log.error(msg),
|
||||
});
|
||||
} catch (err) {
|
||||
|
||||
+28
-15
@@ -6,7 +6,6 @@ import { doseTracking, medications, shareTokens, userSettings } from "../db/sche
|
||||
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 type { AuthUser } from "../types/fastify.js";
|
||||
import {
|
||||
applyOpenApiRouteStandards,
|
||||
@@ -317,22 +316,34 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
|
||||
const { doseId } = parsed.data;
|
||||
|
||||
const result = await markDoseTakenForUser({
|
||||
userId,
|
||||
doseId,
|
||||
source: "manual",
|
||||
markedBy: null,
|
||||
});
|
||||
// Check if already marked
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
|
||||
|
||||
if (!result.success) {
|
||||
const statusCode = result.code === "INVALID_DOSE" ? 400 : 409;
|
||||
return reply.status(statusCode).send({ error: result.message, code: result.code });
|
||||
}
|
||||
|
||||
if (result.status === "already_taken") {
|
||||
if (existing) {
|
||||
return { success: true, message: "Already marked" };
|
||||
}
|
||||
|
||||
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||||
const outOfStock = await isDoseOutOfStock({
|
||||
userId,
|
||||
doseId,
|
||||
stockCalculationMode: (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic",
|
||||
});
|
||||
if (outOfStock) {
|
||||
return reply.status(409).send({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
|
||||
}
|
||||
|
||||
// Insert new record
|
||||
await db.insert(doseTracking).values({
|
||||
userId,
|
||||
doseId,
|
||||
markedBy: null, // Marked by the user themselves
|
||||
takenSource: "manual",
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
);
|
||||
@@ -427,16 +438,17 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
|
||||
const { doseIds } = parsed.data;
|
||||
|
||||
// Preserve the existing route semantics for dismiss: any non-dismissed record
|
||||
// becomes dismissed, regardless of whether it already has a taken timestamp.
|
||||
// Insert dismissed records for each dose that doesn't exist yet
|
||||
let dismissedCount = 0;
|
||||
for (const doseId of doseIds) {
|
||||
// Check if already exists (taken or dismissed)
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, userId), eq(doseTracking.doseId, doseId)));
|
||||
|
||||
if (existing) {
|
||||
// Already exists - update to dismissed if not already
|
||||
if (!existing.dismissed) {
|
||||
await db
|
||||
.update(doseTracking)
|
||||
@@ -445,6 +457,7 @@ export async function doseRoutes(app: FastifyInstance) {
|
||||
dismissedCount++;
|
||||
}
|
||||
} else {
|
||||
// Create new dismissed record
|
||||
await db.insert(doseTracking).values({
|
||||
userId,
|
||||
doseId,
|
||||
|
||||
@@ -1,642 +0,0 @@
|
||||
import formbody from "@fastify/formbody";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { notificationActionGroups, notificationActionTokens, userSettings } from "../db/schema.js";
|
||||
import { getTranslations, type Language } from "../i18n/translations.js";
|
||||
import { markDoseTakenForUser, skipDosesForUser } from "../services/dose-tracking-service.js";
|
||||
import {
|
||||
getNotificationActionTokenRecord,
|
||||
isNotificationActionExpired,
|
||||
} from "../services/notification-actions-service.js";
|
||||
import { getNotificationActionLabels } from "../services/notifications/action-renderer.js";
|
||||
import { sendPushNotification } from "../services/notifications/delivery.js";
|
||||
import { sanitizeNotificationUrl } from "../services/settings-service.js";
|
||||
import { applyOpenApiRouteStandards, genericErrorSchema } from "../utils/openapi-route-standards.js";
|
||||
|
||||
const querySchema = z.object({
|
||||
action: z.enum(["taken", "skip", "dismiss"]).optional(),
|
||||
});
|
||||
|
||||
type NotificationMutationAction = "taken" | "skip";
|
||||
|
||||
function normalizeNotificationAction(action: string | null | undefined): NotificationMutationAction | null {
|
||||
if (action === "taken") {
|
||||
return "taken";
|
||||
}
|
||||
|
||||
if (action === "skip" || action === "dismiss") {
|
||||
return "skip";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const publicNotificationActionMethods = "GET,HEAD,POST,OPTIONS";
|
||||
const reminderFooterSeparator = "\n\n---\n";
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function toHtmlText(value: string): string {
|
||||
return escapeHtml(value).replaceAll("\n", "<br />");
|
||||
}
|
||||
|
||||
function getLanguage(language: string | null): Language {
|
||||
return language === "de" ? "de" : "en";
|
||||
}
|
||||
|
||||
function wantsHtml(request: FastifyRequest): boolean {
|
||||
return request.headers.accept?.includes("text/html") ?? false;
|
||||
}
|
||||
|
||||
function applyPublicNotificationCorsHeaders(
|
||||
request: FastifyRequest,
|
||||
reply: { header: (name: string, value: string) => unknown }
|
||||
) {
|
||||
const requestOrigin = typeof request.headers.origin === "string" ? request.headers.origin : "*";
|
||||
reply.header("access-control-allow-origin", requestOrigin);
|
||||
reply.header("access-control-allow-methods", publicNotificationActionMethods);
|
||||
reply.header("access-control-allow-headers", "content-type");
|
||||
if (requestOrigin !== "*") {
|
||||
reply.header("vary", "Origin");
|
||||
}
|
||||
}
|
||||
|
||||
function getAlreadyProcessedText(language: Language, resolvedAction: NotificationMutationAction) {
|
||||
if (resolvedAction === "taken") {
|
||||
return {
|
||||
bodyTitle: language === "de" ? "Bereits verarbeitet" : "Already processed",
|
||||
bodyText:
|
||||
language === "de"
|
||||
? "Diese Einnahme ist bereits als genommen markiert. Wenn Sie das ändern möchten, öffnen Sie MedAssist und machen Sie die Einnahme dort rückgängig."
|
||||
: "This dose is already marked as taken. If you need to change it, open MedAssist and undo it there.",
|
||||
jsonMessage:
|
||||
language === "de"
|
||||
? "Diese Einnahme ist bereits als genommen markiert. Änderungen sind nur in MedAssist möglich."
|
||||
: "This dose is already marked as taken. Changes can only be made in MedAssist.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
bodyTitle: language === "de" ? "Bereits verarbeitet" : "Already processed",
|
||||
bodyText:
|
||||
language === "de"
|
||||
? "Diese Einnahme ist bereits als übersprungen markiert. Wenn Sie sie stattdessen als genommen markieren möchten, öffnen Sie MedAssist und machen Sie das dort."
|
||||
: "This intake is already marked as skipped. If you want to mark it as taken instead, open MedAssist and do that there.",
|
||||
jsonMessage:
|
||||
language === "de"
|
||||
? "Diese Einnahme ist bereits als übersprungen markiert. Änderungen sind nur in MedAssist möglich."
|
||||
: "This intake is already marked as skipped. Changes can only be made in MedAssist.",
|
||||
};
|
||||
}
|
||||
|
||||
function getActionRecordedText(language: Language, action: NotificationMutationAction) {
|
||||
if (action === "taken") {
|
||||
return {
|
||||
bodyTitle: language === "de" ? "Aktion gespeichert" : "Action recorded",
|
||||
bodyText: language === "de" ? "Die Einnahme wurde als genommen markiert." : "The dose was marked as taken.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
bodyTitle: language === "de" ? "Aktion gespeichert" : "Action recorded",
|
||||
bodyText: language === "de" ? "Die Einnahme wurde als übersprungen markiert." : "The intake was marked as skipped.",
|
||||
};
|
||||
}
|
||||
|
||||
function buildReplacementReminderMessage(
|
||||
language: Language,
|
||||
action: NotificationMutationAction,
|
||||
originalMessage: string
|
||||
): string {
|
||||
const tr = getTranslations(language);
|
||||
const confirmationLine = action === "taken" ? tr.push.intakeTakenConfirmation : tr.push.intakeSkippedConfirmation;
|
||||
const separatorIndex = originalMessage.indexOf(reminderFooterSeparator);
|
||||
|
||||
if (separatorIndex >= 0) {
|
||||
const beforeFooter = originalMessage.slice(0, separatorIndex).trimEnd();
|
||||
const footer = originalMessage.slice(separatorIndex);
|
||||
return `${beforeFooter}\n\n${confirmationLine}${footer}`;
|
||||
}
|
||||
|
||||
return `${originalMessage.trimEnd()}\n\n${confirmationLine}`;
|
||||
}
|
||||
|
||||
async function clearNtfyNotificationSequence(userId: number, sequenceId: string): Promise<void> {
|
||||
const [settings] = await db
|
||||
.select({ shoutrrrEnabled: userSettings.shoutrrrEnabled, shoutrrrUrl: userSettings.shoutrrrUrl })
|
||||
.from(userSettings)
|
||||
.where(eq(userSettings.userId, userId));
|
||||
|
||||
if (!settings?.shoutrrrEnabled || !settings.shoutrrrUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitized = sanitizeNotificationUrl(settings.shoutrrrUrl);
|
||||
if ("error" in sanitized || !sanitized.isNtfy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clearUrl = new URL(sanitized.url);
|
||||
clearUrl.pathname = `${clearUrl.pathname.replace(/\/+$/, "")}/${encodeURIComponent(sequenceId)}/clear`;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (sanitized.auth) {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${sanitized.auth.user}:${sanitized.auth.pass}`).toString("base64")}`;
|
||||
}
|
||||
|
||||
const response = await fetch(clearUrl.toString(), {
|
||||
method: "PUT",
|
||||
headers,
|
||||
redirect: "error",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNtfyNotificationSequence(userId: number, sequenceId: string): Promise<void> {
|
||||
const normalizedSequenceId = sequenceId.trim();
|
||||
if (normalizedSequenceId.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [settings] = await db
|
||||
.select({ shoutrrrEnabled: userSettings.shoutrrrEnabled, shoutrrrUrl: userSettings.shoutrrrUrl })
|
||||
.from(userSettings)
|
||||
.where(eq(userSettings.userId, userId));
|
||||
|
||||
if (!settings?.shoutrrrEnabled || !settings.shoutrrrUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitized = sanitizeNotificationUrl(settings.shoutrrrUrl);
|
||||
if ("error" in sanitized || !sanitized.isNtfy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deleteUrl = new URL(sanitized.url);
|
||||
deleteUrl.pathname = `${deleteUrl.pathname.replace(/\/+$/, "")}/${encodeURIComponent(normalizedSequenceId)}`;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (sanitized.auth) {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${sanitized.auth.user}:${sanitized.auth.pass}`).toString("base64")}`;
|
||||
}
|
||||
|
||||
const response = await fetch(deleteUrl.toString(), {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
redirect: "error",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceNtfyNotificationSequence(options: {
|
||||
userId: number;
|
||||
sequenceId: string;
|
||||
language: Language;
|
||||
title: string;
|
||||
originalMessage: string;
|
||||
action: NotificationMutationAction;
|
||||
viewUrl: string | null;
|
||||
}): Promise<boolean> {
|
||||
const normalizedSequenceId = options.sequenceId.trim();
|
||||
if (normalizedSequenceId.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [settings] = await db
|
||||
.select({ shoutrrrEnabled: userSettings.shoutrrrEnabled, shoutrrrUrl: userSettings.shoutrrrUrl })
|
||||
.from(userSettings)
|
||||
.where(eq(userSettings.userId, options.userId));
|
||||
|
||||
if (!settings?.shoutrrrEnabled || !settings.shoutrrrUrl) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sanitized = sanitizeNotificationUrl(settings.shoutrrrUrl);
|
||||
if ("error" in sanitized || !sanitized.isNtfy) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const labels = getNotificationActionLabels(options.language);
|
||||
const replacementMessage = buildReplacementReminderMessage(options.language, options.action, options.originalMessage);
|
||||
const result = await sendPushNotification(settings.shoutrrrUrl, options.title, replacementMessage, {
|
||||
actions: options.viewUrl ? [{ kind: "view", label: labels.view, url: options.viewUrl, method: "GET" }] : undefined,
|
||||
viewUrl: options.viewUrl ?? undefined,
|
||||
clickUrl: options.viewUrl ?? undefined,
|
||||
sequenceId: normalizedSequenceId,
|
||||
tags: ["pill"],
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error ?? "Failed to replace ntfy notification");
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function renderPage(options: {
|
||||
language: Language;
|
||||
title: string;
|
||||
message: string;
|
||||
bodyTitle: string;
|
||||
bodyText: string;
|
||||
viewUrl: string | null;
|
||||
actionButtons: Array<{ label: string; formAction?: string }>;
|
||||
}): string {
|
||||
const labels = getNotificationActionLabels(options.language);
|
||||
const forms =
|
||||
options.actionButtons.length > 0
|
||||
? `<div class="actions">${options.actionButtons
|
||||
.map((button) => {
|
||||
const formAction = button.formAction ? ` action="${escapeHtml(button.formAction)}"` : "";
|
||||
return `<form method="POST"${formAction}><button type="submit">${escapeHtml(button.label)}</button></form>`;
|
||||
})
|
||||
.join("")}</div>`
|
||||
: "";
|
||||
const viewLink = options.viewUrl
|
||||
? `<p><a href="${escapeHtml(options.viewUrl)}">${escapeHtml(labels.view)}</a></p>`
|
||||
: "";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="${options.language}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${escapeHtml(options.bodyTitle)}</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; margin: 0; background: #f4f5f7; color: #1f2937; }
|
||||
main { max-width: 640px; margin: 48px auto; background: white; border-radius: 16px; padding: 24px; box-shadow: 0 12px 40px rgba(15, 23, 42, 0.08); }
|
||||
h1 { margin-top: 0; font-size: 1.5rem; }
|
||||
.card { padding: 16px; border-radius: 12px; background: #f9fafb; margin: 16px 0 24px; }
|
||||
.actions { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
form { margin: 0; }
|
||||
button, a { display: inline-flex; align-items: center; justify-content: center; min-width: 140px; border-radius: 10px; padding: 12px 16px; font: inherit; text-decoration: none; }
|
||||
button { border: none; background: #0f766e; color: white; cursor: pointer; }
|
||||
form:last-of-type button { background: #475569; }
|
||||
a { background: #e2e8f0; color: #0f172a; }
|
||||
p { line-height: 1.5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>${escapeHtml(options.bodyTitle)}</h1>
|
||||
<p>${escapeHtml(options.bodyText)}</p>
|
||||
<div class="card">
|
||||
<strong>${escapeHtml(options.title)}</strong>
|
||||
<p>${toHtmlText(options.message)}</p>
|
||||
</div>
|
||||
${forms}
|
||||
${viewLink}
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function parseRequestedAction(request: FastifyRequest, tokenKind: string): NotificationMutationAction | null {
|
||||
const normalizedTokenAction = normalizeNotificationAction(tokenKind);
|
||||
if (normalizedTokenAction) {
|
||||
return normalizedTokenAction;
|
||||
}
|
||||
|
||||
const parsedQuery = querySchema.safeParse(request.query);
|
||||
if (parsedQuery.success && parsedQuery.data.action) {
|
||||
return normalizeNotificationAction(parsedQuery.data.action);
|
||||
}
|
||||
|
||||
const body = request.body;
|
||||
if (body && typeof body === "object" && "action" in body) {
|
||||
const actionValue = (body as { action?: unknown }).action;
|
||||
if (typeof actionValue === "string") {
|
||||
return normalizeNotificationAction(actionValue);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildNotificationActionLogContext(
|
||||
record: Awaited<ReturnType<typeof getNotificationActionTokenRecord>> extends infer T ? Exclude<T, null> : never,
|
||||
extra: Record<string, unknown> = {}
|
||||
) {
|
||||
return {
|
||||
groupId: record.group.id,
|
||||
userId: record.group.userId,
|
||||
tokenKind: record.token.kind,
|
||||
doseCount: record.doseIds.length,
|
||||
hasViewUrl: record.viewUrl !== null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function buildNotificationRequestLogContext(request: FastifyRequest, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
method: request.method,
|
||||
hasOrigin: typeof request.headers.origin === "string",
|
||||
expectsHtml: wantsHtml(request),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
export async function notificationActionRoutes(app: FastifyInstance) {
|
||||
await app.register(formbody);
|
||||
|
||||
applyOpenApiRouteStandards(app, { tag: "notification-actions", protectedByDefault: false });
|
||||
|
||||
app.options<{ Params: { token: string } }>("/notification-actions/:token", async (request, reply) => {
|
||||
applyPublicNotificationCorsHeaders(request, reply);
|
||||
return reply.status(204).send();
|
||||
});
|
||||
|
||||
app.get<{ Params: { token: string } }>(
|
||||
"/notification-actions/:token",
|
||||
{
|
||||
config: {
|
||||
rateLimit: { max: 30, timeWindow: "1 minute" },
|
||||
},
|
||||
schema: {
|
||||
tags: ["notification-actions"],
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["token"],
|
||||
properties: { token: { type: "string", minLength: 1 } },
|
||||
},
|
||||
response: {
|
||||
404: genericErrorSchema,
|
||||
405: genericErrorSchema,
|
||||
410: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
applyPublicNotificationCorsHeaders(request, reply);
|
||||
|
||||
const record = await getNotificationActionTokenRecord(request.params.token);
|
||||
if (!record) {
|
||||
request.log.warn(
|
||||
buildNotificationRequestLogContext(request),
|
||||
"[NotificationActions] Unknown notification action token requested"
|
||||
);
|
||||
return reply.status(404).send({ error: "Notification action not found" });
|
||||
}
|
||||
|
||||
if (isNotificationActionExpired(record)) {
|
||||
request.log.warn(
|
||||
buildNotificationActionLogContext(record),
|
||||
"[NotificationActions] Rejected expired notification action GET request"
|
||||
);
|
||||
return reply.status(410).send({ error: "Notification action has expired" });
|
||||
}
|
||||
|
||||
if (record.token.kind !== "respond" && record.group.resolvedAction === null) {
|
||||
request.log.warn(
|
||||
buildNotificationActionLogContext(record),
|
||||
"[NotificationActions] Rejected direct GET for unresolved non-respond token"
|
||||
);
|
||||
return reply.status(405).send({ error: "Direct GET is only available for respond actions" });
|
||||
}
|
||||
|
||||
const language = getLanguage(record.group.language ?? null);
|
||||
const labels = getNotificationActionLabels(language);
|
||||
const resolvedAction = normalizeNotificationAction(record.group.resolvedAction);
|
||||
let bodyTitle: string;
|
||||
let bodyText: string;
|
||||
let actionButtons: Array<{ label: string; formAction?: string }> = [];
|
||||
|
||||
if (resolvedAction) {
|
||||
({ bodyTitle, bodyText } = getAlreadyProcessedText(language, resolvedAction));
|
||||
} else {
|
||||
if (record.token.kind === "taken") {
|
||||
bodyTitle = language === "de" ? "Einnahme bestätigen" : "Confirm dose";
|
||||
bodyText =
|
||||
language === "de"
|
||||
? "Bestätigen Sie, dass diese Einnahme als genommen markiert werden soll."
|
||||
: "Confirm that this dose should be marked as taken.";
|
||||
actionButtons = [{ label: labels.taken }];
|
||||
} else if (record.token.kind === "skip" || record.token.kind === "dismiss") {
|
||||
bodyTitle = language === "de" ? "Einnahme überspringen" : "Skip intake";
|
||||
bodyText =
|
||||
language === "de"
|
||||
? "Bestätigen Sie, dass diese Einnahme als übersprungen markiert werden soll."
|
||||
: "Confirm that this intake should be marked as skipped.";
|
||||
actionButtons = [{ label: labels.skip }];
|
||||
} else {
|
||||
bodyTitle = language === "de" ? "Erinnerung beantworten" : "Respond to reminder";
|
||||
bodyText =
|
||||
language === "de"
|
||||
? "Wählen Sie eine Aktion für diese Medikamentenerinnerung."
|
||||
: "Choose an action for this medication reminder.";
|
||||
actionButtons = [
|
||||
{ label: labels.taken, formAction: "?action=taken" },
|
||||
{ label: labels.skip, formAction: "?action=skip" },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return reply.type("text/html; charset=utf-8").send(
|
||||
renderPage({
|
||||
language,
|
||||
title: record.group.title,
|
||||
message: record.group.message,
|
||||
bodyTitle,
|
||||
bodyText,
|
||||
viewUrl: record.viewUrl,
|
||||
actionButtons: resolvedAction ? [] : actionButtons,
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { token: string } }>(
|
||||
"/notification-actions/:token",
|
||||
{
|
||||
config: {
|
||||
rateLimit: { max: 30, timeWindow: "1 minute" },
|
||||
},
|
||||
schema: {
|
||||
tags: ["notification-actions"],
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["token"],
|
||||
properties: { token: { type: "string", minLength: 1 } },
|
||||
},
|
||||
response: {
|
||||
400: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
409: genericErrorSchema,
|
||||
410: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
applyPublicNotificationCorsHeaders(request, reply);
|
||||
|
||||
const record = await getNotificationActionTokenRecord(request.params.token);
|
||||
if (!record) {
|
||||
request.log.warn(
|
||||
buildNotificationRequestLogContext(request),
|
||||
"[NotificationActions] Unknown notification action token requested"
|
||||
);
|
||||
return reply.status(404).send({ error: "Notification action not found" });
|
||||
}
|
||||
|
||||
if (isNotificationActionExpired(record)) {
|
||||
request.log.warn(
|
||||
buildNotificationActionLogContext(record),
|
||||
"[NotificationActions] Rejected expired notification action POST request"
|
||||
);
|
||||
return reply.status(410).send({ error: "Notification action has expired" });
|
||||
}
|
||||
|
||||
const action = parseRequestedAction(request, record.token.kind);
|
||||
if (!action) {
|
||||
request.log.warn(
|
||||
buildNotificationActionLogContext(record),
|
||||
"[NotificationActions] Missing or invalid action for notification action POST request"
|
||||
);
|
||||
return reply.status(400).send({ error: "Notification action is required" });
|
||||
}
|
||||
|
||||
const language = getLanguage(record.group.language ?? null);
|
||||
const resolvedAction = normalizeNotificationAction(record.group.resolvedAction);
|
||||
if (resolvedAction) {
|
||||
request.log.info(
|
||||
buildNotificationActionLogContext(record, { requestedAction: action, resolvedAction }),
|
||||
"[NotificationActions] Ignored notification action because it was already resolved"
|
||||
);
|
||||
const alreadyProcessedText = getAlreadyProcessedText(language, resolvedAction);
|
||||
|
||||
if (wantsHtml(request)) {
|
||||
return reply.type("text/html; charset=utf-8").send(
|
||||
renderPage({
|
||||
language,
|
||||
title: record.group.title,
|
||||
message: record.group.message,
|
||||
bodyTitle: alreadyProcessedText.bodyTitle,
|
||||
bodyText: alreadyProcessedText.bodyText,
|
||||
viewUrl: record.viewUrl,
|
||||
actionButtons: [],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
action: resolvedAction,
|
||||
alreadyProcessed: true,
|
||||
message: alreadyProcessedText.jsonMessage,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "taken") {
|
||||
for (const [doseIndex, doseId] of record.doseIds.entries()) {
|
||||
const result = await markDoseTakenForUser({
|
||||
userId: record.group.userId,
|
||||
doseId,
|
||||
source: "notification",
|
||||
markedBy: null,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
request.log.warn(
|
||||
buildNotificationActionLogContext(record, {
|
||||
requestedAction: action,
|
||||
failedDoseIndex: doseIndex,
|
||||
code: result.code,
|
||||
}),
|
||||
"[NotificationActions] Failed to record taken notification action"
|
||||
);
|
||||
const statusCode = result.code === "INVALID_DOSE" ? 400 : 409;
|
||||
return reply.status(statusCode).send({ error: result.message, code: result.code });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await skipDosesForUser({ userId: record.group.userId, doseIds: record.doseIds });
|
||||
}
|
||||
|
||||
await db
|
||||
.update(notificationActionGroups)
|
||||
.set({ resolvedAction: action, resolvedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(notificationActionGroups.id, record.group.id));
|
||||
await db
|
||||
.update(notificationActionTokens)
|
||||
.set({ usedAt: new Date() })
|
||||
.where(eq(notificationActionTokens.id, record.token.id));
|
||||
|
||||
request.log.info(
|
||||
buildNotificationActionLogContext(record, { requestedAction: action }),
|
||||
"[NotificationActions] Recorded notification action"
|
||||
);
|
||||
|
||||
const recordedText = getActionRecordedText(language, action);
|
||||
let replacedNtfyNotification = false;
|
||||
|
||||
try {
|
||||
replacedNtfyNotification = await replaceNtfyNotificationSequence({
|
||||
userId: record.group.userId,
|
||||
sequenceId: record.group.sequenceId,
|
||||
language,
|
||||
title: record.group.title,
|
||||
originalMessage: record.group.message,
|
||||
action,
|
||||
viewUrl: record.viewUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
request.log.warn(
|
||||
buildNotificationActionLogContext(record, { requestedAction: action, error }),
|
||||
"[NotificationActions] Failed to replace ntfy notification after resolved action"
|
||||
);
|
||||
}
|
||||
|
||||
if (!replacedNtfyNotification) {
|
||||
try {
|
||||
await deleteNtfyNotificationSequence(record.group.userId, record.group.sequenceId);
|
||||
} catch (error) {
|
||||
request.log.warn(
|
||||
buildNotificationActionLogContext(record, { requestedAction: action, error }),
|
||||
"[NotificationActions] Failed to delete ntfy notification after resolved action"
|
||||
);
|
||||
try {
|
||||
await clearNtfyNotificationSequence(record.group.userId, record.group.sequenceId);
|
||||
} catch (clearError) {
|
||||
request.log.warn(
|
||||
buildNotificationActionLogContext(record, { requestedAction: action, error: clearError }),
|
||||
"[NotificationActions] Failed to clear ntfy notification after delete fallback"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (wantsHtml(request)) {
|
||||
return reply.type("text/html; charset=utf-8").send(
|
||||
renderPage({
|
||||
language,
|
||||
title: record.group.title,
|
||||
message: record.group.message,
|
||||
bodyTitle: recordedText.bodyTitle,
|
||||
bodyText: recordedText.bodyText,
|
||||
viewUrl: record.viewUrl,
|
||||
actionButtons: [],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return reply.send({ success: true, action });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -119,7 +119,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
return reply.redirect(authUrl.href);
|
||||
} catch (err: unknown) {
|
||||
request.log.error({ err }, "[OIDC] Login initialization failed");
|
||||
return reply.redirect(getFrontendUrl());
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_init_failed`);
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -151,25 +151,25 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
// Handle OIDC provider errors
|
||||
if (error) {
|
||||
app.log.warn({ error, errorDescription: error_description }, "[OIDC] Provider returned error");
|
||||
return reply.redirect(getFrontendUrl());
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_${error}`);
|
||||
}
|
||||
|
||||
if (!code || !state) {
|
||||
return reply.redirect(getFrontendUrl());
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_params`);
|
||||
}
|
||||
|
||||
// Verify state
|
||||
const storedState = request.unsignCookie(request.cookies.oidc_state || "");
|
||||
if (!storedState.valid || storedState.value !== state) {
|
||||
request.log.warn("[OIDC] State mismatch during callback validation");
|
||||
return reply.redirect(getFrontendUrl());
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_state_mismatch`);
|
||||
}
|
||||
|
||||
// Get code verifier
|
||||
const storedVerifier = request.unsignCookie(request.cookies.oidc_code_verifier || "");
|
||||
if (!storedVerifier.valid || !storedVerifier.value) {
|
||||
request.log.warn("[OIDC] Missing/invalid code verifier cookie");
|
||||
return reply.redirect(getFrontendUrl());
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_verifier`);
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -190,7 +190,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
const sub = tokens.claims()?.sub;
|
||||
if (!sub) {
|
||||
request.log.error("[OIDC] Missing sub claim in token response");
|
||||
return reply.redirect(getFrontendUrl());
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_sub`);
|
||||
}
|
||||
const userInfo = await client.fetchUserInfo(config, tokens.access_token, sub);
|
||||
|
||||
@@ -208,7 +208,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
{ hasUsername: Boolean(username), hasOidcSubject: Boolean(oidcSubject) },
|
||||
"[OIDC] Missing required user info"
|
||||
);
|
||||
return reply.redirect(getFrontendUrl());
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_missing_user_info`);
|
||||
}
|
||||
|
||||
// Clean cookies
|
||||
@@ -219,7 +219,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
const user = await findOrCreateOIDCUser(username, oidcSubject, reply);
|
||||
|
||||
if (!user) {
|
||||
return reply.redirect(getFrontendUrl());
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_user_creation_failed`);
|
||||
}
|
||||
|
||||
// Update last login
|
||||
@@ -248,7 +248,7 @@ export async function oidcRoutes(app: FastifyInstance) {
|
||||
return reply.redirect(`${frontendUrl}/dashboard`);
|
||||
} catch (err: unknown) {
|
||||
request.log.error({ err }, "[OIDC] Callback processing failed");
|
||||
return reply.redirect(getFrontendUrl());
|
||||
return reply.redirect(`${getFrontendUrl()}/?error=oidc_callback_failed`);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
@@ -2,9 +2,10 @@ import { and, desc, eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { medications, refillHistory } from "../db/schema.js";
|
||||
import { doseTracking, medications, refillHistory, userSettings } from "../db/schema.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import { computeMedicationCurrentStock } from "../services/current-stock.js";
|
||||
import type { AuthUser } from "../types/fastify.js";
|
||||
import {
|
||||
applyOpenApiRouteStandards,
|
||||
@@ -195,13 +196,22 @@ export async function refillRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
const refillBaselineAt = new Date();
|
||||
const baselineStockBeforeRefill = isAmountBased
|
||||
? med.looseTablets + (med.stockAdjustment ?? 0)
|
||||
: med.packCount * pillsPerPack + med.looseTablets + (med.stockAdjustment ?? 0);
|
||||
const targetCurrentStock = baselineStockBeforeRefill + totalPillsAdded;
|
||||
const [settings] = await db
|
||||
.select({ stockCalculationMode: userSettings.stockCalculationMode })
|
||||
.from(userSettings)
|
||||
.where(eq(userSettings.userId, userId));
|
||||
const stockCalculationMode = settings?.stockCalculationMode === "manual" ? "manual" : "automatic";
|
||||
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
|
||||
const currentStockAtRefill = computeMedicationCurrentStock({
|
||||
medication: med,
|
||||
doses,
|
||||
stockCalculationMode,
|
||||
nowMs: refillBaselineAt.getTime(),
|
||||
});
|
||||
const targetCurrentStock = currentStockAtRefill + totalPillsAdded;
|
||||
|
||||
// Update medication stock. Refill establishes a new persisted stock baseline and resets
|
||||
// `lastStockCorrectionAt` so pre-refill dose history is ignored for future stock math.
|
||||
// Update medication stock. Refill establishes a new stock baseline at the current visible
|
||||
// stock level so previously consumed doses are not "resurrected" when lastStockCorrectionAt resets.
|
||||
let newPackCount = med.packCount + effectivePacksAdded;
|
||||
let newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
|
||||
let newStockAdjustment = med.stockAdjustment ?? 0;
|
||||
|
||||
@@ -2,17 +2,8 @@ import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
||||
import { db } from "../db/client.js";
|
||||
import { userSettings } from "../db/schema.js";
|
||||
import { getDateLocale, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.js";
|
||||
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
|
||||
import { env } from "../plugins/env.js";
|
||||
import {
|
||||
createTestNotificationActionContext,
|
||||
storeNotificationActionGroupNtfyMessageId,
|
||||
} from "../services/notification-actions-service.js";
|
||||
import {
|
||||
type PushNotificationOptions,
|
||||
renderNotificationActionPayload,
|
||||
} from "../services/notifications/action-renderer.js";
|
||||
import { getSmtpConfig, sendEmailNotification } from "../services/notifications/delivery.js";
|
||||
import {
|
||||
classifyTestEmailFailure,
|
||||
@@ -79,6 +70,36 @@ const settingsErrorSchema = {
|
||||
},
|
||||
};
|
||||
|
||||
type MailDeliveryInfo = {
|
||||
accepted?: unknown;
|
||||
rejected?: unknown;
|
||||
response?: unknown;
|
||||
};
|
||||
|
||||
function normalizeRecipients(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value
|
||||
.map((entry) => (typeof entry === "string" ? entry : String(entry ?? "")))
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function getDeliveryError(info: MailDeliveryInfo): string | null {
|
||||
const accepted = normalizeRecipients(info.accepted);
|
||||
const rejected = normalizeRecipients(info.rejected);
|
||||
|
||||
if (accepted.length > 0) return null;
|
||||
if (rejected.length > 0) {
|
||||
return `SMTP rejected all recipients: ${rejected.join(", ")}`;
|
||||
}
|
||||
|
||||
if (typeof info.response === "string" && info.response.trim()) {
|
||||
return `SMTP did not confirm accepted recipients. Response: ${info.response}`;
|
||||
}
|
||||
|
||||
return "SMTP did not confirm accepted recipients.";
|
||||
}
|
||||
|
||||
function envInt(key: string, defaultVal: number): number {
|
||||
const val = process.env[key];
|
||||
if (val === undefined) return defaultVal;
|
||||
@@ -86,24 +107,6 @@ function envInt(key: string, defaultVal: number): number {
|
||||
return Number.isNaN(parsed) ? defaultVal : parsed;
|
||||
}
|
||||
|
||||
function getLanguage(language: string | null | undefined): Language {
|
||||
return language === "de" ? "de" : "en";
|
||||
}
|
||||
|
||||
function buildInteractiveTestPushNotification(language: Language): { title: string; message: string } {
|
||||
const tr = getTranslations(language);
|
||||
const reminderAt = new Date(Date.now() + 60 * 1000);
|
||||
const reminderTime = new Intl.DateTimeFormat(getDateLocale(language), {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).format(reminderAt);
|
||||
|
||||
return {
|
||||
title: t(tr.push.intakeTitle, { minutes: 1 }),
|
||||
message: `• MedAssist-ng Test: 1 ${tr.common.pill} (100 mg) @ ${reminderTime}\n\n---\n${getFooterPlain(language)}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function getOrCreateUserSettings(userId: number) {
|
||||
let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId));
|
||||
|
||||
@@ -549,33 +552,14 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
}
|
||||
|
||||
try {
|
||||
const userId = await getUserId(request, reply);
|
||||
const settings = await getOrCreateUserSettings(userId);
|
||||
const language = getLanguage(settings.language);
|
||||
const { title, message } = buildInteractiveTestPushNotification(language);
|
||||
const actionContext = await createTestNotificationActionContext({
|
||||
userId,
|
||||
title,
|
||||
message,
|
||||
publicAppUrl: env.PUBLIC_APP_URL,
|
||||
language,
|
||||
});
|
||||
const provider = getNotificationProvider(url);
|
||||
const result = await sendShoutrrrNotification(url, title, message, {
|
||||
actions: actionContext?.actions,
|
||||
respondUrl: actionContext?.respondUrl,
|
||||
viewUrl: actionContext?.viewUrl,
|
||||
clickUrl: actionContext?.respondUrl ?? actionContext?.viewUrl,
|
||||
sequenceId: actionContext?.sequenceId,
|
||||
tags: ["pill"],
|
||||
priority: 3,
|
||||
});
|
||||
const result = await sendShoutrrrNotification(
|
||||
url,
|
||||
"MedAssist-ng Test",
|
||||
"This is a test notification from MedAssist-ng. If you received this, your notification configuration is working correctly!"
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
if (actionContext?.groupId && result.providerMessageId) {
|
||||
await storeNotificationActionGroupNtfyMessageId(actionContext.groupId, result.providerMessageId);
|
||||
}
|
||||
|
||||
request.log.info({ provider }, "[Settings] Test push notification sent");
|
||||
return reply.send({ success: true, message: "Test notification sent successfully" });
|
||||
} else {
|
||||
@@ -598,9 +582,8 @@ export async function settingsRoutes(app: FastifyInstance) {
|
||||
export async function sendShoutrrrNotification(
|
||||
urlStr: string,
|
||||
title: string,
|
||||
message: string,
|
||||
options: PushNotificationOptions = {}
|
||||
): Promise<{ success: boolean; error?: string; providerMessageId?: string }> {
|
||||
message: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
if (urlStr.startsWith("pushover://")) {
|
||||
const pushoverAuthority = urlStr.slice("pushover://".length).split("/")[0] ?? "";
|
||||
@@ -753,13 +736,12 @@ export async function sendShoutrrrNotification(
|
||||
}
|
||||
|
||||
// Use ONLY the reconstructed URL from validation - never the original urlStr
|
||||
const { url: sanitizedUrl, isNtfy, auth } = validation;
|
||||
const { url: sanitizedUrl, isNtfy: _isNtfy, auth } = validation;
|
||||
|
||||
let targetUrl: string;
|
||||
const method = "POST";
|
||||
let headers: Record<string, string> = {};
|
||||
let body: string | undefined;
|
||||
const renderedPayload = renderNotificationActionPayload(urlStr, message, options);
|
||||
|
||||
// Remove emojis from title for header compatibility
|
||||
const cleanTitle = title
|
||||
@@ -804,27 +786,19 @@ export async function sendShoutrrrNotification(
|
||||
// characters (umlauts, accents, etc.) through HTTP headers
|
||||
const encodedTitle = `=?UTF-8?B?${Buffer.from(cleanTitle, "utf-8").toString("base64")}?=`;
|
||||
headers = { Title: encodedTitle, Tags: "pill" };
|
||||
body = renderedPayload.message;
|
||||
body = message;
|
||||
|
||||
// Add auth if present (extracted during sanitization)
|
||||
if (auth) {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${auth.user}:${auth.pass}`).toString("base64")}`;
|
||||
}
|
||||
|
||||
if (isNtfy) {
|
||||
headers = { ...headers, ...renderedPayload.headers };
|
||||
}
|
||||
} else if (sanitizedUrl.startsWith("http://") || sanitizedUrl.startsWith("https://")) {
|
||||
targetUrl = sanitizedUrl;
|
||||
headers = { "Content-Type": "application/json" };
|
||||
if (isDiscordWebhook) {
|
||||
body = JSON.stringify({ content: `${title}\n\n${renderedPayload.message}` });
|
||||
body = JSON.stringify({ content: `${title}\n\n${message}` });
|
||||
} else {
|
||||
body = JSON.stringify({
|
||||
title,
|
||||
message: renderedPayload.message,
|
||||
text: `${title}\n\n${renderedPayload.message}`,
|
||||
});
|
||||
body = JSON.stringify({ title, message, text: `${title}\n\n${message}` });
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
@@ -849,17 +823,7 @@ export async function sendShoutrrrNotification(
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
let providerMessageId: string | undefined;
|
||||
if (isNtfy) {
|
||||
try {
|
||||
const payload = (await response.json()) as { id?: unknown };
|
||||
providerMessageId = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : undefined;
|
||||
} catch {
|
||||
providerMessageId = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
return { success: true, providerMessageId };
|
||||
return { success: true };
|
||||
} else {
|
||||
const errorText = await response.text();
|
||||
return { success: false, error: `HTTP ${response.status}: ${errorText}` };
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { doseTracking, medications, userSettings } from "../db/schema.js";
|
||||
import { parseIntakesJson, parseLocalDateTime } from "../utils/scheduler-utils.js";
|
||||
import { computeMedicationCurrentStock } from "./current-stock.js";
|
||||
|
||||
const doseIdPattern = /^(\d+)-(\d+)-(\d+)(?:-(.+))?$/;
|
||||
|
||||
type ParsedDoseId = {
|
||||
medicationId: number;
|
||||
intakeIndex: number;
|
||||
timestampMs: number;
|
||||
personSuffix: string | null;
|
||||
};
|
||||
|
||||
export type DoseTrackingSource = "manual" | "automatic" | "notification";
|
||||
|
||||
export type MarkDoseTakenResult =
|
||||
| {
|
||||
success: true;
|
||||
status: "marked" | "already_taken";
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
code: "OUT_OF_STOCK" | "INVALID_DOSE" | "ALREADY_SKIPPED";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type DismissDosesResult = {
|
||||
success: true;
|
||||
dismissedCount: number;
|
||||
alreadyTakenCount: number;
|
||||
};
|
||||
|
||||
export type SkipDosesResult = {
|
||||
success: true;
|
||||
skippedCount: number;
|
||||
alreadySkippedCount: number;
|
||||
switchedFromTakenCount: number;
|
||||
};
|
||||
|
||||
function parseDoseId(doseId: string): ParsedDoseId | null {
|
||||
const match = doseIdPattern.exec(doseId);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const medicationId = Number.parseInt(match[1], 10);
|
||||
const intakeIndex = Number.parseInt(match[2], 10);
|
||||
const timestampMs = Number.parseInt(match[3], 10);
|
||||
const personSuffix = match[4] ? match[4].trim() : null;
|
||||
|
||||
if (Number.isNaN(medicationId) || Number.isNaN(intakeIndex) || Number.isNaN(timestampMs) || intakeIndex < 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
medicationId,
|
||||
intakeIndex,
|
||||
timestampMs,
|
||||
personSuffix,
|
||||
};
|
||||
}
|
||||
|
||||
function hasRealTakenTimestamp(takenAt: Date | null): boolean {
|
||||
return takenAt instanceof Date && takenAt.getTime() > 0;
|
||||
}
|
||||
|
||||
async function isDoseOutOfStock(options: { userId: number; doseId: string }): Promise<boolean> {
|
||||
const parsedDose = parseDoseId(options.doseId);
|
||||
if (!parsedDose) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [medication] = await db
|
||||
.select()
|
||||
.from(medications)
|
||||
.where(and(eq(medications.id, parsedDose.medicationId), eq(medications.userId, options.userId)));
|
||||
|
||||
if (!medication) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, options.userId));
|
||||
const stockCalculationMode = (settings?.stockCalculationMode as "automatic" | "manual") ?? "automatic";
|
||||
|
||||
const intakes = parseIntakesJson(
|
||||
medication.intakesJson,
|
||||
{ usageJson: medication.usageJson, everyJson: medication.everyJson, startJson: medication.startJson },
|
||||
medication.intakeRemindersEnabled ?? false
|
||||
);
|
||||
const intake = intakes[parsedDose.intakeIndex];
|
||||
|
||||
const scheduledOccurrenceMs = intake
|
||||
? (() => {
|
||||
const doseDate = new Date(parsedDose.timestampMs);
|
||||
const intakeStart = parseLocalDateTime(intake.start);
|
||||
return new Date(
|
||||
doseDate.getFullYear(),
|
||||
doseDate.getMonth(),
|
||||
doseDate.getDate(),
|
||||
intakeStart.getHours(),
|
||||
intakeStart.getMinutes(),
|
||||
intakeStart.getSeconds(),
|
||||
intakeStart.getMilliseconds()
|
||||
).getTime();
|
||||
})()
|
||||
: parsedDose.timestampMs;
|
||||
|
||||
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, options.userId));
|
||||
const stockBeforeDoseMs = Math.max(0, scheduledOccurrenceMs - 1);
|
||||
|
||||
return (
|
||||
computeMedicationCurrentStock({
|
||||
medication,
|
||||
doses,
|
||||
stockCalculationMode,
|
||||
nowMs: stockBeforeDoseMs,
|
||||
}) <= 0
|
||||
);
|
||||
}
|
||||
|
||||
export async function markDoseTakenForUser(input: {
|
||||
userId: number;
|
||||
doseId: string;
|
||||
source: DoseTrackingSource;
|
||||
markedBy?: string | null;
|
||||
}): Promise<MarkDoseTakenResult> {
|
||||
const parsedDose = parseDoseId(input.doseId);
|
||||
if (!parsedDose) {
|
||||
return {
|
||||
success: false,
|
||||
code: "INVALID_DOSE",
|
||||
message: "Invalid dose ID",
|
||||
};
|
||||
}
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, input.doseId)));
|
||||
|
||||
if (existing && !existing.dismissed) {
|
||||
return { success: true, status: "already_taken" };
|
||||
}
|
||||
|
||||
if (existing?.dismissed && hasRealTakenTimestamp(existing.takenAt)) {
|
||||
return { success: true, status: "already_taken" };
|
||||
}
|
||||
|
||||
if (existing?.dismissed) {
|
||||
return {
|
||||
success: false,
|
||||
code: "ALREADY_SKIPPED",
|
||||
message: "Dose is already skipped",
|
||||
};
|
||||
}
|
||||
|
||||
const outOfStock = await isDoseOutOfStock({ userId: input.userId, doseId: input.doseId });
|
||||
if (outOfStock) {
|
||||
return {
|
||||
success: false,
|
||||
code: "OUT_OF_STOCK",
|
||||
message: "Medication is out of stock",
|
||||
};
|
||||
}
|
||||
|
||||
await db.insert(doseTracking).values({
|
||||
userId: input.userId,
|
||||
doseId: input.doseId,
|
||||
takenAt: new Date(),
|
||||
markedBy: input.markedBy ?? null,
|
||||
takenSource: input.source,
|
||||
dismissed: false,
|
||||
});
|
||||
|
||||
return { success: true, status: "marked" };
|
||||
}
|
||||
|
||||
export async function skipDosesForUser(input: { userId: number; doseIds: string[] }): Promise<SkipDosesResult> {
|
||||
let skippedCount = 0;
|
||||
let alreadySkippedCount = 0;
|
||||
let switchedFromTakenCount = 0;
|
||||
|
||||
for (const doseId of input.doseIds) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, doseId)));
|
||||
|
||||
if (!existing) {
|
||||
await db.insert(doseTracking).values({
|
||||
userId: input.userId,
|
||||
doseId,
|
||||
markedBy: null,
|
||||
takenAt: new Date(0),
|
||||
dismissed: true,
|
||||
});
|
||||
skippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing.dismissed) {
|
||||
alreadySkippedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasRealTakenTimestamp(existing.takenAt)) {
|
||||
switchedFromTakenCount++;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(doseTracking)
|
||||
.set({
|
||||
dismissed: true,
|
||||
takenAt: new Date(0),
|
||||
takenSource: "manual",
|
||||
markedBy: null,
|
||||
})
|
||||
.where(eq(doseTracking.id, existing.id));
|
||||
skippedCount++;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
skippedCount,
|
||||
alreadySkippedCount,
|
||||
switchedFromTakenCount,
|
||||
};
|
||||
}
|
||||
|
||||
export async function dismissDosesForUser(input: { userId: number; doseIds: string[] }): Promise<DismissDosesResult> {
|
||||
let dismissedCount = 0;
|
||||
let alreadyTakenCount = 0;
|
||||
|
||||
for (const doseId of input.doseIds) {
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, input.userId), eq(doseTracking.doseId, doseId)));
|
||||
|
||||
if (!existing) {
|
||||
await db.insert(doseTracking).values({
|
||||
userId: input.userId,
|
||||
doseId,
|
||||
markedBy: null,
|
||||
takenAt: new Date(0),
|
||||
dismissed: true,
|
||||
});
|
||||
dismissedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (existing.dismissed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasRealTakenTimestamp(existing.takenAt)) {
|
||||
alreadyTakenCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await db
|
||||
.update(doseTracking)
|
||||
.set({
|
||||
dismissed: true,
|
||||
takenAt: new Date(0),
|
||||
takenSource: "manual",
|
||||
markedBy: null,
|
||||
})
|
||||
.where(eq(doseTracking.id, existing.id));
|
||||
dismissedCount++;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
dismissedCount,
|
||||
alreadyTakenCount,
|
||||
};
|
||||
}
|
||||
@@ -12,8 +12,6 @@ import {
|
||||
type Language,
|
||||
t,
|
||||
} from "../i18n/translations.js";
|
||||
|
||||
import { env } from "../plugins/env.js";
|
||||
import { getAllUserSettings, type UserSettings } from "../routes/settings.js";
|
||||
import type { ServiceLogger } from "../utils/logger.js";
|
||||
// Import shared utilities
|
||||
@@ -31,10 +29,6 @@ import {
|
||||
type UpcomingIntake,
|
||||
} from "../utils/scheduler-utils.js";
|
||||
import { computeMedicationCurrentStock } from "./current-stock.js";
|
||||
import {
|
||||
createNotificationActionContext,
|
||||
storeNotificationActionGroupNtfyMessageId,
|
||||
} from "./notification-actions-service.js";
|
||||
import { getSmtpConfig, sendEmailNotification, sendPushNotification } from "./notifications/delivery.js";
|
||||
import { updateReminderSentTime, updateUserReminderSentTime } from "./notifications/state.js";
|
||||
|
||||
@@ -99,31 +93,6 @@ function getMedicationDisplayName(med: { id: number; name: string | null; generi
|
||||
return `Medication #${med.id}`;
|
||||
}
|
||||
|
||||
function getPushProviderLabel(url: string): string {
|
||||
const normalizedUrl = url.trim().toLowerCase();
|
||||
if (normalizedUrl.startsWith("ntfy://")) return "ntfy";
|
||||
if (normalizedUrl.startsWith("discord://")) return "discord";
|
||||
if (normalizedUrl.startsWith("pushover://")) return "pushover";
|
||||
if (normalizedUrl.startsWith("gotify://")) return "gotify";
|
||||
if (normalizedUrl.startsWith("telegram://")) return "telegram";
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url);
|
||||
return parsedUrl.hostname || parsedUrl.protocol.replace(":", "") || "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
function formatActionContextLog(options: {
|
||||
actionMode: "full" | "view-only";
|
||||
doseCount: number;
|
||||
actionContext: Awaited<ReturnType<typeof createNotificationActionContext>> | null;
|
||||
}): string {
|
||||
const { actionMode, doseCount, actionContext } = options;
|
||||
return `actionMode=${actionMode}, doses=${doseCount}, actions=${actionContext?.actions.length ?? 0}, hasRespondUrl=${actionContext?.respondUrl ? "yes" : "no"}, hasViewUrl=${actionContext?.viewUrl ? "yes" : "no"}, sequenceId=${actionContext?.sequenceId ?? "none"}, groupId=${actionContext?.groupId ?? "n/a"}`;
|
||||
}
|
||||
|
||||
async function autoMarkDueIntakesAsTaken(
|
||||
settings: UserSettings & { userId: number },
|
||||
rows: (typeof medications.$inferSelect)[],
|
||||
@@ -514,42 +483,11 @@ export async function checkAndSendIntakeRemindersForUser(
|
||||
return; // No medications have reminders enabled for this user
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const state = loadIntakeReminderState(logger);
|
||||
const trackedDoses = await db
|
||||
.select()
|
||||
.from(doseTracking)
|
||||
.where(and(eq(doseTracking.userId, settings.userId), eq(doseTracking.dismissed, false)));
|
||||
|
||||
const reminderEntriesWithStock = reminderEntries.map((entry) => ({
|
||||
...entry,
|
||||
currentStock: computeMedicationCurrentStock({
|
||||
medication: entry.med,
|
||||
doses: trackedDoses,
|
||||
stockCalculationMode: settings.stockCalculationMode,
|
||||
nowMs: now.getTime(),
|
||||
}),
|
||||
}));
|
||||
const suppressedEmptyStockEntries = reminderEntriesWithStock.filter((entry) => entry.currentStock <= 0);
|
||||
if (suppressedEmptyStockEntries.length > 0) {
|
||||
logger.info(
|
||||
`[IntakeReminder] Skipping reminder-enabled medications with empty stock for user=${username} (userId=${settings.userId}): count=${suppressedEmptyStockEntries.length}, meds=${suppressedEmptyStockEntries
|
||||
.map((entry) =>
|
||||
getMedicationDisplayName({ id: entry.med.id, name: entry.med.name, genericName: entry.med.genericName })
|
||||
)
|
||||
.join(", ")}`
|
||||
);
|
||||
}
|
||||
const reminderEntriesEligible = reminderEntriesWithStock.filter((entry) => entry.currentStock > 0);
|
||||
if (reminderEntriesEligible.length === 0) {
|
||||
logger.info(
|
||||
`[IntakeReminder] No reminder-eligible medications with stock remaining for user=${username} (userId=${settings.userId})`
|
||||
);
|
||||
return;
|
||||
}
|
||||
const allUpcoming: (UpcomingIntake & { medicationId: number; blisterIndex: number })[] = [];
|
||||
let scheduledIntakesTodayCount = 0;
|
||||
// Get start and end of today in user's timezone (for filtering today's doses only)
|
||||
const now = new Date();
|
||||
const todayStart = new Date(now.toLocaleString("en-US", { timeZone: tz }));
|
||||
todayStart.setHours(0, 0, 0, 0);
|
||||
|
||||
@@ -557,7 +495,7 @@ export async function checkAndSendIntakeRemindersForUser(
|
||||
todayEnd.setHours(23, 59, 59, 999);
|
||||
|
||||
// Find intakes: upcoming ones in reminder window + past ones for repeat reminders
|
||||
for (const { med, intakes, intakesWithReminders } of reminderEntriesEligible) {
|
||||
for (const { med, intakes, intakesWithReminders } of reminderEntries) {
|
||||
// Medication-level takenBy (for fallback/display purposes)
|
||||
const medicationTakenBy = parseTakenByJson(med.takenByJson);
|
||||
const medDisplayName = getMedicationDisplayName({ id: med.id, name: med.name, genericName: med.genericName });
|
||||
@@ -863,96 +801,16 @@ export async function checkAndSendIntakeRemindersForUser(
|
||||
.join("\n") +
|
||||
repeatNote +
|
||||
`\n\n---\n${getFooterPlain(language)}`;
|
||||
const actionMode = remindersToSend.length === 1 ? "full" : "view-only";
|
||||
const actionDoseIds = remindersToSend.map((intake) =>
|
||||
buildDoseIdForIntake({
|
||||
...intake,
|
||||
medicationId: intake.medicationId,
|
||||
blisterIndex: intake.blisterIndex,
|
||||
})
|
||||
);
|
||||
let actionContext: Awaited<ReturnType<typeof createNotificationActionContext>> | null = null;
|
||||
let actionContextFailed = false;
|
||||
try {
|
||||
actionContext = await createNotificationActionContext({
|
||||
userId: settings.userId,
|
||||
title,
|
||||
message,
|
||||
doseIds: actionDoseIds,
|
||||
scheduledFor: remindersToSend[0]?.intakeTime ?? new Date(),
|
||||
publicAppUrl: env.PUBLIC_APP_URL,
|
||||
language,
|
||||
actionMode,
|
||||
});
|
||||
} catch (error) {
|
||||
actionContextFailed = true;
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.error(
|
||||
`[IntakeReminder] Notification action context failed for user=${username} (userId=${settings.userId}, provider=${getPushProviderLabel(
|
||||
settings.shoutrrrUrl!
|
||||
)}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext: null })}): ${errorMessage}`
|
||||
);
|
||||
}
|
||||
if (!actionContext) {
|
||||
if (actionContextFailed) {
|
||||
logger.warn(
|
||||
`[IntakeReminder] Sending intake reminders without actions after action context failure for user=${username} (userId=${settings.userId}, provider=${getPushProviderLabel(
|
||||
settings.shoutrrrUrl!
|
||||
)})`
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
`[IntakeReminder] No reachable public app URL configured; sending intake reminders without actions for user=${username} (userId=${settings.userId})`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
logger.info(
|
||||
`[IntakeReminder] Notification action context ready for user=${username} (userId=${settings.userId}, provider=${getPushProviderLabel(
|
||||
settings.shoutrrrUrl!
|
||||
)}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext })})`
|
||||
);
|
||||
}
|
||||
|
||||
const pushProvider = getPushProviderLabel(settings.shoutrrrUrl!);
|
||||
logger.info(
|
||||
`[IntakeReminder] Sending push reminder for user=${username} (userId=${settings.userId}, provider=${pushProvider}, reminders=${remindersToSend.length}, priority=${hasNaggingReminder ? 4 : 3}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext })})`
|
||||
);
|
||||
|
||||
const result = await sendPushNotification(settings.shoutrrrUrl!, title, message, {
|
||||
actions: actionContext?.actions,
|
||||
respondUrl: actionContext?.respondUrl,
|
||||
viewUrl: actionContext?.viewUrl,
|
||||
clickUrl: actionContext?.respondUrl ?? actionContext?.viewUrl,
|
||||
sequenceId: actionContext?.sequenceId,
|
||||
tags: ["pill"],
|
||||
priority: hasNaggingReminder ? 4 : 3,
|
||||
});
|
||||
const result = await sendPushNotification(settings.shoutrrrUrl!, title, message);
|
||||
shoutrrrSuccess = result.success;
|
||||
if (!result.success) {
|
||||
logger.error(
|
||||
`[IntakeReminder] Push delivery failed for user=${username} (userId=${settings.userId}, provider=${pushProvider}, reminders=${remindersToSend.length}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext })}): ${result.error}`
|
||||
`[IntakeReminder] Push delivery failed for user=${username} (userId=${settings.userId}): ${result.error}`
|
||||
);
|
||||
} else {
|
||||
if (actionContext?.groupId && result.providerMessageId) {
|
||||
try {
|
||||
await storeNotificationActionGroupNtfyMessageId(actionContext.groupId, result.providerMessageId);
|
||||
logger.info(
|
||||
`[IntakeReminder] Stored ntfy message id for action group for user=${username} (userId=${settings.userId}, groupId=${actionContext.groupId}, providerMessageId=${result.providerMessageId})`
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(
|
||||
`[IntakeReminder] Failed to store ntfy message id for action group for user=${username} (userId=${settings.userId}, groupId=${actionContext.groupId}, providerMessageId=${result.providerMessageId}): ${errorMessage}`
|
||||
);
|
||||
}
|
||||
} else if (actionContext?.groupId && pushProvider === "ntfy") {
|
||||
logger.warn(
|
||||
`[IntakeReminder] Push delivered without ntfy message id for action group for user=${username} (userId=${settings.userId}, groupId=${actionContext.groupId})`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[IntakeReminder] Push delivered for user=${username} (userId=${settings.userId}, provider=${pushProvider}, reminders=${remindersToSend.length}, providerMessageId=${result.providerMessageId ?? "n/a"}, ${formatActionContextLog({ actionMode, doseCount: actionDoseIds.length, actionContext })})`
|
||||
`[IntakeReminder] Push delivered for user=${username} (userId=${settings.userId}, reminders=${remindersToSend.length})`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import nodemailer from "nodemailer";
|
||||
import { sendShoutrrrNotification } from "../../routes/settings.js";
|
||||
import type { PushNotificationOptions } from "./action-renderer.js";
|
||||
|
||||
type MailDeliveryInfo = {
|
||||
accepted?: unknown;
|
||||
@@ -123,15 +122,14 @@ export async function sendEmailNotification(input: EmailDeliveryRequest): Promis
|
||||
export async function sendPushNotification(
|
||||
url: string,
|
||||
title: string,
|
||||
message: string,
|
||||
options: PushNotificationOptions = {}
|
||||
): Promise<{ success: boolean; error?: string; providerMessageId?: string }> {
|
||||
message: string
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
const result = await sendShoutrrrNotification(url, title, message, options);
|
||||
const result = await sendShoutrrrNotification(url, title, message);
|
||||
if (!result.success) {
|
||||
return { success: false, error: result.error };
|
||||
}
|
||||
return { success: true, providerMessageId: result.providerMessageId };
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||
return { success: false, error: errorMessage };
|
||||
|
||||
@@ -2,7 +2,6 @@ import { eq } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { userSettings } from "../db/schema.js";
|
||||
import type { Language } from "../i18n/translations.js";
|
||||
import { isNtfyNotificationUrl } from "./notifications/action-renderer.js";
|
||||
|
||||
export type UserSettings = {
|
||||
userId: number;
|
||||
@@ -82,7 +81,7 @@ export function getNotificationProvider(url: string): string {
|
||||
if (url.startsWith("telegram://")) return "telegram";
|
||||
if (url.startsWith("gotify://")) return "gotify";
|
||||
if (url.startsWith("pushover://")) return "pushover";
|
||||
if (isNtfyNotificationUrl(url)) return "ntfy";
|
||||
if (url.startsWith("ntfy://")) return "ntfy";
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
@@ -232,7 +231,7 @@ export function sanitizeNotificationUrl(
|
||||
return { url: discordWebhookUrl, isNtfy: false };
|
||||
}
|
||||
|
||||
const isNtfy = isNtfyNotificationUrl(urlStr);
|
||||
const isNtfy = urlStr.startsWith("ntfy://");
|
||||
const normalizedUrl = isNtfy ? urlStr.replace("ntfy://", "https://") : urlStr;
|
||||
const parsed = new URL(normalizedUrl);
|
||||
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runAlterMigrations } from "../db/db-utils.js";
|
||||
|
||||
const { testClient, testDb } = vi.hoisted(() => {
|
||||
const { createClient } = require("@libsql/client");
|
||||
const { drizzle } = require("drizzle-orm/libsql");
|
||||
const client = createClient({ url: ":memory:" });
|
||||
const db = drizzle(client);
|
||||
|
||||
return {
|
||||
testClient: client,
|
||||
testDb: db,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../db/client.js", () => ({
|
||||
db: testDb,
|
||||
migrationsReady: Promise.resolve(),
|
||||
}));
|
||||
|
||||
const { dismissDosesForUser, markDoseTakenForUser } = await import("../services/dose-tracking-service.js");
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||
|
||||
async function clearTables() {
|
||||
await testClient.execute("DELETE FROM dose_tracking");
|
||||
await testClient.execute("DELETE FROM medications");
|
||||
await testClient.execute("DELETE FROM user_settings");
|
||||
await testClient.execute("DELETE FROM users");
|
||||
}
|
||||
|
||||
async function createUser(username: string) {
|
||||
const result = await testClient.execute({
|
||||
sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id",
|
||||
args: [username],
|
||||
});
|
||||
|
||||
return Number(result.rows[0].id);
|
||||
}
|
||||
|
||||
async function insertMedication(options: { id: number; userId: number; packCount?: number; looseTablets?: number }) {
|
||||
const start = "2025-01-01T08:00:00.000Z";
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO medications (
|
||||
id, user_id, name, taken_by_json, medication_form, package_type,
|
||||
pack_count, blisters_per_pack, pills_per_blister, loose_tablets, stock_adjustment,
|
||||
usage_json, every_json, start_json, intakes_json, intake_reminders_enabled
|
||||
) VALUES (?, ?, 'Test Medication', '[]', 'tablet', 'blister', ?, 1, 10, ?, 0, ?, ?, ?, ?, 0)`,
|
||||
args: [
|
||||
options.id,
|
||||
options.userId,
|
||||
options.packCount ?? 1,
|
||||
options.looseTablets ?? 0,
|
||||
JSON.stringify([1]),
|
||||
JSON.stringify([1]),
|
||||
JSON.stringify([start]),
|
||||
JSON.stringify([{ usage: 1, every: 1, start, takenBy: null, intakeRemindersEnabled: false }]),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function insertUserSettings(userId: number, stockCalculationMode: "automatic" | "manual" = "automatic") {
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, ?)",
|
||||
args: [userId, stockCalculationMode],
|
||||
});
|
||||
}
|
||||
|
||||
async function insertDose(options: {
|
||||
userId: number;
|
||||
doseId: string;
|
||||
dismissed?: boolean;
|
||||
takenAt?: number;
|
||||
takenSource?: "manual" | "automatic" | "notification";
|
||||
markedBy?: string | null;
|
||||
}) {
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO dose_tracking (user_id, dose_id, dismissed, taken_at, taken_source, marked_by)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
args: [
|
||||
options.userId,
|
||||
options.doseId,
|
||||
options.dismissed ? 1 : 0,
|
||||
options.takenAt ?? Math.floor(Date.now() / 1000),
|
||||
options.takenSource ?? "manual",
|
||||
options.markedBy ?? null,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
describe("dose-tracking-service", () => {
|
||||
beforeAll(async () => {
|
||||
await migrate(testDb, { migrationsFolder });
|
||||
await runAlterMigrations(testClient);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
testClient.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearTables();
|
||||
});
|
||||
|
||||
it("inserts a taken row for a valid in-stock dose", async () => {
|
||||
const userId = await createUser("dose-service-user");
|
||||
await insertMedication({ id: 5, userId, packCount: 1 });
|
||||
await insertUserSettings(userId, "automatic");
|
||||
|
||||
const result = await markDoseTakenForUser({
|
||||
userId,
|
||||
doseId: "5-0-1736064000000",
|
||||
source: "notification",
|
||||
markedBy: null,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true, status: "marked" });
|
||||
|
||||
const rows = await testClient.execute({
|
||||
sql: "SELECT dismissed, taken_source, marked_by FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||
args: [userId, "5-0-1736064000000"],
|
||||
});
|
||||
expect(rows.rows).toEqual([
|
||||
expect.objectContaining({ dismissed: 0, taken_source: "notification", marked_by: null }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("is idempotent when the dose is already taken", async () => {
|
||||
const userId = await createUser("dose-service-existing");
|
||||
await insertDose({ userId, doseId: "5-0-1736064000000", dismissed: false });
|
||||
|
||||
const result = await markDoseTakenForUser({
|
||||
userId,
|
||||
doseId: "5-0-1736064000000",
|
||||
source: "manual",
|
||||
markedBy: null,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true, status: "already_taken" });
|
||||
|
||||
const count = await testClient.execute({
|
||||
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||
args: [userId, "5-0-1736064000000"],
|
||||
});
|
||||
expect(Number(count.rows[0].count)).toBe(1);
|
||||
});
|
||||
|
||||
it("rejects taking a dose that is already skipped", async () => {
|
||||
const userId = await createUser("dose-service-dismissed");
|
||||
await insertMedication({ id: 5, userId, packCount: 1 });
|
||||
await insertUserSettings(userId, "automatic");
|
||||
await insertDose({
|
||||
userId,
|
||||
doseId: "5-0-1736064000000",
|
||||
dismissed: true,
|
||||
takenAt: 0,
|
||||
takenSource: "manual",
|
||||
markedBy: null,
|
||||
});
|
||||
|
||||
const result = await markDoseTakenForUser({
|
||||
userId,
|
||||
doseId: "5-0-1736064000000",
|
||||
source: "notification",
|
||||
markedBy: "reminder",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, code: "ALREADY_SKIPPED", message: "Dose is already skipped" });
|
||||
|
||||
const rows = await testClient.execute({
|
||||
sql: "SELECT dismissed, taken_source, marked_by, taken_at FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||
args: [userId, "5-0-1736064000000"],
|
||||
});
|
||||
expect(rows.rows).toEqual([expect.objectContaining({ dismissed: 1, taken_source: "manual", marked_by: null })]);
|
||||
expect(Number(rows.rows[0].taken_at)).toBe(0);
|
||||
});
|
||||
|
||||
it("returns OUT_OF_STOCK without mutating dose tracking", async () => {
|
||||
const userId = await createUser("dose-service-stock");
|
||||
await insertMedication({ id: 5, userId, packCount: 0, looseTablets: 0 });
|
||||
await insertUserSettings(userId, "automatic");
|
||||
|
||||
const result = await markDoseTakenForUser({
|
||||
userId,
|
||||
doseId: "5-0-1736064000000",
|
||||
source: "notification",
|
||||
markedBy: null,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: false, code: "OUT_OF_STOCK", message: "Medication is out of stock" });
|
||||
|
||||
const count = await testClient.execute({
|
||||
sql: "SELECT COUNT(*) AS count FROM dose_tracking WHERE user_id = ?",
|
||||
args: [userId],
|
||||
});
|
||||
expect(Number(count.rows[0].count)).toBe(0);
|
||||
});
|
||||
|
||||
it("dismisses new doses, stays idempotent for dismissed rows, and preserves real taken rows", async () => {
|
||||
const userId = await createUser("dose-service-dismiss");
|
||||
await insertDose({ userId, doseId: "5-1-1736064000000", dismissed: true, takenAt: 0 });
|
||||
await insertDose({ userId, doseId: "5-2-1736064000000", dismissed: false });
|
||||
|
||||
const result = await dismissDosesForUser({
|
||||
userId,
|
||||
doseIds: ["5-0-1736064000000", "5-1-1736064000000", "5-2-1736064000000"],
|
||||
});
|
||||
|
||||
expect(result).toEqual({ success: true, dismissedCount: 1, alreadyTakenCount: 1 });
|
||||
|
||||
const rows = await testClient.execute({
|
||||
sql: "SELECT dose_id, dismissed, taken_at FROM dose_tracking WHERE user_id = ? ORDER BY dose_id ASC",
|
||||
args: [userId],
|
||||
});
|
||||
expect(rows.rows).toEqual([
|
||||
expect.objectContaining({ dose_id: "5-0-1736064000000", dismissed: 1, taken_at: 0 }),
|
||||
expect.objectContaining({ dose_id: "5-1-1736064000000", dismissed: 1, taken_at: 0 }),
|
||||
expect.objectContaining({ dose_id: "5-2-1736064000000", dismissed: 0 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -3368,12 +3368,12 @@ describe("E2E Tests with Real Routes", () => {
|
||||
looseTablets: 10,
|
||||
},
|
||||
refillPayload: { packsAdded: 0, loosePillsAdded: 100 },
|
||||
expectedVisibleStockBeforeRefill: 10,
|
||||
expectedVisibleStockBeforeRefill: 4,
|
||||
expectedQuantityAdded: 100,
|
||||
expectedResponsePacksAdded: 0,
|
||||
expectedPackCount: 0,
|
||||
expectedLooseTablets: 110,
|
||||
expectedTotalPills: 110,
|
||||
expectedLooseTablets: 104,
|
||||
expectedTotalPills: 104,
|
||||
expectedPersistedTotalPills: 100,
|
||||
expectedStockAdjustment: 0,
|
||||
},
|
||||
@@ -3387,14 +3387,14 @@ describe("E2E Tests with Real Routes", () => {
|
||||
looseTablets: 0,
|
||||
},
|
||||
refillPayload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||
expectedVisibleStockBeforeRefill: 10,
|
||||
expectedVisibleStockBeforeRefill: 4,
|
||||
expectedQuantityAdded: 10,
|
||||
expectedResponsePacksAdded: 1,
|
||||
expectedPackCount: 2,
|
||||
expectedLooseTablets: 0,
|
||||
expectedTotalPills: 20,
|
||||
expectedTotalPills: 14,
|
||||
expectedPersistedTotalPills: null,
|
||||
expectedStockAdjustment: 0,
|
||||
expectedStockAdjustment: -6,
|
||||
},
|
||||
{
|
||||
name: "liquid_container",
|
||||
@@ -3408,17 +3408,17 @@ describe("E2E Tests with Real Routes", () => {
|
||||
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
|
||||
},
|
||||
refillPayload: { packsAdded: 1, loosePillsAdded: 0 },
|
||||
expectedVisibleStockBeforeRefill: 10,
|
||||
expectedVisibleStockBeforeRefill: 4,
|
||||
expectedQuantityAdded: 100,
|
||||
expectedResponsePacksAdded: 1,
|
||||
expectedAmountPerPackage: 100,
|
||||
expectedPackCount: 2,
|
||||
expectedLooseTablets: 110,
|
||||
expectedTotalPills: 110,
|
||||
expectedPersistedTotalPills: 110,
|
||||
expectedLooseTablets: 104,
|
||||
expectedTotalPills: 104,
|
||||
expectedPersistedTotalPills: 104,
|
||||
expectedStockAdjustment: 0,
|
||||
},
|
||||
])("should refill from the persisted stock baseline after prior consumption for $name", async ({
|
||||
])("should refill from current visible stock after prior consumption for $name", async ({
|
||||
payload,
|
||||
refillPayload,
|
||||
expectedVisibleStockBeforeRefill,
|
||||
|
||||
@@ -1,715 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
mockedEnv,
|
||||
createNotificationActionContextMock,
|
||||
storeNotificationActionGroupNtfyMessageIdMock,
|
||||
sendPushNotificationMock,
|
||||
} = vi.hoisted(() => ({
|
||||
mockedEnv: {
|
||||
PUBLIC_APP_URL: undefined as string | undefined,
|
||||
CORS_ORIGINS: "http://localhost:5173" as string,
|
||||
},
|
||||
createNotificationActionContextMock: vi.fn(),
|
||||
storeNotificationActionGroupNtfyMessageIdMock: vi.fn(),
|
||||
sendPushNotificationMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: () => false,
|
||||
readFileSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../db/path-utils.js", () => ({
|
||||
getDataDir: () => "/tmp",
|
||||
}));
|
||||
|
||||
vi.mock("../db/client.js", () => ({
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
},
|
||||
migrationsReady: Promise.resolve(),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
|
||||
|
||||
vi.mock("../services/notification-actions-service.js", () => ({
|
||||
createNotificationActionContext: createNotificationActionContextMock,
|
||||
storeNotificationActionGroupNtfyMessageId: storeNotificationActionGroupNtfyMessageIdMock,
|
||||
}));
|
||||
|
||||
vi.mock("../services/notifications/delivery.js", () => ({
|
||||
getSmtpConfig: vi.fn(() => null),
|
||||
sendEmailNotification: vi.fn(),
|
||||
sendPushNotification: sendPushNotificationMock,
|
||||
}));
|
||||
|
||||
vi.mock("../services/notifications/state.js", () => ({
|
||||
updateReminderSentTime: vi.fn(),
|
||||
updateUserReminderSentTime: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../utils/scheduler-utils.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../utils/scheduler-utils.js")>("../utils/scheduler-utils.js");
|
||||
const candidate = {
|
||||
medName: "Calcium",
|
||||
intakeTime: new Date("2026-01-05T11:15:00.000Z"),
|
||||
intakeTimeStr: "11:15",
|
||||
usage: 1,
|
||||
takenBy: null,
|
||||
pillWeightMg: null,
|
||||
doseUnit: "mg",
|
||||
};
|
||||
|
||||
return {
|
||||
...actual,
|
||||
getEffectiveTimezone: () => Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
getDateLocale: () => "en-US",
|
||||
parseTakenByJson: () => [],
|
||||
parseIntakesJson: () => [
|
||||
{
|
||||
usage: 1,
|
||||
every: 1,
|
||||
start: "2026-01-05T10:45:00.000Z",
|
||||
takenBy: null,
|
||||
intakeRemindersEnabled: true,
|
||||
},
|
||||
],
|
||||
getTodaysIntakes: () => [candidate],
|
||||
getUpcomingIntakes: () => [candidate],
|
||||
};
|
||||
});
|
||||
|
||||
import { db } from "../db/client.js";
|
||||
import { checkAndSendIntakeRemindersForUser } from "../services/intake-reminder-scheduler.js";
|
||||
|
||||
function createLogger() {
|
||||
return {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function mockSelectWhere<T>(result: T) {
|
||||
return {
|
||||
from: () => ({
|
||||
where: async () => result,
|
||||
}),
|
||||
} as never;
|
||||
}
|
||||
|
||||
describe("intake reminder scheduler action wiring", () => {
|
||||
const mockedDb = vi.mocked(db);
|
||||
let originalTz: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date(2026, 0, 5, 10, 30, 0));
|
||||
originalTz = process.env.TZ;
|
||||
process.env.TZ = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
mockedEnv.PUBLIC_APP_URL = undefined;
|
||||
mockedEnv.CORS_ORIGINS = "http://localhost:5173";
|
||||
createNotificationActionContextMock.mockReset();
|
||||
storeNotificationActionGroupNtfyMessageIdMock.mockReset();
|
||||
sendPushNotificationMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
if (originalTz === undefined) {
|
||||
delete process.env.TZ;
|
||||
} else {
|
||||
process.env.TZ = originalTz;
|
||||
}
|
||||
});
|
||||
|
||||
it("attaches action context to push notifications when PUBLIC_APP_URL is configured", async () => {
|
||||
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
|
||||
|
||||
const selectMock = vi.mocked(mockedDb.select);
|
||||
selectMock
|
||||
.mockImplementationOnce(() => mockSelectWhere([{ username: "push-user" }]))
|
||||
.mockImplementationOnce(() =>
|
||||
mockSelectWhere([
|
||||
{
|
||||
id: 7,
|
||||
userId: 11,
|
||||
name: "Calcium",
|
||||
genericName: null,
|
||||
takenByJson: null,
|
||||
packageType: "blister",
|
||||
medicationForm: "tablet",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: 0,
|
||||
pillWeightMg: null,
|
||||
doseUnit: "mg",
|
||||
isObsolete: false,
|
||||
intakeRemindersEnabled: true,
|
||||
intakesJson: "[]",
|
||||
usageJson: "[]",
|
||||
everyJson: "[]",
|
||||
startJson: "[]",
|
||||
},
|
||||
])
|
||||
)
|
||||
.mockImplementationOnce(() => mockSelectWhere([]));
|
||||
|
||||
createNotificationActionContextMock.mockResolvedValue({
|
||||
groupId: 41,
|
||||
actions: [
|
||||
{
|
||||
kind: "taken",
|
||||
label: "Taken",
|
||||
url: "https://app.example.com/api/notification-actions/taken",
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
respondUrl: "https://app.example.com/api/notification-actions/respond",
|
||||
viewUrl: "https://app.example.com/?date=2026-01-05",
|
||||
sequenceId: "medassist-sequence",
|
||||
});
|
||||
sendPushNotificationMock.mockResolvedValue({ success: true, providerMessageId: "ntfy-msg-1" });
|
||||
|
||||
const logger = createLogger();
|
||||
|
||||
await checkAndSendIntakeRemindersForUser(
|
||||
{
|
||||
userId: 11,
|
||||
language: "en",
|
||||
stockCalculationMode: "manual",
|
||||
emailEnabled: false,
|
||||
notificationEmail: null,
|
||||
emailIntakeReminders: false,
|
||||
shoutrrrEnabled: true,
|
||||
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
|
||||
shoutrrrIntakeReminders: true,
|
||||
repeatRemindersEnabled: false,
|
||||
} as never,
|
||||
logger as never
|
||||
);
|
||||
|
||||
expect(createNotificationActionContextMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 11,
|
||||
publicAppUrl: "https://app.example.com",
|
||||
language: "en",
|
||||
actionMode: "full",
|
||||
doseIds: [expect.stringMatching(/^7-0-/)],
|
||||
})
|
||||
);
|
||||
expect(sendPushNotificationMock).toHaveBeenCalledWith(
|
||||
"ntfy://ntfy.sh/medassist",
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
actions: [
|
||||
{
|
||||
kind: "taken",
|
||||
label: "Taken",
|
||||
url: "https://app.example.com/api/notification-actions/taken",
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
respondUrl: "https://app.example.com/api/notification-actions/respond",
|
||||
viewUrl: "https://app.example.com/?date=2026-01-05",
|
||||
clickUrl: "https://app.example.com/api/notification-actions/respond",
|
||||
sequenceId: "medassist-sequence",
|
||||
tags: ["pill"],
|
||||
priority: 3,
|
||||
})
|
||||
);
|
||||
expect(storeNotificationActionGroupNtfyMessageIdMock).toHaveBeenCalledWith(41, "ntfy-msg-1");
|
||||
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Notification action context ready"));
|
||||
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Sending push reminder"));
|
||||
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Push delivered"));
|
||||
});
|
||||
|
||||
it("uses view-only actions for grouped intake reminders", async () => {
|
||||
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
|
||||
|
||||
const selectMock = vi.mocked(mockedDb.select);
|
||||
selectMock
|
||||
.mockImplementationOnce(() => mockSelectWhere([{ username: "grouped-user" }]))
|
||||
.mockImplementationOnce(() =>
|
||||
mockSelectWhere([
|
||||
{
|
||||
id: 7,
|
||||
userId: 13,
|
||||
name: "Calcium",
|
||||
genericName: null,
|
||||
takenByJson: null,
|
||||
packageType: "blister",
|
||||
medicationForm: "tablet",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: 0,
|
||||
pillWeightMg: null,
|
||||
doseUnit: "mg",
|
||||
isObsolete: false,
|
||||
intakeRemindersEnabled: true,
|
||||
intakesJson: "[]",
|
||||
usageJson: "[]",
|
||||
everyJson: "[]",
|
||||
startJson: "[]",
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
userId: 13,
|
||||
name: "Vitamin D",
|
||||
genericName: null,
|
||||
takenByJson: null,
|
||||
packageType: "blister",
|
||||
medicationForm: "tablet",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: 0,
|
||||
pillWeightMg: null,
|
||||
doseUnit: "mg",
|
||||
isObsolete: false,
|
||||
intakeRemindersEnabled: true,
|
||||
intakesJson: "[]",
|
||||
usageJson: "[]",
|
||||
everyJson: "[]",
|
||||
startJson: "[]",
|
||||
},
|
||||
])
|
||||
)
|
||||
.mockImplementationOnce(() => mockSelectWhere([]));
|
||||
|
||||
createNotificationActionContextMock.mockResolvedValue({
|
||||
actions: [
|
||||
{
|
||||
kind: "view",
|
||||
label: "View",
|
||||
url: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000",
|
||||
method: "GET",
|
||||
},
|
||||
],
|
||||
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000",
|
||||
});
|
||||
sendPushNotificationMock.mockResolvedValue({ success: true });
|
||||
|
||||
const logger = createLogger();
|
||||
|
||||
await checkAndSendIntakeRemindersForUser(
|
||||
{
|
||||
userId: 13,
|
||||
language: "en",
|
||||
stockCalculationMode: "manual",
|
||||
emailEnabled: false,
|
||||
notificationEmail: null,
|
||||
emailIntakeReminders: false,
|
||||
shoutrrrEnabled: true,
|
||||
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
|
||||
shoutrrrIntakeReminders: true,
|
||||
repeatRemindersEnabled: false,
|
||||
} as never,
|
||||
logger as never
|
||||
);
|
||||
|
||||
expect(createNotificationActionContextMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 13,
|
||||
publicAppUrl: "https://app.example.com",
|
||||
language: "en",
|
||||
actionMode: "view-only",
|
||||
doseIds: [expect.stringMatching(/^7-0-/), expect.stringMatching(/^8-0-/)],
|
||||
})
|
||||
);
|
||||
expect(sendPushNotificationMock).toHaveBeenCalledWith(
|
||||
"ntfy://ntfy.sh/medassist",
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
actions: [
|
||||
{
|
||||
kind: "view",
|
||||
label: "View",
|
||||
url: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000",
|
||||
method: "GET",
|
||||
},
|
||||
],
|
||||
respondUrl: undefined,
|
||||
viewUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000",
|
||||
clickUrl: "https://app.example.com/dashboard?day=2026-01-05&dose=7-0-1736075700000",
|
||||
sequenceId: undefined,
|
||||
tags: ["pill"],
|
||||
priority: 3,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("sends push notifications without actions when PUBLIC_APP_URL is missing", async () => {
|
||||
createNotificationActionContextMock.mockResolvedValue(null);
|
||||
|
||||
const selectMock = vi.mocked(mockedDb.select);
|
||||
selectMock
|
||||
.mockImplementationOnce(() => mockSelectWhere([{ username: "pushless-user" }]))
|
||||
.mockImplementationOnce(() =>
|
||||
mockSelectWhere([
|
||||
{
|
||||
id: 7,
|
||||
userId: 12,
|
||||
name: "Calcium",
|
||||
genericName: null,
|
||||
takenByJson: null,
|
||||
packageType: "blister",
|
||||
medicationForm: "tablet",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: 0,
|
||||
pillWeightMg: null,
|
||||
doseUnit: "mg",
|
||||
isObsolete: false,
|
||||
intakeRemindersEnabled: true,
|
||||
intakesJson: "[]",
|
||||
usageJson: "[]",
|
||||
everyJson: "[]",
|
||||
startJson: "[]",
|
||||
},
|
||||
])
|
||||
)
|
||||
.mockImplementationOnce(() => mockSelectWhere([]));
|
||||
|
||||
sendPushNotificationMock.mockResolvedValue({ success: true });
|
||||
|
||||
const logger = createLogger();
|
||||
|
||||
await checkAndSendIntakeRemindersForUser(
|
||||
{
|
||||
userId: 12,
|
||||
language: "en",
|
||||
stockCalculationMode: "manual",
|
||||
emailEnabled: false,
|
||||
notificationEmail: null,
|
||||
emailIntakeReminders: false,
|
||||
shoutrrrEnabled: true,
|
||||
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
|
||||
shoutrrrIntakeReminders: true,
|
||||
repeatRemindersEnabled: false,
|
||||
} as never,
|
||||
logger as never
|
||||
);
|
||||
|
||||
expect(createNotificationActionContextMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId: 12,
|
||||
publicAppUrl: undefined,
|
||||
})
|
||||
);
|
||||
expect(sendPushNotificationMock).toHaveBeenCalledWith(
|
||||
"ntfy://ntfy.sh/medassist",
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
actions: undefined,
|
||||
respondUrl: undefined,
|
||||
viewUrl: undefined,
|
||||
clickUrl: undefined,
|
||||
tags: ["pill"],
|
||||
priority: 3,
|
||||
})
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("No reachable public app URL configured; sending intake reminders without actions")
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to push delivery without actions when action context generation fails", async () => {
|
||||
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
|
||||
|
||||
const selectMock = vi.mocked(mockedDb.select);
|
||||
selectMock
|
||||
.mockImplementationOnce(() => mockSelectWhere([{ username: "context-failure-user" }]))
|
||||
.mockImplementationOnce(() =>
|
||||
mockSelectWhere([
|
||||
{
|
||||
id: 7,
|
||||
userId: 15,
|
||||
name: "Calcium",
|
||||
genericName: null,
|
||||
takenByJson: null,
|
||||
packageType: "blister",
|
||||
medicationForm: "tablet",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: 0,
|
||||
pillWeightMg: null,
|
||||
doseUnit: "mg",
|
||||
isObsolete: false,
|
||||
intakeRemindersEnabled: true,
|
||||
intakesJson: "[]",
|
||||
usageJson: "[]",
|
||||
everyJson: "[]",
|
||||
startJson: "[]",
|
||||
},
|
||||
])
|
||||
)
|
||||
.mockImplementationOnce(() => mockSelectWhere([]));
|
||||
|
||||
createNotificationActionContextMock.mockRejectedValue(new Error("action context write failed"));
|
||||
sendPushNotificationMock.mockResolvedValue({ success: true });
|
||||
|
||||
const logger = createLogger();
|
||||
|
||||
await checkAndSendIntakeRemindersForUser(
|
||||
{
|
||||
userId: 15,
|
||||
language: "en",
|
||||
stockCalculationMode: "manual",
|
||||
emailEnabled: false,
|
||||
notificationEmail: null,
|
||||
emailIntakeReminders: false,
|
||||
shoutrrrEnabled: true,
|
||||
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
|
||||
shoutrrrIntakeReminders: true,
|
||||
repeatRemindersEnabled: false,
|
||||
} as never,
|
||||
logger as never
|
||||
);
|
||||
|
||||
expect(sendPushNotificationMock).toHaveBeenCalledWith(
|
||||
"ntfy://ntfy.sh/medassist",
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
actions: undefined,
|
||||
respondUrl: undefined,
|
||||
viewUrl: undefined,
|
||||
clickUrl: undefined,
|
||||
})
|
||||
);
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("Notification action context failed"));
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Sending intake reminders without actions after action context failure")
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Sending push reminder"));
|
||||
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Push delivered"));
|
||||
});
|
||||
|
||||
it("logs enriched push delivery failures with action context metadata", async () => {
|
||||
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
|
||||
|
||||
const selectMock = vi.mocked(mockedDb.select);
|
||||
selectMock
|
||||
.mockImplementationOnce(() => mockSelectWhere([{ username: "push-failure-user" }]))
|
||||
.mockImplementationOnce(() =>
|
||||
mockSelectWhere([
|
||||
{
|
||||
id: 7,
|
||||
userId: 16,
|
||||
name: "Calcium",
|
||||
genericName: null,
|
||||
takenByJson: null,
|
||||
packageType: "blister",
|
||||
medicationForm: "tablet",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: 0,
|
||||
pillWeightMg: null,
|
||||
doseUnit: "mg",
|
||||
isObsolete: false,
|
||||
intakeRemindersEnabled: true,
|
||||
intakesJson: "[]",
|
||||
usageJson: "[]",
|
||||
everyJson: "[]",
|
||||
startJson: "[]",
|
||||
},
|
||||
])
|
||||
)
|
||||
.mockImplementationOnce(() => mockSelectWhere([]));
|
||||
|
||||
createNotificationActionContextMock.mockResolvedValue({
|
||||
groupId: 52,
|
||||
actions: [
|
||||
{
|
||||
kind: "taken",
|
||||
label: "Taken",
|
||||
url: "https://app.example.com/api/notification-actions/taken",
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
respondUrl: "https://app.example.com/api/notification-actions/respond",
|
||||
viewUrl: "https://app.example.com/?date=2026-01-05",
|
||||
sequenceId: "medassist-sequence",
|
||||
});
|
||||
sendPushNotificationMock.mockResolvedValue({ success: false, error: "HTTP 500: upstream down" });
|
||||
|
||||
const logger = createLogger();
|
||||
|
||||
await checkAndSendIntakeRemindersForUser(
|
||||
{
|
||||
userId: 16,
|
||||
language: "en",
|
||||
stockCalculationMode: "manual",
|
||||
emailEnabled: false,
|
||||
notificationEmail: null,
|
||||
emailIntakeReminders: false,
|
||||
shoutrrrEnabled: true,
|
||||
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
|
||||
shoutrrrIntakeReminders: true,
|
||||
repeatRemindersEnabled: false,
|
||||
} as never,
|
||||
logger as never
|
||||
);
|
||||
|
||||
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Notification action context ready"));
|
||||
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Sending push reminder"));
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("Push delivery failed"));
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("provider=ntfy"));
|
||||
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("actionMode=full"));
|
||||
expect(storeNotificationActionGroupNtfyMessageIdMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("warns but keeps reminder flow alive when ntfy message id persistence fails", async () => {
|
||||
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
|
||||
|
||||
const selectMock = vi.mocked(mockedDb.select);
|
||||
selectMock
|
||||
.mockImplementationOnce(() => mockSelectWhere([{ username: "persist-warning-user" }]))
|
||||
.mockImplementationOnce(() =>
|
||||
mockSelectWhere([
|
||||
{
|
||||
id: 7,
|
||||
userId: 17,
|
||||
name: "Calcium",
|
||||
genericName: null,
|
||||
takenByJson: null,
|
||||
packageType: "blister",
|
||||
medicationForm: "tablet",
|
||||
packCount: 1,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: 0,
|
||||
pillWeightMg: null,
|
||||
doseUnit: "mg",
|
||||
isObsolete: false,
|
||||
intakeRemindersEnabled: true,
|
||||
intakesJson: "[]",
|
||||
usageJson: "[]",
|
||||
everyJson: "[]",
|
||||
startJson: "[]",
|
||||
},
|
||||
])
|
||||
)
|
||||
.mockImplementationOnce(() => mockSelectWhere([]));
|
||||
|
||||
createNotificationActionContextMock.mockResolvedValue({
|
||||
groupId: 77,
|
||||
actions: [
|
||||
{
|
||||
kind: "taken",
|
||||
label: "Taken",
|
||||
url: "https://app.example.com/api/notification-actions/taken",
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
respondUrl: "https://app.example.com/api/notification-actions/respond",
|
||||
viewUrl: "https://app.example.com/?date=2026-01-05",
|
||||
sequenceId: "medassist-sequence",
|
||||
});
|
||||
sendPushNotificationMock.mockResolvedValue({ success: true, providerMessageId: "ntfy-msg-77" });
|
||||
storeNotificationActionGroupNtfyMessageIdMock.mockRejectedValue(new Error("db write failed"));
|
||||
|
||||
const logger = createLogger();
|
||||
|
||||
await checkAndSendIntakeRemindersForUser(
|
||||
{
|
||||
userId: 17,
|
||||
language: "en",
|
||||
stockCalculationMode: "manual",
|
||||
emailEnabled: false,
|
||||
notificationEmail: null,
|
||||
emailIntakeReminders: false,
|
||||
shoutrrrEnabled: true,
|
||||
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
|
||||
shoutrrrIntakeReminders: true,
|
||||
repeatRemindersEnabled: false,
|
||||
} as never,
|
||||
logger as never
|
||||
);
|
||||
|
||||
expect(storeNotificationActionGroupNtfyMessageIdMock).toHaveBeenCalledWith(77, "ntfy-msg-77");
|
||||
expect(logger.warn).toHaveBeenCalledWith(expect.stringContaining("Failed to store ntfy message id"));
|
||||
expect(logger.info).toHaveBeenCalledWith(expect.stringContaining("Push delivered"));
|
||||
});
|
||||
|
||||
it("does not send intake reminders for reminder-enabled medications with empty stock", async () => {
|
||||
const selectMock = vi.mocked(mockedDb.select);
|
||||
selectMock
|
||||
.mockImplementationOnce(() => mockSelectWhere([{ username: "empty-stock-user" }]))
|
||||
.mockImplementationOnce(() =>
|
||||
mockSelectWhere([
|
||||
{
|
||||
id: 7,
|
||||
userId: 14,
|
||||
name: "Calcium",
|
||||
genericName: null,
|
||||
takenByJson: null,
|
||||
packageType: "blister",
|
||||
medicationForm: "tablet",
|
||||
packCount: 0,
|
||||
blistersPerPack: 1,
|
||||
pillsPerBlister: 10,
|
||||
looseTablets: 0,
|
||||
stockAdjustment: 0,
|
||||
pillWeightMg: null,
|
||||
doseUnit: "mg",
|
||||
isObsolete: false,
|
||||
intakeRemindersEnabled: true,
|
||||
intakesJson: "[]",
|
||||
usageJson: "[]",
|
||||
everyJson: "[]",
|
||||
startJson: "[]",
|
||||
},
|
||||
])
|
||||
)
|
||||
.mockImplementationOnce(() => mockSelectWhere([]));
|
||||
|
||||
const logger = createLogger();
|
||||
|
||||
await checkAndSendIntakeRemindersForUser(
|
||||
{
|
||||
userId: 14,
|
||||
language: "en",
|
||||
stockCalculationMode: "manual",
|
||||
emailEnabled: false,
|
||||
notificationEmail: null,
|
||||
emailIntakeReminders: false,
|
||||
shoutrrrEnabled: true,
|
||||
shoutrrrUrl: "ntfy://ntfy.sh/medassist",
|
||||
shoutrrrIntakeReminders: true,
|
||||
repeatRemindersEnabled: false,
|
||||
} as never,
|
||||
logger as never
|
||||
);
|
||||
|
||||
expect(createNotificationActionContextMock).not.toHaveBeenCalled();
|
||||
expect(sendPushNotificationMock).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("Skipping reminder-enabled medications with empty stock")
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
expect.stringContaining("No reminder-eligible medications with stock remaining")
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,587 +0,0 @@
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import Fastify from "fastify";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runAlterMigrations } from "../db/db-utils.js";
|
||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||
|
||||
const { testClient, testDb, mockedEnv, fetchMock, mockLogger } = vi.hoisted(() => {
|
||||
const { createClient } = require("@libsql/client");
|
||||
const { drizzle } = require("drizzle-orm/libsql");
|
||||
const client = createClient({ url: ":memory:" });
|
||||
const db = drizzle(client);
|
||||
const logger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
trace: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
silent: vi.fn(),
|
||||
level: "info",
|
||||
child: vi.fn(),
|
||||
};
|
||||
logger.child.mockImplementation(() => logger);
|
||||
|
||||
return {
|
||||
testClient: client,
|
||||
testDb: db,
|
||||
fetchMock: vi.fn(),
|
||||
mockLogger: logger,
|
||||
mockedEnv: {
|
||||
AUTH_ENABLED: false,
|
||||
OIDC_ENABLED: false,
|
||||
OIDC_PROVIDER_NAME: "SSO",
|
||||
NODE_ENV: "test",
|
||||
LOG_LEVEL: "silent",
|
||||
PORT: 3000,
|
||||
CORS_ORIGINS: "*",
|
||||
PUBLIC_APP_URL: "https://app.example.com",
|
||||
OPENAPI_DOCS_ENABLED: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
global.fetch = fetchMock;
|
||||
|
||||
vi.mock("../db/client.js", () => ({
|
||||
db: testDb,
|
||||
migrationsReady: Promise.resolve(),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
|
||||
|
||||
const { notificationActionRoutes } = await import("../routes/notification-actions.js");
|
||||
const { createNotificationActionContext } = await import("../services/notification-actions-service.js");
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||
|
||||
function extractToken(url: string): string {
|
||||
return url.split("/").at(-1) ?? "";
|
||||
}
|
||||
|
||||
async function clearTables() {
|
||||
await testClient.execute("DELETE FROM dose_tracking");
|
||||
await testClient.execute("DELETE FROM notification_action_tokens");
|
||||
await testClient.execute("DELETE FROM notification_action_groups");
|
||||
await testClient.execute("DELETE FROM medications");
|
||||
await testClient.execute("DELETE FROM user_settings");
|
||||
await testClient.execute("DELETE FROM users");
|
||||
}
|
||||
|
||||
async function createUser(username: string) {
|
||||
const result = await testClient.execute({
|
||||
sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id",
|
||||
args: [username],
|
||||
});
|
||||
|
||||
return Number(result.rows[0].id);
|
||||
}
|
||||
|
||||
async function insertMedication(options: { id: number; userId: number; packCount?: number; looseTablets?: number }) {
|
||||
const start = "2026-01-05T08:00:00.000Z";
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO medications (
|
||||
id, user_id, name, taken_by_json, medication_form, package_type,
|
||||
pack_count, blisters_per_pack, pills_per_blister, loose_tablets, stock_adjustment,
|
||||
usage_json, every_json, start_json, intakes_json, intake_reminders_enabled
|
||||
) VALUES (?, ?, 'Route Medication', '[]', 'tablet', 'blister', ?, 1, 10, ?, 0, ?, ?, ?, ?, 1)`,
|
||||
args: [
|
||||
options.id,
|
||||
options.userId,
|
||||
options.packCount ?? 1,
|
||||
options.looseTablets ?? 0,
|
||||
JSON.stringify([1]),
|
||||
JSON.stringify([1]),
|
||||
JSON.stringify([start]),
|
||||
JSON.stringify([{ usage: 1, every: 1, start, takenBy: null, intakeRemindersEnabled: true }]),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function insertUserSettings(
|
||||
userId: number,
|
||||
stockCalculationMode: "automatic" | "manual" = "automatic",
|
||||
overrides: { shoutrrrEnabled?: boolean; shoutrrrUrl?: string | null } = {}
|
||||
) {
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO user_settings (user_id, stock_calculation_mode, shoutrrr_enabled, shoutrrr_url) VALUES (?, ?, ?, ?)",
|
||||
args: [userId, stockCalculationMode, overrides.shoutrrrEnabled ? 1 : 0, overrides.shoutrrrUrl ?? null],
|
||||
});
|
||||
}
|
||||
|
||||
async function seedContext(options: { userId: number; doseId: string }) {
|
||||
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
|
||||
const context = await createNotificationActionContext({
|
||||
userId: options.userId,
|
||||
title: "Reminder",
|
||||
message: "Take your medication now",
|
||||
doseIds: [options.doseId],
|
||||
scheduledFor,
|
||||
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
|
||||
language: "en",
|
||||
});
|
||||
|
||||
return {
|
||||
respondToken: extractToken(context!.respondUrl!),
|
||||
takenToken: extractToken(context!.actions.find((action) => action.kind === "taken")!.url),
|
||||
skipToken: extractToken(context!.actions.find((action) => action.kind === "skip")!.url),
|
||||
context: context!,
|
||||
};
|
||||
}
|
||||
|
||||
describe("notification action routes", () => {
|
||||
let app: Awaited<ReturnType<typeof Fastify>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
await migrate(testDb, { migrationsFolder });
|
||||
await runAlterMigrations(testClient);
|
||||
app = Fastify({ loggerInstance: mockLogger, disableRequestLogging: true, ajv: documentationSchemaAjv });
|
||||
await app.register(notificationActionRoutes);
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
testClient.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearTables();
|
||||
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
|
||||
mockedEnv.NODE_ENV = "test";
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
mockLogger.info.mockClear();
|
||||
mockLogger.warn.mockClear();
|
||||
mockLogger.error.mockClear();
|
||||
mockLogger.debug.mockClear();
|
||||
mockLogger.trace.mockClear();
|
||||
mockLogger.fatal.mockClear();
|
||||
mockLogger.child.mockClear();
|
||||
});
|
||||
|
||||
it("renders HTML for respond tokens without mutating state", async () => {
|
||||
const userId = await createUser("notification-route-get");
|
||||
const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/notification-actions/${respondToken}`,
|
||||
headers: { accept: "text/html" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers["content-type"]).toContain("text/html");
|
||||
expect(response.body).toContain("Respond to reminder");
|
||||
expect(response.body).toContain("Take your medication now");
|
||||
|
||||
const rows = await testClient.execute({
|
||||
sql: `SELECT g.resolved_action, t.used_at
|
||||
FROM notification_action_groups g
|
||||
INNER JOIN notification_action_tokens t ON t.group_id = g.id
|
||||
WHERE t.kind = 'respond'`,
|
||||
});
|
||||
expect(rows.rows).toEqual([expect.objectContaining({ resolved_action: null, used_at: null })]);
|
||||
});
|
||||
|
||||
it("returns the expected GET behavior for missing, non-respond, and expired tokens", async () => {
|
||||
const missing = await app.inject({ method: "GET", url: "/notification-actions/missing-token" });
|
||||
expect(missing.statusCode).toBe(404);
|
||||
|
||||
const userId = await createUser("notification-route-errors");
|
||||
const { respondToken, takenToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
|
||||
const nonRespond = await app.inject({
|
||||
method: "GET",
|
||||
url: `/notification-actions/${takenToken}`,
|
||||
headers: { accept: "text/html" },
|
||||
});
|
||||
expect(nonRespond.statusCode).toBe(405);
|
||||
expect(nonRespond.json()).toEqual({ error: "Direct GET is only available for respond actions" });
|
||||
|
||||
await testClient.execute({
|
||||
sql: "UPDATE notification_action_groups SET expires_at = ?",
|
||||
args: [new Date(0)],
|
||||
});
|
||||
|
||||
const expired = await app.inject({ method: "GET", url: `/notification-actions/${respondToken}` });
|
||||
expect(expired.statusCode).toBe(410);
|
||||
expect(expired.json()).toEqual({ error: "Notification action has expired" });
|
||||
});
|
||||
|
||||
it("shows an already-processed HTML state for resolved respond tokens", async () => {
|
||||
const userId = await createUser("notification-route-resolved");
|
||||
const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
await testClient.execute({
|
||||
sql: "UPDATE notification_action_groups SET resolved_action = 'skip', resolved_at = ?",
|
||||
args: [new Date()],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/notification-actions/${respondToken}`,
|
||||
headers: { accept: "text/html" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toContain("Already processed");
|
||||
expect(response.body).toContain(
|
||||
"This intake is already marked as skipped. If you want to mark it as taken instead, open MedAssist and do that there."
|
||||
);
|
||||
});
|
||||
|
||||
it("skips doses through a respond token and returns friendly success for already-resolved follow-up actions", async () => {
|
||||
const userId = await createUser("notification-route-skip");
|
||||
const { respondToken, takenToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/notification-actions/${respondToken}`,
|
||||
payload: { action: "skip" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true, action: "skip" });
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId,
|
||||
groupId: expect.any(Number),
|
||||
tokenKind: "respond",
|
||||
doseCount: 1,
|
||||
hasViewUrl: true,
|
||||
requestedAction: "skip",
|
||||
}),
|
||||
"[NotificationActions] Recorded notification action"
|
||||
);
|
||||
|
||||
const dismissedRow = await testClient.execute({
|
||||
sql: "SELECT dismissed, taken_at FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||
args: [userId, "5-0-1736064000000"],
|
||||
});
|
||||
expect(dismissedRow.rows).toEqual([expect.objectContaining({ dismissed: 1, taken_at: 0 })]);
|
||||
|
||||
const groupRow = await testClient.execute({
|
||||
sql: "SELECT resolved_action FROM notification_action_groups",
|
||||
});
|
||||
expect(groupRow.rows).toEqual([expect.objectContaining({ resolved_action: "skip" })]);
|
||||
|
||||
const conflict = await app.inject({
|
||||
method: "POST",
|
||||
url: `/notification-actions/${takenToken}`,
|
||||
});
|
||||
|
||||
expect(conflict.statusCode).toBe(200);
|
||||
expect(conflict.json()).toEqual({
|
||||
success: true,
|
||||
action: "skip",
|
||||
alreadyProcessed: true,
|
||||
message: "This intake is already marked as skipped. Changes can only be made in MedAssist.",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps legacy dismiss respond actions working as a skip alias", async () => {
|
||||
const userId = await createUser("notification-route-dismiss-alias");
|
||||
const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/notification-actions/${respondToken}`,
|
||||
payload: { action: "dismiss" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true, action: "skip" });
|
||||
|
||||
const groupRow = await testClient.execute({
|
||||
sql: "SELECT resolved_action FROM notification_action_groups",
|
||||
});
|
||||
expect(groupRow.rows).toEqual([expect.objectContaining({ resolved_action: "skip" })]);
|
||||
});
|
||||
|
||||
it("returns an undo hint when a reminder was already taken before a follow-up skip action", async () => {
|
||||
const userId = await createUser("notification-route-taken-followup");
|
||||
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
|
||||
await insertUserSettings(userId, "automatic");
|
||||
const { takenToken, respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
|
||||
const firstResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/notification-actions/${takenToken}`,
|
||||
});
|
||||
|
||||
expect(firstResponse.statusCode).toBe(200);
|
||||
expect(firstResponse.json()).toEqual({ success: true, action: "taken" });
|
||||
|
||||
const followUpHtml = await app.inject({
|
||||
method: "GET",
|
||||
url: `/notification-actions/${respondToken}`,
|
||||
headers: { accept: "text/html" },
|
||||
});
|
||||
|
||||
expect(followUpHtml.statusCode).toBe(200);
|
||||
expect(followUpHtml.body).toContain(
|
||||
"This dose is already marked as taken. If you need to change it, open MedAssist and undo it there."
|
||||
);
|
||||
|
||||
const followUpJson = await app.inject({
|
||||
method: "POST",
|
||||
url: `/notification-actions/${respondToken}`,
|
||||
payload: { action: "skip" },
|
||||
});
|
||||
|
||||
expect(followUpJson.statusCode).toBe(200);
|
||||
expect(followUpJson.json()).toEqual({
|
||||
success: true,
|
||||
action: "taken",
|
||||
alreadyProcessed: true,
|
||||
message: "This dose is already marked as taken. Changes can only be made in MedAssist.",
|
||||
});
|
||||
});
|
||||
|
||||
it("replaces the original ntfy notification after a successful action with a view-only confirmation", async () => {
|
||||
const userId = await createUser("notification-route-ntfy-delete");
|
||||
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
|
||||
await insertUserSettings(userId, "automatic", {
|
||||
shoutrrrEnabled: true,
|
||||
shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist",
|
||||
});
|
||||
const { takenToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: "ntfy-msg-2" }) });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/notification-actions/${takenToken}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [targetUrl, requestInit] = fetchMock.mock.calls[0] ?? [];
|
||||
expect(targetUrl).toBe("https://ntfy.example.com/medassist");
|
||||
expect(requestInit).toEqual(
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: "Take your medication now\n\n✅ This dose was marked as taken.",
|
||||
redirect: "error",
|
||||
headers: expect.objectContaining({
|
||||
Authorization: expect.stringMatching(/^Basic /),
|
||||
"X-Sequence-ID": context.sequenceId,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const actionHeader = String((requestInit as { headers?: Record<string, string> }).headers?.Actions ?? "[]");
|
||||
expect(JSON.parse(actionHeader)).toEqual([
|
||||
{
|
||||
action: "view",
|
||||
label: "View",
|
||||
url: context.viewUrl,
|
||||
clear: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("replaces the original ntfy notification after a skip action with a view-only confirmation", async () => {
|
||||
const userId = await createUser("notification-route-ntfy-skip-delete");
|
||||
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
|
||||
await insertUserSettings(userId, "automatic", {
|
||||
shoutrrrEnabled: true,
|
||||
shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist",
|
||||
});
|
||||
const { skipToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, json: () => Promise.resolve({ id: "ntfy-msg-3" }) });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/notification-actions/${skipToken}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [targetUrl, requestInit] = fetchMock.mock.calls[0] ?? [];
|
||||
expect(targetUrl).toBe("https://ntfy.example.com/medassist");
|
||||
expect(requestInit).toEqual(
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: "Take your medication now\n\n⏭️ This intake was marked as skipped.",
|
||||
redirect: "error",
|
||||
headers: expect.objectContaining({
|
||||
Authorization: expect.stringMatching(/^Basic /),
|
||||
"X-Sequence-ID": context.sequenceId,
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when ntfy replacement, delete, and fallback clear all fail", async () => {
|
||||
const userId = await createUser("notification-route-ntfy-delete-warn");
|
||||
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
|
||||
await insertUserSettings(userId, "automatic", {
|
||||
shoutrrrEnabled: true,
|
||||
shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist",
|
||||
});
|
||||
const { takenToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
fetchMock.mockResolvedValueOnce({ ok: false, status: 500, text: () => Promise.resolve("publish failed") });
|
||||
fetchMock.mockResolvedValueOnce({ ok: false, status: 500, text: () => Promise.resolve("upstream down") });
|
||||
fetchMock.mockResolvedValueOnce({ ok: false, status: 404, text: () => Promise.resolve("not found") });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/notification-actions/${takenToken}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
expect(app.log.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requestedAction: "taken" }),
|
||||
expect.stringContaining("Failed to replace ntfy notification after resolved action")
|
||||
);
|
||||
expect(app.log.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requestedAction: "taken" }),
|
||||
expect.stringContaining("Failed to delete ntfy notification after resolved action")
|
||||
);
|
||||
expect(app.log.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requestedAction: "taken" }),
|
||||
expect.stringContaining("Failed to clear ntfy notification after delete fallback")
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to clear when ntfy replacement and delete both fail", async () => {
|
||||
const userId = await createUser("notification-route-ntfy-delete-clear-fallback");
|
||||
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
|
||||
await insertUserSettings(userId, "automatic", {
|
||||
shoutrrrEnabled: true,
|
||||
shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist",
|
||||
});
|
||||
const { takenToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
fetchMock.mockResolvedValueOnce({ ok: false, status: 500, text: () => Promise.resolve("publish failed") });
|
||||
fetchMock.mockResolvedValueOnce({ ok: false, status: 404, text: () => Promise.resolve("missing") });
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve("") });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/notification-actions/${takenToken}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(3);
|
||||
|
||||
const [clearUrl, clearInit] = fetchMock.mock.calls[2] ?? [];
|
||||
expect(clearUrl).toBe(`https://ntfy.example.com/medassist/${context.sequenceId}/clear`);
|
||||
expect(clearInit).toEqual(
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
headers: expect.objectContaining({ Authorization: expect.stringMatching(/^Basic /) }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("allows browser-origin CORS requests for public notification action tokens", async () => {
|
||||
const userId = await createUser("notification-route-cors");
|
||||
const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
|
||||
const preflight = await app.inject({
|
||||
method: "OPTIONS",
|
||||
url: `/notification-actions/${respondToken}?action=taken`,
|
||||
headers: {
|
||||
origin: "https://ntfy.danielvolz.org",
|
||||
"access-control-request-method": "POST",
|
||||
"access-control-request-headers": "content-type",
|
||||
},
|
||||
});
|
||||
|
||||
expect(preflight.statusCode).toBe(204);
|
||||
expect(preflight.headers["access-control-allow-origin"]).toBe("https://ntfy.danielvolz.org");
|
||||
expect(preflight.headers["access-control-allow-methods"]).toContain("POST");
|
||||
expect(preflight.headers["access-control-allow-headers"]).toContain("content-type");
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/notification-actions/${respondToken}`,
|
||||
headers: {
|
||||
origin: "https://ntfy.danielvolz.org",
|
||||
},
|
||||
payload: { action: "skip" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers["access-control-allow-origin"]).toBe("https://ntfy.danielvolz.org");
|
||||
});
|
||||
|
||||
it("accepts standard HTML form posts on respond pages", async () => {
|
||||
const userId = await createUser("notification-route-form-post");
|
||||
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
|
||||
await insertUserSettings(userId, "automatic");
|
||||
const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/notification-actions/${respondToken}?action=taken`,
|
||||
headers: {
|
||||
accept: "text/html",
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
payload: "",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers["content-type"]).toContain("text/html");
|
||||
expect(response.body).toContain("Action recorded");
|
||||
expect(response.body).toContain("The dose was marked as taken.");
|
||||
});
|
||||
|
||||
it("returns non-2xx for invalid, expired, and out-of-stock POST actions", async () => {
|
||||
const missing = await app.inject({ method: "POST", url: "/notification-actions/missing-token" });
|
||||
expect(missing.statusCode).toBe(404);
|
||||
|
||||
const expiredUserId = await createUser("notification-route-expired");
|
||||
const { respondToken } = await seedContext({ userId: expiredUserId, doseId: "5-0-1736064000000" });
|
||||
await testClient.execute({
|
||||
sql: "UPDATE notification_action_groups SET expires_at = ? WHERE user_id = ?",
|
||||
args: [new Date(0), expiredUserId],
|
||||
});
|
||||
|
||||
const expired = await app.inject({
|
||||
method: "POST",
|
||||
url: `/notification-actions/${respondToken}`,
|
||||
payload: { action: "skip" },
|
||||
});
|
||||
expect(expired.statusCode).toBe(410);
|
||||
|
||||
const userId = await createUser("notification-route-stock");
|
||||
await insertMedication({ id: 5, userId, packCount: 0, looseTablets: 0 });
|
||||
await insertUserSettings(userId, "automatic");
|
||||
const { takenToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
|
||||
const outOfStock = await app.inject({
|
||||
method: "POST",
|
||||
url: `/notification-actions/${takenToken}`,
|
||||
});
|
||||
expect(outOfStock.statusCode).toBe(409);
|
||||
expect(outOfStock.json()).toEqual({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId,
|
||||
groupId: expect.any(Number),
|
||||
tokenKind: "taken",
|
||||
doseCount: 1,
|
||||
hasViewUrl: true,
|
||||
requestedAction: "taken",
|
||||
failedDoseIndex: 0,
|
||||
code: "OUT_OF_STOCK",
|
||||
}),
|
||||
"[NotificationActions] Failed to record taken notification action"
|
||||
);
|
||||
|
||||
const state = await testClient.execute({
|
||||
sql: "SELECT resolved_action FROM notification_action_groups WHERE user_id = ?",
|
||||
args: [userId],
|
||||
});
|
||||
expect(state.rows).toEqual([expect.objectContaining({ resolved_action: null })]);
|
||||
});
|
||||
});
|
||||
@@ -117,7 +117,7 @@ describe("OIDC routes", () => {
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toBe("http://localhost:5173");
|
||||
expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_access_denied");
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
@@ -129,7 +129,7 @@ describe("OIDC routes", () => {
|
||||
const res = await app.inject({ method: "GET", url: "/auth/oidc/callback" });
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toBe("http://localhost:5173");
|
||||
expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_missing_params");
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
@@ -144,7 +144,7 @@ describe("OIDC routes", () => {
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(302);
|
||||
expect(res.headers.location).toBe("http://localhost:5173");
|
||||
expect(res.headers.location).toBe("http://localhost:5173/?error=oidc_state_mismatch");
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
|
||||
@@ -374,14 +374,14 @@ describe("Real route coverage: settings/export/report", () => {
|
||||
label: "Take",
|
||||
url: expect.stringMatching(/^https:\/\/app\.example\.com\/api\/notification-actions\//),
|
||||
method: "POST",
|
||||
clear: true,
|
||||
clear: false,
|
||||
},
|
||||
{
|
||||
action: "http",
|
||||
label: "Skip",
|
||||
url: expect.stringMatching(/^https:\/\/app\.example\.com\/api\/notification-actions\//),
|
||||
method: "POST",
|
||||
clear: true,
|
||||
clear: false,
|
||||
},
|
||||
{
|
||||
action: "view",
|
||||
@@ -632,11 +632,7 @@ describe("Real route coverage: settings/export/report", () => {
|
||||
expect(body[medId].dosesTaken).toBe(1);
|
||||
expect(body[medId].dosesSkipped).toBe(1);
|
||||
expect(body[medId].refills).toHaveLength(1);
|
||||
expect(body[medId].refills[0]).toMatchObject({
|
||||
packsAdded: 1,
|
||||
loosePillsAdded: 2,
|
||||
usedPrescription: true,
|
||||
});
|
||||
expect(body[medId].refills[0].quantityAdded).toBe(22);
|
||||
});
|
||||
|
||||
it("POST /medications/report-data filters dose counts by takenBy suffix when requested", async () => {
|
||||
|
||||
@@ -244,46 +244,6 @@ describe("Server Bootstrap", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("should allow browser preflight requests on public notification action routes", async () => {
|
||||
const origins = ["https://medtest.danielvolz.org"];
|
||||
|
||||
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||
await app.register(cors, {
|
||||
delegator: (request, callback) => {
|
||||
if (request.raw.url?.startsWith("/notification-actions/")) {
|
||||
callback(null, {
|
||||
origin: true,
|
||||
credentials: false,
|
||||
methods: ["GET", "HEAD", "POST", "OPTIONS"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
callback(null, { origin: origins, credentials: true });
|
||||
},
|
||||
});
|
||||
|
||||
app.post("/notification-actions/:token", async () => ({ ok: true }));
|
||||
await app.ready();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "OPTIONS",
|
||||
url: "/notification-actions/demo-token",
|
||||
headers: {
|
||||
origin: "https://ntfy.danielvolz.org",
|
||||
"access-control-request-method": "POST",
|
||||
"access-control-request-headers": "content-type",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(204);
|
||||
expect(response.headers["access-control-allow-origin"]).toBe("https://ntfy.danielvolz.org");
|
||||
expect(response.headers["access-control-allow-credentials"]).toBeUndefined();
|
||||
expect(response.headers["access-control-allow-methods"]).toContain("OPTIONS");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("should register cookie plugin", async () => {
|
||||
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||
|
||||
@@ -46,6 +46,5 @@ export const log = {
|
||||
export type ServiceLogger = {
|
||||
info: (msg: string) => void;
|
||||
debug: (msg: string) => void;
|
||||
warn: (msg: string) => void;
|
||||
error: (msg: string) => void;
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ Scope and behavior:
|
||||
|
||||
- These values are applied only when a user's settings are created for the first time.
|
||||
- After that, values stored in the database are used and take precedence.
|
||||
- Source of truth in code: [backend/src/routes/settings.ts](backend/src/routes/settings.ts).
|
||||
|
||||
## Email Defaults
|
||||
|
||||
@@ -46,6 +47,6 @@ Scope and behavior:
|
||||
|----------|---------|-------------|
|
||||
| `DEFAULT_LANGUAGE` | `en` | Default language (`en` or `de`). |
|
||||
| `DEFAULT_STOCK_CALCULATION_MODE` | `automatic` | Default stock mode (`automatic` or `manual`). |
|
||||
| `DEFAULT_SHARE_MEDICATION_OVERVIEW` | `false` | Show medication overview section on shared schedule links. |
|
||||
| `DEFAULT_SHARE_STOCK_STATUS` | `true` | Show stock status on shared schedule links. |
|
||||
| `DEFAULT_UPCOMING_TODAY_ONLY` | `false` | Show only today's upcoming doses by default. |
|
||||
| `DEFAULT_SHARE_SCHEDULE_TODAY_ONLY` | `false` | Show only today's schedule in shared view by default. |
|
||||
|
||||
Generated
+157
-157
@@ -8,22 +8,22 @@
|
||||
"name": "medassist-ng-frontend",
|
||||
"version": "1.23.0",
|
||||
"dependencies": {
|
||||
"i18next": "^26.1.0",
|
||||
"i18next": "^26.0.8",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.7",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"zod": "^4.4.3"
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-i18next": "^17.0.6",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"zod": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.15",
|
||||
"@biomejs/biome": "^2.4.14",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@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.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
@@ -31,7 +31,7 @@
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.12",
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
},
|
||||
@@ -179,9 +179,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/biome": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.15.tgz",
|
||||
"integrity": "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.14.tgz",
|
||||
"integrity": "sha512-TmAvxOEgrpLypzVGJ8FulIZnlyA9TxrO1hyqYrCz9r+bwma9xXxuLA5IuYnj55XQneFx460KjRbx6SWGLkg3bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"bin": {
|
||||
@@ -195,20 +195,20 @@
|
||||
"url": "https://opencollective.com/biome"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@biomejs/cli-darwin-arm64": "2.4.15",
|
||||
"@biomejs/cli-darwin-x64": "2.4.15",
|
||||
"@biomejs/cli-linux-arm64": "2.4.15",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.4.15",
|
||||
"@biomejs/cli-linux-x64": "2.4.15",
|
||||
"@biomejs/cli-linux-x64-musl": "2.4.15",
|
||||
"@biomejs/cli-win32-arm64": "2.4.15",
|
||||
"@biomejs/cli-win32-x64": "2.4.15"
|
||||
"@biomejs/cli-darwin-arm64": "2.4.14",
|
||||
"@biomejs/cli-darwin-x64": "2.4.14",
|
||||
"@biomejs/cli-linux-arm64": "2.4.14",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.4.14",
|
||||
"@biomejs/cli-linux-x64": "2.4.14",
|
||||
"@biomejs/cli-linux-x64-musl": "2.4.14",
|
||||
"@biomejs/cli-win32-arm64": "2.4.14",
|
||||
"@biomejs/cli-win32-x64": "2.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.15.tgz",
|
||||
"integrity": "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.14.tgz",
|
||||
"integrity": "sha512-XvgoE9XOawUOQPdmvs4J7wPhi/DLwSCGks3AlPJDmh34O0awRTqCED1HRcRDdpf1Zrp4us4MGOOdIxNpbqNF5Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -223,9 +223,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-x64": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.15.tgz",
|
||||
"integrity": "sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.14.tgz",
|
||||
"integrity": "sha512-jE7hKBCFhOx3uUh+ZkWBfOHxAcILPfhFplNkuID/eZeSTLHzfZzoZxW8fbqY9xXRnPi7jGNAf1iPVR+0yWsM/Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -240,9 +240,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.15.tgz",
|
||||
"integrity": "sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.14.tgz",
|
||||
"integrity": "sha512-2TELhZnW5RSLL063l9rc5xLpA0ZIw0Ccwy/0q384rvNAgFw3yI76bd59547yxowdQr5MNPET/xDLrLuvgSeeWQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -257,9 +257,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.15.tgz",
|
||||
"integrity": "sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.14.tgz",
|
||||
"integrity": "sha512-/z+6gqAqqUQTHazwStxSXKHg9b8UvqBmDFRp+c4wYbq2KXhELQDon9EoC9RpmQ8JWkqQx/lIUy/cs+MhzDZp6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -274,9 +274,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.15.tgz",
|
||||
"integrity": "sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.14.tgz",
|
||||
"integrity": "sha512-zHrlQZDBDUz4OLAraYpWKcnLS6HOewBFWYOzY91d1ZjdqZwibOyb6BEu6WuWLugyo0P3riCmsbV9UqV1cSXwQg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -291,9 +291,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.15.tgz",
|
||||
"integrity": "sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.14.tgz",
|
||||
"integrity": "sha512-R6BWgJdQOwW9ulJatuTVrQkjnODjqHZkKNOqb1sz++3Noe5LYd0i3PchnOBUCYAPHoPWHhjJqbdZlHEu0hpjdA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -308,9 +308,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-arm64": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.15.tgz",
|
||||
"integrity": "sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.14.tgz",
|
||||
"integrity": "sha512-M3EH5hqOI/F/FUA2u4xcLoUgmxd218mvuj/6JL7Hv2toQvr2/AdOvKSpGkoRuWFCtQPVa+ZqkEV3Q5xBA9+XSA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -325,9 +325,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-x64": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.15.tgz",
|
||||
"integrity": "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.14.tgz",
|
||||
"integrity": "sha512-WL0EG5qE+EAKomGXbf2g6VnSKJhTL3tXC0QRzWRwA5VpjxNYa6H4P7ZWfymbGE4IhZZQi1KXQ2R0YjwInmz2fA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -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.127.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.127.0.tgz",
|
||||
"integrity": "sha512-aIYXQBo4lCbO4z0R3FHeucQHpF46l2LbMdxRvqvuRuW2OxdnSkcng5B8+K12spgLDj93rtN3+J2Vac/TIO+ciQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -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.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==",
|
||||
"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.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==",
|
||||
"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.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==",
|
||||
"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.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==",
|
||||
"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.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==",
|
||||
"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.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==",
|
||||
"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.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==",
|
||||
"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.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==",
|
||||
"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.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==",
|
||||
"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.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==",
|
||||
"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.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==",
|
||||
"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.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==",
|
||||
"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.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==",
|
||||
"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.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==",
|
||||
"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.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==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -981,9 +981,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
|
||||
"integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -1032,9 +1032,9 @@
|
||||
"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.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -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.0.8",
|
||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.0.8.tgz",
|
||||
"integrity": "sha512-BRzLom0mhDhV9v0QhgUUHWQJuwFmnr1194xEcNLYD6ym8y8s542n4jXUvRLnhNTbh9PmpU6kGZamyuGHQMsGjw==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "individual",
|
||||
@@ -2036,9 +2036,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2138,9 +2138,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"version": "8.5.10",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
|
||||
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2193,30 +2193,30 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
|
||||
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz",
|
||||
"integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.6",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
|
||||
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
|
||||
"version": "19.2.5",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz",
|
||||
"integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.6"
|
||||
"react": "^19.2.5"
|
||||
}
|
||||
},
|
||||
"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.6",
|
||||
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.6.tgz",
|
||||
"integrity": "sha512-WzJ6SMKF+GTD7JZZqxSR1AKKmXjaSu39sClUrNlwxS4Tl7a99O+ltFy6yhPMO+wgZuxpQjJ2PZkfrQKmAqrLhw==",
|
||||
"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.0.1",
|
||||
"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.14.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.14.2.tgz",
|
||||
"integrity": "sha512-yCqNne6I8IB6rVCH7XUvlBK7/QKyqypBFGv+8dj4QBFJiiRX+FG7/nkdAvGElyvVZ/HQP5N19wzteuTARXi5Gw==",
|
||||
"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.14.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.14.2.tgz",
|
||||
"integrity": "sha512-YZcM5ES8jJSM+KrJ9BdvHHqlnGTg5tH3sC5ChFRj4inosKctdyzBDhOyyHdGk597q2OT6NTrCA1OvB/YDwfekQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"react-router": "7.15.0"
|
||||
"react-router": "7.14.2"
|
||||
},
|
||||
"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.0-rc.17",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.17.tgz",
|
||||
"integrity": "sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.129.0",
|
||||
"@rolldown/pluginutils": "1.0.0"
|
||||
"@oxc-project/types": "=0.127.0",
|
||||
"@rolldown/pluginutils": "1.0.0-rc.17"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
@@ -2327,27 +2327,27 @@
|
||||
"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.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"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -2592,16 +2592,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.10",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.10.tgz",
|
||||
"integrity": "sha512-rZuUu9j6J5uotLDs+cAA4O5H4K1SfPliUlQwqa6YEwSrWDZzP4rhm00oJR5snMewjxF5V/K3D4kctsUTsIU9Mw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.14",
|
||||
"rolldown": "1.0.0",
|
||||
"postcss": "^8.5.10",
|
||||
"rolldown": "1.0.0-rc.17",
|
||||
"tinyglobby": "^0.2.16"
|
||||
},
|
||||
"bin": {
|
||||
@@ -2618,7 +2618,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.1.18",
|
||||
"@vitejs/devtools": "^0.1.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
@@ -2866,9 +2866,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.2.tgz",
|
||||
"integrity": "sha512-IynmDyxsEsb9RKzO3J9+4SxXnl2FTFSzNBaKKaMV6tsSk0rw9gYw9gs+JFCq/qk2LCZ78KDwyj+Z289TijSkUw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
|
||||
+10
-10
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "medassist-ng-frontend",
|
||||
"private": true,
|
||||
"version": "1.24.0",
|
||||
"version": "1.23.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -27,22 +27,22 @@
|
||||
"test:e2e:report": "playwright show-report"
|
||||
},
|
||||
"dependencies": {
|
||||
"i18next": "^26.1.0",
|
||||
"i18next": "^26.0.8",
|
||||
"i18next-browser-languagedetector": "^8.2.1",
|
||||
"lucide-react": "^1.14.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-i18next": "^17.0.7",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"zod": "^4.4.3"
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"react-i18next": "^17.0.6",
|
||||
"react-router-dom": "^7.14.2",
|
||||
"zod": "^4.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.15",
|
||||
"@biomejs/biome": "^2.4.14",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@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.6.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
@@ -50,7 +50,7 @@
|
||||
"@vitest/coverage-v8": "^4.1.5",
|
||||
"jsdom": "^29.1.1",
|
||||
"typescript": "^6.0.3",
|
||||
"vite": "^8.0.12",
|
||||
"vite": "^8.0.10",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,7 +506,7 @@ function AppContent() {
|
||||
<AboutModal isOpen={showAbout} onClose={closeAbout} />
|
||||
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to={{ pathname: "/dashboard", search: location.search }} replace />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
|
||||
<Route path="/medications" element={<MedicationsPage />} />
|
||||
|
||||
@@ -235,6 +235,10 @@ export function SharedSchedule() {
|
||||
}
|
||||
|
||||
async function markDoseTaken(doseId: string) {
|
||||
if (dismissedDoses.has(doseId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasTaken = takenDoses.has(doseId);
|
||||
const wasSkipped = dismissedDoses.has(doseId);
|
||||
const wasAutomatic = automaticTakenDoses.has(doseId);
|
||||
@@ -462,7 +466,7 @@ export function SharedSchedule() {
|
||||
<button
|
||||
className={`dose-btn take${options.isEmpty ? " out-of-stock" : ""}`}
|
||||
onClick={() => markDoseTaken(options.doseId)}
|
||||
disabled={options.isEmpty}
|
||||
disabled={options.isEmpty || options.isSkipped}
|
||||
title={options.isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
@@ -472,7 +476,7 @@ export function SharedSchedule() {
|
||||
|
||||
const skipButton = options.isSkipped ? (
|
||||
<button className="dose-btn undo skip" onClick={() => undoDoseSkipped(options.doseId)} title={t("common.undo")}>
|
||||
<span className="dose-btn-label">{t("dose.undoSkip")}</span>
|
||||
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||
<span aria-hidden="true">↩</span>
|
||||
</button>
|
||||
) : (
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
type ScheduleEvent,
|
||||
type StockThresholds,
|
||||
} from "../types";
|
||||
import { getSystemLocale, setDefaultFormattingTimezone } from "../utils/formatters";
|
||||
import { getSystemLocale } from "../utils/formatters";
|
||||
import { log } from "../utils/logger";
|
||||
import { buildSchedulePreview, calculateCoverage, computeMissedPastDoseIds, getStockStatus } from "../utils/schedule";
|
||||
import { ShareContextProvider } from "./ShareContext";
|
||||
@@ -77,15 +77,12 @@ export interface AppContextValue {
|
||||
// From useDoses
|
||||
takenDoses: Set<string>;
|
||||
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
skippedDoses: Set<string>;
|
||||
dismissedDoses: Set<string>;
|
||||
getDoseId: (baseDoseId: string, person: string | null) => string;
|
||||
isDoseTakenAutomatically: (doseId: string) => boolean;
|
||||
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
||||
markDoseTaken: (doseId: string) => Promise<void>;
|
||||
markDoseSkipped: (doseId: string) => Promise<void>;
|
||||
undoDoseTaken: (doseId: string) => Promise<void>;
|
||||
undoDoseSkipped: (doseId: string) => Promise<void>;
|
||||
|
||||
// From useCollapsedDays
|
||||
manuallyCollapsedDays: Set<string>;
|
||||
@@ -302,10 +299,6 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
shares: number;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDefaultFormattingTimezone(settingsHook.settings.timezone || settingsHook.settings.serverTimezone || null);
|
||||
}, [settingsHook.settings.timezone, settingsHook.settings.serverTimezone]);
|
||||
|
||||
// Load user-specific scheduleDays when user changes
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined" && user?.id) {
|
||||
@@ -855,15 +848,12 @@ export function AppProvider({ children }: { children: React.ReactNode }) {
|
||||
// From useDoses
|
||||
takenDoses: doses.takenDoses,
|
||||
setTakenDoses: doses.setTakenDoses,
|
||||
skippedDoses: doses.skippedDoses,
|
||||
dismissedDoses: doses.dismissedDoses,
|
||||
getDoseId: doses.getDoseId,
|
||||
isDoseTakenAutomatically: doses.isDoseTakenAutomatically,
|
||||
countTakenDoses: doses.countTakenDoses,
|
||||
markDoseTaken: doses.markDoseTaken,
|
||||
markDoseSkipped: doses.markDoseSkipped,
|
||||
undoDoseTaken: doses.undoDoseTaken,
|
||||
undoDoseSkipped: doses.undoDoseSkipped,
|
||||
|
||||
// From useCollapsedDays
|
||||
manuallyCollapsedDays: collapsed.manuallyCollapsedDays,
|
||||
|
||||
@@ -10,16 +10,13 @@ export interface UseDosesReturn {
|
||||
setTakenDoses: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
takenDoseTimestamps: Map<string, number>;
|
||||
takenDoseSources: Map<string, "manual" | "automatic">;
|
||||
skippedDoses: Set<string>;
|
||||
dismissedDoses: Set<string>;
|
||||
clearDosesState: () => void;
|
||||
getDoseId: (baseDoseId: string, person: string | null) => string;
|
||||
isDoseTakenAutomatically: (doseId: string) => boolean;
|
||||
countTakenDoses: (doses: Array<{ id: string; takenBy: string[] }>) => { total: number; taken: number };
|
||||
markDoseTaken: (doseId: string) => Promise<void>;
|
||||
markDoseSkipped: (doseId: string) => Promise<void>;
|
||||
undoDoseTaken: (doseId: string) => Promise<void>;
|
||||
undoDoseSkipped: (doseId: string) => Promise<void>;
|
||||
loadTakenDoses: () => Promise<void>;
|
||||
}
|
||||
|
||||
@@ -59,7 +56,7 @@ export function useDoses(): UseDosesReturn {
|
||||
const sources = new Map<string, "manual" | "automatic">();
|
||||
const dismissed = new Set<string>();
|
||||
for (const d of data.doses) {
|
||||
if (d.skipped === true || d.dismissed === true) {
|
||||
if (d.dismissed) {
|
||||
dismissed.add(d.doseId);
|
||||
} else {
|
||||
taken.add(d.doseId);
|
||||
@@ -130,15 +127,6 @@ export function useDoses(): UseDosesReturn {
|
||||
|
||||
const markDoseTaken = useCallback(
|
||||
async (doseId: string) => {
|
||||
if (dismissedDoses.has(doseId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasTaken = takenDoses.has(doseId);
|
||||
const wasSkipped = dismissedDoses.has(doseId);
|
||||
const previousTimestamp = takenDoseTimestamps.get(doseId);
|
||||
const previousSource = takenDoseSources.get(doseId);
|
||||
|
||||
// Optimistic update
|
||||
mutationInFlightRef.current++;
|
||||
setTakenDoses((prev) => {
|
||||
@@ -146,11 +134,6 @@ export function useDoses(): UseDosesReturn {
|
||||
next.add(doseId);
|
||||
return next;
|
||||
});
|
||||
setDismissedDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(doseId);
|
||||
return next;
|
||||
});
|
||||
setTakenDoseTimestamps((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(doseId, Date.now());
|
||||
@@ -180,38 +163,17 @@ export function useDoses(): UseDosesReturn {
|
||||
// Revert on error
|
||||
setTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (wasTaken) {
|
||||
next.add(doseId);
|
||||
} else {
|
||||
next.delete(doseId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setDismissedDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (wasSkipped) {
|
||||
next.add(doseId);
|
||||
} else {
|
||||
next.delete(doseId);
|
||||
}
|
||||
next.delete(doseId);
|
||||
return next;
|
||||
});
|
||||
setTakenDoseTimestamps((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (wasTaken && typeof previousTimestamp === "number") {
|
||||
next.set(doseId, previousTimestamp);
|
||||
} else {
|
||||
next.delete(doseId);
|
||||
}
|
||||
next.delete(doseId);
|
||||
return next;
|
||||
});
|
||||
setTakenDoseSources((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (wasTaken && previousSource) {
|
||||
next.set(doseId, previousSource);
|
||||
} else {
|
||||
next.delete(doseId);
|
||||
}
|
||||
next.delete(doseId);
|
||||
return next;
|
||||
});
|
||||
} finally {
|
||||
@@ -220,96 +182,11 @@ export function useDoses(): UseDosesReturn {
|
||||
loadTakenDoses();
|
||||
}
|
||||
},
|
||||
[dismissedDoses, getErrorCode, loadTakenDoses, t, takenDoseSources, takenDoseTimestamps, takenDoses]
|
||||
);
|
||||
|
||||
const markDoseSkipped = useCallback(
|
||||
async (doseId: string) => {
|
||||
if (takenDoses.has(doseId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasTaken = takenDoses.has(doseId);
|
||||
const wasSkipped = dismissedDoses.has(doseId);
|
||||
const previousTimestamp = takenDoseTimestamps.get(doseId);
|
||||
const previousSource = takenDoseSources.get(doseId);
|
||||
|
||||
mutationInFlightRef.current++;
|
||||
setDismissedDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.add(doseId);
|
||||
return next;
|
||||
});
|
||||
setTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(doseId);
|
||||
return next;
|
||||
});
|
||||
setTakenDoseTimestamps((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(doseId);
|
||||
return next;
|
||||
});
|
||||
setTakenDoseSources((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.delete(doseId);
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/doses/skip", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ doseId }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to mark dose as skipped");
|
||||
}
|
||||
} catch {
|
||||
setDismissedDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (wasSkipped) {
|
||||
next.add(doseId);
|
||||
} else {
|
||||
next.delete(doseId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setTakenDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (wasTaken) {
|
||||
next.add(doseId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setTakenDoseTimestamps((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (wasTaken && typeof previousTimestamp === "number") {
|
||||
next.set(doseId, previousTimestamp);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setTakenDoseSources((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (wasTaken && previousSource) {
|
||||
next.set(doseId, previousSource);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} finally {
|
||||
mutationInFlightRef.current--;
|
||||
loadTakenDoses();
|
||||
}
|
||||
},
|
||||
[dismissedDoses, loadTakenDoses, takenDoseSources, takenDoseTimestamps, takenDoses]
|
||||
[getErrorCode, loadTakenDoses, t]
|
||||
);
|
||||
|
||||
const undoDoseTaken = useCallback(
|
||||
async (doseId: string) => {
|
||||
const previousTimestamp = takenDoseTimestamps.get(doseId);
|
||||
const previousSource = takenDoseSources.get(doseId);
|
||||
|
||||
// Optimistic update
|
||||
mutationInFlightRef.current++;
|
||||
setTakenDoses((prev) => {
|
||||
@@ -341,59 +218,13 @@ export function useDoses(): UseDosesReturn {
|
||||
next.add(doseId);
|
||||
return next;
|
||||
});
|
||||
setTakenDoseTimestamps((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (typeof previousTimestamp === "number") {
|
||||
next.set(doseId, previousTimestamp);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setTakenDoseSources((prev) => {
|
||||
const next = new Map(prev);
|
||||
if (previousSource) {
|
||||
next.set(doseId, previousSource);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} finally {
|
||||
mutationInFlightRef.current--;
|
||||
// Re-sync with server after mutation completes
|
||||
loadTakenDoses();
|
||||
}
|
||||
},
|
||||
[loadTakenDoses, takenDoseSources, takenDoseTimestamps]
|
||||
);
|
||||
|
||||
const undoDoseSkipped = useCallback(
|
||||
async (doseId: string) => {
|
||||
const wasSkipped = dismissedDoses.has(doseId);
|
||||
|
||||
mutationInFlightRef.current++;
|
||||
setDismissedDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(doseId);
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
await fetch(`/api/doses/skip/${encodeURIComponent(doseId)}`, {
|
||||
method: "DELETE",
|
||||
credentials: "include",
|
||||
});
|
||||
} catch {
|
||||
setDismissedDoses((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (wasSkipped) {
|
||||
next.add(doseId);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} finally {
|
||||
mutationInFlightRef.current--;
|
||||
loadTakenDoses();
|
||||
}
|
||||
},
|
||||
[dismissedDoses, loadTakenDoses]
|
||||
[loadTakenDoses]
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -401,16 +232,13 @@ export function useDoses(): UseDosesReturn {
|
||||
setTakenDoses,
|
||||
takenDoseTimestamps,
|
||||
takenDoseSources,
|
||||
skippedDoses: dismissedDoses,
|
||||
dismissedDoses,
|
||||
clearDosesState,
|
||||
getDoseId,
|
||||
isDoseTakenAutomatically,
|
||||
countTakenDoses,
|
||||
markDoseTaken,
|
||||
markDoseSkipped,
|
||||
undoDoseTaken,
|
||||
undoDoseSkipped,
|
||||
loadTakenDoses,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,11 +23,8 @@ export function useScheduleController() {
|
||||
futureDays: ctx.futureDays,
|
||||
takenDoses: ctx.takenDoses,
|
||||
dismissedDoses: ctx.dismissedDoses,
|
||||
skippedDoses: ctx.skippedDoses,
|
||||
markDoseTaken: ctx.markDoseTaken,
|
||||
markDoseSkipped: ctx.markDoseSkipped,
|
||||
undoDoseTaken: ctx.undoDoseTaken,
|
||||
undoDoseSkipped: ctx.undoDoseSkipped,
|
||||
manuallyCollapsedDays: ctx.manuallyCollapsedDays,
|
||||
manuallyExpandedDays: ctx.manuallyExpandedDays,
|
||||
toggleDayCollapse: ctx.toggleDayCollapse,
|
||||
|
||||
+19
-23
@@ -110,7 +110,7 @@
|
||||
"fullBlisters": "Volle Blister",
|
||||
"openBlister": "Offener Blister",
|
||||
"stock": "Bestand",
|
||||
"dailyConsumption": "Täglicher Verbrauch",
|
||||
"dailyConsumption": "Taeglicher Verbrauch",
|
||||
"stockDetails": "Details",
|
||||
"daysLeft": "Tage übrig",
|
||||
"status": "Status",
|
||||
@@ -133,7 +133,7 @@
|
||||
"obsoleteTitle": "Obsolet ({{count}})",
|
||||
"obsoleteSince": "Beendet",
|
||||
"started": "Gestartet",
|
||||
"emptyState": "Noch keine Medikamente. Füge dein erstes Medikament hinzu."
|
||||
"emptyState": "Noch keine Medikamente. Fuege dein erstes Medikament hinzu."
|
||||
},
|
||||
"details": {
|
||||
"packs": "Packungen",
|
||||
@@ -142,7 +142,7 @@
|
||||
"loose": "Lose",
|
||||
"total": "Gesamt",
|
||||
"stock": "Bestand",
|
||||
"capacityPerPackage": "Kapazität pro Packung",
|
||||
"capacityPerPackage": "Kapazitaet pro Packung",
|
||||
"totalCapacity": "Kapazität",
|
||||
"type": "Typ"
|
||||
},
|
||||
@@ -174,17 +174,17 @@
|
||||
"medicationForm": "Medikationsform",
|
||||
"medicationFormCapsule": "Kapsel",
|
||||
"medicationFormTablet": "Tablette",
|
||||
"medicationFormLiquid": "Flüssigkeit",
|
||||
"medicationFormLiquid": "Fluessigkeit",
|
||||
"medicationFormTopical": "Topisch",
|
||||
"pillForm": "Pillenform",
|
||||
"lifecycleCategory": "Lebenszyklus",
|
||||
"lifecycleRefillWhenEmpty": "Nachfüllen wenn leer",
|
||||
"lifecycleRefillWhenEmpty": "Nachfuellen wenn leer",
|
||||
"lifecycleTreatmentPeriod": "Behandlungszeitraum",
|
||||
"packageType": "Verpackungsart",
|
||||
"packageTypeBlister": "Blisterpackung",
|
||||
"packageTypeBottle": "Pillendose",
|
||||
"packageTypeTube": "Tube",
|
||||
"packageTypeLiquidContainer": "Flüssigbehältnis",
|
||||
"packageTypeLiquidContainer": "Fluessigbehaeltnis",
|
||||
"packs": "Packungen",
|
||||
"bottles": "Flaschen",
|
||||
"tubes": "Tuben",
|
||||
@@ -317,17 +317,17 @@
|
||||
"usageApplication": "Dosis (Anwendungen)",
|
||||
"intakeUnit": "Einnahmeeinheit",
|
||||
"intakeUnitMl": "Milliliter (ml)",
|
||||
"intakeUnitTsp": "Teelöffel (5 ml)",
|
||||
"intakeUnitTbsp": "Esslöffel (15 ml)",
|
||||
"intakeUnitTsp": "Teeloeffel (5 ml)",
|
||||
"intakeUnitTbsp": "Essloeffel (15 ml)",
|
||||
"intakes": "Einnahmen",
|
||||
"intakes_one": "Einnahme",
|
||||
"intakes_other": "Einnahmen",
|
||||
"teaspoons": "Teelöffel",
|
||||
"teaspoons_one": "Teelöffel",
|
||||
"teaspoons_other": "Teelöffel",
|
||||
"tablespoons": "Esslöffel",
|
||||
"tablespoons_one": "Esslöffel",
|
||||
"tablespoons_other": "Esslöffel",
|
||||
"teaspoons": "Teeloeffel",
|
||||
"teaspoons_one": "Teeloeffel",
|
||||
"teaspoons_other": "Teeloeffel",
|
||||
"tablespoons": "Essloeffel",
|
||||
"tablespoons_one": "Essloeffel",
|
||||
"tablespoons_other": "Essloeffel",
|
||||
"applications": "Anwendungen",
|
||||
"applications_one": "Anwendung",
|
||||
"applications_other": "Anwendungen",
|
||||
@@ -337,7 +337,7 @@
|
||||
"everyDays": "Alle (Tage)",
|
||||
"every": "alle",
|
||||
"weekdays": "Wochentage",
|
||||
"weekdaysRequired": "Wähle mindestens einen Wochentag aus",
|
||||
"weekdaysRequired": "Waehle mindestens einen Wochentag aus",
|
||||
"weekdaysShort": {
|
||||
"mon": "Mo",
|
||||
"tue": "Di",
|
||||
@@ -548,11 +548,7 @@
|
||||
"dose": {
|
||||
"takenBy": "eingenommen von",
|
||||
"markAsTaken": "Als eingenommen markieren",
|
||||
"take": "Nehmen",
|
||||
"skip": "Überspringen",
|
||||
"markAsSkipped": "Als übersprungen markieren",
|
||||
"undoTake": "Nehmen rückgängig",
|
||||
"undoSkip": "Überspringen rückgängig"
|
||||
"take": "Nehmen"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Anmelden",
|
||||
@@ -788,11 +784,11 @@
|
||||
"loosePills": "Lose Tabletten",
|
||||
"pillsPerBlister": "(je {{count}} Tabletten)",
|
||||
"packageSize": "Packungsgröße: {{count}} Tabletten",
|
||||
"packageSizeAmount": "Packungsgröße: {{count}} {{unit}}",
|
||||
"packageSizeAmount": "Packungsgroesse: {{count}} {{unit}}",
|
||||
"packageSizeBreakdown": "{{packCount}} x {{sizePerPack}} Tabletten Packung = {{total}} Tabletten",
|
||||
"currentComposition": "Aktueller Bestand: {{fullBlisters}} volle Blister + {{partialPills}} angebrochen + {{loosePills}} lose = {{total}} Tabletten",
|
||||
"maxExceeded": "Die maximale Packungsgröße beträgt {{count}} Tabletten. Werte wurden begrenzt.",
|
||||
"maxExceededAmount": "Die maximale Packungsgröße beträgt {{count}} {{unit}}. Werte wurden begrenzt.",
|
||||
"maxExceededAmount": "Die maximale Packungsgroesse betraegt {{count}} {{unit}}. Werte wurden begrenzt.",
|
||||
"decreaseValue": "Wert verringern",
|
||||
"increaseValue": "Wert erhöhen",
|
||||
"currentTotal": "Aktueller Bestand",
|
||||
@@ -874,7 +870,7 @@
|
||||
"docIntakeHistory": "Einnahme-Verlauf",
|
||||
"docDosesTaken": "Eingenommene Dosen",
|
||||
"docDosesTakenAutomatic": "Automatisch eingenommen",
|
||||
"docDosesSkipped": "Übersprungene Dosen",
|
||||
"docDosesDismissed": "Verworfene Dosen",
|
||||
"docFirstDose": "Erste Dosis",
|
||||
"docLastDose": "Letzte Dosis",
|
||||
"docRefillHistory": "Nachfüll-Verlauf",
|
||||
|
||||
@@ -548,11 +548,7 @@
|
||||
"dose": {
|
||||
"takenBy": "taken by",
|
||||
"markAsTaken": "Mark as taken",
|
||||
"take": "Take",
|
||||
"skip": "Skip",
|
||||
"markAsSkipped": "Mark as skipped",
|
||||
"undoTake": "Undo take",
|
||||
"undoSkip": "Undo skip"
|
||||
"take": "Take"
|
||||
},
|
||||
"auth": {
|
||||
"login": "Login",
|
||||
@@ -874,7 +870,7 @@
|
||||
"docIntakeHistory": "Intake History",
|
||||
"docDosesTaken": "Doses taken",
|
||||
"docDosesTakenAutomatic": "Automatically taken",
|
||||
"docDosesSkipped": "Doses skipped",
|
||||
"docDosesDismissed": "Doses dismissed",
|
||||
"docFirstDose": "First dose",
|
||||
"docLastDose": "Last dose",
|
||||
"docRefillHistory": "Refill History",
|
||||
|
||||
@@ -257,10 +257,8 @@ export function MedicationsPage() {
|
||||
useUnsavedChangesWarning(formChanged);
|
||||
|
||||
// View mode: grid (default) or form (edit/new)
|
||||
// If navigating in with a medication deep-link, suppress rendering until the target form is ready
|
||||
const [pendingEditTransition, setPendingEditTransition] = useState(
|
||||
() => searchParams.has("editMedId") || searchParams.has("viewMedId")
|
||||
);
|
||||
// If navigating in with editMedId, suppress rendering until the edit form is ready
|
||||
const [pendingEditTransition, setPendingEditTransition] = useState(() => searchParams.has("editMedId"));
|
||||
const [viewMode, setViewMode] = useState<"grid" | "form">(pendingEditTransition ? "form" : "grid");
|
||||
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general");
|
||||
@@ -271,23 +269,9 @@ export function MedicationsPage() {
|
||||
useEffect(() => {
|
||||
showEditModalRef.current = showEditModal;
|
||||
}, [showEditModal]);
|
||||
const processedMedicationLinkRef = useRef<string | null>(null);
|
||||
const processedEditMedIdRef = useRef<string | null>(null);
|
||||
const hasDesktopFormHistoryState = useRef(false);
|
||||
|
||||
const getMedicationLinkState = useCallback((params: URLSearchParams) => {
|
||||
const viewMedId = params.get("viewMedId");
|
||||
if (viewMedId) {
|
||||
return { mode: "view" as const, linkedMedId: viewMedId };
|
||||
}
|
||||
|
||||
const editMedId = params.get("editMedId");
|
||||
if (editMedId) {
|
||||
return { mode: "edit" as const, linkedMedId: editMedId };
|
||||
}
|
||||
|
||||
return { mode: null, linkedMedId: null };
|
||||
}, []);
|
||||
|
||||
// Sync formChanged state to the global context for navigation blocking
|
||||
const { setHasUnsavedChanges } = useUnsavedChanges();
|
||||
useEffect(() => {
|
||||
@@ -835,13 +819,12 @@ export function MedicationsPage() {
|
||||
[t]
|
||||
);
|
||||
|
||||
const clearMedicationLinkParams = useCallback(() => {
|
||||
const clearEditMedIdParam = useCallback(() => {
|
||||
setSearchParams(
|
||||
(prevParams) => {
|
||||
if (!prevParams.has("editMedId") && !prevParams.has("viewMedId")) return prevParams;
|
||||
if (!prevParams.has("editMedId")) return prevParams;
|
||||
const nextParams = new URLSearchParams(prevParams);
|
||||
nextParams.delete("editMedId");
|
||||
nextParams.delete("viewMedId");
|
||||
return nextParams;
|
||||
},
|
||||
{ replace: true }
|
||||
@@ -865,7 +848,7 @@ export function MedicationsPage() {
|
||||
setShowUnsavedConfirm(true);
|
||||
return;
|
||||
}
|
||||
clearMedicationLinkParams();
|
||||
clearEditMedIdParam();
|
||||
// Mark as confirmed to avoid double confirmation in popstate handler
|
||||
closeConfirmedRef.current = true;
|
||||
window.history.back();
|
||||
@@ -1176,7 +1159,7 @@ export function MedicationsPage() {
|
||||
if (shouldCloseMobileModal) {
|
||||
// Treat post-save close as confirmed so popstate does not trigger unsaved guards.
|
||||
closeConfirmedRef.current = true;
|
||||
clearMedicationLinkParams();
|
||||
clearEditMedIdParam();
|
||||
setShowEditModal(false);
|
||||
setReadOnlyView(false);
|
||||
setActiveTab("general");
|
||||
@@ -1205,8 +1188,7 @@ export function MedicationsPage() {
|
||||
// Handle browser back button for modals and unsaved changes
|
||||
useEffect(() => {
|
||||
const handlePopState = () => {
|
||||
const currentParams = new URLSearchParams(window.location.search);
|
||||
const { mode: currentLinkMode, linkedMedId: currentMedicationLinkId } = getMedicationLinkState(currentParams);
|
||||
const currentEditMedId = new URLSearchParams(window.location.search).get("editMedId");
|
||||
|
||||
// Obsolete confirmation is open — dismiss it and stay where we are
|
||||
if (showObsoleteConfirm) {
|
||||
@@ -1225,10 +1207,10 @@ export function MedicationsPage() {
|
||||
// If close was already confirmed programmatically, allow navigation
|
||||
if (closeConfirmedRef.current) {
|
||||
closeConfirmedRef.current = false;
|
||||
if (currentMedicationLinkId && currentLinkMode) {
|
||||
if (currentEditMedId) {
|
||||
// Prevent URL popstate from immediately reopening mobile edit for the same id.
|
||||
processedMedicationLinkRef.current = `${currentLinkMode}:${currentMedicationLinkId}`;
|
||||
clearMedicationLinkParams();
|
||||
processedEditMedIdRef.current = currentEditMedId;
|
||||
clearEditMedIdParam();
|
||||
}
|
||||
if (showEditModal) {
|
||||
setShowEditModal(false);
|
||||
@@ -1249,11 +1231,11 @@ export function MedicationsPage() {
|
||||
setShowUnsavedConfirm(true);
|
||||
return;
|
||||
}
|
||||
if (currentMedicationLinkId && currentLinkMode) {
|
||||
if (currentEditMedId) {
|
||||
// Mark as handled before URL cleanup to avoid same-tick re-open races.
|
||||
processedMedicationLinkRef.current = `${currentLinkMode}:${currentMedicationLinkId}`;
|
||||
processedEditMedIdRef.current = currentEditMedId;
|
||||
}
|
||||
clearMedicationLinkParams();
|
||||
clearEditMedIdParam();
|
||||
setShowEditModal(false);
|
||||
resetForm();
|
||||
resetMedicationEnrichment();
|
||||
@@ -1289,16 +1271,7 @@ export function MedicationsPage() {
|
||||
};
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
return () => window.removeEventListener("popstate", handlePopState);
|
||||
}, [
|
||||
showObsoleteConfirm,
|
||||
showDeleteConfirm,
|
||||
showEditModal,
|
||||
viewMode,
|
||||
formChanged,
|
||||
resetForm,
|
||||
clearMedicationLinkParams,
|
||||
getMedicationLinkState,
|
||||
]);
|
||||
}, [showObsoleteConfirm, showDeleteConfirm, showEditModal, viewMode, formChanged, resetForm, clearEditMedIdParam]);
|
||||
|
||||
// Close modal on Escape key
|
||||
useEffect(() => {
|
||||
@@ -1416,23 +1389,22 @@ export function MedicationsPage() {
|
||||
}, [activeMeds, editingId]);
|
||||
|
||||
useEffect(() => {
|
||||
const { mode: linkMode, linkedMedId } = getMedicationLinkState(searchParams);
|
||||
if (!linkedMedId || !linkMode) {
|
||||
processedMedicationLinkRef.current = null;
|
||||
const editMedId = searchParams.get("editMedId");
|
||||
if (!editMedId) {
|
||||
processedEditMedIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
const linkKey = `${linkMode}:${linkedMedId}`;
|
||||
if (processedMedicationLinkRef.current === linkKey) return;
|
||||
const parsedMedId = Number.parseInt(linkedMedId, 10);
|
||||
if (processedEditMedIdRef.current === editMedId) return;
|
||||
const parsedMedId = Number.parseInt(editMedId, 10);
|
||||
if (Number.isNaN(parsedMedId)) return;
|
||||
const medicationToEdit =
|
||||
meds.find((med) => med.id === parsedMedId) ?? allMeds.find((med) => med.id === parsedMedId);
|
||||
if (!medicationToEdit) return;
|
||||
|
||||
processedMedicationLinkRef.current = linkKey;
|
||||
processedEditMedIdRef.current = editMedId;
|
||||
|
||||
setShowNameValidation(false);
|
||||
setReadOnlyView(linkMode === "view");
|
||||
setReadOnlyView(false);
|
||||
setActiveTab("general");
|
||||
resetMedicationEnrichment(medicationToEdit.name || medicationToEdit.genericName || "");
|
||||
startEdit(medicationToEdit, openEditModal);
|
||||
@@ -1443,9 +1415,8 @@ export function MedicationsPage() {
|
||||
|
||||
const nextParams = new URLSearchParams(searchParams);
|
||||
nextParams.delete("editMedId");
|
||||
nextParams.delete("viewMedId");
|
||||
setSearchParams(nextParams, { replace: true });
|
||||
}, [allMeds, getMedicationLinkState, meds, openEditModal, searchParams, setSearchParams, startEdit]);
|
||||
}, [allMeds, meds, openEditModal, searchParams, setSearchParams, startEdit]);
|
||||
|
||||
const selectedMedication = useMemo(() => {
|
||||
if (!editingId) return null;
|
||||
|
||||
@@ -24,8 +24,8 @@ function getStockStatus(
|
||||
packageType?: string
|
||||
) {
|
||||
if (isTubePackageType(packageType)) return { className: "success", label: "status.noSchedule" };
|
||||
// Only a real zero-or-below stock count is out of stock.
|
||||
if (medsLeft <= 0) return { className: "danger", label: "status.outOfStock" };
|
||||
// Out of stock or completely depleted = danger (red)
|
||||
if (medsLeft <= 0 || daysLeft === 0) return { className: "danger", label: "status.outOfStock" };
|
||||
// No schedule, but has stock = normal
|
||||
if (daysLeft === null) return { className: "success", label: "status.noSchedule" };
|
||||
if (isLiquidContainerPackageType(packageType)) {
|
||||
@@ -85,10 +85,7 @@ export function SchedulePage() {
|
||||
isDoseTakenAutomatically,
|
||||
dismissedDoses,
|
||||
markDoseTaken,
|
||||
skippedDoses,
|
||||
markDoseSkipped,
|
||||
undoDoseTaken,
|
||||
undoDoseSkipped,
|
||||
coverageByMed,
|
||||
depletionByMed,
|
||||
manuallyExpandedDays,
|
||||
@@ -175,59 +172,6 @@ export function SchedulePage() {
|
||||
doses?: Array<{ usage: number; intakeUnit?: IntakeUnit | null }>
|
||||
) => formatScheduleTotalUsageLabel(med, total, t, doses);
|
||||
|
||||
const renderDoseActionButtons = (options: {
|
||||
doseId: string;
|
||||
isTaken: boolean;
|
||||
isSkipped: boolean;
|
||||
isAutomaticallyTaken: boolean;
|
||||
isEmpty: boolean;
|
||||
}) => {
|
||||
const takeButton = options.isTaken ? (
|
||||
<button className="dose-btn undo take" onClick={() => undoDoseTaken(options.doseId)} title={t("common.undo")}>
|
||||
{options.isAutomaticallyTaken && (
|
||||
<span className="info-tooltip" data-tooltip={t("tooltips.automaticTaken")}>
|
||||
🤖
|
||||
</span>
|
||||
)}
|
||||
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||
<span aria-hidden="true">↩</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className={`dose-btn take${options.isEmpty ? " out-of-stock" : ""}`}
|
||||
onClick={() => markDoseTaken(options.doseId)}
|
||||
disabled={options.isEmpty || options.isSkipped}
|
||||
title={options.isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">{options.isEmpty ? "⊘" : "✓"}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
const skipButton = options.isSkipped ? (
|
||||
<button className="dose-btn undo skip" onClick={() => undoDoseSkipped(options.doseId)} title={t("common.undo")}>
|
||||
<span className="dose-btn-label">{t("common.undo")}</span>
|
||||
<span aria-hidden="true">↩</span>
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="dose-btn skip"
|
||||
onClick={() => markDoseSkipped(options.doseId)}
|
||||
title={t("dose.markAsSkipped")}
|
||||
disabled={options.isTaken}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.skip")}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{takeButton}
|
||||
{skipButton}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="grid">
|
||||
<article className="card schedule-full">
|
||||
@@ -376,14 +320,10 @@ export function SchedulePage() {
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = isDoseTakenForDisplay(doseId);
|
||||
const isSkipped = skippedDoses.has(doseId);
|
||||
const isAutomaticallyTaken =
|
||||
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= Date.now();
|
||||
const personClasses = ["dose-person"];
|
||||
if (isTaken) personClasses.push("taken");
|
||||
if (isSkipped) personClasses.push("skipped");
|
||||
return (
|
||||
<div key={doseId} className={personClasses.join(" ")}>
|
||||
<div key={doseId} className={`dose-person ${isTaken ? "taken" : ""}`}>
|
||||
{person && (
|
||||
<span
|
||||
className="person-name clickable"
|
||||
@@ -395,13 +335,35 @@ export function SchedulePage() {
|
||||
{person}
|
||||
</span>
|
||||
)}
|
||||
{renderDoseActionButtons({
|
||||
doseId,
|
||||
isTaken,
|
||||
isSkipped,
|
||||
isAutomaticallyTaken,
|
||||
isEmpty,
|
||||
})}
|
||||
{isTaken ? (
|
||||
<button
|
||||
className="dose-btn undo"
|
||||
onClick={() => undoDoseTaken(doseId)}
|
||||
title={t("common.undo")}
|
||||
>
|
||||
{isAutomaticallyTaken && (
|
||||
<span
|
||||
className="info-tooltip"
|
||||
data-tooltip={t("tooltips.automaticTaken")}
|
||||
>
|
||||
🤖
|
||||
</span>
|
||||
)}
|
||||
↩
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
|
||||
onClick={() => markDoseTaken(doseId)}
|
||||
disabled={isEmpty}
|
||||
title={
|
||||
isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")
|
||||
}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -587,16 +549,14 @@ export function SchedulePage() {
|
||||
{people.map((person) => {
|
||||
const doseId = getDoseId(dose.id, person);
|
||||
const isTaken = isDoseTakenForDisplay(doseId);
|
||||
const isSkipped = skippedDoses.has(doseId);
|
||||
const isAutomaticallyTaken =
|
||||
isTaken && isDoseTakenAutomatically(doseId) && dose.when <= now;
|
||||
const isOverdue = !isTaken && !isSkipped && !isEmpty && dose.when < now && !isPastDay;
|
||||
const personClasses = ["dose-person"];
|
||||
if (isTaken) personClasses.push("taken");
|
||||
if (isSkipped) personClasses.push("skipped");
|
||||
if (isOverdue) personClasses.push("overdue");
|
||||
const isOverdue = !isTaken && dose.when < now && !isPastDay;
|
||||
return (
|
||||
<div key={doseId} className={personClasses.join(" ")}>
|
||||
<div
|
||||
key={doseId}
|
||||
className={`dose-person ${isTaken ? "taken" : ""} ${isOverdue ? "overdue" : ""}`}
|
||||
>
|
||||
{person && (
|
||||
<span
|
||||
className="person-name clickable"
|
||||
@@ -608,13 +568,30 @@ export function SchedulePage() {
|
||||
{person}
|
||||
</span>
|
||||
)}
|
||||
{renderDoseActionButtons({
|
||||
doseId,
|
||||
isTaken,
|
||||
isSkipped,
|
||||
isAutomaticallyTaken,
|
||||
isEmpty,
|
||||
})}
|
||||
{isTaken ? (
|
||||
<button
|
||||
className="dose-btn undo"
|
||||
onClick={() => undoDoseTaken(doseId)}
|
||||
title={t("common.undo")}
|
||||
>
|
||||
{isAutomaticallyTaken && (
|
||||
<span className="info-tooltip" data-tooltip={t("tooltips.automaticTaken")}>
|
||||
🤖
|
||||
</span>
|
||||
)}
|
||||
↩
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className={`dose-btn take${isEmpty ? " out-of-stock" : ""}`}
|
||||
onClick={() => markDoseTaken(doseId)}
|
||||
disabled={isEmpty}
|
||||
title={isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
|
||||
>
|
||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||
<span aria-hidden="true">{isEmpty ? "⊘" : "✓"}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ConfirmModal, ExportModal } from "../components";
|
||||
import { useAppContext } from "../context";
|
||||
import { getSystemLocale, withFormattingTimezone } from "../utils/formatters";
|
||||
import { getSystemLocale } from "../utils/formatters";
|
||||
|
||||
export function SettingsPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -737,16 +737,13 @@ export function SettingsPage() {
|
||||
<div className="schedule-row">
|
||||
<span className="schedule-label">{t("settings.schedule.nextCheck")}</span>
|
||||
<span className="schedule-value">
|
||||
{new Date(settings.nextScheduledCheck).toLocaleString(
|
||||
getSystemLocale(i18n.language),
|
||||
withFormattingTimezone({
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
)}
|
||||
{new Date(settings.nextScheduledCheck).toLocaleString(getSystemLocale(i18n.language), {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -754,16 +751,13 @@ export function SettingsPage() {
|
||||
<div className="schedule-row">
|
||||
<span className="schedule-label">{t("settings.schedule.lastStockSent")}</span>
|
||||
<span className="schedule-value">
|
||||
{new Date(settings.lastStockReminderSent).toLocaleString(
|
||||
getSystemLocale(i18n.language),
|
||||
withFormattingTimezone({
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
)}
|
||||
{new Date(settings.lastStockReminderSent).toLocaleString(getSystemLocale(i18n.language), {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -771,16 +765,13 @@ export function SettingsPage() {
|
||||
<div className="schedule-row">
|
||||
<span className="schedule-label">{t("settings.schedule.lastIntakeSent")}</span>
|
||||
<span className="schedule-value">
|
||||
{new Date(settings.lastAutoEmailSent).toLocaleString(
|
||||
getSystemLocale(i18n.language),
|
||||
withFormattingTimezone({
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
)}
|
||||
{new Date(settings.lastAutoEmailSent).toLocaleString(getSystemLocale(i18n.language), {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -788,16 +779,13 @@ export function SettingsPage() {
|
||||
<div className="schedule-row">
|
||||
<span className="schedule-label">{t("settings.schedule.lastPrescriptionSent")}</span>
|
||||
<span className="schedule-value">
|
||||
{new Date(settings.lastPrescriptionReminderSent).toLocaleString(
|
||||
getSystemLocale(i18n.language),
|
||||
withFormattingTimezone({
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
)}
|
||||
{new Date(settings.lastPrescriptionReminderSent).toLocaleString(getSystemLocale(i18n.language), {
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { Coverage, Medication, PackageType } from "../types";
|
||||
import { getMedTotal as getMedTotalFromTypes, isLiquidContainerPackageType, isTubePackageType } from "../types";
|
||||
import { withFormattingTimezone } from "../utils/formatters";
|
||||
import { splitCurrentBlisterStock } from "../utils/stock";
|
||||
|
||||
export function userStorageKey(userId: number | undefined, key: string): string {
|
||||
@@ -133,15 +132,12 @@ export function getReminderStatusData(
|
||||
let lastStockSent: { date: string; medNames: string | null } | null = null;
|
||||
if (lastStockReminderSent) {
|
||||
const sentDate = new Date(lastStockReminderSent);
|
||||
const formattedDate = sentDate.toLocaleDateString(
|
||||
locale,
|
||||
withFormattingTimezone({
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
);
|
||||
const formattedDate = sentDate.toLocaleDateString(locale, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
lastStockSent = {
|
||||
date: formattedDate,
|
||||
medNames: lastStockReminderMedNames,
|
||||
@@ -151,15 +147,12 @@ export function getReminderStatusData(
|
||||
let lastIntakeSent: { date: string; medName: string | null; takenBy: string | null } | null = null;
|
||||
if (lastAutoEmailSent) {
|
||||
const sentDate = new Date(lastAutoEmailSent);
|
||||
const formattedDate = sentDate.toLocaleDateString(
|
||||
locale,
|
||||
withFormattingTimezone({
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
);
|
||||
const formattedDate = sentDate.toLocaleDateString(locale, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
lastIntakeSent = {
|
||||
date: formattedDate,
|
||||
medName: lastReminderMedName,
|
||||
|
||||
@@ -2112,20 +2112,6 @@ button.has-validation-error {
|
||||
border-color: color-mix(in srgb, var(--danger) 42%, transparent);
|
||||
color: color-mix(in srgb, var(--danger) 82%, white 18%);
|
||||
}
|
||||
|
||||
.time-row.notification-focus-target-row {
|
||||
scroll-margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.time-row.notification-focus-target-row .med-name-text {
|
||||
color: color-mix(in srgb, var(--primary) 88%, white 12%);
|
||||
}
|
||||
|
||||
.time-row.notification-focus-target-row .tag.subtle {
|
||||
background: color-mix(in srgb, var(--primary) 12%, transparent);
|
||||
border-color: color-mix(in srgb, var(--primary) 28%, transparent);
|
||||
}
|
||||
|
||||
.time-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -2223,12 +2209,12 @@ button.has-validation-error {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.dose-item.overdue .dose-btn.take:not(.undo):not(:disabled) {
|
||||
.dose-item.overdue .dose-btn.take {
|
||||
box-shadow: 0 0 0 2px var(--warning);
|
||||
animation: overduePulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.dose-item.overdue .dose-btn.take:not(.undo):hover {
|
||||
.dose-item.overdue .dose-btn.take:hover {
|
||||
filter: brightness(0.87);
|
||||
}
|
||||
|
||||
@@ -2346,7 +2332,7 @@ button.has-validation-error {
|
||||
}
|
||||
|
||||
.dose-btn .dose-btn-label {
|
||||
display: inline;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.dose-btn.take {
|
||||
@@ -2393,16 +2379,6 @@ button.has-validation-error {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.dose-btn.skip {
|
||||
background: color-mix(in srgb, var(--warning) 18%, var(--bg-tertiary));
|
||||
border: 1px solid color-mix(in srgb, var(--warning) 52%, var(--border-primary));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.dose-btn.skip:hover {
|
||||
filter: brightness(0.94);
|
||||
}
|
||||
|
||||
.dose-btn.take.out-of-stock,
|
||||
.dose-btn.take.out-of-stock:disabled,
|
||||
.dashboard-schedules-section .dose-btn.take.out-of-stock,
|
||||
@@ -2433,18 +2409,6 @@ button.has-validation-error {
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
.dose-btn.undo.take {
|
||||
background: color-mix(in srgb, var(--success) 82%, var(--accent-bg));
|
||||
border-color: color-mix(in srgb, var(--success) 88%, white 12%);
|
||||
color: #eafff6;
|
||||
}
|
||||
|
||||
.dose-btn.undo.skip {
|
||||
background: color-mix(in srgb, var(--warning) 50%, var(--bg-tertiary));
|
||||
border-color: color-mix(in srgb, var(--warning) 68%, var(--border-primary));
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* Per-person dose tracking */
|
||||
.dose-checks {
|
||||
display: flex;
|
||||
@@ -2462,20 +2426,10 @@ button.has-validation-error {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.dose-person.notification-focus-target {
|
||||
background: color-mix(in srgb, var(--primary) 16%, var(--accent-bg));
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 46%, transparent);
|
||||
animation: notification-focus-pulse 1.5s ease 2;
|
||||
}
|
||||
|
||||
.dose-person.taken {
|
||||
background: var(--success-bg);
|
||||
}
|
||||
|
||||
.dose-person.skipped {
|
||||
background: color-mix(in srgb, var(--warning) 20%, var(--accent-bg));
|
||||
}
|
||||
|
||||
.dose-person.overdue {
|
||||
background: var(--warning-bg);
|
||||
}
|
||||
@@ -2503,10 +2457,6 @@ button.has-validation-error {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.dose-person.skipped .person-name {
|
||||
color: color-mix(in srgb, var(--warning) 82%, var(--text-primary));
|
||||
}
|
||||
|
||||
.dose-person .dose-btn {
|
||||
margin-left: 0;
|
||||
height: 24px;
|
||||
@@ -2515,16 +2465,6 @@ button.has-validation-error {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
@keyframes notification-focus-pulse {
|
||||
0%,
|
||||
100% {
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 46%, transparent);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 5px color-mix(in srgb, var(--primary) 14%, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 769px) {
|
||||
.time-row {
|
||||
grid-template-columns: minmax(170px, 230px) 1fr;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import App from "../App";
|
||||
|
||||
@@ -59,15 +59,7 @@ vi.mock("../context", async () => {
|
||||
});
|
||||
|
||||
vi.mock("../pages", () => ({
|
||||
DashboardPage: () => {
|
||||
const location = useLocation();
|
||||
return (
|
||||
<div>
|
||||
<span>dashboard-page</span>
|
||||
<span data-testid="dashboard-location-search">{location.search}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
DashboardPage: () => <div>dashboard-page</div>,
|
||||
MedicationsPage: () => <div>medications-page</div>,
|
||||
PlannerPage: () => <div>planner-page</div>,
|
||||
SchedulePage: () => <div>schedule-page</div>,
|
||||
@@ -273,19 +265,6 @@ describe("App", () => {
|
||||
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preserves notification query params when redirecting root to dashboard", () => {
|
||||
const search = "?date=2026-05-06&medId=4332&doseId=4332-0-1778104500000";
|
||||
|
||||
render(
|
||||
<MemoryRouter initialEntries={[`/${search}`]}>
|
||||
<App />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
|
||||
expect(screen.getByTestId("dashboard-location-search")).toHaveTextContent(search);
|
||||
});
|
||||
|
||||
it("renders initializing state when auth state is missing", () => {
|
||||
authMock = {
|
||||
user: null,
|
||||
|
||||
@@ -175,10 +175,6 @@ describe("LoginForm", () => {
|
||||
oidcProviderName: "",
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
window.history.replaceState({}, "", "/");
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
(global.fetch as ReturnType<typeof vi.fn>)
|
||||
|
||||
@@ -37,7 +37,6 @@ vi.mock("../../hooks", () => ({
|
||||
|
||||
vi.mock("../../utils/formatters", () => ({
|
||||
getSystemLocale: () => "en-US",
|
||||
setDefaultFormattingTimezone: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../utils/schedule", async () => {
|
||||
|
||||
@@ -475,21 +475,6 @@ describe("MedicationsPage with items", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("opens read-only view from viewMedId query parameter", async () => {
|
||||
const startEdit = vi.fn();
|
||||
mockFormHookValue = createMockFormHook({ startEdit });
|
||||
fetchMock.mockResolvedValue({ ok: true, json: async () => mockMeds });
|
||||
|
||||
renderPage("/medications?viewMedId=1");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(startEdit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(screen.getByText("common.close")).toBeInTheDocument();
|
||||
expect(screen.queryByText("common.save")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens unsaved confirm and continues edit after confirmation", async () => {
|
||||
const startEdit = vi.fn();
|
||||
const resetForm = vi.fn();
|
||||
|
||||
@@ -103,12 +103,9 @@ const createMockContext = (overrides = {}) => ({
|
||||
pastDays: [],
|
||||
futureDays: [],
|
||||
takenDoses: new Set(),
|
||||
skippedDoses: new Set(),
|
||||
dismissedDoses: new Set(),
|
||||
markDoseTaken: vi.fn(),
|
||||
markDoseSkipped: vi.fn(),
|
||||
undoDoseTaken: vi.fn(),
|
||||
undoDoseSkipped: vi.fn(),
|
||||
coverageByMed: {},
|
||||
depletionByMed: {},
|
||||
manuallyExpandedDays: new Set(),
|
||||
@@ -677,144 +674,6 @@ describe("SchedulePage with taken doses", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("SchedulePage skip behavior", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
localStorage.clear();
|
||||
mockContextValue = createMockContext({
|
||||
meds: mockMeds,
|
||||
futureDays: mockFutureDays,
|
||||
coverageByMed: mockCoverageByMed,
|
||||
});
|
||||
});
|
||||
|
||||
it("shows a skip action alongside take for neutral doses", () => {
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(document.querySelector(".dose-btn.take")).toBeInTheDocument();
|
||||
expect(document.querySelector(".dose-btn.skip")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("calls markDoseSkipped when clicking skip", () => {
|
||||
const markDoseSkipped = vi.fn();
|
||||
mockContextValue = createMockContext({
|
||||
meds: mockMeds,
|
||||
futureDays: mockFutureDays,
|
||||
coverageByMed: mockCoverageByMed,
|
||||
markDoseSkipped,
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const skipButton = document.querySelector(".dose-btn.skip");
|
||||
expect(skipButton).toBeInTheDocument();
|
||||
|
||||
if (skipButton) {
|
||||
fireEvent.click(skipButton);
|
||||
}
|
||||
|
||||
expect(markDoseSkipped).toHaveBeenCalledWith(`1-0-${FIXED_TIMESTAMP}-John`);
|
||||
});
|
||||
|
||||
it("renders undo skip state for skipped doses", () => {
|
||||
const skippedDoseId = `1-0-${FIXED_TIMESTAMP}-John`;
|
||||
mockContextValue = createMockContext({
|
||||
meds: mockMeds,
|
||||
futureDays: mockFutureDays,
|
||||
coverageByMed: mockCoverageByMed,
|
||||
skippedDoses: new Set([skippedDoseId]),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
expect(document.querySelector(".dose-btn.undo.skip")).toBeInTheDocument();
|
||||
expect(screen.getByText("John").closest(".dose-person")).toHaveClass("skipped");
|
||||
});
|
||||
|
||||
it("calls undoDoseSkipped when clicking undo skip", () => {
|
||||
const skippedDoseId = `1-0-${FIXED_TIMESTAMP}-John`;
|
||||
const undoDoseSkipped = vi.fn();
|
||||
mockContextValue = createMockContext({
|
||||
meds: mockMeds,
|
||||
futureDays: mockFutureDays,
|
||||
coverageByMed: mockCoverageByMed,
|
||||
skippedDoses: new Set([skippedDoseId]),
|
||||
undoDoseSkipped,
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const undoSkipButton = document.querySelector(".dose-btn.undo.skip");
|
||||
expect(undoSkipButton).toBeInTheDocument();
|
||||
|
||||
if (undoSkipButton) {
|
||||
fireEvent.click(undoSkipButton);
|
||||
}
|
||||
|
||||
expect(undoDoseSkipped).toHaveBeenCalledWith(skippedDoseId);
|
||||
});
|
||||
|
||||
it("does not mark skipped due doses as overdue", () => {
|
||||
vi.useFakeTimers();
|
||||
const now = new Date("2026-01-22T12:00:00.000Z");
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const when = new Date("2026-01-22T09:00:00.000Z").getTime();
|
||||
const baseDoseId = `1-0-${when}`;
|
||||
const skippedDoseId = `${baseDoseId}-John`;
|
||||
const dueDay = [
|
||||
{
|
||||
dateStr: "Wed, Jan 22",
|
||||
date: new Date(now),
|
||||
isPast: false,
|
||||
meds: [
|
||||
{
|
||||
medName: "Aspirin",
|
||||
total: 1,
|
||||
doses: [{ id: baseDoseId, timeStr: "09:00", when, usage: 1, takenBy: ["John"] }],
|
||||
lastWhen: when,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
mockContextValue = createMockContext({
|
||||
meds: mockMeds,
|
||||
futureDays: dueDay,
|
||||
coverageByMed: mockCoverageByMed,
|
||||
skippedDoses: new Set([skippedDoseId]),
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<SchedulePage />
|
||||
</MemoryRouter>
|
||||
);
|
||||
|
||||
const personRow = screen.getByText("John").closest(".dose-person");
|
||||
expect(personRow).toHaveClass("skipped");
|
||||
expect(personRow).not.toHaveClass("overdue");
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
||||
describe("SchedulePage with low stock", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -59,43 +59,17 @@ const TIMEZONE_TO_REGION: Record<string, string> = {
|
||||
"Pacific/Auckland": "NZ",
|
||||
};
|
||||
|
||||
let defaultFormattingTimezone: string | null = null;
|
||||
|
||||
export function setDefaultFormattingTimezone(timezone: string | null | undefined): void {
|
||||
defaultFormattingTimezone = timezone?.trim() || null;
|
||||
}
|
||||
|
||||
export function getFormattingTimezone(): string | undefined {
|
||||
if (defaultFormattingTimezone) {
|
||||
return defaultFormattingTimezone;
|
||||
}
|
||||
|
||||
try {
|
||||
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function withFormattingTimezone(options: Intl.DateTimeFormatOptions): Intl.DateTimeFormatOptions {
|
||||
const timezone = getFormattingTimezone();
|
||||
if (!timezone) {
|
||||
return options;
|
||||
}
|
||||
|
||||
return {
|
||||
...options,
|
||||
timeZone: timezone,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get region code from timezone.
|
||||
* Returns undefined if timezone is not mapped.
|
||||
*/
|
||||
export function getRegionFromTimezone(): string | undefined {
|
||||
const timezone = getFormattingTimezone();
|
||||
return timezone ? TIMEZONE_TO_REGION[timezone] : undefined;
|
||||
try {
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
|
||||
return TIMEZONE_TO_REGION[timezone];
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,24 +2,6 @@ import { existsSync, readFileSync } from "fs";
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
function parseCsvEnv(value: string | undefined, fallback: string[]) {
|
||||
const entries = value
|
||||
?.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry) => entry.length > 0);
|
||||
|
||||
return entries && entries.length > 0 ? entries : fallback;
|
||||
}
|
||||
|
||||
function parseOptionalPort(value: string | undefined) {
|
||||
if (!value) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
|
||||
// Read version from package.json at build time
|
||||
const packageJson = JSON.parse(readFileSync("./package.json", "utf-8"));
|
||||
|
||||
@@ -27,19 +9,6 @@ const packageJson = JSON.parse(readFileSync("./package.json", "utf-8"));
|
||||
// In Docker, prefer backend-dev to avoid localhost proxy failures.
|
||||
const defaultBackendTarget = existsSync("/.dockerenv") ? "http://backend-dev:3000" : "http://localhost:3000";
|
||||
const backendTarget = process.env.BACKEND_URL || defaultBackendTarget;
|
||||
const allowedHosts = parseCsvEnv(process.env.VITE_ALLOWED_HOSTS, ["localhost", "127.0.0.1"]);
|
||||
const hmrHost = process.env.VITE_HMR_HOST?.trim();
|
||||
const hmrProtocol = process.env.VITE_HMR_PROTOCOL === "ws" ? "ws" : process.env.VITE_HMR_PROTOCOL === "wss" ? "wss" : undefined;
|
||||
const hmrClientPort = parseOptionalPort(process.env.VITE_HMR_CLIENT_PORT);
|
||||
const hmrPort = parseOptionalPort(process.env.VITE_HMR_PORT);
|
||||
const hmr = hmrHost
|
||||
? {
|
||||
host: hmrHost,
|
||||
protocol: hmrProtocol ?? "wss",
|
||||
clientPort: hmrClientPort ?? (hmrProtocol === "ws" ? 80 : 443),
|
||||
port: hmrPort ?? 5173,
|
||||
}
|
||||
: undefined;
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
@@ -50,8 +19,6 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
strictPort: true,
|
||||
allowedHosts,
|
||||
hmr,
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: backendTarget,
|
||||
|
||||
Generated
+122
-141
@@ -6,9 +6,9 @@
|
||||
"": {
|
||||
"name": "medassist-ng",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.15",
|
||||
"@biomejs/biome": "^2.4.14",
|
||||
"husky": "^9.1.0",
|
||||
"lint-staged": "^17.0.4"
|
||||
"lint-staged": "^16.4.0"
|
||||
}
|
||||
},
|
||||
"backend": {
|
||||
@@ -76,9 +76,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/biome": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.15.tgz",
|
||||
"integrity": "sha512-j5VH3a/h/HXTKBM50MDMxRCzkeLv9S2XJcW2WgnZT1+xyisi+0bISrXR82gCX+8S9lvK0skEvHJRN+3Ktr2hlw==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/biome/-/biome-2.4.14.tgz",
|
||||
"integrity": "sha512-TmAvxOEgrpLypzVGJ8FulIZnlyA9TxrO1hyqYrCz9r+bwma9xXxuLA5IuYnj55XQneFx460KjRbx6SWGLkg3bQ==",
|
||||
"dev": true,
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"bin": {
|
||||
@@ -92,20 +92,20 @@
|
||||
"url": "https://opencollective.com/biome"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@biomejs/cli-darwin-arm64": "2.4.15",
|
||||
"@biomejs/cli-darwin-x64": "2.4.15",
|
||||
"@biomejs/cli-linux-arm64": "2.4.15",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.4.15",
|
||||
"@biomejs/cli-linux-x64": "2.4.15",
|
||||
"@biomejs/cli-linux-x64-musl": "2.4.15",
|
||||
"@biomejs/cli-win32-arm64": "2.4.15",
|
||||
"@biomejs/cli-win32-x64": "2.4.15"
|
||||
"@biomejs/cli-darwin-arm64": "2.4.14",
|
||||
"@biomejs/cli-darwin-x64": "2.4.14",
|
||||
"@biomejs/cli-linux-arm64": "2.4.14",
|
||||
"@biomejs/cli-linux-arm64-musl": "2.4.14",
|
||||
"@biomejs/cli-linux-x64": "2.4.14",
|
||||
"@biomejs/cli-linux-x64-musl": "2.4.14",
|
||||
"@biomejs/cli-win32-arm64": "2.4.14",
|
||||
"@biomejs/cli-win32-x64": "2.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-arm64": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.15.tgz",
|
||||
"integrity": "sha512-rF3PPqLq1yoST79zaQbDjVJwsuIeci/O+9bgNmC5QpgOqz6aqYuzA4abyAGx+mgyiDXn4A049xAN8gijbuR1Qg==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-arm64/-/cli-darwin-arm64-2.4.14.tgz",
|
||||
"integrity": "sha512-XvgoE9XOawUOQPdmvs4J7wPhi/DLwSCGks3AlPJDmh34O0awRTqCED1HRcRDdpf1Zrp4us4MGOOdIxNpbqNF5Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -120,9 +120,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-darwin-x64": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.15.tgz",
|
||||
"integrity": "sha512-/5KHXYMfSJs1fNXiX30xFtI8JcCFV6zaVVLxOa0M2sfqBKHkpQhRTv94yxQWxeTY2lzo2OuTlNvPC+hDQt2wcQ==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-darwin-x64/-/cli-darwin-x64-2.4.14.tgz",
|
||||
"integrity": "sha512-jE7hKBCFhOx3uUh+ZkWBfOHxAcILPfhFplNkuID/eZeSTLHzfZzoZxW8fbqY9xXRnPi7jGNAf1iPVR+0yWsM/Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -137,9 +137,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.15.tgz",
|
||||
"integrity": "sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64/-/cli-linux-arm64-2.4.14.tgz",
|
||||
"integrity": "sha512-2TELhZnW5RSLL063l9rc5xLpA0ZIw0Ccwy/0q384rvNAgFw3yI76bd59547yxowdQr5MNPET/xDLrLuvgSeeWQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -154,9 +154,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-arm64-musl": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.15.tgz",
|
||||
"integrity": "sha512-ZPcxznxm0pogHBLZhYntyR3sR+MrZjqJIKEr7ZqVen0Rl+P/4upVmfYXjftizi9RoqZntg33fv/1fbdhbYXpEQ==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.4.14.tgz",
|
||||
"integrity": "sha512-/z+6gqAqqUQTHazwStxSXKHg9b8UvqBmDFRp+c4wYbq2KXhELQDon9EoC9RpmQ8JWkqQx/lIUy/cs+MhzDZp6A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -171,9 +171,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.15.tgz",
|
||||
"integrity": "sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64/-/cli-linux-x64-2.4.14.tgz",
|
||||
"integrity": "sha512-zHrlQZDBDUz4OLAraYpWKcnLS6HOewBFWYOzY91d1ZjdqZwibOyb6BEu6WuWLugyo0P3riCmsbV9UqV1cSXwQg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -188,9 +188,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-linux-x64-musl": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.15.tgz",
|
||||
"integrity": "sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-linux-x64-musl/-/cli-linux-x64-musl-2.4.14.tgz",
|
||||
"integrity": "sha512-R6BWgJdQOwW9ulJatuTVrQkjnODjqHZkKNOqb1sz++3Noe5LYd0i3PchnOBUCYAPHoPWHhjJqbdZlHEu0hpjdA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -205,9 +205,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-arm64": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.15.tgz",
|
||||
"integrity": "sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-arm64/-/cli-win32-arm64-2.4.14.tgz",
|
||||
"integrity": "sha512-M3EH5hqOI/F/FUA2u4xcLoUgmxd218mvuj/6JL7Hv2toQvr2/AdOvKSpGkoRuWFCtQPVa+ZqkEV3Q5xBA9+XSA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -222,9 +222,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@biomejs/cli-win32-x64": {
|
||||
"version": "2.4.15",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.15.tgz",
|
||||
"integrity": "sha512-zBrGq5mx5wwpnow4+2BxUvleDM+GNd4sLbPaMapsSLQLD0NGRCquqPBTgN+7XkUteHvj7M+BstuI8tmnV7+HgQ==",
|
||||
"version": "2.4.14",
|
||||
"resolved": "https://registry.npmjs.org/@biomejs/cli-win32-x64/-/cli-win32-x64-2.4.14.tgz",
|
||||
"integrity": "sha512-WL0EG5qE+EAKomGXbf2g6VnSKJhTL3tXC0QRzWRwA5VpjxNYa6H4P7ZWfymbGE4IhZZQi1KXQ2R0YjwInmz2fA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -297,14 +297,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cli-truncate": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz",
|
||||
"integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==",
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz",
|
||||
"integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"slice-ansi": "^8.0.0",
|
||||
"string-width": "^8.2.0"
|
||||
"slice-ansi": "^7.1.0",
|
||||
"string-width": "^8.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -313,6 +313,23 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/colorette": {
|
||||
"version": "2.0.20",
|
||||
"resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz",
|
||||
"integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "14.0.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
||||
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "10.6.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
|
||||
@@ -341,9 +358,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/get-east-asian-width": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
|
||||
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.4.0.tgz",
|
||||
"integrity": "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -386,45 +403,45 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lint-staged": {
|
||||
"version": "17.0.4",
|
||||
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.4.tgz",
|
||||
"integrity": "sha512-+rU9lSUyVOZ/hDUmRLVGzyS2v73cDdQjX+XQz1AaOdIE4RysLq0HoPW2HrrgeNCLklkhi904VBU1bmgWLHVnkA==",
|
||||
"version": "16.4.0",
|
||||
"resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz",
|
||||
"integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"listr2": "^10.2.1",
|
||||
"picomatch": "^4.0.4",
|
||||
"commander": "^14.0.3",
|
||||
"listr2": "^9.0.5",
|
||||
"picomatch": "^4.0.3",
|
||||
"string-argv": "^0.3.2",
|
||||
"tinyexec": "^1.1.2"
|
||||
"tinyexec": "^1.0.4",
|
||||
"yaml": "^2.8.2"
|
||||
},
|
||||
"bin": {
|
||||
"lint-staged": "bin/lint-staged.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.22.1"
|
||||
"node": ">=20.17"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/lint-staged"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"yaml": "^2.8.4"
|
||||
}
|
||||
},
|
||||
"node_modules/listr2": {
|
||||
"version": "10.2.1",
|
||||
"resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz",
|
||||
"integrity": "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==",
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz",
|
||||
"integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cli-truncate": "^5.2.0",
|
||||
"eventemitter3": "^5.0.4",
|
||||
"cli-truncate": "^5.0.0",
|
||||
"colorette": "^2.0.20",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"log-update": "^6.1.0",
|
||||
"rfdc": "^1.4.1",
|
||||
"wrap-ansi": "^10.0.0"
|
||||
"wrap-ansi": "^9.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.13.0"
|
||||
"node": ">=20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/log-update": {
|
||||
@@ -447,59 +464,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/log-update/node_modules/slice-ansi": {
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
|
||||
"integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"is-fullwidth-code-point": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/log-update/node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/log-update/node_modules/wrap-ansi": {
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.1",
|
||||
"string-width": "^7.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-function": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
|
||||
@@ -580,17 +544,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/slice-ansi": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz",
|
||||
"integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==",
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
|
||||
"integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.3",
|
||||
"is-fullwidth-code-point": "^5.1.0"
|
||||
"ansi-styles": "^6.2.1",
|
||||
"is-fullwidth-code-point": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/slice-ansi?sponsor=1"
|
||||
@@ -607,14 +571,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "8.2.1",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz",
|
||||
"integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==",
|
||||
"version": "8.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.1.1.tgz",
|
||||
"integrity": "sha512-KpqHIdDL9KwYk22wEOg/VIqYbrnLeSApsKT/bSj6Ez7pn3CftUiLAv2Lccpq1ALcpLV9UX1Ppn92npZWu2w/aw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"get-east-asian-width": "^1.5.0",
|
||||
"strip-ansi": "^7.1.2"
|
||||
"get-east-asian-width": "^1.3.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
@@ -624,13 +588,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
|
||||
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
|
||||
"version": "7.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
|
||||
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^6.2.2"
|
||||
"ansi-regex": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
@@ -640,9 +604,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
|
||||
"integrity": "sha512-dAqSqE/RabpBKI8+h26GfLq6Vb3JVXs30XYQjdMjaj/c2tS8IYYMbIzP599KtRj7c57/wYApb3QjgRgXmrCukA==",
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz",
|
||||
"integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -650,30 +614,47 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz",
|
||||
"integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==",
|
||||
"version": "9.0.2",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
|
||||
"integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^6.2.3",
|
||||
"string-width": "^8.2.0",
|
||||
"strip-ansi": "^7.1.2"
|
||||
"ansi-styles": "^6.2.1",
|
||||
"string-width": "^7.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/wrap-ansi/node_modules/string-width": {
|
||||
"version": "7.2.0",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
|
||||
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^10.3.0",
|
||||
"get-east-asian-width": "^1.0.0",
|
||||
"strip-ansi": "^7.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/yaml": {
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
|
||||
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"optional": true,
|
||||
"bin": {
|
||||
"yaml": "bin.mjs"
|
||||
},
|
||||
|
||||
+2
-2
@@ -7,9 +7,9 @@
|
||||
"lint:fix": "cd backend && npm run lint:fix && cd ../frontend && npm run lint:fix"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^2.4.15",
|
||||
"@biomejs/biome": "^2.4.14",
|
||||
"husky": "^9.1.0",
|
||||
"lint-staged": "^17.0.4"
|
||||
"lint-staged": "^16.4.0"
|
||||
},
|
||||
"overrides": {
|
||||
"yaml": "^2.8.3"
|
||||
|
||||
Reference in New Issue
Block a user