fix(FN-3825): surface inline notification test feedback in settings

- Persist per-provider notification test results and render inline success/error feedback blocks
- Add ntfy "Test message notification" action wired to message-event test payload
- Keep toast notifications while also showing provider-specific status text with aria-live feedback
- Update SettingsModal tests to verify ntfy message-event call path and inline webhook/ntfy feedback rendering

Fusion-Task-Id: FN-3825
This commit is contained in:
Fusion
2026-05-09 11:02:08 -07:00
committed by gsxdsm
parent b8455ec91e
commit b326385c0a
15 changed files with 426 additions and 32 deletions

View File

@@ -41,6 +41,7 @@ export class NotificationService {
private notificationsEnabled = false;
private ntfyProvider?: NtfyNotificationProvider;
private webhookProvider?: WebhookNotificationProvider;
private refreshInFlight: Promise<void> | null = null;
constructor(
private readonly store: NotificationServiceStore,
@@ -236,8 +237,19 @@ export class NotificationService {
}
private handleMessageSent = (message: Message): void => {
void this.handleMessageSentAsync(message);
};
private async handleMessageSentAsync(message: Message): Promise<void> {
schedulerLog.log(
`NotificationService.handleMessageSent messageId=${message.id} type=${message.type} notificationsEnabled=${String(this.notificationsEnabled)} hasNtfyProvider=${String(Boolean(this.ntfyProvider))}`,
);
if (!this.notificationsEnabled) {
return;
await this.refreshNotificationState("message:sent");
if (!this.notificationsEnabled) {
return;
}
}
let eventType: NotificationEvent;
@@ -270,7 +282,11 @@ export class NotificationService {
preview,
},
});
};
schedulerLog.log(
`NotificationService.handleMessageSent scheduled eventType=${eventType} messageId=${message.id}`,
);
}
private setNotificationsEnabledFromSettings(settings: Settings): void {
this.notificationsEnabled = Boolean(
@@ -281,13 +297,37 @@ export class NotificationService {
async dispatch(eventType: NotificationEvent, payload: NotificationPayload): Promise<void> {
if (!this.notificationsEnabled) {
return;
await this.refreshNotificationState("manual-dispatch");
if (!this.notificationsEnabled) {
return;
}
}
const dedupTaskId = payload.taskId ?? "global";
this.maybeNotify(dedupTaskId, eventType, payload);
}
private async refreshNotificationState(reason: string): Promise<void> {
if (this.refreshInFlight) {
await this.refreshInFlight;
return;
}
this.refreshInFlight = (async () => {
const settings = await this.store.getSettings();
this.setNotificationsEnabledFromSettings(settings);
await this.syncNtfyProvider(settings);
await this.syncWebhookProvider(settings);
schedulerLog.log(`NotificationService refreshed notification state reason=${reason} enabled=${String(this.notificationsEnabled)}`);
})();
try {
await this.refreshInFlight;
} finally {
this.refreshInFlight = null;
}
}
private createTaskPayload(task: Task, event: NotificationEvent): NotificationPayload {
return {
taskId: task.id,
@@ -300,10 +340,12 @@ export class NotificationService {
private maybeNotify(taskId: string, eventType: NotificationEvent, payload: NotificationPayload): void {
const key = `${taskId}:${eventType}`;
if (this.notifiedEvents.has(key)) {
schedulerLog.log(`NotificationService.maybeNotify suppressed duplicate key=${key}`);
return;
}
this.notifiedEvents.add(key);
schedulerLog.log(`NotificationService.maybeNotify dispatching key=${key}`);
this.dispatcher.dispatch(eventType, payload).catch(() => {
// best effort dispatch
});

View File

@@ -11,8 +11,9 @@ import {
buildNtfyClickUrl,
formatTaskIdentifier,
resolveNtfyEvents,
sendNtfyNotification,
sendNtfyNotificationWithResult,
} from "../notifier.js";
import { schedulerLog } from "../logger.js";
export interface NtfyProviderConfig {
/** ntfy topic name */
@@ -75,11 +76,16 @@ export class NtfyNotificationProvider implements NotificationProvider {
isEventSupported(event: NotificationEvent): boolean {
if (!SUPPORTED_EVENTS.has(event as SupportedNtfyEvent)) {
schedulerLog.log(`NtfyNotificationProvider event filtered unsupported event=${event}`);
return false;
}
const enabledEvents = this.config?.events ?? [...DEFAULT_NTFY_EVENTS];
return enabledEvents.includes(event as NtfyNotificationEvent);
const allowed = enabledEvents.includes(event as NtfyNotificationEvent);
schedulerLog.log(
`NtfyNotificationProvider allowlist event=${event} decision=${allowed ? "allowed" : "filtered-by-event"}`,
);
return allowed;
}
async sendNotification(
@@ -173,7 +179,20 @@ export class NtfyNotificationProvider implements NotificationProvider {
};
const content = contentByEvent[event as SupportedNtfyEvent];
await sendNtfyNotification({
const resolvedBaseUrl = this.config.ntfyBaseUrl?.trim() || "https://ntfy.sh";
const host = (() => {
try {
return new URL(resolvedBaseUrl).host;
} catch {
return "invalid-host";
}
})();
schedulerLog.log(
`NtfyNotificationProvider send event=${event} host=${host} topic=${this.config.topic}`,
);
const response = await sendNtfyNotificationWithResult({
ntfyBaseUrl: this.config.ntfyBaseUrl,
topic: this.config.topic,
title: content.title,
@@ -183,6 +202,14 @@ export class NtfyNotificationProvider implements NotificationProvider {
signal: this.abortController?.signal,
});
return { success: true, providerId: this.getProviderId() };
schedulerLog.log(
`NtfyNotificationProvider delivery event=${event} status=${response?.status ?? "error"} ok=${String(response?.ok ?? false)}`,
);
return {
success: Boolean(response?.ok),
providerId: this.getProviderId(),
...(response?.ok ? {} : { error: response ? `${response.status} ${response.statusText}` : "request failed" }),
};
}
}