diff options
| author | Adam Malczewski <[email protected]> | 2026-06-01 09:28:30 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-01 09:28:30 +0900 |
| commit | 21cdb1199599c4dc6e2a941e52713ba6511cd675 (patch) | |
| tree | e3711384f16c476bf5d8def148dc922f9ceb5f3d /packages/api/src/routes | |
| parent | 5e72191cac9469c2ade91aaba1e62f69fa1ad94a (diff) | |
| download | dispatch-21cdb1199599c4dc6e2a941e52713ba6511cd675.tar.gz dispatch-21cdb1199599c4dc6e2a941e52713ba6511cd675.zip | |
feat(api): wire notification dispatcher into app + /notifications routes
PermissionManager: add onPromptAdded(listener) callback. Fires exactly
once per unique pending prompt id, even when broadcastPending is called
repeatedly for unrelated mutations (e.g. another prompt resolving while
this one is still pending).
app.ts: instantiate NotificationDispatcher, attach to both AgentManager
and PermissionManager. Tab-title lookup via core's getTab so the
notifications carry human-readable context instead of raw UUIDs.
routes/notifications.ts:
- GET /notifications — current config (auth token redacted) plus
the event-type catalog and defaults
- PUT /notifications — partial update; auth token semantics are
undefined=keep, ''=clear, otherwise replace
- POST /notifications/test — sends a test notification with the current
config (rejects if disabled or topic invalid)
Tests:
- new permission-manager.test.ts covers the onPromptAdded contract
(one-fire-per-prompt, dedup across rebroadcasts, unsubscribe, listener
throws don't break siblings)
- existing routes.test.ts gets stubs for the new core notification
exports so the @dispatch/core mock stays complete
Diffstat (limited to 'packages/api/src/routes')
| -rw-r--r-- | packages/api/src/routes/notifications.ts | 81 |
1 files changed, 81 insertions, 0 deletions
diff --git a/packages/api/src/routes/notifications.ts b/packages/api/src/routes/notifications.ts new file mode 100644 index 0000000..57519bc --- /dev/null +++ b/packages/api/src/routes/notifications.ts @@ -0,0 +1,81 @@ +// `/notifications` — ntfy.sh config + test-send route. + +import { + defaultNtfyConfig, + loadNtfyConfig, + type NotificationEventType, + NTFY_EVENT_TYPES, + type NtfyConfig, + normalizeNtfyConfig, + redactNtfyConfig, + saveNtfyConfig, + sendNtfy, + validateTopicUrl, +} from "@dispatch/core"; +import { Hono } from "hono"; + +export const notificationsRoutes = new Hono(); + +notificationsRoutes.get("/", (c) => { + const config = loadNtfyConfig(); + return c.json({ + config: redactNtfyConfig(config), + eventTypes: NTFY_EVENT_TYPES, + defaults: defaultNtfyConfig(), + }); +}); + +notificationsRoutes.put("/", async (c) => { + const body = await c.req.json<Partial<NtfyConfig> & { authToken?: string }>(); + const existing = loadNtfyConfig(); + + // `authToken === ""` ⇒ explicit clear; `authToken === undefined` ⇒ keep + // the existing token (the GET response redacts it, so the frontend doesn't + // have it to send back). Any other string ⇒ replace. + let nextAuthToken = existing.authToken; + if (typeof body.authToken === "string") nextAuthToken = body.authToken; + + const merged = normalizeNtfyConfig({ + enabled: typeof body.enabled === "boolean" ? body.enabled : existing.enabled, + topicUrl: typeof body.topicUrl === "string" ? body.topicUrl : existing.topicUrl, + authToken: nextAuthToken, + events: { ...existing.events, ...(body.events ?? {}) }, + }); + + if (merged.enabled) { + const err = validateTopicUrl(merged.topicUrl); + if (err) return c.json({ error: err }, 400); + } + + saveNtfyConfig(merged); + return c.json({ config: redactNtfyConfig(merged) }); +}); + +notificationsRoutes.post("/test", async (c) => { + const config = loadNtfyConfig(); + if (!config.enabled) { + return c.json({ ok: false, error: "Notifications are disabled" }, 400); + } + const err = validateTopicUrl(config.topicUrl); + if (err) return c.json({ ok: false, error: err }, 400); + + // Use a real event type so the per-event toggle is honored when wiring + // is tested end-to-end; pick `turn-completed` since it's the most + // common enabled-by-default event. + const eventType: NotificationEventType = "turn-completed"; + if (!config.events[eventType]) { + return c.json( + { ok: false, error: `Event type "${eventType}" is disabled — enable it to test.` }, + 400, + ); + } + + const result = await sendNtfy(config, { + type: eventType, + title: "Dispatch test notification", + message: "If you can see this, ntfy.sh notifications are wired up correctly.", + tags: ["bell"], + }); + if (!result.ok) return c.json(result, 502); + return c.json(result); +}); |
