summaryrefslogtreecommitdiffhomepage
path: root/packages/api/src/routes
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-01 09:28:30 +0900
committerAdam Malczewski <[email protected]>2026-06-01 09:28:30 +0900
commit21cdb1199599c4dc6e2a941e52713ba6511cd675 (patch)
treee3711384f16c476bf5d8def148dc922f9ceb5f3d /packages/api/src/routes
parent5e72191cac9469c2ade91aaba1e62f69fa1ad94a (diff)
downloaddispatch-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.ts81
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);
+});