From 88cc18549cfa1287808178bb0aaa4db25c387b87 Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 3 Apr 2026 00:00:14 -0700 Subject: [PATCH] feat(cowork-service): H.9 notification + webhook proxy routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add notification and webhook management routes to cowork-service: - GET /api/notifications/prefs — get user notification preferences - PUT /api/notifications/prefs — update user notification preferences - GET /api/webhooks — list webhook subscriptions - POST /api/webhooks — create webhook subscription - DELETE /api/webhooks/:id — delete webhook subscription - modules/notifications/routes.ts — proxy routes with error handling - server.ts — register notificationRoutes (6 route modules total) - server.test.ts — add notification routes mock, update register count to 6 57 tests passing, 9 test files, typecheck clean --- .../src/modules/notifications/routes.ts | 134 ++++++++++++++++++ services/cowork-service/src/server.test.ts | 5 +- services/cowork-service/src/server.ts | 2 + 3 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 services/cowork-service/src/modules/notifications/routes.ts diff --git a/services/cowork-service/src/modules/notifications/routes.ts b/services/cowork-service/src/modules/notifications/routes.ts new file mode 100644 index 00000000..3661c4e2 --- /dev/null +++ b/services/cowork-service/src/modules/notifications/routes.ts @@ -0,0 +1,134 @@ +/** + * Notification preference proxy routes — forward to platform-service. + * + * Lets the Tauri desktop app manage notification preferences + * and webhook subscriptions via cowork-service. + */ + +import type { FastifyInstance } from 'fastify'; +import { config } from '../../lib/config.js'; +import { PRODUCT_ID } from '../../lib/product-config.js'; +import { getUserId } from '../../lib/request-context.js'; + +export async function notificationRoutes(app: FastifyInstance) { + const platformUrl = config.PLATFORM_SERVICE_URL; + + // GET /api/notifications/prefs — get current user's notification preferences + app.get('/api/notifications/prefs', async (req, reply) => { + const userId = getUserId(req); + + try { + const res = await fetch(`${platformUrl}/notifications/prefs/${encodeURIComponent(userId)}`, { + headers: { + 'x-product-id': PRODUCT_ID, + 'x-request-id': req.id, + }, + }); + if (!res.ok) { + reply.code(res.status); + return { error: `Platform returned ${res.status}` }; + } + return res.json(); + } catch (err) { + req.log.warn({ err }, 'Failed to proxy notification prefs'); + reply.code(502); + return { error: 'Platform-service unavailable' }; + } + }); + + // PUT /api/notifications/prefs — update current user's notification preferences + app.put('/api/notifications/prefs', async (req, reply) => { + const userId = getUserId(req); + + try { + const res = await fetch(`${platformUrl}/notifications/prefs/${encodeURIComponent(userId)}`, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + 'x-product-id': PRODUCT_ID, + 'x-request-id': req.id, + }, + body: JSON.stringify(req.body), + }); + if (!res.ok) { + reply.code(res.status); + return { error: `Platform returned ${res.status}` }; + } + return res.json(); + } catch (err) { + req.log.warn({ err }, 'Failed to proxy notification prefs update'); + reply.code(502); + return { error: 'Platform-service unavailable' }; + } + }); + + // GET /api/webhooks — list webhook subscriptions for this product + app.get('/api/webhooks', async (req, reply) => { + try { + const res = await fetch(`${platformUrl}/webhooks/subscriptions`, { + headers: { + 'x-product-id': PRODUCT_ID, + 'x-request-id': req.id, + }, + }); + if (!res.ok) { + reply.code(res.status); + return { error: `Platform returned ${res.status}` }; + } + return res.json(); + } catch (err) { + req.log.warn({ err }, 'Failed to proxy webhook list'); + reply.code(502); + return { error: 'Platform-service unavailable' }; + } + }); + + // POST /api/webhooks — create a webhook subscription + app.post('/api/webhooks', async (req, reply) => { + try { + const res = await fetch(`${platformUrl}/webhooks/subscriptions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-product-id': PRODUCT_ID, + 'x-request-id': req.id, + }, + body: JSON.stringify(req.body), + }); + if (!res.ok) { + reply.code(res.status); + return { error: `Platform returned ${res.status}` }; + } + reply.code(201); + return res.json(); + } catch (err) { + req.log.warn({ err }, 'Failed to proxy webhook create'); + reply.code(502); + return { error: 'Platform-service unavailable' }; + } + }); + + // DELETE /api/webhooks/:id — delete a webhook subscription + app.delete('/api/webhooks/:id', async (req, reply) => { + const { id } = req.params as { id: string }; + + try { + const res = await fetch(`${platformUrl}/webhooks/subscriptions/${encodeURIComponent(id)}`, { + method: 'DELETE', + headers: { + 'x-product-id': PRODUCT_ID, + 'x-request-id': req.id, + }, + }); + if (!res.ok) { + reply.code(res.status); + return { error: `Platform returned ${res.status}` }; + } + return res.json(); + } catch (err) { + req.log.warn({ err }, 'Failed to proxy webhook delete'); + reply.code(502); + return { error: 'Platform-service unavailable' }; + } + }); +} diff --git a/services/cowork-service/src/server.test.ts b/services/cowork-service/src/server.test.ts index 2e5c9aba..ee231b02 100644 --- a/services/cowork-service/src/server.test.ts +++ b/services/cowork-service/src/server.test.ts @@ -72,6 +72,7 @@ vi.mock('./lib/llm-router.js', () => ({ vi.mock('./modules/llm/routes.js', () => ({ llmRoutes: vi.fn() })); vi.mock('./modules/audit/routes.js', () => ({ auditRoutes: vi.fn() })); vi.mock('./modules/usage/routes.js', () => ({ usageRoutes: vi.fn() })); +vi.mock('./modules/notifications/routes.js', () => ({ notificationRoutes: vi.fn() })); describe('cowork-service bootstrap', () => { beforeEach(() => { @@ -91,9 +92,9 @@ describe('cowork-service bootstrap', () => { expect(opts.version).toBe('0.1.0'); expect(opts.readiness).toBe(true); - // JWT context + health + task + llm + audit + usage routes = 5 register calls + 1 JWT + // health + task + llm + audit + usage + notifications = 6 register calls + 1 JWT expect(registerOptionalJwtContextMock).toHaveBeenCalledOnce(); - expect(appMock.register).toHaveBeenCalledTimes(5); + expect(appMock.register).toHaveBeenCalledTimes(6); expect(startServiceMock).toHaveBeenCalledWith(appMock, { port: 4009, host: '0.0.0.0' }); }); }); diff --git a/services/cowork-service/src/server.ts b/services/cowork-service/src/server.ts index a1f98527..193aaeb4 100644 --- a/services/cowork-service/src/server.ts +++ b/services/cowork-service/src/server.ts @@ -27,6 +27,7 @@ import { initLlmRouter } from './lib/llm-router.js'; import { llmRoutes } from './modules/llm/routes.js'; import { auditRoutes } from './modules/audit/routes.js'; import { usageRoutes } from './modules/usage/routes.js'; +import { notificationRoutes } from './modules/notifications/routes.js'; import type { JwtPayload } from './lib/request-context.js'; const jwtSecret = new TextEncoder().encode(config.JWT_SECRET); @@ -58,6 +59,7 @@ await app.register(taskRoutes); await app.register(llmRoutes); await app.register(auditRoutes); await app.register(usageRoutes); +await app.register(notificationRoutes); // Bootstrap endpoint (same pattern as FlowMonk, ActionTrail, etc.) app.get('/api/bootstrap', async () => ({