mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
feat(notifications): add generic webhook channel
A single WEBHOOK channel covers Gotify, Discord, Slack, Matrix, and Home Assistant: the user supplies a public URL and an optional shared-secret header, and the dispatcher POSTs a small plain-text JSON envelope. The outbound goes through safeFetch with requirePublicHost so the SSRF floor and the connect-time DNS-rebinding pin apply to the user-supplied host, exactly as they do for ntfy. Responses classify 404/410/401/403 as hard rejects (auto-disable) and 5xx/429/timeout as transient (backoff). Settings card mirrors the ntfy card with a save + test-connection flow; the header value is never returned by the GET and is preserved across a save round-trip when left blank.
This commit is contained in:
1 parent
aec41e154f
commit
7532bbcc05
5 files changed
+594
No files matched your search
@@ -0,0 +1,106 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess, apiError, safeJson } from "@/lib/api-response";
|
||||
import { webhookSettingsSchema } from "@/lib/validations/notifications";
|
||||
import { encrypt, decrypt } from "@/lib/crypto";
|
||||
import { NextRequest } from "next/server";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
/**
|
||||
* Generic-webhook channel config (v1.17.1).
|
||||
* GET: current config (header value redacted — only presence flagged).
|
||||
* PUT: upsert config.
|
||||
*
|
||||
* Covers Gotify / Discord / Slack / Matrix-bridge / Home Assistant in one
|
||||
* channel: the user supplies a public URL and an optional shared-secret
|
||||
* header. SSRF is enforced at input time (`isPublicUrl` in the schema) and
|
||||
* again at dispatch time (`safeFetch({ requirePublicHost: true })`).
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const channel = await prisma.notificationChannel.findUnique({
|
||||
where: { userId_type: { userId: user.id, type: "WEBHOOK" } },
|
||||
});
|
||||
|
||||
if (!channel) {
|
||||
return apiSuccess({
|
||||
enabled: false,
|
||||
url: "",
|
||||
headerName: "",
|
||||
hasHeaderValue: false,
|
||||
});
|
||||
}
|
||||
|
||||
const config = JSON.parse(decrypt(channel.config)) as {
|
||||
url: string;
|
||||
headerName?: string;
|
||||
headerValue?: string;
|
||||
};
|
||||
|
||||
annotate({ action: { name: "settings.webhook.get" } });
|
||||
|
||||
return apiSuccess({
|
||||
enabled: channel.enabled,
|
||||
url: config.url,
|
||||
headerName: config.headerName ?? "",
|
||||
hasHeaderValue: !!config.headerValue,
|
||||
});
|
||||
});
|
||||
|
||||
export const PUT = apiHandler(async (request: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const { data: body, error: jsonError } = await safeJson(request, {
|
||||
maxBytes: 64 * 1024,
|
||||
});
|
||||
|
||||
if (jsonError) return jsonError;
|
||||
const parsed = webhookSettingsSchema.safeParse(body);
|
||||
if (!parsed.success) return apiError("Invalid data", 422);
|
||||
|
||||
const { url, headerName, headerValue, enabled } = parsed.data;
|
||||
|
||||
if (enabled && !url) {
|
||||
return apiError("Webhook URL is required when the webhook is enabled", 422);
|
||||
}
|
||||
|
||||
// Preserve an existing header value when the client sends an empty one (the
|
||||
// GET path never returns the secret, so a save round-trip would otherwise
|
||||
// wipe it). A non-empty value replaces it.
|
||||
let nextHeaderValue = headerValue || undefined;
|
||||
if (!nextHeaderValue) {
|
||||
const existing = await prisma.notificationChannel.findUnique({
|
||||
where: { userId_type: { userId: user.id, type: "WEBHOOK" } },
|
||||
});
|
||||
if (existing) {
|
||||
const prev = JSON.parse(decrypt(existing.config)) as {
|
||||
headerValue?: string;
|
||||
};
|
||||
nextHeaderValue = prev.headerValue || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const config = JSON.stringify({
|
||||
url: url || "",
|
||||
...(headerName ? { headerName } : {}),
|
||||
...(nextHeaderValue ? { headerValue: nextHeaderValue } : {}),
|
||||
});
|
||||
|
||||
const encryptedConfig = encrypt(config);
|
||||
|
||||
await prisma.notificationChannel.upsert({
|
||||
where: { userId_type: { userId: user.id, type: "WEBHOOK" } },
|
||||
create: {
|
||||
userId: user.id,
|
||||
type: "WEBHOOK",
|
||||
enabled,
|
||||
config: encryptedConfig,
|
||||
},
|
||||
update: { enabled, config: encryptedConfig },
|
||||
});
|
||||
|
||||
annotate({ action: { name: "settings.webhook.update" }, meta: { enabled } });
|
||||
|
||||
return apiSuccess({ saved: true });
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { prisma } from "@/lib/db";
|
||||
import { apiSuccess, apiError } from "@/lib/api-response";
|
||||
import { decrypt } from "@/lib/crypto";
|
||||
import { sendViaWebhook } from "@/lib/notifications/senders/webhook";
|
||||
import type { WebhookChannelConfig } from "@/lib/notifications/types";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
|
||||
/**
|
||||
* POST: send a test notification via the configured generic webhook.
|
||||
*/
|
||||
export const POST = apiHandler(async () => {
|
||||
const { user } = await requireAuth();
|
||||
|
||||
const rl = await checkRateLimit(`webhook-test:${user.id}`, 5, 5 * 60 * 1000);
|
||||
if (!rl.allowed) {
|
||||
return apiError("Maximum 5 tests in 5 minutes", 429);
|
||||
}
|
||||
|
||||
const channel = await prisma.notificationChannel.findUnique({
|
||||
where: { userId_type: { userId: user.id, type: "WEBHOOK" } },
|
||||
});
|
||||
|
||||
if (!channel) {
|
||||
return apiError("Webhook is not configured", 400);
|
||||
}
|
||||
|
||||
const config = JSON.parse(decrypt(channel.config)) as WebhookChannelConfig;
|
||||
|
||||
if (!config.url) {
|
||||
return apiError("Webhook URL is required", 400);
|
||||
}
|
||||
|
||||
const result = await sendViaWebhook(config, {
|
||||
eventType: "SYSTEM_ALERT",
|
||||
userId: user.id,
|
||||
title: "HealthLog Test",
|
||||
message: "HealthLog: Connection successful! Webhook notifications are active.",
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
return apiError("Failed to send test message", 500);
|
||||
}
|
||||
|
||||
annotate({
|
||||
action: { name: "settings.webhook.test" },
|
||||
meta: { success: true },
|
||||
});
|
||||
|
||||
return apiSuccess({ sent: true });
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader2, Webhook } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { PasswordInput } from "@/components/ui/password-input";
|
||||
import { SettingsCardHeader } from "@/components/settings/_card-header";
|
||||
import { TestConnectionButton } from "@/components/settings/test-connection-button";
|
||||
import { useTranslations } from "@/lib/i18n/context";
|
||||
import { queryKeys } from "@/lib/query-keys";
|
||||
import { apiFetchRaw, apiGet } from "@/lib/api/api-fetch";
|
||||
|
||||
interface WebhookSettings {
|
||||
enabled: boolean;
|
||||
url: string;
|
||||
headerName: string;
|
||||
hasHeaderValue: boolean;
|
||||
}
|
||||
|
||||
export function WebhookCard({ isAuthenticated }: { isAuthenticated: boolean }) {
|
||||
const { t } = useTranslations();
|
||||
const queryClient = useQueryClient();
|
||||
const [url, setUrl] = useState("");
|
||||
const [headerName, setHeaderName] = useState("");
|
||||
const [headerValue, setHeaderValue] = useState("");
|
||||
const [saveMsg, setSaveMsg] = useState<string | null>(null);
|
||||
const [saveMsgType, setSaveMsgType] = useState<"success" | "error" | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: queryKeys.settingsWebhook(),
|
||||
queryFn: async () => {
|
||||
return apiGet<WebhookSettings>("/api/settings/webhook");
|
||||
},
|
||||
enabled: isAuthenticated,
|
||||
});
|
||||
|
||||
// React-recommended sync-from-server pattern (no setState-in-effect).
|
||||
const settingsKey = settings ? `${settings.url}|${settings.headerName}` : null;
|
||||
const [seededKey, setSeededKey] = useState<string | null>(null);
|
||||
if (settingsKey && settingsKey !== seededKey) {
|
||||
setSeededKey(settingsKey);
|
||||
setUrl(settings!.url);
|
||||
setHeaderName(settings!.headerName);
|
||||
}
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async (enabled: boolean) => {
|
||||
const res = await apiFetchRaw("/api/settings/webhook", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
headerName: headerName || undefined,
|
||||
headerValue: headerValue || undefined,
|
||||
enabled,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = await res.json();
|
||||
throw new Error(json.error || t("common.error"));
|
||||
}
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.settingsWebhook() });
|
||||
setSaveMsg(t("settings.saved"));
|
||||
setSaveMsgType("success");
|
||||
setHeaderValue("");
|
||||
},
|
||||
onError: (err: Error) => {
|
||||
setSaveMsg(err.message);
|
||||
setSaveMsgType("error");
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="bg-card border-border rounded-xl border p-4 sm:p-6">
|
||||
<SettingsCardHeader
|
||||
icon={Webhook}
|
||||
title={t("settings.webhook")}
|
||||
description={t("settings.webhookDescription")}
|
||||
/>
|
||||
|
||||
<div className="mt-4 space-y-4 pl-7">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="webhook-toggle">{t("settings.webhookEnable")}</Label>
|
||||
<Switch
|
||||
id="webhook-toggle"
|
||||
checked={settings?.enabled ?? false}
|
||||
onCheckedChange={(checked) => save.mutate(checked)}
|
||||
disabled={save.isPending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
save.mutate(settings?.enabled ?? false);
|
||||
}}
|
||||
className="space-y-4"
|
||||
>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div className="space-y-2 sm:col-span-2">
|
||||
<Label htmlFor="webhook-url">{t("settings.webhookUrl")}</Label>
|
||||
<Input
|
||||
id="webhook-url"
|
||||
placeholder="https://gotify.example.com/message?token=..."
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{t("settings.webhookUrlHint")}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="webhook-header-name">
|
||||
{t("settings.webhookHeaderName")}
|
||||
</Label>
|
||||
<Input
|
||||
id="webhook-header-name"
|
||||
placeholder="Authorization"
|
||||
value={headerName}
|
||||
onChange={(e) => setHeaderName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="webhook-header-value">
|
||||
{t("settings.webhookHeaderValue")}
|
||||
</Label>
|
||||
<PasswordInput
|
||||
id="webhook-header-value"
|
||||
placeholder={
|
||||
settings?.hasHeaderValue
|
||||
? t("settings.withingsCredentialsSavedPlaceholder")
|
||||
: t("common.optional")
|
||||
}
|
||||
value={headerValue}
|
||||
onChange={(e) => setHeaderValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{saveMsg && (
|
||||
<p
|
||||
role="alert"
|
||||
className={`text-sm ${saveMsgType === "success" ? "text-success" : "text-destructive"}`}
|
||||
>
|
||||
{saveMsg}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-start justify-end gap-2">
|
||||
<TestConnectionButton
|
||||
endpoint="/api/settings/webhook/test"
|
||||
disabled={!settings?.enabled}
|
||||
/>
|
||||
<Button type="submit" disabled={save.isPending} className="min-h-11">
|
||||
{save.isPending && (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin motion-reduce:animate-none" />
|
||||
)}
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Generic-webhook sender unit tests (v1.17.1).
|
||||
*
|
||||
* Covers: success, hard-reject classification (404/410/401/403), SSRF block
|
||||
* (safeFetch throws `private_host`), cooldown-relevant transient classification
|
||||
* (5xx), and the push_attempts ledger write per outcome.
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const safeFetchMock = vi.fn();
|
||||
const recordPushAttemptMock = vi.fn();
|
||||
|
||||
vi.mock("@/lib/safe-fetch", () => ({
|
||||
safeFetch: (...args: unknown[]) => safeFetchMock(...args),
|
||||
SafeFetchError: class SafeFetchError extends Error {
|
||||
kind: string;
|
||||
constructor(message: string, kind: string) {
|
||||
super(message);
|
||||
this.kind = kind;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
getEvent: () => ({ addWarning: vi.fn(), addExternalCall: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/notifications/senders/push-attempt-record", () => ({
|
||||
recordPushAttempt: (...args: unknown[]) => recordPushAttemptMock(...args),
|
||||
}));
|
||||
|
||||
import { sendViaWebhook } from "../webhook";
|
||||
import { SafeFetchError } from "@/lib/safe-fetch";
|
||||
|
||||
const config = {
|
||||
url: "https://gotify.example.com/message",
|
||||
headerName: "Authorization",
|
||||
headerValue: "Bearer secret",
|
||||
};
|
||||
|
||||
function payload(over?: Record<string, unknown>) {
|
||||
return {
|
||||
eventType: "SYSTEM_ALERT" as const,
|
||||
userId: "user-1",
|
||||
title: "Title",
|
||||
message: "Body",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
safeFetchMock.mockReset();
|
||||
recordPushAttemptMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe("sendViaWebhook", () => {
|
||||
it("POSTs through safeFetch with requirePublicHost and returns ok on 200", async () => {
|
||||
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
|
||||
const result = await sendViaWebhook(config, payload());
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.statusCode).toBe(200);
|
||||
// SSRF floor + DNS-rebinding pin: requirePublicHost must be set.
|
||||
const [, init, opts] = safeFetchMock.mock.calls[0];
|
||||
expect((opts as { requirePublicHost?: boolean }).requirePublicHost).toBe(
|
||||
true,
|
||||
);
|
||||
expect((init as { method?: string }).method).toBe("POST");
|
||||
// Custom header is attached.
|
||||
expect(
|
||||
(init as { headers?: Record<string, string> }).headers?.Authorization,
|
||||
).toBe("Bearer secret");
|
||||
expect(recordPushAttemptMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ channel: "WEBHOOK", result: "ok" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("hard-rejects on 410 (endpoint gone)", async () => {
|
||||
safeFetchMock.mockResolvedValue({ ok: false, status: 410 });
|
||||
|
||||
const result = await sendViaWebhook(config, payload());
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.hardReject).toBe(true);
|
||||
expect(result.reason).toBe("webhook_410_gone");
|
||||
expect(recordPushAttemptMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ channel: "WEBHOOK", result: "error" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("hard-rejects on 401/403 (shared secret wrong)", async () => {
|
||||
safeFetchMock.mockResolvedValue({ ok: false, status: 403 });
|
||||
const result = await sendViaWebhook(config, payload());
|
||||
expect(result.hardReject).toBe(true);
|
||||
expect(result.reason).toBe("webhook_auth_rejected");
|
||||
});
|
||||
|
||||
it("soft-fails on 503 (transient — eligible for backoff)", async () => {
|
||||
safeFetchMock.mockResolvedValue({ ok: false, status: 503 });
|
||||
|
||||
const result = await sendViaWebhook(config, payload());
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.hardReject).toBe(false);
|
||||
expect(result.reason).toBe("webhook_503");
|
||||
});
|
||||
|
||||
it("returns a transient network error when safeFetch blocks a private host (SSRF)", async () => {
|
||||
safeFetchMock.mockRejectedValue(
|
||||
new SafeFetchError("refused private host", "private_host"),
|
||||
);
|
||||
|
||||
const result = await sendViaWebhook(config, payload());
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.hardReject).toBe(false);
|
||||
expect(result.reason).toBe("webhook_network_error");
|
||||
expect(recordPushAttemptMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "WEBHOOK",
|
||||
result: "error",
|
||||
reason: "webhook_network_error",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits the custom header when not configured", async () => {
|
||||
safeFetchMock.mockResolvedValue({ ok: true, status: 200 });
|
||||
await sendViaWebhook({ url: "https://example.com/hook" }, payload());
|
||||
const [, init] = safeFetchMock.mock.calls[0];
|
||||
expect(
|
||||
(init as { headers?: Record<string, string> }).headers?.Authorization,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import type {
|
||||
WebhookChannelConfig,
|
||||
NotificationPayload,
|
||||
} from "@/lib/notifications/types";
|
||||
import type { SendOutcome } from "@/lib/notifications/retry-policy";
|
||||
import { classifyHttpStatus } from "@/lib/notifications/retry-policy";
|
||||
import { getEvent } from "@/lib/logging/context";
|
||||
import { recordPushAttempt } from "@/lib/notifications/senders/push-attempt-record";
|
||||
import { safeFetch } from "@/lib/safe-fetch";
|
||||
import { plainPushText } from "@/lib/notifications/strip-emoji";
|
||||
|
||||
/**
|
||||
* Send a notification via a generic outbound webhook (v1.17.1).
|
||||
*
|
||||
* The user supplies a public URL (and optionally one custom header — e.g.
|
||||
* `Authorization: Bearer <token>` for Gotify). We POST a small JSON envelope
|
||||
* that Gotify / Discord / Slack / Matrix-bridge / Home Assistant / any homelab
|
||||
* relay can consume. The body is plain text (no markdown — hard rule); the
|
||||
* `title`/`message` fields are stripped of HTML + decorative emoji on routine
|
||||
* reminders exactly like the ntfy sender.
|
||||
*
|
||||
* Outbound goes through `safeFetch({ requirePublicHost: true })`: the SSRF
|
||||
* floor plus the connect-time DNS-rebinding pin apply because the host is
|
||||
* user-supplied. The optional shared-secret header would otherwise leak on a
|
||||
* rebinding flip to a private address.
|
||||
*
|
||||
* Returns a `SendOutcome` so the dispatcher can distinguish hard rejects
|
||||
* (404/410 endpoint gone, 401/403 secret wrong) from soft errors (5xx, 429,
|
||||
* network timeout) and apply the right retry / auto-disable policy.
|
||||
*/
|
||||
export async function sendViaWebhook(
|
||||
config: WebhookChannelConfig,
|
||||
payload: NotificationPayload,
|
||||
): Promise<SendOutcome> {
|
||||
const start = performance.now();
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
if (config.headerName && config.headerValue) {
|
||||
headers[config.headerName] = config.headerValue;
|
||||
}
|
||||
|
||||
// Plain-text envelope. The `title` field doubles as Gotify's `title` and
|
||||
// Discord/Slack ignore unknown keys, so a single shape covers the common
|
||||
// relays. Discreet mode (cycle privacy) is honoured by the title/body
|
||||
// already being masked upstream; we also send a generic `eventType` tag so
|
||||
// a relay rule can route without leaking the cycle event name.
|
||||
const body = JSON.stringify({
|
||||
title: plainPushText(payload.title, payload.eventType),
|
||||
message: plainPushText(
|
||||
payload.message.replace(/<[^>]*>/g, ""),
|
||||
payload.eventType,
|
||||
),
|
||||
eventType: payload.discreet ? "reminder" : payload.eventType,
|
||||
priority:
|
||||
payload.eventType === "MEDICATION_REMINDER" ? "high" : "default",
|
||||
});
|
||||
|
||||
const res = await safeFetch(
|
||||
config.url,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
body,
|
||||
},
|
||||
{ timeoutMs: 5_000, requirePublicHost: true },
|
||||
);
|
||||
|
||||
getEvent()?.addExternalCall({
|
||||
service: "webhook",
|
||||
method: "sendNotification",
|
||||
duration_ms: Math.round(performance.now() - start),
|
||||
status: res.status,
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
recordPushAttempt({
|
||||
userId: payload.userId,
|
||||
channel: "WEBHOOK",
|
||||
eventType: payload.eventType,
|
||||
result: "ok",
|
||||
});
|
||||
return { ok: true, statusCode: res.status };
|
||||
}
|
||||
const classified = classifyHttpStatus(res.status, "webhook");
|
||||
recordPushAttempt({
|
||||
userId: payload.userId,
|
||||
channel: "WEBHOOK",
|
||||
eventType: payload.eventType,
|
||||
result: "error",
|
||||
reason: classified.reason,
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
statusCode: res.status,
|
||||
hardReject: classified.hardReject,
|
||||
reason: classified.reason,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "request_failed";
|
||||
getEvent()?.addExternalCall({
|
||||
service: "webhook",
|
||||
method: "sendNotification",
|
||||
duration_ms: Math.round(performance.now() - start),
|
||||
error: message,
|
||||
});
|
||||
recordPushAttempt({
|
||||
userId: payload.userId,
|
||||
channel: "WEBHOOK",
|
||||
eventType: payload.eventType,
|
||||
result: "error",
|
||||
reason: "webhook_network_error",
|
||||
});
|
||||
return {
|
||||
ok: false,
|
||||
hardReject: false,
|
||||
reason: "webhook_network_error",
|
||||
message,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user