feat: add public notification action routes
This commit is contained in:
Generated
+24
-3
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/formbody": "^8.0.2",
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
@@ -895,6 +896,26 @@
|
||||
"fast-json-stringify": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/formbody": {
|
||||
"version": "8.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/formbody/-/formbody-8.0.2.tgz",
|
||||
"integrity": "sha512-84v5J2KrkXzjgBpYnaNRPqwgMsmY7ZDjuj0YVuMR3NXCJRCgKEZy/taSP1wUYGn0onfxJpLyRGDLa+NMaDJtnA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/fastify"
|
||||
},
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/fastify"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-querystring": "^1.1.2",
|
||||
"fastify-plugin": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@fastify/forwarded": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz",
|
||||
@@ -3130,9 +3151,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
|
||||
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.0.2",
|
||||
"@fastify/cors": "^11.2.0",
|
||||
"@fastify/formbody": "^8.0.2",
|
||||
"@fastify/helmet": "^13.0.2",
|
||||
"@fastify/multipart": "^10.0.0",
|
||||
"@fastify/rate-limit": "^10.3.0",
|
||||
|
||||
+57
-4
@@ -23,6 +23,7 @@ import { exportRoutes } from "./routes/export.js";
|
||||
import { healthRoutes } from "./routes/health.js";
|
||||
import { medicationEnrichmentRoutes } from "./routes/medication-enrichment.js";
|
||||
import { medicationRoutes } from "./routes/medications.js";
|
||||
import { notificationActionRoutes } from "./routes/notification-actions.js";
|
||||
import { oidcRoutes } from "./routes/oidc.js";
|
||||
import { plannerRoutes } from "./routes/planner.js";
|
||||
import { refillRoutes } from "./routes/refills.js";
|
||||
@@ -79,6 +80,19 @@ function buildLoggerOptions(level: string) {
|
||||
return base;
|
||||
}
|
||||
|
||||
function buildHelmetOptions(_isProduction: boolean) {
|
||||
return {};
|
||||
}
|
||||
|
||||
function isPublicNotificationActionPath(url: string | undefined): boolean {
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalizedUrl = url.split("?")[0]?.toLowerCase() ?? "";
|
||||
return /(^|\/)(api\/)?notification-actions(\/|$)/.test(normalizedUrl);
|
||||
}
|
||||
|
||||
async function registerApiDocs(app: FastifyInstance, enabled: boolean) {
|
||||
if (!enabled) return;
|
||||
|
||||
@@ -166,6 +180,7 @@ export async function createApp(options?: {
|
||||
app.addHook("onRequest", (request, reply, done) => {
|
||||
request.correlationId = request.id;
|
||||
reply.header("x-correlation-id", request.id);
|
||||
|
||||
done();
|
||||
});
|
||||
|
||||
@@ -182,8 +197,26 @@ export async function createApp(options?: {
|
||||
|
||||
// Register plugins
|
||||
await app.register(sensible);
|
||||
await app.register(helmet);
|
||||
await app.register(cors, { origin: opts.corsOrigins, credentials: true });
|
||||
await app.register(helmet, buildHelmetOptions(opts.isProduction));
|
||||
await app.register(cors, {
|
||||
hook: "preHandler",
|
||||
delegator: (request, callback) => {
|
||||
if (isPublicNotificationActionPath(request.raw.url)) {
|
||||
callback(null, {
|
||||
origin: true,
|
||||
credentials: false,
|
||||
methods: ["GET", "HEAD", "POST", "OPTIONS"],
|
||||
preflightContinue: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
callback(null, {
|
||||
origin: opts.corsOrigins,
|
||||
credentials: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
await app.register(rateLimit, { max: 300, timeWindow: "1 minute" });
|
||||
await app.register(cookie, { secret: opts.cookieSecret });
|
||||
|
||||
@@ -212,6 +245,7 @@ export async function createApp(options?: {
|
||||
await app.register(medicationEnrichmentRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(plannerRoutes);
|
||||
await app.register(notificationActionRoutes);
|
||||
await app.register(shareRoutes);
|
||||
await app.register(doseRoutes);
|
||||
await app.register(exportRoutes);
|
||||
@@ -266,8 +300,26 @@ app.decorate("config", {
|
||||
});
|
||||
|
||||
await app.register(sensible);
|
||||
await app.register(helmet);
|
||||
await app.register(cors, { origin: origins, credentials: true });
|
||||
await app.register(helmet, buildHelmetOptions(env.NODE_ENV === "production"));
|
||||
await app.register(cors, {
|
||||
hook: "preHandler",
|
||||
delegator: (request, callback) => {
|
||||
if (isPublicNotificationActionPath(request.raw.url)) {
|
||||
callback(null, {
|
||||
origin: true,
|
||||
credentials: false,
|
||||
methods: ["GET", "HEAD", "POST", "OPTIONS"],
|
||||
preflightContinue: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
callback(null, {
|
||||
origin: origins,
|
||||
credentials: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
await app.register(rateLimit, {
|
||||
max: Number(process.env.RATE_LIMIT_MAX) || 100,
|
||||
timeWindow: "1 minute",
|
||||
@@ -294,6 +346,7 @@ await app.register(medicationRoutes);
|
||||
await app.register(medicationEnrichmentRoutes);
|
||||
await app.register(settingsRoutes);
|
||||
await app.register(plannerRoutes);
|
||||
await app.register(notificationActionRoutes);
|
||||
await app.register(shareRoutes);
|
||||
await app.register(doseRoutes);
|
||||
await app.register(exportRoutes);
|
||||
|
||||
@@ -0,0 +1,557 @@
|
||||
import formbody from "@fastify/formbody";
|
||||
import { eq } from "drizzle-orm";
|
||||
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||
import { z } from "zod";
|
||||
import { db } from "../db/client.js";
|
||||
import { notificationActionGroups, notificationActionTokens, userSettings } from "../db/schema.js";
|
||||
import type { Language } from "../i18n/translations.js";
|
||||
import { markDoseTakenForUser, skipDosesForUser } from "../services/dose-tracking-service.js";
|
||||
import {
|
||||
getNotificationActionTokenRecord,
|
||||
isNotificationActionExpired,
|
||||
} from "../services/notification-actions-service.js";
|
||||
import { getNotificationActionLabels } from "../services/notifications/action-renderer.js";
|
||||
import { sanitizeNotificationUrl } from "../services/settings-service.js";
|
||||
import { applyOpenApiRouteStandards, genericErrorSchema } from "../utils/openapi-route-standards.js";
|
||||
|
||||
const querySchema = z.object({
|
||||
action: z.enum(["taken", "skip", "dismiss"]).optional(),
|
||||
});
|
||||
|
||||
type NotificationMutationAction = "taken" | "skip";
|
||||
|
||||
function normalizeNotificationAction(action: string | null | undefined): NotificationMutationAction | null {
|
||||
if (action === "taken") {
|
||||
return "taken";
|
||||
}
|
||||
|
||||
if (action === "skip" || action === "dismiss") {
|
||||
return "skip";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const publicNotificationActionMethods = "GET,HEAD,POST,OPTIONS";
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
function toHtmlText(value: string): string {
|
||||
return escapeHtml(value).replaceAll("\n", "<br />");
|
||||
}
|
||||
|
||||
function getLanguage(language: string | null): Language {
|
||||
return language === "de" ? "de" : "en";
|
||||
}
|
||||
|
||||
function wantsHtml(request: FastifyRequest): boolean {
|
||||
return request.headers.accept?.includes("text/html") ?? false;
|
||||
}
|
||||
|
||||
function applyPublicNotificationCorsHeaders(
|
||||
request: FastifyRequest,
|
||||
reply: { header: (name: string, value: string) => unknown }
|
||||
) {
|
||||
const requestOrigin = typeof request.headers.origin === "string" ? request.headers.origin : "*";
|
||||
reply.header("access-control-allow-origin", requestOrigin);
|
||||
reply.header("access-control-allow-methods", publicNotificationActionMethods);
|
||||
reply.header("access-control-allow-headers", "content-type");
|
||||
if (requestOrigin !== "*") {
|
||||
reply.header("vary", "Origin");
|
||||
}
|
||||
}
|
||||
|
||||
function getAlreadyProcessedText(language: Language, resolvedAction: NotificationMutationAction) {
|
||||
if (resolvedAction === "taken") {
|
||||
return {
|
||||
bodyTitle: language === "de" ? "Bereits verarbeitet" : "Already processed",
|
||||
bodyText:
|
||||
language === "de"
|
||||
? "Diese Einnahme ist bereits als genommen markiert. Wenn Sie das ändern möchten, öffnen Sie MedAssist und machen Sie die Einnahme dort rückgängig."
|
||||
: "This dose is already marked as taken. If you need to change it, open MedAssist and undo it there.",
|
||||
jsonMessage:
|
||||
language === "de"
|
||||
? "Diese Einnahme ist bereits als genommen markiert. Änderungen sind nur in MedAssist möglich."
|
||||
: "This dose is already marked as taken. Changes can only be made in MedAssist.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
bodyTitle: language === "de" ? "Bereits verarbeitet" : "Already processed",
|
||||
bodyText:
|
||||
language === "de"
|
||||
? "Diese Einnahme ist bereits als übersprungen markiert. Wenn Sie sie stattdessen als genommen markieren möchten, öffnen Sie MedAssist und machen Sie das dort."
|
||||
: "This intake is already marked as skipped. If you want to mark it as taken instead, open MedAssist and do that there.",
|
||||
jsonMessage:
|
||||
language === "de"
|
||||
? "Diese Einnahme ist bereits als übersprungen markiert. Änderungen sind nur in MedAssist möglich."
|
||||
: "This intake is already marked as skipped. Changes can only be made in MedAssist.",
|
||||
};
|
||||
}
|
||||
|
||||
function getActionRecordedText(language: Language, action: NotificationMutationAction) {
|
||||
if (action === "taken") {
|
||||
return {
|
||||
bodyTitle: language === "de" ? "Aktion gespeichert" : "Action recorded",
|
||||
bodyText: language === "de" ? "Die Einnahme wurde als genommen markiert." : "The dose was marked as taken.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
bodyTitle: language === "de" ? "Aktion gespeichert" : "Action recorded",
|
||||
bodyText: language === "de" ? "Die Einnahme wurde als übersprungen markiert." : "The intake was marked as skipped.",
|
||||
};
|
||||
}
|
||||
|
||||
async function clearNtfyNotificationSequence(userId: number, sequenceId: string): Promise<void> {
|
||||
const [settings] = await db
|
||||
.select({ shoutrrrEnabled: userSettings.shoutrrrEnabled, shoutrrrUrl: userSettings.shoutrrrUrl })
|
||||
.from(userSettings)
|
||||
.where(eq(userSettings.userId, userId));
|
||||
|
||||
if (!settings?.shoutrrrEnabled || !settings.shoutrrrUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitized = sanitizeNotificationUrl(settings.shoutrrrUrl);
|
||||
if ("error" in sanitized || !sanitized.isNtfy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clearUrl = new URL(sanitized.url);
|
||||
clearUrl.pathname = `${clearUrl.pathname.replace(/\/+$/, "")}/${encodeURIComponent(sequenceId)}/clear`;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (sanitized.auth) {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${sanitized.auth.user}:${sanitized.auth.pass}`).toString("base64")}`;
|
||||
}
|
||||
|
||||
const response = await fetch(clearUrl.toString(), {
|
||||
method: "PUT",
|
||||
headers,
|
||||
redirect: "error",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteNtfyNotificationSequence(userId: number, sequenceId: string): Promise<void> {
|
||||
const normalizedSequenceId = sequenceId.trim();
|
||||
if (normalizedSequenceId.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const [settings] = await db
|
||||
.select({ shoutrrrEnabled: userSettings.shoutrrrEnabled, shoutrrrUrl: userSettings.shoutrrrUrl })
|
||||
.from(userSettings)
|
||||
.where(eq(userSettings.userId, userId));
|
||||
|
||||
if (!settings?.shoutrrrEnabled || !settings.shoutrrrUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sanitized = sanitizeNotificationUrl(settings.shoutrrrUrl);
|
||||
if ("error" in sanitized || !sanitized.isNtfy) {
|
||||
return;
|
||||
}
|
||||
|
||||
const deleteUrl = new URL(sanitized.url);
|
||||
deleteUrl.pathname = `${deleteUrl.pathname.replace(/\/+$/, "")}/${encodeURIComponent(normalizedSequenceId)}`;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (sanitized.auth) {
|
||||
headers.Authorization = `Basic ${Buffer.from(`${sanitized.auth.user}:${sanitized.auth.pass}`).toString("base64")}`;
|
||||
}
|
||||
|
||||
const response = await fetch(deleteUrl.toString(), {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
redirect: "error",
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`HTTP ${response.status}: ${errorText}`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderPage(options: {
|
||||
language: Language;
|
||||
title: string;
|
||||
message: string;
|
||||
bodyTitle: string;
|
||||
bodyText: string;
|
||||
viewUrl: string | null;
|
||||
actionButtons: Array<{ label: string; formAction?: string }>;
|
||||
}): string {
|
||||
const labels = getNotificationActionLabels(options.language);
|
||||
const forms =
|
||||
options.actionButtons.length > 0
|
||||
? `<div class="actions">${options.actionButtons
|
||||
.map((button) => {
|
||||
const formAction = button.formAction ? ` action="${escapeHtml(button.formAction)}"` : "";
|
||||
return `<form method="POST"${formAction}><button type="submit">${escapeHtml(button.label)}</button></form>`;
|
||||
})
|
||||
.join("")}</div>`
|
||||
: "";
|
||||
const viewLink = options.viewUrl
|
||||
? `<p><a href="${escapeHtml(options.viewUrl)}">${escapeHtml(labels.view)}</a></p>`
|
||||
: "";
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="${options.language}">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${escapeHtml(options.bodyTitle)}</title>
|
||||
<style>
|
||||
body { font-family: system-ui, -apple-system, sans-serif; margin: 0; background: #f4f5f7; color: #1f2937; }
|
||||
main { max-width: 640px; margin: 48px auto; background: white; border-radius: 16px; padding: 24px; box-shadow: 0 12px 40px rgba(15, 23, 42, 0.08); }
|
||||
h1 { margin-top: 0; font-size: 1.5rem; }
|
||||
.card { padding: 16px; border-radius: 12px; background: #f9fafb; margin: 16px 0 24px; }
|
||||
.actions { display: flex; gap: 12px; flex-wrap: wrap; }
|
||||
form { margin: 0; }
|
||||
button, a { display: inline-flex; align-items: center; justify-content: center; min-width: 140px; border-radius: 10px; padding: 12px 16px; font: inherit; text-decoration: none; }
|
||||
button { border: none; background: #0f766e; color: white; cursor: pointer; }
|
||||
form:last-of-type button { background: #475569; }
|
||||
a { background: #e2e8f0; color: #0f172a; }
|
||||
p { line-height: 1.5; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<h1>${escapeHtml(options.bodyTitle)}</h1>
|
||||
<p>${escapeHtml(options.bodyText)}</p>
|
||||
<div class="card">
|
||||
<strong>${escapeHtml(options.title)}</strong>
|
||||
<p>${toHtmlText(options.message)}</p>
|
||||
</div>
|
||||
${forms}
|
||||
${viewLink}
|
||||
</main>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function parseRequestedAction(request: FastifyRequest, tokenKind: string): NotificationMutationAction | null {
|
||||
const normalizedTokenAction = normalizeNotificationAction(tokenKind);
|
||||
if (normalizedTokenAction) {
|
||||
return normalizedTokenAction;
|
||||
}
|
||||
|
||||
const parsedQuery = querySchema.safeParse(request.query);
|
||||
if (parsedQuery.success && parsedQuery.data.action) {
|
||||
return normalizeNotificationAction(parsedQuery.data.action);
|
||||
}
|
||||
|
||||
const body = request.body;
|
||||
if (body && typeof body === "object" && "action" in body) {
|
||||
const actionValue = (body as { action?: unknown }).action;
|
||||
if (typeof actionValue === "string") {
|
||||
return normalizeNotificationAction(actionValue);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildNotificationActionLogContext(
|
||||
record: Awaited<ReturnType<typeof getNotificationActionTokenRecord>> extends infer T ? Exclude<T, null> : never,
|
||||
extra: Record<string, unknown> = {}
|
||||
) {
|
||||
return {
|
||||
groupId: record.group.id,
|
||||
userId: record.group.userId,
|
||||
tokenKind: record.token.kind,
|
||||
doseCount: record.doseIds.length,
|
||||
hasViewUrl: record.viewUrl !== null,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
function buildNotificationRequestLogContext(request: FastifyRequest, extra: Record<string, unknown> = {}) {
|
||||
return {
|
||||
method: request.method,
|
||||
hasOrigin: typeof request.headers.origin === "string",
|
||||
expectsHtml: wantsHtml(request),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
export async function notificationActionRoutes(app: FastifyInstance) {
|
||||
await app.register(formbody);
|
||||
|
||||
applyOpenApiRouteStandards(app, { tag: "notification-actions", protectedByDefault: false });
|
||||
|
||||
app.options<{ Params: { token: string } }>("/notification-actions/:token", async (request, reply) => {
|
||||
applyPublicNotificationCorsHeaders(request, reply);
|
||||
return reply.status(204).send();
|
||||
});
|
||||
|
||||
app.get<{ Params: { token: string } }>(
|
||||
"/notification-actions/:token",
|
||||
{
|
||||
config: {
|
||||
rateLimit: { max: 30, timeWindow: "1 minute" },
|
||||
},
|
||||
schema: {
|
||||
tags: ["notification-actions"],
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["token"],
|
||||
properties: { token: { type: "string", minLength: 1 } },
|
||||
},
|
||||
response: {
|
||||
404: genericErrorSchema,
|
||||
405: genericErrorSchema,
|
||||
410: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
applyPublicNotificationCorsHeaders(request, reply);
|
||||
|
||||
const record = await getNotificationActionTokenRecord(request.params.token);
|
||||
if (!record) {
|
||||
request.log.warn(
|
||||
buildNotificationRequestLogContext(request),
|
||||
"[NotificationActions] Unknown notification action token requested"
|
||||
);
|
||||
return reply.status(404).send({ error: "Notification action not found" });
|
||||
}
|
||||
|
||||
if (isNotificationActionExpired(record)) {
|
||||
request.log.warn(
|
||||
buildNotificationActionLogContext(record),
|
||||
"[NotificationActions] Rejected expired notification action GET request"
|
||||
);
|
||||
return reply.status(410).send({ error: "Notification action has expired" });
|
||||
}
|
||||
|
||||
if (record.token.kind !== "respond" && record.group.resolvedAction === null) {
|
||||
request.log.warn(
|
||||
buildNotificationActionLogContext(record),
|
||||
"[NotificationActions] Rejected direct GET for unresolved non-respond token"
|
||||
);
|
||||
return reply.status(405).send({ error: "Direct GET is only available for respond actions" });
|
||||
}
|
||||
|
||||
const language = getLanguage(record.group.language ?? null);
|
||||
const labels = getNotificationActionLabels(language);
|
||||
const resolvedAction = normalizeNotificationAction(record.group.resolvedAction);
|
||||
let bodyTitle: string;
|
||||
let bodyText: string;
|
||||
let actionButtons: Array<{ label: string; formAction?: string }> = [];
|
||||
|
||||
if (resolvedAction) {
|
||||
({ bodyTitle, bodyText } = getAlreadyProcessedText(language, resolvedAction));
|
||||
} else {
|
||||
if (record.token.kind === "taken") {
|
||||
bodyTitle = language === "de" ? "Einnahme bestätigen" : "Confirm dose";
|
||||
bodyText =
|
||||
language === "de"
|
||||
? "Bestätigen Sie, dass diese Einnahme als genommen markiert werden soll."
|
||||
: "Confirm that this dose should be marked as taken.";
|
||||
actionButtons = [{ label: labels.taken }];
|
||||
} else if (record.token.kind === "skip" || record.token.kind === "dismiss") {
|
||||
bodyTitle = language === "de" ? "Einnahme überspringen" : "Skip intake";
|
||||
bodyText =
|
||||
language === "de"
|
||||
? "Bestätigen Sie, dass diese Einnahme als übersprungen markiert werden soll."
|
||||
: "Confirm that this intake should be marked as skipped.";
|
||||
actionButtons = [{ label: labels.skip }];
|
||||
} else {
|
||||
bodyTitle = language === "de" ? "Erinnerung beantworten" : "Respond to reminder";
|
||||
bodyText =
|
||||
language === "de"
|
||||
? "Wählen Sie eine Aktion für diese Medikamentenerinnerung."
|
||||
: "Choose an action for this medication reminder.";
|
||||
actionButtons = [
|
||||
{ label: labels.taken, formAction: "?action=taken" },
|
||||
{ label: labels.skip, formAction: "?action=skip" },
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return reply.type("text/html; charset=utf-8").send(
|
||||
renderPage({
|
||||
language,
|
||||
title: record.group.title,
|
||||
message: record.group.message,
|
||||
bodyTitle,
|
||||
bodyText,
|
||||
viewUrl: record.viewUrl,
|
||||
actionButtons: resolvedAction ? [] : actionButtons,
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
app.post<{ Params: { token: string } }>(
|
||||
"/notification-actions/:token",
|
||||
{
|
||||
config: {
|
||||
rateLimit: { max: 30, timeWindow: "1 minute" },
|
||||
},
|
||||
schema: {
|
||||
tags: ["notification-actions"],
|
||||
params: {
|
||||
type: "object",
|
||||
required: ["token"],
|
||||
properties: { token: { type: "string", minLength: 1 } },
|
||||
},
|
||||
response: {
|
||||
400: genericErrorSchema,
|
||||
404: genericErrorSchema,
|
||||
409: genericErrorSchema,
|
||||
410: genericErrorSchema,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
applyPublicNotificationCorsHeaders(request, reply);
|
||||
|
||||
const record = await getNotificationActionTokenRecord(request.params.token);
|
||||
if (!record) {
|
||||
request.log.warn(
|
||||
buildNotificationRequestLogContext(request),
|
||||
"[NotificationActions] Unknown notification action token requested"
|
||||
);
|
||||
return reply.status(404).send({ error: "Notification action not found" });
|
||||
}
|
||||
|
||||
if (isNotificationActionExpired(record)) {
|
||||
request.log.warn(
|
||||
buildNotificationActionLogContext(record),
|
||||
"[NotificationActions] Rejected expired notification action POST request"
|
||||
);
|
||||
return reply.status(410).send({ error: "Notification action has expired" });
|
||||
}
|
||||
|
||||
const action = parseRequestedAction(request, record.token.kind);
|
||||
if (!action) {
|
||||
request.log.warn(
|
||||
buildNotificationActionLogContext(record),
|
||||
"[NotificationActions] Missing or invalid action for notification action POST request"
|
||||
);
|
||||
return reply.status(400).send({ error: "Notification action is required" });
|
||||
}
|
||||
|
||||
const language = getLanguage(record.group.language ?? null);
|
||||
const resolvedAction = normalizeNotificationAction(record.group.resolvedAction);
|
||||
if (resolvedAction) {
|
||||
request.log.info(
|
||||
buildNotificationActionLogContext(record, { requestedAction: action, resolvedAction }),
|
||||
"[NotificationActions] Ignored notification action because it was already resolved"
|
||||
);
|
||||
const alreadyProcessedText = getAlreadyProcessedText(language, resolvedAction);
|
||||
|
||||
if (wantsHtml(request)) {
|
||||
return reply.type("text/html; charset=utf-8").send(
|
||||
renderPage({
|
||||
language,
|
||||
title: record.group.title,
|
||||
message: record.group.message,
|
||||
bodyTitle: alreadyProcessedText.bodyTitle,
|
||||
bodyText: alreadyProcessedText.bodyText,
|
||||
viewUrl: record.viewUrl,
|
||||
actionButtons: [],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return reply.send({
|
||||
success: true,
|
||||
action: resolvedAction,
|
||||
alreadyProcessed: true,
|
||||
message: alreadyProcessedText.jsonMessage,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === "taken") {
|
||||
for (const [doseIndex, doseId] of record.doseIds.entries()) {
|
||||
const result = await markDoseTakenForUser({
|
||||
userId: record.group.userId,
|
||||
doseId,
|
||||
source: "notification",
|
||||
markedBy: null,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
request.log.warn(
|
||||
buildNotificationActionLogContext(record, {
|
||||
requestedAction: action,
|
||||
failedDoseIndex: doseIndex,
|
||||
code: result.code,
|
||||
}),
|
||||
"[NotificationActions] Failed to record taken notification action"
|
||||
);
|
||||
const statusCode = result.code === "INVALID_DOSE" ? 400 : 409;
|
||||
return reply.status(statusCode).send({ error: result.message, code: result.code });
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await skipDosesForUser({ userId: record.group.userId, doseIds: record.doseIds });
|
||||
}
|
||||
|
||||
await db
|
||||
.update(notificationActionGroups)
|
||||
.set({ resolvedAction: action, resolvedAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(notificationActionGroups.id, record.group.id));
|
||||
await db
|
||||
.update(notificationActionTokens)
|
||||
.set({ usedAt: new Date() })
|
||||
.where(eq(notificationActionTokens.id, record.token.id));
|
||||
|
||||
request.log.info(
|
||||
buildNotificationActionLogContext(record, { requestedAction: action }),
|
||||
"[NotificationActions] Recorded notification action"
|
||||
);
|
||||
|
||||
const recordedText = getActionRecordedText(language, action);
|
||||
|
||||
try {
|
||||
await deleteNtfyNotificationSequence(record.group.userId, record.group.sequenceId);
|
||||
} catch (error) {
|
||||
request.log.warn(
|
||||
buildNotificationActionLogContext(record, { requestedAction: action, error }),
|
||||
"[NotificationActions] Failed to delete ntfy notification after resolved action"
|
||||
);
|
||||
try {
|
||||
await clearNtfyNotificationSequence(record.group.userId, record.group.sequenceId);
|
||||
} catch (clearError) {
|
||||
request.log.warn(
|
||||
buildNotificationActionLogContext(record, { requestedAction: action, error: clearError }),
|
||||
"[NotificationActions] Failed to clear ntfy notification after delete fallback"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (wantsHtml(request)) {
|
||||
return reply.type("text/html; charset=utf-8").send(
|
||||
renderPage({
|
||||
language,
|
||||
title: record.group.title,
|
||||
message: record.group.message,
|
||||
bodyTitle: recordedText.bodyTitle,
|
||||
bodyText: recordedText.bodyText,
|
||||
viewUrl: record.viewUrl,
|
||||
actionButtons: [],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return reply.send({ success: true, action });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { migrate } from "drizzle-orm/libsql/migrator";
|
||||
import Fastify from "fastify";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runAlterMigrations } from "../db/db-utils.js";
|
||||
import { documentationSchemaAjv } from "../utils/documentation-schema-keywords.js";
|
||||
|
||||
const { testClient, testDb, mockedEnv, fetchMock, mockLogger } = vi.hoisted(() => {
|
||||
const { createClient } = require("@libsql/client");
|
||||
const { drizzle } = require("drizzle-orm/libsql");
|
||||
const client = createClient({ url: ":memory:" });
|
||||
const db = drizzle(client);
|
||||
const logger = {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
trace: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
silent: vi.fn(),
|
||||
level: "info",
|
||||
child: vi.fn(),
|
||||
};
|
||||
logger.child.mockImplementation(() => logger);
|
||||
|
||||
return {
|
||||
testClient: client,
|
||||
testDb: db,
|
||||
fetchMock: vi.fn(),
|
||||
mockLogger: logger,
|
||||
mockedEnv: {
|
||||
AUTH_ENABLED: false,
|
||||
OIDC_ENABLED: false,
|
||||
OIDC_PROVIDER_NAME: "SSO",
|
||||
NODE_ENV: "test",
|
||||
LOG_LEVEL: "silent",
|
||||
PORT: 3000,
|
||||
CORS_ORIGINS: "*",
|
||||
PUBLIC_APP_URL: "https://app.example.com",
|
||||
OPENAPI_DOCS_ENABLED: false,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
global.fetch = fetchMock;
|
||||
|
||||
vi.mock("../db/client.js", () => ({
|
||||
db: testDb,
|
||||
migrationsReady: Promise.resolve(),
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/env.js", () => ({ env: mockedEnv }));
|
||||
|
||||
const { notificationActionRoutes } = await import("../routes/notification-actions.js");
|
||||
const { createNotificationActionContext } = await import("../services/notification-actions-service.js");
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
const migrationsFolder = resolve(__dirname, "../../drizzle");
|
||||
|
||||
function extractToken(url: string): string {
|
||||
return url.split("/").at(-1) ?? "";
|
||||
}
|
||||
|
||||
async function clearTables() {
|
||||
await testClient.execute("DELETE FROM dose_tracking");
|
||||
await testClient.execute("DELETE FROM notification_action_tokens");
|
||||
await testClient.execute("DELETE FROM notification_action_groups");
|
||||
await testClient.execute("DELETE FROM medications");
|
||||
await testClient.execute("DELETE FROM user_settings");
|
||||
await testClient.execute("DELETE FROM users");
|
||||
}
|
||||
|
||||
async function createUser(username: string) {
|
||||
const result = await testClient.execute({
|
||||
sql: "INSERT INTO users (username, auth_provider, is_active) VALUES (?, 'local', 1) RETURNING id",
|
||||
args: [username],
|
||||
});
|
||||
|
||||
return Number(result.rows[0].id);
|
||||
}
|
||||
|
||||
async function insertMedication(options: { id: number; userId: number; packCount?: number; looseTablets?: number }) {
|
||||
const start = "2026-01-05T08:00:00.000Z";
|
||||
await testClient.execute({
|
||||
sql: `INSERT INTO medications (
|
||||
id, user_id, name, taken_by_json, medication_form, package_type,
|
||||
pack_count, blisters_per_pack, pills_per_blister, loose_tablets, stock_adjustment,
|
||||
usage_json, every_json, start_json, intakes_json, intake_reminders_enabled
|
||||
) VALUES (?, ?, 'Route Medication', '[]', 'tablet', 'blister', ?, 1, 10, ?, 0, ?, ?, ?, ?, 1)`,
|
||||
args: [
|
||||
options.id,
|
||||
options.userId,
|
||||
options.packCount ?? 1,
|
||||
options.looseTablets ?? 0,
|
||||
JSON.stringify([1]),
|
||||
JSON.stringify([1]),
|
||||
JSON.stringify([start]),
|
||||
JSON.stringify([{ usage: 1, every: 1, start, takenBy: null, intakeRemindersEnabled: true }]),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function insertUserSettings(
|
||||
userId: number,
|
||||
stockCalculationMode: "automatic" | "manual" = "automatic",
|
||||
overrides: { shoutrrrEnabled?: boolean; shoutrrrUrl?: string | null } = {}
|
||||
) {
|
||||
await testClient.execute({
|
||||
sql: "INSERT INTO user_settings (user_id, stock_calculation_mode, shoutrrr_enabled, shoutrrr_url) VALUES (?, ?, ?, ?)",
|
||||
args: [userId, stockCalculationMode, overrides.shoutrrrEnabled ? 1 : 0, overrides.shoutrrrUrl ?? null],
|
||||
});
|
||||
}
|
||||
|
||||
async function seedContext(options: { userId: number; doseId: string }) {
|
||||
const scheduledFor = new Date("2026-01-05T08:00:00.000Z");
|
||||
const context = await createNotificationActionContext({
|
||||
userId: options.userId,
|
||||
title: "Reminder",
|
||||
message: "Take your medication now",
|
||||
doseIds: [options.doseId],
|
||||
scheduledFor,
|
||||
publicAppUrl: mockedEnv.PUBLIC_APP_URL,
|
||||
language: "en",
|
||||
});
|
||||
|
||||
return {
|
||||
respondToken: extractToken(context!.respondUrl!),
|
||||
takenToken: extractToken(context!.actions.find((action) => action.kind === "taken")!.url),
|
||||
skipToken: extractToken(context!.actions.find((action) => action.kind === "skip")!.url),
|
||||
context: context!,
|
||||
};
|
||||
}
|
||||
|
||||
describe("notification action routes", () => {
|
||||
let app: Awaited<ReturnType<typeof Fastify>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
await migrate(testDb, { migrationsFolder });
|
||||
await runAlterMigrations(testClient);
|
||||
app = Fastify({ loggerInstance: mockLogger, disableRequestLogging: true, ajv: documentationSchemaAjv });
|
||||
await app.register(notificationActionRoutes);
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
testClient.close();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearTables();
|
||||
mockedEnv.PUBLIC_APP_URL = "https://app.example.com";
|
||||
mockedEnv.NODE_ENV = "test";
|
||||
fetchMock.mockReset();
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
mockLogger.info.mockClear();
|
||||
mockLogger.warn.mockClear();
|
||||
mockLogger.error.mockClear();
|
||||
mockLogger.debug.mockClear();
|
||||
mockLogger.trace.mockClear();
|
||||
mockLogger.fatal.mockClear();
|
||||
mockLogger.child.mockClear();
|
||||
});
|
||||
|
||||
it("renders HTML for respond tokens without mutating state", async () => {
|
||||
const userId = await createUser("notification-route-get");
|
||||
const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/notification-actions/${respondToken}`,
|
||||
headers: { accept: "text/html" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers["content-type"]).toContain("text/html");
|
||||
expect(response.body).toContain("Respond to reminder");
|
||||
expect(response.body).toContain("Take your medication now");
|
||||
|
||||
const rows = await testClient.execute({
|
||||
sql: `SELECT g.resolved_action, t.used_at
|
||||
FROM notification_action_groups g
|
||||
INNER JOIN notification_action_tokens t ON t.group_id = g.id
|
||||
WHERE t.kind = 'respond'`,
|
||||
});
|
||||
expect(rows.rows).toEqual([expect.objectContaining({ resolved_action: null, used_at: null })]);
|
||||
});
|
||||
|
||||
it("returns the expected GET behavior for missing, non-respond, and expired tokens", async () => {
|
||||
const missing = await app.inject({ method: "GET", url: "/notification-actions/missing-token" });
|
||||
expect(missing.statusCode).toBe(404);
|
||||
|
||||
const userId = await createUser("notification-route-errors");
|
||||
const { respondToken, takenToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
|
||||
const nonRespond = await app.inject({
|
||||
method: "GET",
|
||||
url: `/notification-actions/${takenToken}`,
|
||||
headers: { accept: "text/html" },
|
||||
});
|
||||
expect(nonRespond.statusCode).toBe(405);
|
||||
expect(nonRespond.json()).toEqual({ error: "Direct GET is only available for respond actions" });
|
||||
|
||||
await testClient.execute({
|
||||
sql: "UPDATE notification_action_groups SET expires_at = ?",
|
||||
args: [new Date(0)],
|
||||
});
|
||||
|
||||
const expired = await app.inject({ method: "GET", url: `/notification-actions/${respondToken}` });
|
||||
expect(expired.statusCode).toBe(410);
|
||||
expect(expired.json()).toEqual({ error: "Notification action has expired" });
|
||||
});
|
||||
|
||||
it("shows an already-processed HTML state for resolved respond tokens", async () => {
|
||||
const userId = await createUser("notification-route-resolved");
|
||||
const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
await testClient.execute({
|
||||
sql: "UPDATE notification_action_groups SET resolved_action = 'skip', resolved_at = ?",
|
||||
args: [new Date()],
|
||||
});
|
||||
|
||||
const response = await app.inject({
|
||||
method: "GET",
|
||||
url: `/notification-actions/${respondToken}`,
|
||||
headers: { accept: "text/html" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body).toContain("Already processed");
|
||||
expect(response.body).toContain(
|
||||
"This intake is already marked as skipped. If you want to mark it as taken instead, open MedAssist and do that there."
|
||||
);
|
||||
});
|
||||
|
||||
it("skips doses through a respond token and returns friendly success for already-resolved follow-up actions", async () => {
|
||||
const userId = await createUser("notification-route-skip");
|
||||
const { respondToken, takenToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/notification-actions/${respondToken}`,
|
||||
payload: { action: "skip" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true, action: "skip" });
|
||||
expect(mockLogger.info).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId,
|
||||
groupId: expect.any(Number),
|
||||
tokenKind: "respond",
|
||||
doseCount: 1,
|
||||
hasViewUrl: true,
|
||||
requestedAction: "skip",
|
||||
}),
|
||||
"[NotificationActions] Recorded notification action"
|
||||
);
|
||||
|
||||
const dismissedRow = await testClient.execute({
|
||||
sql: "SELECT dismissed, taken_at FROM dose_tracking WHERE user_id = ? AND dose_id = ?",
|
||||
args: [userId, "5-0-1736064000000"],
|
||||
});
|
||||
expect(dismissedRow.rows).toEqual([expect.objectContaining({ dismissed: 1, taken_at: 0 })]);
|
||||
|
||||
const groupRow = await testClient.execute({
|
||||
sql: "SELECT resolved_action FROM notification_action_groups",
|
||||
});
|
||||
expect(groupRow.rows).toEqual([expect.objectContaining({ resolved_action: "skip" })]);
|
||||
|
||||
const conflict = await app.inject({
|
||||
method: "POST",
|
||||
url: `/notification-actions/${takenToken}`,
|
||||
});
|
||||
|
||||
expect(conflict.statusCode).toBe(200);
|
||||
expect(conflict.json()).toEqual({
|
||||
success: true,
|
||||
action: "skip",
|
||||
alreadyProcessed: true,
|
||||
message: "This intake is already marked as skipped. Changes can only be made in MedAssist.",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps legacy dismiss respond actions working as a skip alias", async () => {
|
||||
const userId = await createUser("notification-route-dismiss-alias");
|
||||
const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/notification-actions/${respondToken}`,
|
||||
payload: { action: "dismiss" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({ success: true, action: "skip" });
|
||||
|
||||
const groupRow = await testClient.execute({
|
||||
sql: "SELECT resolved_action FROM notification_action_groups",
|
||||
});
|
||||
expect(groupRow.rows).toEqual([expect.objectContaining({ resolved_action: "skip" })]);
|
||||
});
|
||||
|
||||
it("returns an undo hint when a reminder was already taken before a follow-up skip action", async () => {
|
||||
const userId = await createUser("notification-route-taken-followup");
|
||||
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
|
||||
await insertUserSettings(userId, "automatic");
|
||||
const { takenToken, respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
|
||||
const firstResponse = await app.inject({
|
||||
method: "POST",
|
||||
url: `/notification-actions/${takenToken}`,
|
||||
});
|
||||
|
||||
expect(firstResponse.statusCode).toBe(200);
|
||||
expect(firstResponse.json()).toEqual({ success: true, action: "taken" });
|
||||
|
||||
const followUpHtml = await app.inject({
|
||||
method: "GET",
|
||||
url: `/notification-actions/${respondToken}`,
|
||||
headers: { accept: "text/html" },
|
||||
});
|
||||
|
||||
expect(followUpHtml.statusCode).toBe(200);
|
||||
expect(followUpHtml.body).toContain(
|
||||
"This dose is already marked as taken. If you need to change it, open MedAssist and undo it there."
|
||||
);
|
||||
|
||||
const followUpJson = await app.inject({
|
||||
method: "POST",
|
||||
url: `/notification-actions/${respondToken}`,
|
||||
payload: { action: "skip" },
|
||||
});
|
||||
|
||||
expect(followUpJson.statusCode).toBe(200);
|
||||
expect(followUpJson.json()).toEqual({
|
||||
success: true,
|
||||
action: "taken",
|
||||
alreadyProcessed: true,
|
||||
message: "This dose is already marked as taken. Changes can only be made in MedAssist.",
|
||||
});
|
||||
});
|
||||
|
||||
it("deletes the original ntfy notification after a successful action", async () => {
|
||||
const userId = await createUser("notification-route-ntfy-delete");
|
||||
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
|
||||
await insertUserSettings(userId, "automatic", {
|
||||
shoutrrrEnabled: true,
|
||||
shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist",
|
||||
});
|
||||
const { takenToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve("") });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/notification-actions/${takenToken}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [targetUrl, requestInit] = fetchMock.mock.calls[0] ?? [];
|
||||
expect(targetUrl).toBe(`https://ntfy.example.com/medassist/${context.sequenceId}`);
|
||||
expect(requestInit).toEqual(
|
||||
expect.objectContaining({
|
||||
method: "DELETE",
|
||||
redirect: "error",
|
||||
headers: expect.objectContaining({
|
||||
Authorization: expect.stringMatching(/^Basic /),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("deletes the original ntfy notification after a skip action", async () => {
|
||||
const userId = await createUser("notification-route-ntfy-skip-delete");
|
||||
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
|
||||
await insertUserSettings(userId, "automatic", {
|
||||
shoutrrrEnabled: true,
|
||||
shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist",
|
||||
});
|
||||
const { skipToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve("") });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/notification-actions/${skipToken}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const [targetUrl, requestInit] = fetchMock.mock.calls[0] ?? [];
|
||||
expect(targetUrl).toBe(`https://ntfy.example.com/medassist/${context.sequenceId}`);
|
||||
expect(requestInit).toEqual(
|
||||
expect.objectContaining({
|
||||
method: "DELETE",
|
||||
redirect: "error",
|
||||
headers: expect.objectContaining({
|
||||
Authorization: expect.stringMatching(/^Basic /),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("warns when ntfy delete and fallback clear both fail", async () => {
|
||||
const userId = await createUser("notification-route-ntfy-delete-warn");
|
||||
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
|
||||
await insertUserSettings(userId, "automatic", {
|
||||
shoutrrrEnabled: true,
|
||||
shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist",
|
||||
});
|
||||
const { takenToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
fetchMock.mockResolvedValueOnce({ ok: false, status: 500, text: () => Promise.resolve("upstream down") });
|
||||
fetchMock.mockResolvedValueOnce({ ok: false, status: 404, text: () => Promise.resolve("not found") });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/notification-actions/${takenToken}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(app.log.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requestedAction: "taken" }),
|
||||
expect.stringContaining("Failed to delete ntfy notification after resolved action")
|
||||
);
|
||||
expect(app.log.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ requestedAction: "taken" }),
|
||||
expect.stringContaining("Failed to clear ntfy notification after delete fallback")
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to clear when deleting the ntfy notification fails", async () => {
|
||||
const userId = await createUser("notification-route-ntfy-delete-clear-fallback");
|
||||
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
|
||||
await insertUserSettings(userId, "automatic", {
|
||||
shoutrrrEnabled: true,
|
||||
shoutrrrUrl: "ntfy://user:pass@ntfy.example.com/medassist",
|
||||
});
|
||||
const { takenToken, context } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
fetchMock.mockResolvedValueOnce({ ok: false, status: 404, text: () => Promise.resolve("missing") });
|
||||
fetchMock.mockResolvedValueOnce({ ok: true, text: () => Promise.resolve("") });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/notification-actions/${takenToken}`,
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
|
||||
const [clearUrl, clearInit] = fetchMock.mock.calls[1] ?? [];
|
||||
expect(clearUrl).toBe(`https://ntfy.example.com/medassist/${context.sequenceId}/clear`);
|
||||
expect(clearInit).toEqual(
|
||||
expect.objectContaining({
|
||||
method: "PUT",
|
||||
headers: expect.objectContaining({ Authorization: expect.stringMatching(/^Basic /) }),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("allows browser-origin CORS requests for public notification action tokens", async () => {
|
||||
const userId = await createUser("notification-route-cors");
|
||||
const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
|
||||
const preflight = await app.inject({
|
||||
method: "OPTIONS",
|
||||
url: `/notification-actions/${respondToken}?action=taken`,
|
||||
headers: {
|
||||
origin: "https://ntfy.danielvolz.org",
|
||||
"access-control-request-method": "POST",
|
||||
"access-control-request-headers": "content-type",
|
||||
},
|
||||
});
|
||||
|
||||
expect(preflight.statusCode).toBe(204);
|
||||
expect(preflight.headers["access-control-allow-origin"]).toBe("https://ntfy.danielvolz.org");
|
||||
expect(preflight.headers["access-control-allow-methods"]).toContain("POST");
|
||||
expect(preflight.headers["access-control-allow-headers"]).toContain("content-type");
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/notification-actions/${respondToken}`,
|
||||
headers: {
|
||||
origin: "https://ntfy.danielvolz.org",
|
||||
},
|
||||
payload: { action: "skip" },
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers["access-control-allow-origin"]).toBe("https://ntfy.danielvolz.org");
|
||||
});
|
||||
|
||||
it("accepts standard HTML form posts on respond pages", async () => {
|
||||
const userId = await createUser("notification-route-form-post");
|
||||
await insertMedication({ id: 5, userId, packCount: 1, looseTablets: 0 });
|
||||
await insertUserSettings(userId, "automatic");
|
||||
const { respondToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
|
||||
const response = await app.inject({
|
||||
method: "POST",
|
||||
url: `/notification-actions/${respondToken}?action=taken`,
|
||||
headers: {
|
||||
accept: "text/html",
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
payload: "",
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.headers["content-type"]).toContain("text/html");
|
||||
expect(response.body).toContain("Action recorded");
|
||||
expect(response.body).toContain("The dose was marked as taken.");
|
||||
});
|
||||
|
||||
it("returns non-2xx for invalid, expired, and out-of-stock POST actions", async () => {
|
||||
const missing = await app.inject({ method: "POST", url: "/notification-actions/missing-token" });
|
||||
expect(missing.statusCode).toBe(404);
|
||||
|
||||
const expiredUserId = await createUser("notification-route-expired");
|
||||
const { respondToken } = await seedContext({ userId: expiredUserId, doseId: "5-0-1736064000000" });
|
||||
await testClient.execute({
|
||||
sql: "UPDATE notification_action_groups SET expires_at = ? WHERE user_id = ?",
|
||||
args: [new Date(0), expiredUserId],
|
||||
});
|
||||
|
||||
const expired = await app.inject({
|
||||
method: "POST",
|
||||
url: `/notification-actions/${respondToken}`,
|
||||
payload: { action: "skip" },
|
||||
});
|
||||
expect(expired.statusCode).toBe(410);
|
||||
|
||||
const userId = await createUser("notification-route-stock");
|
||||
await insertMedication({ id: 5, userId, packCount: 0, looseTablets: 0 });
|
||||
await insertUserSettings(userId, "automatic");
|
||||
const { takenToken } = await seedContext({ userId, doseId: "5-0-1736064000000" });
|
||||
|
||||
const outOfStock = await app.inject({
|
||||
method: "POST",
|
||||
url: `/notification-actions/${takenToken}`,
|
||||
});
|
||||
expect(outOfStock.statusCode).toBe(409);
|
||||
expect(outOfStock.json()).toEqual({ error: "Medication is out of stock", code: "OUT_OF_STOCK" });
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userId,
|
||||
groupId: expect.any(Number),
|
||||
tokenKind: "taken",
|
||||
doseCount: 1,
|
||||
hasViewUrl: true,
|
||||
requestedAction: "taken",
|
||||
failedDoseIndex: 0,
|
||||
code: "OUT_OF_STOCK",
|
||||
}),
|
||||
"[NotificationActions] Failed to record taken notification action"
|
||||
);
|
||||
|
||||
const state = await testClient.execute({
|
||||
sql: "SELECT resolved_action FROM notification_action_groups WHERE user_id = ?",
|
||||
args: [userId],
|
||||
});
|
||||
expect(state.rows).toEqual([expect.objectContaining({ resolved_action: null })]);
|
||||
});
|
||||
});
|
||||
@@ -244,6 +244,46 @@ describe("Server Bootstrap", () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("should allow browser preflight requests on public notification action routes", async () => {
|
||||
const origins = ["https://medtest.danielvolz.org"];
|
||||
|
||||
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||
await app.register(cors, {
|
||||
delegator: (request, callback) => {
|
||||
if (request.raw.url?.startsWith("/notification-actions/")) {
|
||||
callback(null, {
|
||||
origin: true,
|
||||
credentials: false,
|
||||
methods: ["GET", "HEAD", "POST", "OPTIONS"],
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
callback(null, { origin: origins, credentials: true });
|
||||
},
|
||||
});
|
||||
|
||||
app.post("/notification-actions/:token", async () => ({ ok: true }));
|
||||
await app.ready();
|
||||
|
||||
const response = await app.inject({
|
||||
method: "OPTIONS",
|
||||
url: "/notification-actions/demo-token",
|
||||
headers: {
|
||||
origin: "https://ntfy.danielvolz.org",
|
||||
"access-control-request-method": "POST",
|
||||
"access-control-request-headers": "content-type",
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.statusCode).toBe(204);
|
||||
expect(response.headers["access-control-allow-origin"]).toBe("https://ntfy.danielvolz.org");
|
||||
expect(response.headers["access-control-allow-credentials"]).toBeUndefined();
|
||||
expect(response.headers["access-control-allow-methods"]).toContain("OPTIONS");
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("should register cookie plugin", async () => {
|
||||
const app = Fastify({ logger: false, ajv: documentationSchemaAjv });
|
||||
await app.register(cookie, { secret: "test-cookie-secret" });
|
||||
|
||||
Reference in New Issue
Block a user