diff --git a/src/backend/clients/event/types.ts b/src/backend/clients/event/types.ts index d54e4abce..99036d2ac 100644 --- a/src/backend/clients/event/types.ts +++ b/src/backend/clients/event/types.ts @@ -552,13 +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. */ + /** + * Every non-silent write or update, only on the node that wrote it. The + * value is the listener's own copy of what the socket carries. + */ '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; + audience: string; + /** The row's app; null when it names none or an update could not tell. */ + appUid: string | null; value: Record; /** An earlier notification rewritten in place. */ updated?: true; diff --git a/src/backend/services/notification/NotificationService.test.ts b/src/backend/services/notification/NotificationService.test.ts index 68f8d14af..ab7065f2a 100644 --- a/src/backend/services/notification/NotificationService.test.ts +++ b/src/backend/services/notification/NotificationService.test.ts @@ -3,18 +3,19 @@ * * This file is part of Puter. * - * Puter is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. + * Puter is free software: you can redistribute it and/or modify it under the + * terms of the GNU Affero General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) any + * later version. * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. + * This program is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more + * details. * * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . + * along with this program. If not, see + * [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/). */ import { v4 as uuidv4 } from 'uuid'; @@ -361,6 +362,7 @@ describe("NotificationService — 'notif.written'", () => { userId: u.id, uid: rows[i][0].uid, type: 'app.events.ended', + audience: 'app-user', appUid, value: { title: 'ended', type: 'app.events.ended' }, })); @@ -404,11 +406,82 @@ describe("NotificationService — 'notif.written'", () => { userId: user.id, uid, type: 'share.received', + audience: 'account', + appUid: null, value: { title: 'two shares', type: 'share.received' }, updated: true, }); written.stop(); }); + + it("names the row's app on a rewrite", async () => { + const user = await makeUser(); + const uid = uuidv4(); + const appUid = `app-${uuidv4()}`; + await server.stores.notification.insert({ + userId: user.id, + value: { title: 'one', type: 'share.received' }, + uid, + type: 'share.received', + audience: 'account', + appUid, + }); + const written = collect('notif.written'); + + await notifications.notifyUpdate( + uid, + user.id, + { title: 'two' }, + { type: 'share.received' }, + ); + await waitFor(() => written.seen.length === 1); + + expect(written.seen[0]).toMatchObject({ uid, appUid, updated: true }); + written.stop(); + }); + + it('hands each listener its own copy of the value', async () => { + const user = await makeUser(); + const handler = (_k: string, data: unknown) => { + const value = (data as { value: Record }).value; + value.title = 'changed by a listener'; + (value.fields as Record).path = '/elsewhere'; + }; + server.clients.event.on('notif.written', handler); + const pushed = collect('outer.gui.notif.message'); + try { + const uid = await notifications.notify( + [user.id], + { title: 'one share', fields: { path: '/a' } }, + { type: 'share.received' }, + ); + await notifications.notifyUpdate( + uid!, + user.id, + { title: 'two shares', fields: { path: '/b' } }, + { type: 'share.received' }, + ); + await waitFor(() => pushed.seen.length === 2); + } finally { + server.clients.event.off?.('notif.written', handler); + pushed.stop(); + } + + expect( + pushed.seen.map((p) => (p as NotifPush).response.notification), + ).toEqual([ + { + title: 'one share', + fields: { path: '/a' }, + type: 'share.received', + }, + { + title: 'two shares', + fields: { path: '/b' }, + type: 'share.received', + }, + ]); + }); }); describe('NotificationService.markAcknowledged', () => { @@ -608,7 +681,10 @@ describe('NotificationService — shown on delivery', () => { { type: 'share.received' }, ); - const [row] = await server.stores.notification.listByUserId(user.id, {}); + const [row] = await server.stores.notification.listByUserId( + user.id, + {}, + ); expect(row.uid).toBe(uid); expect(row.shown).toBeTruthy(); diff --git a/src/backend/services/notification/NotificationService.ts b/src/backend/services/notification/NotificationService.ts index c12962a23..5f661c4c3 100644 --- a/src/backend/services/notification/NotificationService.ts +++ b/src/backend/services/notification/NotificationService.ts @@ -170,14 +170,16 @@ export class NotificationService extends PuterService { } if (!opts.silent) { + // Listeners get their own copy; the socket sends `payload`. this.clients.event.emit( 'notif.written', { userId, uid, type: registered.type, + audience: registered.audience, appUid, - value: payload, + value: structuredClone(payload), }, {}, ); @@ -229,13 +231,21 @@ export class NotificationService extends PuterService { // to anything deduplicating on `event.id`. this.#socket().message(userId, uid, payload); this.#markDelivered(uid, userId); + const row = await this.stores.notification.getByUid(uid, { + userId, + }); + const app = payload.app as { uid?: unknown } | undefined; this.clients.event.emit( 'notif.written', { userId, uid, type: registered.type, - value: payload, + audience: registered.audience, + appUid: + row?.app_uid ?? + (typeof app?.uid === 'string' ? app.uid : null), + value: structuredClone(payload), updated: true, }, {},