feat(FN-669): add ntfy deep link support for dashboard notifications
- Add ntfyDashboardHost setting to GlobalSettings for dashboard URL - Generate deep link URLs in ntfy notifications with Click header - Add tests for deep link generation with various URL scenarios - Handle ?task= query param in dashboard for deep linking - Add dashboard hostname UI in settings modal with validation
This commit is contained in:
@@ -275,6 +275,168 @@ describe("NtfyNotifier", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("deep link (Click header)", () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it("includes Click header with task URL when ntfyDashboardHost is set", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "https://fusion.example.com",
|
||||
});
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Click": "https://fusion.example.com/?task=FN-001",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("does not include Click header when ntfyDashboardHost is not set", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: undefined,
|
||||
});
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
const callArgs = fetchMock.mock.calls[0][1];
|
||||
expect(callArgs.headers).not.toHaveProperty("Click");
|
||||
});
|
||||
|
||||
it("handles hostname with trailing slash correctly", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "http://localhost:3000/",
|
||||
});
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-042", "Test Task"), "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Click": "http://localhost:3000/?task=FN-042",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("handles hostname without trailing slash correctly", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "http://localhost:3000",
|
||||
});
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-042", "Test Task"), "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Click": "http://localhost:3000/?task=FN-042",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("includes Click header for failed task notifications when dashboard host is set", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "https://fusion.example.com",
|
||||
});
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const failedTask = createTask("FN-001", "Test Task", "failed");
|
||||
store.triggerTaskUpdated(failedTask);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Click": "https://fusion.example.com/?task=FN-001",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("includes Click header for merged task notifications when dashboard host is set", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "https://fusion.example.com",
|
||||
});
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
const mergeResult: MergeResult = {
|
||||
task: createTask("FN-001", "Test Task"),
|
||||
branch: "fusion/fn-001",
|
||||
merged: true,
|
||||
worktreeRemoved: true,
|
||||
branchDeleted: true,
|
||||
};
|
||||
store.triggerTaskMerged(mergeResult);
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Click": "https://fusion.example.com/?task=FN-001",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes task IDs with special characters in Click URL", async () => {
|
||||
store.setSettings({
|
||||
ntfyEnabled: true,
|
||||
ntfyTopic: "test-topic",
|
||||
ntfyDashboardHost: "http://localhost:3000",
|
||||
});
|
||||
notifier = new NtfyNotifier(store);
|
||||
await notifier.start();
|
||||
|
||||
store.triggerTaskMoved(createTask("FN-001/test", "Test Task"), "in-progress", "in-review");
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://ntfy.sh/test-topic",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"Click": "http://localhost:3000/?task=FN-001%2Ftest",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime reconfiguration", () => {
|
||||
it("starts sending notifications when enabled at runtime", async () => {
|
||||
store.setSettings({ ntfyEnabled: false, ntfyTopic: "test-topic" });
|
||||
|
||||
@@ -33,6 +33,7 @@ interface NtfyNotifierStore {
|
||||
interface NtfyConfig {
|
||||
enabled: boolean;
|
||||
topic: string | undefined;
|
||||
dashboardHost: string | undefined;
|
||||
}
|
||||
|
||||
/** Event types for notification deduplication */
|
||||
@@ -50,7 +51,7 @@ type NotificationEventType = "in-review" | "merged" | "failed";
|
||||
* - Configurable notification events (hardcoded defaults)
|
||||
*/
|
||||
export class NtfyNotifier {
|
||||
private config: NtfyConfig = { enabled: false, topic: undefined };
|
||||
private config: NtfyConfig = { enabled: false, topic: undefined, dashboardHost: undefined };
|
||||
private ntfyBaseUrl: string;
|
||||
/** Tracks which (taskId, eventType) pairs have been notified to prevent duplicates */
|
||||
private notifiedEvents: Set<string> = new Set();
|
||||
@@ -116,12 +117,14 @@ export class NtfyNotifier {
|
||||
|
||||
// Notify when task moves to in-review (completed work, ready for review)
|
||||
if (to === "in-review") {
|
||||
const clickUrl = this.buildTaskUrl(task.id);
|
||||
this.maybeNotify(task.id, "in-review", () =>
|
||||
this.sendNotification(
|
||||
this.config.topic!,
|
||||
`Task ${task.id} completed`,
|
||||
`Task "${formatTaskIdentifier(task)}" is ready for review`,
|
||||
"default",
|
||||
clickUrl,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -135,12 +138,14 @@ export class NtfyNotifier {
|
||||
|
||||
// Notify when task fails
|
||||
if (task.status === "failed") {
|
||||
const clickUrl = this.buildTaskUrl(task.id);
|
||||
this.maybeNotify(task.id, "failed", () =>
|
||||
this.sendNotification(
|
||||
this.config.topic!,
|
||||
`Task ${task.id} failed`,
|
||||
`Task "${formatTaskIdentifier(task)}" has failed and needs attention`,
|
||||
"high",
|
||||
clickUrl,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -151,12 +156,14 @@ export class NtfyNotifier {
|
||||
|
||||
// Only notify on successful merges
|
||||
if (result.merged) {
|
||||
const clickUrl = this.buildTaskUrl(result.task.id);
|
||||
this.maybeNotify(result.task.id, "merged", () =>
|
||||
this.sendNotification(
|
||||
this.config.topic!,
|
||||
`Task ${result.task.id} merged`,
|
||||
`Task "${formatTaskIdentifier(result.task)}" has been merged to main`,
|
||||
"default",
|
||||
clickUrl,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -167,7 +174,8 @@ export class NtfyNotifier {
|
||||
|
||||
// Check if ntfy settings changed
|
||||
if (settings.ntfyEnabled !== previous.ntfyEnabled ||
|
||||
settings.ntfyTopic !== previous.ntfyTopic) {
|
||||
settings.ntfyTopic !== previous.ntfyTopic ||
|
||||
settings.ntfyDashboardHost !== previous.ntfyDashboardHost) {
|
||||
const wasEnabled = this.config.enabled;
|
||||
this.loadConfig(settings);
|
||||
|
||||
@@ -177,6 +185,8 @@ export class NtfyNotifier {
|
||||
schedulerLog.log("NtfyNotifier disabled");
|
||||
} else if (this.config.topic !== previous.ntfyTopic) {
|
||||
schedulerLog.log("NtfyNotifier topic updated");
|
||||
} else if (this.config.dashboardHost !== previous.ntfyDashboardHost) {
|
||||
schedulerLog.log("NtfyNotifier dashboard host updated");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -185,9 +195,23 @@ export class NtfyNotifier {
|
||||
this.config = {
|
||||
enabled: settings.ntfyEnabled ?? false,
|
||||
topic: settings.ntfyTopic,
|
||||
dashboardHost: settings.ntfyDashboardHost,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a dashboard URL for deep linking to a task.
|
||||
* Returns undefined if dashboardHost is not configured.
|
||||
*/
|
||||
private buildTaskUrl(taskId: string): string | undefined {
|
||||
if (!this.config.dashboardHost) {
|
||||
return undefined;
|
||||
}
|
||||
// Strip trailing slash from hostname if present
|
||||
const host = this.config.dashboardHost.replace(/\/$/, "");
|
||||
return `${host}/?task=${encodeURIComponent(taskId)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send notification if this (taskId, eventType) pair hasn't been notified before.
|
||||
* This prevents duplicate notifications for the same event type per task.
|
||||
@@ -219,18 +243,26 @@ export class NtfyNotifier {
|
||||
title: string,
|
||||
message: string,
|
||||
priority: "low" | "default" | "high" | "urgent" = "default",
|
||||
clickUrl?: string,
|
||||
): Promise<void> {
|
||||
const url = `${this.ntfyBaseUrl}/${topic}`;
|
||||
const signal = this.abortController?.signal;
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
"Title": title,
|
||||
"Priority": priority,
|
||||
"Content-Type": "text/plain",
|
||||
};
|
||||
|
||||
// Add Click header for deep linking if URL is provided
|
||||
if (clickUrl) {
|
||||
headers["Click"] = clickUrl;
|
||||
}
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Title": title,
|
||||
"Priority": priority,
|
||||
"Content-Type": "text/plain",
|
||||
},
|
||||
headers,
|
||||
body: message,
|
||||
signal,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user