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 () => ({