Skip to content

Commit 232e098

Browse files
os-zhuangclaude
andcommitted
feat(messaging,runtime): implement /api/v1/notifications REST surface (ADR-0030)
The notification REST routes (GET /notifications, POST /notifications/read, /read/all) were declared in the spec but never had a server-side handler — no plugin registered the `notification` core service, so the routes were never advertised and client.notifications.* 404'd. (The Console bell works only because it bypasses these endpoints via the generic data API.) - MessagingService: add listInbox/markRead/markAllRead reading sys_inbox_message + sys_notification_receipt; mark-read upserts the receipt keyed (notification_id, user_id, channel:'inbox'), updating the existing delivered receipt in place and inserting only when absent. - MessagingServicePlugin: also register the service under the `notification` core slot so the dispatcher resolves + advertises the routes. - HttpDispatcher: add handleNotification + /notifications dispatch branch; takes the user from the execution context; responses match the spec schemas ({ notifications, unreadCount }, { success, readCount }). - Tests: 6 new MessagingService inbox-API tests; client notification response shapes reconciled to the spec schemas. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 7648242 commit 232e098

6 files changed

Lines changed: 397 additions & 5 deletions

File tree

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
---
2+
'@objectstack/service-messaging': minor
3+
'@objectstack/runtime': minor
4+
---
5+
6+
Implement the `/api/v1/notifications` REST surface (ADR-0030)
7+
8+
The notification REST routes (`GET /notifications`, `POST /notifications/read`,
9+
`POST /notifications/read/all`) were declared in the spec but never had a
10+
server-side handler — no plugin registered the `notification` core service, so
11+
the routes were never advertised in discovery and `client.notifications.*`
12+
calls 404'd. (The Console bell works today only because it bypasses these
13+
endpoints and reads the inbox via the generic data API.)
14+
15+
This wires the surface end-to-end against the ADR-0030 L5 model:
16+
17+
- **`MessagingService`** gains an inbox read API: `listInbox(userId, opts)`
18+
reads `sys_inbox_message` joined with `sys_notification_receipt` for
19+
read-state (a message is unread until its event has a `read`/`clicked`/
20+
`dismissed` receipt); `markRead(userId, ids)` and `markAllRead(userId)`
21+
upsert the receipt to `read`, keyed `(notification_id, user_id,
22+
channel:'inbox')` — updating the existing `delivered` receipt in place,
23+
inserting only when absent. No reliance on the re-modeled `sys_notification`
24+
L2 event (which carries no recipient/read columns).
25+
- **`MessagingServicePlugin`** now also registers the messaging service under
26+
the `notification` core service slot, so the dispatcher resolves + advertises
27+
the routes. The legacy `INotificationService.send()` abstraction is unused and
28+
unconsumed.
29+
- **`HttpDispatcher`** gains `handleNotification` + a `/notifications` dispatch
30+
branch: it takes the authenticated user from the execution context and maps
31+
list / mark-read / mark-all-read to the service. Responses match the spec
32+
schemas (`{ notifications, unreadCount }`, `{ success, readCount }`).
33+
34+
Pairs with the objectui SDK consumer repoint (`useClientNotifications` →
35+
`markRead`/`registerDevice` signatures). Device registration and preference
36+
endpoints remain out of scope (unimplemented as before).

