Compare commits

..

2 Commits

Author SHA1 Message Date
Daniel Volz de31ac7eb7 feat: add ntfy notification action renderer 2026-05-10 19:01:11 +02:00
Daniel Volz e2ed25059a feat: add ntfy notification action foundation 2026-05-10 19:00:57 +02:00
23 changed files with 985 additions and 209 deletions
+7 -1
View File
@@ -13,6 +13,12 @@ PORT=3000
CORS_ORIGINS=http://localhost:4174
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
# Controls: backend Fastify logging, frontend nginx access logs (Docker),
# and frontend browser console (via build-time injection)
@@ -149,6 +155,6 @@ EXPIRY_WARNING_DAYS=30 # Days before expiry to show yellow warning
# UI defaults
# DEFAULT_LANGUAGE=en # en or de
# DEFAULT_STOCK_CALCULATION_MODE=automatic # 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
# DEFAULT_SHARE_SCHEDULE_TODAY_ONLY=false
+1 -7
View File
@@ -1,17 +1,11 @@
# MedAssist-ng - Copilot Entry Point
## VERY IMPORTANT - Prioritized Constraints
## VERY IMPORTANT
**First: Update Memory and Reports**
- Always keep agent work memory updated in `doku/memory_notes.md` so progress and decisions remain recoverable across context loss.
- If `doku/memory_notes.md` is missing, create it immediately.
- Always keep a user-facing work report updated in `doku/report.md` so completed work is easy to review.
- If `doku/report.md` is missing, create it immediately.
- This memory/report rule replaces the previous `doku/APP_BEHAVIOR.md` persistence requirement.
**Second: Follow Governance Rules**
- Consult `AGENTS.md` for all governance, workflow, and skill rules.
Use `AGENTS.md` as the single source of truth for all governance, workflow, and skill rules.
## Required Startup Steps
+1 -15
View File
@@ -107,18 +107,4 @@ docs/SPEC_KIT.md
.github/skills/nodejs-backend-patterns/
.github/skills/nodejs-best-practices/
.github/skills/seo/
.playwright-mcp
# Local GSD/copilot generated workspace artifacts (not for upstream)
.github/agents/copilot-instructions.md
.github/agents/gsd-*.agent.md
.github/agents/medassist-feature-orchestrator.agent.md
.github/agents/speckit.*.agent.md
.github/get-shit-done/
.github/gsd-file-manifest.json
.github/prompts/speckit.*.prompt.md
.github/skills/gsd-*/
.planning/
doku/memory_notes.md
doku/report.md
ops/medtest/
.playwright-mcp
-8
View File
@@ -378,14 +378,6 @@ docker compose -p medassist-dev -f docker-compose.dev.yml up
- API docs UI: `http://localhost:3000/docs` (when docs are enabled)
- OpenAPI JSON: `http://localhost:3000/docs/json` (when docs are enabled)
If you run the frontend dev server behind a reverse proxy or on a remote host, you can optionally set these frontend-only environment variables before starting Vite:
- `VITE_ALLOWED_HOSTS`: comma-separated hostnames allowed to connect to the dev server; defaults to `localhost,127.0.0.1`
- `VITE_HMR_HOST`: public hostname used for HMR websocket connections
- `VITE_HMR_PROTOCOL`: optional websocket protocol override (`ws` or `wss`)
- `VITE_HMR_CLIENT_PORT`: optional public websocket port exposed to the browser
- `VITE_HMR_PORT`: optional server-side websocket port for the Vite process
Useful local commands:
```bash
@@ -0,0 +1,30 @@
CREATE TABLE `notification_action_groups` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`user_id` integer NOT NULL,
`group_key` text(255) NOT NULL,
`sequence_id` text(255) NOT NULL,
`dose_ids_json` text NOT NULL,
`title` text(255) NOT NULL,
`message` text NOT NULL,
`language` text(10) DEFAULT 'en' NOT NULL,
`scheduled_for` integer,
`expires_at` integer NOT NULL,
`resolved_action` text(20),
`resolved_at` integer,
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
`updated_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `notification_action_groups_group_key_unique` ON `notification_action_groups` (`group_key`);--> statement-breakpoint
CREATE TABLE `notification_action_tokens` (
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
`group_id` integer NOT NULL,
`token_hash` text(128) NOT NULL,
`kind` text(20) NOT NULL,
`used_at` integer,
`created_at` integer DEFAULT CURRENT_TIMESTAMP NOT NULL,
FOREIGN KEY (`group_id`) REFERENCES `notification_action_groups`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
CREATE UNIQUE INDEX `notification_action_tokens_token_hash_unique` ON `notification_action_tokens` (`token_hash`);
@@ -0,0 +1 @@
ALTER TABLE `notification_action_groups` ADD `ntfy_original_message_id` text(255) DEFAULT '' NOT NULL;
+29
View File
@@ -59,6 +59,7 @@ 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_channel 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_medication_overview integer NOT NULL DEFAULT 0`,
`ALTER TABLE user_settings ADD COLUMN upcoming_today_only integer NOT NULL DEFAULT 0`,
@@ -75,6 +76,7 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_channel text`,
`ALTER TABLE user_settings ADD COLUMN last_prescription_reminder_med_names text`,
`ALTER TABLE refill_history ADD COLUMN used_prescription integer NOT NULL DEFAULT 0`,
`ALTER TABLE notification_action_groups ADD COLUMN ntfy_original_message_id text NOT NULL DEFAULT ''`,
];
for (const sql of alterMigrations) {
@@ -96,6 +98,31 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
loose_pills_added INTEGER NOT NULL DEFAULT 0,
refill_date INTEGER NOT NULL DEFAULT (strftime('%s','now'))
)`,
`CREATE TABLE IF NOT EXISTS 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 (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
@@ -124,6 +151,8 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo
const createIndexMigrations = [
`CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`,
`CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`,
`CREATE UNIQUE INDEX IF NOT EXISTS 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)`,
];
+42 -4
View File
@@ -108,8 +108,9 @@ export const userSettings = sqliteTable("user_settings", {
timezone: text("timezone", { length: 64 }).notNull().default(""),
// Stock calculation mode: "automatic" (schedule-based) or "manual" (only marked doses)
stockCalculationMode: text("stock_calculation_mode", { length: 20 }).notNull().default("automatic"),
// Whether shared schedule links show stock status (Critical/Low/Normal) to intake users
shareStockStatus: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
// Legacy column kept only so existing SQLite files continue to open cleanly after upgrades.
// Current MedAssist versions no longer read or expose this setting in product flows.
legacyShareStockStatusCompat: integer("share_stock_status", { mode: "boolean" }).notNull().default(true),
// Whether shared schedule links also embed the medication overview section
shareMedicationOverview: integer("share_medication_overview", { mode: "boolean" }).notNull().default(false),
// UI timeline visibility preferences
@@ -183,6 +184,43 @@ export const shareTokens = sqliteTable("share_tokens", {
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
// =============================================================================
@@ -194,8 +232,8 @@ export const doseTracking = sqliteTable("dose_tracking", {
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'))`),
markedBy: text("marked_by", { length: 100 }), // null = user, "Daniel" = via share link
takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual or automatic
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // true = missed dose acknowledged without taking
takenSource: text("taken_source", { length: 20 }).notNull().default("manual"), // manual, automatic, or notification
dismissed: integer("dismissed", { mode: "boolean" }).notNull().default(false), // legacy column: true = intake skipped without stock deduction
});
// =============================================================================
+17 -16
View File
@@ -10,10 +10,11 @@ const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
PORT: z
.string()
.transform((v) => parseInt(v, 10))
.default(3000),
.default("3000")
.transform((v) => parseInt(v, 10)),
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
LOG_LEVEL: z.string().default("info"),
PUBLIC_APP_URL: z.string().url().optional(),
OPENAPI_DOCS_ENABLED: z
.string()
.transform((v) => v === "true")
@@ -25,18 +26,18 @@ const EnvSchema = z.object({
// Master switch: Enable/disable authentication (default: disabled for easy setup)
AUTH_ENABLED: z
.string()
.transform((v) => v === "true")
.default(false),
.default("false")
.transform((v) => v === "true"),
// Allow new user registrations (auto-enabled if no users exist)
REGISTRATION_ENABLED: z
.string()
.transform((v) => v === "true")
.default(false),
.default("false")
.transform((v) => v === "true"),
// Disable username/password form login (useful for OIDC-only setups)
FORM_LOGIN_ENABLED: z
.string()
.transform((v) => v === "true")
.default(true),
.default("true")
.transform((v) => v === "true"),
// JWT Secrets - only required when AUTH_ENABLED=true
JWT_SECRET: z.string().min(10).optional(),
@@ -46,20 +47,20 @@ const EnvSchema = z.object({
// Token TTL settings
ACCESS_TOKEN_TTL_MINUTES: z
.string()
.transform((v) => parseInt(v, 10))
.default(15),
.default("15")
.transform((v) => parseInt(v, 10)),
REFRESH_TOKEN_TTL_DAYS: z
.string()
.transform((v) => parseInt(v, 10))
.default(7),
.default("7")
.transform((v) => parseInt(v, 10)),
// ==========================================================================
// OIDC SSO Configuration (Pocket ID, Authelia, etc.)
// ==========================================================================
OIDC_ENABLED: z
.string()
.transform((v) => v === "true")
.default(false),
.default("false")
.transform((v) => v === "true"),
OIDC_ISSUER_URL: z.string().url().optional(), // e.g., https://auth.example.com
OIDC_CLIENT_ID: z.string().optional(),
OIDC_CLIENT_SECRET: z.string().optional(),
@@ -67,8 +68,8 @@ const EnvSchema = z.object({
OIDC_SCOPES: z.string().default("openid profile email"),
OIDC_AUTO_CREATE_USERS: z
.string()
.transform((v) => v === "true")
.default(true),
.default("true")
.transform((v) => v === "true"),
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"), // or 'email', 'sub'
OIDC_PROVIDER_NAME: z.string().default("SSO"), // Display name for UI button
});
+17 -7
View File
@@ -2,9 +2,10 @@ import { and, desc, eq } from "drizzle-orm";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { z } from "zod";
import { db } from "../db/client.js";
import { medications, refillHistory } from "../db/schema.js";
import { doseTracking, medications, refillHistory, userSettings } from "../db/schema.js";
import { getAnonymousUserId, requireAuth } from "../plugins/auth.js";
import { env } from "../plugins/env.js";
import { computeMedicationCurrentStock } from "../services/current-stock.js";
import type { AuthUser } from "../types/fastify.js";
import {
applyOpenApiRouteStandards,
@@ -195,13 +196,22 @@ export async function refillRoutes(app: FastifyInstance) {
}
const refillBaselineAt = new Date();
const baselineStockBeforeRefill = isAmountBased
? med.looseTablets + (med.stockAdjustment ?? 0)
: med.packCount * pillsPerPack + med.looseTablets + (med.stockAdjustment ?? 0);
const targetCurrentStock = baselineStockBeforeRefill + totalPillsAdded;
const [settings] = await db
.select({ stockCalculationMode: userSettings.stockCalculationMode })
.from(userSettings)
.where(eq(userSettings.userId, userId));
const stockCalculationMode = settings?.stockCalculationMode === "manual" ? "manual" : "automatic";
const doses = await db.select().from(doseTracking).where(eq(doseTracking.userId, userId));
const currentStockAtRefill = computeMedicationCurrentStock({
medication: med,
doses,
stockCalculationMode,
nowMs: refillBaselineAt.getTime(),
});
const targetCurrentStock = currentStockAtRefill + totalPillsAdded;
// Update medication stock. Refill establishes a new persisted stock baseline and resets
// `lastStockCorrectionAt` so pre-refill dose history is ignored for future stock math.
// Update medication stock. Refill establishes a new stock baseline at the current visible
// stock level so previously consumed doses are not "resurrected" when lastStockCorrectionAt resets.
let newPackCount = med.packCount + effectivePacksAdded;
let newLooseTablets = med.looseTablets + effectiveLoosePillsAdded;
let newStockAdjustment = med.stockAdjustment ?? 0;
+22 -2
View File
@@ -65,6 +65,7 @@ const reportDataResponseSchema = {
properties: {
packsAdded: { type: "integer" },
loosePillsAdded: { type: "integer" },
quantityAdded: { type: "integer" },
usedPrescription: { type: "boolean" },
refillDate: { type: "string", format: "date-time" },
},
@@ -115,7 +116,16 @@ export async function reportRoutes(app: FastifyInstance) {
: null;
// Verify all medications belong to this user
const userMeds = await db.select({ id: medications.id }).from(medications).where(eq(medications.userId, userId));
const userMeds = await db
.select({
id: medications.id,
packageType: medications.packageType,
blistersPerPack: medications.blistersPerPack,
pillsPerBlister: medications.pillsPerBlister,
})
.from(medications)
.where(eq(medications.userId, userId));
const medMap = new Map(userMeds.map((med) => [med.id, med]));
const userMedIds = new Set(userMeds.map((m) => m.id));
for (const id of medicationIds) {
@@ -159,7 +169,13 @@ export async function reportRoutes(app: FastifyInstance) {
dosesSkipped: number;
firstDoseAt: string | null;
lastDoseAt: string | null;
refills: { packsAdded: number; loosePillsAdded: number; usedPrescription: boolean; refillDate: string }[];
refills: {
packsAdded: number;
loosePillsAdded: number;
quantityAdded: number;
usedPrescription: boolean;
refillDate: string;
}[];
}
> = {};
@@ -170,6 +186,9 @@ export async function reportRoutes(app: FastifyInstance) {
const skippedDoses = doses.filter((d) => d.dismissed);
const sortedTaken = takenDoses.map((d) => d.takenAt.getTime()).sort((a, b) => a - b);
const medication = medMap.get(medId);
const pillsPerPack = Math.max(1, (medication?.blistersPerPack ?? 1) * (medication?.pillsPerBlister ?? 1));
const isAmountBased = medication?.packageType === "liquid_container" || medication?.packageType === "tube";
// Get refills for this medication scoped to the authenticated user.
const refills = await db
@@ -186,6 +205,7 @@ export async function reportRoutes(app: FastifyInstance) {
refills: refills.map((r) => ({
packsAdded: r.packsAdded,
loosePillsAdded: r.loosePillsAdded,
quantityAdded: isAmountBased ? r.loosePillsAdded : r.packsAdded * pillsPerPack + r.loosePillsAdded,
usedPrescription: r.usedPrescription ?? false,
refillDate: r.refillDate instanceof Date ? r.refillDate.toISOString() : String(r.refillDate),
})),
@@ -0,0 +1,175 @@
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 };
}
+398 -4
View File
@@ -345,6 +345,7 @@ describe("E2E Tests with Real Routes", () => {
expect(data[medId].refills[0]).toMatchObject({
packsAdded: 2,
loosePillsAdded: 5,
quantityAdded: 7,
usedPrescription: true,
});
});
@@ -376,6 +377,7 @@ describe("E2E Tests with Real Routes", () => {
expect(data[medId].refills[0]).toMatchObject({
packsAdded: 1,
loosePillsAdded: 0,
quantityAdded: 1,
usedPrescription: false,
});
});
@@ -2443,6 +2445,81 @@ describe("E2E Tests with Real Routes", () => {
expect(med.stockAdjustment).toBe(0);
});
it("should align liquid amount-base fields for stale stock-adjustment clients before refill", async () => {
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
args: [userId],
});
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
name: "Liquid Stale Client Stock Correction",
medicationForm: "liquid",
packageType: "liquid_container",
doseUnit: "ml",
packCount: 7,
packageAmountValue: 150,
packageAmountUnit: "ml",
blistersPerPack: 1,
pillsPerBlister: 1,
totalPills: 1050,
looseTablets: 1050,
blisters: [{ usage: 5, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
const correctionResponse = await app.inject({
method: "PATCH",
url: `/medications/${medId}/stock-adjustment`,
payload: {
stockAdjustment: 0,
packCount: 1,
totalPills: 150,
},
});
expect(correctionResponse.statusCode).toBe(200);
const afterCorrectionResponse = await app.inject({ method: "GET", url: "/medications" });
expect(afterCorrectionResponse.statusCode).toBe(200);
const correctedMed = afterCorrectionResponse.json().find((item: Record<string, unknown>) => item.id === medId);
expect(correctedMed).toBeTruthy();
expect(correctedMed.packCount).toBe(1);
expect(correctedMed.totalPills).toBe(150);
expect(correctedMed.looseTablets).toBe(150);
expect(correctedMed.stockAdjustment).toBe(0);
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 0 },
});
expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json();
expect(refillData.refill.quantityAdded).toBe(150);
expect(refillData.newStock.packCount).toBe(2);
expect(refillData.newStock.looseTablets).toBe(300);
expect(refillData.newStock.totalPills).toBe(300);
const historyResponse = await app.inject({
method: "GET",
url: `/medications/${medId}/refills`,
});
expect(historyResponse.statusCode).toBe(200);
expect(historyResponse.json()[0].quantityAdded).toBe(150);
const afterRefillResponse = await app.inject({ method: "GET", url: "/medications" });
expect(afterRefillResponse.statusCode).toBe(200);
const refilledMed = afterRefillResponse.json().find((item: Record<string, unknown>) => item.id === medId);
expect(refilledMed).toBeTruthy();
expect(refilledMed.packCount).toBe(2);
expect(refilledMed.totalPills).toBe(300);
expect(refilledMed.looseTablets).toBe(300);
});
it("should persist stockAdjustment in GET /medications", async () => {
const createResponse = await app.inject({
method: "POST",
@@ -3048,6 +3125,47 @@ describe("E2E Tests with Real Routes", () => {
blisters: [{ usage: 2, every: 1, start: "2025-01-01T08:00:00.000Z" }],
};
async function expectRefillInvariants({
medId,
refillData,
visibleStockBeforeRefill,
expectedQuantityAdded,
expectedPacksAdded,
expectedAmountPerPackage,
}: {
medId: number;
refillData: {
refill: { packsAdded: number; quantityAdded: number; totalPillsAdded: number };
newStock: { packCount: number; totalPills: number; looseTablets: number };
};
visibleStockBeforeRefill: number;
expectedQuantityAdded: number;
expectedPacksAdded: number;
expectedAmountPerPackage?: number;
}) {
expect(refillData.refill.packsAdded).toBe(expectedPacksAdded);
expect(refillData.refill.quantityAdded).toBe(expectedQuantityAdded);
expect(refillData.refill.totalPillsAdded).toBe(expectedQuantityAdded);
expect(refillData.newStock.totalPills - visibleStockBeforeRefill).toBe(expectedQuantityAdded);
const historyResponse = await app.inject({
method: "GET",
url: `/medications/${medId}/refills`,
});
expect(historyResponse.statusCode).toBe(200);
expect(historyResponse.json()[0]).toMatchObject({
packsAdded: expectedPacksAdded,
quantityAdded: expectedQuantityAdded,
totalPillsAdded: expectedQuantityAdded,
});
if (expectedAmountPerPackage) {
expect(refillData.newStock.packCount).toBe(
Math.max(1, Math.ceil(refillData.newStock.totalPills / expectedAmountPerPackage))
);
}
}
it("should create and return bottle type medication", async () => {
const response = await app.inject({
method: "POST",
@@ -3241,6 +3359,196 @@ describe("E2E Tests with Real Routes", () => {
});
});
it.each([
{
name: "bottle",
payload: {
...bottleMedication,
totalPills: 100,
looseTablets: 10,
},
refillPayload: { packsAdded: 0, loosePillsAdded: 100 },
expectedVisibleStockBeforeRefill: 4,
expectedQuantityAdded: 100,
expectedResponsePacksAdded: 0,
expectedPackCount: 0,
expectedLooseTablets: 104,
expectedTotalPills: 104,
expectedPersistedTotalPills: 100,
expectedStockAdjustment: 0,
},
{
name: "blister",
payload: {
...blisterMedication,
packCount: 1,
blistersPerPack: 1,
pillsPerBlister: 10,
looseTablets: 0,
},
refillPayload: { packsAdded: 1, loosePillsAdded: 0 },
expectedVisibleStockBeforeRefill: 4,
expectedQuantityAdded: 10,
expectedResponsePacksAdded: 1,
expectedPackCount: 2,
expectedLooseTablets: 0,
expectedTotalPills: 14,
expectedPersistedTotalPills: null,
expectedStockAdjustment: -6,
},
{
name: "liquid_container",
payload: {
...liquidContainerMedication,
packCount: 1,
packageAmountValue: 100,
packageAmountUnit: "ml",
totalPills: 10,
looseTablets: 10,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
refillPayload: { packsAdded: 1, loosePillsAdded: 0 },
expectedVisibleStockBeforeRefill: 4,
expectedQuantityAdded: 100,
expectedResponsePacksAdded: 1,
expectedAmountPerPackage: 100,
expectedPackCount: 2,
expectedLooseTablets: 104,
expectedTotalPills: 104,
expectedPersistedTotalPills: 104,
expectedStockAdjustment: 0,
},
])("should refill from current visible stock after prior consumption for $name", async ({
payload,
refillPayload,
expectedVisibleStockBeforeRefill,
expectedQuantityAdded,
expectedResponsePacksAdded,
expectedAmountPerPackage,
expectedPackCount,
expectedLooseTablets,
expectedTotalPills,
expectedPersistedTotalPills,
expectedStockAdjustment,
}) => {
await testClient.execute({
sql: `INSERT OR REPLACE INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
args: [userId],
});
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload,
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
for (let day = 1; day <= 6; day += 1) {
const doseDateOnlyMs = new Date(`2025-01-0${day}T00:00:00.000Z`).getTime();
const takenAtMs = new Date(`2025-01-0${day}T10:00:00.000Z`).getTime();
await testClient.execute({
sql: `INSERT INTO dose_tracking (user_id, dose_id, taken_at, dismissed)
VALUES (?, ?, ?, 0)`,
args: [userId, `${medId}-0-${doseDateOnlyMs}`, takenAtMs],
});
}
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: refillPayload,
});
expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json();
await expectRefillInvariants({
medId,
refillData,
visibleStockBeforeRefill: expectedVisibleStockBeforeRefill,
expectedQuantityAdded,
expectedPacksAdded: expectedResponsePacksAdded,
expectedAmountPerPackage,
});
expect(refillData.newStock.packCount).toBe(expectedPackCount);
expect(refillData.newStock.looseTablets).toBe(expectedLooseTablets);
expect(refillData.newStock.totalPills).toBe(expectedTotalPills);
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
expect(medsResponse.statusCode).toBe(200);
const med = medsResponse.json().find((item: Record<string, unknown>) => item.id === medId);
expect(med).toBeTruthy();
expect(med.packCount).toBe(expectedPackCount);
expect(med.looseTablets).toBe(expectedLooseTablets);
expect(med.totalPills).toBe(expectedPersistedTotalPills);
expect(med.stockAdjustment).toBe(expectedStockAdjustment);
});
it("should refill tube stock from the corrected visible baseline", async () => {
await testClient.execute({
sql: `INSERT OR REPLACE INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
args: [userId],
});
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
...tubeMedication,
packCount: 1,
packageAmountValue: 80,
packageAmountUnit: "g",
totalPills: 10,
looseTablets: 10,
blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }],
},
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
const correctionResponse = await app.inject({
method: "PATCH",
url: `/medications/${medId}/stock-adjustment`,
payload: {
stockAdjustment: -6,
looseTablets: 10,
totalPills: 10,
packageAmountValue: 80,
packCount: 1,
},
});
expect(correctionResponse.statusCode).toBe(200);
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 1, loosePillsAdded: 0 },
});
expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json();
await expectRefillInvariants({
medId,
refillData,
visibleStockBeforeRefill: 4,
expectedQuantityAdded: 80,
expectedPacksAdded: 1,
expectedAmountPerPackage: 80,
});
expect(refillData.newStock.packCount).toBe(2);
expect(refillData.newStock.looseTablets).toBe(84);
expect(refillData.newStock.totalPills).toBe(84);
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
expect(medsResponse.statusCode).toBe(200);
const med = medsResponse.json().find((item: Record<string, unknown>) => item.id === medId);
expect(med).toBeTruthy();
expect(med.packCount).toBe(2);
expect(med.looseTablets).toBe(84);
expect(med.totalPills).toBe(84);
expect(med.stockAdjustment).toBe(0);
});
it("should calculate correct refill totalPillsAdded for blister type", async () => {
const createResponse = await app.inject({
method: "POST",
@@ -3272,6 +3580,11 @@ describe("E2E Tests with Real Routes", () => {
});
it("should keep liquid_container refill additive and preserve amount baseline", async () => {
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
args: [userId],
});
const createResponse = await app.inject({
method: "POST",
url: "/medications",
@@ -3294,9 +3607,15 @@ describe("E2E Tests with Real Routes", () => {
expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json();
expect(refillData.refill.packsAdded).toBe(1);
await expectRefillInvariants({
medId,
refillData,
visibleStockBeforeRefill: 180,
expectedQuantityAdded: 180,
expectedPacksAdded: 1,
expectedAmountPerPackage: 180,
});
expect(refillData.refill.loosePillsAdded).toBe(180);
expect(refillData.refill.totalPillsAdded).toBe(180);
expect(refillData.newStock.totalPills).toBe(360);
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
@@ -3307,6 +3626,54 @@ describe("E2E Tests with Real Routes", () => {
expect(med.looseTablets).toBe(360);
});
it("should normalize liquid_container packCount to the full visible stock after refill", async () => {
await testClient.execute({
sql: `INSERT INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
args: [userId],
});
const createResponse = await app.inject({
method: "POST",
url: "/medications",
payload: {
...liquidContainerMedication,
packCount: 0,
packageAmountValue: 150,
totalPills: 300,
looseTablets: 300,
},
});
expect(createResponse.statusCode).toBe(200);
const medId = createResponse.json().id;
const refillResponse = await app.inject({
method: "POST",
url: `/medications/${medId}/refill`,
payload: { packsAdded: 5, loosePillsAdded: 0 },
});
expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json();
await expectRefillInvariants({
medId,
refillData,
visibleStockBeforeRefill: 300,
expectedQuantityAdded: 750,
expectedPacksAdded: 5,
expectedAmountPerPackage: 150,
});
expect(refillData.newStock.packCount).toBe(7);
expect(refillData.newStock.totalPills).toBe(1050);
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
expect(medsResponse.statusCode).toBe(200);
const med = medsResponse.json().find((m: Record<string, unknown>) => m.id === medId);
expect(med).toBeTruthy();
expect(med.packCount).toBe(7);
expect(med.totalPills).toBe(1050);
expect(med.looseTablets).toBe(1050);
});
it.each([
{
name: "liquid_container",
@@ -3323,10 +3690,12 @@ describe("E2E Tests with Real Routes", () => {
prescriptionLowRefillThreshold: 1,
},
refillPayload: { packsAdded: 0, loosePillsAdded: 180, usePrescription: true },
expectedVisibleStockBeforeRefill: 180,
expectedPacksAdded: 1,
expectedLooseAdded: 180,
expectedRemainingRefills: 1,
expectedTotalPills: 360,
expectedAmountPerPackage: 180,
},
{
name: "tube",
@@ -3338,19 +3707,28 @@ describe("E2E Tests with Real Routes", () => {
prescriptionLowRefillThreshold: 1,
},
refillPayload: { packsAdded: 0, loosePillsAdded: 80, usePrescription: true },
expectedVisibleStockBeforeRefill: 80,
expectedPacksAdded: 2,
expectedLooseAdded: 80,
expectedRemainingRefills: 1,
expectedTotalPills: 160,
expectedAmountPerPackage: 40,
},
])("should derive amount-based refill counts and decrement prescription remaining refills for $name", async ({
payload,
refillPayload,
expectedVisibleStockBeforeRefill,
expectedPacksAdded,
expectedLooseAdded,
expectedRemainingRefills,
expectedTotalPills,
expectedAmountPerPackage,
}) => {
await testClient.execute({
sql: `INSERT OR REPLACE INTO user_settings (user_id, stock_calculation_mode) VALUES (?, 'manual')`,
args: [userId],
});
const createResponse = await app.inject({
method: "POST",
url: "/medications",
@@ -3367,8 +3745,17 @@ describe("E2E Tests with Real Routes", () => {
expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json();
await expectRefillInvariants({
medId,
refillData,
visibleStockBeforeRefill: expectedVisibleStockBeforeRefill,
expectedQuantityAdded: expectedLooseAdded,
expectedPacksAdded,
expectedAmountPerPackage,
});
expect(refillData.refill.packsAdded).toBe(expectedPacksAdded);
expect(refillData.refill.loosePillsAdded).toBe(expectedLooseAdded);
expect(refillData.refill.quantityAdded).toBe(expectedLooseAdded);
expect(refillData.refill.totalPillsAdded).toBe(expectedLooseAdded);
expect(refillData.prescription.used).toBe(true);
expect(refillData.prescription.remainingRefills).toBe(expectedRemainingRefills);
@@ -3382,6 +3769,7 @@ describe("E2E Tests with Real Routes", () => {
expect(historyResponse.json()[0]).toMatchObject({
packsAdded: expectedPacksAdded,
loosePillsAdded: expectedLooseAdded,
quantityAdded: expectedLooseAdded,
usedPrescription: true,
});
});
@@ -3403,9 +3791,15 @@ describe("E2E Tests with Real Routes", () => {
expect(refillResponse.statusCode).toBe(200);
const refillData = refillResponse.json();
expect(refillData.refill.packsAdded).toBe(1);
await expectRefillInvariants({
medId,
refillData,
visibleStockBeforeRefill: 80,
expectedQuantityAdded: 40,
expectedPacksAdded: 1,
expectedAmountPerPackage: 40,
});
expect(refillData.refill.loosePillsAdded).toBe(40);
expect(refillData.refill.totalPillsAdded).toBe(40);
expect(refillData.newStock.totalPills).toBe(120);
const medsResponse = await app.inject({ method: "GET", url: "/medications" });
+25 -14
View File
@@ -10,33 +10,34 @@ const EnvSchema = z.object({
NODE_ENV: z.enum(["development", "production", "test"]).default("production"),
PORT: z
.string()
.transform((v) => parseInt(v, 10))
.default(3000),
.default("3000")
.transform((v) => parseInt(v, 10)),
CORS_ORIGINS: z.string().default("http://localhost:5173,http://localhost:4173"),
LOG_LEVEL: z.string().default("info"),
PUBLIC_APP_URL: z.string().url().optional(),
AUTH_ENABLED: z
.string()
.transform((v) => v === "true")
.default(false),
.default("false")
.transform((v) => v === "true"),
REGISTRATION_ENABLED: z
.string()
.transform((v) => v === "true")
.default(false),
.default("false")
.transform((v) => v === "true"),
JWT_SECRET: z.string().min(10).optional(),
REFRESH_SECRET: z.string().min(10).optional(),
COOKIE_SECRET: z.string().min(10).optional(),
ACCESS_TOKEN_TTL_MINUTES: z
.string()
.transform((v) => parseInt(v, 10))
.default(15),
.default("15")
.transform((v) => parseInt(v, 10)),
REFRESH_TOKEN_TTL_DAYS: z
.string()
.transform((v) => parseInt(v, 10))
.default(7),
.default("7")
.transform((v) => parseInt(v, 10)),
OIDC_ENABLED: z
.string()
.transform((v) => v === "true")
.default(false),
.default("false")
.transform((v) => v === "true"),
OIDC_ISSUER_URL: z.string().url().optional(),
OIDC_CLIENT_ID: z.string().optional(),
OIDC_CLIENT_SECRET: z.string().optional(),
@@ -44,8 +45,8 @@ const EnvSchema = z.object({
OIDC_SCOPES: z.string().default("openid profile email"),
OIDC_AUTO_CREATE_USERS: z
.string()
.transform((v) => v === "true")
.default(true),
.default("true")
.transform((v) => v === "true"),
OIDC_USERNAME_CLAIM: z.string().default("preferred_username"),
OIDC_PROVIDER_NAME: z.string().default("SSO"),
});
@@ -81,6 +82,7 @@ describe("EnvSchema", () => {
expect(result.PORT).toBe(3000);
expect(result.CORS_ORIGINS).toBe("http://localhost:5173,http://localhost:4173");
expect(result.LOG_LEVEL).toBe("info");
expect(result.PUBLIC_APP_URL).toBeUndefined();
expect(result.AUTH_ENABLED).toBe(false);
expect(result.REGISTRATION_ENABLED).toBe(false);
expect(result.ACCESS_TOKEN_TTL_MINUTES).toBe(15);
@@ -188,6 +190,15 @@ describe("EnvSchema", () => {
});
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", () => {
const result = EnvSchema.parse({ OIDC_ISSUER_URL: "https://auth.example.com" });
expect(result.OIDC_ISSUER_URL).toBe("https://auth.example.com");
@@ -0,0 +1,186 @@
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"
);
});
});
+2 -1
View File
@@ -6,6 +6,7 @@ Scope and behavior:
- These values are applied only when a user's settings are created for the first time.
- After that, values stored in the database are used and take precedence.
- Source of truth in code: [backend/src/routes/settings.ts](backend/src/routes/settings.ts).
## Email Defaults
@@ -46,6 +47,6 @@ Scope and behavior:
|----------|---------|-------------|
| `DEFAULT_LANGUAGE` | `en` | Default language (`en` or `de`). |
| `DEFAULT_STOCK_CALCULATION_MODE` | `automatic` | Default stock mode (`automatic` or `manual`). |
| `DEFAULT_SHARE_MEDICATION_OVERVIEW` | `false` | Show medication overview section on shared schedule links. |
| `DEFAULT_SHARE_STOCK_STATUS` | `true` | Show stock status on shared schedule links. |
| `DEFAULT_UPCOMING_TODAY_ONLY` | `false` | Show only today's upcoming doses by default. |
| `DEFAULT_SHARE_SCHEDULE_TODAY_ONLY` | `false` | Show only today's schedule in shared view by default. |
+1 -1
View File
@@ -506,7 +506,7 @@ function AppContent() {
<AboutModal isOpen={showAbout} onClose={closeAbout} />
<Routes>
<Route path="/" element={<Navigate to={{ pathname: "/dashboard", search: location.search }} replace />} />
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<DashboardPage />} />
<Route path="/medications" element={<MedicationsPage />} />
+6 -2
View File
@@ -235,6 +235,10 @@ export function SharedSchedule() {
}
async function markDoseTaken(doseId: string) {
if (dismissedDoses.has(doseId)) {
return;
}
const wasTaken = takenDoses.has(doseId);
const wasSkipped = dismissedDoses.has(doseId);
const wasAutomatic = automaticTakenDoses.has(doseId);
@@ -462,7 +466,7 @@ export function SharedSchedule() {
<button
className={`dose-btn take${options.isEmpty ? " out-of-stock" : ""}`}
onClick={() => markDoseTaken(options.doseId)}
disabled={options.isEmpty}
disabled={options.isEmpty || options.isSkipped}
title={options.isEmpty ? t("common.outOfStockTakeBlocked") : t("dose.markAsTaken")}
>
<span className="dose-btn-label">{t("dose.take")}</span>
@@ -472,7 +476,7 @@ export function SharedSchedule() {
const skipButton = options.isSkipped ? (
<button className="dose-btn undo skip" onClick={() => undoDoseSkipped(options.doseId)} title={t("common.undo")}>
<span className="dose-btn-label">{t("dose.undoSkip")}</span>
<span className="dose-btn-label">{t("common.undo")}</span>
<span aria-hidden="true"></span>
</button>
) : (
+23 -52
View File
@@ -257,10 +257,8 @@ export function MedicationsPage() {
useUnsavedChangesWarning(formChanged);
// View mode: grid (default) or form (edit/new)
// If navigating in with a medication deep-link, suppress rendering until the target form is ready
const [pendingEditTransition, setPendingEditTransition] = useState(
() => searchParams.has("editMedId") || searchParams.has("viewMedId")
);
// If navigating in with editMedId, suppress rendering until the edit form is ready
const [pendingEditTransition, setPendingEditTransition] = useState(() => searchParams.has("editMedId"));
const [viewMode, setViewMode] = useState<"grid" | "form">(pendingEditTransition ? "form" : "grid");
const [lightboxImage, setLightboxImage] = useState<{ src: string; alt: string } | null>(null);
const [activeTab, setActiveTab] = useState<"general" | "stock" | "prescription" | "schedule">("general");
@@ -271,23 +269,9 @@ export function MedicationsPage() {
useEffect(() => {
showEditModalRef.current = showEditModal;
}, [showEditModal]);
const processedMedicationLinkRef = useRef<string | null>(null);
const processedEditMedIdRef = useRef<string | null>(null);
const hasDesktopFormHistoryState = useRef(false);
const getMedicationLinkState = useCallback((params: URLSearchParams) => {
const viewMedId = params.get("viewMedId");
if (viewMedId) {
return { mode: "view" as const, linkedMedId: viewMedId };
}
const editMedId = params.get("editMedId");
if (editMedId) {
return { mode: "edit" as const, linkedMedId: editMedId };
}
return { mode: null, linkedMedId: null };
}, []);
// Sync formChanged state to the global context for navigation blocking
const { setHasUnsavedChanges } = useUnsavedChanges();
useEffect(() => {
@@ -835,13 +819,12 @@ export function MedicationsPage() {
[t]
);
const clearMedicationLinkParams = useCallback(() => {
const clearEditMedIdParam = useCallback(() => {
setSearchParams(
(prevParams) => {
if (!prevParams.has("editMedId") && !prevParams.has("viewMedId")) return prevParams;
if (!prevParams.has("editMedId")) return prevParams;
const nextParams = new URLSearchParams(prevParams);
nextParams.delete("editMedId");
nextParams.delete("viewMedId");
return nextParams;
},
{ replace: true }
@@ -865,7 +848,7 @@ export function MedicationsPage() {
setShowUnsavedConfirm(true);
return;
}
clearMedicationLinkParams();
clearEditMedIdParam();
// Mark as confirmed to avoid double confirmation in popstate handler
closeConfirmedRef.current = true;
window.history.back();
@@ -1176,7 +1159,7 @@ export function MedicationsPage() {
if (shouldCloseMobileModal) {
// Treat post-save close as confirmed so popstate does not trigger unsaved guards.
closeConfirmedRef.current = true;
clearMedicationLinkParams();
clearEditMedIdParam();
setShowEditModal(false);
setReadOnlyView(false);
setActiveTab("general");
@@ -1205,8 +1188,7 @@ export function MedicationsPage() {
// Handle browser back button for modals and unsaved changes
useEffect(() => {
const handlePopState = () => {
const currentParams = new URLSearchParams(window.location.search);
const { mode: currentLinkMode, linkedMedId: currentMedicationLinkId } = getMedicationLinkState(currentParams);
const currentEditMedId = new URLSearchParams(window.location.search).get("editMedId");
// Obsolete confirmation is open — dismiss it and stay where we are
if (showObsoleteConfirm) {
@@ -1225,10 +1207,10 @@ export function MedicationsPage() {
// If close was already confirmed programmatically, allow navigation
if (closeConfirmedRef.current) {
closeConfirmedRef.current = false;
if (currentMedicationLinkId && currentLinkMode) {
if (currentEditMedId) {
// Prevent URL popstate from immediately reopening mobile edit for the same id.
processedMedicationLinkRef.current = `${currentLinkMode}:${currentMedicationLinkId}`;
clearMedicationLinkParams();
processedEditMedIdRef.current = currentEditMedId;
clearEditMedIdParam();
}
if (showEditModal) {
setShowEditModal(false);
@@ -1249,11 +1231,11 @@ export function MedicationsPage() {
setShowUnsavedConfirm(true);
return;
}
if (currentMedicationLinkId && currentLinkMode) {
if (currentEditMedId) {
// Mark as handled before URL cleanup to avoid same-tick re-open races.
processedMedicationLinkRef.current = `${currentLinkMode}:${currentMedicationLinkId}`;
processedEditMedIdRef.current = currentEditMedId;
}
clearMedicationLinkParams();
clearEditMedIdParam();
setShowEditModal(false);
resetForm();
resetMedicationEnrichment();
@@ -1289,16 +1271,7 @@ export function MedicationsPage() {
};
window.addEventListener("popstate", handlePopState);
return () => window.removeEventListener("popstate", handlePopState);
}, [
showObsoleteConfirm,
showDeleteConfirm,
showEditModal,
viewMode,
formChanged,
resetForm,
clearMedicationLinkParams,
getMedicationLinkState,
]);
}, [showObsoleteConfirm, showDeleteConfirm, showEditModal, viewMode, formChanged, resetForm, clearEditMedIdParam]);
// Close modal on Escape key
useEffect(() => {
@@ -1416,23 +1389,22 @@ export function MedicationsPage() {
}, [activeMeds, editingId]);
useEffect(() => {
const { mode: linkMode, linkedMedId } = getMedicationLinkState(searchParams);
if (!linkedMedId || !linkMode) {
processedMedicationLinkRef.current = null;
const editMedId = searchParams.get("editMedId");
if (!editMedId) {
processedEditMedIdRef.current = null;
return;
}
const linkKey = `${linkMode}:${linkedMedId}`;
if (processedMedicationLinkRef.current === linkKey) return;
const parsedMedId = Number.parseInt(linkedMedId, 10);
if (processedEditMedIdRef.current === editMedId) return;
const parsedMedId = Number.parseInt(editMedId, 10);
if (Number.isNaN(parsedMedId)) return;
const medicationToEdit =
meds.find((med) => med.id === parsedMedId) ?? allMeds.find((med) => med.id === parsedMedId);
if (!medicationToEdit) return;
processedMedicationLinkRef.current = linkKey;
processedEditMedIdRef.current = editMedId;
setShowNameValidation(false);
setReadOnlyView(linkMode === "view");
setReadOnlyView(false);
setActiveTab("general");
resetMedicationEnrichment(medicationToEdit.name || medicationToEdit.genericName || "");
startEdit(medicationToEdit, openEditModal);
@@ -1443,9 +1415,8 @@ export function MedicationsPage() {
const nextParams = new URLSearchParams(searchParams);
nextParams.delete("editMedId");
nextParams.delete("viewMedId");
setSearchParams(nextParams, { replace: true });
}, [allMeds, getMedicationLinkState, meds, openEditModal, searchParams, setSearchParams, startEdit]);
}, [allMeds, meds, openEditModal, searchParams, setSearchParams, startEdit]);
const selectedMedication = useMemo(() => {
if (!editingId) return null;
+2 -23
View File
@@ -1,5 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter, useLocation } from "react-router-dom";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import App from "../App";
@@ -59,15 +59,7 @@ vi.mock("../context", async () => {
});
vi.mock("../pages", () => ({
DashboardPage: () => {
const location = useLocation();
return (
<div>
<span>dashboard-page</span>
<span data-testid="dashboard-location-search">{location.search}</span>
</div>
);
},
DashboardPage: () => <div>dashboard-page</div>,
MedicationsPage: () => <div>medications-page</div>,
PlannerPage: () => <div>planner-page</div>,
SchedulePage: () => <div>schedule-page</div>,
@@ -273,19 +265,6 @@ describe("App", () => {
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
});
it("preserves notification query params when redirecting root to dashboard", () => {
const search = "?date=2026-05-06&medId=4332&doseId=4332-0-1778104500000";
render(
<MemoryRouter initialEntries={[`/${search}`]}>
<App />
</MemoryRouter>
);
expect(screen.getByText("dashboard-page")).toBeInTheDocument();
expect(screen.getByTestId("dashboard-location-search")).toHaveTextContent(search);
});
it("renders initializing state when auth state is missing", () => {
authMock = {
user: null,
@@ -175,10 +175,6 @@ describe("LoginForm", () => {
oidcProviderName: "",
};
afterEach(() => {
window.history.replaceState({}, "", "/");
});
beforeEach(() => {
vi.clearAllMocks();
(global.fetch as ReturnType<typeof vi.fn>)
@@ -475,21 +475,6 @@ describe("MedicationsPage with items", () => {
});
});
it("opens read-only view from viewMedId query parameter", async () => {
const startEdit = vi.fn();
mockFormHookValue = createMockFormHook({ startEdit });
fetchMock.mockResolvedValue({ ok: true, json: async () => mockMeds });
renderPage("/medications?viewMedId=1");
await waitFor(() => {
expect(startEdit).toHaveBeenCalledTimes(1);
});
expect(screen.getByText("common.close")).toBeInTheDocument();
expect(screen.queryByText("common.save")).not.toBeInTheDocument();
});
it("opens unsaved confirm and continues edit after confirmation", async () => {
const startEdit = vi.fn();
const resetForm = vi.fn();
-33
View File
@@ -2,24 +2,6 @@ import { existsSync, readFileSync } from "fs";
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
function parseCsvEnv(value: string | undefined, fallback: string[]) {
const entries = value
?.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
return entries && entries.length > 0 ? entries : fallback;
}
function parseOptionalPort(value: string | undefined) {
if (!value) {
return undefined;
}
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
// Read version from package.json at build time
const packageJson = JSON.parse(readFileSync("./package.json", "utf-8"));
@@ -27,19 +9,6 @@ const packageJson = JSON.parse(readFileSync("./package.json", "utf-8"));
// In Docker, prefer backend-dev to avoid localhost proxy failures.
const defaultBackendTarget = existsSync("/.dockerenv") ? "http://backend-dev:3000" : "http://localhost:3000";
const backendTarget = process.env.BACKEND_URL || defaultBackendTarget;
const allowedHosts = parseCsvEnv(process.env.VITE_ALLOWED_HOSTS, ["localhost", "127.0.0.1"]);
const hmrHost = process.env.VITE_HMR_HOST?.trim();
const hmrProtocol = process.env.VITE_HMR_PROTOCOL === "ws" ? "ws" : process.env.VITE_HMR_PROTOCOL === "wss" ? "wss" : undefined;
const hmrClientPort = parseOptionalPort(process.env.VITE_HMR_CLIENT_PORT);
const hmrPort = parseOptionalPort(process.env.VITE_HMR_PORT);
const hmr = hmrHost
? {
host: hmrHost,
protocol: hmrProtocol ?? "wss",
clientPort: hmrClientPort ?? (hmrProtocol === "ws" ? 80 : 443),
port: hmrPort ?? 5173,
}
: undefined;
export default defineConfig({
plugins: [react()],
@@ -50,8 +19,6 @@ export default defineConfig({
server: {
port: 5173,
strictPort: true,
allowedHosts,
hmr,
proxy: {
"/api": {
target: backendTarget,