From c38964cd709e0a5b04c16214cd34f2cf59375848 Mon Sep 17 00:00:00 2001 From: Daniel Volz Date: Sun, 10 May 2026 22:09:06 +0200 Subject: [PATCH] fix: restore backend ntfy and refill CI baseline Closes #605 --- backend/src/db/migration-utils.ts | 42 +++ backend/src/db/schema.ts | 46 ++- backend/src/plugins/env.ts | 33 +- backend/src/routes/refills.ts | 24 +- backend/src/routes/settings.ts | 120 +++--- .../services/notification-actions-service.ts | 350 ++++++++++++++++++ .../services/notifications/action-renderer.ts | 175 +++++++++ .../src/services/notifications/delivery.ts | 10 +- backend/src/test/e2e-routes.test.ts | 22 +- backend/src/test/env.test.ts | 39 +- backend/src/test/routes-real.test.ts | 10 +- 11 files changed, 760 insertions(+), 111 deletions(-) create mode 100644 backend/src/services/notification-actions-service.ts create mode 100644 backend/src/services/notifications/action-renderer.ts diff --git a/backend/src/db/migration-utils.ts b/backend/src/db/migration-utils.ts index 601ed8f..a6fc65e 100644 --- a/backend/src/db/migration-utils.ts +++ b/backend/src/db/migration-utils.ts @@ -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`, @@ -96,6 +97,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, @@ -121,9 +147,25 @@ export async function runAlterMigrations(client: Client): Promise<{ success: boo } } + const postCreateAlterMigrations = [ + `ALTER TABLE notification_action_groups ADD COLUMN ntfy_original_message_id text NOT NULL DEFAULT ''`, + ]; + + for (const sql of postCreateAlterMigrations) { + try { + await client.execute(sql); + } catch (e: unknown) { + if (!(e as Error).message?.includes("duplicate column")) { + errors.push((e as Error).message); + } + } + } + const createIndexMigrations = [ `CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_unique ON users(lower(username))`, `CREATE UNIQUE INDEX IF NOT EXISTS api_keys_key_hash_unique ON api_keys(key_hash)`, + `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)`, ]; diff --git a/backend/src/db/schema.ts b/backend/src/db/schema.ts index 99dba58..b2d3ae5 100644 --- a/backend/src/db/schema.ts +++ b/backend/src/db/schema.ts @@ -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 }); // ============================================================================= diff --git a/backend/src/plugins/env.ts b/backend/src/plugins/env.ts index f723b9d..72c9c1d 100644 --- a/backend/src/plugins/env.ts +++ b/backend/src/plugins/env.ts @@ -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 }); diff --git a/backend/src/routes/refills.ts b/backend/src/routes/refills.ts index 3e30104..85cf679 100644 --- a/backend/src/routes/refills.ts +++ b/backend/src/routes/refills.ts @@ -2,10 +2,9 @@ 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 { doseTracking, medications, refillHistory, userSettings } from "../db/schema.js"; +import { medications, refillHistory } 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, @@ -196,22 +195,13 @@ export async function refillRoutes(app: FastifyInstance) { } const refillBaselineAt = new Date(); - 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; + const baselineStockBeforeRefill = isAmountBased + ? med.looseTablets + (med.stockAdjustment ?? 0) + : med.packCount * pillsPerPack + med.looseTablets + (med.stockAdjustment ?? 0); + const targetCurrentStock = baselineStockBeforeRefill + totalPillsAdded; - // 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. + // Update medication stock. Refill establishes a new persisted stock baseline and resets + // `lastStockCorrectionAt` so pre-refill dose history is ignored for future stock math. let newPackCount = med.packCount + effectivePacksAdded; let newLooseTablets = med.looseTablets + effectiveLoosePillsAdded; let newStockAdjustment = med.stockAdjustment ?? 0; diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index 167b523..806ae6f 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -2,8 +2,17 @@ import { eq } from "drizzle-orm"; import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; import { db } from "../db/client.js"; import { userSettings } from "../db/schema.js"; +import { getDateLocale, getFooterPlain, getTranslations, type Language, t } from "../i18n/translations.js"; import { getAnonymousUserId, requireAuth } from "../plugins/auth.js"; import { env } from "../plugins/env.js"; +import { + createTestNotificationActionContext, + storeNotificationActionGroupNtfyMessageId, +} from "../services/notification-actions-service.js"; +import { + type PushNotificationOptions, + renderNotificationActionPayload, +} from "../services/notifications/action-renderer.js"; import { getSmtpConfig, sendEmailNotification } from "../services/notifications/delivery.js"; import { classifyTestEmailFailure, @@ -70,36 +79,6 @@ const settingsErrorSchema = { }, }; -type MailDeliveryInfo = { - accepted?: unknown; - rejected?: unknown; - response?: unknown; -}; - -function normalizeRecipients(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return value - .map((entry) => (typeof entry === "string" ? entry : String(entry ?? ""))) - .map((entry) => entry.trim()) - .filter(Boolean); -} - -function getDeliveryError(info: MailDeliveryInfo): string | null { - const accepted = normalizeRecipients(info.accepted); - const rejected = normalizeRecipients(info.rejected); - - if (accepted.length > 0) return null; - if (rejected.length > 0) { - return `SMTP rejected all recipients: ${rejected.join(", ")}`; - } - - if (typeof info.response === "string" && info.response.trim()) { - return `SMTP did not confirm accepted recipients. Response: ${info.response}`; - } - - return "SMTP did not confirm accepted recipients."; -} - function envInt(key: string, defaultVal: number): number { const val = process.env[key]; if (val === undefined) return defaultVal; @@ -107,6 +86,24 @@ function envInt(key: string, defaultVal: number): number { return Number.isNaN(parsed) ? defaultVal : parsed; } +function getLanguage(language: string | null | undefined): Language { + return language === "de" ? "de" : "en"; +} + +function buildInteractiveTestPushNotification(language: Language): { title: string; message: string } { + const tr = getTranslations(language); + const reminderAt = new Date(Date.now() + 60 * 1000); + const reminderTime = new Intl.DateTimeFormat(getDateLocale(language), { + hour: "2-digit", + minute: "2-digit", + }).format(reminderAt); + + return { + title: t(tr.push.intakeTitle, { minutes: 1 }), + message: `• MedAssist-ng Test: 1 ${tr.common.pill} (100 mg) @ ${reminderTime}\n\n---\n${getFooterPlain(language)}`, + }; +} + async function getOrCreateUserSettings(userId: number) { let [settings] = await db.select().from(userSettings).where(eq(userSettings.userId, userId)); @@ -552,14 +549,33 @@ export async function settingsRoutes(app: FastifyInstance) { } try { + const userId = await getUserId(request, reply); + const settings = await getOrCreateUserSettings(userId); + const language = getLanguage(settings.language); + const { title, message } = buildInteractiveTestPushNotification(language); + const actionContext = await createTestNotificationActionContext({ + userId, + title, + message, + publicAppUrl: env.PUBLIC_APP_URL, + language, + }); const provider = getNotificationProvider(url); - const result = await sendShoutrrrNotification( - url, - "MedAssist-ng Test", - "This is a test notification from MedAssist-ng. If you received this, your notification configuration is working correctly!" - ); + const result = await sendShoutrrrNotification(url, title, message, { + actions: actionContext?.actions, + respondUrl: actionContext?.respondUrl, + viewUrl: actionContext?.viewUrl, + clickUrl: actionContext?.respondUrl ?? actionContext?.viewUrl, + sequenceId: actionContext?.sequenceId, + tags: ["pill"], + priority: 3, + }); if (result.success) { + if (actionContext?.groupId && result.providerMessageId) { + await storeNotificationActionGroupNtfyMessageId(actionContext.groupId, result.providerMessageId); + } + request.log.info({ provider }, "[Settings] Test push notification sent"); return reply.send({ success: true, message: "Test notification sent successfully" }); } else { @@ -582,8 +598,9 @@ export async function settingsRoutes(app: FastifyInstance) { export async function sendShoutrrrNotification( urlStr: string, title: string, - message: string -): Promise<{ success: boolean; error?: string }> { + message: string, + options: PushNotificationOptions = {} +): Promise<{ success: boolean; error?: string; providerMessageId?: string }> { try { if (urlStr.startsWith("pushover://")) { const pushoverAuthority = urlStr.slice("pushover://".length).split("/")[0] ?? ""; @@ -736,12 +753,13 @@ export async function sendShoutrrrNotification( } // 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; const method = "POST"; let headers: Record = {}; let body: string | undefined; + const renderedPayload = renderNotificationActionPayload(urlStr, message, options); // Remove emojis from title for header compatibility const cleanTitle = title @@ -786,19 +804,27 @@ export async function sendShoutrrrNotification( // characters (umlauts, accents, etc.) through HTTP headers const encodedTitle = `=?UTF-8?B?${Buffer.from(cleanTitle, "utf-8").toString("base64")}?=`; headers = { Title: encodedTitle, Tags: "pill" }; - body = message; + body = renderedPayload.message; // Add auth if present (extracted during sanitization) if (auth) { headers.Authorization = `Basic ${Buffer.from(`${auth.user}:${auth.pass}`).toString("base64")}`; } + + if (isNtfy) { + headers = { ...headers, ...renderedPayload.headers }; + } } else if (sanitizedUrl.startsWith("http://") || sanitizedUrl.startsWith("https://")) { targetUrl = sanitizedUrl; headers = { "Content-Type": "application/json" }; if (isDiscordWebhook) { - body = JSON.stringify({ content: `${title}\n\n${message}` }); + body = JSON.stringify({ content: `${title}\n\n${renderedPayload.message}` }); } else { - body = JSON.stringify({ title, message, text: `${title}\n\n${message}` }); + body = JSON.stringify({ + title, + message: renderedPayload.message, + text: `${title}\n\n${renderedPayload.message}`, + }); } } else { return { @@ -823,7 +849,17 @@ export async function sendShoutrrrNotification( }); 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 { const errorText = await response.text(); return { success: false, error: `HTTP ${response.status}: ${errorText}` }; diff --git a/backend/src/services/notification-actions-service.ts b/backend/src/services/notification-actions-service.ts new file mode 100644 index 0000000..9230aa5 --- /dev/null +++ b/backend/src/services/notification-actions-service.ts @@ -0,0 +1,350 @@ +import { createHash, randomBytes } from "node:crypto"; +import { and, eq, gt, isNull } from "drizzle-orm"; +import { db } from "../db/client.js"; +import { notificationActionGroups, notificationActionTokens } from "../db/schema.js"; +import type { Language } from "../i18n/translations.js"; +import { env } from "../plugins/env.js"; +import { getNotificationActionLabels, type PushNotificationAction } from "./notifications/action-renderer.js"; + +export type NotificationActionKind = "taken" | "skip" | "respond" | "view"; + +type TokenKind = Exclude; +type ActiveTokenKind = "taken" | "skip" | "respond"; + +export type NotificationActionContext = { + groupId?: number; + sequenceId?: string; + respondUrl?: string; + viewUrl: string; + actions: PushNotificationAction[]; +}; + +type NotificationActionMode = "full" | "view-only"; + +export type NotificationActionTokenRecord = { + token: typeof notificationActionTokens.$inferSelect; + group: typeof notificationActionGroups.$inferSelect; + doseIds: string[]; + viewUrl: string | null; +}; + +const NOTIFICATION_ACTION_TTL_MS = 24 * 60 * 60 * 1000; + +function normalizePublicAppUrl(publicAppUrl: string): string { + return publicAppUrl.replace(/\/+$/, ""); +} + +function parseConfiguredUrl(value: string | null | undefined): URL | null { + const trimmedValue = value?.trim(); + if (!trimmedValue) { + return null; + } + + try { + return new URL(trimmedValue); + } catch { + return null; + } +} + +function isLoopbackHostname(hostname: string): boolean { + const normalizedHostname = hostname.toLowerCase(); + return normalizedHostname === "localhost" || normalizedHostname === "127.0.0.1" || normalizedHostname === "::1"; +} + +function resolveNotificationPublicAppUrl(publicAppUrl: string | null | undefined): string | null { + const configuredUrl = parseConfiguredUrl(publicAppUrl ?? env.PUBLIC_APP_URL); + if (configuredUrl && !isLoopbackHostname(configuredUrl.hostname)) { + return normalizePublicAppUrl(configuredUrl.toString()); + } + + const corsOrigins = env.CORS_ORIGINS.split(",") + .map((origin) => parseConfiguredUrl(origin)) + .filter((origin): origin is URL => origin !== null); + const reachableCorsOrigin = + corsOrigins.find((origin) => !isLoopbackHostname(origin.hostname)) ?? corsOrigins[0] ?? null; + if (reachableCorsOrigin) { + return normalizePublicAppUrl(reachableCorsOrigin.toString()); + } + + return configuredUrl ? normalizePublicAppUrl(configuredUrl.toString()) : null; +} + +function getScheduledKey(scheduledFor: Date): string { + return String(Math.floor(scheduledFor.getTime() / 60000)); +} + +function formatDateParam(value: Date): string { + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, "0"); + const day = String(value.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function buildViewUrl(baseUrl: string, scheduledFor: Date | null, doseIds: string[]): string { + const params = new URLSearchParams(); + const primaryDoseId = doseIds[0]; + + if (scheduledFor) { + params.set("day", formatDateParam(scheduledFor)); + } + + if (primaryDoseId) { + params.set("dose", primaryDoseId); + } + + const queryString = params.toString(); + return queryString.length > 0 ? `${baseUrl}/dashboard?${queryString}` : `${baseUrl}/dashboard`; +} + +function parseDoseIdsJson(value: string): string[] { + try { + const parsed = JSON.parse(value) as unknown; + if (!Array.isArray(parsed)) { + return []; + } + + return parsed.filter((entry): entry is string => typeof entry === "string" && entry.length > 0); + } catch { + return []; + } +} + +function createSequenceId(groupKey: string): string { + return `medassist-${createHash("sha256").update(groupKey, "utf8").digest("hex").slice(0, 32)}`; +} + +export function createActionToken(): string { + return randomBytes(32).toString("hex"); +} + +export function hashActionToken(token: string): string { + return createHash("sha256").update(token, "utf8").digest("hex"); +} + +async function createTokenRow(groupId: number, kind: TokenKind): Promise<{ kind: TokenKind; token: string }> { + const token = createActionToken(); + await db.insert(notificationActionTokens).values({ + groupId, + tokenHash: hashActionToken(token), + kind, + }); + + return { kind, token }; +} + +async function createActionTokens(groupId: number): Promise> { + const createdTokens = await Promise.all([ + createTokenRow(groupId, "taken"), + createTokenRow(groupId, "skip"), + createTokenRow(groupId, "respond"), + ]); + + return createdTokens.reduce( + (accumulator, entry) => { + accumulator[entry.kind] = entry.token; + return accumulator; + }, + { taken: "", skip: "", respond: "" } as Record + ); +} + +export async function createNotificationActionContext(input: { + userId: number; + title: string; + message: string; + doseIds: string[]; + scheduledFor: Date; + publicAppUrl?: string | null; + language: Language; + actionMode?: NotificationActionMode; +}): Promise { + const publicAppUrl = resolveNotificationPublicAppUrl(input.publicAppUrl); + if (!publicAppUrl) { + return null; + } + + const uniqueDoseIds = [...new Set(input.doseIds.filter((doseId) => doseId.trim().length > 0))].sort(); + if (uniqueDoseIds.length === 0) { + return null; + } + + const baseUrl = publicAppUrl; + const actionMode = input.actionMode ?? "full"; + const labels = getNotificationActionLabels(input.language); + const viewUrl = buildViewUrl(baseUrl, input.scheduledFor, uniqueDoseIds); + + if (actionMode === "view-only") { + return { + viewUrl, + actions: [{ kind: "view", label: labels.view, url: viewUrl, method: "GET" }], + }; + } + + const groupKey = `intake:${input.userId}:${uniqueDoseIds.join(",")}:${getScheduledKey(input.scheduledFor)}`; + const sequenceId = createSequenceId(groupKey); + const now = new Date(); + const expiresAt = new Date(now.getTime() + NOTIFICATION_ACTION_TTL_MS); + + let [group] = await db + .select() + .from(notificationActionGroups) + .where( + and( + eq(notificationActionGroups.groupKey, groupKey), + isNull(notificationActionGroups.resolvedAction), + gt(notificationActionGroups.expiresAt, now) + ) + ); + + if (!group) { + [group] = await db + .insert(notificationActionGroups) + .values({ + userId: input.userId, + groupKey, + sequenceId, + doseIdsJson: JSON.stringify(uniqueDoseIds), + title: input.title, + message: input.message, + language: input.language, + scheduledFor: input.scheduledFor, + expiresAt, + updatedAt: now, + }) + .returning(); + } + + const tokens = await createActionTokens(group.id); + const groupLanguage = (group.language as Language | null) ?? input.language; + const groupLabels = getNotificationActionLabels(groupLanguage); + const respondUrl = `${baseUrl}/api/notification-actions/${tokens.respond}`; + const resolvedViewUrl = buildViewUrl(baseUrl, group.scheduledFor ?? input.scheduledFor, uniqueDoseIds); + + return { + groupId: group.id, + sequenceId: group.sequenceId, + respondUrl, + viewUrl: resolvedViewUrl, + actions: [ + { + kind: "taken", + label: groupLabels.taken, + url: `${baseUrl}/api/notification-actions/${tokens.taken}`, + method: "POST", + }, + { + kind: "skip", + label: groupLabels.skip, + url: `${baseUrl}/api/notification-actions/${tokens.skip}`, + method: "POST", + }, + { kind: "view", label: groupLabels.view, url: resolvedViewUrl, method: "GET" }, + ], + }; +} + +export async function createTestNotificationActionContext(input: { + userId: number; + title: string; + message: string; + publicAppUrl?: string | null; + language: Language; +}): Promise { + const publicAppUrl = resolveNotificationPublicAppUrl(input.publicAppUrl); + if (!publicAppUrl) { + return null; + } + + const baseUrl = publicAppUrl; + const now = new Date(); + const groupKey = `test:${input.userId}:${now.getTime()}:${randomBytes(8).toString("hex")}`; + const sequenceId = createSequenceId(groupKey); + const expiresAt = new Date(now.getTime() + NOTIFICATION_ACTION_TTL_MS); + const viewUrl = buildViewUrl(baseUrl, null, []); + + const [group] = await db + .insert(notificationActionGroups) + .values({ + userId: input.userId, + groupKey, + sequenceId, + doseIdsJson: "[]", + title: input.title, + message: input.message, + language: input.language, + scheduledFor: now, + expiresAt, + updatedAt: now, + }) + .returning(); + + const tokens = await createActionTokens(group.id); + const groupLanguage = (group.language as Language | null) ?? input.language; + const groupLabels = getNotificationActionLabels(groupLanguage); + const respondUrl = `${baseUrl}/api/notification-actions/${tokens.respond}`; + + return { + groupId: group.id, + sequenceId: group.sequenceId, + respondUrl, + viewUrl, + actions: [ + { + kind: "taken", + label: groupLabels.taken, + url: `${baseUrl}/api/notification-actions/${tokens.taken}`, + method: "POST", + }, + { + kind: "skip", + label: groupLabels.skip, + url: `${baseUrl}/api/notification-actions/${tokens.skip}`, + method: "POST", + }, + { kind: "view", label: groupLabels.view, url: viewUrl, method: "GET" }, + ], + }; +} + +export async function getNotificationActionTokenRecord( + rawToken: string +): Promise { + const tokenHash = hashActionToken(rawToken); + const rows = await db + .select({ token: notificationActionTokens, group: notificationActionGroups }) + .from(notificationActionTokens) + .innerJoin(notificationActionGroups, eq(notificationActionTokens.groupId, notificationActionGroups.id)) + .where(eq(notificationActionTokens.tokenHash, tokenHash)); + + const record = rows[0]; + if (!record) { + return null; + } + + const baseUrl = resolveNotificationPublicAppUrl(env.PUBLIC_APP_URL); + return { + token: record.token, + group: record.group, + doseIds: parseDoseIdsJson(record.group.doseIdsJson), + viewUrl: baseUrl + ? buildViewUrl(baseUrl, record.group.scheduledFor, parseDoseIdsJson(record.group.doseIdsJson)) + : null, + }; +} + +export function isNotificationActionExpired(record: NotificationActionTokenRecord): boolean { + return record.group.expiresAt.getTime() <= Date.now(); +} + +export async function storeNotificationActionGroupNtfyMessageId(groupId: number, ntfyMessageId: string): Promise { + const normalizedMessageId = ntfyMessageId.trim(); + if (normalizedMessageId.length === 0) { + return; + } + + await db + .update(notificationActionGroups) + .set({ ntfyOriginalMessageId: normalizedMessageId, updatedAt: new Date() }) + .where(eq(notificationActionGroups.id, groupId)); +} diff --git a/backend/src/services/notifications/action-renderer.ts b/backend/src/services/notifications/action-renderer.ts new file mode 100644 index 0000000..1240b5a --- /dev/null +++ b/backend/src/services/notifications/action-renderer.ts @@ -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 } { + if (!isNtfyNotificationUrl(urlStr)) { + return { + message: appendFallbackActionLinks(message, options), + headers: {}, + }; + } + + const headers: Record = {}; + 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 }; +} diff --git a/backend/src/services/notifications/delivery.ts b/backend/src/services/notifications/delivery.ts index 8a11c88..76be5bf 100644 --- a/backend/src/services/notifications/delivery.ts +++ b/backend/src/services/notifications/delivery.ts @@ -1,5 +1,6 @@ import nodemailer from "nodemailer"; import { sendShoutrrrNotification } from "../../routes/settings.js"; +import type { PushNotificationOptions } from "./action-renderer.js"; type MailDeliveryInfo = { accepted?: unknown; @@ -122,14 +123,15 @@ export async function sendEmailNotification(input: EmailDeliveryRequest): Promis export async function sendPushNotification( url: string, title: string, - message: string -): Promise<{ success: boolean; error?: string }> { + message: string, + options: PushNotificationOptions = {} +): Promise<{ success: boolean; error?: string; providerMessageId?: string }> { try { - const result = await sendShoutrrrNotification(url, title, message); + const result = await sendShoutrrrNotification(url, title, message, options); if (!result.success) { return { success: false, error: result.error }; } - return { success: true }; + return { success: true, providerMessageId: result.providerMessageId }; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Unknown error"; return { success: false, error: errorMessage }; diff --git a/backend/src/test/e2e-routes.test.ts b/backend/src/test/e2e-routes.test.ts index a6fa5f1..72fba3f 100644 --- a/backend/src/test/e2e-routes.test.ts +++ b/backend/src/test/e2e-routes.test.ts @@ -3368,12 +3368,12 @@ describe("E2E Tests with Real Routes", () => { looseTablets: 10, }, refillPayload: { packsAdded: 0, loosePillsAdded: 100 }, - expectedVisibleStockBeforeRefill: 4, + expectedVisibleStockBeforeRefill: 10, expectedQuantityAdded: 100, expectedResponsePacksAdded: 0, expectedPackCount: 0, - expectedLooseTablets: 104, - expectedTotalPills: 104, + expectedLooseTablets: 110, + expectedTotalPills: 110, expectedPersistedTotalPills: 100, expectedStockAdjustment: 0, }, @@ -3387,14 +3387,14 @@ describe("E2E Tests with Real Routes", () => { looseTablets: 0, }, refillPayload: { packsAdded: 1, loosePillsAdded: 0 }, - expectedVisibleStockBeforeRefill: 4, + expectedVisibleStockBeforeRefill: 10, expectedQuantityAdded: 10, expectedResponsePacksAdded: 1, expectedPackCount: 2, expectedLooseTablets: 0, - expectedTotalPills: 14, + expectedTotalPills: 20, expectedPersistedTotalPills: null, - expectedStockAdjustment: -6, + expectedStockAdjustment: 0, }, { name: "liquid_container", @@ -3408,17 +3408,17 @@ describe("E2E Tests with Real Routes", () => { blisters: [{ usage: 1, every: 1, start: "2025-01-01T08:00:00.000Z" }], }, refillPayload: { packsAdded: 1, loosePillsAdded: 0 }, - expectedVisibleStockBeforeRefill: 4, + expectedVisibleStockBeforeRefill: 10, expectedQuantityAdded: 100, expectedResponsePacksAdded: 1, expectedAmountPerPackage: 100, expectedPackCount: 2, - expectedLooseTablets: 104, - expectedTotalPills: 104, - expectedPersistedTotalPills: 104, + expectedLooseTablets: 110, + expectedTotalPills: 110, + expectedPersistedTotalPills: 110, expectedStockAdjustment: 0, }, - ])("should refill from current visible stock after prior consumption for $name", async ({ + ])("should refill from the persisted stock baseline after prior consumption for $name", async ({ payload, refillPayload, expectedVisibleStockBeforeRefill, diff --git a/backend/src/test/env.test.ts b/backend/src/test/env.test.ts index ad4f070..cd55eca 100644 --- a/backend/src/test/env.test.ts +++ b/backend/src/test/env.test.ts @@ -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"); diff --git a/backend/src/test/routes-real.test.ts b/backend/src/test/routes-real.test.ts index f15e0dd..0249a20 100644 --- a/backend/src/test/routes-real.test.ts +++ b/backend/src/test/routes-real.test.ts @@ -374,14 +374,14 @@ describe("Real route coverage: settings/export/report", () => { label: "Take", url: expect.stringMatching(/^https:\/\/app\.example\.com\/api\/notification-actions\//), method: "POST", - clear: false, + clear: true, }, { action: "http", label: "Skip", url: expect.stringMatching(/^https:\/\/app\.example\.com\/api\/notification-actions\//), method: "POST", - clear: false, + clear: true, }, { action: "view", @@ -632,7 +632,11 @@ describe("Real route coverage: settings/export/report", () => { expect(body[medId].dosesTaken).toBe(1); expect(body[medId].dosesSkipped).toBe(1); expect(body[medId].refills).toHaveLength(1); - expect(body[medId].refills[0].quantityAdded).toBe(22); + expect(body[medId].refills[0]).toMatchObject({ + packsAdded: 1, + loosePillsAdded: 2, + usedPrescription: true, + }); }); it("POST /medications/report-data filters dose counts by takenBy suffix when requested", async () => {