diff --git a/src/backend/clients/event/types.ts b/src/backend/clients/event/types.ts index 8e0b651d3..d54e4abce 100644 --- a/src/backend/clients/event/types.ts +++ b/src/backend/clients/event/types.ts @@ -552,6 +552,17 @@ export type EventMap = { /** Events handler runs behind whatever created it. */ handlerDepth?: number; }; + /** Every non-silent write or update, only on the node that wrote it. */ + 'notif.written': { + userId: number; + uid: string; + type: string; + /** The row's app. Absent on an update, which keeps the row's own. */ + appUid?: string | null; + value: Record; + /** An earlier notification rewritten in place. */ + updated?: true; + }; /** * One notification, addressed at whatever sockets each region holds for the * recipient. `outer.*` reaches peer regions and never sibling nodes, so diff --git a/src/backend/services/events/registry.ts b/src/backend/services/events/registry.ts index 7389c47d7..3544c68e1 100644 --- a/src/backend/services/events/registry.ts +++ b/src/backend/services/events/registry.ts @@ -444,6 +444,10 @@ export const UNPUBLISHED_INTERNAL_EVENTS = [ event: 'kv.share.retired', reason: 'A share handle stopping is not a change to any key; it ends the subscriptions made through the handle rather than being delivered to them.', }, + { + event: 'notif.written', + reason: 'A node-local hook after a notification is written, for extensions; the notification itself publishes through `notif.created`.', + }, ] as const satisfies readonly UnpublishedInternalEvent[]; // Every `fs.*`/`kv.*`/`notif.*` bus key needs a decision. A new key that is in diff --git a/src/backend/services/notification/NotificationService.test.ts b/src/backend/services/notification/NotificationService.test.ts index 93e4463aa..68f8d14af 100644 --- a/src/backend/services/notification/NotificationService.test.ts +++ b/src/backend/services/notification/NotificationService.test.ts @@ -338,6 +338,79 @@ describe('NotificationService.notifyUpdate', () => { }); }); +describe("NotificationService — 'notif.written'", () => { + it('fires once per recipient whose row was written', async () => { + const a = await makeUser(); + const b = await makeUser(); + const appUid = `app-${uuidv4()}`; + const written = collect('notif.written'); + + await notifications.notify( + [a.id, b.id], + { title: 'ended' }, + { type: 'app.events.ended', appUid }, + ); + await waitFor(() => written.seen.length === 2); + + const rows = await Promise.all( + [a, b].map((u) => + server.stores.notification.listByUserId(u.id, {}), + ), + ); + const expected = [a, b].map((u, i) => ({ + userId: u.id, + uid: rows[i][0].uid, + type: 'app.events.ended', + appUid, + value: { title: 'ended', type: 'app.events.ended' }, + })); + expect(written.seen).toEqual(expect.arrayContaining(expected)); + written.stop(); + }); + + it('stays quiet for a silent write', async () => { + const user = await makeUser(); + const written = collect('notif.written'); + + await notifications.notify( + [user.id], + { title: 'quiet' }, + { type: 'share.received', silent: true }, + ); + await new Promise((r) => setTimeout(r, 50)); + + expect(written.seen).toEqual([]); + written.stop(); + }); + + it('marks a rewrite in place as updated', async () => { + const user = await makeUser(); + const uid = await notifications.notify( + [user.id], + { title: 'one share' }, + { type: 'share.received' }, + ); + const written = collect('notif.written'); + + await notifications.notifyUpdate( + uid!, + user.id, + { title: 'two shares' }, + { type: 'share.received' }, + ); + await waitFor(() => written.seen.length === 1); + + expect(written.seen[0]).toEqual({ + userId: user.id, + uid, + type: 'share.received', + value: { title: 'two shares', type: 'share.received' }, + updated: true, + }); + written.stop(); + }); +}); + describe('NotificationService.markAcknowledged', () => { it("acknowledges the row and pushes an ack to the user's other tabs", async () => { const user = await makeUser(); diff --git a/src/backend/services/notification/NotificationService.ts b/src/backend/services/notification/NotificationService.ts index 078317a84..c12962a23 100644 --- a/src/backend/services/notification/NotificationService.ts +++ b/src/backend/services/notification/NotificationService.ts @@ -169,12 +169,24 @@ export class NotificationService extends PuterService { return null; } - if (!opts.silent) + if (!opts.silent) { + this.clients.event.emit( + 'notif.written', + { + userId, + uid, + type: registered.type, + appUid, + value: payload, + }, + {}, + ); await this.#push(userId, uid, payload, { type: registered.type, audience: registered.audience, appUid, }); + } return uid; }), ); @@ -217,6 +229,17 @@ export class NotificationService extends PuterService { // to anything deduplicating on `event.id`. this.#socket().message(userId, uid, payload); this.#markDelivered(uid, userId); + this.clients.event.emit( + 'notif.written', + { + userId, + uid, + type: registered.type, + value: payload, + updated: true, + }, + {}, + ); } return true; }