Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| de300ad919 | |||
| 06bf608913 | |||
| a47bde0956 | |||
| d02f16af3a | |||
| dbdf3b61cb | |||
| aa29d1c699 | |||
| bfc9aaaa6d | |||
| 2a9ca39c24 | |||
| 691550fb33 |
@@ -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>'`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
Generated
+2
-2
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "medassist-ng-backend",
|
||||
"version": "1.16.0",
|
||||
"version": "1.16.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"]],
|
||||
|
||||
@@ -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" ? (
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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
|
||||
// =============================================================================
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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}"
|
||||
|
||||
Reference in New Issue
Block a user