feat: add ntfy interactive settings test delivery
This commit is contained in:
@@ -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<string, string> = {};
|
||||
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}` };
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -2,6 +2,7 @@ import { eq } from "drizzle-orm";
|
||||
import { db } from "../db/client.js";
|
||||
import { userSettings } from "../db/schema.js";
|
||||
import type { Language } from "../i18n/translations.js";
|
||||
import { isNtfyNotificationUrl } from "./notifications/action-renderer.js";
|
||||
|
||||
export type UserSettings = {
|
||||
userId: number;
|
||||
@@ -81,7 +82,7 @@ export function getNotificationProvider(url: string): string {
|
||||
if (url.startsWith("telegram://")) return "telegram";
|
||||
if (url.startsWith("gotify://")) return "gotify";
|
||||
if (url.startsWith("pushover://")) return "pushover";
|
||||
if (url.startsWith("ntfy://")) return "ntfy";
|
||||
if (isNtfyNotificationUrl(url)) return "ntfy";
|
||||
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
@@ -231,7 +232,7 @@ export function sanitizeNotificationUrl(
|
||||
return { url: discordWebhookUrl, isNtfy: false };
|
||||
}
|
||||
|
||||
const isNtfy = urlStr.startsWith("ntfy://");
|
||||
const isNtfy = isNtfyNotificationUrl(urlStr);
|
||||
const normalizedUrl = isNtfy ? urlStr.replace("ntfy://", "https://") : urlStr;
|
||||
const parsed = new URL(normalizedUrl);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user