diff --git a/.changeset/notification-action-url-from-source.md b/.changeset/notification-action-url-from-source.md new file mode 100644 index 000000000..5e908f8c7 --- /dev/null +++ b/.changeset/notification-action-url-from-source.md @@ -0,0 +1,22 @@ +--- +"@objectstack/service-messaging": minor +--- + +Synthesize the inbox `action_url` from the event `source` (ADR-0030 L5). + +The Console bell reads `sys_inbox_message` (the L5 in-app materialization), +which carries only `action_url` — not the L2 `sys_notification` 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 the bell entry couldn't +deep-link to the originating record. + +`emit()` now synthesizes an app-relative `/{object}/{id}` link from `source` +when no explicit `payload.url`/`payload.actionUrl` is supplied — in both the +inline fan-out and the durable-outbox enqueue paths (`actionUrlFor()`). +Precedence: explicit url → source-derived link → `undefined`. Keeps the L5 +materialization self-sufficient for navigation (the objectui bell consumes +`action_url`). + +Tests: 3 new `messaging-service.test.ts` cases (source→link, explicit-url +precedence, neither→undefined); all 95 service-messaging tests green. diff --git a/packages/services/service-messaging/src/messaging-service.test.ts b/packages/services/service-messaging/src/messaging-service.test.ts index 3e14d6712..a9f4b3d26 100644 --- a/packages/services/service-messaging/src/messaging-service.test.ts +++ b/packages/services/service-messaging/src/messaging-service.test.ts @@ -105,6 +105,39 @@ describe('MessagingService', () => { expect(result.deliveries[0]).toMatchObject({ channel: 'inbox', recipient: 'user_1', ok: true, externalId: 'row_1' }); }); + it('synthesizes an action_url from source when no explicit url is given (ADR-0030 L5 deep-link)', async () => { + const inbox = recordingChannel('inbox'); + service.registerChannel(inbox.channel); + await service.emit({ + topic: 'collab.assignment', + audience: ['user_1'], + payload: { title: 'Assigned to you' }, + source: { object: 'showcase_task', id: 't_42' }, + }); + // The materialization carries a navigable link the bell can follow, + // even though the producer didn't set payload.url. + expect(inbox.seen[0].notification.actionUrl).toBe('/showcase_task/t_42'); + }); + + it('prefers an explicit payload.url over the source-derived link', async () => { + const inbox = recordingChannel('inbox'); + service.registerChannel(inbox.channel); + await service.emit({ + topic: 't', + audience: ['user_1'], + payload: { title: 'Hi', url: '/custom/landing' }, + source: { object: 'showcase_task', id: 't_42' }, + }); + expect(inbox.seen[0].notification.actionUrl).toBe('/custom/landing'); + }); + + it('leaves action_url undefined when there is neither a url nor a source', async () => { + const inbox = recordingChannel('inbox'); + service.registerChannel(inbox.channel); + await service.emit({ topic: 't', audience: ['user_1'], payload: { title: 'Hi' } }); + expect(inbox.seen[0].notification.actionUrl).toBeUndefined(); + }); + it('accepts a single (non-array) audience entry', async () => { const inbox = recordingChannel('inbox'); service.registerChannel(inbox.channel); diff --git a/packages/services/service-messaging/src/messaging-service.ts b/packages/services/service-messaging/src/messaging-service.ts index 4bcbbcb97..3fc78b98c 100644 --- a/packages/services/service-messaging/src/messaging-service.ts +++ b/packages/services/service-messaging/src/messaging-service.ts @@ -239,7 +239,7 @@ export class MessagingService { severity: input.severity ?? 'info', recipients, channels: input.channels, - actionUrl: str(payload.url) ?? str(payload.actionUrl), + actionUrl: actionUrlFor(input, payload), payload: input.payload, }; @@ -267,7 +267,7 @@ export class MessagingService { title: str(payload.title) ?? input.topic, body: str(payload.body) ?? '', severity: input.severity ?? 'info', - actionUrl: str(payload.url) ?? str(payload.actionUrl), + actionUrl: actionUrlFor(input, payload), }; const deliveries: DeliveryOutcome[] = []; for (const { recipient, channels, notBefore } of targets) { @@ -392,3 +392,19 @@ function str(v: unknown): string | undefined { const s = String(v); return s.length > 0 ? s : undefined; } + +/** + * The deep-link the in-app materialization should carry. An explicit + * `payload.url`/`payload.actionUrl` wins; otherwise, when the emit names a + * `source` record, synthesize an app-relative `/{object}/{id}` link so the + * materialization is self-sufficient for navigation (the bell no longer has the + * L2 event's `source_object/source_id` to fall back on — ADR-0030 L5). Returns + * `undefined` when there is nothing to link to. + */ +function actionUrlFor(input: EmitInput, payload: Record): string | undefined { + const explicit = str(payload.url) ?? str(payload.actionUrl); + if (explicit) return explicit; + const obj = str(input.source?.object); + const id = str(input.source?.id); + return obj && id ? `/${obj}/${id}` : undefined; +}