Skip to content

Commit 3a45780

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(messaging): synthesize inbox action_url from event source (ADR-0030 L5) (#1456)
* feat(messaging): synthesize inbox action_url from event source (ADR-0030 L5) The bell reads `sys_inbox_message` (the L5 materialization), which carries only `action_url` — not the L2 event's `source_object`/`source_id`. Producers that pass a `source` but no explicit `payload.url` (collaboration @mention, record assignment) therefore materialized inbox rows with no navigable link, so clicking the bell entry couldn't deep-link to the record. Synthesize an app-relative `/{object}/{id}` link from `emit()`'s `source` when no explicit `payload.url`/`payload.actionUrl` is given, in both the inline fan-out and the durable-outbox enqueue paths. An explicit url still wins; with neither url nor source the link stays undefined. This keeps the materialization self-sufficient for navigation (the objectui bell consumes action_url). Tests: 3 new cases (source→link, explicit-url precedence, neither→undefined); all 95 service-messaging tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore: add changeset for messaging action_url synthesis Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 70b63f2 commit 3a45780

3 files changed

Lines changed: 73 additions & 2 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
"@objectstack/service-messaging": minor
3+
---
4+
5+
Synthesize the inbox `action_url` from the event `source` (ADR-0030 L5).
6+
7+
The Console bell reads `sys_inbox_message` (the L5 in-app materialization),
8+
which carries only `action_url` — not the L2 `sys_notification` event's
9+
`source_object`/`source_id`. Producers that pass a `source` but no explicit
10+
`payload.url` (collaboration `@mention`, record assignment) therefore
11+
materialized inbox rows with no navigable link, so the bell entry couldn't
12+
deep-link to the originating record.
13+
14+
`emit()` now synthesizes an app-relative `/{object}/{id}` link from `source`
15+
when no explicit `payload.url`/`payload.actionUrl` is supplied — in both the
16+
inline fan-out and the durable-outbox enqueue paths (`actionUrlFor()`).
17+
Precedence: explicit url → source-derived link → `undefined`. Keeps the L5
18+
materialization self-sufficient for navigation (the objectui bell consumes
19+
`action_url`).
20+
21+
Tests: 3 new `messaging-service.test.ts` cases (source→link, explicit-url
22+
precedence, neither→undefined); all 95 service-messaging tests green.

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,39 @@ describe('MessagingService', () => {
105105
expect(result.deliveries[0]).toMatchObject({ channel: 'inbox', recipient: 'user_1', ok: true, externalId: 'row_1' });
106106
});
107107

108+
it('synthesizes an action_url from source when no explicit url is given (ADR-0030 L5 deep-link)', async () => {
109+
const inbox = recordingChannel('inbox');
110+
service.registerChannel(inbox.channel);
111+
await service.emit({
112+
topic: 'collab.assignment',
113+
audience: ['user_1'],
114+
payload: { title: 'Assigned to you' },
115+
source: { object: 'showcase_task', id: 't_42' },
116+
});
117+
// The materialization carries a navigable link the bell can follow,
118+
// even though the producer didn't set payload.url.
119+
expect(inbox.seen[0].notification.actionUrl).toBe('/showcase_task/t_42');
120+
});
121+
122+
it('prefers an explicit payload.url over the source-derived link', async () => {
123+
const inbox = recordingChannel('inbox');
124+
service.registerChannel(inbox.channel);
125+
await service.emit({
126+
topic: 't',
127+
audience: ['user_1'],
128+
payload: { title: 'Hi', url: '/custom/landing' },
129+
source: { object: 'showcase_task', id: 't_42' },
130+
});
131+
expect(inbox.seen[0].notification.actionUrl).toBe('/custom/landing');
132+
});
133+
134+
it('leaves action_url undefined when there is neither a url nor a source', async () => {
135+
const inbox = recordingChannel('inbox');
136+
service.registerChannel(inbox.channel);
137+
await service.emit({ topic: 't', audience: ['user_1'], payload: { title: 'Hi' } });
138+
expect(inbox.seen[0].notification.actionUrl).toBeUndefined();
139+
});
140+
108141
it('accepts a single (non-array) audience entry', async () => {
109142
const inbox = recordingChannel('inbox');
110143
service.registerChannel(inbox.channel);

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ export class MessagingService {
239239
severity: input.severity ?? 'info',
240240
recipients,
241241
channels: input.channels,
242-
actionUrl: str(payload.url) ?? str(payload.actionUrl),
242+
actionUrl: actionUrlFor(input, payload),
243243
payload: input.payload,
244244
};
245245

@@ -267,7 +267,7 @@ export class MessagingService {
267267
title: str(payload.title) ?? input.topic,
268268
body: str(payload.body) ?? '',
269269
severity: input.severity ?? 'info',
270-
actionUrl: str(payload.url) ?? str(payload.actionUrl),
270+
actionUrl: actionUrlFor(input, payload),
271271
};
272272
const deliveries: DeliveryOutcome[] = [];
273273
for (const { recipient, channels, notBefore } of targets) {
@@ -392,3 +392,19 @@ function str(v: unknown): string | undefined {
392392
const s = String(v);
393393
return s.length > 0 ? s : undefined;
394394
}
395+
396+
/**
397+
* The deep-link the in-app materialization should carry. An explicit
398+
* `payload.url`/`payload.actionUrl` wins; otherwise, when the emit names a
399+
* `source` record, synthesize an app-relative `/{object}/{id}` link so the
400+
* materialization is self-sufficient for navigation (the bell no longer has the
401+
* L2 event's `source_object/source_id` to fall back on — ADR-0030 L5). Returns
402+
* `undefined` when there is nothing to link to.
403+
*/
404+
function actionUrlFor(input: EmitInput, payload: Record<string, unknown>): string | undefined {
405+
const explicit = str(payload.url) ?? str(payload.actionUrl);
406+
if (explicit) return explicit;
407+
const obj = str(input.source?.object);
408+
const id = str(input.source?.id);
409+
return obj && id ? `/${obj}/${id}` : undefined;
410+
}

0 commit comments

Comments
 (0)