Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 693922fff1 | |||
| 72ba4d1272 | |||
| eba77c9520 | |||
| d4b8ddc590 | |||
| 4d6c568668 | |||
| 12dc77455c |
+1
-7
@@ -13,12 +13,6 @@ PORT=3000
|
|||||||
CORS_ORIGINS=http://localhost:4174
|
CORS_ORIGINS=http://localhost:4174
|
||||||
LOG_LEVEL=warn
|
LOG_LEVEL=warn
|
||||||
|
|
||||||
# Public base URL used for notification action links.
|
|
||||||
# Required for intake reminder action buttons/links.
|
|
||||||
# PUBLIC_APP_URL=https://medassist.example.com
|
|
||||||
# For mobile testing on the same LAN, use your laptop IP instead of localhost,
|
|
||||||
# e.g. PUBLIC_APP_URL=http://192.168.0.113:5173 and add that origin to CORS_ORIGINS.
|
|
||||||
|
|
||||||
# Levels: debug, info, warn, error, silent
|
# Levels: debug, info, warn, error, silent
|
||||||
# Controls: backend Fastify logging, frontend nginx access logs (Docker),
|
# Controls: backend Fastify logging, frontend nginx access logs (Docker),
|
||||||
# and frontend browser console (via build-time injection)
|
# and frontend browser console (via build-time injection)
|
||||||
@@ -155,6 +149,6 @@ EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
|
|||||||
# UI defaults
|
# UI defaults
|
||||||
# DEFAULT_LANGUAGE=en # en or de
|
# DEFAULT_LANGUAGE=en # en or de
|
||||||
# DEFAULT_STOCK_CALCULATION_MODE=automatic # automatic or manual
|
# DEFAULT_STOCK_CALCULATION_MODE=automatic # automatic or manual
|
||||||
# DEFAULT_SHARE_MEDICATION_OVERVIEW=false # Show medication overview section on shared schedule links
|
# DEFAULT_SHARE_STOCK_STATUS=true # Show stock status on shared schedule links
|
||||||
# DEFAULT_UPCOMING_TODAY_ONLY=false
|
# DEFAULT_UPCOMING_TODAY_ONLY=false
|
||||||
# DEFAULT_SHARE_SCHEDULE_TODAY_ONLY=false
|
# DEFAULT_SHARE_SCHEDULE_TODAY_ONLY=false
|
||||||
@@ -1,11 +1,17 @@
|
|||||||
# MedAssist-ng - Copilot Entry Point
|
# MedAssist-ng - Copilot Entry Point
|
||||||
|
|
||||||
## VERY IMPORTANT
|
## VERY IMPORTANT - Prioritized Constraints
|
||||||
|
|
||||||
|
**First: Update Memory and Reports**
|
||||||
- Always keep agent work memory updated in `doku/memory_notes.md` so progress and decisions remain recoverable across context loss.
|
- Always keep agent work memory updated in `doku/memory_notes.md` so progress and decisions remain recoverable across context loss.
|
||||||
|
- If `doku/memory_notes.md` is missing, create it immediately.
|
||||||
- Always keep a user-facing work report updated in `doku/report.md` so completed work is easy to review.
|
- Always keep a user-facing work report updated in `doku/report.md` so completed work is easy to review.
|
||||||
|
- If `doku/report.md` is missing, create it immediately.
|
||||||
- This memory/report rule replaces the previous `doku/APP_BEHAVIOR.md` persistence requirement.
|
- This memory/report rule replaces the previous `doku/APP_BEHAVIOR.md` persistence requirement.
|
||||||
|
|
||||||
|
**Second: Follow Governance Rules**
|
||||||
|
- Consult `AGENTS.md` for all governance, workflow, and skill rules.
|
||||||
|
|
||||||
Use `AGENTS.md` as the single source of truth for all governance, workflow, and skill rules.
|
Use `AGENTS.md` as the single source of truth for all governance, workflow, and skill rules.
|
||||||
|
|
||||||
## Required Startup Steps
|
## Required Startup Steps
|
||||||
|
|||||||
+15
-1
@@ -107,4 +107,18 @@ docs/SPEC_KIT.md
|
|||||||
.github/skills/nodejs-backend-patterns/
|
.github/skills/nodejs-backend-patterns/
|
||||||
.github/skills/nodejs-best-practices/
|
.github/skills/nodejs-best-practices/
|
||||||
.github/skills/seo/
|
.github/skills/seo/
|
||||||
.playwright-mcp
|
.playwright-mcp
|
||||||
|
|
||||||
|
# Local GSD/copilot generated workspace artifacts (not for upstream)
|
||||||
|
.github/agents/copilot-instructions.md
|
||||||
|
.github/agents/gsd-*.agent.md
|
||||||
|
.github/agents/medassist-feature-orchestrator.agent.md
|
||||||
|
.github/agents/speckit.*.agent.md
|
||||||
|
.github/get-shit-done/
|
||||||
|
.github/gsd-file-manifest.json
|
||||||
|
.github/prompts/speckit.*.prompt.md
|
||||||
|
.github/skills/gsd-*/
|
||||||
|
.planning/
|
||||||
|
doku/memory_notes.md
|
||||||
|
doku/report.md
|
||||||
|
ops/medtest/
|
||||||
@@ -378,6 +378,14 @@ docker compose -p medassist-dev -f docker-compose.dev.yml up
|
|||||||
- API docs UI: `http://localhost:3000/docs` (when docs are enabled)
|
- API docs UI: `http://localhost:3000/docs` (when docs are enabled)
|
||||||
- OpenAPI JSON: `http://localhost:3000/docs/json` (when docs are enabled)
|
- OpenAPI JSON: `http://localhost:3000/docs/json` (when docs are enabled)
|
||||||
|
|
||||||
|
If you run the frontend dev server behind a reverse proxy or on a remote host, you can optionally set these frontend-only environment variables before starting Vite:
|
||||||
|
|
||||||
|
- `VITE_ALLOWED_HOSTS`: comma-separated hostnames allowed to connect to the dev server; defaults to `localhost,127.0.0.1`
|
||||||
|
- `VITE_HMR_HOST`: public hostname used for HMR websocket connections
|
||||||
|
- `VITE_HMR_PROTOCOL`: optional websocket protocol override (`ws` or `wss`)
|
||||||
|
- `VITE_HMR_CLIENT_PORT`: optional public websocket port exposed to the browser
|
||||||
|
- `VITE_HMR_PORT`: optional server-side websocket port for the Vite process
|
||||||
|
|
||||||
Useful local commands:
|
Useful local commands:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
CREATE TABLE `notification_action_groups` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`user_id` integer NOT NULL,
|
|
||||||
`group_key` text(255) NOT NULL,
|
|
||||||
`sequence_id` text(255) NOT NULL,
|
|
||||||
`dose_ids_json` text NOT NULL,
|
|
||||||
`title` text(255) NOT NULL,
|
|
||||||
`message` text NOT NULL,
|
|
||||||
`language` text(10) DEFAULT 'en' NOT NULL,
|
|
||||||
`scheduled_for` integer,
|
|
||||||
`expires_at` integer NOT NULL,
|
|
||||||
`resolved_action` text(20),
|
|
||||||
`resolved_at` integer,
|
|
||||||
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `notification_action_groups_group_key_unique` ON `notification_action_groups` (`group_key`);--> statement-breakpoint
|
|
||||||
CREATE TABLE `notification_action_tokens` (
|
|
||||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
|
||||||
`group_id` integer NOT NULL,
|
|
||||||
`token_hash` text(128) NOT NULL,
|
|
||||||
`kind` text(20) NOT NULL,
|
|
||||||
`used_at` integer,
|
|
||||||
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
|
||||||
FOREIGN KEY (`group_id`) REFERENCES `notification_action_groups`(`id`) ON UPDATE no action ON DELETE cascade
|
|
||||||
);
|
|
||||||
--> statement-breakpoint
|
|
||||||
CREATE UNIQUE INDEX `notification_action_tokens_token_hash_unique` ON `notification_action_tokens` (`token_hash`);
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
ALTER TABLE `notification_action_groups` ADD `ntfy_original_message_id` text(255) DEFAULT '' NOT NULL;
|
|
||||||
@@ -59,7 +59,6 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_sent text`,
|
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_sent text`,
|
||||||
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_channel text`,
|
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_channel text`,
|
||||||
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`,
|
`ALTER TABLE user_settings ADD COLUMN last_stock_reminder_med_names text`,
|
||||||
// Keep the removed legacy setting column for backward compatibility with older SQLite files.
|
|
||||||
`ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`,
|
`ALTER TABLE user_settings ADD COLUMN share_stock_status integer NOT NULL DEFAULT 1`,
|
||||||
`ALTER TABLE user_settings ADD COLUMN share_medication_overview integer NOT NULL DEFAULT 0`,
|
`ALTER TABLE user_settings ADD COLUMN share_medication_overview integer NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE user_settings ADD COLUMN upcoming_today_only integer NOT NULL DEFAULT 0`,
|
`ALTER TABLE user_settings ADD COLUMN upcoming_today_only integer NOT NULL DEFAULT 0`,
|
||||||
@@ -76,7 +75,6 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_channel text`,
|
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_channel text`,
|
||||||
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_med_names text`,
|
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_med_names text`,
|
||||||
`ALTER TABLE refill_history ADD COLUMN used_prescription integer NOT NULL DEFAULT 0`,
|
`ALTER TABLE refill_history ADD COLUMN used_prescription integer NOT NULL DEFAULT 0`,
|
||||||
`ALTER TABLE notification_action_groups ADD COLUMN ntfy_original_message_id text NOT NULL DEFAULT ''`,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const sql of alterMigrations) {
|
for (const sql of alterMigrations) {
|
||||||
@@ -98,31 +96,6 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
loose_pills_added INTEGER NOT NULL DEFAULT 0,
|
loose_pills_added INTEGER NOT NULL DEFAULT 0,
|
||||||
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||||||
)`,
|
)`,
|
||||||
`CREATE TABLE IF NOT EXISTS notification_action_groups (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
||||||
group_key TEXT NOT NULL UNIQUE,
|
|
||||||
sequence_id TEXT NOT NULL,
|
|
||||||
ntfy_original_message_id TEXT NOT NULL DEFAULT '',
|
|
||||||
dose_ids_json TEXT NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
language TEXT NOT NULL DEFAULT 'en',
|
|
||||||
scheduled_for INTEGER,
|
|
||||||
expires_at INTEGER NOT NULL,
|
|
||||||
resolved_action TEXT,
|
|
||||||
resolved_at INTEGER,
|
|
||||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
|
||||||
updated_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
|
||||||
)`,
|
|
||||||
`CREATE TABLE IF NOT EXISTS notification_action_tokens (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
group_id INTEGER NOT NULL REFERENCES notification_action_groups(id) ON DELETE CASCADE,
|
|
||||||
token_hash TEXT NOT NULL UNIQUE,
|
|
||||||
kind TEXT NOT NULL,
|
|
||||||
used_at INTEGER,
|
|
||||||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
|
||||||
)`,
|
|
||||||
`CREATE TABLE IF NOT EXISTS api_keys (
|
`CREATE TABLE IF NOT EXISTS api_keys (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
@@ -151,8 +124,6 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
|
|||||||
const createIndexMigrations = [
|
const createIndexMigrations = [
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`,
|
`CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`,
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`,
|
`CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`,
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS notification_action_groups_group_key_unique ON notification_action_groups(group_key)`,
|
|
||||||
`CREATE UNIQUE INDEX IF NOT EXISTS notification_action_tokens_token_hash_unique ON notification_action_tokens(token_hash)`,
|
|
||||||
`CREATE INDEX IF NOT EXISTS api_keys_user_id_idx ON api_keys(user_id)`,
|
`CREATE INDEX IF NOT EXISTS api_keys_user_id_idx ON api_keys(user_id)`,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -108,9 +108,8 @@ export const userSettings = sqliteTable("user_settings", {
|
|||||||
timezone: text("timezone", { length: 64 }).notNull().default(""),
|
timezone: text("timezone", { length: 64 }).notNull().default(""),
|
||||||
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
|
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
|
||||||
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
|
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
|
||||||
// Legacy column kept only so existing SQLite files continue to open cleanly after upgrades.
|
// Whether shared schedule links show stock status (Critical/Low/Normal) to intake users
|
||||||
// Current MedAssist versions no longer read or expose this setting in product flows.
|
shareStockStatus: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
|
||||||
legacyShareStockStatusCompat: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
|
|
||||||
// Whether shared schedule links also embed the medication overview section
|
// Whether shared schedule links also embed the medication overview section
|
||||||
shareMedicationOverview: integer("share_medication_overview", { mode: "boolean" }).notNull().default(false),
|
shareMedicationOverview: integer("share_medication_overview", { mode: "boolean" }).notNull().default(false),
|
||||||
// UI timeline visibility preferences
|
// UI timeline visibility preferences
|
||||||
@@ -184,43 +183,6 @@ export const shareTokens = sqliteTable("share_tokens", {
|
|||||||
expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires
|
expiresAt: integer("expires_at", { mode: "timestamp" }), // NULL = never expires
|
||||||
});
|
});
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Notification Action Groups - Shared action state for reminder notifications
|
|
||||||
// =============================================================================
|
|
||||||
export const notificationActionGroups = sqliteTable("notification_action_groups", {
|
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
||||||
userId: integer("user_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => users.id, { onDelete: "cascade" }),
|
|
||||||
groupKey: text("group_key", { length: 255 }).notNull().unique(),
|
|
||||||
sequenceId: text("sequence_id", { length: 255 }).notNull(),
|
|
||||||
ntfyOriginalMessageId: text("ntfy_original_message_id", { length: 255 }).notNull().default(""),
|
|
||||||
doseIdsJson: text("dose_ids_json").notNull(),
|
|
||||||
title: text("title", { length: 255 }).notNull(),
|
|
||||||
message: text("message").notNull(),
|
|
||||||
language: text("language", { length: 10 }).notNull().default("en"),
|
|
||||||
scheduledFor: integer("scheduled_for", { mode: "timestamp" }),
|
|
||||||
expiresAt: integer("expires_at", { mode: "timestamp" }).notNull(),
|
|
||||||
resolvedAction: text("resolved_action", { length: 20 }),
|
|
||||||
resolvedAt: integer("resolved_at", { mode: "timestamp" }),
|
|
||||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
|
||||||
updatedAt: integer("updated_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
|
||||||
});
|
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// Notification Action Tokens - Hashed tokens for public reminder responses
|
|
||||||
// =============================================================================
|
|
||||||
export const notificationActionTokens = sqliteTable("notification_action_tokens", {
|
|
||||||
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
||||||
groupId: integer("group_id")
|
|
||||||
.notNull()
|
|
||||||
.references(() => notificationActionGroups.id, { onDelete: "cascade" }),
|
|
||||||
tokenHash: text("token_hash", { length: 128 }).notNull().unique(),
|
|
||||||
kind: text("kind", { length: 20 }).notNull(),
|
|
||||||
usedAt: integer("used_at", { mode: "timestamp" }),
|
|
||||||
createdAt: integer("created_at", { mode: "timestamp" }).notNull().default(sql`CURRENT_TIMESTAMP`),
|
|
||||||
});
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Dose Tracking - Tracks when doses are marked as taken
|
// Dose Tracking - Tracks when doses are marked as taken
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -232,8 +194,8 @@ export const doseTracking = sqliteTable("dose_tracking", {
|
|||||||
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
|
doseId: text("dose_id", { length: 255 }).notNull(), // e.g. "med-5-1-86400000-1735200000000"
|
||||||
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
takenAt: integer("taken_at", { mode: "timestamp" }).notNull().default(sql`(strftime('%s','now'))`),
|
||||||
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
|
||||||
takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual, automatic, or notification
|
takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual or automatic
|
||||||
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // legacy column: true = intake skipped without stock deduction
|
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
|
||||||
});
|
});
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
+16
-17
@@ -10,11 +10,10 @@ const EnvSchema = z.object({
|
|||||||
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
||||||
PORT: z
|
PORT: z
|
||||||
.string()
|
.string()
|
||||||
.default("3000")
|
.transform((v) => parseInt(v, 10))
|
||||||
.transform((v) => parseInt(v, 10)),
|
.default(3000),
|
||||||
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
|
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
|
||||||
LOG_LEVEL: z.string().default("info"),
|
LOG_LEVEL: z.string().default("info"),
|
||||||
PUBLIC_APP_URL: z.string().url().optional(),
|
|
||||||
OPENAPI_DOCS_ENABLED: z
|
OPENAPI_DOCS_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.transform((v) => v === "true")
|
.transform((v) => v === "true")
|
||||||
@@ -26,18 +25,18 @@ const EnvSchema = z.object({
|
|||||||
// Master switch: Enable/disable authentication (default: disabled for easy setup)
|
// Master switch: Enable/disable authentication (default: disabled for easy setup)
|
||||||
AUTH_ENABLED: z
|
AUTH_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.default("false")
|
.transform((v) => v === "true")
|
||||||
.transform((v) => v === "true"),
|
.default(false),
|
||||||
// Allow new user registrations (auto-enabled if no users exist)
|
// Allow new user registrations (auto-enabled if no users exist)
|
||||||
REGISTRATION_ENABLED: z
|
REGISTRATION_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.default("false")
|
.transform((v) => v === "true")
|
||||||
.transform((v) => v === "true"),
|
.default(false),
|
||||||
// Disable username/password form login (useful for OIDC-only setups)
|
// Disable username/password form login (useful for OIDC-only setups)
|
||||||
FORM_LOGIN_ENABLED: z
|
FORM_LOGIN_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.default("true")
|
.transform((v) => v === "true")
|
||||||
.transform((v) => v === "true"),
|
.default(true),
|
||||||
|
|
||||||
// JWT Secrets - only required when AUTH_ENABLED=true
|
// JWT Secrets - only required when AUTH_ENABLED=true
|
||||||
JWT_SECRET: z.string().min(10).optional(),
|
JWT_SECRET: z.string().min(10).optional(),
|
||||||
@@ -47,20 +46,20 @@ const EnvSchema = z.object({
|
|||||||
// Token TTL settings
|
// Token TTL settings
|
||||||
ACCESS_TOKEN_TTL_MINUTES: z
|
ACCESS_TOKEN_TTL_MINUTES: z
|
||||||
.string()
|
.string()
|
||||||
.default("15")
|
.transform((v) => parseInt(v, 10))
|
||||||
.transform((v) => parseInt(v, 10)),
|
.default(15),
|
||||||
REFRESH_TOKEN_TTL_DAYS: z
|
REFRESH_TOKEN_TTL_DAYS: z
|
||||||
.string()
|
.string()
|
||||||
.default("7")
|
.transform((v) => parseInt(v, 10))
|
||||||
.transform((v) => parseInt(v, 10)),
|
.default(7),
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// OIDC SSO Configuration (Pocket ID, Authelia, etc.)
|
// OIDC SSO Configuration (Pocket ID, Authelia, etc.)
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
OIDC_ENABLED: z
|
OIDC_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.default("false")
|
.transform((v) => v === "true")
|
||||||
.transform((v) => v === "true"),
|
.default(false),
|
||||||
OIDC_ISSUER_URL: z.string().url().optional(), // e.g., https://auth.example.com
|
OIDC_ISSUER_URL: z.string().url().optional(), // e.g., https://auth.example.com
|
||||||
OIDC_CLIENT_ID: z.string().optional(),
|
OIDC_CLIENT_ID: z.string().optional(),
|
||||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||||
@@ -68,8 +67,8 @@ const EnvSchema = z.object({
|
|||||||
OIDC_SCOPES: z.string().default("openid profile email"),
|
OIDC_SCOPES: z.string().default("openid profile email"),
|
||||||
OIDC_AUTO_CREATE_USERS: z
|
OIDC_AUTO_CREATE_USERS: z
|
||||||
.string()
|
.string()
|
||||||
.default("true")
|
.transform((v) => v === "true")
|
||||||
.transform((v) => v === "true"),
|
.default(true),
|
||||||
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), // or 'email', 'sub'
|
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), // or 'email', 'sub'
|
||||||
OIDC_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button
|
OIDC_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -583,7 +583,7 @@ export async function sendShoutrrrNotification(
|
|||||||
urlStr: string,
|
urlStr: string,
|
||||||
title: string,
|
title: string,
|
||||||
message: string
|
message: string
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string; providerMessageId?: string }> {
|
||||||
try {
|
try {
|
||||||
if (urlStr.startsWith("pushover://")) {
|
if (urlStr.startsWith("pushover://")) {
|
||||||
const pushoverAuthority = urlStr.slice("pushover://".length).split("/")[0] ?? "";
|
const pushoverAuthority = urlStr.slice("pushover://".length).split("/")[0] ?? "";
|
||||||
@@ -736,7 +736,7 @@ export async function sendShoutrrrNotification(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use ONLY the reconstructed URL from validation - never the original urlStr
|
// Use ONLY the reconstructed URL from validation - never the original urlStr
|
||||||
const { url: sanitizedUrl, isNtfy: _isNtfy, auth } = validation;
|
const { url: sanitizedUrl, isNtfy, auth } = validation;
|
||||||
|
|
||||||
let targetUrl: string;
|
let targetUrl: string;
|
||||||
const method = "POST";
|
const method = "POST";
|
||||||
@@ -823,7 +823,17 @@ export async function sendShoutrrrNotification(
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
return { success: true };
|
let providerMessageId: string | undefined;
|
||||||
|
if (isNtfy) {
|
||||||
|
try {
|
||||||
|
const payload = (await response.json()) as { id?: unknown };
|
||||||
|
providerMessageId = typeof payload.id === "string" && payload.id.length > 0 ? payload.id : undefined;
|
||||||
|
} catch {
|
||||||
|
providerMessageId = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true, providerMessageId };
|
||||||
} else {
|
} else {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
return { success: false, error: `HTTP ${response.status}: ${errorText}` };
|
return { success: false, error: `HTTP ${response.status}: ${errorText}` };
|
||||||
|
|||||||
@@ -1,175 +0,0 @@
|
|||||||
import type { Language } from "../../i18n/translations.js";
|
|
||||||
|
|
||||||
export type PushNotificationAction =
|
|
||||||
| {
|
|
||||||
kind: "taken";
|
|
||||||
label: string;
|
|
||||||
url: string;
|
|
||||||
method: "POST";
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
kind: "skip";
|
|
||||||
label: string;
|
|
||||||
url: string;
|
|
||||||
method: "POST";
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
kind: "view";
|
|
||||||
label: string;
|
|
||||||
url: string;
|
|
||||||
method: "GET";
|
|
||||||
};
|
|
||||||
|
|
||||||
export type PushNotificationOptions = {
|
|
||||||
actions?: PushNotificationAction[];
|
|
||||||
respondUrl?: string;
|
|
||||||
viewUrl?: string;
|
|
||||||
clickUrl?: string;
|
|
||||||
tags?: string[];
|
|
||||||
priority?: number;
|
|
||||||
sequenceId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type NtfyActionPayload = {
|
|
||||||
action: "http" | "view";
|
|
||||||
label: string;
|
|
||||||
url: string;
|
|
||||||
method?: "POST";
|
|
||||||
clear: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
function encodeHeaderValue(value: string): string {
|
|
||||||
if ([...value].every((char) => char.charCodeAt(0) <= 0x7f)) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `=?UTF-8?B?${Buffer.from(value, "utf-8").toString("base64")}?=`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isNtfyNotificationUrl(urlStr: string): boolean {
|
|
||||||
if (urlStr.startsWith("ntfy://")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = new URL(urlStr);
|
|
||||||
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const hostname = parsed.hostname.toLowerCase();
|
|
||||||
return hostname === "ntfy.sh" || hostname === "ntfy" || hostname.startsWith("ntfy.") || hostname.includes(".ntfy.");
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getNotificationProvider(urlStr: string): string {
|
|
||||||
if (isNtfyNotificationUrl(urlStr)) {
|
|
||||||
return "ntfy";
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return new URL(urlStr).protocol.replace(":", "").toLowerCase();
|
|
||||||
} catch {
|
|
||||||
return "unknown";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getNotificationActionLabels(language: Language): {
|
|
||||||
taken: string;
|
|
||||||
skip: string;
|
|
||||||
respond: string;
|
|
||||||
view: string;
|
|
||||||
} {
|
|
||||||
if (language === "de") {
|
|
||||||
return {
|
|
||||||
taken: "Einnehmen",
|
|
||||||
skip: "Überspringen",
|
|
||||||
respond: "Antworten",
|
|
||||||
view: "Öffnen",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
taken: "Take",
|
|
||||||
skip: "Skip",
|
|
||||||
respond: "Respond",
|
|
||||||
view: "View",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildNtfyActions(options: PushNotificationOptions): NtfyActionPayload[] {
|
|
||||||
const actions = options.actions ?? [];
|
|
||||||
|
|
||||||
return actions.map((action) => {
|
|
||||||
if (action.kind === "view") {
|
|
||||||
return {
|
|
||||||
action: "view",
|
|
||||||
label: action.label,
|
|
||||||
url: action.url,
|
|
||||||
clear: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
action: "http",
|
|
||||||
label: action.label,
|
|
||||||
url: action.url,
|
|
||||||
method: "POST",
|
|
||||||
// Clear the original actionable ntfy notification locally after a successful mutation.
|
|
||||||
clear: true,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function appendFallbackActionLinks(message: string, options: PushNotificationOptions): string {
|
|
||||||
if (!options.respondUrl && !options.viewUrl) {
|
|
||||||
return message;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = [message.trimEnd()];
|
|
||||||
|
|
||||||
if (options.respondUrl) {
|
|
||||||
lines.push("", "Respond:", options.respondUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (options.viewUrl) {
|
|
||||||
lines.push("", "View:", options.viewUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function renderNotificationActionPayload(
|
|
||||||
urlStr: string,
|
|
||||||
message: string,
|
|
||||||
options: PushNotificationOptions
|
|
||||||
): { message: string; headers: Record<string, string> } {
|
|
||||||
if (!isNtfyNotificationUrl(urlStr)) {
|
|
||||||
return {
|
|
||||||
message: appendFallbackActionLinks(message, options),
|
|
||||||
headers: {},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers: Record<string, string> = {};
|
|
||||||
const ntfyActions = buildNtfyActions(options);
|
|
||||||
if (ntfyActions.length > 0) {
|
|
||||||
headers.Actions = encodeHeaderValue(JSON.stringify(ntfyActions));
|
|
||||||
}
|
|
||||||
if (options.clickUrl && ntfyActions.length === 0) {
|
|
||||||
headers.Click = options.clickUrl;
|
|
||||||
}
|
|
||||||
if (options.tags && options.tags.length > 0) {
|
|
||||||
headers.Tags = options.tags.join(",");
|
|
||||||
}
|
|
||||||
if (typeof options.priority === "number") {
|
|
||||||
headers.Priority = String(options.priority);
|
|
||||||
}
|
|
||||||
if (options.sequenceId) {
|
|
||||||
headers["X-Sequence-ID"] = options.sequenceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { message, headers };
|
|
||||||
}
|
|
||||||
@@ -123,13 +123,13 @@ export async function sendPushNotification(
|
|||||||
url: string,
|
url: string,
|
||||||
title: string,
|
title: string,
|
||||||
message: string
|
message: string
|
||||||
): Promise<{ success: boolean; error?: string }> {
|
): Promise<{ success: boolean; error?: string; providerMessageId?: string }> {
|
||||||
try {
|
try {
|
||||||
const result = await sendShoutrrrNotification(url, title, message);
|
const result = await sendShoutrrrNotification(url, title, message);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
return { success: false, error: result.error };
|
return { success: false, error: result.error };
|
||||||
}
|
}
|
||||||
return { success: true };
|
return { success: true, providerMessageId: result.providerMessageId };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
const errorMessage = error instanceof Error ? error.message : "Unknown error";
|
||||||
return { success: false, error: errorMessage };
|
return { success: false, error: errorMessage };
|
||||||
|
|||||||
@@ -10,34 +10,33 @@ const EnvSchema = z.object({
|
|||||||
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
|
||||||
PORT: z
|
PORT: z
|
||||||
.string()
|
.string()
|
||||||
.default("3000")
|
.transform((v) => parseInt(v, 10))
|
||||||
.transform((v) => parseInt(v, 10)),
|
.default(3000),
|
||||||
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
|
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
|
||||||
LOG_LEVEL: z.string().default("info"),
|
LOG_LEVEL: z.string().default("info"),
|
||||||
PUBLIC_APP_URL: z.string().url().optional(),
|
|
||||||
AUTH_ENABLED: z
|
AUTH_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.default("false")
|
.transform((v) => v === "true")
|
||||||
.transform((v) => v === "true"),
|
.default(false),
|
||||||
REGISTRATION_ENABLED: z
|
REGISTRATION_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.default("false")
|
.transform((v) => v === "true")
|
||||||
.transform((v) => v === "true"),
|
.default(false),
|
||||||
JWT_SECRET: z.string().min(10).optional(),
|
JWT_SECRET: z.string().min(10).optional(),
|
||||||
REFRESH_SECRET: z.string().min(10).optional(),
|
REFRESH_SECRET: z.string().min(10).optional(),
|
||||||
COOKIE_SECRET: z.string().min(10).optional(),
|
COOKIE_SECRET: z.string().min(10).optional(),
|
||||||
ACCESS_TOKEN_TTL_MINUTES: z
|
ACCESS_TOKEN_TTL_MINUTES: z
|
||||||
.string()
|
.string()
|
||||||
.default("15")
|
.transform((v) => parseInt(v, 10))
|
||||||
.transform((v) => parseInt(v, 10)),
|
.default(15),
|
||||||
REFRESH_TOKEN_TTL_DAYS: z
|
REFRESH_TOKEN_TTL_DAYS: z
|
||||||
.string()
|
.string()
|
||||||
.default("7")
|
.transform((v) => parseInt(v, 10))
|
||||||
.transform((v) => parseInt(v, 10)),
|
.default(7),
|
||||||
OIDC_ENABLED: z
|
OIDC_ENABLED: z
|
||||||
.string()
|
.string()
|
||||||
.default("false")
|
.transform((v) => v === "true")
|
||||||
.transform((v) => v === "true"),
|
.default(false),
|
||||||
OIDC_ISSUER_URL: z.string().url().optional(),
|
OIDC_ISSUER_URL: z.string().url().optional(),
|
||||||
OIDC_CLIENT_ID: z.string().optional(),
|
OIDC_CLIENT_ID: z.string().optional(),
|
||||||
OIDC_CLIENT_SECRET: z.string().optional(),
|
OIDC_CLIENT_SECRET: z.string().optional(),
|
||||||
@@ -45,8 +44,8 @@ const EnvSchema = z.object({
|
|||||||
OIDC_SCOPES: z.string().default("openid profile email"),
|
OIDC_SCOPES: z.string().default("openid profile email"),
|
||||||
OIDC_AUTO_CREATE_USERS: z
|
OIDC_AUTO_CREATE_USERS: z
|
||||||
.string()
|
.string()
|
||||||
.default("true")
|
.transform((v) => v === "true")
|
||||||
.transform((v) => v === "true"),
|
.default(true),
|
||||||
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"),
|
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"),
|
||||||
OIDC_PROVIDER_NAME: z.string().default("SSO"),
|
OIDC_PROVIDER_NAME: z.string().default("SSO"),
|
||||||
});
|
});
|
||||||
@@ -82,7 +81,6 @@ describe("EnvSchema", () => {
|
|||||||
expect(result.PORT).toBe(3000);
|
expect(result.PORT).toBe(3000);
|
||||||
expect(result.CORS_ORIGINS).toBe("http://localhost:5173,http://localhost:4173");
|
expect(result.CORS_ORIGINS).toBe("http://localhost:5173,http://localhost:4173");
|
||||||
expect(result.LOG_LEVEL).toBe("info");
|
expect(result.LOG_LEVEL).toBe("info");
|
||||||
expect(result.PUBLIC_APP_URL).toBeUndefined();
|
|
||||||
expect(result.AUTH_ENABLED).toBe(false);
|
expect(result.AUTH_ENABLED).toBe(false);
|
||||||
expect(result.REGISTRATION_ENABLED).toBe(false);
|
expect(result.REGISTRATION_ENABLED).toBe(false);
|
||||||
expect(result.ACCESS_TOKEN_TTL_MINUTES).toBe(15);
|
expect(result.ACCESS_TOKEN_TTL_MINUTES).toBe(15);
|
||||||
@@ -190,15 +188,6 @@ describe("EnvSchema", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("OIDC URL validation", () => {
|
describe("OIDC URL validation", () => {
|
||||||
it("should accept valid PUBLIC_APP_URL", () => {
|
|
||||||
const result = EnvSchema.parse({ PUBLIC_APP_URL: "https://medassist.example.com" });
|
|
||||||
expect(result.PUBLIC_APP_URL).toBe("https://medassist.example.com");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should reject invalid PUBLIC_APP_URL", () => {
|
|
||||||
expect(() => EnvSchema.parse({ PUBLIC_APP_URL: "not-a-url" })).toThrow();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("should accept valid OIDC_ISSUER_URL", () => {
|
it("should accept valid OIDC_ISSUER_URL", () => {
|
||||||
const result = EnvSchema.parse({ OIDC_ISSUER_URL: "https://auth.example.com" });
|
const result = EnvSchema.parse({ OIDC_ISSUER_URL: "https://auth.example.com" });
|
||||||
expect(result.OIDC_ISSUER_URL).toBe("https://auth.example.com");
|
expect(result.OIDC_ISSUER_URL).toBe("https://auth.example.com");
|
||||||
|
|||||||
@@ -1,186 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import {
|
|
||||||
getNotificationActionLabels,
|
|
||||||
isNtfyNotificationUrl,
|
|
||||||
type PushNotificationAction,
|
|
||||||
renderNotificationActionPayload,
|
|
||||||
} from "../services/notifications/action-renderer.js";
|
|
||||||
|
|
||||||
function decodeRfc2047Base64(value: string): string {
|
|
||||||
const match = /^=\?UTF-8\?B\?(.+)\?=$/.exec(value);
|
|
||||||
if (!match) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return Buffer.from(match[1], "base64").toString("utf-8");
|
|
||||||
}
|
|
||||||
|
|
||||||
const actions: PushNotificationAction[] = [
|
|
||||||
{
|
|
||||||
kind: "taken",
|
|
||||||
label: "Take",
|
|
||||||
url: "https://app.example.com/api/notification-actions/taken-token",
|
|
||||||
method: "POST",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "skip",
|
|
||||||
label: "Skip",
|
|
||||||
url: "https://app.example.com/api/notification-actions/skip-token",
|
|
||||||
method: "POST",
|
|
||||||
},
|
|
||||||
{ kind: "view", label: "View", url: "https://app.example.com/?date=2026-01-05", method: "GET" },
|
|
||||||
];
|
|
||||||
|
|
||||||
describe("notification action renderer", () => {
|
|
||||||
it("builds ntfy native actions without duplicate click headers", () => {
|
|
||||||
const result = renderNotificationActionPayload("ntfy://ntfy.sh/medassist", "Body", {
|
|
||||||
actions,
|
|
||||||
clickUrl: "https://app.example.com/api/notification-actions/respond-token",
|
|
||||||
respondUrl: "https://app.example.com/api/notification-actions/respond-token",
|
|
||||||
viewUrl: "https://app.example.com/?date=2026-01-05",
|
|
||||||
tags: ["pill"],
|
|
||||||
priority: 4,
|
|
||||||
sequenceId: "medassist-sequence",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.message).toBe("Body");
|
|
||||||
expect(result.headers).toMatchObject({
|
|
||||||
Tags: "pill",
|
|
||||||
Priority: "4",
|
|
||||||
"X-Sequence-ID": "medassist-sequence",
|
|
||||||
});
|
|
||||||
expect(result.headers.Click).toBeUndefined();
|
|
||||||
|
|
||||||
const parsedActions = JSON.parse(result.headers.Actions ?? "[]");
|
|
||||||
expect(parsedActions).toEqual([
|
|
||||||
{
|
|
||||||
action: "http",
|
|
||||||
label: "Take",
|
|
||||||
url: "https://app.example.com/api/notification-actions/taken-token",
|
|
||||||
method: "POST",
|
|
||||||
clear: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: "http",
|
|
||||||
label: "Skip",
|
|
||||||
url: "https://app.example.com/api/notification-actions/skip-token",
|
|
||||||
method: "POST",
|
|
||||||
clear: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: "view",
|
|
||||||
label: "View",
|
|
||||||
url: "https://app.example.com/?date=2026-01-05",
|
|
||||||
clear: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the ntfy click header when there are no native actions", () => {
|
|
||||||
const result = renderNotificationActionPayload("ntfy://ntfy.sh/medassist", "Body", {
|
|
||||||
clickUrl: "https://app.example.com/api/notification-actions/respond-token",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.headers.Click).toBe("https://app.example.com/api/notification-actions/respond-token");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("treats direct https ntfy URLs as ntfy targets with native actions", () => {
|
|
||||||
const result = renderNotificationActionPayload("https://ntfy.danielvolz.org/medis_test", "Body", {
|
|
||||||
actions,
|
|
||||||
clickUrl: "https://app.example.com/api/notification-actions/respond-token",
|
|
||||||
respondUrl: "https://app.example.com/api/notification-actions/respond-token",
|
|
||||||
viewUrl: "https://app.example.com/?date=2026-01-05",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(isNtfyNotificationUrl("https://ntfy.danielvolz.org/medis_test")).toBe(true);
|
|
||||||
expect(result.message).toBe("Body");
|
|
||||||
expect(result.headers.Actions).toBeTruthy();
|
|
||||||
expect(result.message).not.toContain("Respond:");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps insecure http mutation targets as direct ntfy http actions without the dev fallback", () => {
|
|
||||||
const result = renderNotificationActionPayload("https://ntfy.danielvolz.org/medis_test", "Body", {
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
kind: "taken",
|
|
||||||
label: "Take",
|
|
||||||
url: "http://192.168.0.113:5173/api/notification-actions/taken-token",
|
|
||||||
method: "POST",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(JSON.parse(result.headers.Actions ?? "[]")).toEqual([
|
|
||||||
{
|
|
||||||
action: "http",
|
|
||||||
label: "Take",
|
|
||||||
url: "http://192.168.0.113:5173/api/notification-actions/taken-token",
|
|
||||||
method: "POST",
|
|
||||||
clear: true,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("encodes non-ascii ntfy action labels as RFC 2047 headers", () => {
|
|
||||||
const result = renderNotificationActionPayload("https://ntfy.danielvolz.org/medis_test", "Body", {
|
|
||||||
actions: [
|
|
||||||
{
|
|
||||||
kind: "skip",
|
|
||||||
label: "Überspringen",
|
|
||||||
url: "https://app.example.com/api/notification-actions/skip-token",
|
|
||||||
method: "POST",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
kind: "view",
|
|
||||||
label: "Öffnen",
|
|
||||||
url: "https://app.example.com/?date=2026-01-05",
|
|
||||||
method: "GET",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.headers.Actions).toMatch(/^=\?UTF-8\?B\?/);
|
|
||||||
expect(JSON.parse(decodeRfc2047Base64(result.headers.Actions ?? "[]"))).toEqual([
|
|
||||||
{
|
|
||||||
action: "http",
|
|
||||||
label: "Überspringen",
|
|
||||||
url: "https://app.example.com/api/notification-actions/skip-token",
|
|
||||||
method: "POST",
|
|
||||||
clear: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: "view",
|
|
||||||
label: "Öffnen",
|
|
||||||
url: "https://app.example.com/?date=2026-01-05",
|
|
||||||
clear: false,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses consistent action-form labels for English and German", () => {
|
|
||||||
expect(getNotificationActionLabels("en")).toEqual({
|
|
||||||
taken: "Take",
|
|
||||||
skip: "Skip",
|
|
||||||
respond: "Respond",
|
|
||||||
view: "View",
|
|
||||||
});
|
|
||||||
expect(getNotificationActionLabels("de")).toEqual({
|
|
||||||
taken: "Einnehmen",
|
|
||||||
skip: "Überspringen",
|
|
||||||
respond: "Antworten",
|
|
||||||
view: "Öffnen",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("appends respond and view fallback links for non-ntfy providers", () => {
|
|
||||||
const result = renderNotificationActionPayload("https://hooks.slack.com/services/a/b/c", "Body", {
|
|
||||||
respondUrl: "https://app.example.com/api/notification-actions/respond-token",
|
|
||||||
viewUrl: "https://app.example.com/?date=2026-01-05",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.headers).toEqual({});
|
|
||||||
expect(result.message).toBe(
|
|
||||||
"Body\n\nRespond:\nhttps://app.example.com/api/notification-actions/respond-token\n\nView:\nhttps://app.example.com/?date=2026-01-05"
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -6,7 +6,6 @@ Scope and behavior:
|
|||||||
|
|
||||||
- These values are applied only when a user's settings are created for the first time.
|
- These values are applied only when a user's settings are created for the first time.
|
||||||
- After that, values stored in the database are used and take precedence.
|
- After that, values stored in the database are used and take precedence.
|
||||||
- Source of truth in code: [backend/src/routes/settings.ts](backend/src/routes/settings.ts).
|
|
||||||
|
|
||||||
## Email Defaults
|
## Email Defaults
|
||||||
|
|
||||||
@@ -47,6 +46,6 @@ Scope and behavior:
|
|||||||
|----------|---------|-------------|
|
|----------|---------|-------------|
|
||||||
| `DEFAULT_LANGUAGE` | `en` | Default language (`en` or `de`). |
|
| `DEFAULT_LANGUAGE` | `en` | Default language (`en` or `de`). |
|
||||||
| `DEFAULT_STOCK_CALCULATION_MODE` | `automatic` | Default stock mode (`automatic` or `manual`). |
|
| `DEFAULT_STOCK_CALCULATION_MODE` | `automatic` | Default stock mode (`automatic` or `manual`). |
|
||||||
| `DEFAULT_SHARE_STOCK_STATUS` | `true` | Show stock status on shared schedule links. |
|
| `DEFAULT_SHARE_MEDICATION_OVERVIEW` | `false` | Show medication overview section on shared schedule links. |
|
||||||
| `DEFAULT_UPCOMING_TODAY_ONLY` | `false` | Show only today's upcoming doses by default. |
|
| `DEFAULT_UPCOMING_TODAY_ONLY` | `false` | Show only today's upcoming doses by default. |
|
||||||
| `DEFAULT_SHARE_SCHEDULE_TODAY_ONLY` | `false` | Show only today's schedule in shared view by default. |
|
| `DEFAULT_SHARE_SCHEDULE_TODAY_ONLY` | `false` | Show only today's schedule in shared view by default. |
|
||||||
|
|||||||
@@ -506,7 +506,7 @@ function AppContent() {
|
|||||||
<AboutModal isOpen={showAbout} onClose={closeAbout} />
|
<AboutModal isOpen={showAbout} onClose={closeAbout} />
|
||||||
|
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
<Route path="/" element={<Navigate to={{ pathname: "/dashboard", search: location.search }} replace />} />
|
||||||
<Route path="/dashboard" element={<DashboardPage />} />
|
<Route path="/dashboard" element={<DashboardPage />} />
|
||||||
|
|
||||||
<Route path="/medications" element={<MedicationsPage />} />
|
<Route path="/medications" element={<MedicationsPage />} />
|
||||||
|
|||||||
@@ -235,10 +235,6 @@ export function SharedSchedule() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function markDoseTaken(doseId: string) {
|
async function markDoseTaken(doseId: string) {
|
||||||
if (dismissedDoses.has(doseId)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const wasTaken = takenDoses.has(doseId);
|
const wasTaken = takenDoses.has(doseId);
|
||||||
const wasSkipped = dismissedDoses.has(doseId);
|
const wasSkipped = dismissedDoses.has(doseId);
|
||||||
const wasAutomatic = automaticTakenDoses.has(doseId);
|
const wasAutomatic = automaticTakenDoses.has(doseId);
|
||||||
@@ -466,7 +462,7 @@ export function SharedSchedule() {
|
|||||||
<button
|
<button
|
||||||
className={`dose-btn take${options.isEmpty ? " out-of-stock" : ""}`}
|
className={`dose-btn take${options.isEmpty ? " out-of-stock" : ""}`}
|
||||||
onClick={() => markDoseTaken(options.doseId)}
|
onClick={() => markDoseTaken(options.doseId)}
|
||||||
disabled={options.isEmpty || options.isSkipped}
|
disabled={options.isEmpty}
|
||||||
title={options.isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
|
title={options.isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
|
||||||
>
|
>
|
||||||
<span className="dose-btn-label">{t("dose.take")}</span>
|
<span className="dose-btn-label">{t("dose.take")}</span>
|
||||||
@@ -476,7 +472,7 @@ export function SharedSchedule() {
|
|||||||
|
|
||||||
const skipButton = options.isSkipped ? (
|
const skipButton = options.isSkipped ? (
|
||||||
<button className="dose-btn undo skip" onClick={() => undoDoseSkipped(options.doseId)} title={t("common.undo")}>
|
<button className="dose-btn undo skip" onClick={() => undoDoseSkipped(options.doseId)} title={t("common.undo")}>
|
||||||
<span className="dose-btn-label">{t("common.undo")}</span>
|
<span className="dose-btn-label">{t("dose.undoSkip")}</span>
|
||||||
<span aria-hidden="true">↩</span>
|
<span aria-hidden="true">↩</span>
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -257,8 +257,10 @@ export function MedicationsPage() {
|
|||||||
useUnsavedChangesWarning(formChanged);
|
useUnsavedChangesWarning(formChanged);
|
||||||
|
|
||||||
// View mode: grid (default) or form (edit/new)
|
// View mode: grid (default) or form (edit/new)
|
||||||
// If navigating in with editMedId, suppress rendering until the edit form is ready
|
// If navigating in with a medication deep-link, suppress rendering until the target form is ready
|
||||||
const [pendingEditTransition, setPendingEditTransition] = useState(() => searchParams.has("editMedId"));
|
const [pendingEditTransition, setPendingEditTransition] = useState(
|
||||||
|
() => searchParams.has("editMedId") || searchParams.has("viewMedId")
|
||||||
|
);
|
||||||
const [viewMode, setViewMode] = useState<"grid" | "form">(pendingEditTransition ? "form" : "grid");
|
const [viewMode, setViewMode] = useState<"grid" | "form">(pendingEditTransition ? "form" : "grid");
|
||||||
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null);
|
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null);
|
||||||
const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general");
|
const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general");
|
||||||
@@ -269,9 +271,23 @@ export function MedicationsPage() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
showEditModalRef.current = showEditModal;
|
showEditModalRef.current = showEditModal;
|
||||||
}, [showEditModal]);
|
}, [showEditModal]);
|
||||||
const processedEditMedIdRef = useRef<string | null>(null);
|
const processedMedicationLinkRef = useRef<string | null>(null);
|
||||||
const hasDesktopFormHistoryState = useRef(false);
|
const hasDesktopFormHistoryState = useRef(false);
|
||||||
|
|
||||||
|
const getMedicationLinkState = useCallback((params: URLSearchParams) => {
|
||||||
|
const viewMedId = params.get("viewMedId");
|
||||||
|
if (viewMedId) {
|
||||||
|
return { mode: "view" as const, linkedMedId: viewMedId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const editMedId = params.get("editMedId");
|
||||||
|
if (editMedId) {
|
||||||
|
return { mode: "edit" as const, linkedMedId: editMedId };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { mode: null, linkedMedId: null };
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Sync formChanged state to the global context for navigation blocking
|
// Sync formChanged state to the global context for navigation blocking
|
||||||
const { setHasUnsavedChanges } = useUnsavedChanges();
|
const { setHasUnsavedChanges } = useUnsavedChanges();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -819,12 +835,13 @@ export function MedicationsPage() {
|
|||||||
[t]
|
[t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const clearEditMedIdParam = useCallback(() => {
|
const clearMedicationLinkParams = useCallback(() => {
|
||||||
setSearchParams(
|
setSearchParams(
|
||||||
(prevParams) => {
|
(prevParams) => {
|
||||||
if (!prevParams.has("editMedId")) return prevParams;
|
if (!prevParams.has("editMedId") && !prevParams.has("viewMedId")) return prevParams;
|
||||||
const nextParams = new URLSearchParams(prevParams);
|
const nextParams = new URLSearchParams(prevParams);
|
||||||
nextParams.delete("editMedId");
|
nextParams.delete("editMedId");
|
||||||
|
nextParams.delete("viewMedId");
|
||||||
return nextParams;
|
return nextParams;
|
||||||
},
|
},
|
||||||
{ replace: true }
|
{ replace: true }
|
||||||
@@ -848,7 +865,7 @@ export function MedicationsPage() {
|
|||||||
setShowUnsavedConfirm(true);
|
setShowUnsavedConfirm(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
clearEditMedIdParam();
|
clearMedicationLinkParams();
|
||||||
// Mark as confirmed to avoid double confirmation in popstate handler
|
// Mark as confirmed to avoid double confirmation in popstate handler
|
||||||
closeConfirmedRef.current = true;
|
closeConfirmedRef.current = true;
|
||||||
window.history.back();
|
window.history.back();
|
||||||
@@ -1159,7 +1176,7 @@ export function MedicationsPage() {
|
|||||||
if (shouldCloseMobileModal) {
|
if (shouldCloseMobileModal) {
|
||||||
// Treat post-save close as confirmed so popstate does not trigger unsaved guards.
|
// Treat post-save close as confirmed so popstate does not trigger unsaved guards.
|
||||||
closeConfirmedRef.current = true;
|
closeConfirmedRef.current = true;
|
||||||
clearEditMedIdParam();
|
clearMedicationLinkParams();
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setReadOnlyView(false);
|
setReadOnlyView(false);
|
||||||
setActiveTab("general");
|
setActiveTab("general");
|
||||||
@@ -1188,7 +1205,8 @@ export function MedicationsPage() {
|
|||||||
// Handle browser back button for modals and unsaved changes
|
// Handle browser back button for modals and unsaved changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handlePopState = () => {
|
const handlePopState = () => {
|
||||||
const currentEditMedId = new URLSearchParams(window.location.search).get("editMedId");
|
const currentParams = new URLSearchParams(window.location.search);
|
||||||
|
const { mode: currentLinkMode, linkedMedId: currentMedicationLinkId } = getMedicationLinkState(currentParams);
|
||||||
|
|
||||||
// Obsolete confirmation is open — dismiss it and stay where we are
|
// Obsolete confirmation is open — dismiss it and stay where we are
|
||||||
if (showObsoleteConfirm) {
|
if (showObsoleteConfirm) {
|
||||||
@@ -1207,10 +1225,10 @@ export function MedicationsPage() {
|
|||||||
// If close was already confirmed programmatically, allow navigation
|
// If close was already confirmed programmatically, allow navigation
|
||||||
if (closeConfirmedRef.current) {
|
if (closeConfirmedRef.current) {
|
||||||
closeConfirmedRef.current = false;
|
closeConfirmedRef.current = false;
|
||||||
if (currentEditMedId) {
|
if (currentMedicationLinkId && currentLinkMode) {
|
||||||
// Prevent URL popstate from immediately reopening mobile edit for the same id.
|
// Prevent URL popstate from immediately reopening mobile edit for the same id.
|
||||||
processedEditMedIdRef.current = currentEditMedId;
|
processedMedicationLinkRef.current = `${currentLinkMode}:${currentMedicationLinkId}`;
|
||||||
clearEditMedIdParam();
|
clearMedicationLinkParams();
|
||||||
}
|
}
|
||||||
if (showEditModal) {
|
if (showEditModal) {
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
@@ -1231,11 +1249,11 @@ export function MedicationsPage() {
|
|||||||
setShowUnsavedConfirm(true);
|
setShowUnsavedConfirm(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (currentEditMedId) {
|
if (currentMedicationLinkId && currentLinkMode) {
|
||||||
// Mark as handled before URL cleanup to avoid same-tick re-open races.
|
// Mark as handled before URL cleanup to avoid same-tick re-open races.
|
||||||
processedEditMedIdRef.current = currentEditMedId;
|
processedMedicationLinkRef.current = `${currentLinkMode}:${currentMedicationLinkId}`;
|
||||||
}
|
}
|
||||||
clearEditMedIdParam();
|
clearMedicationLinkParams();
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
resetForm();
|
resetForm();
|
||||||
resetMedicationEnrichment();
|
resetMedicationEnrichment();
|
||||||
@@ -1271,7 +1289,16 @@ export function MedicationsPage() {
|
|||||||
};
|
};
|
||||||
window.addEventListener("popstate", handlePopState);
|
window.addEventListener("popstate", handlePopState);
|
||||||
return () => window.removeEventListener("popstate", handlePopState);
|
return () => window.removeEventListener("popstate", handlePopState);
|
||||||
}, [showObsoleteConfirm, showDeleteConfirm, showEditModal, viewMode, formChanged, resetForm, clearEditMedIdParam]);
|
}, [
|
||||||
|
showObsoleteConfirm,
|
||||||
|
showDeleteConfirm,
|
||||||
|
showEditModal,
|
||||||
|
viewMode,
|
||||||
|
formChanged,
|
||||||
|
resetForm,
|
||||||
|
clearMedicationLinkParams,
|
||||||
|
getMedicationLinkState,
|
||||||
|
]);
|
||||||
|
|
||||||
// Close modal on Escape key
|
// Close modal on Escape key
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -1389,22 +1416,23 @@ export function MedicationsPage() {
|
|||||||
}, [activeMeds, editingId]);
|
}, [activeMeds, editingId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const editMedId = searchParams.get("editMedId");
|
const { mode: linkMode, linkedMedId } = getMedicationLinkState(searchParams);
|
||||||
if (!editMedId) {
|
if (!linkedMedId || !linkMode) {
|
||||||
processedEditMedIdRef.current = null;
|
processedMedicationLinkRef.current = null;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (processedEditMedIdRef.current === editMedId) return;
|
const linkKey = `${linkMode}:${linkedMedId}`;
|
||||||
const parsedMedId = Number.parseInt(editMedId, 10);
|
if (processedMedicationLinkRef.current === linkKey) return;
|
||||||
|
const parsedMedId = Number.parseInt(linkedMedId, 10);
|
||||||
if (Number.isNaN(parsedMedId)) return;
|
if (Number.isNaN(parsedMedId)) return;
|
||||||
const medicationToEdit =
|
const medicationToEdit =
|
||||||
meds.find((med) => med.id === parsedMedId) ?? allMeds.find((med) => med.id === parsedMedId);
|
meds.find((med) => med.id === parsedMedId) ?? allMeds.find((med) => med.id === parsedMedId);
|
||||||
if (!medicationToEdit) return;
|
if (!medicationToEdit) return;
|
||||||
|
|
||||||
processedEditMedIdRef.current = editMedId;
|
processedMedicationLinkRef.current = linkKey;
|
||||||
|
|
||||||
setShowNameValidation(false);
|
setShowNameValidation(false);
|
||||||
setReadOnlyView(false);
|
setReadOnlyView(linkMode === "view");
|
||||||
setActiveTab("general");
|
setActiveTab("general");
|
||||||
resetMedicationEnrichment(medicationToEdit.name || medicationToEdit.genericName || "");
|
resetMedicationEnrichment(medicationToEdit.name || medicationToEdit.genericName || "");
|
||||||
startEdit(medicationToEdit, openEditModal);
|
startEdit(medicationToEdit, openEditModal);
|
||||||
@@ -1415,8 +1443,9 @@ export function MedicationsPage() {
|
|||||||
|
|
||||||
const nextParams = new URLSearchParams(searchParams);
|
const nextParams = new URLSearchParams(searchParams);
|
||||||
nextParams.delete("editMedId");
|
nextParams.delete("editMedId");
|
||||||
|
nextParams.delete("viewMedId");
|
||||||
setSearchParams(nextParams, { replace: true });
|
setSearchParams(nextParams, { replace: true });
|
||||||
}, [allMeds, meds, openEditModal, searchParams, setSearchParams, startEdit]);
|
}, [allMeds, getMedicationLinkState, meds, openEditModal, searchParams, setSearchParams, startEdit]);
|
||||||
|
|
||||||
const selectedMedication = useMemo(() => {
|
const selectedMedication = useMemo(() => {
|
||||||
if (!editingId) return null;
|
if (!editingId) return null;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter, useLocation } from "react-router-dom";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import App from "../App";
|
import App from "../App";
|
||||||
|
|
||||||
@@ -59,7 +59,15 @@ vi.mock("../context", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
vi.mock("../pages", () => ({
|
vi.mock("../pages", () => ({
|
||||||
DashboardPage: () => <div>dashboard-page</div>,
|
DashboardPage: () => {
|
||||||
|
const location = useLocation();
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<span>dashboard-page</span>
|
||||||
|
<span data-testid="dashboard-location-search">{location.search}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
MedicationsPage: () => <div>medications-page</div>,
|
MedicationsPage: () => <div>medications-page</div>,
|
||||||
PlannerPage: () => <div>planner-page</div>,
|
PlannerPage: () => <div>planner-page</div>,
|
||||||
SchedulePage: () => <div>schedule-page</div>,
|
SchedulePage: () => <div>schedule-page</div>,
|
||||||
@@ -265,6 +273,19 @@ describe("App", () => {
|
|||||||
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
|
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves notification query params when redirecting root to dashboard", () => {
|
||||||
|
const search = "?date=2026-05-06&medId=4332&doseId=4332-0-1778104500000";
|
||||||
|
|
||||||
|
render(
|
||||||
|
<MemoryRouter initialEntries={[`/${search}`]}>
|
||||||
|
<App />
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("dashboard-location-search")).toHaveTextContent(search);
|
||||||
|
});
|
||||||
|
|
||||||
it("renders initializing state when auth state is missing", () => {
|
it("renders initializing state when auth state is missing", () => {
|
||||||
authMock = {
|
authMock = {
|
||||||
user: null,
|
user: null,
|
||||||
|
|||||||
@@ -175,6 +175,10 @@ describe("LoginForm", () => {
|
|||||||
oidcProviderName: "",
|
oidcProviderName: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
window.history.replaceState({}, "", "/");
|
||||||
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
(global.fetch as ReturnType<typeof vi.fn>)
|
(global.fetch as ReturnType<typeof vi.fn>)
|
||||||
|
|||||||
@@ -475,6 +475,21 @@ describe("MedicationsPage with items", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens read-only view from viewMedId query parameter", async () => {
|
||||||
|
const startEdit = vi.fn();
|
||||||
|
mockFormHookValue = createMockFormHook({ startEdit });
|
||||||
|
fetchMock.mockResolvedValue({ ok: true, json: async () => mockMeds });
|
||||||
|
|
||||||
|
renderPage("/medications?viewMedId=1");
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(startEdit).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("common.close")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("common.save")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("opens unsaved confirm and continues edit after confirmation", async () => {
|
it("opens unsaved confirm and continues edit after confirmation", async () => {
|
||||||
const startEdit = vi.fn();
|
const startEdit = vi.fn();
|
||||||
const resetForm = vi.fn();
|
const resetForm = vi.fn();
|
||||||
|
|||||||
@@ -2,6 +2,24 @@ import { existsSync, readFileSync } from "fs";
|
|||||||
import { defineConfig } from "vite";
|
import { defineConfig } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
function parseCsvEnv(value: string | undefined, fallback: string[]) {
|
||||||
|
const entries = value
|
||||||
|
?.split(",")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.filter((entry) => entry.length > 0);
|
||||||
|
|
||||||
|
return entries && entries.length > 0 ? entries : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOptionalPort(value: string | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = Number.parseInt(value, 10);
|
||||||
|
return Number.isFinite(parsed) ? parsed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Read version from package.json at build time
|
// Read version from package.json at build time
|
||||||
const packageJson = JSON.parse(readFileSync("./package.json", "utf-8"));
|
const packageJson = JSON.parse(readFileSync("./package.json", "utf-8"));
|
||||||
|
|
||||||
@@ -9,6 +27,19 @@ const packageJson = JSON.parse(readFileSync("./package.json", "utf-8"));
|
|||||||
// In Docker, prefer backend-dev to avoid localhost proxy failures.
|
// In Docker, prefer backend-dev to avoid localhost proxy failures.
|
||||||
const defaultBackendTarget = existsSync("/.dockerenv") ? "http://backend-dev:3000" : "http://localhost:3000";
|
const defaultBackendTarget = existsSync("/.dockerenv") ? "http://backend-dev:3000" : "http://localhost:3000";
|
||||||
const backendTarget = process.env.BACKEND_URL || defaultBackendTarget;
|
const backendTarget = process.env.BACKEND_URL || defaultBackendTarget;
|
||||||
|
const allowedHosts = parseCsvEnv(process.env.VITE_ALLOWED_HOSTS, ["localhost", "127.0.0.1"]);
|
||||||
|
const hmrHost = process.env.VITE_HMR_HOST?.trim();
|
||||||
|
const hmrProtocol = process.env.VITE_HMR_PROTOCOL === "ws" ? "ws" : process.env.VITE_HMR_PROTOCOL === "wss" ? "wss" : undefined;
|
||||||
|
const hmrClientPort = parseOptionalPort(process.env.VITE_HMR_CLIENT_PORT);
|
||||||
|
const hmrPort = parseOptionalPort(process.env.VITE_HMR_PORT);
|
||||||
|
const hmr = hmrHost
|
||||||
|
? {
|
||||||
|
host: hmrHost,
|
||||||
|
protocol: hmrProtocol ?? "wss",
|
||||||
|
clientPort: hmrClientPort ?? (hmrProtocol === "ws" ? 80 : 443),
|
||||||
|
port: hmrPort ?? 5173,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
@@ -19,6 +50,8 @@ export default defineConfig({
|
|||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
|
allowedHosts,
|
||||||
|
hmr,
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
target: backendTarget,
|
target: backendTarget,
|
||||||
|
|||||||
Reference in New Issue
Block a user