Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .changeset/notification-rest-inbox-surface.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
'@objectstack/service-messaging': minor
'@objectstack/runtime': minor
---

Implement the `/api/v1/notifications` REST surface (ADR-0030)

The notification REST routes (`GET /notifications`, `POST /notifications/read`,
`POST /notifications/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 in discovery and `client.notifications.*`
calls 404'd. (The Console bell works today only because it bypasses these
endpoints and reads the inbox via the generic data API.)

This wires the surface end-to-end against the ADR-0030 L5 model:

- **`MessagingService`** gains an inbox read API: `listInbox(userId, opts)`
reads `sys_inbox_message` joined with `sys_notification_receipt` for
read-state (a message is unread until its event has a `read`/`clicked`/
`dismissed` receipt); `markRead(userId, ids)` and `markAllRead(userId)`
upsert the receipt to `read`, keyed `(notification_id, user_id,
channel:'inbox')` — updating the existing `delivered` receipt in place,
inserting only when absent. No reliance on the re-modeled `sys_notification`
L2 event (which carries no recipient/read columns).
- **`MessagingServicePlugin`** now also registers the messaging service under
the `notification` core service slot, so the dispatcher resolves + advertises
the routes. The legacy `INotificationService.send()` abstraction is unused and
unconsumed.
- **`HttpDispatcher`** gains `handleNotification` + a `/notifications` dispatch
branch: it takes the authenticated user from the execution context and maps
list / mark-read / mark-all-read to the service. Responses match the spec
schemas (`{ notifications, unreadCount }`, `{ success, readCount }`).

Pairs with the objectui SDK consumer repoint (`useClientNotifications` →
`markRead`/`registerDevice` signatures). Device registration and preference
endpoints remain out of scope (unimplemented as before).
10 changes: 5 additions & 5 deletions packages/client/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,7 @@ describe('Notifications namespace', () => {
it('should list notifications with filters', async () => {
const { client, fetchMock } = createMockClient({
success: true,
data: { notifications: [], total: 0 }
data: { notifications: [], unreadCount: 0 }
});
await client.notifications.list({ read: false, limit: 10 });
const url = fetchMock.mock.calls[0][0] as string;
Expand All @@ -452,21 +452,21 @@ describe('Notifications namespace', () => {
it('should mark notifications as read', async () => {
const { client, fetchMock } = createMockClient({
success: true,
data: { updated: 2 }
data: { success: true, readCount: 2 }
});
const result = await client.notifications.markRead(['n1', 'n2']);
expect(result.updated).toBe(2);
expect(result.readCount).toBe(2);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
expect(body.ids).toEqual(['n1', 'n2']);
});

it('should mark all notifications as read', async () => {
const { client, fetchMock } = createMockClient({
success: true,
data: { updated: 5 }
data: { success: true, readCount: 5 }
});
const result = await client.notifications.markAllRead();
expect(result.updated).toBe(5);
expect(result.readCount).toBe(5);
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toContain('/api/v1/notifications/read/all');
expect(opts.method).toBe('POST');
Expand Down
56 changes: 56 additions & 0 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1212,6 +1212,56 @@
return { handled: false };
}

/**
* Handles in-app notification requests (ADR-0030) — the
* `/api/v1/notifications` surface backed by the messaging service's inbox
* read API. Reads the L5 `sys_inbox_message` + `sys_notification_receipt`
* join; mark-read upserts the receipt keyed `(notification_id, user_id,
* channel:'inbox')`. The routes are `auth: true`, so an authenticated user
* is required.
*
* Routes (path is the sub-path after `/notifications`):
* GET '' → listInbox (query: read, type, limit)
* POST /read → markRead (body: { ids: string[] })
* POST /read/all → markAllRead
*/
async handleNotification(path: string, method: string, body: any, query: any, context: HttpProtocolContext): Promise<HttpDispatcherResult> {
const service = await this.resolveService(CoreServiceName.enum.notification, context.environmentId) as any;
if (!service || typeof service.listInbox !== 'function') return { handled: false };

const userId: string | undefined = context.executionContext?.userId;
if (!userId) {
return { handled: true, response: this.error('Authentication required', 401) };
}

const m = method.toUpperCase();
const subPath = path.replace(/^\/+/, '').replace(/\/+$/, '');

Check failure

Code scanning / CodeQL

Polynomial regular expression used on uncontrolled data High

This
regular expression
that depends on
a user-provided value
may run slow on strings with many repetitions of '/'.
This
regular expression
that depends on
library input
may run slow on strings with many repetitions of '/'.
This
regular expression
that depends on
a user-provided value
may run slow on strings with many repetitions of '/'.
This
regular expression
that depends on
library input
may run slow on strings with many repetitions of '/'.
This
regular expression
that depends on
a user-provided value
may run slow on strings with many repetitions of '/'.
This
regular expression
that depends on
library input
may run slow on strings with many repetitions of '/'.
This
regular expression
that depends on
library input
may run slow on strings with many repetitions of '/'.
This
regular expression
that depends on
library input
may run slow on strings with many repetitions of '/'.

// GET /notifications — list the user's inbox joined with read-state.
if (subPath === '' && m === 'GET') {
const read = query?.read === undefined ? undefined : String(query.read) === 'true';
const limit = query?.limit ? Number(query.limit) : undefined;
const type = query?.type ? String(query.type) : undefined;
const result = await service.listInbox(userId, { read, type, limit });
return { handled: true, response: this.success(result) };
}

// POST /notifications/read — mark specific notifications read.
if (subPath === 'read' && m === 'POST') {
const ids: string[] = Array.isArray(body?.ids) ? body.ids.map((x: unknown) => String(x)) : [];
const result = await service.markRead(userId, ids);
return { handled: true, response: this.success(result) };
}

// POST /notifications/read/all — mark all of the user's inbox read.
if (subPath === 'read/all' && m === 'POST') {
const result = await service.markAllRead(userId);
return { handled: true, response: this.success(result) };
}

return { handled: false };
}

/**
* Handles i18n requests
* path: sub-path after /i18n/
Expand Down Expand Up @@ -2357,6 +2407,12 @@
return this.handleAnalytics(cleanPath.substring(10), method, body, context);
}

// In-app notifications (ADR-0030) — inbox list + receipt mark-read,
// backed by the messaging service registered under the `notification` slot.
if (cleanPath.startsWith('/notifications')) {
return this.handleNotification(cleanPath.substring(14), method, body, query, context);
}

if (cleanPath.startsWith('/packages')) {
return this.handlePackages(cleanPath.substring(9), method, body, query, context);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,15 @@ export class MessagingServicePlugin implements Plugin {

ctx.registerService('messaging', service);

// ADR-0030: the messaging service also backs the `notification` core
// service slot — it owns the in-app inbox + receipts, so it answers the
// `/api/v1/notifications` REST surface (list / mark-read / mark-all-read)
// via its inbox read API. Registering it here makes the dispatcher
// resolve + advertise those routes (`hasNotification`). The legacy
// INotificationService `send()` abstraction is unused; nothing consumes
// the slot expecting it.
ctx.registerService('notification', service);

// Register the messaging objects so their rows can be written. The
// preference/subscription objects (ADR-0030 P2) are Studio-configurable,
// so contribute them to the Setup app's Configuration slot (ADR-0029 D7)
Expand Down
137 changes: 137 additions & 0 deletions packages/services/service-messaging/src/messaging-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,3 +425,140 @@ describe('MessagingService', () => {
});
});
});

/**
* A stateful in-memory engine for the inbox read API (ADR-0030). Supports the
* flat-equality `where` filters listInbox/markRead/markAllRead issue, plus
* `update(..., { where: { id } })` mutation and `insert`.
*/
function inboxEngine(seed: { inbox?: any[]; receipts?: any[] } = {}) {
const store: Record<string, any[]> = {
sys_inbox_message: [...(seed.inbox ?? [])],
sys_notification_receipt: [...(seed.receipts ?? [])],
};
let seq = 0;
const matches = (row: any, where: any = {}) =>
Object.entries(where).every(([k, v]) => String(row[k]) === String(v));
const engine = {
store,
async find(object: string, query: any = {}) {
let rows = (store[object] ?? []).filter((r) => matches(r, query.where));
const ob = Array.isArray(query.orderBy) ? query.orderBy : [];
if (ob.some((o: any) => o.field === 'created_at' && o.order === 'desc')) {
rows = [...rows].sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)));
}
return typeof query.limit === 'number' ? rows.slice(0, query.limit) : rows;
},
async findOne(object: string, query: any = {}) {
return (store[object] ?? []).find((r) => matches(r, query.where)) ?? null;
},
async insert(object: string, row: any) {
const created = { id: `row_${++seq}`, ...row };
(store[object] ??= []).push(created);
return created;
},
async update(object: string, data: any, options: any = {}) {
for (const r of store[object] ?? []) {
if (matches(r, options.where)) Object.assign(r, data);
}
return {};
},
async delete() { return {}; },
async count() { return 0; },
async aggregate() { return []; },
};
return engine as any;
}

describe('MessagingService — inbox read API (ADR-0030)', () => {
const logger = silentLogger();

it('lists inbox rows joined with receipt read-state and counts unread', async () => {
const engine = inboxEngine({
inbox: [
{ 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' },
{ id: 'm2', user_id: 'u1', notification_id: 'n2', topic: 'task.assigned', title: 'B', body_md: 'b', created_at: '2026-01-01T00:00:02Z' },
{ id: 'm3', user_id: 'u2', notification_id: 'n3', topic: 'x', title: 'C', created_at: '2026-01-01T00:00:03Z' },
],
receipts: [
{ id: 'r1', notification_id: 'n1', user_id: 'u1', channel: 'inbox', state: 'read' },
{ id: 'r2', notification_id: 'n2', user_id: 'u1', channel: 'inbox', state: 'delivered' },
],
});
const svc = new MessagingService({ logger, getData: () => engine });

const res = await svc.listInbox('u1');
// Only u1's rows; newest first; n2 unread, n1 read.
expect(res.notifications.map((n) => n.id)).toEqual(['n2', 'n1']);
expect(res.unreadCount).toBe(1);
const n1 = res.notifications.find((n) => n.id === 'n1')!;
expect(n1).toMatchObject({ type: 'collab.mention', title: 'A', body: 'a', read: true, actionUrl: '/x' });
expect(res.notifications.find((n) => n.id === 'n2')!.read).toBe(false);
});

it('filters by read state when requested', async () => {
const engine = inboxEngine({
inbox: [
{ id: 'm1', user_id: 'u1', notification_id: 'n1', title: 'A', created_at: '1' },
{ id: 'm2', user_id: 'u1', notification_id: 'n2', title: 'B', created_at: '2' },
],
receipts: [{ id: 'r1', notification_id: 'n1', user_id: 'u1', channel: 'inbox', state: 'read' }],
});
const svc = new MessagingService({ logger, getData: () => engine });

expect((await svc.listInbox('u1', { read: false })).notifications.map((n) => n.id)).toEqual(['n2']);
expect((await svc.listInbox('u1', { read: true })).notifications.map((n) => n.id)).toEqual(['n1']);
});

it('markRead updates the existing delivered receipt in place (no duplicate)', async () => {
const engine = inboxEngine({
inbox: [{ id: 'm1', user_id: 'u1', notification_id: 'n1', title: 'A', created_at: '1' }],
receipts: [{ id: 'r1', notification_id: 'n1', user_id: 'u1', channel: 'inbox', state: 'delivered' }],
});
const svc = new MessagingService({ logger, getData: () => engine });

const res = await svc.markRead('u1', ['n1']);
expect(res).toEqual({ success: true, readCount: 1 });
const receipts = engine.store.sys_notification_receipt;
expect(receipts).toHaveLength(1); // updated in place, not duplicated
expect(receipts[0]).toMatchObject({ id: 'r1', state: 'read' });
expect(receipts[0].at).toBeTruthy();
});

it('markRead inserts a read receipt when none exists yet', async () => {
const engine = inboxEngine({
inbox: [{ id: 'm1', user_id: 'u1', notification_id: 'n1', title: 'A', created_at: '1' }],
});
const svc = new MessagingService({ logger, getData: () => engine });

const res = await svc.markRead('u1', ['n1']);
expect(res.readCount).toBe(1);
const receipts = engine.store.sys_notification_receipt;
expect(receipts).toHaveLength(1);
expect(receipts[0]).toMatchObject({ notification_id: 'n1', user_id: 'u1', channel: 'inbox', state: 'read' });
});

it('markAllRead flips every unread message and leaves already-read ones', async () => {
const engine = inboxEngine({
inbox: [
{ id: 'm1', user_id: 'u1', notification_id: 'n1', title: 'A', created_at: '1' },
{ id: 'm2', user_id: 'u1', notification_id: 'n2', title: 'B', created_at: '2' },
],
receipts: [{ id: 'r1', notification_id: 'n1', user_id: 'u1', channel: 'inbox', state: 'read' }],
});
const svc = new MessagingService({ logger, getData: () => engine });

const res = await svc.markAllRead('u1');
expect(res.readCount).toBe(1); // only n2 was unread
expect((await svc.listInbox('u1')).unreadCount).toBe(0);
});

it('degrades to empty without a data engine or user id', async () => {
const noData = new MessagingService({ logger });
expect(await noData.listInbox('u1')).toEqual({ notifications: [], unreadCount: 0 });
expect(await noData.markRead('u1', ['n1'])).toEqual({ success: true, readCount: 0 });

const svc = new MessagingService({ logger, getData: () => inboxEngine() });
expect(await svc.listInbox('')).toEqual({ notifications: [], unreadCount: 0 });
});
});
Loading
Loading