feat(cowork-service): H.9 notification + webhook proxy routes

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
This commit is contained in:
saravanakumardb1 2026-04-03 00:00:14 -07:00
parent cd5483c95b
commit 88cc18549c
3 changed files with 139 additions and 2 deletions

View File

@ -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' };
}
});
}

View File

@ -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' });
});
});

View File

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