mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
The webhook, ntfy and email test routes turned every delivery failure into a bare 500, so the settings card could only say "Test failed" while the server knew the relay had answered 400. A failure at the other end, or on the way to it, is now a 502 with meta.errorCode, the upstream HTTP status or SMTP reply code, and for webhook and ntfy what the relay answered when that is short and holds no credential. The codes reuse the test card's vocabulary; upstream_rejected is new, for a relay that accepted the connection and refused the payload. The private-origin 422 is unchanged, and only a failure the server cannot name stays a 500. The guard that pinned the bare 500 now asserts the opposite, with the reason in its name. The admin fan-out test reports the same code and status per channel, and tests the webhook and email channels, which it used to list as an unknown channel type. Refs #947
309 lines
9.2 KiB
TypeScript
309 lines
9.2 KiB
TypeScript
import { prisma } from "@/lib/db";
|
|
import { apiHandler, requireAdmin } from "@/lib/api-handler";
|
|
import { annotate, getEvent } from "@/lib/logging/context";
|
|
import { apiSuccess } from "@/lib/api-response";
|
|
import { decrypt, encrypt } from "@/lib/crypto";
|
|
import { sendViaTelegram } from "@/lib/notifications/senders/telegram";
|
|
import { sendViaNtfy } from "@/lib/notifications/senders/ntfy";
|
|
import { sendViaWebPush } from "@/lib/notifications/senders/web-push";
|
|
import { sendViaApns } from "@/lib/notifications/senders/apns";
|
|
import { sendViaWebhook } from "@/lib/notifications/senders/webhook";
|
|
import { sendViaEmail } from "@/lib/notifications/senders/email";
|
|
import type { SendOutcome } from "@/lib/notifications/retry-policy";
|
|
import {
|
|
testFailureDetail,
|
|
testFailureSentence,
|
|
type TestFailureDetail,
|
|
} from "@/lib/notifications/test-delivery-failure";
|
|
import type {
|
|
TelegramChannelConfig,
|
|
NtfyChannelConfig,
|
|
WebhookChannelConfig,
|
|
EmailChannelConfig,
|
|
ChannelType,
|
|
NotificationPayload,
|
|
} from "@/lib/notifications/types";
|
|
import { defaultLocale, type Locale } from "@/lib/i18n/config";
|
|
import { getServerTranslator } from "@/lib/i18n/server-translator";
|
|
|
|
export const dynamic = "force-dynamic";
|
|
|
|
/**
|
|
* Ensure legacy Telegram config (stored on User model) has a matching
|
|
* NotificationChannel record. Returns true if a channel was created.
|
|
*/
|
|
async function ensureTelegramChannel(userId: string): Promise<boolean> {
|
|
const existing = await prisma.notificationChannel.findUnique({
|
|
where: { userId_type: { userId, type: "TELEGRAM" } },
|
|
});
|
|
if (existing) return false;
|
|
|
|
const user = await prisma.user.findUnique({
|
|
where: { id: userId },
|
|
select: {
|
|
telegramBotToken: true,
|
|
telegramChatId: true,
|
|
telegramEnabled: true,
|
|
},
|
|
});
|
|
|
|
if (
|
|
!user?.telegramEnabled ||
|
|
!user.telegramBotToken ||
|
|
!user.telegramChatId
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const botToken = decrypt(user.telegramBotToken);
|
|
const config = encrypt(
|
|
JSON.stringify({ botToken, chatId: user.telegramChatId }),
|
|
);
|
|
|
|
await prisma.notificationChannel.create({
|
|
data: {
|
|
userId,
|
|
type: "TELEGRAM",
|
|
enabled: true,
|
|
config,
|
|
},
|
|
});
|
|
|
|
return true;
|
|
}
|
|
|
|
type TestResult = {
|
|
channel: string;
|
|
success: boolean;
|
|
error?: string;
|
|
} & TestFailureDetail;
|
|
|
|
/**
|
|
* A failed HTTP or SMTP send, with the code, status and SMTP reply code the
|
|
* user-facing test routes answer with, instead of a sentence that drops
|
|
* them. A private-origin refusal forwards its own code.
|
|
*/
|
|
function failedSend(
|
|
channel: string,
|
|
label: string,
|
|
result: SendOutcome,
|
|
context?: string,
|
|
): TestResult {
|
|
const detail: TestFailureDetail = result.errorCode
|
|
? { errorCode: result.errorCode }
|
|
: testFailureDetail(result);
|
|
const cause = result.errorCode
|
|
? `refused by the private-origin policy (${result.errorCode})`
|
|
: detail.errorCode
|
|
? testFailureSentence(label, detail)
|
|
: (result.reason ?? "send failed");
|
|
return {
|
|
channel,
|
|
success: false,
|
|
error: context ? `${cause} (${context})` : cause,
|
|
...detail,
|
|
};
|
|
}
|
|
|
|
function isLocale(value: string | null | undefined): value is Locale {
|
|
return (
|
|
value === "de" ||
|
|
value === "en" ||
|
|
value === "fr" ||
|
|
value === "es" ||
|
|
value === "it" ||
|
|
value === "pl" ||
|
|
value === "ko"
|
|
);
|
|
}
|
|
|
|
export const POST = apiHandler(async () => {
|
|
const { user } = await requireAdmin();
|
|
annotate({ action: { name: "admin.notifications.test" } });
|
|
|
|
// Auto-migrate legacy Telegram config before querying channels
|
|
const migrated = await ensureTelegramChannel(user.id);
|
|
if (migrated) {
|
|
getEvent()?.addMeta("telegram_migrated", true);
|
|
}
|
|
|
|
const channels = await prisma.notificationChannel.findMany({
|
|
where: { userId: user.id, enabled: true },
|
|
include: {
|
|
preferences: { where: { eventType: "SYSTEM_ALERT" } },
|
|
},
|
|
});
|
|
|
|
if (channels.length === 0) {
|
|
return apiSuccess({
|
|
sent: false,
|
|
message: "No enabled notification channels found",
|
|
results: [],
|
|
});
|
|
}
|
|
|
|
// v1.4.27 F21 — resolve the admin's persisted locale so the test
|
|
// notification arrives in their preferred language. The test route
|
|
// talks to the senders directly (not the dispatcher) because it
|
|
// needs per-channel success / error feedback for the UI, so we
|
|
// compose the payload locally rather than via
|
|
// `dispatchLocalisedNotification`.
|
|
const adminLocaleRow = await prisma.user.findUnique({
|
|
where: { id: user.id },
|
|
select: { locale: true },
|
|
});
|
|
const adminLocale: Locale = isLocale(adminLocaleRow?.locale)
|
|
? adminLocaleRow.locale
|
|
: defaultLocale;
|
|
const t = getServerTranslator(adminLocale).t;
|
|
|
|
const payload: NotificationPayload = {
|
|
eventType: "SYSTEM_ALERT",
|
|
userId: user.id,
|
|
title: t("notifications.admin.testNotificationTitle"),
|
|
message: t("notifications.admin.testNotificationBody"),
|
|
};
|
|
|
|
const results: TestResult[] = [];
|
|
|
|
for (const channel of channels) {
|
|
const pref = channel.preferences[0];
|
|
if (pref && !pref.enabled) {
|
|
results.push({
|
|
channel: channel.type,
|
|
success: false,
|
|
error: "SYSTEM_ALERT disabled in settings",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
try {
|
|
const type = channel.type as ChannelType;
|
|
let success = false;
|
|
|
|
switch (type) {
|
|
case "TELEGRAM": {
|
|
const config = JSON.parse(
|
|
decrypt(channel.config),
|
|
) as TelegramChannelConfig;
|
|
const telegramResult = await sendViaTelegram(config, payload);
|
|
success = telegramResult.ok;
|
|
if (!success) {
|
|
results.push({
|
|
channel: "TELEGRAM",
|
|
success: false,
|
|
error: `Telegram API reported error (chatId: ${config.chatId})`,
|
|
});
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
case "NTFY": {
|
|
const config = JSON.parse(
|
|
decrypt(channel.config),
|
|
) as NtfyChannelConfig;
|
|
const ntfyResult = await sendViaNtfy(config, payload);
|
|
success = ntfyResult.ok;
|
|
if (!success) {
|
|
results.push(
|
|
failedSend(
|
|
"NTFY",
|
|
"The ntfy server",
|
|
ntfyResult,
|
|
`topic: ${config.topic}`,
|
|
),
|
|
);
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
// The generic webhook and email are first-class channels in the
|
|
// dispatcher; without these arms they reported "Unknown channel type".
|
|
case "WEBHOOK": {
|
|
const config = JSON.parse(
|
|
decrypt(channel.config),
|
|
) as WebhookChannelConfig;
|
|
const webhookResult = await sendViaWebhook(config, payload);
|
|
success = webhookResult.ok;
|
|
if (!success) {
|
|
results.push(failedSend("WEBHOOK", "The webhook", webhookResult));
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
case "EMAIL": {
|
|
const config = JSON.parse(
|
|
decrypt(channel.config),
|
|
) as EmailChannelConfig;
|
|
const emailResult = await sendViaEmail(config, payload);
|
|
success = emailResult.ok;
|
|
if (!success) {
|
|
results.push(failedSend("EMAIL", "The mail server", emailResult));
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
case "WEB_PUSH": {
|
|
const webPushResult = await sendViaWebPush(user.id, payload);
|
|
success = webPushResult.ok;
|
|
break;
|
|
}
|
|
// v1.4.46 — APNs was missing from the admin-test switch even
|
|
// though the dispatcher already routes the same payload to the
|
|
// sender in production. Mirrors the WEB_PUSH branch: the sender
|
|
// owns its own DB lookup (`prisma.device.findMany` for the
|
|
// user's iOS devices) and its own per-device fan-out, so the
|
|
// admin route just calls the per-channel entry point and reads
|
|
// the boolean outcome.
|
|
case "APNS": {
|
|
const apnsResult = await sendViaApns(user.id, {
|
|
title: payload.title,
|
|
message: payload.message,
|
|
eventType: payload.eventType,
|
|
metadata: payload.metadata,
|
|
});
|
|
success = apnsResult.ok;
|
|
if (!success) {
|
|
results.push({
|
|
channel: "APNS",
|
|
success: false,
|
|
error: apnsResult.reason ?? "apns_send_failed",
|
|
});
|
|
continue;
|
|
}
|
|
break;
|
|
}
|
|
default:
|
|
results.push({
|
|
channel: type,
|
|
success: false,
|
|
error: "Unknown channel type",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
results.push({ channel: type, success });
|
|
} catch (err) {
|
|
results.push({
|
|
channel: channel.type,
|
|
success: false,
|
|
error: err instanceof Error ? err.message : "Unknown error",
|
|
});
|
|
}
|
|
}
|
|
|
|
const successCount = results.filter((r) => r.success).length;
|
|
const failCount = results.filter((r) => !r.success).length;
|
|
|
|
let message: string;
|
|
if (failCount === 0) {
|
|
message = `All ${successCount} channels successful`;
|
|
} else if (successCount === 0) {
|
|
message = `All ${failCount} channels failed`;
|
|
} else {
|
|
message = `${successCount} successful, ${failCount} failed`;
|
|
}
|
|
|
|
return apiSuccess({ sent: successCount > 0, message, results });
|
|
});
|