‎packages/client/src/client.test.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -440,7 +440,7 @@ describe('Notifications namespace', () => {
440440
it('should list notifications with filters', async () => {
441441
const { client, fetchMock } = createMockClient({
442442
success: true,
443-
data: { notifications: [], total: 0 }
443+
data: { notifications: [], unreadCount: 0 }
444444
});
445445
await client.notifications.list({ read: false, limit: 10 });
446446
const url = fetchMock.mock.calls[0][0] as string;
@@ -452,21 +452,21 @@ describe('Notifications namespace', () => {
452452
it('should mark notifications as read', async () => {
453453
const { client, fetchMock } = createMockClient({
454454
success: true,
455-
data: { updated: 2 }
455+
data: { success: true, readCount: 2 }
456456
});
457457
const result = await client.notifications.markRead(['n1', 'n2']);
458-
expect(result.updated).toBe(2);
458+
expect(result.readCount).toBe(2);
459459
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
460460
expect(body.ids).toEqual(['n1', 'n2']);
461461
});
462462

463463
it('should mark all notifications as read', async () => {
464464
const { client, fetchMock } = createMockClient({
465465
success: true,
466-
data: { updated: 5 }
466+
data: { success: true, readCount: 5 }
467467
});
468468
const result = await client.notifications.markAllRead();
469-
expect(result.updated).toBe(5);
469+
expect(result.readCount).toBe(5);
470470
const [url, opts] = fetchMock.mock.calls[0];
471471
expect(url).toContain('/api/v1/notifications/read/all');
472472
expect(opts.method).toBe('POST');

‎packages/runtime/src/http-dispatcher.ts‎

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1212,6 +1212,56 @@ export class HttpDispatcher {
12121212
return { handled: false };
12131213
}
12141214

1215+
/**
1216+
* Handles in-app notification requests (ADR-0030) — the
1217+
* `/api/v1/notifications` surface backed by the messaging service's inbox
1218+
* read API. Reads the L5 `sys_inbox_message` + `sys_notification_receipt`
1219+
* join; mark-read upserts the receipt keyed `(notification_id, user_id,
1220+
* channel:'inbox')`. The routes are `auth: true`, so an authenticated user
1221+
* is required.
1222+
*
1223+
* Routes (path is the sub-path after `/notifications`):
1224+
* GET '' → listInbox (query: read, type, limit)
1225+
* POST /read → markRead (body: { ids: string[] })
1226+
* POST /read/all → markAllRead
1227+
*/
1228+
async handleNotification(path: string, method: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
1229+
const service = await this.resolveService(CoreServiceName.enum.notification, context.environmentId) as any;
1230+
if (!service || typeof service.listInbox !== 'function') return { handled: false };
1231+
1232+
const userId: string | undefined = context.executionContext?.userId;
1233+
if (!userId) {
1234+
return { handled: true, response: this.error('Authentication required', 401) };
1235+
}
1236+
1237+
const m = method.toUpperCase();
1238+
const subPath = path.replace(/^\/+/, '').replace(/\/+$/, '');
1239+
1240+
// GET /notifications — list the user's inbox joined with read-state.
1241+
if (subPath === '' && m === 'GET') {
1242+
const read = query?.read === undefined ? undefined : String(query.read) === 'true';
1243+
const limit = query?.limit ? Number(query.limit) : undefined;
1244+
const type = query?.type ? String(query.type) : undefined;
1245+
const result = await service.listInbox(userId, { read, type, limit });
1246+
return { handled: true, response: this.success(result) };
1247+
}
1248+
1249+
// POST /notifications/read — mark specific notifications read.
1250+
if (subPath === 'read' && m === 'POST') {
1251+
const ids: string[] = Array.isArray(body?.ids) ? body.ids.map((x: unknown) => String(x)) : [];
1252+
const result = await service.markRead(userId, ids);
1253+
return { handled: true, response: this.success(result) };
1254+
}
1255+
1256+
// POST /notifications/read/all — mark all of the user's inbox read.
1257+
if (subPath === 'read/all' && m === 'POST') {
1258+
const result = await service.markAllRead(userId);
1259+
return { handled: true, response: this.success(result) };
1260+
}
1261+
1262+
return { handled: false };
1263+
}
1264+
12151265
/**
12161266
* Handles i18n requests
12171267
* path: sub-path after /i18n/
@@ -2357,6 +2407,12 @@ export class HttpDispatcher {
23572407
return this.handleAnalytics(cleanPath.substring(10), method, body, context);
23582408
}
23592409

2410+
// In-app notifications (ADR-0030) — inbox list + receipt mark-read,
2411+
// backed by the messaging service registered under the `notification` slot.
2412+
if (cleanPath.startsWith('/notifications')) {
2413+
return this.handleNotification(cleanPath.substring(14), method, body, query, context);
2414+
}
2415+
23602416
if (cleanPath.startsWith('/packages')) {
23612417
return this.handlePackages(cleanPath.substring(9), method, body, query, context);
23622418
}

‎packages/services/service-messaging/src/messaging-service-plugin.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,15 @@ export class MessagingServicePlugin implements Plugin {
129129

130130
ctx.registerService('messaging', service);
131131

132+
// ADR-0030: the messaging service also backs the `notification` core
133+
// service slot — it owns the in-app inbox + receipts, so it answers the
134+
// `/api/v1/notifications` REST surface (list / mark-read / mark-all-read)
135+
// via its inbox read API. Registering it here makes the dispatcher
136+
// resolve + advertise those routes (`hasNotification`). The legacy
137+
// INotificationService `send()` abstraction is unused; nothing consumes
138+
// the slot expecting it.
139+
ctx.registerService('notification', service);
140+
132141
// Register the messaging objects so their rows can be written. The
133142
// preference/subscription objects (ADR-0030 P2) are Studio-configurable,
134143
// so contribute them to the Setup app's Configuration slot (ADR-0029 D7)

‎packages/services/service-messaging/src/messaging-service.test.ts‎

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,3 +425,140 @@ describe('MessagingService', () => {
425425
});
426426
});
427427
});
428+
429+
/**
430+
* A stateful in-memory engine for the inbox read API (ADR-0030). Supports the
431+
* flat-equality `where` filters listInbox/markRead/markAllRead issue, plus
432+
* `update(..., { where: { id } })` mutation and `insert`.
433+
*/
434+
function inboxEngine(seed: { inbox?: any[]; receipts?: any[] } = {}) {
435+
const store: Record<string, any[]> = {
436+
sys_inbox_message: [...(seed.inbox ?? [])],
437+
sys_notification_receipt: [...(seed.receipts ?? [])],
438+
};
439+
let seq = 0;
440+
const matches = (row: any, where: any = {}) =>
441+
Object.entries(where).every(([k, v]) => String(row[k]) === String(v));
442+
const engine = {
443+
store,
444+
async find(object: string, query: any = {}) {
445+
let rows = (store[object] ?? []).filter((r) => matches(r, query.where));
446+
const ob = Array.isArray(query.orderBy) ? query.orderBy : [];
447+
if (ob.some((o: any) => o.field === 'created_at' && o.order === 'desc')) {
448+
rows = [...rows].sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)));
449+
}
450+
return typeof query.limit === 'number' ? rows.slice(0, query.limit) : rows;
451+
},
452+
async findOne(object: string, query: any = {}) {
453+
return (store[object] ?? []).find((r) => matches(r, query.where)) ?? null;
454+
},
455+
async insert(object: string, row: any) {
456+
const created = { id: `row_${++seq}`, ...row };
457+
(store[object] ??= []).push(created);
458+
return created;
459+
},
460+
async update(object: string, data: any, options: any = {}) {
461+
for (const r of store[object] ?? []) {
462+
if (matches(r, options.where)) Object.assign(r, data);
463+
}
464+
return {};
465+
},
466+
async delete() { return {}; },
467+
async count() { return 0; },
468+
async aggregate() { return []; },
469+
};
470+
return engine as any;
471+
}
472+
473+
describe('MessagingService — inbox read API (ADR-0030)', () => {
474+
const logger = silentLogger();
475+
476+
it('lists inbox rows joined with receipt read-state and counts unread', async () => {
477+
const engine = inboxEngine({
478+
inbox: [
479+
{ id: 'm1', user_id: 'u1', notification_id: 'n1', topic: 'collab.mention', title: 'A', body_md: 'a', action_url: '/x', created_at: '2026-01-01T00:00:01Z' },
480+
{ id: 'm2', user_id: 'u1', notification_id: 'n2', topic: 'task.assigned', title: 'B', body_md: 'b', created_at: '2026-01-01T00:00:02Z' },
481+
{ id: 'm3', user_id: 'u2', notification_id: 'n3', topic: 'x', title: 'C', created_at: '2026-01-01T00:00:03Z' },
482+
],
483+
receipts: [
484+
{ id: 'r1', notification_id: 'n1', user_id: 'u1', channel: 'inbox', state: 'read' },
485+
{ id: 'r2', notification_id: 'n2', user_id: 'u1', channel: 'inbox', state: 'delivered' },
486+
],
487+
});
488+
const svc = new MessagingService({ logger, getData: () => engine });
489+
490+
const res = await svc.listInbox('u1');
491+
// Only u1's rows; newest first; n2 unread, n1 read.
492+
expect(res.notifications.map((n) => n.id)).toEqual(['n2', 'n1']);
493+
expect(res.unreadCount).toBe(1);
494+
const n1 = res.notifications.find((n) => n.id === 'n1')!;
495+
expect(n1).toMatchObject({ type: 'collab.mention', title: 'A', body: 'a', read: true, actionUrl: '/x' });
496+
expect(res.notifications.find((n) => n.id === 'n2')!.read).toBe(false);
497+
});
498+
499+
it('filters by read state when requested', async () => {
500+
const engine = inboxEngine({
501+
inbox: [
502+
{ id: 'm1', user_id: 'u1', notification_id: 'n1', title: 'A', created_at: '1' },
503+
{ id: 'm2', user_id: 'u1', notification_id: 'n2', title: 'B', created_at: '2' },
504+
],
505+
receipts: [{ id: 'r1', notification_id: 'n1', user_id: 'u1', channel: 'inbox', state: 'read' }],
506+
});
507+
const svc = new MessagingService({ logger, getData: () => engine });
508+
509+
expect((await svc.listInbox('u1', { read: false })).notifications.map((n) => n.id)).toEqual(['n2']);
510+
expect((await svc.listInbox('u1', { read: true })).notifications.map((n) => n.id)).toEqual(['n1']);
511+
});
512+
513+
it('markRead updates the existing delivered receipt in place (no duplicate)', async () => {
514+
const engine = inboxEngine({
515+
inbox: [{ id: 'm1', user_id: 'u1', notification_id: 'n1', title: 'A', created_at: '1' }],
516+
receipts: [{ id: 'r1', notification_id: 'n1', user_id: 'u1', channel: 'inbox', state: 'delivered' }],
517+
});
518+
const svc = new MessagingService({ logger, getData: () => engine });
519+
520+
const res = await svc.markRead('u1', ['n1']);
521+
expect(res).toEqual({ success: true, readCount: 1 });
522+
const receipts = engine.store.sys_notification_receipt;
523+
expect(receipts).toHaveLength(1); // updated in place, not duplicated
524+
expect(receipts[0]).toMatchObject({ id: 'r1', state: 'read' });
525+
expect(receipts[0].at).toBeTruthy();
526+
});
527+
528+
it('markRead inserts a read receipt when none exists yet', async () => {
529+
const engine = inboxEngine({
530+
inbox: [{ id: 'm1', user_id: 'u1', notification_id: 'n1', title: 'A', created_at: '1' }],
531+
});
532+
const svc = new MessagingService({ logger, getData: () => engine });
533+
534+
const res = await svc.markRead('u1', ['n1']);
535+
expect(res.readCount).toBe(1);
536+
const receipts = engine.store.sys_notification_receipt;
537+
expect(receipts).toHaveLength(1);
538+
expect(receipts[0]).toMatchObject({ notification_id: 'n1', user_id: 'u1', channel: 'inbox', state: 'read' });
539+
});
540+
541+
it('markAllRead flips every unread message and leaves already-read ones', async () => {
542+
const engine = inboxEngine({
543+
inbox: [
544+
{ id: 'm1', user_id: 'u1', notification_id: 'n1', title: 'A', created_at: '1' },
545+
{ id: 'm2', user_id: 'u1', notification_id: 'n2', title: 'B', created_at: '2' },
546+
],
547+
receipts: [{ id: 'r1', notification_id: 'n1', user_id: 'u1', channel: 'inbox', state: 'read' }],
548+
});
549+
const svc = new MessagingService({ logger, getData: () => engine });
550+
551+
const res = await svc.markAllRead('u1');
552+
expect(res.readCount).toBe(1); // only n2 was unread
553+
expect((await svc.listInbox('u1')).unreadCount).toBe(0);
554+
});
555+
556+
it('degrades to empty without a data engine or user id', async () => {
557+
const noData = new MessagingService({ logger });
558+
expect(await noData.listInbox('u1')).toEqual({ notifications: [], unreadCount: 0 });
559+
expect(await noData.markRead('u1', ['n1'])).toEqual({ success: true, readCount: 0 });
560+
561+
const svc = new MessagingService({ logger, getData: () => inboxEngine() });
562+
expect(await svc.listInbox('')).toEqual({ notifications: [], unreadCount: 0 });
563+
});
564+
});

0 commit comments

Comments
 (0)