fix(notifications): persist delivery health status (#850)

* fix(notifications): persist delivery health status

* fix: pr feedback double update
This commit is contained in:
Nico 2026-05-02 11:51:16 +02:00 committed by GitHub
parent 3d5a0a9b75
commit 43a481d52c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 2697 additions and 161 deletions

View file

@ -76,171 +76,154 @@ export const createClient = (config: Config = {}): Client => {
}; };
const request: Client['request'] = async (options) => { const request: Client['request'] = async (options) => {
const { opts, url } = await beforeRequest(options); const throwOnError = options.throwOnError ?? _config.throwOnError;
const requestInit: ReqInit = { const responseStyle = options.responseStyle ?? _config.responseStyle;
redirect: 'follow',
...opts,
body: getValidRequestBody(opts),
};
let request = new Request(url, requestInit); let request: Request | undefined;
let response: Response | undefined;
for (const fn of interceptors.request.fns) {
if (fn) {
request = await fn(request, opts);
}
}
// fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = opts.fetch!;
let response: Response;
try { try {
response = await _fetch(request); const { opts, url } = await beforeRequest(options);
} catch (error) { const requestInit: ReqInit = {
// Handle fetch exceptions (AbortError, network errors, etc.) redirect: 'follow',
let finalError = error; ...opts,
body: getValidRequestBody(opts),
};
for (const fn of interceptors.error.fns) { request = new Request(url, requestInit);
for (const fn of interceptors.request.fns) {
if (fn) { if (fn) {
finalError = (await fn(error, undefined as any, request, opts)) as unknown; request = await fn(request, opts);
} }
} }
finalError = finalError || ({} as unknown); // fetch must be assigned here, otherwise it would throw the error:
// TypeError: Failed to execute 'fetch' on 'Window': Illegal invocation
const _fetch = opts.fetch!;
if (opts.throwOnError) { response = await _fetch(request);
throw finalError;
for (const fn of interceptors.response.fns) {
if (fn) {
response = await fn(response, request, opts);
}
} }
// Return error response const result = {
return opts.responseStyle === 'data' request,
? undefined response,
: { };
error: finalError,
request,
response: undefined as any,
};
}
for (const fn of interceptors.response.fns) { if (response.ok) {
if (fn) { const parseAs =
response = await fn(response, request, opts); (opts.parseAs === 'auto'
} ? getParseAs(response.headers.get('Content-Type'))
} : opts.parseAs) ?? 'json';
const result = { if (response.status === 204 || response.headers.get('Content-Length') === '0') {
request, let emptyData: any;
response, switch (parseAs) {
}; case 'arrayBuffer':
case 'blob':
case 'text':
emptyData = await response[parseAs]();
break;
case 'formData':
emptyData = new FormData();
break;
case 'stream':
emptyData = response.body;
break;
case 'json':
default:
emptyData = {};
break;
}
return opts.responseStyle === 'data'
? emptyData
: {
data: emptyData,
...result,
};
}
if (response.ok) { let data: any;
const parseAs =
(opts.parseAs === 'auto'
? getParseAs(response.headers.get('Content-Type'))
: opts.parseAs) ?? 'json';
if (response.status === 204 || response.headers.get('Content-Length') === '0') {
let emptyData: any;
switch (parseAs) { switch (parseAs) {
case 'arrayBuffer': case 'arrayBuffer':
case 'blob': case 'blob':
case 'text':
emptyData = await response[parseAs]();
break;
case 'formData': case 'formData':
emptyData = new FormData(); case 'text':
data = await response[parseAs]();
break; break;
case 'json': {
// Some servers return 200 with no Content-Length and empty body.
// response.json() would throw; read as text and parse if non-empty.
const text = await response.text();
data = text ? JSON.parse(text) : {};
break;
}
case 'stream': case 'stream':
emptyData = response.body; return opts.responseStyle === 'data'
break; ? response.body
case 'json': : {
default: data: response.body,
emptyData = {}; ...result,
break; };
} }
if (parseAs === 'json') {
if (opts.responseValidator) {
await opts.responseValidator(data);
}
if (opts.responseTransformer) {
data = await opts.responseTransformer(data);
}
}
return opts.responseStyle === 'data' return opts.responseStyle === 'data'
? emptyData ? data
: { : {
data: emptyData, data,
...result, ...result,
}; };
} }
let data: any; const textError = await response.text();
switch (parseAs) { let jsonError: unknown;
case 'arrayBuffer':
case 'blob': try {
case 'formData': jsonError = JSON.parse(textError);
case 'text': } catch {
data = await response[parseAs](); // noop
break;
case 'json': {
// Some servers return 200 with no Content-Length and empty body.
// response.json() would throw; read as text and parse if non-empty.
const text = await response.text();
data = text ? JSON.parse(text) : {};
break;
}
case 'stream':
return opts.responseStyle === 'data'
? response.body
: {
data: response.body,
...result,
};
} }
if (parseAs === 'json') { throw jsonError ?? textError;
if (opts.responseValidator) { } catch (error) {
await opts.responseValidator(data); let finalError = error;
}
if (opts.responseTransformer) { for (const fn of interceptors.error.fns) {
data = await opts.responseTransformer(data); if (fn) {
finalError = await fn(finalError, response, request, options as ResolvedRequestOptions);
} }
} }
return opts.responseStyle === 'data' finalError = finalError || {};
? data
if (throwOnError) {
throw finalError;
}
// TODO: we probably want to return error and improve types
return responseStyle === 'data'
? undefined
: { : {
data, error: finalError,
...result, request,
response,
}; };
} }
const textError = await response.text();
let jsonError: unknown;
try {
jsonError = JSON.parse(textError);
} catch {
// noop
}
const error = jsonError ?? textError;
let finalError = error;
for (const fn of interceptors.error.fns) {
if (fn) {
finalError = (await fn(error, response, request, opts)) as string;
}
}
finalError = finalError || ({} as string);
if (opts.throwOnError) {
throw finalError;
}
// TODO: we probably want to return error and improve types
return opts.responseStyle === 'data'
? undefined
: {
error: finalError,
...result,
};
}; };
const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) => const makeMethodFn = (method: Uppercase<HttpMethod>) => (options: RequestOptions) =>
@ -251,7 +234,6 @@ export const createClient = (config: Config = {}): Client => {
return createSseClient({ return createSseClient({
...opts, ...opts,
body: opts.body as BodyInit | null | undefined, body: opts.body as BodyInit | null | undefined,
headers: opts.headers as unknown as Record<string, string>,
method, method,
onRequest: async (url, init) => { onRequest: async (url, init) => {
let request = new Request(url, init); let request = new Request(url, init);

View file

@ -94,6 +94,7 @@ export interface ResolvedRequestOptions<
ThrowOnError extends boolean = boolean, ThrowOnError extends boolean = boolean,
Url extends string = string, Url extends string = string,
> extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> { > extends RequestOptions<unknown, TResponseStyle, ThrowOnError, Url> {
headers: Headers;
serializedBody?: string; serializedBody?: string;
} }
@ -127,8 +128,10 @@ export type RequestResult<
error: TError extends Record<string, unknown> ? TError[keyof TError] : TError; error: TError extends Record<string, unknown> ? TError[keyof TError] : TError;
} }
) & { ) & {
request: Request; /** request may be undefined, because error may be from building the request object itself */
response: Response; request?: Request;
/** response may be undefined, because error may be from building the request object itself or from a network error */
response?: Response;
} }
>; >;

View file

@ -219,8 +219,10 @@ export const mergeHeaders = (
type ErrInterceptor<Err, Res, Req, Options> = ( type ErrInterceptor<Err, Res, Req, Options> = (
error: Err, error: Err,
response: Res, /** response may be undefined due to a network error where no response object is produced */
request: Req, response: Res | undefined,
/** request may be undefined, because error may be from building the request object itself */
request: Req | undefined,
options: Options, options: Options,
) => Err | Promise<Err>; ) => Err | Promise<Err>;

View file

@ -3610,6 +3610,9 @@ export type GetScheduleNotificationsResponses = {
id: number; id: number;
name: string; name: string;
enabled: boolean; enabled: boolean;
status: 'healthy' | 'error' | 'unknown';
lastChecked: number | null;
lastError: string | null;
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom'; type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
config: { config: {
type: 'email'; type: 'email';
@ -3711,6 +3714,9 @@ export type UpdateScheduleNotificationsResponses = {
id: number; id: number;
name: string; name: string;
enabled: boolean; enabled: boolean;
status: 'healthy' | 'error' | 'unknown';
lastChecked: number | null;
lastError: string | null;
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom'; type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
config: { config: {
type: 'email'; type: 'email';
@ -4342,6 +4348,9 @@ export type ListNotificationDestinationsResponses = {
id: number; id: number;
name: string; name: string;
enabled: boolean; enabled: boolean;
status: 'healthy' | 'error' | 'unknown';
lastChecked: number | null;
lastError: string | null;
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom'; type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
config: { config: {
type: 'email'; type: 'email';
@ -4485,6 +4494,9 @@ export type CreateNotificationDestinationResponses = {
id: number; id: number;
name: string; name: string;
enabled: boolean; enabled: boolean;
status: 'healthy' | 'error' | 'unknown';
lastChecked: number | null;
lastError: string | null;
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom'; type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
config: { config: {
type: 'email'; type: 'email';
@ -4603,6 +4615,9 @@ export type GetNotificationDestinationResponses = {
id: number; id: number;
name: string; name: string;
enabled: boolean; enabled: boolean;
status: 'healthy' | 'error' | 'unknown';
lastChecked: number | null;
lastError: string | null;
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom'; type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
config: { config: {
type: 'email'; type: 'email';
@ -4756,6 +4771,9 @@ export type UpdateNotificationDestinationResponses = {
id: number; id: number;
name: string; name: string;
enabled: boolean; enabled: boolean;
status: 'healthy' | 'error' | 'unknown';
lastChecked: number | null;
lastError: string | null;
type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom'; type: 'email' | 'slack' | 'discord' | 'gotify' | 'ntfy' | 'pushover' | 'telegram' | 'generic' | 'custom';
config: { config: {
type: 'email'; type: 'email';

View file

@ -28,6 +28,7 @@ const invalidatingEvents = new Set<ServerEventType>([
"backup:completed", "backup:completed",
"volume:updated", "volume:updated",
"volume:status_changed", "volume:status_changed",
"notification:updated",
"mirror:completed", "mirror:completed",
"doctor:started", "doctor:started",
"doctor:completed", "doctor:completed",

View file

@ -40,6 +40,7 @@ import {
Lock, Lock,
Mail, Mail,
AtSign, AtSign,
AlertCircle,
MessageSquare, MessageSquare,
Pencil, Pencil,
Server, Server,
@ -55,6 +56,7 @@ import type { GetNotificationDestinationResponse } from "~/client/api-client/typ
import { useTimeFormat } from "~/client/lib/datetime"; import { useTimeFormat } from "~/client/lib/datetime";
type NotificationConfig = GetNotificationDestinationResponse["config"]; type NotificationConfig = GetNotificationDestinationResponse["config"];
type NotificationStatus = GetNotificationDestinationResponse["status"];
type Props = { type Props = {
icon: React.ReactNode; icon: React.ReactNode;
label: string; label: string;
@ -72,6 +74,17 @@ function ConfigRow({ icon, label, value, mono }: Props) {
); );
} }
function getStatusLabel(enabled: boolean, status: NotificationStatus) {
return enabled ? status : "disabled";
}
function getStatusClass(enabled: boolean, status: NotificationStatus) {
if (!enabled) return "bg-gray-500";
if (status === "healthy") return "bg-success";
if (status === "error") return "bg-red-500";
return "bg-yellow-500";
}
function NotificationConfigRows({ config }: { config: NotificationConfig }) { function NotificationConfigRows({ config }: { config: NotificationConfig }) {
switch (config.type) { switch (config.type) {
case "slack": case "slack":
@ -228,13 +241,8 @@ export function NotificationDetailsPage({ notificationId }: { notificationId: st
<h2 className="text-lg font-semibold tracking-tight">{data.name}</h2> <h2 className="text-lg font-semibold tracking-tight">{data.name}</h2>
<Separator orientation="vertical" className="h-4 mx-1" /> <Separator orientation="vertical" className="h-4 mx-1" />
<Badge variant="outline" className="capitalize gap-1.5"> <Badge variant="outline" className="capitalize gap-1.5">
<span <span className={cn("w-2 h-2 rounded-full shrink-0", getStatusClass(data.enabled, data.status))} />
className={cn("w-2 h-2 rounded-full shrink-0", { {getStatusLabel(data.enabled, data.status)}
"bg-success": data.enabled,
"bg-red-500": !data.enabled,
})}
/>
{data.enabled ? "Enabled" : "Disabled"}
</Badge> </Badge>
<Badge variant="secondary" className="capitalize"> <Badge variant="secondary" className="capitalize">
{data.type} {data.type}
@ -289,6 +297,14 @@ export function NotificationDetailsPage({ notificationId }: { notificationId: st
<NotificationConfigRows config={data.config} /> <NotificationConfigRows config={data.config} />
</div> </div>
</Card> </Card>
<Card className={cn("px-6 py-6", { hidden: !data.lastError })}>
<CardTitle className="flex items-center gap-2 mb-3 text-red-600">
<AlertCircle className="h-4 w-4" />
Last Delivery Error
</CardTitle>
<p className="text-sm text-muted-foreground wrap-break-word">{data.lastError}</p>
</Card>
</div> </div>
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}> <AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>

View file

@ -28,6 +28,15 @@ type NotificationRow = {
name: string; name: string;
type: "email" | "slack" | "discord" | "gotify" | "ntfy" | "pushover" | "telegram" | "custom" | "generic"; type: "email" | "slack" | "discord" | "gotify" | "ntfy" | "pushover" | "telegram" | "custom" | "generic";
enabled: boolean; enabled: boolean;
status: "healthy" | "error" | "unknown";
};
const getNotificationStatus = (row: NotificationRow) => (row.enabled ? row.status : "disabled");
const getNotificationStatusVariant = (row: NotificationRow) => {
if (!row.enabled) return "neutral";
if (row.status === "healthy") return "success";
if (row.status === "error") return "error";
return "warning";
}; };
const notificationColumns: ColumnDef<NotificationRow>[] = [ const notificationColumns: ColumnDef<NotificationRow>[] = [
@ -43,16 +52,13 @@ const notificationColumns: ColumnDef<NotificationRow>[] = [
filterFn: (row, id, value) => row.getValue(id) === value, filterFn: (row, id, value) => row.getValue(id) === value,
}, },
{ {
accessorFn: (row) => (row.enabled ? "enabled" : "disabled"), accessorFn: getNotificationStatus,
id: "status", id: "status",
header: ({ column }) => ( header: ({ column }) => (
<DataTableSortHeader column={column} title="Status" sortDirection={column.getIsSorted()} center /> <DataTableSortHeader column={column} title="Status" sortDirection={column.getIsSorted()} center />
), ),
cell: ({ row }) => ( cell: ({ row }) => (
<StatusDot <StatusDot variant={getNotificationStatusVariant(row.original)} label={getNotificationStatus(row.original)} />
variant={row.original.enabled ? "success" : "neutral"}
label={row.original.enabled ? "Enabled" : "Disabled"}
/>
), ),
filterFn: (row, id, value) => row.getValue(id) === value, filterFn: (row, id, value) => row.getValue(id) === value,
}, },
@ -138,7 +144,9 @@ export function NotificationsPage() {
<SelectValue placeholder="All status" /> <SelectValue placeholder="All status" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="enabled">Enabled</SelectItem> <SelectItem value="healthy">Healthy</SelectItem>
<SelectItem value="error">Error</SelectItem>
<SelectItem value="unknown">Unknown</SelectItem>
<SelectItem value="disabled">Disabled</SelectItem> <SelectItem value="disabled">Disabled</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>

View file

@ -0,0 +1,3 @@
ALTER TABLE `notification_destinations_table` ADD `status` text DEFAULT 'unknown' NOT NULL;--> statement-breakpoint
ALTER TABLE `notification_destinations_table` ADD `last_checked` integer;--> statement-breakpoint
ALTER TABLE `notification_destinations_table` ADD `last_error` text;

File diff suppressed because it is too large Load diff

View file

@ -41,6 +41,12 @@ const serverEventPayloads = {
"volume:unmounted": payload<{ organizationId: string; volumeName: string }>(), "volume:unmounted": payload<{ organizationId: string; volumeName: string }>(),
"volume:updated": payload<{ organizationId: string; volumeName: string }>(), "volume:updated": payload<{ organizationId: string; volumeName: string }>(),
"volume:status_changed": payload<{ organizationId: string; volumeName: string; status: string }>(), "volume:status_changed": payload<{ organizationId: string; volumeName: string; status: string }>(),
"notification:updated": payload<{
organizationId: string;
notificationId: number;
notificationName: string;
status: "healthy" | "error" | "unknown";
}>(),
"doctor:started": payload<{ organizationId: string; repositoryId: string; repositoryName: string }>(), "doctor:started": payload<{ organizationId: string; repositoryId: string; repositoryName: string }>(),
"doctor:completed": payload< "doctor:completed": payload<
{ {

View file

@ -337,6 +337,9 @@ export const notificationDestinationsTable = sqliteTable("notification_destinati
id: int().primaryKey({ autoIncrement: true }), id: int().primaryKey({ autoIncrement: true }),
name: text().notNull(), name: text().notNull(),
enabled: int("enabled", { mode: "boolean" }).notNull().default(true), enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
status: text().$type<"healthy" | "error" | "unknown">().notNull().default("unknown"),
lastChecked: int("last_checked", { mode: "number" }),
lastError: text("last_error"),
type: text().$type<NotificationType>().notNull(), type: text().$type<NotificationType>().notNull(),
config: text("config", { mode: "json" }).$type<NotificationConfig>().notNull(), config: text("config", { mode: "json" }).$type<NotificationConfig>().notNull(),
createdAt: int("created_at", { mode: "number" }) createdAt: int("created_at", { mode: "number" })

View file

@ -20,6 +20,7 @@ const broadcastEvents = [
"volume:mounted", "volume:mounted",
"volume:unmounted", "volume:unmounted",
"volume:updated", "volume:updated",
"notification:updated",
"mirror:started", "mirror:started",
"mirror:completed", "mirror:completed",
"restore:started", "restore:started",

View file

@ -0,0 +1,86 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { db } from "~/server/db/db";
import { notificationDestinationsTable } from "~/server/db/schema";
import { withContext } from "~/server/core/request-context";
import { createTestSession } from "~/test/helpers/auth";
import * as shoutrrr from "~/server/utils/shoutrrr";
import { notificationsService } from "../notifications.service";
import { serverEvents } from "~/server/core/events";
afterEach(() => {
vi.restoreAllMocks();
});
describe("notificationsService.testDestination", () => {
test("marks the destination as error when delivery fails", async () => {
const { organizationId, user } = await createTestSession();
await withContext({ organizationId, userId: user.id }, async () => {
const destination = await notificationsService.createDestination("Broken webhook", {
type: "custom",
shoutrrrUrl: "discord://token@webhookid",
});
vi.spyOn(shoutrrr, "sendNotification").mockResolvedValue({ success: false, error: "webhook rejected" });
const event = new Promise<Parameters<typeof serverEvents.emit>[1]>((resolve) => {
serverEvents.once("notification:updated", resolve);
});
await expect(notificationsService.testDestination(destination.id)).rejects.toThrow("webhook rejected");
await expect(event).resolves.toEqual(
expect.objectContaining({
organizationId,
notificationId: destination.id,
notificationName: "Broken webhook",
status: "error",
}),
);
const updated = await db.query.notificationDestinationsTable.findFirst({
where: { id: destination.id },
});
expect(updated).toEqual(
expect.objectContaining({
status: "error",
lastChecked: expect.any(Number),
lastError: expect.stringContaining("webhook rejected"),
}),
);
});
});
test("marks the destination as healthy when delivery succeeds", async () => {
const { organizationId, user } = await createTestSession();
await withContext({ organizationId, userId: user.id }, async () => {
const [destination] = await db
.insert(notificationDestinationsTable)
.values({
name: "Recovered webhook",
type: "custom",
config: { type: "custom", shoutrrrUrl: "discord://token@webhookid" },
organizationId,
status: "error",
lastError: "previous failure",
})
.returning();
vi.spyOn(shoutrrr, "sendNotification").mockResolvedValue({ success: true });
await expect(notificationsService.testDestination(destination.id)).resolves.toEqual({ success: true });
const updated = await db.query.notificationDestinationsTable.findFirst({
where: { id: destination.id },
});
expect(updated).toEqual(
expect.objectContaining({
status: "healthy",
lastChecked: expect.any(Number),
lastError: null,
}),
);
});
});
});

View file

@ -6,6 +6,9 @@ const notificationDestinationSchema = z.object({
id: z.number(), id: z.number(),
name: z.string(), name: z.string(),
enabled: z.boolean(), enabled: z.boolean(),
status: z.enum(["healthy", "error", "unknown"]),
lastChecked: z.number().nullable(),
lastError: z.string().nullable(),
type: z.enum(NOTIFICATION_TYPES), type: z.enum(NOTIFICATION_TYPES),
config: notificationConfigSchema, config: notificationConfigSchema,
createdAt: z.number(), createdAt: z.number(),

View file

@ -18,6 +18,7 @@ import { config } from "~/server/core/config";
import { getOrganizationId } from "~/server/core/request-context"; import { getOrganizationId } from "~/server/core/request-context";
import { formatBytes } from "~/utils/format-bytes"; import { formatBytes } from "~/utils/format-bytes";
import { decryptNotificationConfig, encryptNotificationConfig } from "./notification-config-secrets"; import { decryptNotificationConfig, encryptNotificationConfig } from "./notification-config-secrets";
import { serverEvents } from "~/server/core/events";
const getCustomShoutrrrWebhookUrl = (shoutrrrUrl: string) => { const getCustomShoutrrrWebhookUrl = (shoutrrrUrl: string) => {
if (!URL.canParse(shoutrrrUrl)) { if (!URL.canParse(shoutrrrUrl)) {
@ -188,21 +189,51 @@ const deleteDestination = async (id: number) => {
); );
}; };
const updateDeliveryStatus = async (destinationId: number, result: { success: boolean; error?: string }) => {
const [updated] = await db
.update(notificationDestinationsTable)
.set({
status: result.success ? "healthy" : "error",
lastChecked: Date.now(),
lastError: result.success ? null : (result.error ?? "Unknown error"),
updatedAt: Date.now(),
})
.where(eq(notificationDestinationsTable.id, destinationId))
.returning();
if (updated) {
serverEvents.emit("notification:updated", {
organizationId: updated.organizationId,
notificationId: updated.id,
notificationName: updated.name,
status: updated.status,
});
}
};
const testDestination = async (id: number) => { const testDestination = async (id: number) => {
const destination = await getDestination(id); const destination = await getDestination(id);
let result: Awaited<ReturnType<typeof sendNotification>>;
const decryptedConfig = await decryptNotificationConfig(destination.config); try {
assertNotificationWebhookOriginAllowed(decryptedConfig); const decryptedConfig = await decryptNotificationConfig(destination.config);
assertNotificationWebhookOriginAllowed(decryptedConfig);
const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig); const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig);
logger.debug("Testing notification with Shoutrrr URL:", shoutrrrUrl); logger.debug("Testing notification with Shoutrrr URL:", shoutrrrUrl);
const result = await sendNotification({ result = await sendNotification({
shoutrrrUrl, shoutrrrUrl,
title: "Zerobyte Test Notification", title: "Zerobyte Test Notification",
body: `This is a test notification from Zerobyte for destination: ${destination.name}`, body: `This is a test notification from Zerobyte for destination: ${destination.name}`,
}); });
} catch (error) {
await updateDeliveryStatus(destination.id, { success: false, error: toMessage(error) });
throw error;
}
await updateDeliveryStatus(destination.id, result);
if (!result.success) { if (!result.success) {
throw new InternalServerError(`Failed to send test notification: ${result.error}`); throw new InternalServerError(`Failed to send test notification: ${result.error}`);
@ -394,6 +425,7 @@ const sendBackupNotification = async (
const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig); const shoutrrrUrl = buildShoutrrrUrl(decryptedConfig);
const result = await sendNotification({ shoutrrrUrl, title, body }); const result = await sendNotification({ shoutrrrUrl, title, body });
await updateDeliveryStatus(assignment.destination.id, result);
if (result.success) { if (result.success) {
logger.info( logger.info(
@ -405,6 +437,7 @@ const sendBackupNotification = async (
); );
} }
} catch (error) { } catch (error) {
await updateDeliveryStatus(assignment.destination.id, { success: false, error: toMessage(error) });
logger.error( logger.error(
`Error sending notification to ${assignment.destination.name} for backup ${scheduleId}: ${toMessage(error)}`, `Error sending notification to ${assignment.destination.name} for backup ${scheduleId}: ${toMessage(error)}`,
); );

View file

@ -5,12 +5,12 @@ export default defineConfig({
input: `http://${config.serverIp}:3000/api/v1/openapi.json`, input: `http://${config.serverIp}:3000/api/v1/openapi.json`,
output: { output: {
path: "./app/client/api-client", path: "./app/client/api-client",
header: [ header: ["// @ts-nocheck", "// This file is auto-generated by @hey-api/openapi-ts"],
"// @ts-nocheck",
"// This file is auto-generated by @hey-api/openapi-ts",
],
postProcess: [ postProcess: [
"oxfmt", {
command: "vp",
args: ["fmt", "--write", "{{path}}"],
},
{ command: "bun", args: ["scripts/patch-api-client.ts"] }, { command: "bun", args: ["scripts/patch-api-client.ts"] },
], ],
}, },