feat(FN-693): add projectId to NtfyNotifier for multi-project deep links

- Add projectId parameter to NtfyNotifier constructor and sendMessage method
- Include projectId in notification deep links for multi-project context
- Add unit tests for deep link URL construction with projectId in App.tsx
- Add unit tests verifying project ID handling in notifier
This commit is contained in:
gsxdsm
2026-04-02 10:27:49 -07:00
parent 6daf95bbb3
commit ac0e96e491
4 changed files with 287 additions and 9 deletions

View File

@@ -443,6 +443,152 @@ describe("NtfyNotifier", () => {
});
});
describe("project ID in URLs", () => {
beforeEach(() => {
fetchMock.mockResolvedValue({ ok: true });
});
it("includes projectId in Click URL when configured for in-review notifications", async () => {
store.setSettings({
ntfyEnabled: true,
ntfyTopic: "test-topic",
ntfyDashboardHost: "http://localhost:3000",
});
notifier = new NtfyNotifier(store, { projectId: "proj_123" });
await notifier.start();
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
await flushAsyncWork();
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/test-topic",
expect.objectContaining({
headers: expect.objectContaining({
"Click": "http://localhost:3000/?project=proj_123&task=FN-001",
}),
})
);
});
it("includes projectId in Click URL when configured for failed task notifications", async () => {
store.setSettings({
ntfyEnabled: true,
ntfyTopic: "test-topic",
ntfyDashboardHost: "https://fusion.example.com",
});
notifier = new NtfyNotifier(store, { projectId: "my-project" });
await notifier.start();
const failedTask = createTask("FN-001", "Test Task", "failed");
store.triggerTaskUpdated(failedTask);
await flushAsyncWork();
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/test-topic",
expect.objectContaining({
headers: expect.objectContaining({
"Click": "https://fusion.example.com/?project=my-project&task=FN-001",
}),
})
);
});
it("includes projectId in Click URL when configured for merged task notifications", async () => {
store.setSettings({
ntfyEnabled: true,
ntfyTopic: "test-topic",
ntfyDashboardHost: "https://fusion.example.com",
});
notifier = new NtfyNotifier(store, { projectId: "another-project" });
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 flushAsyncWork();
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/test-topic",
expect.objectContaining({
headers: expect.objectContaining({
"Click": "https://fusion.example.com/?project=another-project&task=FN-001",
}),
})
);
});
it("falls back to task-only URL when projectId not configured", 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 Task"), "in-progress", "in-review");
await flushAsyncWork();
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/test-topic",
expect.objectContaining({
headers: expect.objectContaining({
"Click": "http://localhost:3000/?task=FN-001",
}),
})
);
});
it("encodes special characters in projectId", async () => {
store.setSettings({
ntfyEnabled: true,
ntfyTopic: "test-topic",
ntfyDashboardHost: "http://localhost:3000",
});
notifier = new NtfyNotifier(store, { projectId: "proj/abc" });
await notifier.start();
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
await flushAsyncWork();
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/test-topic",
expect.objectContaining({
headers: expect.objectContaining({
"Click": "http://localhost:3000/?project=proj%2Fabc&task=FN-001",
}),
})
);
});
it("handles projectId with spaces and special characters", async () => {
store.setSettings({
ntfyEnabled: true,
ntfyTopic: "test-topic",
ntfyDashboardHost: "http://localhost:3000",
});
notifier = new NtfyNotifier(store, { projectId: "my project test" });
await notifier.start();
store.triggerTaskMoved(createTask("FN-001", "Test Task"), "in-progress", "in-review");
await flushAsyncWork();
expect(fetchMock).toHaveBeenCalledWith(
"https://ntfy.sh/test-topic",
expect.objectContaining({
headers: expect.objectContaining({
"Click": "http://localhost:3000/?project=my%20project%20test&task=FN-001",
}),
})
);
});
});
describe("runtime reconfiguration", () => {
it("starts sending notifications when enabled at runtime", async () => {
store.setSettings({ ntfyEnabled: false, ntfyTopic: "test-topic" });

View File

@@ -5,6 +5,8 @@ import { schedulerLog } from "./logger.js";
export interface NtfyNotifierOptions {
/** Base URL for ntfy.sh. Default: https://ntfy.sh */
ntfyBaseUrl?: string;
/** Project identifier for deep links in notifications */
projectId?: string;
}
/**
@@ -53,6 +55,8 @@ type NotificationEventType = "in-review" | "merged" | "failed";
export class NtfyNotifier {
private config: NtfyConfig = { enabled: false, topic: undefined, dashboardHost: undefined };
private ntfyBaseUrl: string;
/** Project identifier for deep links in notifications */
private projectId?: string;
/** Tracks which (taskId, eventType) pairs have been notified to prevent duplicates */
private notifiedEvents: Set<string> = new Set();
/** AbortController for in-flight requests during shutdown */
@@ -63,6 +67,7 @@ export class NtfyNotifier {
options: NtfyNotifierOptions = {},
) {
this.ntfyBaseUrl = options.ntfyBaseUrl ?? "https://ntfy.sh";
this.projectId = options.projectId;
}
/**
@@ -204,6 +209,7 @@ export class NtfyNotifier {
/**
* Build a dashboard URL for deep linking to a task.
* Returns undefined if dashboardHost is not configured.
* Includes projectId in the URL when configured for multi-project support.
*/
private buildTaskUrl(taskId: string): string | undefined {
if (!this.config.dashboardHost) {
@@ -211,6 +217,9 @@ export class NtfyNotifier {
}
// Strip trailing slash from hostname if present
const host = this.config.dashboardHost.replace(/\/$/, "");
if (this.projectId) {
return `${host}/?project=${encodeURIComponent(this.projectId)}&task=${encodeURIComponent(taskId)}`;
}
return `${host}/?task=${encodeURIComponent(taskId)}`;
}