Compare commits

...

9 Commits

Author SHA1 Message Date
Daniel Volz de300ad919 chore: release v1.16.1 (#326) 2026-02-25 22:26:19 +01:00
Daniel Volz 06bf608913 fix: support core shoutrrr provider schemes (#317)
* fix: support core shoutrrr provider schemes

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

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

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

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

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

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

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

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

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

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

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

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

* fix: update placeholder text and fix FIELD_LIMITS test

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

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

---------

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

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-25 21:29:13 +01:00
39 changed files with 789 additions and 370 deletions
+5 -5
View File
@@ -13,6 +13,7 @@ You are the release manager for **MedAssist-ng**. Your job is to guide code from
## Critical Safety Rules
- **NEVER release, tag, push, or create PRs without explicit user confirmation at each step.** Always present your plan and wait for approval.
- **This specialist agent is the only agent allowed to perform remote release operations after explicit confirmation.**
- **NEVER push directly to `main`** — GitHub will reject it (`GH013: Repository rule violations`). All changes go through Pull Requests.
- **NEVER skip CI checks.** Wait for all status checks to pass before merging.
- **Testing ownership belongs to `@testing-manager`**. Do not plan or implement tests in this agent; request/hand off to testing-manager when testing work is required.
@@ -48,12 +49,11 @@ This repository intentionally uses only two operational agents for CI/CD handoff
- Never use `gh` commands that can open an interactive pager and block execution (requiring `q`).
- Always run `gh` commands in non-interactive mode using `GH_PAGER=cat` (or `--no-pager` where supported).
- Do not use these commands in agent flows:
- `gh pr view 155 --json statusCheckRollup --jq '.statusCheckRollup[] | {name:.name,conclusion:.conclusion,detailsUrl:.detailsUrl,workflowName:.workflowName}'`
- `SHA=$(gh pr view 155 --json headRefOid --jq .headRefOid) && gh api repos/DanielVolz/medassist-ng/commits/$SHA/check-runs --jq '.check_runs[] | {name,conclusion,details_url,html_url,app:.app.name}'`
- Use safe variants instead:
- Avoid hardcoded PR/repo examples in instructions; always use parameterized placeholders.
- Use safe command patterns:
- `GH_PAGER=cat gh pr view <PR_NUMBER> --json statusCheckRollup --jq '<jq-filter>'`
- `GH_PAGER=cat gh api repos/<owner>/<repo>/commits/<sha>/check-runs --jq '<jq-filter>'`
- `SHA=$(GH_PAGER=cat gh pr view <PR_NUMBER> --json headRefOid --jq .headRefOid)`
- `GH_PAGER=cat gh api repos/<owner>/<repo>/commits/$SHA/check-runs --jq '<jq-filter>'`
---
+5 -2
View File
@@ -15,6 +15,8 @@ You are the testing manager for **MedAssist-ng**. Your job is to ensure every fe
- **Tests are mandatory**: Every new feature and every bug fix MUST have corresponding tests.
- **Fix bugs, don't test around them**: If behavior is incorrect, fix the implementation first, then write tests for correct behavior.
- **Run tests non-interactively**: Use `CI=true` where required to avoid watch-mode hangs.
- **Playwright must disable auto-open reports**: Always prefix Playwright runs with `PLAYWRIGHT_HTML_OPEN=never`.
- **Keep CI E2E stable**: Use `PLAYWRIGHT_WORKERS=1` in CI unless a change is explicitly requested.
- **Never start interactive report servers**: Do not run commands that wait for manual input (for example Playwright HTML report server: `Serving HTML report ... Press Ctrl+C to quit`). Always use finite, non-interactive commands and reporters.
- **No remote git operations**: Do not push, merge, create PRs, tags, or releases. Hand over to `@release-manager` when ready.
- **Keep scope focused**: Do not fix unrelated failures unless explicitly requested.
@@ -66,8 +68,9 @@ cd frontend && npm run build
### Playwright E2E
```bash
cd frontend && npm run test:e2e
cd frontend && npm run test:e2e -- --project=chromium
cd frontend && PLAYWRIGHT_HTML_OPEN=never npm run test:e2e
cd frontend && PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=4 npm run test:e2e:local
cd frontend && PLAYWRIGHT_HTML_OPEN=never npm run test:e2e -- --project=chromium
# Never use interactive UI/headed/report-server commands in agent runs.
# Do not use: npm run test:e2e:ui, npm run test:e2e:headed, npx playwright show-report
```
+8 -72
View File
@@ -1,77 +1,13 @@
# MedAssist-ng - AI Coding Instructions
# MedAssist-ng - Copilot Entry Point
## Purpose
Use `AGENTS.md` as the single source of truth for all governance, workflow, and skill rules.
Use `AGENTS.md` as the canonical governance source. Read the referenced skill files before starting any task.
## Required Startup Steps
## Project Orientation (Read First)
1. Read `AGENTS.md` first.
2. Identify triggered skills from `AGENTS.md` and read each referenced `SKILL.md` before making changes.
3. Follow delegation boundaries exactly (`@testing-manager` for testing, `@release-manager` for release orchestration).
- **Product**: MedAssist-ng is a medication planner with stock tracking, reminders (email/push), refill history, and schedule sharing.
- **Tech stack**: React + TypeScript + Vite (`frontend/`), Fastify + TypeScript + Drizzle + SQLite (`backend/`).
- **Request path**: Frontend uses `/api/*` only; backend route handlers live in `backend/src/routes/`.
- **Primary backend modules**:
- Auth/SSO: `backend/src/routes/auth.ts`, `backend/src/routes/oidc.ts`, `backend/src/plugins/auth.ts`
- Medications/data: `backend/src/routes/medications.ts`, `backend/src/db/schema.ts`
- Reminders: `backend/src/services/reminder-scheduler.ts`, `backend/src/routes/planner.ts`, `backend/src/routes/settings.ts`
- **Primary frontend modules**:
- Pages: `frontend/src/pages/`
- Shared app state: `frontend/src/context/AppContext.tsx`
- Domain hooks: `frontend/src/hooks/`
- Translations: `frontend/src/i18n/en.json`, `frontend/src/i18n/de.json`
## Scope
Use this orientation for quick navigation before applying the rules below.
## Always-On Rules
- English only for project artifacts.
- **NEVER run remote git commands** — no `git push`, no `gh pr create/merge`, no `gh release`, no `git tag`. Prepare locally, then hand off to `@release-manager`.
- Testing work belongs to `@testing-manager`.
- PR/release/CI orchestration belongs to `@release-manager`.
- Keep changes local, focused, and consistent with existing UI/API patterns.
- **Hard PR scope + size rule**: one cohesive objective per PR; if scope drifts or diff becomes large (target <= 500 changed lines, hard split at ~800+), split into logical follow-up PRs instead of bundling.
- Remove obsolete code when re-implementing — never leave dead code behind.
- **Document behavioral discoveries**: When you discover or clarify how a feature works (e.g., what triggers notifications, how thresholds interact, which code paths exist), **always** add or update the relevant section in `doku/APP_BEHAVIOR.md`. This is mandatory — do not rely on conversation context alone.
## MedAssist Essentials
- Frontend calls backend through `/api/*`.
- DB changes must stay backward-compatible (schema default + alter migration + null-safe reads).
---
## Skills (MANDATORY — read before every task)
Before starting any task, identify which skills apply and **read their full SKILL.md file** for detailed rules.
| Skill | Trigger | File |
|---|---|---|
| **Architecture Guard** | API endpoints, frontend API calls, routing, code placement | `.github/skills/medassist-architecture-guard/SKILL.md` |
| **DB Compatibility** | Persisted data, schema changes, migrations | `.github/skills/medassist-db-compat-check/SKILL.md` |
| **i18n Enforcer** ⚠️ | Any user-facing text in frontend or backend | `.github/skills/medassist-i18n-enforcer/SKILL.md` |
| **UI Consistency** | UI flows, modals, buttons, forms, settings | `.github/skills/medassist-ui-consistency/SKILL.md` |
| **Frontend Polish** | Visual quality improvements | `.github/skills/medassist-frontend-polish/SKILL.md` |
| **Security Sanity** | Backend routes, auth, file handling, external input | `.github/skills/medassist-security-sanity/SKILL.md` |
| **Observability Guard** | Services, schedulers, startup, failure handling | `.github/skills/medassist-observability-guard/SKILL.md` |
| **Config Change Guard** | `.env`, Docker, Vite proxy, runtime defaults | `.github/skills/medassist-config-change-guard/SKILL.md` |
| **Doc Sync Guard** | Behavior changes, setup, env vars, workflows | `.github/skills/medassist-doc-sync-guard/SKILL.md` |
| **Testing Handoff** | Writing/running tests, CI test failures | `.github/skills/medassist-testing-handoff/SKILL.md` |
| **Release Handoff** | Branch push, PR, merge, tagging, release | `.github/skills/medassist-release-handoff/SKILL.md` |
| **Skill Quality Review** | Creating/modifying skills | `.github/skills/medassist-skill-quality-review/SKILL.md` |
### Non-negotiable parity rules (always apply)
1. **Desktop + Mobile Parity**: Medication edit has two paths — `MedicationsPage.tsx` (desktop) and `MobileEditModal` (mobile). **Always update BOTH**.
2. **Notification Dual Code Paths**: Notifications have two code paths — `backend/src/services/reminder-scheduler.ts` (scheduler) and `backend/src/routes/planner.ts` (manual). **Always update BOTH**.
---
## Delegation
- **Testing handoff → `@testing-manager`**: test planning, writing, execution, CI test triage.
- **Release handoff → `@release-manager`**: PR/release orchestration, merge flow, workflow monitoring.
## Key References
- Canonical governance: `AGENTS.md`
- Skill files: `.github/skills/*/SKILL.md`
- Specialist agents: `.github/agents/testing-manager.agent.md`, `.github/agents/release-manager.agent.md`
This file intentionally stays minimal to prevent duplicated or conflicting instructions.
+1 -1
View File
@@ -13,7 +13,7 @@ Use one governance source to avoid duplicated or conflicting policy text.
## Skills
- `medassist-karpathy-core` — enforce assumption clarity, simplicity, surgical diffs, and verifiable execution.
- `medassist-karpathy-core` — enforce think-before-coding, simplicity-first changes, surgical diffs, and goal-driven verification.
- `medassist-architecture-guard` — enforce frontend/backend boundary and `/api/*` data-flow conventions.
- `medassist-db-compat-check` — enforce backward-compatible SQLite/Drizzle schema changes.
- `medassist-i18n-enforcer` — enforce translation-key-only UI copy with EN/DE parity.
@@ -0,0 +1,69 @@
---
name: medassist-karpathy-core
description: Apply assumption clarity, simplicity-first implementation, surgical diffs, and goal-driven verification for non-trivial coding tasks.
---
# Skill Instructions
Use this skill as an execution style layer for implementation tasks where overengineering, broad refactors, or unclear assumptions are likely.
## Use When
- The request is ambiguous and assumptions must be made explicit.
- The change can easily balloon in scope.
- A bug fix or feature needs explicit success criteria and verification.
- You need to keep diffs minimal and directly tied to the request.
## Do Not Use When
- The task is trivial and can be completed safely without extra process overhead.
- The task is only about ownership routing (use `medassist-testing-handoff` / `medassist-release-handoff`).
- The task is only about domain guardrails already covered by specialized skills (architecture, DB, i18n, UI, security, config, observability).
## Core Principles
### 1. Think Before Coding
- Do not assume silently.
- State assumptions explicitly.
- If multiple interpretations exist, present them instead of picking one invisibly.
- If uncertain or blocked by ambiguity, stop and ask.
- If a simpler approach exists, call it out.
### 2. Simplicity First
- Implement the minimum code required to solve the asked problem.
- Do not add speculative features, abstractions, or configurability.
- Avoid defensive handling for impossible scenarios.
- If the solution feels overcomplicated, simplify before finalizing.
### 3. Surgical Changes
- Touch only lines required for the request.
- Do not refactor unrelated areas.
- Match existing local style and patterns.
- Remove only unused code introduced by your own change.
- If unrelated dead code is discovered, mention it but do not remove it unless requested.
### 4. Goal-Driven Execution
- Translate requests into verifiable outcomes before implementation.
- For multi-step tasks, define short steps with checks.
- Verify the requested behavior explicitly before declaring done.
Example execution frame:
```text
1. [Step] -> verify: [check]
2. [Step] -> verify: [check]
3. [Step] -> verify: [check]
```
## Response Format
When this skill is used, report briefly:
- Assumptions made (or clarifications requested)
- Why the chosen approach is the simplest viable one
- What was changed (and what was intentionally not changed)
- Verification performed and result
+2
View File
@@ -50,6 +50,8 @@ jobs:
run: npx playwright test --project=chromium
env:
CI: true
PLAYWRIGHT_WORKERS: 1
PLAYWRIGHT_HTML_OPEN: never
JWT_SECRET: e2e-test-secret-that-is-long-enough
SESSION_SECRET: e2e-test-session-secret-long-enough
+16 -1
View File
@@ -250,7 +250,9 @@ Generate secrets with: `openssl rand -hex 32`
MedAssist uses [Shoutrrr](https://containrrr.dev/shoutrrr/) for push notifications, supporting many services with a single URL format.
**Supported services:** ntfy, Pushover, Gotify, Discord, Telegram, Slack, Matrix, and [many more](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
**Implemented URL schemes in MedAssist:** `ntfy://`, `discord://`, `pushover://`, `gotify://`, `telegram://`, plus direct `https://` webhooks.
This covers common providers like ntfy, Discord, Pushover, Gotify, Telegram, Slack webhooks, and many others via webhook URLs.
Configure push notifications in Settings → Push, or set defaults via environment variables:
@@ -288,6 +290,7 @@ Get your keys at [pushover.net](https://pushover.net/):
**Gotify** (self-hosted):
```
gotify://your-server.com/TOKEN
gotify://your-server.com:443/path/to/gotify/TOKEN?priority=1
```
**Discord**:
@@ -298,6 +301,7 @@ discord://TOKEN@WEBHOOK_ID
**Telegram**:
```
telegram://TOKEN@telegram?chats=CHAT_ID
telegram://TOKEN@telegram?chats=@your_channel,-1001234567890
```
For all services and options, see the [Shoutrrr documentation](https://containrrr.dev/shoutrrr/v0.8/services/overview/).
@@ -311,6 +315,17 @@ docker compose -f docker-compose.dev.yml up
- Frontend: `http://localhost:5173` (hot reload)
- Backend: `http://localhost:3000`
Playwright E2E recommendations:
```bash
cd frontend
npm run test:e2e:local # local run with PLAYWRIGHT_WORKERS=4
npm run test:e2e:all:local # local all-browser run with PLAYWRIGHT_WORKERS=4
```
- CI stays at `PLAYWRIGHT_WORKERS=1` for stability.
- Data-heavy specs remain sequential via the `chromium-data` project config.
# Acknowledgements
This project was inspired by [MedAssist](https://github.com/njic/medassist) by njic.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "medassist-ng-backend",
"version": "1.15.1",
"version": "1.16.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "medassist-ng-backend",
"version": "1.15.1",
"version": "1.16.0",
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "medassist-ng-backend",
"version": "1.16.0",
"version": "1.16.1",
"private": true,
"type": "module",
"scripts": {
+9 -4
View File
@@ -57,6 +57,13 @@ function sanitizeCorrelationId(headers: IncomingHttpHeaders): string | null {
return trimmed;
}
function buildLoggerOptions(level: string) {
return {
level,
timestamp: () => `,"time":"${new Date().toISOString()}"`,
};
}
/** Create and configure Fastify app (without starting) */
export async function createApp(options?: {
logLevel?: string;
@@ -84,7 +91,7 @@ export async function createApp(options?: {
};
const app = Fastify({
logger: { level: opts.logLevel },
logger: buildLoggerOptions(opts.logLevel),
genReqId: (request) => sanitizeCorrelationId(request.headers) ?? randomUUID(),
});
@@ -157,9 +164,7 @@ log.info("[DB] Migrations complete, starting server...");
const imagesDir = ensureImagesDirectory();
const app = Fastify({
logger: {
level: env.LOG_LEVEL,
},
logger: buildLoggerOptions(env.LOG_LEVEL),
genReqId: (request) => sanitizeCorrelationId(request.headers) ?? randomUUID(),
});
+5 -1
View File
@@ -42,7 +42,7 @@ const medicationStartDateSchema = z
const medicationSchema = z
.object({
name: z.string().trim().min(1).max(100),
name: z.string().trim().max(100).default(""),
genericName: z.string().trim().max(100).nullable().optional(),
takenBy: z.array(z.string().trim().max(100)).default([]), // Medication-level takenBy (fallback)
packageType: packageTypeSchema,
@@ -66,6 +66,10 @@ const medicationSchema = z
intakes: z.array(intakeSchema).min(1).max(12).optional(),
blisters: z.array(blisterSchema).min(1).max(12).optional(), // Legacy format
})
.refine((data) => (data.name && data.name.length > 0) || (data.genericName && data.genericName.length > 0), {
message: "Either 'name' or 'genericName' must be provided",
path: ["name"],
})
.refine((data) => data.intakes || data.blisters, { message: "Either 'intakes' or 'blisters' must be provided" })
.refine(
(data) => {
+4 -4
View File
@@ -371,10 +371,10 @@ ${getFooterPlain(language)}`;
// Load user settings
const userId = await getUserId(request);
const activeMeds = await db
.select({ name: medications.name })
.select({ name: medications.name, genericName: medications.genericName })
.from(medications)
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
const activeMedNames = new Set(activeMeds.map((med) => med.name));
const activeMedNames = new Set(activeMeds.map((med) => med.name || med.genericName || ""));
const filteredLowStock = lowStock.filter((item) => activeMedNames.has(item.name));
if (filteredLowStock.length === 0) {
return reply.status(400).send({ error: "No active medications to notify" });
@@ -641,10 +641,10 @@ ${getFooterPlain(language)}`;
const userId = await getUserId(request);
const activeMeds = await db
.select({ name: medications.name })
.select({ name: medications.name, genericName: medications.genericName })
.from(medications)
.where(and(eq(medications.userId, userId), eq(medications.isObsolete, false)));
const activeMedNames = new Set(activeMeds.map((med) => med.name));
const activeMedNames = new Set(activeMeds.map((med) => med.name || med.genericName || ""));
const filteredPrescriptionLow = prescriptionLow.filter((item) => activeMedNames.has(item.name));
if (filteredPrescriptionLow.length === 0) {
return reply.status(400).send({ error: "No active medications to notify" });
+236 -35
View File
@@ -85,6 +85,21 @@ type TestShoutrrrBody = {
url: string;
};
function getNotificationProvider(url: string): string {
if (url.startsWith("discord://")) return "discord";
if (url.startsWith("telegram://")) return "telegram";
if (url.startsWith("gotify://")) return "gotify";
if (url.startsWith("pushover://")) return "pushover";
if (url.startsWith("ntfy://")) return "ntfy";
try {
const parsed = new URL(url);
return parsed.hostname || "https";
} catch {
return "unknown";
}
}
// Helper to parse boolean env vars
function envBool(key: string, defaultVal: boolean): boolean {
const val = process.env[key];
@@ -467,6 +482,7 @@ export async function settingsRoutes(app: FastifyInstance) {
}
try {
const provider = getNotificationProvider(url);
const result = await sendShoutrrrNotification(
url,
"MedAssist-ng Test",
@@ -474,11 +490,17 @@ export async function settingsRoutes(app: FastifyInstance) {
);
if (result.success) {
request.log.info({ provider }, "[Settings] Test push notification sent");
return reply.send({ success: true, message: "Test notification sent successfully" });
} else {
request.log.warn({ provider, error: result.error ?? "unknown" }, "[Settings] Test push notification failed");
return reply.status(500).send({ error: result.error });
}
} catch (error) {
request.log.error(
{ provider: getNotificationProvider(url), error },
"[Settings] Unexpected error while sending test push notification"
);
const errorMessage = error instanceof Error ? error.message : "Unknown error";
return reply.status(500).send({ error: `Failed to send notification: ${errorMessage}` });
}
@@ -491,6 +513,28 @@ function sanitizeNotificationUrl(
urlStr: string
): { url: string; isNtfy: boolean; auth?: { user: string; pass: string } } | { error: string } {
try {
// Support Shoutrrr Discord format: discord://TOKEN@WEBHOOK_ID
if (urlStr.startsWith("discord://")) {
const parsedDiscord = new URL(urlStr);
const webhookId = parsedDiscord.hostname;
const webhookToken = parsedDiscord.username;
if (!webhookId || !webhookToken) {
return { error: "Invalid Discord URL format" };
}
if (!/^\d+$/.test(webhookId)) {
return { error: "Invalid Discord webhook ID" };
}
if (!/^[A-Za-z0-9._-]+$/.test(webhookToken)) {
return { error: "Invalid Discord webhook token" };
}
const discordWebhookUrl = `https://discord.com/api/webhooks/${webhookId}/${webhookToken}`;
return { url: discordWebhookUrl, isNtfy: false };
}
// Convert ntfy:// to https:// for parsing, track if it was ntfy
const isNtfy = urlStr.startsWith("ntfy://");
const normalizedUrl = isNtfy ? urlStr.replace("ntfy://", "https://") : urlStr;
@@ -502,38 +546,9 @@ function sanitizeNotificationUrl(
return { error: "Only HTTP/HTTPS protocols are allowed" };
}
// Block private/internal IP addresses
const hostname = parsed.hostname.toLowerCase();
// Block localhost
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
return { error: "Localhost URLs are not allowed" };
}
// Block private IP ranges (basic check)
const ipMatch = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
if (ipMatch) {
const [, a, b] = ipMatch.map(Number);
// 10.x.x.x, 172.16-31.x.x, 192.168.x.x, 169.254.x.x (link-local)
if (
a === 10 ||
a === 127 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254)
) {
return { error: "Private IP addresses are not allowed" };
}
}
// Block common internal hostnames
if (
hostname.endsWith(".local") ||
hostname.endsWith(".internal") ||
hostname.endsWith(".lan") ||
hostname === "metadata.google.internal"
) {
return { error: "Internal hostnames are not allowed" };
const hostValidationError = validateNotificationHostname(parsed.hostname);
if (hostValidationError) {
return { error: hostValidationError };
}
// Reconstruct URL from validated components - this breaks taint tracking
@@ -550,6 +565,39 @@ function sanitizeNotificationUrl(
}
}
function validateNotificationHostname(hostnameRaw: string): string | null {
const hostname = hostnameRaw.toLowerCase();
if (hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1") {
return "Localhost URLs are not allowed";
}
const ipMatch = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
if (ipMatch) {
const [, a, b] = ipMatch.map(Number);
if (
a === 10 ||
a === 127 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254)
) {
return "Private IP addresses are not allowed";
}
}
if (
hostname.endsWith(".local") ||
hostname.endsWith(".internal") ||
hostname.endsWith(".lan") ||
hostname === "metadata.google.internal"
) {
return "Internal hostnames are not allowed";
}
return null;
}
// Send notification via Shoutrrr-compatible URL (supports ntfy, Discord, Telegram, etc.)
export async function sendShoutrrrNotification(
urlStr: string,
@@ -557,6 +605,149 @@ export async function sendShoutrrrNotification(
message: string
): Promise<{ success: boolean; error?: string }> {
try {
if (urlStr.startsWith("pushover://")) {
const pushoverAuthority = urlStr.slice("pushover://".length).split("/")[0] ?? "";
const atIndex = pushoverAuthority.lastIndexOf("@");
const credentialPart = atIndex >= 0 ? pushoverAuthority.slice(0, atIndex) : "";
const userKey = atIndex >= 0 ? pushoverAuthority.slice(atIndex + 1) : "";
const tokenSeparatorIndex = credentialPart.indexOf(":");
const apiToken = tokenSeparatorIndex >= 0 ? credentialPart.slice(tokenSeparatorIndex + 1) : "";
const parsedPushover = new URL(urlStr);
if (!apiToken || !userKey) {
return { success: false, error: "Invalid Pushover URL format" };
}
const pushoverBody = new URLSearchParams({
token: apiToken,
user: userKey,
title,
message,
});
const devices = parsedPushover.searchParams.get("devices");
if (devices) {
pushoverBody.set("device", devices);
}
const priority = parsedPushover.searchParams.get("priority");
if (priority && /^-?\d+$/.test(priority)) {
pushoverBody.set("priority", priority);
}
const response = await fetch("https://api.pushover.net/1/messages.json", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: pushoverBody.toString(),
redirect: "error",
});
if (response.ok) return { success: true };
const errorText = await response.text();
return { success: false, error: `HTTP ${response.status}: ${errorText}` };
}
if (urlStr.startsWith("telegram://")) {
const parsedTelegram = new URL(urlStr);
const token = parsedTelegram.username;
if (!token || parsedTelegram.hostname !== "telegram") {
return { success: false, error: "Invalid Telegram URL format" };
}
const chatsRaw = parsedTelegram.searchParams.get("chats") ?? parsedTelegram.searchParams.get("channels") ?? "";
const chats = chatsRaw
.split(",")
.map((chat) => chat.trim())
.filter(Boolean);
if (chats.length === 0) {
return { success: false, error: "Telegram URL requires chats parameter" };
}
const parseModeRaw = parsedTelegram.searchParams.get("parseMode")?.toLowerCase();
let parseMode: "HTML" | "Markdown" | "MarkdownV2" | undefined;
if (parseModeRaw === "html") {
parseMode = "HTML";
} else if (parseModeRaw === "markdown") {
parseMode = "Markdown";
} else if (parseModeRaw === "markdownv2") {
parseMode = "MarkdownV2";
}
const notificationRaw = parsedTelegram.searchParams.get("notification")?.toLowerCase();
const disableNotification = notificationRaw === "no" || notificationRaw === "false";
const previewRaw = parsedTelegram.searchParams.get("preview")?.toLowerCase();
const disablePreview = previewRaw === "no" || previewRaw === "false";
if (!/^\d+:[A-Za-z0-9_-]+$/.test(token)) {
return { success: false, error: "Invalid Telegram token format" };
}
const telegramSendMessageUrl = new URL("/bot/sendMessage", "https://api.telegram.org");
telegramSendMessageUrl.pathname = `/bot${token}/sendMessage`;
for (const chatId of chats) {
const payload: Record<string, string | boolean> = {
chat_id: chatId,
text: `${title}\n\n${message}`,
disable_notification: disableNotification,
disable_web_page_preview: disablePreview,
};
if (parseMode) {
payload.parse_mode = parseMode;
}
// codeql[js/request-forgery]: host is fixed to api.telegram.org and token is pattern-validated.
const response = await fetch(telegramSendMessageUrl.toString(), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
redirect: "error",
});
if (!response.ok) {
const errorText = await response.text();
return { success: false, error: `HTTP ${response.status}: ${errorText}` };
}
}
return { success: true };
}
if (urlStr.startsWith("gotify://")) {
const parsedGotify = new URL(urlStr);
const hostValidationError = validateNotificationHostname(parsedGotify.hostname);
if (hostValidationError) {
return { success: false, error: hostValidationError };
}
const pathParts = parsedGotify.pathname
.split("/")
.map((part) => part.trim())
.filter(Boolean);
if (pathParts.length === 0) {
return { success: false, error: "Invalid Gotify URL format" };
}
const token = pathParts[pathParts.length - 1];
const basePath = pathParts.slice(0, -1).join("/");
const disableTlsRaw = parsedGotify.searchParams.get("disabletls")?.toLowerCase();
const protocol = disableTlsRaw === "yes" || disableTlsRaw === "true" || disableTlsRaw === "1" ? "http" : "https";
const gotifyWebhookUrl = `${protocol}://${parsedGotify.host}${basePath ? `/${basePath}` : ""}/message?token=${encodeURIComponent(token)}`;
const gotifyPriority = parsedGotify.searchParams.get("priority");
const gotifyMessage = gotifyPriority ? `${message}\n\n(priority=${gotifyPriority})` : message;
// Reuse validated https webhook path to keep a single outbound request sink.
return sendShoutrrrNotification(gotifyWebhookUrl, title, gotifyMessage);
}
// Validate and sanitize URL to prevent SSRF - this reconstructs the URL
// from validated components, breaking taint tracking
const validation = sanitizeNotificationUrl(urlStr);
@@ -584,14 +775,17 @@ export async function sendShoutrrrNotification(
// Use JSON format only for known webhook services that require it
// Use proper URL parsing to prevent bypass attacks (e.g., evil.com?hooks.slack.com)
let isJsonWebhook = false;
let isDiscordWebhook = false;
try {
const parsedUrl = new URL(sanitizedUrl);
const hostname = parsedUrl.hostname.toLowerCase();
const pathname = parsedUrl.pathname.toLowerCase();
isDiscordWebhook =
(hostname === "discord.com" || hostname === "discordapp.com") && pathname.startsWith("/api/webhooks");
isJsonWebhook =
// Discord webhooks
((hostname === "discord.com" || hostname === "discordapp.com") && pathname.startsWith("/api/webhooks")) ||
isDiscordWebhook ||
// Slack webhooks
hostname === "hooks.slack.com" ||
hostname.endsWith(".hooks.slack.com") ||
@@ -621,9 +815,16 @@ export async function sendShoutrrrNotification(
} else if (sanitizedUrl.startsWith("http://") || sanitizedUrl.startsWith("https://")) {
targetUrl = sanitizedUrl;
headers = { "Content-Type": "application/json" };
body = JSON.stringify({ title, message, text: `${title}\n\n${message}` });
if (isDiscordWebhook) {
body = JSON.stringify({ content: `${title}\n\n${message}` });
} else {
body = JSON.stringify({ title, message, text: `${title}\n\n${message}` });
}
} else {
return { success: false, error: "Unsupported URL format. Use ntfy:// or https:// URL" };
return {
success: false,
error: "Unsupported URL format. Use ntfy://, discord://, pushover://, gotify://, telegram://, or https:// URL",
};
}
// SSRF protection: targetUrl is reconstructed from sanitizeNotificationUrl() which validates:
@@ -106,8 +106,9 @@ async function autoMarkDueIntakesAsTaken(
}
const medicationTakenBy = parseTakenByJson(med.takenByJson);
const medDisplayName = med.name || med.genericName || "";
const todaysIntakes = getTodaysIntakes(
med.name,
medDisplayName,
intakes,
medicationTakenBy,
med.pillWeightMg,
@@ -415,9 +416,10 @@ async function checkAndSendIntakeRemindersForUser(
);
// Medication-level takenBy (for fallback/display purposes)
const medicationTakenBy = parseTakenByJson(med.takenByJson);
const medDisplayName = med.name || med.genericName || "";
logger.debug(
`[IntakeReminder] User ${settings.userId}: Processing medication "${med.name}" with ${intakes.length} intakes`
`[IntakeReminder] User ${settings.userId}: Processing medication "${medDisplayName}" with ${intakes.length} intakes`
);
// Filter intakes that have reminders enabled (per-intake setting or medication-level)
@@ -438,7 +440,7 @@ async function checkAndSendIntakeRemindersForUser(
// Always get upcoming intakes (15 min before) for first reminders
const upcomingIntakes = getUpcomingIntakes(
med.name,
medDisplayName,
[intake],
REMINDER_MINUTES_BEFORE,
medicationTakenBy,
@@ -465,7 +467,7 @@ async function checkAndSendIntakeRemindersForUser(
// If repeat reminders enabled, also check for missed intakes (past the intake time)
if (settings.repeatRemindersEnabled) {
const allTodaysIntakes = getTodaysIntakes(
med.name,
medDisplayName,
[intake],
medicationTakenBy,
med.pillWeightMg,
+168 -123
View File
@@ -724,85 +724,113 @@ async function checkAndSendReminderForUser(
);
} else {
try {
logger.info(
`[Reminder] User ${settings.userId}: Sending prescription reminder for ${allPrescriptionLow.length} medications...`
);
// Re-check using fresh state after acquiring lock and pre-mark today as notified.
// This blocks duplicate sends when two reminder checks overlap in time.
const lockedState = loadReminderState();
const alreadyNotified = lockedState.notifiedMedications.includes(userPrescriptionNotifiedKey);
const shouldSend = !alreadyNotified || settings.repeatDailyReminders;
if (!shouldSend) {
logger.debug(
`[Reminder] User ${settings.userId}: prescription reminder already marked as sent today, skipping`
);
}
const emptyRx = allPrescriptionLow.filter((m) => m.remainingRefills <= 0);
const lowRx = allPrescriptionLow.filter((m) => m.remainingRefills > 0);
const lines = allPrescriptionLow.map((m) => {
const expirySuffix = m.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: m.expiryDate }) : "";
if (m.remainingRefills <= 0) {
return `- ${t(tr.prescriptionReminder.lineEmpty, {
const preMarkedNotified =
!shouldSend || alreadyNotified
? lockedState.notifiedMedications
: [...new Set([...lockedState.notifiedMedications, userPrescriptionNotifiedKey])];
if (shouldSend && !alreadyNotified) {
saveReminderState({
lastAutoEmailSent: lockedState.lastAutoEmailSent,
lastAutoEmailDate: lockedState.lastAutoEmailDate,
lastStockSchedulerCheckDate: lockedState.lastStockSchedulerCheckDate,
notifiedMedications: preMarkedNotified,
nextScheduledCheck: lockedState.nextScheduledCheck,
lastNotificationType: lockedState.lastNotificationType,
lastNotificationChannel: lockedState.lastNotificationChannel,
});
}
if (shouldSend) {
logger.info(
`[Reminder] User ${settings.userId}: Sending prescription reminder for ${allPrescriptionLow.length} medications...`
);
const emptyRx = allPrescriptionLow.filter((m) => m.remainingRefills <= 0);
const lowRx = allPrescriptionLow.filter((m) => m.remainingRefills > 0);
const lines = allPrescriptionLow.map((m) => {
const expirySuffix = m.expiryDate ? t(tr.prescriptionReminder.expiresSuffix, { date: m.expiryDate }) : "";
if (m.remainingRefills <= 0) {
return `- ${t(tr.prescriptionReminder.lineEmpty, {
name: m.name,
expirySuffix,
})}`;
}
return `- ${t(tr.prescriptionReminder.line, {
name: m.name,
refills: m.remainingRefills,
expirySuffix,
})}`;
}
return `- ${t(tr.prescriptionReminder.line, {
name: m.name,
refills: m.remainingRefills,
expirySuffix,
})}`;
});
});
let emailSuccess = false;
let shoutrrrSuccess = false;
let emailSuccess = false;
let shoutrrrSuccess = false;
if (prescriptionEmailEnabled) {
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
const smtpSecure = process.env.SMTP_SECURE === "true";
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
if (prescriptionEmailEnabled) {
const smtpHost = process.env.SMTP_HOST;
const smtpUser = process.env.SMTP_USER;
const smtpPass = process.env.SMTP_TOKEN || process.env.SMTP_PASS;
const smtpPort = parseInt(process.env.SMTP_PORT ?? "587", 10);
const smtpSecure = process.env.SMTP_SECURE === "true";
const smtpFrom = process.env.SMTP_FROM ?? smtpUser;
if (smtpHost && smtpUser) {
try {
const transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: { user: smtpUser, pass: smtpPass ?? "" },
});
if (smtpHost && smtpUser) {
try {
const transporter = nodemailer.createTransport({
host: smtpHost,
port: smtpPort,
secure: smtpSecure,
auth: { user: smtpUser, pass: smtpPass ?? "" },
});
const subject =
allPrescriptionLow.length === 1
? tr.prescriptionReminder.subjectSingle
: t(tr.prescriptionReminder.subjectMultiple, { count: allPrescriptionLow.length });
const subject =
allPrescriptionLow.length === 1
? tr.prescriptionReminder.subjectSingle
: t(tr.prescriptionReminder.subjectMultiple, { count: allPrescriptionLow.length });
const bodyText =
emptyRx.length > 0
? tr.prescriptionReminder.descriptionEmpty
: tr.prescriptionReminder.descriptionLow;
const emptyAlert =
emptyRx.length === 1
? tr.prescriptionReminder.alertEmptySingle
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length });
const lowAlert =
lowRx.length === 1
? tr.prescriptionReminder.alertLowSingle
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert;
const bodyText =
emptyRx.length > 0
? tr.prescriptionReminder.descriptionEmpty
: tr.prescriptionReminder.descriptionLow;
const emptyAlert =
emptyRx.length === 1
? tr.prescriptionReminder.alertEmptySingle
: t(tr.prescriptionReminder.alertEmptyMultiple, { count: emptyRx.length });
const lowAlert =
lowRx.length === 1
? tr.prescriptionReminder.alertLowSingle
: t(tr.prescriptionReminder.alertLowMultiple, { count: lowRx.length });
const alertText = emptyRx.length > 0 ? emptyAlert : lowAlert;
const tableRows = allPrescriptionLow
.map((item) => {
const isEmpty = item.remainingRefills <= 0;
const safeName = escapeHtml(item.name);
const safeRefills = Number(item.remainingRefills) || 0;
const safeThreshold = Number(item.lowThreshold) || 0;
const safeExpiry = item.expiryDate ? escapeHtml(String(item.expiryDate)) : "-";
const rowBg = isEmpty ? "#fef2f2" : "white";
return `
const tableRows = allPrescriptionLow
.map((item) => {
const isEmpty = item.remainingRefills <= 0;
const safeName = escapeHtml(item.name);
const safeRefills = Number(item.remainingRefills) || 0;
const safeThreshold = Number(item.lowThreshold) || 0;
const safeExpiry = item.expiryDate ? escapeHtml(String(item.expiryDate)) : "-";
const rowBg = isEmpty ? "#fef2f2" : "white";
return `
<tr style="background: ${rowBg};">
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; white-space: nowrap;">${isEmpty ? "🚨" : "⚠️"} ${safeName}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap; ${isEmpty ? "color: #dc2626; font-weight: 600;" : ""}"><strong>${safeRefills}</strong></td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeThreshold}</td>
<td style="padding: 10px 12px; border-bottom: 1px solid #e5e7eb; text-align: center; white-space: nowrap;">${safeExpiry}</td>
</tr>`;
})
.join("");
})
.join("");
const html = `
const html = `
<div style="font-family: system-ui, -apple-system, sans-serif; max-width: 100%; margin: 0 auto; padding: 12px; background: #f9fafb;">
<div style="background: white; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,0.1);">
<h2 style="color: #1f2937; margin: 0 0 8px; font-size: 18px;">${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}</h2>
@@ -842,76 +870,93 @@ async function checkAndSendReminderForUser(
</div>
</div>
`;
const text = `${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}\n\n${bodyText}\n\n${lines.join("\n")}\n\n---\n${getFooterPlain(language)}${settings.repeatDailyReminders ? `\n\n${tr.prescriptionReminder.repeatDailyNote}` : ""}`;
const text = `${emptyRx.length > 0 ? tr.prescriptionReminder.titleEmpty : tr.prescriptionReminder.title}\n\n${bodyText}\n\n${lines.join("\n")}\n\n---\n${getFooterPlain(language)}${settings.repeatDailyReminders ? `\n\n${tr.prescriptionReminder.repeatDailyNote}` : ""}`;
await transporter.sendMail({
from: smtpFrom,
to: settings.notificationEmail!,
subject,
text,
html,
});
emailSuccess = true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
logger.error(`[Reminder] User ${settings.userId}: Failed to send prescription email: ${errorMessage}`);
await transporter.sendMail({
from: smtpFrom,
to: settings.notificationEmail!,
subject,
text,
html,
});
emailSuccess = true;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : "Unknown error";
logger.error(
`[Reminder] User ${settings.userId}: Failed to send prescription email: ${errorMessage}`
);
}
}
}
}
if (prescriptionPushEnabled) {
const titleParts: string[] = [];
if (emptyRx.length > 0)
titleParts.push(
`🚨 ${emptyRx.length} ${emptyRx.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}`
);
if (lowRx.length > 0)
titleParts.push(
`🚨 ${lowRx.length} ${lowRx.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}`
);
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`;
const messageParts: string[] = [];
if (emptyRx.length > 0) {
messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`);
for (const m of emptyRx) {
messageParts.push(`${m.name}`);
}
}
if (lowRx.length > 0) {
if (emptyRx.length > 0) messageParts.push("");
messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`);
for (const m of lowRx) {
messageParts.push(
`${m.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: m.remainingRefills })}`
if (prescriptionPushEnabled) {
const titleParts: string[] = [];
if (emptyRx.length > 0)
titleParts.push(
`🚨 ${emptyRx.length} ${emptyRx.length === 1 ? tr.prescriptionReminder.pushEmptySingle : tr.prescriptionReminder.pushEmpty}`
);
if (lowRx.length > 0)
titleParts.push(
`🚨 ${lowRx.length} ${lowRx.length === 1 ? tr.prescriptionReminder.pushLowSingle : tr.prescriptionReminder.pushLow}`
);
const title = `MedAssist-ng: ${titleParts.join(", ")} - ${tr.prescriptionReminder.pushRenewNow}`;
const messageParts: string[] = [];
if (emptyRx.length > 0) {
messageParts.push(`🚨 ${tr.prescriptionReminder.pushEmptySection}:`);
for (const m of emptyRx) {
messageParts.push(`${m.name}`);
}
}
if (lowRx.length > 0) {
if (emptyRx.length > 0) messageParts.push("");
messageParts.push(`🚨 ${tr.prescriptionReminder.pushLowSection}:`);
for (const m of lowRx) {
messageParts.push(
`${m.name}: ${t(tr.prescriptionReminder.pushRefillsLeft, { count: m.remainingRefills })}`
);
}
}
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
shoutrrrSuccess = result.success;
if (!result.success) {
logger.error(`[Reminder] User ${settings.userId}: Failed to send prescription push: ${result.error}`);
}
}
const message = `${messageParts.join("\n")}\n\n---\n${getFooterPlain(language)}`;
const result = await sendShoutrrrNotification(settings.shoutrrrUrl!, title, message);
shoutrrrSuccess = result.success;
if (!result.success) {
logger.error(`[Reminder] User ${settings.userId}: Failed to send prescription push: ${result.error}`);
if (emailSuccess || shoutrrrSuccess) {
const currentState = loadReminderState();
const singleChannel = emailSuccess ? "email" : "push";
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
saveReminderState({
lastAutoEmailSent: new Date().toISOString(),
lastAutoEmailDate: today,
lastStockSchedulerCheckDate: currentState.lastStockSchedulerCheckDate,
notifiedMedications: [...new Set([...currentState.notifiedMedications, userPrescriptionNotifiedKey])],
nextScheduledCheck: currentState.nextScheduledCheck,
lastNotificationType: "prescription",
lastNotificationChannel: channel,
});
const medNames = allPrescriptionLow.map((m) => m.name).join(", ");
await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames);
} else if (!alreadyNotified) {
// Roll back pre-mark when both channels failed so retries remain possible.
const currentState = loadReminderState();
saveReminderState({
lastAutoEmailSent: currentState.lastAutoEmailSent,
lastAutoEmailDate: currentState.lastAutoEmailDate,
lastStockSchedulerCheckDate: currentState.lastStockSchedulerCheckDate,
notifiedMedications: currentState.notifiedMedications.filter(
(key) => key !== userPrescriptionNotifiedKey
),
nextScheduledCheck: currentState.nextScheduledCheck,
lastNotificationType: currentState.lastNotificationType,
lastNotificationChannel: currentState.lastNotificationChannel,
});
}
}
if (emailSuccess || shoutrrrSuccess) {
const currentState = loadReminderState();
const singleChannel = emailSuccess ? "email" : "push";
const channel = emailSuccess && shoutrrrSuccess ? "both" : singleChannel;
saveReminderState({
lastAutoEmailSent: new Date().toISOString(),
lastAutoEmailDate: today,
lastStockSchedulerCheckDate: currentState.lastStockSchedulerCheckDate,
notifiedMedications: [...new Set([...currentState.notifiedMedications, userPrescriptionNotifiedKey])],
nextScheduledCheck: currentState.nextScheduledCheck,
lastNotificationType: "prescription",
lastNotificationChannel: channel,
});
const medNames = allPrescriptionLow.map((m) => m.name).join(", ");
await updateUserReminderSentTime(settings.userId, "prescription", channel, medNames);
}
} finally {
releaseReminderSendLock(prescriptionSendLock);
}
+5 -1
View File
@@ -122,7 +122,11 @@ export function getNextScheduledTime(reminderHour: number, tz?: string): Date {
/** Calculate milliseconds until next check at the given reminder hour */
export function getMsUntilNextCheck(reminderHour: number, tz?: string): number {
const next = getNextScheduledTime(reminderHour, tz);
return next.getTime() - Date.now();
const msUntilNext = next.getTime() - Date.now();
if (msUntilNext <= 0) {
return msUntilNext + 24 * 60 * 60 * 1000;
}
return msUntilNext;
}
// =============================================================================
+3 -1
View File
@@ -1,7 +1,7 @@
import * as fs from "node:fs";
import * as path from "node:path";
import { expect, test as setup } from "@playwright/test";
import { TEST_USER } from "./fixtures";
import { applyVideoSafetyMode, TEST_USER } from "./fixtures";
const authFile = path.join(import.meta.dirname, ".auth", "user.json");
@@ -33,6 +33,8 @@ function isTokenValid(token: string): boolean {
* 4. Log in via the UI.
*/
setup("authenticate", async ({ page }) => {
await applyVideoSafetyMode(page);
// Create .auth directory if it doesn't exist
const authDir = path.dirname(authFile);
if (!fs.existsSync(authDir)) {
+24
View File
@@ -60,6 +60,29 @@ async function setupAuthMeMock(page: Page): Promise<void> {
}
}
/**
* Reduce visual flashing in recorded videos by forcing a dark first paint and
* disabling most animations/transitions in test mode.
*/
export async function applyVideoSafetyMode(page: Page): Promise<void> {
await page.emulateMedia({ reducedMotion: "reduce", colorScheme: "dark" });
await page.addInitScript(() => {
const style = document.createElement("style");
style.id = "pw-video-safety-style";
style.textContent = `
html, body {
background: #111111 !important;
color-scheme: dark !important;
}
*, *::before, *::after {
animation: none !important;
transition: none !important;
}
`;
document.documentElement.appendChild(style);
});
}
/**
* Extended test fixture that automatically mocks /auth/me on every page
* using user data from the JWT in the stored auth file.
@@ -72,6 +95,7 @@ async function setupAuthMeMock(page: Page): Promise<void> {
*/
export const test = base.extend<object>({
page: async ({ page }, use) => {
await applyVideoSafetyMode(page);
await setupAuthMeMock(page);
await use(page);
},
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "medassist-ng-frontend",
"version": "1.15.1",
"version": "1.16.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "medassist-ng-frontend",
"version": "1.15.1",
"version": "1.16.0",
"dependencies": {
"i18next": "^25.8.13",
"i18next-browser-languagedetector": "^8.2.1",
+3 -1
View File
@@ -1,7 +1,7 @@
{
"name": "medassist-ng-frontend",
"private": true,
"version": "1.16.0",
"version": "1.16.1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -16,6 +16,8 @@
"test:coverage": "vitest run --coverage",
"test:e2e": "rm -rf test-results && playwright test --config=playwright.stable.config.ts",
"test:e2e:all": "rm -rf test-results && playwright test --config=playwright.all.config.ts",
"test:e2e:local": "PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=4 npm run test:e2e",
"test:e2e:all:local": "PLAYWRIGHT_HTML_OPEN=never PLAYWRIGHT_WORKERS=4 npm run test:e2e:all",
"test:e2e:with-video": "npm run test:e2e && npm run test:e2e:video",
"test:e2e:all:with-video": "npm run test:e2e:all && npm run test:e2e:video",
"test:e2e:video": "find \"$PWD/test-results\" -name video.webm -not -path '*retry*' -print0 | xargs -0 ls -tr > /tmp/e2e-videos.list && if [ -s /tmp/e2e-videos.list ]; then sed \"s/^/file '/\" /tmp/e2e-videos.list | sed \"s/$/'/\" > /tmp/e2e-videos.txt && ffmpeg -y -f concat -safe 0 -i /tmp/e2e-videos.txt -c copy test-results/all-tests.webm; else echo 'No videos found to merge'; fi",
+3 -1
View File
@@ -6,6 +6,8 @@ export function buildPlaywrightConfig(runAllBrowsers: boolean) {
? ((globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env ?? {})
: {};
const baseURL = env.PLAYWRIGHT_BASE_URL || "http://localhost:5173";
const parsedWorkers = Number.parseInt(env.PLAYWRIGHT_WORKERS ?? "", 10);
const workers = Number.isFinite(parsedWorkers) && parsedWorkers > 0 ? parsedWorkers : env.CI ? 1 : 4;
const projects: NonNullable<PlaywrightTestConfig["projects"]> = [
{
@@ -64,7 +66,7 @@ export function buildPlaywrightConfig(runAllBrowsers: boolean) {
fullyParallel: true,
forbidOnly: !!env.CI,
retries: env.CI ? 2 : 0,
workers: 1,
workers,
reporter: env.CI
? [["html", { outputFolder: "playwright-report" }], ["github"]]
: [["html", { outputFolder: "playwright-report" }], ["list"]],
+14 -8
View File
@@ -15,7 +15,7 @@ import { useTranslation } from "react-i18next";
import { Lightbox, MedicationAvatar } from "../components";
import { useEscapeKey } from "../hooks";
import type { Coverage, Medication, RefillEntry, StockThresholds } from "../types";
import { getMedTotal, getPackageSize } from "../types";
import { getMedDisplayName, getMedTotal, getPackageSize } from "../types";
import { formatNumber, generateICS, getExpiryClass, getSystemLocale } from "../utils";
import { getStockStatus } from "../utils/schedule";
import { splitCurrentBlisterStock } from "../utils/stock";
@@ -193,7 +193,7 @@ export function MedDetailModal({
if (!selectedMed) return null;
const medCoverage = coverage.all.find((c) => c.name === selectedMed.name);
const medCoverage = coverage.all.find((c) => c.name === getMedDisplayName(selectedMed));
const packageSize = getPackageSize(selectedMed);
// Structural max = sealed package capacity only (excludes pre-existing looseTablets).
const structuralMax =
@@ -380,7 +380,7 @@ export function MedDetailModal({
<X size={18} aria-hidden="true" />
</button>
<h2>{t("editStock.title")}</h2>
<p className="edit-stock-med-name">{selectedMed.name}</p>
<p className="edit-stock-med-name">{getMedDisplayName(selectedMed)}</p>
<p className="edit-stock-hint">{t("editStock.hint")}</p>
{selectedMed.packageType === "blister" && (
<p className="edit-stock-cap-info edit-stock-live-breakdown">
@@ -667,12 +667,14 @@ export function MedDetailModal({
}
}}
>
<MedicationAvatar name={selectedMed.name} imageUrl={selectedMed.imageUrl} size="lg" />
<MedicationAvatar name={getMedDisplayName(selectedMed)} imageUrl={selectedMed.imageUrl} size="lg" />
{selectedMed.imageUrl && <span className="expand-icon">🔍</span>}
</div>
<div className="med-detail-titles">
<h2>{selectedMed.name}</h2>
{selectedMed.genericName && <span className="med-generic-name">{selectedMed.genericName}</span>}
<h2>{getMedDisplayName(selectedMed)}</h2>
{selectedMed.name && selectedMed.genericName && (
<span className="med-generic-name">{selectedMed.genericName}</span>
)}
{selectedMed.takenBy && (selectedMed.takenBy || []).length > 0 && (
<span className="med-taken-by">
{t("modal.for")}{" "}
@@ -1017,7 +1019,11 @@ export function MedDetailModal({
{/* Image Lightbox */}
{showImageLightbox && selectedMed.imageUrl && (
<Lightbox src={`/api/images/${selectedMed.imageUrl}`} alt={selectedMed.name} onClose={onCloseImageLightbox} />
<Lightbox
src={`/api/images/${selectedMed.imageUrl}`}
alt={getMedDisplayName(selectedMed)}
onClose={onCloseImageLightbox}
/>
)}
{/* Refill Modal */}
@@ -1049,7 +1055,7 @@ export function MedDetailModal({
<X size={18} aria-hidden="true" />
</button>
<h2>{t("refill.title")}</h2>
<p className="refill-med-name">{selectedMed.name}</p>
<p className="refill-med-name">{getMedDisplayName(selectedMed)}</p>
<div className="refill-form">
{selectedMed.packageType === "blister" ? (
+15 -5
View File
@@ -253,7 +253,10 @@ export function MobileEditModal({
const mobileTitle = (() => {
if (!editingId) return t("form.newEntry");
if (readOnlyMode) return t("form.viewEntry");
const medicationName = currentMed?.name?.trim() || form.name.trim();
const medicationName =
(currentMed ? currentMed.name?.trim() || currentMed.genericName?.trim() : null) ||
form.name.trim() ||
form.genericName.trim();
if (!medicationName) return t("form.editEntry");
return t("form.editEntryWithName", { name: medicationName });
})();
@@ -361,21 +364,28 @@ export function MobileEditModal({
onBlur={() => setShowNameValidation(true)}
placeholder={t("form.placeholders.commercial")}
maxLength={FIELD_LIMITS.name.max}
required={!readOnlyMode}
/>
{!readOnlyMode && showNameValidation && fieldErrors.name && (
<span className="field-error">{fieldErrors.name}</span>
)}
</label>
<label className={`full ${fieldErrors.genericName ? "has-error" : ""}`}>
<label
className={`full ${!readOnlyMode && showNameValidation && fieldErrors.genericName ? "has-error" : ""}`}
>
{t("form.genericName")}
<input
value={form.genericName}
onChange={(e) => onFormChange({ ...form, genericName: e.target.value })}
onChange={(e) => {
setShowNameValidation(true);
onFormChange({ ...form, genericName: e.target.value });
}}
onBlur={() => setShowNameValidation(true)}
placeholder={t("form.placeholders.generic")}
maxLength={FIELD_LIMITS.genericName.max}
/>
{fieldErrors.genericName && <span className="field-error">{fieldErrors.genericName}</span>}
{!readOnlyMode && showNameValidation && fieldErrors.genericName && (
<span className="field-error">{fieldErrors.genericName}</span>
)}
</label>
<label className="full">
{t("form.medicationStartDate")}
+20 -16
View File
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
import { useEscapeKey } from "../hooks/useEscapeKey";
import { useScrollLock } from "../hooks/useScrollLock";
import type { Medication } from "../types";
import { getPackageSize } from "../types";
import { getMedDisplayName, getPackageSize } from "../types";
import { MedicationAvatar } from "./MedicationAvatar";
type ReportFormat = "txt" | "md" | "pdf";
@@ -200,10 +200,10 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
{activeMeds.map((med) => (
<label key={med.id} className="report-med-item">
<input type="checkbox" checked={selectedIds.has(med.id)} onChange={() => toggleMed(med.id)} />
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
<MedicationAvatar name={getMedDisplayName(med)} imageUrl={med.imageUrl} size="sm" />
<span className="report-med-name">
{med.name}
{med.genericName && <span className="report-med-generic"> ({med.genericName})</span>}
{getMedDisplayName(med)}
{med.name && med.genericName && <span className="report-med-generic"> ({med.genericName})</span>}
</span>
</label>
))}
@@ -218,10 +218,10 @@ export function ReportModal({ isOpen, onClose, medications }: ReportModalProps)
{obsoleteMeds.map((med) => (
<label key={med.id} className="report-med-item">
<input type="checkbox" checked={selectedIds.has(med.id)} onChange={() => toggleMed(med.id)} />
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
<MedicationAvatar name={getMedDisplayName(med)} imageUrl={med.imageUrl} size="sm" />
<span className="report-med-name obsolete-name">
{med.name}
{med.genericName && <span className="report-med-generic"> ({med.genericName})</span>}
{getMedDisplayName(med)}
{med.name && med.genericName && <span className="report-med-generic"> ({med.genericName})</span>}
</span>
</label>
))}
@@ -320,13 +320,15 @@ function generateTextReport(
for (const med of meds) {
lines.push(sep);
lines.push("");
const title = med.isObsolete ? `${med.name} (${t("report.docStatusObsolete")})` : med.name;
const title = med.isObsolete
? `${getMedDisplayName(med)} (${t("report.docStatusObsolete")})`
: getMedDisplayName(med);
lines.push(h2(title));
lines.push("");
// General
lines.push(h3(t("report.docGeneral")));
lines.push(item(t("report.docCommercialName"), med.name));
if (med.name) lines.push(item(t("report.docCommercialName"), med.name));
if (med.genericName) lines.push(item(t("report.docGenericName"), med.genericName));
if (med.takenBy?.length) lines.push(item(t("report.docTakenBy"), med.takenBy.join(", ")));
lines.push(
@@ -489,22 +491,24 @@ function buildPrintHtml(
for (const med of meds) {
const data = reportData[med.id];
const intakes = med.intakes ?? med.blisters;
const displayName = getMedDisplayName(med);
const title = med.isObsolete
? `${escHtml(med.name)} <span class="obsolete-badge">${escHtml(t("report.docStatusObsolete"))}</span>`
: escHtml(med.name);
? `${escHtml(displayName)} <span class="obsolete-badge">${escHtml(t("report.docStatusObsolete"))}</span>`
: escHtml(displayName);
let s = `<div class="med-section">`;
const imgDataUrl = imageMap[med.id];
// Title with generic name subtitle
s += `<h2>${title}</h2>`;
if (med.genericName) s += `<p class="generic-subtitle">${escHtml(med.genericName)}</p>`;
if (med.name && med.genericName) s += `<p class="generic-subtitle">${escHtml(med.genericName)}</p>`;
// Build general info table rows
const generalRows: string[] = [];
generalRows.push(
`<tr><td class="label">${escHtml(t("report.docCommercialName"))}</td><td>${escHtml(med.name)}</td></tr>`
);
if (med.name)
generalRows.push(
`<tr><td class="label">${escHtml(t("report.docCommercialName"))}</td><td>${escHtml(med.name)}</td></tr>`
);
if (med.genericName)
generalRows.push(
`<tr><td class="label">${escHtml(t("report.docGenericName"))}</td><td>${escHtml(med.genericName)}</td></tr>`
@@ -527,7 +531,7 @@ function buildPrintHtml(
const generalTable = `<h3>${escHtml(t("report.docGeneral"))}</h3><table><tbody>${generalRows.join("")}</tbody></table>`;
if (imgDataUrl) {
s += `<div class="med-overview"><img class="med-img" src="${imgDataUrl}" alt="${escHtml(med.name)}" /><div class="med-overview-info">${generalTable}</div></div>`;
s += `<div class="med-overview"><img class="med-img" src="${imgDataUrl}" alt="${escHtml(displayName)}" /><div class="med-overview-info">${generalTable}</div></div>`;
} else {
s += generalTable;
}
+14 -14
View File
@@ -9,7 +9,7 @@ import { useTranslation } from "react-i18next";
import { useParams } from "react-router-dom";
import { useEscapeKey } from "../hooks";
import type { ExpiredLinkData, SharedScheduleData } from "../types";
import { getMedTotal } from "../types";
import { getMedDisplayName, getMedTotal } from "../types";
import { getSystemLocale } from "../utils/formatters";
import { isDoseDismissed } from "../utils/schedule";
import { loadCollapsedDaysFromStorage } from "../utils/storage";
@@ -343,7 +343,7 @@ export function SharedSchedule() {
doses.push({
id: doseId,
when: t,
medName: med.name,
medName: getMedDisplayName(med),
usage: intake.usage,
isPast,
takenBy: intake.takenBy, // Per-intake takenBy (string | null)
@@ -547,8 +547,8 @@ export function SharedSchedule() {
const daysLeft = rawDaysLeft !== null ? Math.max(0, Math.floor(rawDaysLeft)) : null;
const depletionMs = daysLeft !== null ? now + daysLeft * MS_PER_DAY : null;
coverage[med.name] = { daysLeft, medsLeft: Number(medsLeft.toFixed(1)), dailyUsage: dailyRate };
depletion[med.name] = depletionMs;
coverage[getMedDisplayName(med)] = { daysLeft, medsLeft: Number(medsLeft.toFixed(1)), dailyUsage: dailyRate };
depletion[getMedDisplayName(med)] = depletionMs;
}
return { coverageByMed: coverage, depletionByMed: depletion };
}, [data, takenDoses]);
@@ -746,7 +746,7 @@ export function SharedSchedule() {
// Count missed doses that are NOT dismissed (for warning icon)
const missedNotDismissedCount = day.meds.reduce((count, item) => {
const med = data.medications.find((m) => m.name === item.medName);
const med = data.medications.find((m) => getMedDisplayName(m) === item.medName);
const dismissedUntilDate = med?.dismissedUntil ?? undefined;
return (
count +
@@ -800,7 +800,7 @@ export function SharedSchedule() {
</div>
{!isCollapsed &&
day.meds.map((item) => {
const med = data.medications.find((m) => m.name === item.medName);
const med = data.medications.find((m) => getMedDisplayName(m) === item.medName);
const medCoverage = coverageByMed[item.medName];
const isEmpty = showStock && medCoverage ? medCoverage.medsLeft <= 0 : false;
const depletionTime = depletionByMed[item.medName];
@@ -825,10 +825,10 @@ export function SharedSchedule() {
<div className="med-name">
<div
className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, getMedDisplayName(med))}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med?.imageUrl) openLightbox(med.imageUrl, med.name);
if (med?.imageUrl) openLightbox(med.imageUrl, getMedDisplayName(med));
}
}}
>
@@ -984,7 +984,7 @@ export function SharedSchedule() {
</div>
{!isCollapsed &&
day.meds.map((item) => {
const med = data.medications.find((m) => m.name === item.medName);
const med = data.medications.find((m) => getMedDisplayName(m) === item.medName);
const medCoverage = coverageByMed[item.medName];
const isEmpty = showStock && medCoverage ? medCoverage.medsLeft <= 0 : false;
const depletionTime = depletionByMed[item.medName];
@@ -1008,10 +1008,10 @@ export function SharedSchedule() {
<div className="med-name">
<div
className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, getMedDisplayName(med))}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med?.imageUrl) openLightbox(med.imageUrl, med.name);
if (med?.imageUrl) openLightbox(med.imageUrl, getMedDisplayName(med));
}
}}
>
@@ -1161,7 +1161,7 @@ export function SharedSchedule() {
</div>
{!isCollapsed &&
day.meds.map((item) => {
const med = data.medications.find((m) => m.name === item.medName);
const med = data.medications.find((m) => getMedDisplayName(m) === item.medName);
const medCoverage = coverageByMed[item.medName];
const depletionTime = depletionByMed[item.medName];
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
@@ -1184,10 +1184,10 @@ export function SharedSchedule() {
<div className="med-name">
<div
className={med?.imageUrl ? "med-avatar clickable" : ""}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, med.name)}
onClick={() => med?.imageUrl && openLightbox(med.imageUrl, getMedDisplayName(med))}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med?.imageUrl) openLightbox(med.imageUrl, med.name);
if (med?.imageUrl) openLightbox(med.imageUrl, getMedDisplayName(med));
}
}}
>
+5 -5
View File
@@ -6,7 +6,7 @@ import { useTranslation } from "react-i18next";
import { MedicationAvatar } from "../components";
import { useEscapeKey } from "../hooks/useEscapeKey";
import type { Coverage, Medication, StockThresholds } from "../types";
import { getMedTotal, getPackageSize } from "../types";
import { getMedDisplayName, getMedTotal, getPackageSize } from "../types";
import { formatNumber } from "../utils";
import { getSystemLocale } from "../utils/formatters";
import { getStockStatus } from "../utils/schedule";
@@ -64,7 +64,7 @@ export function UserFilterModal({
<div className="user-meds-list">
{userMeds.map((med) => {
const medCoverage = coverage.all.find((c) => c.name === med.name);
const medCoverage = coverage.all.find((c) => c.name === getMedDisplayName(med));
// Fallback: if no coverage data (e.g. obsolete med), compute basic status from total pills
const status = medCoverage
? getStockStatus(medCoverage.daysLeft, medCoverage.medsLeft, settings)
@@ -97,10 +97,10 @@ export function UserFilterModal({
}
}}
>
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="sm" />
<MedicationAvatar name={getMedDisplayName(med)} imageUrl={med.imageUrl} size="sm" />
<div className="user-med-info">
<span className="user-med-name">{med.name}</span>
{med.genericName && <span className="user-med-generic">{med.genericName}</span>}
<span className="user-med-name">{getMedDisplayName(med)}</span>
{med.name && med.genericName && <span className="user-med-generic">{med.genericName}</span>}
{personIntakes.length > 0 && (
<div className="user-med-intakes">
{personIntakes.map((intake) => {
+9 -4
View File
@@ -115,9 +115,6 @@ export function useMedicationForm(): UseMedicationFormReturn {
// Skip validation for takenBy array (individual items validated on add)
if (field === "takenBy") return undefined;
const strValue = typeof value === "string" ? value : "";
if (field === "name" && (!strValue || strValue.trim().length === 0)) {
return t("common.validation.required");
}
if ("max" in limits && strValue.length > limits.max) {
return t("common.validation.maxLength", { max: limits.max, current: strValue.length });
}
@@ -150,8 +147,16 @@ export function useMedicationForm(): UseMedicationFormReturn {
const error = validateField(f, form[f]);
if (error) errors[f] = error;
});
// Cross-field validation: at least one of name or genericName is required
const hasName = form.name && form.name.trim().length > 0;
const hasGenericName = form.genericName && form.genericName.trim().length > 0;
if (!hasName && !hasGenericName) {
const msg = t("common.validation.nameOrGenericRequired");
errors.name = errors.name || msg;
errors.genericName = errors.genericName || msg;
}
setFieldErrors(errors);
}, [form.name, form.genericName, form.notes, validateField, form]);
}, [form.name, form.genericName, form.notes, validateField, form, t]);
const setBlisterValue = useCallback((idx: number, field: keyof FormBlister, value: string) => {
setForm((prev) => {
+2 -1
View File
@@ -192,7 +192,7 @@
},
"placeholders": {
"commercial": "z.B. Ozempic",
"generic": "z.B. Semaglutid (optional)",
"generic": "z.B. Semaglutid",
"takenBy": "Name eingeben und Enter drücken",
"addPerson": "Weitere Person hinzufügen...",
"weight": "z.B. 240",
@@ -436,6 +436,7 @@
},
"validation": {
"required": "Dieses Feld ist erforderlich",
"nameOrGenericRequired": "Handelsname oder Wirkstoff ist erforderlich",
"maxLength": "Maximal {{max}} Zeichen ({{current}}/{{max}})",
"tooLong": "{{current}}/{{max}} Zeichen"
},
+2 -1
View File
@@ -192,7 +192,7 @@
},
"placeholders": {
"commercial": "e.g. Ozempic",
"generic": "e.g. Semaglutide (optional)",
"generic": "e.g. Semaglutide",
"takenBy": "Type name and press Enter",
"addPerson": "Add another person...",
"weight": "e.g. 240",
@@ -436,6 +436,7 @@
},
"validation": {
"required": "This field is required",
"nameOrGenericRequired": "Either commercial name or generic name is required",
"maxLength": "Maximum {{max}} characters ({{current}}/{{max}})",
"tooLong": "{{current}}/{{max}} characters"
},
+13 -10
View File
@@ -6,6 +6,7 @@ import { ConfirmModal, MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
import { useAppContext } from "../context";
import { useModalHistory } from "../hooks";
import { getMedDisplayName } from "../types";
import { formatNumber, getExpiryClass, getSystemLocale } from "../utils/formatters";
import { expandDoseIds, getStockStatus, isDoseDismissed } from "../utils/schedule";
import {
@@ -118,7 +119,7 @@ export function DashboardPage() {
})
.map((med) => ({
id: med.id,
name: med.name,
name: getMedDisplayName(med),
remainingRefills: med.prescriptionRemainingRefills ?? 0,
threshold: med.prescriptionLowRefillThreshold ?? 1,
}))
@@ -250,7 +251,7 @@ export function DashboardPage() {
<span className="reminder-status-label">{t("dashboard.reminders.needsRefill")}:</span>
<span className="reminder-status-value">
{reminderData.lowStockMeds.map((med, idx) => {
const medication = meds.find((m) => m.name === med.name);
const medication = meds.find((m) => getMedDisplayName(m) === med.name);
const cov = coverage.all.find((c) => c.name === med.name);
const status = cov ? getStockStatus(cov.daysLeft, cov.medsLeft, stockThresholds) : null;
const textClass =
@@ -322,7 +323,7 @@ export function DashboardPage() {
(() => {
const names = reminderData.lastStockSent!.medNames!.split(", ");
return names.map((name, idx) => {
const medication = meds.find((m) => m.name === name);
const medication = meds.find((m) => getMedDisplayName(m) === name);
return (
<span key={name}>
{idx > 0 && ", "}
@@ -353,7 +354,9 @@ export function DashboardPage() {
<span className="reminder-status-value">
{reminderData.lastIntakeSent.medName &&
(() => {
const medication = meds.find((m) => m.name === reminderData.lastIntakeSent!.medName);
const medication = meds.find(
(m) => getMedDisplayName(m) === reminderData.lastIntakeSent!.medName
);
return medication ? (
<span
className="med-link clickable"
@@ -428,7 +431,7 @@ export function DashboardPage() {
<p>
{t("dashboard.reorder.lowWarningPrefix")}{" "}
{lowStockMeds.map((c, idx) => {
const med = meds.find((m) => m.name === c.name);
const med = meds.find((m) => getMedDisplayName(m) === c.name);
const status = getStockStatus(c.daysLeft, c.medsLeft, stockThresholds);
const textClass =
status.className === "danger"
@@ -485,7 +488,7 @@ export function DashboardPage() {
</div>
{coverage.all.map((row) => {
const status = getStockStatus(row.daysLeft, row.medsLeft, stockThresholds);
const med = meds.find((m) => m.name === row.name);
const med = meds.find((m) => getMedDisplayName(m) === row.name);
const expiryClass = getExpiryClass(med?.expiryDate, settings.expiryWarningDays);
const textClass =
status.className === "danger"
@@ -673,7 +676,7 @@ export function DashboardPage() {
// Count missed doses that are NOT dismissed (for warning icon)
const missedNotDismissedCount = day.meds.reduce((count, item) => {
const med = meds.find((m) => m.name === item.medName);
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
const dismissedUntilDate = med?.dismissedUntil ?? undefined;
return (
count +
@@ -729,7 +732,7 @@ export function DashboardPage() {
</div>
{!isCollapsed &&
day.meds.map((item) => {
const med = meds.find((m) => m.name === item.medName);
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
const medCov = coverageByMed[item.medName];
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
const status = medCov
@@ -986,7 +989,7 @@ export function DashboardPage() {
{!isCollapsed &&
day.meds.map((item) => {
const medCoverage = coverageByMed[item.medName];
const med = meds.find((m) => m.name === item.medName);
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
const depletionTime = depletionByMed[item.medName];
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
@@ -1217,7 +1220,7 @@ export function DashboardPage() {
{!isCollapsed &&
day.meds.map((item) => {
const medCoverage = coverageByMed[item.medName];
const med = meds.find((m) => m.name === item.medName);
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
const depletionTime = depletionByMed[item.medName];
const _isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
+32 -19
View File
@@ -18,7 +18,7 @@ import { useAuth } from "../components/Auth";
import { useAppContext, useUnsavedChanges } from "../context";
import { useMedicationForm, useModalHistory, useUnsavedChangesWarning } from "../hooks";
import type { DoseUnit, Medication } from "../types";
import { DOSE_UNITS, FIELD_LIMITS, getPackageSize } from "../types";
import { DOSE_UNITS, FIELD_LIMITS, getMedDisplayName, getPackageSize } from "../types";
import { combineDateAndTime, formatDate, formatDateTime, formatNumber } from "../utils/formatters";
import { MAX_IMAGE_UPLOAD_BYTES, resolveImageUploadError } from "../utils/image-upload";
import { log } from "../utils/logger";
@@ -836,19 +836,21 @@ export function MedicationsPage() {
<span
className={med.imageUrl ? "med-avatar-clickable" : undefined}
onClick={() =>
med.imageUrl && setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name })
med.imageUrl &&
setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: getMedDisplayName(med) })
}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med.imageUrl) setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name });
if (med.imageUrl)
setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: getMedDisplayName(med) });
}
}}
>
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="lg" />
<MedicationAvatar name={getMedDisplayName(med)} imageUrl={med.imageUrl} size="lg" />
</span>
<div className="med-name-block">
<div className="med-name">{med.name}</div>
{med.genericName && <div className="med-generic-name">{med.genericName}</div>}
<div className="med-name">{getMedDisplayName(med)}</div>
{med.name && med.genericName && <div className="med-generic-name">{med.genericName}</div>}
</div>
</div>
<div className="med-actions">
@@ -910,10 +912,12 @@ export function MedicationsPage() {
)}
<div className="med-total">
{t("medications.details.stock")}:{" "}
{coverageByMed[med.name] ? Math.round(coverageByMed[med.name].medsLeft) : getPackageSize(med)} /{" "}
{getPackageSize(med)} {getPackageSize(med) === 1 ? t("common.pill") : t("common.pills")}
{(coverageByMed[med.name]
? Math.round(coverageByMed[med.name].medsLeft)
{coverageByMed[getMedDisplayName(med)]
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
: getPackageSize(med)}{" "}
/ {getPackageSize(med)} {getPackageSize(med) === 1 ? t("common.pill") : t("common.pills")}
{(coverageByMed[getMedDisplayName(med)]
? Math.round(coverageByMed[getMedDisplayName(med)].medsLeft)
: getPackageSize(med)) > getPackageSize(med) && (
<span
className="info-tooltip tooltip-align-left warning-text"
@@ -970,20 +974,24 @@ export function MedicationsPage() {
<span
className={med.imageUrl ? "med-avatar-clickable" : undefined}
onClick={() =>
med.imageUrl && setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name })
med.imageUrl &&
setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: getMedDisplayName(med) })
}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
if (med.imageUrl)
setLightboxImage({ src: `/api/images/${med.imageUrl}`, alt: med.name });
setLightboxImage({
src: `/api/images/${med.imageUrl}`,
alt: getMedDisplayName(med),
});
}
}}
>
<MedicationAvatar name={med.name} imageUrl={med.imageUrl} size="lg" />
<MedicationAvatar name={getMedDisplayName(med)} imageUrl={med.imageUrl} size="lg" />
</span>
<div className="med-name-block">
<div className="med-name">{med.name}</div>
{med.genericName && <div className="med-generic-name">{med.genericName}</div>}
<div className="med-name">{getMedDisplayName(med)}</div>
{med.name && med.genericName && <div className="med-generic-name">{med.genericName}</div>}
</div>
</div>
<div className="med-actions">
@@ -1106,21 +1114,26 @@ export function MedicationsPage() {
onBlur={() => setShowNameValidation(true)}
placeholder={t("form.placeholders.commercial")}
maxLength={FIELD_LIMITS.name.max}
required={!readOnlyView}
/>
{!readOnlyView && showNameValidation && fieldErrors.name && (
<span className="field-error">{fieldErrors.name}</span>
)}
</label>
<label className={fieldErrors.genericName ? "has-error" : ""}>
<label className={!readOnlyView && showNameValidation && fieldErrors.genericName ? "has-error" : ""}>
{t("form.genericName")}
<input
value={form.genericName}
onChange={(e) => setForm({ ...form, genericName: e.target.value })}
onChange={(e) => {
setShowNameValidation(true);
setForm({ ...form, genericName: e.target.value });
}}
onBlur={() => setShowNameValidation(true)}
placeholder={t("form.placeholders.generic")}
maxLength={FIELD_LIMITS.genericName.max}
/>
{fieldErrors.genericName && <span className="field-error">{fieldErrors.genericName}</span>}
{!readOnlyView && showNameValidation && fieldErrors.genericName && (
<span className="field-error">{fieldErrors.genericName}</span>
)}
</label>
<label>
{t("form.medicationStartDate")}
+3 -1
View File
@@ -5,6 +5,7 @@ import { DateTimeInput, MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
import { useAppContext } from "../context";
import type { PlannerRow } from "../types";
import { getMedDisplayName } from "../types";
import { toInputValue } from "../utils/formatters";
// Date helpers
@@ -204,7 +205,8 @@ export function PlannerPage() {
</div>
{plannerRows.map((row) => {
const med =
meds.find((m) => m.id === row.medicationId) || meds.find((m) => m.name === row.medicationName);
meds.find((m) => m.id === row.medicationId) ||
meds.find((m) => getMedDisplayName(m) === row.medicationName);
const remainingRefills = med?.prescriptionEnabled ? (med.prescriptionRemainingRefills ?? 0) : null;
return (
<div
+4 -3
View File
@@ -5,6 +5,7 @@ import { MedicationAvatar } from "../components";
import { useAuth } from "../components/Auth";
import { useAppContext } from "../context";
import type { Coverage } from "../types";
import { getMedDisplayName } from "../types";
import { expandDoseIds, isDoseDismissed } from "../utils/schedule";
// Helper for user-specific localStorage keys
@@ -116,7 +117,7 @@ export function SchedulePage() {
// Count missed doses that are NOT dismissed (for warning icon)
const missedNotDismissedCount = day.meds.reduce((count, item) => {
const med = meds.find((m) => m.name === item.medName);
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
const dismissedUntilDate = med?.dismissedUntil ?? undefined;
return (
count +
@@ -171,7 +172,7 @@ export function SchedulePage() {
</div>
{!isCollapsed &&
day.meds.map((item) => {
const med = meds.find((m) => m.name === item.medName);
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
const medCov = coverageByMed[item.medName];
const isEmpty = medCov ? medCov.medsLeft <= 0 : false;
const itemDoseIds = expandDoseIds(item.doses);
@@ -333,7 +334,7 @@ export function SchedulePage() {
{day.meds.map((item) => {
const medCoverage = coverageByMed[item.medName];
const isEmpty = medCoverage ? medCoverage.medsLeft <= 0 : false;
const med = meds.find((m) => m.name === item.medName);
const med = meds.find((m) => getMedDisplayName(m) === item.medName);
const depletionTime = depletionByMed[item.medName];
// Check if this dose is scheduled after medication runs out
const willBeOutOfStock = typeof depletionTime === "number" && item.lastWhen > depletionTime;
@@ -123,7 +123,7 @@ describe("useMedicationForm", () => {
expect(result.current.formChanged).toBe(false);
await waitFor(() => {
expect(result.current.fieldErrors.name).toBe("common.validation.required");
expect(result.current.fieldErrors.name).toBe("common.validation.nameOrGenericRequired");
expect(result.current.hasValidationErrors).toBe(true);
});
});
@@ -131,7 +131,8 @@ describe("useMedicationForm", () => {
it("validates name required and max length fields", () => {
const { result } = renderHook(() => useMedicationForm());
expect(result.current.validateField("name", "")).toBe("common.validation.required");
// Cross-field validation: empty name alone returns no per-field error
expect(result.current.validateField("name", "")).toBeUndefined();
expect(result.current.validateField("takenBy", ["Alice"])).toBeUndefined();
const tooLongGeneric = "a".repeat(101);
+1 -1
View File
@@ -152,7 +152,7 @@ describe("getPackageSize", () => {
describe("FIELD_LIMITS", () => {
it("has correct limits for name field", () => {
expect(FIELD_LIMITS.name.min).toBe(1);
expect(FIELD_LIMITS.name.min).toBe(0);
expect(FIELD_LIMITS.name.max).toBe(100);
});
+6 -1
View File
@@ -230,12 +230,17 @@ export type ExpiredLinkData = {
// Field Validation Limits (must match backend)
// =============================================================================
export const FIELD_LIMITS = {
name: { min: 1, max: 100 },
name: { min: 0, max: 100 },
genericName: { max: 100 },
takenBy: { max: 100 },
notes: { max: 2000 },
} as const;
/** Returns the best display name for a medication: commercial name, or generic name as fallback */
export function getMedDisplayName(med: { name: string; genericName?: string | null }): string {
return med.name || med.genericName || "";
}
// =============================================================================
// Helper Functions for Medication Calculations
// =============================================================================
+7 -5
View File
@@ -3,6 +3,7 @@
// =============================================================================
import type { Medication } from "../types";
import { getMedDisplayName } from "../types";
/**
* Format a Date for ICS format (YYYYMMDDTHHMMSSZ)
@@ -18,6 +19,7 @@ function formatICSDate(date: Date): string {
* Generate and download an ICS calendar file for a medication's schedule
*/
export function generateICS(med: Medication): void {
const displayName = getMedDisplayName(med);
const events = med.blisters
.map((blister, idx) => {
const start = new Date(blister.start);
@@ -25,9 +27,9 @@ export function generateICS(med: Medication): void {
const interval = blister.every;
const pillInfo = `${blister.usage} pill${blister.usage !== 1 ? "s" : ""}${med.pillWeightMg ? ` (${blister.usage * med.pillWeightMg} mg)` : ""}`;
const summary = `💊 ${med.name} - ${pillInfo}`;
const summary = `💊 ${displayName} - ${pillInfo}`;
const description = [
`Medication: ${med.name}`,
`Medication: ${displayName}`,
med.genericName ? `Generic: ${med.genericName}` : "",
med.takenBy && med.takenBy.length > 0 ? `For: ${med.takenBy.join(", ")}` : "",
`Dosage: ${pillInfo}`,
@@ -48,7 +50,7 @@ DESCRIPTION:${description}
BEGIN:VALARM
TRIGGER:-PT5M
ACTION:DISPLAY
DESCRIPTION:Time to take ${med.name}
DESCRIPTION:Time to take ${displayName}
END:VALARM
END:VEVENT`;
})
@@ -59,7 +61,7 @@ VERSION:2.0
PRODID:-//MedAssist-ng//Medication Schedule//EN
CALSCALE:GREGORIAN
METHOD:PUBLISH
X-WR-CALNAME:${med.name} Schedule
X-WR-CALNAME:${displayName} Schedule
${events}
END:VCALENDAR`;
@@ -67,7 +69,7 @@ END:VCALENDAR`;
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${med.name.replace(/[^a-zA-Z0-9]/g, "_")}_schedule.ics`;
link.download = `${displayName.replace(/[^a-zA-Z0-9]/g, "_")}_schedule.ics`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
+5 -4
View File
@@ -3,7 +3,7 @@
// =============================================================================
import type { Blister, Coverage, Intake, Medication, ScheduleEvent, StockStatus, StockThresholds } from "../types";
import { getMedTotal } from "../types";
import { getMedDisplayName, getMedTotal } from "../types";
/**
* Get intakes for a medication, preferring new intakes format over legacy blisters
@@ -63,7 +63,7 @@ export function buildSchedulePreview(
const dateOnlyMs = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
events.push({
id: `${med.id}-${idx}-${dateOnlyMs}`,
medName: med.name,
medName: getMedDisplayName(med),
takenBy: intake.takenBy, // Per-intake takenBy (string | null)
usage: intake.usage,
when: whenMs,
@@ -267,10 +267,11 @@ export function calculateCoverage(
depletionMs !== null
? new Date(depletionMs).toLocaleDateString(locale, { weekday: "short", day: "2-digit", month: "short" })
: null;
const nextEvent = events.find((e) => e.medName === m.name);
const displayName = getMedDisplayName(m);
const nextEvent = events.find((e) => e.medName === displayName);
return {
name: m.name,
name: displayName,
medsLeft: Number(medsLeft.toFixed(1)),
daysLeft,
depletionDate,
+57 -9
View File
@@ -33,16 +33,36 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
cd "$PROJECT_ROOT"
# Detect git remote name (prefer 'origin', fall back to 'github')
# Detect git remote name for the configured GitHub repository.
# This avoids accidentally pulling from a non-GitHub origin in multi-remote setups.
detect_remote() {
if git remote | grep -q '^origin$'; then
echo "origin"
elif git remote | grep -q '^github$'; then
local target_repo_lower
target_repo_lower=$(echo "${GITHUB_REPO}" | tr '[:upper:]' '[:lower:]')
local remote
while read -r remote; do
local url
url=$(git remote get-url "$remote" 2>/dev/null || true)
local url_lower
url_lower=$(echo "$url" | tr '[:upper:]' '[:lower:]')
if [[ "$url_lower" == *"github.com"* && "$url_lower" == *"${target_repo_lower}.git"* ]]; then
echo "$remote"
return 0
fi
if [[ "$url_lower" == *"github.com"* && "$url_lower" == *"${target_repo_lower}" ]]; then
echo "$remote"
return 0
fi
done < <(git remote)
if git remote | grep -q '^github$'; then
echo "github"
else
echo -e "${RED}Error: No 'origin' or 'github' remote found.${NC}" >&2
exit 1
return 0
fi
echo -e "${RED}Error: No git remote points to github.com/${GITHUB_REPO}.${NC}" >&2
exit 1
}
GIT_REMOTE=$(detect_remote)
@@ -180,6 +200,8 @@ This PR was created by the release script.")
echo -e "${GREEN}PR created: ${YELLOW}${PR_URL}${NC}"
PR_NUMBER=$(echo "$PR_URL" | grep -oE '[0-9]+$')
MERGED_SHA=""
# ─── Wait for CI and merge ────────────────────────────────────────────────────
if ! wait_for_ci "${PR_NUMBER}"; then
@@ -191,9 +213,35 @@ fi
echo -e "${BLUE}Merging PR #${PR_NUMBER}...${NC}"
gh pr merge "${PR_NUMBER}" --repo "${GITHUB_REPO}" --squash --delete-branch
MERGED_SHA=$(gh pr view "${PR_NUMBER}" --repo "${GITHUB_REPO}" --json mergeCommit --jq '.mergeCommit.oid')
if [[ -z "${MERGED_SHA}" || "${MERGED_SHA}" == "null" ]]; then
echo -e "${RED}Error: Could not resolve merge commit SHA for PR #${PR_NUMBER}.${NC}"
exit 1
fi
echo -e "${BLUE}Resolved merge commit: ${YELLOW}${MERGED_SHA}${NC}"
echo -e "${BLUE}Updating main branch...${NC}"
git checkout main
git pull "${GIT_REMOTE}" main
git fetch "${GIT_REMOTE}" main
git pull --ff-only "${GIT_REMOTE}" main
if ! git cat-file -e "${MERGED_SHA}^{commit}" 2>/dev/null; then
echo -e "${BLUE}Fetching merge commit from ${GIT_REMOTE}...${NC}"
git fetch "${GIT_REMOTE}" "${MERGED_SHA}"
fi
HEAD_SHA=$(git rev-parse HEAD)
if [[ "${HEAD_SHA}" != "${MERGED_SHA}" ]]; then
echo -e "${YELLOW}Local main is at ${HEAD_SHA}, expected merge commit ${MERGED_SHA}.${NC}"
echo -e "${YELLOW}Tag will be created on the merge commit SHA to avoid stale tags.${NC}"
fi
MERGED_VERSION=$(git show "${MERGED_SHA}:backend/package.json" | sed -n 's/.*"version": "\([^"]*\)".*/\1/p')
if [[ "${MERGED_VERSION}" != "${NEW_VERSION}" ]]; then
echo -e "${RED}Error: merge commit backend/package.json version is '${MERGED_VERSION}', expected '${NEW_VERSION}'.${NC}"
echo -e "${RED}Aborting to prevent creating a tag on the wrong release commit.${NC}"
exit 1
fi
# ─── Create and push signed tag ──────────────────────────────────────────────
@@ -208,7 +256,7 @@ if git ls-remote --tags "${GIT_REMOTE}" "v${NEW_VERSION}" 2>/dev/null | grep -q
fi
echo -e "${BLUE}Creating signed tag v${NEW_VERSION}...${NC}"
git tag -s "v${NEW_VERSION}" -m "Release v${NEW_VERSION}"
git tag -s "v${NEW_VERSION}" "${MERGED_SHA}" -m "Release v${NEW_VERSION}"
echo -e "${BLUE}Pushing tag...${NC}"
git push "${GIT_REMOTE}" "v${NEW_VERSION}"