diff --git a/plugins/rw-backend-module-notifications/README.md b/plugins/rw-backend-module-notifications/README.md index fb2b490..03135d1 100644 --- a/plugins/rw-backend-module-notifications/README.md +++ b/plugins/rw-backend-module-notifications/README.md @@ -94,6 +94,94 @@ The owner-side notification therefore reads as _"which docs need attention"_ (do realtime `new_notification` signal on every event even when it only updates an existing row, so the Web badge still blinks per event (one row, repeated signal). +### Customizing who gets notified + +By default a new top-level comment notifies the section's owning group (which Backstage expands +to all members) and a reply/resolve notifies the prior thread participants. To change **who** is +notified — for example, notify a list of entity *maintainers* (users) instead of the whole owning +group — register a `CommentRecipientResolver` from a sibling backend module. + +The resolver owns the recipient decision for every audience and returns the recipient entity refs +(users or groups; `[]` means notify nobody). Delegate the audiences you don't want to change to the +built-in `DefaultCommentRecipientResolver`, so you only write the branch you care about: + +```ts +// packages/backend/src/rwMaintainerRecipients.ts +import { createBackendModule, coreServices } from "@backstage/backend-plugin-api"; +import { catalogServiceRef } from "@backstage/plugin-catalog-node"; +import { + rwCommentRecipientExtensionPoint, + DefaultCommentRecipientResolver, + type CommentRecipientResolver, +} from "@rwdocs/backstage-plugin-rw-backend-module-notifications"; +import { type CommentActivity } from "@rwdocs/backstage-plugin-rw-node"; + +class MaintainerRecipientResolver implements CommentRecipientResolver { + private readonly base = new DefaultCommentRecipientResolver(); + + constructor(private readonly deps: { catalog: typeof catalogServiceRef.T; auth: typeof coreServices.auth.T }) {} + + getName(): string { + return "maintainer-recipients"; + } + + async resolveRecipients(activity: CommentActivity): Promise { + // Only override new top-level threads; replies/resolves keep the default participant policy. + const isNewThread = activity.action === "created" && activity.parentId === null; + if (!isNewThread || !activity.entityRef) return this.base.resolveRecipients(activity); + + // Read e.g. a `mycompany.com/maintainers` annotation off the owning entity and map it to + // user refs. Fall back to the default if there are none. + const credentials = await this.deps.auth.getOwnServiceCredentials(); + const entity = await this.deps.catalog.getEntityByRef(activity.entityRef, { credentials }); + const maintainers = (entity?.metadata.annotations?.["mycompany.com/maintainers"] ?? "") + .split(",") + .map(s => s.trim()) + .filter(Boolean) + .map(name => `user:default/${name}`); + return maintainers.length ? maintainers : this.base.resolveRecipients(activity); + } +} + +export default createBackendModule({ + pluginId: "rw", // MUST be "rw" — extension points are plugin-scoped + moduleId: "maintainer-recipients", + register(reg) { + reg.registerInit({ + deps: { + recipients: rwCommentRecipientExtensionPoint, + catalog: catalogServiceRef, + auth: coreServices.auth, + }, + async init({ recipients, catalog, auth }) { + recipients.setRecipientResolver(new MaintainerRecipientResolver({ catalog, auth })); + }, + }); + }, +}); +``` + +```ts +// packages/backend/src/index.ts — install alongside the notifications module +backend.add(import("@rwdocs/backstage-plugin-rw-backend-module-notifications")); +backend.add(import("./rwMaintainerRecipients")); +``` + +Notes: + +- **The notifications module must be installed.** `rwCommentRecipientExtensionPoint` is registered + by this module; a resolver module that depends on it will fail fast at backend startup if the + notifications module isn't installed. +- **Only one resolver may be registered** — a second `setRecipientResolver` throws at startup. +- **Recipients only.** The resolver cannot change the coalescing `scope` or the notification + `topic`; those stay tied to the activity kind (per-page for new threads, per-thread for + replies/resolves), so per-page burst-coalescing still applies even when you redirect new-thread + recipients. +- **Fail-closed.** If a resolver throws, the notification is discarded (logged at `error`) rather + than falling back to the default audience — a custom resolver exists to restrict the audience, so + a transient error must not leak the notification to the group you meant to exclude. +- The resolver wires its own `catalog`/`auth`; the notifications module gains no catalog dependency. + ### Optional: Slack delivery The official `@backstage/plugin-notifications-backend-module-slack` (a first-party diff --git a/plugins/rw-backend-module-notifications/src/CommentNotifier.test.ts b/plugins/rw-backend-module-notifications/src/CommentNotifier.test.ts index ab6840b..556581a 100644 --- a/plugins/rw-backend-module-notifications/src/CommentNotifier.test.ts +++ b/plugins/rw-backend-module-notifications/src/CommentNotifier.test.ts @@ -189,4 +189,72 @@ describe("CommentNotifier", () => { expect(send).not.toHaveBeenCalled(); }); + + describe("with a custom recipient resolver", () => { + it("uses the resolver's recipients verbatim (default policy ignored)", async () => { + const custom = { + getName: () => "custom", + resolveRecipients: jest.fn(async () => [ + "user:default/maintainer-a", + "user:default/maintainer-b", + ]), + }; + const n = new CommentNotifier({ + notifications: { send } as any, + logger, + recipientResolver: custom, + }); + + await n.process(makeActivity()); // sectionOwnerRef is group:default/docs — must be ignored + + expect(custom.resolveRecipients).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0][0].recipients).toEqual({ + type: "entity", + entityRef: ["user:default/maintainer-a", "user:default/maintainer-b"], + excludeEntityRef: "user:default/jane", + }); + // scope/topic still derive from the activity kind, not the recipients + expect(send.mock.calls[0][0].payload.topic).toBe("comment:thread:created"); + expect(send.mock.calls[0][0].payload.scope).toBe( + "rw:page:component:default/site|sec-1#guide", + ); + }); + + it("resolver returns [] → send NOT called", async () => { + const custom = { + getName: () => "custom", + resolveRecipients: jest.fn(async () => [] as string[]), + }; + const n = new CommentNotifier({ + notifications: { send } as any, + logger, + recipientResolver: custom, + }); + + await n.process(makeActivity()); + + expect(send).not.toHaveBeenCalled(); + }); + + it("resolver throws → discards (send NOT called), logs at error, never throws", async () => { + const boom = new Error("catalog down"); + const custom = { + getName: () => "custom", + resolveRecipients: jest.fn(async () => { + throw boom; + }), + }; + const n = new CommentNotifier({ + notifications: { send } as any, + logger, + recipientResolver: custom, + }); + + await expect(n.process(makeActivity())).resolves.toBeUndefined(); + + expect(send).not.toHaveBeenCalled(); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining("custom")); + }); + }); }); diff --git a/plugins/rw-backend-module-notifications/src/CommentNotifier.ts b/plugins/rw-backend-module-notifications/src/CommentNotifier.ts index 36c8a08..95dc1e2 100644 --- a/plugins/rw-backend-module-notifications/src/CommentNotifier.ts +++ b/plugins/rw-backend-module-notifications/src/CommentNotifier.ts @@ -3,20 +3,29 @@ import { parseEntityRef } from "@backstage/catalog-model"; import { NotificationService } from "@backstage/plugin-notifications-node"; import { buildCommentDeepLinkSuffix } from "@rwdocs/backstage-plugin-rw-common"; import { CommentActivity, CommentProcessor } from "@rwdocs/backstage-plugin-rw-node"; +import { CommentRecipientResolver } from "./CommentRecipientResolver"; +import { DefaultCommentRecipientResolver } from "./DefaultCommentRecipientResolver"; +import { isNewThread } from "./activityKind"; /** CommentProcessor that turns a resolved CommentActivity into a native notification. - * Presentation + delivery only — no DB, events, or catalog. Computes recipients from the - * activity's raw owner/participants fields; the actor is forwarded as excludeEntityRef so the - * notifications resolver drops them after expanding groups (the single point of actor + * Presentation + delivery only — no DB, events, or catalog. Delegates recipient selection to a + * CommentRecipientResolver (custom-or-default); the actor is forwarded as excludeEntityRef so + * the notifications resolver drops them after expanding groups (the single point of actor * exclusion). The one catalog-path convention (`/catalog///`) is isolated here. * Best-effort: catches + logs on send failure, never throws. */ export class CommentNotifier implements CommentProcessor { private readonly notifications: NotificationService; private readonly logger: LoggerService; + private readonly recipientResolver: CommentRecipientResolver; - constructor(opts: { notifications: NotificationService; logger: LoggerService }) { + constructor(opts: { + notifications: NotificationService; + logger: LoggerService; + recipientResolver?: CommentRecipientResolver; + }) { this.notifications = opts.notifications; this.logger = opts.logger; + this.recipientResolver = opts.recipientResolver ?? new DefaultCommentRecipientResolver(); } getName(): string { @@ -25,24 +34,28 @@ export class CommentNotifier implements CommentProcessor { async process(comment: CommentActivity): Promise { let recipients: string[]; - let scope: string; - if (comment.action === "created" && comment.parentId === null) { - // Owner-side burst path: coalesce per-page so a hot doc collapses into one - // self-updating Web inbox row per recipient group, not one row per comment. - recipients = comment.sectionOwnerRef ? [comment.sectionOwnerRef] : []; - scope = `rw:page:${comment.siteRef}|${comment.pageRef}`; - } else { - // Participant-side (replies + resolves): per-thread, so each conversation keeps its - // own self-updating row rather than coalescing across threads on the same page. - recipients = comment.participants; - scope = `rw:comment:${comment.rootId}`; + try { + recipients = await this.recipientResolver.resolveRecipients(comment); + } catch (error) { + // Fail-closed: a custom resolver exists to change/restrict the audience, so never fall + // back to the broader default on error — discard rather than risk notifying the wrong people. + this.logger.error( + `rw.comments recipient resolver ${this.recipientResolver.getName()} threw; discarding notification (comment ${comment.commentId}): ${error}`, + ); + return; } if (recipients.length === 0) return; // nothing to notify + // Coalescing scope is a property of the activity kind, not the recipients: a new top-level + // thread coalesces per-page; replies/resolves coalesce per-thread. + const scope = isNewThread(comment) + ? `rw:page:${comment.siteRef}|${comment.pageRef}` + : `rw:comment:${comment.rootId}`; + try { await this.notifications.send({ - // Single point of actor exclusion: the resolver drops the actor from the resolved - // recipients, including from an expanded group. + // Single point of actor exclusion: Backstage's notification resolver drops the actor + // from the recipients via this field, including from an expanded group. recipients: { type: "entity", entityRef: recipients, @@ -82,7 +95,7 @@ export class CommentNotifier implements CommentProcessor { const s = this.subject(comment); const a = this.actor(comment); if (comment.action === "resolved") return `${a} resolved a thread on ${s}`; - if (comment.parentId === null) return `${a} commented on ${s}`; + if (isNewThread(comment)) return `${a} commented on ${s}`; return `${a} replied on ${s}`; } @@ -91,7 +104,7 @@ export class CommentNotifier implements CommentProcessor { * settings key hash). Mirrors the title() branch. */ private topic(comment: CommentActivity): string { if (comment.action === "resolved") return "comment:thread:resolved"; - if (comment.parentId === null) return "comment:thread:created"; + if (isNewThread(comment)) return "comment:thread:created"; return "comment:reply:created"; } diff --git a/plugins/rw-backend-module-notifications/src/CommentRecipientResolver.ts b/plugins/rw-backend-module-notifications/src/CommentRecipientResolver.ts new file mode 100644 index 0000000..0ca33c1 --- /dev/null +++ b/plugins/rw-backend-module-notifications/src/CommentRecipientResolver.ts @@ -0,0 +1,15 @@ +import { CommentActivity } from "@rwdocs/backstage-plugin-rw-node"; + +/** Decides the notification recipients for a resolved comment activity. A single resolver owns + * the decision for every audience (new threads and replies/resolves); delegate the audiences you + * don't want to change to the built-in DefaultCommentRecipientResolver. Mirrors Backstage's + * NotificationRecipientResolver: a single, full-ownership resolver with no per-call defer + * sentinel. */ +export interface CommentRecipientResolver { + /** Stable name for logging. */ + getName(): string; + /** Recipient entity refs (users or groups; groups are expanded downstream by Backstage's + * notification resolver). Return [] to notify nobody. Should not throw — a throw discards the + * notification (fail-closed). */ + resolveRecipients(activity: CommentActivity): Promise; +} diff --git a/plugins/rw-backend-module-notifications/src/DefaultCommentRecipientResolver.test.ts b/plugins/rw-backend-module-notifications/src/DefaultCommentRecipientResolver.test.ts new file mode 100644 index 0000000..dce8f90 --- /dev/null +++ b/plugins/rw-backend-module-notifications/src/DefaultCommentRecipientResolver.test.ts @@ -0,0 +1,76 @@ +import { type CommentActivity } from "@rwdocs/backstage-plugin-rw-node"; +import { DefaultCommentRecipientResolver } from "./DefaultCommentRecipientResolver"; + +function makeActivity(over: Partial = {}): CommentActivity { + return { + action: "created", + occurredAt: "2026-06-26T00:00:00.000Z", + commentId: "c1", + rootId: "c1", + parentId: null, + siteRef: "component:default/site", + sectionRef: "sec-1", + pageRef: "sec-1#guide", + actorRef: "user:default/jane", + actorName: "Jane Doe", + participants: ["user:default/jane"], + sectionOwnerRef: "group:default/docs", + entityRef: "component:default/my-docs", + pageTitle: "Guide", + sectionTitle: "Docs", + viewerPath: "guide", + bodySnippet: "hello", + ...over, + }; +} + +describe("DefaultCommentRecipientResolver", () => { + const resolver = new DefaultCommentRecipientResolver(); + + it("getName() returns 'rw-default-recipients'", () => { + expect(resolver.getName()).toBe("rw-default-recipients"); + }); + + it("new top-level thread → [sectionOwnerRef]", async () => { + await expect(resolver.resolveRecipients(makeActivity())).resolves.toEqual([ + "group:default/docs", + ]); + }); + + it("new top-level thread with null sectionOwnerRef → []", async () => { + await expect( + resolver.resolveRecipients(makeActivity({ sectionOwnerRef: null })), + ).resolves.toEqual([]); + }); + + it("reply (parentId set) → participants", async () => { + await expect( + resolver.resolveRecipients( + makeActivity({ + commentId: "c2", + parentId: "c1", + participants: ["user:default/jane", "user:default/bob"], + }), + ), + ).resolves.toEqual(["user:default/jane", "user:default/bob"]); + }); + + it("reply with empty participants → []", async () => { + await expect( + resolver.resolveRecipients(makeActivity({ parentId: "c1", participants: [] })), + ).resolves.toEqual([]); + }); + + it("resolve action → participants", async () => { + await expect( + resolver.resolveRecipients( + makeActivity({ + action: "resolved", + commentId: "c2", + rootId: "c1", + participants: ["user:default/jane", "user:default/bob"], + }), + ), + ).resolves.toEqual(["user:default/jane", "user:default/bob"]); + }); +}); diff --git a/plugins/rw-backend-module-notifications/src/DefaultCommentRecipientResolver.ts b/plugins/rw-backend-module-notifications/src/DefaultCommentRecipientResolver.ts new file mode 100644 index 0000000..5c27f95 --- /dev/null +++ b/plugins/rw-backend-module-notifications/src/DefaultCommentRecipientResolver.ts @@ -0,0 +1,19 @@ +import { CommentActivity } from "@rwdocs/backstage-plugin-rw-node"; +import { CommentRecipientResolver } from "./CommentRecipientResolver"; +import { isNewThread } from "./activityKind"; + +/** The built-in recipient policy: a new top-level thread notifies the section's effective owner; + * a reply or resolve notifies the thread's prior participants. Stateless and never throws, so a + * custom resolver can construct one and delegate the audiences it doesn't override. */ +export class DefaultCommentRecipientResolver implements CommentRecipientResolver { + getName(): string { + return "rw-default-recipients"; + } + + async resolveRecipients(activity: CommentActivity): Promise { + if (isNewThread(activity)) { + return activity.sectionOwnerRef ? [activity.sectionOwnerRef] : []; + } + return activity.participants; + } +} diff --git a/plugins/rw-backend-module-notifications/src/activityKind.ts b/plugins/rw-backend-module-notifications/src/activityKind.ts new file mode 100644 index 0000000..04d9fe3 --- /dev/null +++ b/plugins/rw-backend-module-notifications/src/activityKind.ts @@ -0,0 +1,9 @@ +import { CommentActivity } from "@rwdocs/backstage-plugin-rw-node"; + +/** A "new thread" is a top-level comment creation (not a reply, not a resolve). The owner-side + * vs participant-side notification policy — recipients, coalescing scope, and topic — all branch + * on this single predicate, so it lives in one place to keep the notifier and the default + * resolver from drifting. */ +export function isNewThread(activity: CommentActivity): boolean { + return activity.action === "created" && activity.parentId === null; +} diff --git a/plugins/rw-backend-module-notifications/src/extensionPoints.ts b/plugins/rw-backend-module-notifications/src/extensionPoints.ts new file mode 100644 index 0000000..e7c1b5e --- /dev/null +++ b/plugins/rw-backend-module-notifications/src/extensionPoints.ts @@ -0,0 +1,16 @@ +import { createExtensionPoint } from "@backstage/backend-plugin-api"; +import { CommentRecipientResolver } from "./CommentRecipientResolver"; + +export interface RwCommentRecipientExtensionPoint { + /** Replace the built-in comment-notification recipient policy. May only be called once. */ + setRecipientResolver(resolver: CommentRecipientResolver): void; +} + +/** Registered by the notifications module; a sibling backend module (same pluginId "rw") consumes + * it and calls setRecipientResolver in its init to override who is notified about doc comments. + * Single resolver — a second registration throws (mirrors Backstage's setNotificationRecipientResolver + * / Slack's setBlockKitRenderer). */ +export const rwCommentRecipientExtensionPoint = + createExtensionPoint({ + id: "rw.comment-recipients", + }); diff --git a/plugins/rw-backend-module-notifications/src/index.ts b/plugins/rw-backend-module-notifications/src/index.ts index f79b64f..6f5e02f 100644 --- a/plugins/rw-backend-module-notifications/src/index.ts +++ b/plugins/rw-backend-module-notifications/src/index.ts @@ -1 +1,5 @@ export { default } from "./module"; +export { rwCommentRecipientExtensionPoint } from "./extensionPoints"; +export type { RwCommentRecipientExtensionPoint } from "./extensionPoints"; +export type { CommentRecipientResolver } from "./CommentRecipientResolver"; +export { DefaultCommentRecipientResolver } from "./DefaultCommentRecipientResolver"; diff --git a/plugins/rw-backend-module-notifications/src/module.test.ts b/plugins/rw-backend-module-notifications/src/module.test.ts index 6d5c5b7..6f8c527 100644 --- a/plugins/rw-backend-module-notifications/src/module.test.ts +++ b/plugins/rw-backend-module-notifications/src/module.test.ts @@ -8,7 +8,11 @@ * notifications module, and asserts exactly one processor was registered with * the expected name. */ -import { createBackendPlugin, createServiceFactory } from "@backstage/backend-plugin-api"; +import { + createBackendModule, + createBackendPlugin, + createServiceFactory, +} from "@backstage/backend-plugin-api"; import { mockServices, startTestBackend } from "@backstage/backend-test-utils"; import { notificationService, @@ -22,6 +26,7 @@ import { // The module under test. import notificationsModule from "./module"; +import { rwCommentRecipientExtensionPoint } from "./extensionPoints"; describe("rw notifications module — startTestBackend extension-point capture", () => { it("registers exactly one processor named 'rw-comment-notifications'", async () => { @@ -98,3 +103,134 @@ describe("rw notifications module — startTestBackend extension-point capture", expect((notificationsModule as any).$$type).toBe("@backstage/BackendFeature"); }); }); + +describe("rw notifications module — recipient resolver extension point", () => { + function notificationFactoryWith( + send: jest.Mock, [Parameters[0]]>, + ) { + return createServiceFactory({ + service: notificationService, + deps: {}, + factory: () => ({ send }) as NotificationService, + }); + } + + // Host plugin that owns rwCommentProcessingExtensionPoint and captures registered processors. + function captureHost(capture: { processors: CommentProcessor[] }) { + return createBackendPlugin({ + pluginId: "rw", + register(env) { + const processors: CommentProcessor[] = []; + env.registerExtensionPoint(rwCommentProcessingExtensionPoint, { + addProcessor: (...ps) => { + processors.push(...ps.flat()); + }, + }); + env.registerInit({ + deps: {}, + async init() { + capture.processors = processors; + }, + }); + }, + }); + } + + it("a registered custom resolver overrides recipients", async () => { + const send = jest.fn, [Parameters[0]]>( + async () => undefined, + ); + const capture = { processors: [] as CommentProcessor[] }; + + const activity: CommentActivity = { + action: "created", + occurredAt: "2026-06-26T00:00:00.000Z", + commentId: "c1", + rootId: "c1", + parentId: null, + siteRef: "component:default/site", + sectionRef: "sec-1", + pageRef: "sec-1#guide", + actorRef: "user:default/jane", + actorName: "Jane Doe", + participants: ["user:default/jane"], + sectionOwnerRef: "group:default/docs", + entityRef: "component:default/my-docs", + pageTitle: "Guide", + sectionTitle: "Docs", + viewerPath: "guide", + bodySnippet: "hello", + }; + + // Sibling module (same pluginId "rw") that consumes the recipient extension point. + const companyModule = createBackendModule({ + pluginId: "rw", + moduleId: "company-recipients", + register(reg) { + reg.registerInit({ + deps: { recipients: rwCommentRecipientExtensionPoint }, + async init({ recipients }) { + recipients.setRecipientResolver({ + getName: () => "company", + resolveRecipients: async () => ["user:default/maintainer"], + }); + }, + }); + }, + }); + + await startTestBackend({ + features: [ + captureHost(capture), + notificationsModule, + companyModule, + notificationFactoryWith(send), + mockServices.rootLogger.factory({ level: "none" }), + ], + }); + + expect(capture.processors).toHaveLength(1); + await capture.processors[0].process(activity); + + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0][0].recipients).toEqual({ + type: "entity", + entityRef: ["user:default/maintainer"], // not group:default/docs + excludeEntityRef: "user:default/jane", + }); + }); + + it("a second setRecipientResolver throws → backend startup fails", async () => { + const send = jest.fn, [Parameters[0]]>( + async () => undefined, + ); + const capture = { processors: [] as CommentProcessor[] }; + + const doubleRegisterModule = createBackendModule({ + pluginId: "rw", + moduleId: "double-register", + register(reg) { + reg.registerInit({ + deps: { recipients: rwCommentRecipientExtensionPoint }, + async init({ recipients }) { + const r = { getName: () => "x", resolveRecipients: async () => [] as string[] }; + recipients.setRecipientResolver(r); + recipients.setRecipientResolver(r); // second call must throw + }, + }); + }, + }); + + await expect( + startTestBackend({ + features: [ + captureHost(capture), + notificationsModule, + doubleRegisterModule, + notificationFactoryWith(send), + mockServices.rootLogger.factory({ level: "none" }), + ], + }), + ).rejects.toThrow(/already registered/); + }); +}); diff --git a/plugins/rw-backend-module-notifications/src/module.ts b/plugins/rw-backend-module-notifications/src/module.ts index 502f0e9..0cf3dec 100644 --- a/plugins/rw-backend-module-notifications/src/module.ts +++ b/plugins/rw-backend-module-notifications/src/module.ts @@ -2,15 +2,30 @@ import { coreServices, createBackendModule } from "@backstage/backend-plugin-api import { notificationService } from "@backstage/plugin-notifications-node"; import { rwCommentProcessingExtensionPoint } from "@rwdocs/backstage-plugin-rw-node"; import { CommentNotifier } from "./CommentNotifier"; +import { CommentRecipientResolver } from "./CommentRecipientResolver"; +import { rwCommentRecipientExtensionPoint } from "./extensionPoints"; /** Opt-in backend module: registers a CommentProcessor on rw-backend's comment-processing * extension point and delivers native notifications. Installing this module is the opt-in; * omitting it means rw-backend resolves nothing extra. pluginId is `rw` (it augments the rw - * plugin). No DB, events, or catalog — it only formats a resolved CommentActivity and sends. */ + * plugin). No DB, events, or catalog — it only formats a resolved CommentActivity and sends. + * + * It also registers rwCommentRecipientExtensionPoint so a sibling `rw` module can replace who + * is notified (e.g. maintainers instead of the owning group). With none registered, the + * built-in DefaultCommentRecipientResolver is used. */ export default createBackendModule({ pluginId: "rw", moduleId: "notifications", register(env) { + let recipientResolver: CommentRecipientResolver | undefined; + env.registerExtensionPoint(rwCommentRecipientExtensionPoint, { + setRecipientResolver(resolver) { + if (recipientResolver) { + throw new Error("rw comment recipient resolver already registered"); + } + recipientResolver = resolver; + }, + }); env.registerInit({ deps: { logger: coreServices.logger, @@ -18,8 +33,12 @@ export default createBackendModule({ comments: rwCommentProcessingExtensionPoint, }, async init({ logger, notifications, comments }) { - comments.addProcessor(new CommentNotifier({ notifications, logger })); - logger.info("rw notifications module registered a comment processor"); + comments.addProcessor(new CommentNotifier({ notifications, logger, recipientResolver })); + logger.info( + `rw notifications module registered a comment processor${ + recipientResolver ? ` (custom recipient resolver: ${recipientResolver.getName()})` : "" + }`, + ); }, }); }, diff --git a/plugins/rw-node/src/CommentActivity.ts b/plugins/rw-node/src/CommentActivity.ts index b948853..5fdb3bb 100644 --- a/plugins/rw-node/src/CommentActivity.ts +++ b/plugins/rw-node/src/CommentActivity.ts @@ -16,7 +16,7 @@ export interface CommentActivity { actorRef: string; // the user who triggered the activity actorName: string; // resolved display name participants: string[]; // distinct author refs, creation order - sectionOwnerRef: string | null; // recipient for top-level creates + sectionOwnerRef: string | null; // the section's effective owner ref (null => no owner) entityRef: string | null; // deep-link target (null => no link) pageTitle: string | null; sectionTitle: string | null;