diff --git a/CLAUDE.md b/CLAUDE.md index 8cd458b..a7fb7b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,13 +4,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -Backstage plugins for embedding RW documentation sites. Yarn 4.12.0 workspace monorepo with five packages: +Backstage plugins for embedding RW documentation sites. Yarn 4.12.0 workspace monorepo with six packages: - **`@rwdocs/backstage-plugin-rw`** (frontend) — Renders RW docs in Backstage UI via `@rwdocs/viewer` - **`@rwdocs/backstage-plugin-rw-backend`** (backend) — Express-based API serving docs via `@rwdocs/core` - **`@rwdocs/backstage-plugin-search-backend-module-rw`** (search) — Indexes RW documentation for Backstage search via a collator module - **`@rwdocs/backstage-plugin-rw-common`** (common) — Shared utilities: entity path construction, annotation parsing, S3 config reading -- **`@rwdocs/backstage-plugin-rw-backend-module-notifications`** (notifications) — Opt-in backend module: subscribes to `rw.comments` events and delivers doc-comment notifications via the native notifications plugin +- **`@rwdocs/backstage-plugin-rw-node`** (node-library) — Knex-free shared library: the comment-processing extension point (`rwCommentProcessingExtensionPoint`) plus the `CommentProcessor` / `CommentActivity` types. rw-backend registers the extension point and pushes a resolved `CommentActivity`; the notifications module registers a `CommentProcessor`. No `@rwdocs/core`, no DB. +- **`@rwdocs/backstage-plugin-rw-backend-module-notifications`** (notifications) — Opt-in backend module: registers a `CommentProcessor` on rw-backend's comment-processing extension point that formats a resolved `CommentActivity` and delivers doc-comment notifications via the native notifications plugin. No DB, events, or catalog. ## Commands diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 95031d4..49ac123 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -17,8 +17,8 @@ backend.add(import('@backstage/plugin-search-backend-module-catalog')); backend.add(import('@rwdocs/backstage-plugin-rw-backend')); backend.add(import('@rwdocs/backstage-plugin-search-backend-module-rw')); // Doc-comment notifications: the native notifications + signals backends, plus -// the opt-in rw notifications module that turns rw.comments events into -// notifications. (eventsServiceRef ships in-process with backend-defaults.) +// the opt-in rw notifications module that registers a CommentProcessor on rw-backend's +// comment-processing extension point. backend.add(import('@backstage/plugin-notifications-backend')); backend.add(import('@backstage/plugin-signals-backend')); backend.add(import('@rwdocs/backstage-plugin-rw-backend-module-notifications')); diff --git a/plugins/rw-backend-module-notifications/README.md b/plugins/rw-backend-module-notifications/README.md index 4c87136..eda7c0b 100644 --- a/plugins/rw-backend-module-notifications/README.md +++ b/plugins/rw-backend-module-notifications/README.md @@ -1,8 +1,9 @@ # @rwdocs/backstage-plugin-rw-backend-module-notifications -Opt-in backend module that delivers **doc-comment notifications**. It subscribes to the -`rw.comments` domain events published by `@rwdocs/backstage-plugin-rw-backend` and sends -native Backstage notifications: +Opt-in backend module that delivers **doc-comment notifications**. It registers a +`CommentProcessor` on `@rwdocs/backstage-plugin-rw-backend`'s comment-processing extension +point (`rwCommentProcessingExtensionPoint`); rw-backend resolves each comment action into a +`CommentActivity` and hands it to the processor, which sends native Backstage notifications: - **Owner-side:** a new top-level comment on docs owned by a group notifies that group. - **Commenter-side:** a reply to a thread, or resolution of a thread, notifies all prior @@ -24,13 +25,15 @@ backend.add(import('@backstage/plugin-signals-backend')); // real-time updates // packages/app/src/App.tsx — add the notifications + signals frontend plugins ``` -### Events +### Same-backend requirement -`rw-backend` publishes `rw.comments` via the core `eventsServiceRef`, which ships in-process -with `backend-defaults` — **no extra package is required** for single-backend deployments. -Install `@backstage/plugin-events-backend` only if your backend runs as multiple instances -and you need cross-process event distribution. -(rw-backend's publish and this module's subscription must run in the same backend process for the in-process events service to route between them — the default single-backend setup.) +This module is wired to rw-backend through an **in-process extension point**, not an event +bus. The processor must therefore run in the **same backend process** as +`@rwdocs/backstage-plugin-rw-backend` (which registers `rwCommentProcessingExtensionPoint`). +There is no `@backstage/plugin-events-backend` dependency and nothing crosses a process +boundary, so no extra package is needed — but a multi-process deployment that splits this +module out from rw-backend would leave its extension-point dependency unsatisfied and is not +supported. ### Notification topics @@ -75,5 +78,5 @@ are cleaned up by `notifications.retention` (default `1y`). ### Notes - Notification links resolve into the entity's RW docs tab at the comment anchor. -- If you don't install this module, `rw-backend` still publishes events harmlessly and - commenting is unaffected. +- If you don't install this module, rw-backend simply has no comment processor registered; + commenting is unaffected and no notifications are sent. diff --git a/plugins/rw-backend-module-notifications/package.json b/plugins/rw-backend-module-notifications/package.json index 43dfda0..4b3a267 100644 --- a/plugins/rw-backend-module-notifications/package.json +++ b/plugins/rw-backend-module-notifications/package.json @@ -41,18 +41,17 @@ }, "dependencies": { "@backstage/catalog-model": "^1.7.6", - "@rwdocs/backstage-plugin-rw-common": "workspace:^" + "@rwdocs/backstage-plugin-rw-common": "workspace:^", + "@rwdocs/backstage-plugin-rw-node": "workspace:^" }, "peerDependencies": { "@backstage/backend-plugin-api": "^1.0.0", - "@backstage/plugin-events-node": "^0.4.23", "@backstage/plugin-notifications-node": "^0.2.27" }, "devDependencies": { "@backstage/backend-plugin-api": "^1.0.0", "@backstage/backend-test-utils": "^1.11.0", "@backstage/cli": "^0.36.0", - "@backstage/plugin-events-node": "^0.4.23", "@backstage/plugin-notifications-node": "^0.2.27", "@types/jest": "^30.0.0", "jest": "^30.2.0", diff --git a/plugins/rw-backend-module-notifications/src/CommentNotifier.test.ts b/plugins/rw-backend-module-notifications/src/CommentNotifier.test.ts index ec9f3d3..b661caf 100644 --- a/plugins/rw-backend-module-notifications/src/CommentNotifier.test.ts +++ b/plugins/rw-backend-module-notifications/src/CommentNotifier.test.ts @@ -1,173 +1,169 @@ import { mockServices } from "@backstage/backend-test-utils"; -import { CommentEventPayload } from "@rwdocs/backstage-plugin-rw-common"; +import { type NotificationService } from "@backstage/plugin-notifications-node"; +import { type CommentActivity } from "@rwdocs/backstage-plugin-rw-node"; import { CommentNotifier } from "./CommentNotifier"; -function payload(over: Partial): CommentEventPayload { +function makeActivity(over: Partial = {}): CommentActivity { return { - kind: "created", - audience: "owner", + 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/setup", - actorRef: "user:default/alice", - actorName: "Alice Smith", - pageTitle: "Setup Guide", - sectionTitle: "My Service", - recipients: ["group:default/team"], - entityRef: "component:default/site", - deepLinkSuffix: "/docs/guide/setup#comment-c1", - bodySnippet: "please review", + 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("CommentNotifier", () => { const logger = mockServices.logger.mock(); + let send: jest.Mock, [Parameters[0]]>; + let notifier: CommentNotifier; - it("owner/created → ' commented on · '", async () => { - const send = jest.fn().mockResolvedValue(undefined); - const notifier = new CommentNotifier({ notifications: { send } as any, logger }); + beforeEach(() => { + send = jest.fn, [Parameters[0]]>( + async () => undefined, + ); + notifier = new CommentNotifier({ notifications: { send } as any, logger }); + }); - await notifier.handle(payload({})); + it("top-level create → recipients from sectionOwnerRef, topic:thread:created, title, link", async () => { + await notifier.process(makeActivity()); expect(send).toHaveBeenCalledTimes(1); const arg = send.mock.calls[0][0]; - // The recipient is a group and the actor is forwarded as excludeEntityRef: this is what - // lets Backstage's resolver drop a group-owning actor from the expanded set (so a member - // commenting on their own entity is not self-notified). expect(arg.recipients).toEqual({ type: "entity", - entityRef: ["group:default/team"], - excludeEntityRef: "user:default/alice", + entityRef: ["group:default/docs"], + excludeEntityRef: "user:default/jane", }); - expect(arg.payload.title).toBe("Alice Smith commented on Setup Guide · My Service"); - expect(arg.payload.description).toBe("please review"); - expect(arg.payload.link).toBe("/catalog/default/component/site/docs/guide/setup#comment-c1"); + expect(arg.payload.topic).toBe("comment:thread:created"); + expect(arg.payload.title).toBe("Jane Doe commented on Guide · Docs"); + expect(arg.payload.description).toBe("hello"); expect(arg.payload.scope).toBe("rw:comment:c1"); expect(arg.payload.severity).toBe("normal"); + expect(arg.payload.link).toBe("/catalog/default/component/my-docs/docs/guide#comment-c1"); }); - it("drops a payload with no actorRef without sending (exclusion would be a no-op)", async () => { - const send = jest.fn().mockResolvedValue(undefined); - const notifier = new CommentNotifier({ notifications: { send } as any, logger }); - await notifier.handle(payload({ actorRef: "" })); - expect(send).not.toHaveBeenCalled(); - }); - - it("participants/created → ' replied on '", async () => { - const send = jest.fn().mockResolvedValue(undefined); - const notifier = new CommentNotifier({ notifications: { send } as any, logger }); - await notifier.handle( - payload({ audience: "participants", parentId: "c1", recipients: ["user:default/bob"] }), + it("reply create → recipients from participants, topic:reply:created, title 'replied on'", async () => { + await notifier.process( + makeActivity({ + parentId: "c1", + participants: ["user:default/jane", "user:default/bob"], + }), ); - expect(send.mock.calls[0][0].payload.title).toBe( - "Alice Smith replied on Setup Guide · My Service", - ); - }); - it("resolved → ' resolved a thread on ', description prefixed Re:", async () => { - const send = jest.fn().mockResolvedValue(undefined); - const notifier = new CommentNotifier({ notifications: { send } as any, logger }); - await notifier.handle( - payload({ kind: "resolved", audience: "participants", recipients: ["user:default/alice"] }), + expect(send).toHaveBeenCalledTimes(1); + const arg = send.mock.calls[0][0]; + expect(arg.recipients).toEqual({ + type: "entity", + entityRef: ["user:default/jane", "user:default/bob"], + excludeEntityRef: "user:default/jane", + }); + expect(arg.payload.topic).toBe("comment:reply:created"); + expect(arg.payload.title).toBe("Jane Doe replied on Guide · Docs"); + }); + + it("resolve → recipients from participants, topic:thread:resolved, title 'resolved a thread', description prefixed Re:", async () => { + await notifier.process( + makeActivity({ + action: "resolved", + rootId: "c1", + participants: ["user:default/jane", "user:default/bob"], + bodySnippet: "all set", + }), ); - const p = send.mock.calls[0][0].payload; - expect(p.title).toBe("Alice Smith resolved a thread on Setup Guide · My Service"); - expect(p.description).toBe("Re: please review"); + + expect(send).toHaveBeenCalledTimes(1); + const arg = send.mock.calls[0][0]; + expect(arg.recipients).toEqual({ + type: "entity", + entityRef: ["user:default/jane", "user:default/bob"], + excludeEntityRef: "user:default/jane", + }); + expect(arg.payload.topic).toBe("comment:thread:resolved"); + expect(arg.payload.title).toBe("Jane Doe resolved a thread on Guide · Docs"); + expect(arg.payload.description).toBe("Re: all set"); }); - it("subject dedup: page === area → uses page only (no · suffix)", async () => { - const send = jest.fn().mockResolvedValue(undefined); - const notifier = new CommentNotifier({ notifications: { send } as any, logger }); - // When comment is on the section root (subpath ""), pageTitle === sectionTitle → shown once. - await notifier.handle(payload({ pageTitle: "Биллинг", sectionTitle: "Биллинг" })); - expect(send.mock.calls[0][0].payload.title).toBe("Alice Smith commented on Биллинг"); + it("null entityRef → link: undefined", async () => { + await notifier.process(makeActivity({ entityRef: null })); + + expect(send.mock.calls[0][0].payload.link).toBeUndefined(); }); - it("subject: page !== area → 'page · area' format", async () => { - const send = jest.fn().mockResolvedValue(undefined); - const notifier = new CommentNotifier({ notifications: { send } as any, logger }); - // When comment is on a sub-page: pageTitle="ADRs", sectionTitle="Биллинг" (section root). - await notifier.handle(payload({ pageTitle: "ADRs", sectionTitle: "Биллинг" })); - expect(send.mock.calls[0][0].payload.title).toBe("Alice Smith commented on ADRs · Биллинг"); + it("subject dedup: pageTitle === sectionTitle → title contains 'on Guide' (no · suffix)", async () => { + await notifier.process(makeActivity({ pageTitle: "Guide", sectionTitle: "Guide" })); + + expect(send.mock.calls[0][0].payload.title).toBe("Jane Doe commented on Guide"); }); it("subject fallback: both null → 'the docs'", async () => { - const send = jest.fn().mockResolvedValue(undefined); - const notifier = new CommentNotifier({ notifications: { send } as any, logger }); - await notifier.handle(payload({ pageTitle: null, sectionTitle: null })); - expect(send.mock.calls[0][0].payload.title).toBe("Alice Smith commented on the docs"); + await notifier.process(makeActivity({ pageTitle: null, sectionTitle: null })); + + expect(send.mock.calls[0][0].payload.title).toBe("Jane Doe commented on the docs"); }); it("actor fallback: empty actorName → 'Someone'", async () => { - const send = jest.fn().mockResolvedValue(undefined); - const notifier = new CommentNotifier({ notifications: { send } as any, logger }); - await notifier.handle(payload({ actorName: "" })); - expect(send.mock.calls[0][0].payload.title).toBe( - "Someone commented on Setup Guide · My Service", - ); - }); + await notifier.process(makeActivity({ actorName: "" })); - it("omits the link when entityRef is null (degraded)", async () => { - const send = jest.fn().mockResolvedValue(undefined); - const notifier = new CommentNotifier({ notifications: { send } as any, logger }); - await notifier.handle(payload({ entityRef: null })); - expect(send.mock.calls[0][0].payload.link).toBeUndefined(); + expect(send.mock.calls[0][0].payload.title).toBe("Someone commented on Guide · Docs"); }); - it("never throws when send rejects", async () => { - const send = jest.fn().mockRejectedValue(new Error("notifications down")); - const notifier = new CommentNotifier({ notifications: { send } as any, logger }); - await expect(notifier.handle(payload({}))).resolves.toBeUndefined(); - }); + it("empty recipients (top-level create with sectionOwnerRef:null) → send NOT called", async () => { + await notifier.process(makeActivity({ sectionOwnerRef: null })); - it("drops a payload with empty recipients without sending", async () => { - const send = jest.fn().mockResolvedValue(undefined); - const notifier = new CommentNotifier({ notifications: { send } as any, logger }); - await notifier.handle(payload({ recipients: [] })); expect(send).not.toHaveBeenCalled(); }); - it("drops a malformed payload (recipients not an array) without sending", async () => { - const send = jest.fn().mockResolvedValue(undefined); - const notifier = new CommentNotifier({ notifications: { send } as any, logger }); - await notifier.handle(payload({ recipients: undefined as any })); - expect(send).not.toHaveBeenCalled(); - }); + it("never throws when send rejects (best-effort)", async () => { + send.mockRejectedValue(new Error("notifications down")); - it("drops a malformed payload (unknown kind) without sending", async () => { - const send = jest.fn().mockResolvedValue(undefined); - const notifier = new CommentNotifier({ notifications: { send } as any, logger }); - await notifier.handle(payload({ kind: "deleted" as any })); - expect(send).not.toHaveBeenCalled(); + await expect(notifier.process(makeActivity())).resolves.toBeUndefined(); }); - it("topic: owner/created → comment:thread:created", async () => { - const send = jest.fn().mockResolvedValue(undefined); - const notifier = new CommentNotifier({ notifications: { send } as any, logger }); - await notifier.handle(payload({})); - expect(send.mock.calls[0][0].payload.topic).toBe("comment:thread:created"); + it("getName() returns 'rw-comment-notifications'", () => { + expect(notifier.getName()).toBe("rw-comment-notifications"); }); - it("topic: participants/created (reply) → comment:reply:created", async () => { - const send = jest.fn().mockResolvedValue(undefined); - const notifier = new CommentNotifier({ notifications: { send } as any, logger }); - await notifier.handle( - payload({ audience: "participants", parentId: "c1", recipients: ["user:default/bob"] }), + it("top-level create: section owner IS the actor → still sends with owner as recipient (excluded at delivery)", async () => { + // Guards against a filter like `sectionOwnerRef !== actorRef` that would suppress + // notifications when the owner posts to their own section. Exclusion is left entirely + // to the delivery layer (excludeEntityRef); the processor always emits. + await notifier.process( + makeActivity({ + actorRef: "user:default/jane", + sectionOwnerRef: "user:default/jane", + }), ); - expect(send.mock.calls[0][0].payload.topic).toBe("comment:reply:created"); + + expect(send).toHaveBeenCalledTimes(1); + expect(send.mock.calls[0][0].recipients).toEqual({ + type: "entity", + entityRef: ["user:default/jane"], + excludeEntityRef: "user:default/jane", + }); }); - it("topic: resolved → comment:thread:resolved", async () => { - const send = jest.fn().mockResolvedValue(undefined); - const notifier = new CommentNotifier({ notifications: { send } as any, logger }); - await notifier.handle( - payload({ kind: "resolved", audience: "participants", recipients: ["user:default/alice"] }), + it("reply with empty participants → send NOT called", async () => { + await notifier.process( + makeActivity({ + parentId: "c1", + participants: [], + }), ); - expect(send.mock.calls[0][0].payload.topic).toBe("comment:thread:resolved"); + + expect(send).not.toHaveBeenCalled(); }); }); diff --git a/plugins/rw-backend-module-notifications/src/CommentNotifier.ts b/plugins/rw-backend-module-notifications/src/CommentNotifier.ts index 2010339..bc99ec6 100644 --- a/plugins/rw-backend-module-notifications/src/CommentNotifier.ts +++ b/plugins/rw-backend-module-notifications/src/CommentNotifier.ts @@ -1,117 +1,102 @@ import { LoggerService } from "@backstage/backend-plugin-api"; import { parseEntityRef } from "@backstage/catalog-model"; import { NotificationService } from "@backstage/plugin-notifications-node"; -import { CommentEventPayload } from "@rwdocs/backstage-plugin-rw-common"; +import { buildCommentDeepLinkSuffix } from "@rwdocs/backstage-plugin-rw-common"; +import { CommentActivity, CommentProcessor } from "@rwdocs/backstage-plugin-rw-node"; -/** Subscriber-side: turns a self-contained `rw.comments` event into a native notification. - * Presentation + delivery — no DB. The publisher emits raw owner/participant recipients (which may - * include the actor); this is the single point of actor exclusion: the actor is forwarded as - * `excludeEntityRef` so Backstage's resolver drops them after expanding groups. The one catalog-path - * convention (`/catalog///`) is isolated here: the backend can't resolve the - * frontend's catalog routeRef, so the app-relative link is composed from the entity ref by - * convention. Best-effort: catches + logs, always resolves. */ -export class CommentNotifier { +/** 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 + * 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; - constructor(deps: { notifications: NotificationService; logger: LoggerService }) { - this.notifications = deps.notifications; - this.logger = deps.logger; + constructor(opts: { notifications: NotificationService; logger: LoggerService }) { + this.notifications = opts.notifications; + this.logger = opts.logger; } - async handle(payload: CommentEventPayload): Promise { - if (!this.isValid(payload)) { - this.logger.warn( - `rw.comments: dropping malformed event payload (comment ${payload?.commentId ?? "?"})`, - ); - return; + getName(): string { + return "rw-comment-notifications"; + } + + async process(comment: CommentActivity): Promise { + let recipients: string[]; + if (comment.action === "created" && comment.parentId === null) { + recipients = comment.sectionOwnerRef ? [comment.sectionOwnerRef] : []; + } else { + recipients = comment.participants; } + if (recipients.length === 0) return; // nothing to notify + try { await this.notifications.send({ - // Single point of actor exclusion (see class doc): the resolver drops the actor from - // the resolved recipients, including from an expanded group. + // Single point of actor exclusion: the resolver drops the actor from the resolved + // recipients, including from an expanded group. recipients: { type: "entity", - entityRef: payload.recipients, - excludeEntityRef: payload.actorRef, + entityRef: recipients, + excludeEntityRef: comment.actorRef, }, payload: { - title: this.title(payload), - description: this.description(payload), - link: this.link(payload), + title: this.title(comment), + description: this.description(comment), + link: this.link(comment), severity: "normal", - topic: this.topic(payload), - // Per-thread collapse: events on one thread share this scope, and the - // backend dedups on (user, scope, origin) — not topic — overwriting the - // prior row's topic/title in place. So a recipient who is both the owner - // and a resolve participant on one thread sees the latest event, not two - // notifications. A disabled topic short-circuits the send before that - // overwrite, so a recipient who muted the newer event keeps the prior row. - scope: `rw:comment:${payload.rootId}`, + topic: this.topic(comment), + // Per-thread collapse: activities on one thread share this scope, and the backend + // dedups on (user, scope, origin) — not topic — overwriting the prior row in place. + scope: `rw:comment:${comment.rootId}`, }, }); } catch (error) { this.logger.warn( - `rw.comments notification send failed (comment ${payload.commentId}): ${error}`, + `rw.comments notification send failed (comment ${comment.commentId}): ${error}`, ); } } - /** Defensive shape guard at the subscriber boundary: the module casts the raw event - * payload to `CommentEventPayload`, so a malformed or foreign event on the topic would - * otherwise flow straight into `send`. Validates the fields this notifier relies on — - * including `actorRef`, the sole basis for actor exclusion (passed as `excludeEntityRef`); - * without it the exclusion silently becomes a no-op and the actor self-notifies. */ - private isValid(payload: CommentEventPayload): boolean { - return ( - !!payload && - (payload.kind === "created" || payload.kind === "resolved") && - typeof payload.rootId === "string" && - typeof payload.actorRef === "string" && - payload.actorRef.length > 0 && - Array.isArray(payload.recipients) && - payload.recipients.length > 0 - ); - } - - private subject(payload: CommentEventPayload): string { - const p = payload.pageTitle?.trim(); - const a = payload.sectionTitle?.trim(); + private subject(comment: CommentActivity): string { + const p = comment.pageTitle?.trim(); + const a = comment.sectionTitle?.trim(); if (p && a && p !== a) return `${p} · ${a}`; return p || a || "the docs"; } - private actor(payload: CommentEventPayload): string { - return payload.actorName?.trim() || "Someone"; + private actor(comment: CommentActivity): string { + return comment.actorName?.trim() || "Someone"; } - private title(payload: CommentEventPayload): string { - const s = this.subject(payload); - const a = this.actor(payload); - if (payload.kind === "resolved") return `${a} resolved a thread on ${s}`; - if (payload.audience === "owner") return `${a} commented on ${s}`; + private title(comment: CommentActivity): string { + 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}`; return `${a} replied on ${s}`; } - /** Stable, frozen notification topic id per event kind (see README "Notification - * topics"). Colon-delimited ::; lowercase and never renamed - * (persisted in the settings key hash). Mirrors the title() branch. */ - private topic(payload: CommentEventPayload): string { - if (payload.kind === "resolved") return "comment:thread:resolved"; - if (payload.audience === "owner") return "comment:thread:created"; + /** Stable, frozen notification topic id (see README "Notification topics"). + * Colon-delimited ::; lowercase, never renamed (persisted in the + * 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"; return "comment:reply:created"; } - private description(payload: CommentEventPayload): string { - if (payload.kind === "resolved") return `Re: ${payload.bodySnippet}`; - return payload.bodySnippet; + private description(comment: CommentActivity): string { + if (comment.action === "resolved") return `Re: ${comment.bodySnippet}`; + return comment.bodySnippet; } /** App-relative deep link, or undefined when the owning entity is unknown (degraded). */ - private link(payload: CommentEventPayload): string | undefined { - if (!payload.entityRef) return undefined; - const { kind, namespace, name } = parseEntityRef(payload.entityRef); + private link(comment: CommentActivity): string | undefined { + if (!comment.entityRef) return undefined; + const { kind, namespace, name } = parseEntityRef(comment.entityRef); const prefix = `/catalog/${namespace.toLowerCase()}/${kind.toLowerCase()}/${name}`; - return `${prefix}${payload.deepLinkSuffix}`; + return `${prefix}${buildCommentDeepLinkSuffix({ viewerPath: comment.viewerPath, commentId: comment.rootId })}`; } } diff --git a/plugins/rw-backend-module-notifications/src/module.test.ts b/plugins/rw-backend-module-notifications/src/module.test.ts index 4fa41a0..6d5c5b7 100644 --- a/plugins/rw-backend-module-notifications/src/module.test.ts +++ b/plugins/rw-backend-module-notifications/src/module.test.ts @@ -1,130 +1,56 @@ /** * Integration test for the rw notifications module. * - * Harness approach: real `startTestBackend` integration (sandbox disabled for net.listen). + * Harness approach: real `startTestBackend` (sandbox disabled for net.listen). * - * The test boots the module via `startTestBackend`, which: - * - Provides a `MockEventsService` (from `mockServices.events.factory()`) that actually - * routes `publish` → all matching `subscribe` callbacks. - * - Requires a mock `notificationService` factory (plugin-scoped) so the module's init can - * resolve its dependency. - * - Uses a "capture module" that deps on `eventsServiceRef` and stashes the service instance - * into a test-scoped variable (since `TestBackend.server` exposes HTTP, not getService()). - * - * This exercises module.ts's literal `registerInit` / `subscribe` wiring and the real - * `CommentNotifier.handle` path end-to-end. + * Boots a tiny host plugin (pluginId `rw`) that registers + * `rwCommentProcessingExtensionPoint` with a capturing impl, then mounts the + * notifications module, and asserts exactly one processor was registered with + * the expected name. */ -import { createBackendModule, createServiceFactory } from "@backstage/backend-plugin-api"; -import { eventsServiceRef } from "@backstage/plugin-events-node"; +import { createBackendPlugin, createServiceFactory } from "@backstage/backend-plugin-api"; import { mockServices, startTestBackend } from "@backstage/backend-test-utils"; import { notificationService, type NotificationService, } from "@backstage/plugin-notifications-node"; -import { CommentEventPayload, RW_COMMENTS_TOPIC } from "@rwdocs/backstage-plugin-rw-common"; -import type { EventsService } from "@backstage/plugin-events-node"; +import { + rwCommentProcessingExtensionPoint, + type CommentActivity, + type CommentProcessor, +} from "@rwdocs/backstage-plugin-rw-node"; // The module under test. import notificationsModule from "./module"; -function makePayload(over: Partial = {}): CommentEventPayload { - return { - kind: "created", - audience: "owner", - 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/alice", - actorName: "Alice", - pageTitle: null, - sectionTitle: null, - recipients: ["group:default/team"], - entityRef: "component:default/site", - deepLinkSuffix: "/docs/guide#comment-c1", - bodySnippet: "review please", - ...over, - }; -} - -describe("rw notifications module — real startTestBackend integration", () => { - it("subscribe→handle: publishes a notification when an rw.comments event is emitted", async () => { +describe("rw notifications module — startTestBackend extension-point capture", () => { + it("registers exactly one processor named 'rw-comment-notifications'", async () => { const send = jest.fn, [Parameters[0]]>( async () => undefined, ); - // Mock factory for notificationService (plugin-scoped). const notificationFactory = createServiceFactory({ service: notificationService, deps: {}, factory: () => ({ send }) as NotificationService, }); - // Capture module: stashes the events service so we can publish after boot. - let capturedEvents: EventsService | undefined; - const captureModule = createBackendModule({ + // host plugin provides the extension point and captures registered processors; + // module init runs before the plugin init, so `captured` is populated when init reads it. + let captured: CommentProcessor[] = []; + const hostPlugin = createBackendPlugin({ pluginId: "rw", - moduleId: "test-events-capture", register(env) { - env.registerInit({ - deps: { events: eventsServiceRef }, - async init({ events }) { - capturedEvents = events; + const processors: CommentProcessor[] = []; + env.registerExtensionPoint(rwCommentProcessingExtensionPoint, { + addProcessor: (...ps) => { + processors.push(...ps.flat()); }, }); - }, - }); - - await startTestBackend({ - features: [ - notificationsModule, - notificationFactory, - captureModule, - // Silence logger noise in tests. - mockServices.rootLogger.factory({ level: "none" }), - ], - }); - - // capturedEvents is set by captureModule.init() during startTestBackend. - expect(capturedEvents).toBeDefined(); - - // Publish an rw.comments event through the real MockEventsService. - const payload = makePayload(); - await capturedEvents!.publish({ topic: RW_COMMENTS_TOPIC, eventPayload: payload }); - await new Promise((resolve) => setImmediate(resolve)); - - // Module's subscribe callback → CommentNotifier.handle → notificationService.send. - expect(send).toHaveBeenCalledTimes(1); - expect(send.mock.calls[0][0].recipients).toEqual({ - type: "entity", - entityRef: ["group:default/team"], - excludeEntityRef: "user:default/alice", - }); - }); - - it("onEvent handler is best-effort: never throws even when send rejects", async () => { - const send = jest - .fn, [Parameters[0]]>() - .mockRejectedValue(new Error("notifications down")); - - const notificationFactory = createServiceFactory({ - service: notificationService, - deps: {}, - factory: () => ({ send }) as NotificationService, - }); - - let capturedEvents: EventsService | undefined; - const captureModule = createBackendModule({ - pluginId: "rw", - moduleId: "test-events-capture", - register(env) { env.registerInit({ - deps: { events: eventsServiceRef }, - async init({ events }) { - capturedEvents = events; + deps: {}, + async init() { + captured = processors; }, }); }, @@ -132,19 +58,39 @@ describe("rw notifications module — real startTestBackend integration", () => await startTestBackend({ features: [ + hostPlugin, notificationsModule, notificationFactory, - captureModule, mockServices.rootLogger.factory({ level: "none" }), ], }); - expect(capturedEvents).toBeDefined(); - - // Should resolve (not throw) even though send rejects — CommentNotifier catches internally. - await expect( - capturedEvents!.publish({ topic: RW_COMMENTS_TOPIC, eventPayload: makePayload() }), - ).resolves.toBeUndefined(); + expect(captured).toHaveLength(1); + expect(captured[0].getName()).toBe("rw-comment-notifications"); + + // Drive the registered processor end-to-end: a top-level create with a sectionOwnerRef + // must reach the injected notification service, catching wrong-service-injection wiring. + 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", + }; + await captured[0].process(activity); + expect(send).toHaveBeenCalledTimes(1); }); it("is a BackendFeature (module definition passes basic shape check)", () => { diff --git a/plugins/rw-backend-module-notifications/src/module.ts b/plugins/rw-backend-module-notifications/src/module.ts index 40cf694..502f0e9 100644 --- a/plugins/rw-backend-module-notifications/src/module.ts +++ b/plugins/rw-backend-module-notifications/src/module.ts @@ -1,34 +1,25 @@ import { coreServices, createBackendModule } from "@backstage/backend-plugin-api"; -import { eventsServiceRef } from "@backstage/plugin-events-node"; import { notificationService } from "@backstage/plugin-notifications-node"; -import { RW_COMMENTS_TOPIC, CommentEventPayload } from "@rwdocs/backstage-plugin-rw-common"; +import { rwCommentProcessingExtensionPoint } from "@rwdocs/backstage-plugin-rw-node"; import { CommentNotifier } from "./CommentNotifier"; -/** Opt-in backend module: subscribes to rw-backend's `rw.comments` domain events and - * delivers native notifications. Installing this module is the opt-in; omitting it is the - * opt-out (rw-backend still publishes harmlessly). pluginId is `rw` (it augments the rw - * plugin, driven by rw's events) — it is a notification *sender*, not a *processor*, so it - * registers into no notifications-plugin extension point. */ +/** 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. */ export default createBackendModule({ pluginId: "rw", moduleId: "notifications", register(env) { env.registerInit({ deps: { - events: eventsServiceRef, - notifications: notificationService, logger: coreServices.logger, + notifications: notificationService, + comments: rwCommentProcessingExtensionPoint, }, - async init({ events, notifications, logger }) { - const notifier = new CommentNotifier({ notifications, logger }); - await events.subscribe({ - id: "rw-comment-notifications", - topics: [RW_COMMENTS_TOPIC], - onEvent: async (params) => { - await notifier.handle(params.eventPayload as CommentEventPayload); - }, - }); - logger.info("rw notifications module subscribed to rw.comments"); + async init({ logger, notifications, comments }) { + comments.addProcessor(new CommentNotifier({ notifications, logger })); + logger.info("rw notifications module registered a comment processor"); }, }); }, diff --git a/plugins/rw-backend/package.json b/plugins/rw-backend/package.json index a1c9b9c..2072771 100644 --- a/plugins/rw-backend/package.json +++ b/plugins/rw-backend/package.json @@ -49,11 +49,11 @@ "@backstage/config": "^1.3.6", "@backstage/errors": "^1.2.7", "@backstage/plugin-catalog-node": "^2.2.2", - "@backstage/plugin-events-node": "^0.4.23", "@backstage/plugin-permission-common": "^0.9.9", "@backstage/plugin-permission-node": "^0.11.1", "@backstage/types": "^1.2.2", "@rwdocs/backstage-plugin-rw-common": "workspace:^", + "@rwdocs/backstage-plugin-rw-node": "workspace:^", "@rwdocs/core": "^0.1.28", "express": "^4.21.0", "express-promise-router": "^4.1.0", diff --git a/plugins/rw-backend/src/comments/CommentActivityResolver.test.ts b/plugins/rw-backend/src/comments/CommentActivityResolver.test.ts new file mode 100644 index 0000000..1649d22 --- /dev/null +++ b/plugins/rw-backend/src/comments/CommentActivityResolver.test.ts @@ -0,0 +1,255 @@ +import { TestDatabases } from "@backstage/backend-test-utils"; +import { resolvePackagePath } from "@backstage/backend-plugin-api"; +import { parseEntityRef } from "@backstage/catalog-model"; +import type { Knex } from "knex"; +import { v7 as uuidv7 } from "uuid"; +import { CommentActivityResolver } from "./CommentActivityResolver"; +import { CommentStore } from "./CommentStore"; +import { SectionsReader } from "../siteIndex/SectionsReader"; +import { PagesReader } from "../siteIndex/PagesReader"; + +jest.mock("@rwdocs/core", () => ({ + renderCommentBody: jest.fn(async (md: string) => `

${md}

`), +})); + +const SITE_REF = "component:default/arch"; +const SECTION_REF = "section:default/root"; +const SECTION_PATH = "guide"; +const ENTITY_REF = "component:default/arch"; +const ENTITY_OWNER_REF = "group:default/owners"; +const PAGE_SUBPATH = "intro"; +const PAGE_REF = `${SECTION_REF}#${PAGE_SUBPATH}`; +const PAGE_TITLE = "Introduction"; +const SECTION_TITLE = "Guide"; + +async function freshResolver(databases: TestDatabases): Promise<{ + resolver: CommentActivityResolver; + knex: Knex; + catalog: jest.Mocked<{ getEntityByRef: jest.Mock }>; + auth: jest.Mocked<{ getOwnServiceCredentials: jest.Mock }>; +}> { + const knex = await databases.init("SQLITE_3"); + const directory = resolvePackagePath("@rwdocs/backstage-plugin-rw-backend", "migrations"); + await knex.migrate.latest({ directory }); + + // Seed sections table + await knex("sections").insert({ + site_ref: SITE_REF, + section_ref: SECTION_REF, + section_path: SECTION_PATH, + parent_section_ref: null, + entity_ref: ENTITY_REF, + entity_owner_ref: ENTITY_OWNER_REF, + }); + + // Seed pages table — one page (subpath) and the section root (empty subpath) + await knex("pages").insert([ + { + site_ref: SITE_REF, + section_ref: SECTION_REF, + subpath: PAGE_SUBPATH, + title: PAGE_TITLE, + }, + { + site_ref: SITE_REF, + section_ref: SECTION_REF, + subpath: "", + title: SECTION_TITLE, + }, + ]); + + const catalog = { getEntityByRef: jest.fn() } as any; + const auth = { getOwnServiceCredentials: jest.fn().mockResolvedValue({}) } as any; + const logger = { warn: jest.fn(), info: jest.fn(), error: jest.fn(), debug: jest.fn() } as any; + + const resolver = new CommentActivityResolver({ + sections: new SectionsReader(knex), + pages: new PagesReader(knex), + comments: new CommentStore(knex), + catalog, + auth, + logger, + }); + + return { resolver, knex, catalog, auth }; +} + +function makeCommentRow(overrides: Partial> = {}): Record { + const now = new Date().toISOString(); + return { + id: uuidv7(), + parent_id: null, + site_ref: SITE_REF, + page_ref: PAGE_REF, + section_ref: SECTION_REF, + author_ref: "user:default/jane", + author_profile: JSON.stringify({ displayName: "Jane Doe" }), + body: "Hello world", + body_html: "

Hello world

", + selectors: "[]", + status: "open", + created_at: now, + updated_at: now, + resolved_at: null, + resolved_by: null, + deleted_at: null, + ...overrides, + }; +} + +describe("CommentActivityResolver", () => { + const databases = TestDatabases.create({ ids: ["SQLITE_3"] }); + + it("case 1: created top-level with author_profile displayName", async () => { + const { resolver, knex, catalog } = await freshResolver(databases); + const row = makeCommentRow({ author_profile: JSON.stringify({ displayName: "Jane Doe" }) }); + await knex("comments").insert(row); + + const activity = await resolver.resolve("created", row as any, row.author_ref as string); + + expect(activity).toBeDefined(); + expect(activity!.action).toBe("created"); + expect(activity!.commentId).toBe(row.id); + expect(activity!.rootId).toBe(row.id); // top-level: rootId = id + expect(activity!.parentId).toBeNull(); + expect(activity!.actorName).toBe("Jane Doe"); + expect(activity!.participants).toEqual(["user:default/jane"]); + expect(activity!.sectionOwnerRef).toBe(ENTITY_OWNER_REF); + expect(activity!.entityRef).toBe(ENTITY_REF); + expect(activity!.viewerPath).toBe(`${SECTION_PATH}/${PAGE_SUBPATH}`); + expect(activity!.bodySnippet).toBe("Hello world"); + expect(activity!.pageTitle).toBe(PAGE_TITLE); + expect(activity!.sectionTitle).toBe(SECTION_TITLE); + expect(catalog.getEntityByRef).not.toHaveBeenCalled(); + }); + + it("case 2: created with author_profile null falls back to ref name", async () => { + const { resolver, knex } = await freshResolver(databases); + const row = makeCommentRow({ author_profile: null }); + await knex("comments").insert(row); + + const activity = await resolver.resolve("created", row as any, row.author_ref as string); + + expect(activity).toBeDefined(); + expect(activity!.actorName).toBe(parseEntityRef(row.author_ref as string).name); + }); + + it("case 3: deleted_at set on trigger row returns undefined", async () => { + const { resolver, knex } = await freshResolver(databases); + const now = new Date().toISOString(); + const row = makeCommentRow({ deleted_at: now }); + await knex("comments").insert(row); + + const activity = await resolver.resolve("created", row as any, row.author_ref as string); + + expect(activity).toBeUndefined(); + }); + + it("case 4: resolved path calls catalog and returns resolved actorName", async () => { + const { resolver, knex, catalog, auth } = await freshResolver(databases); + const row = makeCommentRow({ + status: "resolved", + resolved_at: new Date().toISOString(), + resolved_by: "user:default/carol", + }); + await knex("comments").insert(row); + + catalog.getEntityByRef.mockResolvedValue({ + spec: { profile: { displayName: "Carol R" } }, + metadata: {}, + }); + + const activity = await resolver.resolve("resolved", row as any, "user:default/carol"); + + expect(activity).toBeDefined(); + expect(activity!.action).toBe("resolved"); + expect(activity!.rootId).toBe(row.id); + expect(activity!.actorName).toBe("Carol R"); + expect(catalog.getEntityByRef).toHaveBeenCalledWith("user:default/carol", expect.anything()); + expect(auth.getOwnServiceCredentials).toHaveBeenCalled(); + }); + + it("case 5: a participantsOf failure degrades to [] and does not suppress the owner notification", async () => { + const { knex, catalog, auth } = await freshResolver(databases); + const logger = { warn: jest.fn(), info: jest.fn(), error: jest.fn(), debug: jest.fn() } as any; + // A top-level create's owner notification depends on the section, not on participants; + // a transient participants read failure must not reject the whole resolve. + const resolver = new CommentActivityResolver({ + sections: new SectionsReader(knex), + pages: new PagesReader(knex), + comments: { participantsOf: jest.fn().mockRejectedValue(new Error("db down")) } as any, + catalog: catalog as any, + auth: auth as any, + logger, + }); + const row = makeCommentRow(); + + const activity = await resolver.resolve("created", row as any, row.author_ref as string); + + expect(activity).toBeDefined(); + expect(activity!.participants).toEqual([]); + expect(activity!.sectionOwnerRef).toBe(ENTITY_OWNER_REF); // owner fields unaffected + expect(logger.warn).toHaveBeenCalled(); + }); + + it("case 6: a section read failure degrades to a null section without suppressing a reply notification", async () => { + const { knex, catalog, auth } = await freshResolver(databases); + const logger = { warn: jest.fn(), info: jest.fn(), error: jest.fn(), debug: jest.fn() } as any; + // A reply's recipients come from participants, not the section; a transient sections read + // failure must degrade to a null section (no deep link) rather than rejecting the resolve and + // dropping a notification the participants were already resolved for. + const resolver = new CommentActivityResolver({ + sections: { getSection: jest.fn().mockRejectedValue(new Error("db down")) } as any, + pages: new PagesReader(knex), + comments: new CommentStore(knex), + catalog: catalog as any, + auth: auth as any, + logger, + }); + const root = makeCommentRow(); + await knex("comments").insert(root); + const reply = makeCommentRow({ + parent_id: root.id, + author_ref: "user:default/bob", + author_profile: JSON.stringify({ displayName: "Bob" }), + }); + await knex("comments").insert(reply); + + const activity = await resolver.resolve("created", reply as any, reply.author_ref as string); + + expect(activity).toBeDefined(); + expect(activity!.rootId).toBe(root.id); // reply: rootId = parent_id + expect(activity!.participants).toEqual( + expect.arrayContaining(["user:default/jane", "user:default/bob"]), + ); + expect(activity!.sectionOwnerRef).toBeNull(); + expect(activity!.entityRef).toBeNull(); + expect(activity!.viewerPath).toBe(PAGE_SUBPATH); // bare subpath: no section prefix + expect(logger.warn).toHaveBeenCalled(); + }); + + it("case 7: a section-root comment reads the page title once and reuses it for both fields", async () => { + const { knex, catalog, auth } = await freshResolver(databases); + const logger = { warn: jest.fn(), info: jest.fn(), error: jest.fn(), debug: jest.fn() } as any; + const getTitle = jest.fn().mockResolvedValue(SECTION_TITLE); + const resolver = new CommentActivityResolver({ + sections: new SectionsReader(knex), + pages: { getTitle } as any, + comments: new CommentStore(knex), + catalog: catalog as any, + auth: auth as any, + logger, + }); + // page_ref with no "#" → empty subpath (comment on the section root) + const row = makeCommentRow({ page_ref: SECTION_REF }); + await knex("comments").insert(row); + + const activity = await resolver.resolve("created", row as any, row.author_ref as string); + + expect(activity).toBeDefined(); + expect(activity!.pageTitle).toBe(SECTION_TITLE); + expect(activity!.sectionTitle).toBe(SECTION_TITLE); + expect(getTitle).toHaveBeenCalledTimes(1); // deduped: one read for the section root + expect(getTitle).toHaveBeenCalledWith(SITE_REF, SECTION_REF, ""); + }); +}); diff --git a/plugins/rw-backend/src/comments/CommentActivityResolver.ts b/plugins/rw-backend/src/comments/CommentActivityResolver.ts new file mode 100644 index 0000000..0520209 --- /dev/null +++ b/plugins/rw-backend/src/comments/CommentActivityResolver.ts @@ -0,0 +1,160 @@ +import type { AuthService, LoggerService } from "@backstage/backend-plugin-api"; +import { parseEntityRef } from "@backstage/catalog-model"; +import type { CatalogService } from "@backstage/plugin-catalog-node"; +import { CommentAction, CommentActivity } from "@rwdocs/backstage-plugin-rw-node"; +import { SectionsReader } from "../siteIndex/SectionsReader"; +import { PagesReader } from "../siteIndex/PagesReader"; +import { snippetFromHtml } from "../inbox/snippet"; +import { joinNonEmpty } from "../inbox/mapping"; +import { CommentStore } from "./CommentStore"; +import { authorFromRow } from "./author"; +import { CommentRow, subpathOf } from "./types"; +import { toIso } from "./timestamps"; + +/** Resolves a self-contained CommentActivity from rw-backend's own tables (comments/ + * sections/pages) plus a catalog lookup for the resolver's display name on the resolved + * path. Best-effort: title reads and the catalog lookup degrade to null/ref-name on error, + * never throw. Returns undefined when the triggering row is soft-deleted (suppress). */ +export class CommentActivityResolver { + private readonly sections: SectionsReader; + private readonly pages: PagesReader; + private readonly comments: CommentStore; + private readonly catalog: CatalogService; + private readonly auth: AuthService; + private readonly logger: LoggerService; + + constructor(opts: { + sections: SectionsReader; + pages: PagesReader; + comments: CommentStore; + catalog: CatalogService; + auth: AuthService; + logger: LoggerService; + }) { + this.sections = opts.sections; + this.pages = opts.pages; + this.comments = opts.comments; + this.catalog = opts.catalog; + this.auth = opts.auth; + this.logger = opts.logger; + } + + async resolve( + action: CommentAction, + row: CommentRow, + actorRef: string, + ): Promise { + if (row.deleted_at !== null) return undefined; // soft-deleted trigger: suppress + + const subpath = subpathOf(row.page_ref); + const rootId = action === "created" ? (row.parent_id ?? row.id) : row.id; + + // Every read depends only on row fields (available above), and the actor name only on + // actorRef, so resolve them all concurrently rather than serializing the round-trips on + // this fire-and-forget path. Each read is best-effort and degrades on failure (see the + // helpers below), so one transient error can't reject the whole resolve and suppress an + // otherwise-deliverable notification. + const [section, participants, [pageTitle, sectionTitle], actorName] = await Promise.all([ + this.resolveSection(row), + this.resolveParticipants(rootId), + this.resolveTitles(row, subpath), + action === "created" + ? Promise.resolve(authorFromRow(row).name) + : this.resolveActorName(actorRef), + ]); + + const viewerPath = section ? joinNonEmpty([section.section_path, subpath], "/") : subpath; + const occurredAt = + action === "created" + ? (toIso(row.created_at) ?? "") + : (toIso(row.resolved_at) ?? toIso(row.updated_at) ?? ""); + + return { + action, + occurredAt, + commentId: row.id, + rootId, + parentId: row.parent_id, + siteRef: row.site_ref, + sectionRef: row.section_ref, + pageRef: row.page_ref, + actorRef, + actorName, + participants, + sectionOwnerRef: section?.entity_owner_ref ?? null, + entityRef: section?.entity_ref ?? null, + pageTitle, + sectionTitle, + viewerPath, + bodySnippet: snippetFromHtml(row.body_html), + }; + } + + /** Best-effort section read: a transient failure degrades to no section (null owner/entity and + * a bare-subpath viewer path) rather than rejecting the whole resolve, so a reply/resolve whose + * recipients come from participants — not the section — still gets notified. */ + private async resolveSection( + row: CommentRow, + ): Promise>> { + try { + return await this.sections.getSection(row.site_ref, row.section_ref); + } catch (err) { + this.logger.warn(`rw comment activity: could not resolve section: ${err}`); + return undefined; + } + } + + /** Reads the page title and the section-root title. When the comment is on the section root + * (empty subpath) both resolve to the same page row, so read once and reuse rather than + * issuing a duplicate query. */ + private async resolveTitles( + row: CommentRow, + subpath: string, + ): Promise<[string | null, string | null]> { + if (subpath === "") { + const title = await this.resolveTitle(row, ""); + return [title, title]; + } + return Promise.all([this.resolveTitle(row, subpath), this.resolveTitle(row, "")]); + } + + /** Best-effort participant read: a transient failure degrades to no participants rather than + * rejecting the whole resolve. A top-level create's owner notification (which doesn't depend + * on participants) then still goes out; a reply/resolve with no resolvable participants sends + * nothing. */ + private async resolveParticipants(rootId: string): Promise { + try { + return await this.comments.participantsOf(rootId); + } catch (err) { + this.logger.warn(`rw comment activity: could not resolve participants: ${err}`); + return []; + } + } + + private async resolveTitle(row: CommentRow, subpath: string): Promise { + try { + return await this.pages.getTitle(row.site_ref, row.section_ref, subpath); + } catch (err) { + this.logger.warn(`rw comment activity: could not resolve page title: ${err}`); + return null; + } + } + + /** Resolver display name (resolved path only): own service-credential catalog read of the + * actor entity; falls back through metadata.title to the humanized ref name on any miss + * or error. */ + private async resolveActorName(actorRef: string): Promise { + try { + const credentials = await this.auth.getOwnServiceCredentials(); + const entity = await this.catalog.getEntityByRef(actorRef, { credentials }); + const profile = entity?.spec?.profile as { displayName?: string } | undefined; + return ( + profile?.displayName?.trim() || + entity?.metadata.title?.trim() || + parseEntityRef(actorRef).name + ); + } catch { + return parseEntityRef(actorRef).name; + } + } +} diff --git a/plugins/rw-backend/src/comments/CommentEventPublisher.test.ts b/plugins/rw-backend/src/comments/CommentEventPublisher.test.ts deleted file mode 100644 index 35d08e3..0000000 --- a/plugins/rw-backend/src/comments/CommentEventPublisher.test.ts +++ /dev/null @@ -1,419 +0,0 @@ -import { mockServices } from "@backstage/backend-test-utils"; -import { EventParams, EventsService } from "@backstage/plugin-events-node"; -import { CommentEventPublisher } from "./CommentEventPublisher"; -import { CommentRow } from "./types"; - -function fakeEvents() { - const published: EventParams[] = []; - const events: EventsService = { - publish: async (p: EventParams) => { - published.push(p); - }, - subscribe: async () => {}, - }; - return { events, published }; -} - -function fakePages(titles: Record = {}) { - return { - getTitle: async (_siteRef: string, _sectionRef: string, subpath: string) => - titles[subpath] ?? null, - } as any; -} - -function row(over: Partial): CommentRow { - return { - id: "c1", - site_ref: "component:default/site", - page_ref: "sec-1#setup", - section_ref: "sec-1", - parent_id: null, - author_ref: "user:default/alice", - author_profile: JSON.stringify({ displayName: "Alice Smith" }), - body: "hello", - body_html: "

hello

", - selectors: "[]", - status: "open", - created_at: 0, - updated_at: 0, - resolved_at: null, - resolved_by: null, - deleted_at: null, - ...over, - }; -} - -describe("CommentEventPublisher", () => { - const logger = mockServices.logger.mock(); - - it("owner-side: top-level create publishes to the section owner with correct titles", async () => { - const { events, published } = fakeEvents(); - const sections = { - getSection: async () => ({ - site_ref: "component:default/site", - section_ref: "sec-1", - section_path: "guide", - parent_section_ref: null, - entity_ref: "component:default/site", - entity_owner_ref: "group:default/team", - }), - } as any; - const comments = { participantsOf: async () => [] } as any; - // pageTitle: pages.getTitle(siteRef, sectionRef, "setup") - // sectionTitle: pages.getTitle(siteRef, sectionRef, "") — section root page - const pub = new CommentEventPublisher({ - events, - sections, - comments, - logger, - pages: fakePages({ setup: "Setup Page", "": "Биллинг" }), - }); - - await pub.onCommentCreated(row({ parent_id: null }), "user:default/alice"); - - expect(published).toHaveLength(1); - expect(published[0].topic).toBe("rw.comments"); - expect(published[0].eventPayload).toMatchObject({ - kind: "created", - audience: "owner", - recipients: ["group:default/team"], - actorRef: "user:default/alice", // forwarded as excludeEntityRef by the notifier - entityRef: "component:default/site", - pageRef: "sec-1#setup", - deepLinkSuffix: "/docs/guide/setup#comment-c1", - bodySnippet: "hello", - actorName: "Alice Smith", - pageTitle: "Setup Page", - sectionTitle: "Биллинг", - }); - }); - - it("owner-side: sectionTitle comes from pages.getTitle(siteRef, sectionRef, '') — section root", async () => { - const { events, published } = fakeEvents(); - const sections = { - getSection: async () => ({ - site_ref: "component:default/site", - section_ref: "sec-1", - section_path: "guide", - parent_section_ref: null, - entity_ref: "component:default/site", - entity_owner_ref: "group:default/team", - }), - } as any; - const getTitleCalls: Array<[string, string, string]> = []; - const pages = { - getTitle: async (siteRef: string, sectionRef: string, subpath: string) => { - getTitleCalls.push([siteRef, sectionRef, subpath]); - return subpath === "" ? "Биллинг" : "Setup Page"; - }, - } as any; - const pub = new CommentEventPublisher({ - events, - sections, - comments: { participantsOf: async () => [] } as any, - logger, - pages, - }); - await pub.onCommentCreated(row({ parent_id: null }), "user:default/alice"); - // Must call getTitle with subpath="" for the area - expect(getTitleCalls).toContainEqual(["component:default/site", "sec-1", ""]); - expect(published[0].eventPayload).toMatchObject({ - pageTitle: "Setup Page", - sectionTitle: "Биллинг", - }); - }); - - it("owner-side: deepLinkSuffix prepends section_path (not just subpath)", async () => { - // Regression: viewerPath must be joinNonEmpty([section_path, subpath]), not just subpath. - // With section_path="guide" and page_ref subpath "setup", the suffix must be - // "/docs/guide/setup#comment-c1", not "/docs/setup#comment-c1". - const { events, published } = fakeEvents(); - const sections = { - getSection: async () => ({ - site_ref: "component:default/site", - section_ref: "sec-1", - section_path: "guide", - parent_section_ref: null, - entity_ref: "component:default/site", - entity_owner_ref: "group:default/team", - }), - } as any; - const pub = new CommentEventPublisher({ - events, - sections, - comments: { participantsOf: async () => [] } as any, - logger, - pages: fakePages(), - }); - await pub.onCommentCreated(row({ parent_id: null }), "user:default/alice"); - expect(published).toHaveLength(1); - const suffix: string = (published[0].eventPayload as any).deepLinkSuffix; - expect(suffix).toBe("/docs/guide/setup#comment-c1"); - expect(suffix).not.toBe("/docs/setup#comment-c1"); - }); - - it("owner-side: skips (no publish) when the section has no owner", async () => { - const { events, published } = fakeEvents(); - const sections = { - getSection: async () => ({ - site_ref: "component:default/site", - section_ref: "sec-1", - section_path: "guide", - parent_section_ref: null, - entity_ref: "component:default/site", - entity_owner_ref: null, - }), - } as any; - const pub = new CommentEventPublisher({ - events, - sections, - comments: { participantsOf: async () => [] } as any, - logger, - pages: fakePages(), - }); - await pub.onCommentCreated(row({ parent_id: null }), "user:default/alice"); - expect(published).toHaveLength(0); - }); - - it("owner-side: skips when the section row is missing", async () => { - const { events, published } = fakeEvents(); - const pub = new CommentEventPublisher({ - events, - sections: { getSection: async () => undefined } as any, - comments: { participantsOf: async () => [] } as any, - logger, - pages: fakePages(), - }); - await pub.onCommentCreated(row({ parent_id: null }), "user:default/alice"); - expect(published).toHaveLength(0); - }); - - it("owner-side: still publishes when the section owner IS the actor (recipients=[actor], dropped at delivery)", async () => { - // Guards the refactor: the publisher no longer strips the actor from owner recipients, so an - // owner commenting on their own section still emits an owner-side event (recipients=[actor]); - // the notifier's excludeEntityRef drops them at delivery. If the literal-ref filter were - // re-added here, recipients would empty out and this event would silently stop publishing — - // re-opening the self-notify hole. (The other owner-side fixtures use owner=group != actor, - // so the deleted filter was a no-op in them and would NOT catch such a regression.) - const { events, published } = fakeEvents(); - const sections = { - getSection: async () => ({ - site_ref: "component:default/site", - section_ref: "sec-1", - section_path: "guide", - parent_section_ref: null, - entity_ref: "component:default/site", - entity_owner_ref: "user:default/alice", - }), - } as any; - const pub = new CommentEventPublisher({ - events, - sections, - comments: { participantsOf: async () => [] } as any, - logger, - pages: fakePages(), - }); - - await pub.onCommentCreated(row({ parent_id: null }), "user:default/alice"); - - expect(published).toHaveLength(1); - expect(published[0].eventPayload).toMatchObject({ - audience: "owner", - recipients: ["user:default/alice"], - actorRef: "user:default/alice", - }); - }); - - it("commenter-side: reply recipients include all thread participants (actor not stripped here)", async () => { - const { events, published } = fakeEvents(); - const sections = { - getSection: async () => ({ - site_ref: "component:default/site", - section_ref: "sec-1", - section_path: "guide", - parent_section_ref: null, - entity_ref: "component:default/site", - entity_owner_ref: "group:default/team", - }), - } as any; - const comments = { - participantsOf: async () => ["user:default/alice", "user:default/bob"], - } as any; - const pub = new CommentEventPublisher({ - events, - sections, - comments, - logger, - pages: fakePages(), - }); - - await pub.onCommentCreated( - row({ id: "c2", parent_id: "c1", author_ref: "user:default/bob" }), - "user:default/bob", - ); - - expect(published).toHaveLength(1); - expect(published[0].eventPayload).toMatchObject({ - kind: "created", - audience: "participants", - commentId: "c2", - rootId: "c1", - // The actor (bob) is NOT stripped here — recipients are the raw participant set; the - // notifier forwards the actor as excludeEntityRef so the resolver drops them at delivery. - recipients: ["user:default/alice", "user:default/bob"], - // deeplink anchors on the thread root (c1), not the reply (c2), - // so opening the notification lands on the whole thread. - deepLinkSuffix: "/docs/guide/setup#comment-c1", - }); - }); - - it("commenter-side: still publishes when the actor is the only participant (resolver drops them at delivery)", async () => { - // The publisher no longer short-circuits the actor-only case; it emits the raw participant - // set and the notifier's excludeEntityRef makes the resolver drop the actor, yielding no - // notification. (Slightly more work than the old early-exit, but a single exclusion path.) - const { events, published } = fakeEvents(); - const comments = { participantsOf: async () => ["user:default/bob"] } as any; - const pub = new CommentEventPublisher({ - events, - sections: { getSection: async () => undefined } as any, - comments, - logger, - pages: fakePages(), - }); - await pub.onCommentCreated( - row({ id: "c2", parent_id: "c1", author_ref: "user:default/bob" }), - "user:default/bob", - ); - expect(published).toHaveLength(1); - expect(published[0].eventPayload).toMatchObject({ recipients: ["user:default/bob"] }); - }); - - it("owner-side: actorName comes from author_profile in the row", async () => { - const { events, published } = fakeEvents(); - const sections = { - getSection: async () => ({ - site_ref: "component:default/site", - section_ref: "sec-1", - section_path: "guide", - parent_section_ref: null, - entity_ref: "component:default/site", - entity_owner_ref: "group:default/team", - }), - } as any; - const pub = new CommentEventPublisher({ - events, - sections, - comments: { participantsOf: async () => [] } as any, - logger, - pages: fakePages(), - }); - await pub.onCommentCreated( - row({ author_profile: JSON.stringify({ displayName: "Alice Smith" }) }), - "user:default/alice", - ); - expect(published[0].eventPayload).toMatchObject({ actorName: "Alice Smith" }); - }); - - it("resolve: recipients include all participants (resolver not stripped here); uses passed-in actorName", async () => { - const { events, published } = fakeEvents(); - const sections = { getSection: async () => undefined } as any; // degraded link OK - const comments = { - participantsOf: async () => ["user:default/alice", "user:default/bob"], - } as any; - const pub = new CommentEventPublisher({ - events, - sections, - comments, - logger, - pages: fakePages(), - }); - - await pub.onCommentResolved( - row({ id: "c1", parent_id: null }), - "user:default/bob", - "Bob Builder", - ); - - expect(published).toHaveLength(1); - expect(published[0].eventPayload).toMatchObject({ - kind: "resolved", - audience: "participants", - // resolver (bob) is not stripped here; excludeEntityRef drops them at delivery. - recipients: ["user:default/alice", "user:default/bob"], - entityRef: null, // degraded: no section row - actorName: "Bob Builder", - }); - }); - - it("resolve: falls back to parsed entity name when actorName param is missing", async () => { - const { events, published } = fakeEvents(); - const comments = { - participantsOf: async () => ["user:default/alice", "user:default/bob"], - } as any; - const pub = new CommentEventPublisher({ - events, - sections: { getSection: async () => undefined } as any, - comments, - logger, - pages: fakePages(), - }); - - // No actorName param passed - await pub.onCommentResolved(row({ id: "c1", parent_id: null }), "user:default/bob"); - - expect(published[0].eventPayload).toMatchObject({ - actorName: "bob", // parseEntityRef("user:default/bob").name - }); - }); - - it("never throws when publish rejects", async () => { - const events: EventsService = { - publish: async () => { - throw new Error("bus down"); - }, - subscribe: async () => {}, - }; - const sections = { - getSection: async () => ({ - site_ref: "component:default/site", - section_ref: "sec-1", - section_path: "guide", - parent_section_ref: null, - entity_ref: "component:default/site", - entity_owner_ref: "group:default/team", - }), - } as any; - const pub = new CommentEventPublisher({ - events, - sections, - comments: { participantsOf: async () => [] } as any, - logger, - pages: fakePages(), - }); - await expect( - pub.onCommentCreated(row({ parent_id: null }), "user:default/alice"), - ).resolves.toBeUndefined(); - }); - - it("onCommentResolved never throws when publish rejects", async () => { - const events: EventsService = { - publish: async () => { - throw new Error("bus down"); - }, - subscribe: async () => {}, - }; - const comments = { - participantsOf: async () => ["user:default/alice", "user:default/bob"], - } as any; - const pub = new CommentEventPublisher({ - events, - sections: { getSection: async () => undefined } as any, - comments, - logger, - pages: fakePages(), - }); - await expect( - pub.onCommentResolved(row({ id: "c1", parent_id: null }), "user:default/bob", "Bob Builder"), - ).resolves.toBeUndefined(); - }); -}); diff --git a/plugins/rw-backend/src/comments/CommentEventPublisher.ts b/plugins/rw-backend/src/comments/CommentEventPublisher.ts deleted file mode 100644 index 7010486..0000000 --- a/plugins/rw-backend/src/comments/CommentEventPublisher.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { LoggerService } from "@backstage/backend-plugin-api"; -import { parseEntityRef } from "@backstage/catalog-model"; -import { EventsService } from "@backstage/plugin-events-node"; -import { - RW_COMMENTS_TOPIC, - CommentEventPayload, - CommentEventAudience, - buildCommentDeepLinkSuffix, -} from "@rwdocs/backstage-plugin-rw-common"; -import { SectionsReader } from "../siteIndex/SectionsReader"; -import { PagesReader } from "../siteIndex/PagesReader"; -import { snippetFromHtml } from "../inbox/snippet"; -import { joinNonEmpty } from "../inbox/mapping"; -import { CommentStore } from "./CommentStore"; -import { authorFromRow } from "./author"; -import { CommentRow, subpathOf } from "./types"; - -/** Publishes self-contained `rw.comments` domain events after a comment write commits. - * Owns recipient + deep-link resolution from the comments + sections tables, so the - * notifications module stays a thin sender. `recipients` are the raw section owner / thread - * participants and may include the actor; actor exclusion is done entirely subscriber-side via - * `excludeEntityRef` (see CommentNotifier), which the recipient resolver applies after expanding - * groups — the one place that also catches an actor who owns the section through a group. - * Best-effort: every method catches and logs, and always resolves, so a publish failure can - * never affect the comment write. Callers invoke fire-and-forget. */ -export class CommentEventPublisher { - private readonly events: EventsService; - private readonly sections: SectionsReader; - private readonly comments: CommentStore; - private readonly logger: LoggerService; - private readonly pages: PagesReader; - - constructor(deps: { - events: EventsService; - sections: SectionsReader; - comments: CommentStore; - logger: LoggerService; - pages: PagesReader; - }) { - this.events = deps.events; - this.sections = deps.sections; - this.comments = deps.comments; - this.logger = deps.logger; - this.pages = deps.pages; - } - - async onCommentCreated(row: CommentRow, actorRef: string): Promise { - try { - if (row.parent_id === null) { - await this.publishOwnerSide(row, actorRef); - } else { - await this.publishParticipantSide("created", row, row.parent_id, actorRef); - } - } catch (error) { - this.logger.warn(`rw.comments publish (created) failed: ${error}`); - } - } - - async onCommentResolved(row: CommentRow, actorRef: string, actorName?: string): Promise { - try { - // resolve only happens on top-level rows, so the row IS the thread root. - await this.publishParticipantSide("resolved", row, row.id, actorRef, actorName); - } catch (error) { - this.logger.warn(`rw.comments publish (resolved) failed: ${error}`); - } - } - - private async resolvePageTitle( - siteRef: string, - sectionRef: string, - subpath: string, - ): Promise { - try { - return await this.pages.getTitle(siteRef, sectionRef, subpath); - } catch (err) { - this.logger.warn(`rw.comments: could not resolve page title: ${err}`); - return null; - } - } - - private async publishOwnerSide(row: CommentRow, actorRef: string): Promise { - const section = await this.sections.getSection(row.site_ref, row.section_ref); - if (!section || !section.entity_owner_ref) return; // new/unowned section: inbox catches it - // Actor left in even when they own the section; excluded at delivery (see class doc). - const recipients = [section.entity_owner_ref]; - const subpath = subpathOf(row.page_ref); - const viewerPath = joinNonEmpty([section.section_path, subpath], "/"); - const actorName = authorFromRow(row).name; - const [pageTitle, sectionTitle] = await Promise.all([ - this.resolvePageTitle(row.site_ref, row.section_ref, subpath), - this.resolvePageTitle(row.site_ref, row.section_ref, ""), - ]); - await this.publish("created", "owner", row, row.id, recipients, actorRef, { - entityRef: section.entity_ref, - viewerPath, - actorName, - pageTitle, - sectionTitle, - }); - } - - private async publishParticipantSide( - kind: "created" | "resolved", - row: CommentRow, - rootId: string, - actorRef: string, - resolvedActorName?: string, - ): Promise { - // participantsOf returns distinct refs (no extra dedup); the actor is left in and excluded - // at delivery (see class doc). - const recipients = await this.comments.participantsOf(rootId); - if (recipients.length === 0) return; // defensive: thread with no live participants (e.g. root deleted) - // The section row provides entityRef (link target) + section_path (path prefix); degrade gracefully if absent: entityRef -> null (module emits no link), viewerPath -> bare subpath. - const section = await this.sections.getSection(row.site_ref, row.section_ref); - const subpath = subpathOf(row.page_ref); - const viewerPath = section ? joinNonEmpty([section.section_path, subpath], "/") : subpath; - const actorName = - kind === "resolved" - ? (resolvedActorName ?? parseEntityRef(actorRef).name) - : authorFromRow(row).name; - const [pageTitle, sectionTitle] = await Promise.all([ - this.resolvePageTitle(row.site_ref, row.section_ref, subpath), - this.resolvePageTitle(row.site_ref, row.section_ref, ""), - ]); - await this.publish(kind, "participants", row, rootId, recipients, actorRef, { - entityRef: section?.entity_ref ?? null, - viewerPath, - actorName, - pageTitle, - sectionTitle, - }); - } - - private async publish( - kind: "created" | "resolved", - audience: CommentEventAudience, - row: CommentRow, - rootId: string, - recipients: string[], - actorRef: string, - link: { - entityRef: string | null; - viewerPath: string; - actorName: string; - pageTitle: string | null; - sectionTitle: string | null; - }, - ): Promise { - const eventPayload: CommentEventPayload = { - kind, - audience, - occurredAt: new Date().toISOString(), - commentId: row.id, - rootId, - parentId: row.parent_id, - siteRef: row.site_ref, - sectionRef: row.section_ref, - pageRef: row.page_ref, - actorRef, - recipients, - entityRef: link.entityRef, - // Anchor on the thread root, not the triggering row: a reply notification - // should open the whole thread (rootId === row.id for top-level + resolve events). - deepLinkSuffix: buildCommentDeepLinkSuffix({ - viewerPath: link.viewerPath, - commentId: rootId, - }), - bodySnippet: snippetFromHtml(row.body_html), - actorName: link.actorName, - pageTitle: link.pageTitle, - sectionTitle: link.sectionTitle, - }; - await this.events.publish({ topic: RW_COMMENTS_TOPIC, eventPayload }); - } -} diff --git a/plugins/rw-backend/src/comments/CommentPostProcessor.test.ts b/plugins/rw-backend/src/comments/CommentPostProcessor.test.ts new file mode 100644 index 0000000..1e8f0c1 --- /dev/null +++ b/plugins/rw-backend/src/comments/CommentPostProcessor.test.ts @@ -0,0 +1,80 @@ +import { CommentPostProcessor } from "./CommentPostProcessor"; +import { CommentRow } from "./types"; + +const row = {} as CommentRow; + +const flush = () => new Promise((r) => setImmediate(r)); + +const makeLogger = () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + child: jest.fn(), +}); + +const makeProcessor = (name: string, impl?: () => Promise) => ({ + getName: () => name, + process: jest.fn().mockImplementation(impl ?? (() => Promise.resolve())), +}); + +describe("CommentPostProcessor", () => { + it("resolves once and calls every processor with the activity", async () => { + const activity = { action: "created" as const } as any; + const resolver = { resolve: jest.fn().mockResolvedValue(activity) } as any; + const a = makeProcessor("a"); + const b = makeProcessor("b"); + const logger = makeLogger(); + const cpp = new CommentPostProcessor({ resolver, processors: [a, b], logger: logger as any }); + + cpp.postProcess("created", row, "user:default/jane"); + await flush(); + + expect(resolver.resolve).toHaveBeenCalledTimes(1); + expect(a.process).toHaveBeenCalledWith(activity); + expect(b.process).toHaveBeenCalledWith(activity); + }); + + it("skips DB work when there are zero processors", async () => { + const resolver = { resolve: jest.fn() } as any; + const logger = makeLogger(); + const cpp = new CommentPostProcessor({ resolver, processors: [], logger: logger as any }); + + cpp.postProcess("created", row, "user:default/jane"); + await flush(); + + expect(resolver.resolve).not.toHaveBeenCalled(); + }); + + it("isolates a throwing processor and still calls the rest", async () => { + const activity = { action: "created" as const } as any; + const resolver = { resolve: jest.fn().mockResolvedValue(activity) } as any; + const boom = makeProcessor("boom", () => Promise.reject(new Error("boom!"))); + const ok = makeProcessor("ok"); + const logger = makeLogger(); + const cpp = new CommentPostProcessor({ + resolver, + processors: [boom, ok], + logger: logger as any, + }); + + cpp.postProcess("created", row, "user:default/jane"); + await flush(); + + expect(boom.process).toHaveBeenCalledWith(activity); + expect(ok.process).toHaveBeenCalledWith(activity); + expect(logger.error).toHaveBeenCalled(); + }); + + it("suppresses processor calls when the resolver returns undefined", async () => { + const resolver = { resolve: jest.fn().mockResolvedValue(undefined) } as any; + const p = makeProcessor("p"); + const logger = makeLogger(); + const cpp = new CommentPostProcessor({ resolver, processors: [p], logger: logger as any }); + + cpp.postProcess("created", row, "user:default/jane"); + await flush(); + + expect(p.process).not.toHaveBeenCalled(); + }); +}); diff --git a/plugins/rw-backend/src/comments/CommentPostProcessor.ts b/plugins/rw-backend/src/comments/CommentPostProcessor.ts new file mode 100644 index 0000000..63690c1 --- /dev/null +++ b/plugins/rw-backend/src/comments/CommentPostProcessor.ts @@ -0,0 +1,45 @@ +import { LoggerService } from "@backstage/backend-plugin-api"; +import { CommentAction, CommentProcessor } from "@rwdocs/backstage-plugin-rw-node"; +import { CommentActivityResolver } from "./CommentActivityResolver"; +import { CommentRow } from "./types"; + +/** Resolves a CommentActivity once and fans it out to every registered CommentProcessor. + * Injected into the comments router and called synchronously after the write responds; + * postProcess returns immediately and does its work detached, so notification side-effects + * never block or fail the comment write. Per-processor and outer try/catch ensure no floating + * rejection and no cross-processor failure propagation. Zero processors => no DB work. */ +export class CommentPostProcessor { + private readonly resolver: CommentActivityResolver; + private readonly processors: CommentProcessor[]; + private readonly logger: LoggerService; + + constructor(opts: { + resolver: CommentActivityResolver; + processors: CommentProcessor[]; + logger: LoggerService; + }) { + this.resolver = opts.resolver; + this.processors = opts.processors; + this.logger = opts.logger; + } + + /** Fire-and-forget: returns immediately; resolve + fan-out run detached. */ + postProcess(action: CommentAction, row: CommentRow, actorRef: string): void { + if (this.processors.length === 0) return; // no DB work when nobody's listening + void (async () => { + try { + const activity = await this.resolver.resolve(action, row, actorRef); + if (!activity) return; // suppressed (deleted trigger) + for (const processor of this.processors) { + try { + await processor.process(activity); + } catch (e) { + this.logger.error(`Comment processor ${processor.getName()} failed: ${e}`); + } + } + } catch (e) { + this.logger.error(`Comment post-processing failed: ${e}`); + } + })(); + } +} diff --git a/plugins/rw-backend/src/comments/CommentStore.ts b/plugins/rw-backend/src/comments/CommentStore.ts index f97f111..2f07bb2 100644 --- a/plugins/rw-backend/src/comments/CommentStore.ts +++ b/plugins/rw-backend/src/comments/CommentStore.ts @@ -143,7 +143,7 @@ export class CommentStore { /** * Distinct author refs participating in a thread: the root comment plus its direct, * non-deleted replies (threads are one level deep, so `rootId` is the parent of every - * reply). Used by the comment-event publisher to address commenter-side notifications. + * reply). Used by CommentActivityResolver to address commenter-side notifications. * Order is stable (creation order) so notification recipient lists are deterministic. */ async participantsOf(rootId: string): Promise { diff --git a/plugins/rw-backend/src/comments/router.test.ts b/plugins/rw-backend/src/comments/router.test.ts index e80a812..3cbe0bd 100644 --- a/plugins/rw-backend/src/comments/router.test.ts +++ b/plugins/rw-backend/src/comments/router.test.ts @@ -9,7 +9,7 @@ import { AuthorizeResult } from "@backstage/plugin-permission-common"; import type { PermissionsService } from "@backstage/backend-plugin-api"; import { CommentStore } from "./CommentStore"; import { createCommentsRouter } from "./router"; -import { CommentEventPublisher } from "./CommentEventPublisher"; +import { CommentPostProcessor } from "./CommentPostProcessor"; jest.mock("@rwdocs/core", () => ({ renderCommentBody: jest.fn(async (md: string) => `

${md}

`), @@ -846,9 +846,9 @@ describe("comments router", () => { expect(res.body).toEqual({ enabled: true }); }); - // ── Task 6: publisher wiring ───────────────────────────────────────────── + // ── post-processing wiring ─────────────────────────────────────────────── - async function buildAppWithPublisher(publisher: CommentEventPublisher) { + async function buildAppWithPostProcessor(postProcessor: CommentPostProcessor) { const knex = await databases.init("SQLITE_3"); await knex.migrate.latest({ directory: resolvePackagePath("@rwdocs/backstage-plugin-rw-backend", "migrations"), @@ -875,7 +875,7 @@ describe("comments router", () => { ], }), commentsEnabled: true, - publisher, + postProcessor, }), ); app.use( @@ -891,32 +891,28 @@ describe("comments router", () => { return { server, store }; } - it("fire-and-forgets onCommentCreated after a successful create", async () => { - const onCommentCreated = jest.fn().mockResolvedValue(undefined); - const onCommentResolved = jest.fn().mockResolvedValue(undefined); - const publisher = { onCommentCreated, onCommentResolved } as unknown as CommentEventPublisher; - const { server } = await buildAppWithPublisher(publisher); + it("calls postProcess('created', ...) after a successful create", async () => { + const postProcess = jest.fn(); + const postProcessor = { postProcess } as unknown as CommentPostProcessor; + const { server } = await buildAppWithPostProcessor(postProcessor); const res = await request(server) .post("/comments") - .send({ siteRef: ARCH, documentId: DOC, body: "hello publisher", selectors: [] }); + .send({ siteRef: ARCH, documentId: DOC, body: "hello postProcessor", selectors: [] }); expect(res.status).toBe(201); - // Let the fire-and-forget settle before asserting - await new Promise((resolve) => setImmediate(resolve)); - - expect(onCommentCreated).toHaveBeenCalledTimes(1); - const [rowArg, actorArg] = onCommentCreated.mock.calls[0]; - expect(rowArg.parent_id).toBeNull(); - expect(typeof actorArg).toBe("string"); - expect(onCommentResolved).not.toHaveBeenCalled(); + expect(postProcess).toHaveBeenCalledWith( + "created", + expect.objectContaining({ id: expect.any(String) }), + expect.any(String), + ); + expect(postProcess).toHaveBeenCalledTimes(1); }); - it("fire-and-forgets onCommentResolved after a resolve PATCH", async () => { - const onCommentCreated = jest.fn().mockResolvedValue(undefined); - const onCommentResolved = jest.fn().mockResolvedValue(undefined); - const publisher = { onCommentCreated, onCommentResolved } as unknown as CommentEventPublisher; - const { server, store } = await buildAppWithPublisher(publisher); + it("calls postProcess('resolved', ...) after a resolve PATCH", async () => { + const postProcess = jest.fn(); + const postProcessor = { postProcess } as unknown as CommentPostProcessor; + const { server, store } = await buildAppWithPostProcessor(postProcessor); // Seed a top-level comment directly in the store const row = await store.create(ARCH, { @@ -929,21 +925,14 @@ describe("comments router", () => { const res = await request(server).patch(`/comments/${row.id}`).send({ status: "resolved" }); expect(res.status).toBe(200); - await new Promise((resolve) => setImmediate(resolve)); - - expect(onCommentResolved).toHaveBeenCalledTimes(1); - const [resolvedRowArg, actorArg, resolverNameArg] = onCommentResolved.mock.calls[0]; - expect(resolvedRowArg.id).toBe(row.id); - expect(typeof actorArg).toBe("string"); - // resolverName is the third arg: string (display name) or undefined (if not resolvable) - expect(resolverNameArg === undefined || typeof resolverNameArg === "string").toBe(true); + expect(postProcess).toHaveBeenCalledWith("resolved", expect.anything(), expect.any(String)); + expect(postProcess).toHaveBeenCalledTimes(1); }); - it("does NOT call onCommentResolved on reopen (status:'open') or edit (body)", async () => { - const onCommentCreated = jest.fn().mockResolvedValue(undefined); - const onCommentResolved = jest.fn().mockResolvedValue(undefined); - const publisher = { onCommentCreated, onCommentResolved } as unknown as CommentEventPublisher; - const { server, store } = await buildAppWithPublisher(publisher); + it("does NOT call postProcess('resolved', ...) on reopen (status:'open') or edit (body)", async () => { + const postProcess = jest.fn(); + const postProcessor = { postProcess } as unknown as CommentPostProcessor; + const { server, store } = await buildAppWithPostProcessor(postProcessor); // Seed a resolved top-level comment to reopen const row = await store.create(ARCH, { @@ -958,8 +947,7 @@ describe("comments router", () => { const reopenRes = await request(server).patch(`/comments/${row.id}`).send({ status: "open" }); expect(reopenRes.status).toBe(200); - await new Promise((resolve) => setImmediate(resolve)); - expect(onCommentResolved).not.toHaveBeenCalled(); + expect(postProcess).not.toHaveBeenCalledWith("resolved", expect.anything(), expect.anything()); // Edit (body change by author) const editRes = await request(server) @@ -967,11 +955,10 @@ describe("comments router", () => { .send({ body: "edited body" }); expect(editRes.status).toBe(200); - await new Promise((resolve) => setImmediate(resolve)); - expect(onCommentResolved).not.toHaveBeenCalled(); + expect(postProcess).not.toHaveBeenCalledWith("resolved", expect.anything(), expect.anything()); }); - // ── End Task 6: publisher wiring ───────────────────────────────────────── + // ── End post-processing wiring ─────────────────────────────────────────── // ── Preserved-seam guard (Task 1) ──────────────────────────────────────── // This test locks the viewer wire: GET ?documentId= / POST body.documentId / diff --git a/plugins/rw-backend/src/comments/router.ts b/plugins/rw-backend/src/comments/router.ts index 3b9c1a6..d919c14 100644 --- a/plugins/rw-backend/src/comments/router.ts +++ b/plugins/rw-backend/src/comments/router.ts @@ -34,7 +34,7 @@ import type { CommentResponse } from "./mapping"; import { resolveAuthor } from "./author"; import { logCommentOp } from "./logging"; import { commentResourceRef } from "./permissions"; -import type { CommentEventPublisher } from "./CommentEventPublisher"; +import { CommentPostProcessor } from "./CommentPostProcessor"; const MAX_BODY_BYTES = 16 * 1024; @@ -61,9 +61,9 @@ export interface CommentsRouterDeps { permissionsRegistry: PermissionsRegistryService; catalog: CatalogService; commentsEnabled: boolean; - /** Optional: when present, comment create/resolve fire-and-forget a domain event. - * Absent in tests that don't exercise notifications. */ - publisher?: CommentEventPublisher; + /** Optional: when present, comment create/resolve fire-and-forget notification + * post-processing. Absent in tests that don't exercise notifications. */ + postProcessor?: CommentPostProcessor; } /** Split a viewer pageRef ("#"); throws InputError(400) on a @@ -225,7 +225,7 @@ export function createCommentsRouter(deps: CommentsRouterDeps): Router { }); logCommentOp(logger, { kind: "mutation", op: "create", siteRef, commentId: row.id, parentId }); res.status(201).json(toCommentResponse(row, authorRef)); - void deps.publisher?.onCommentCreated(row, authorRef); + deps.postProcessor?.postProcess("created", row, authorRef); }); router.get("/comments/:id", async (req, res) => { @@ -362,16 +362,7 @@ export function createCommentsRouter(deps: CommentsRouterDeps): Router { res.json(toCommentResponse(updated!, userRef)); // Notify participants only on resolve; reopens/edits aren't a thread-ending event worth a push (spec §6 noise model). if (status === "resolved") { - // Resolve the display name of the resolver (best-effort; falls back to parsed entity name). - const resolverName = await resolveAuthor({ - userInfo: deps.userInfo, - auth: deps.auth, - catalog, - credentials, - }) - .then((a) => a.authorProfile?.displayName ?? undefined) - .catch(() => undefined); - void deps.publisher?.onCommentResolved(updated!, userRef, resolverName); + deps.postProcessor?.postProcess("resolved", updated!, userRef); } }); diff --git a/plugins/rw-backend/src/plugin.ts b/plugins/rw-backend/src/plugin.ts index 1d325b7..0f2b32c 100644 --- a/plugins/rw-backend/src/plugin.ts +++ b/plugins/rw-backend/src/plugin.ts @@ -17,12 +17,16 @@ import { toEntityPath, } from "@rwdocs/backstage-plugin-rw-common"; import { catalogServiceRef } from "@backstage/plugin-catalog-node"; -import { eventsServiceRef } from "@backstage/plugin-events-node"; +import { + rwCommentProcessingExtensionPoint, + CommentProcessor, +} from "@rwdocs/backstage-plugin-rw-node"; import { createRouter } from "./router"; import { Hub, type HubOptions } from "./hub"; import { CommentStore } from "./comments/CommentStore"; import { createCommentsRouter } from "./comments/router"; -import { CommentEventPublisher } from "./comments/CommentEventPublisher"; +import { CommentActivityResolver } from "./comments/CommentActivityResolver"; +import { CommentPostProcessor } from "./comments/CommentPostProcessor"; import { SectionsReader } from "./siteIndex/SectionsReader"; import { PagesReader } from "./siteIndex/PagesReader"; import { commentResourceRef, isCommentAuthor } from "./comments/permissions"; @@ -33,6 +37,12 @@ import { createInboxRouter } from "./inbox/inboxRouter"; export const rwPlugin = createBackendPlugin({ pluginId: "rw", register(env) { + const commentProcessors = new Array(); + env.registerExtensionPoint(rwCommentProcessingExtensionPoint, { + addProcessor: (...processors) => { + commentProcessors.push(...processors.flat()); + }, + }); env.registerInit({ deps: { httpRouter: coreServices.httpRouter, @@ -46,7 +56,6 @@ export const rwPlugin = createBackendPlugin({ userInfo: coreServices.userInfo, auth: coreServices.auth, catalog: catalogServiceRef, - events: eventsServiceRef, }, async init({ httpRouter, @@ -60,7 +69,6 @@ export const rwPlugin = createBackendPlugin({ userInfo, auth, catalog, - events, }) { const siteConfig = readRwSiteConfig(config); const cacheSize = config.getOptionalNumber("rw.cacheSize"); @@ -109,12 +117,18 @@ export const rwPlugin = createBackendPlugin({ const siteRefreshStore = new SiteRefreshStore(client); const sectionsReader = new SectionsReader(client); const pagesReader = new PagesReader(client); - const publisher = new CommentEventPublisher({ - events, + const resolver = new CommentActivityResolver({ sections: sectionsReader, + pages: pagesReader, comments: store, + catalog, + auth, + logger, + }); + const postProcessor = new CommentPostProcessor({ + resolver, + processors: commentProcessors, logger, - pages: pagesReader, }); const makeSite = makeSiteFactory(siteConfig); @@ -194,7 +208,7 @@ export const rwPlugin = createBackendPlugin({ permissionsRegistry, catalog, commentsEnabled, - publisher, + postProcessor, }), ); httpRouter.addAuthPolicy({ diff --git a/plugins/rw-backend/src/siteIndex/PagesReader.ts b/plugins/rw-backend/src/siteIndex/PagesReader.ts index a32815e..404c33d 100644 --- a/plugins/rw-backend/src/siteIndex/PagesReader.ts +++ b/plugins/rw-backend/src/siteIndex/PagesReader.ts @@ -2,8 +2,8 @@ import type { Knex } from "knex"; const TABLE = "pages"; -/** By-key reader for the `pages` registry. Used by the comment-event publisher - * to resolve the page title (pageTitle) for a given (siteRef, sectionRef, subpath). */ +/** By-key reader for the `pages` registry. Used by CommentActivityResolver to resolve the page + * title for a given (siteRef, sectionRef, subpath). */ export class PagesReader { constructor(private readonly knex: Knex) {} diff --git a/plugins/rw-backend/src/siteIndex/SectionsReader.ts b/plugins/rw-backend/src/siteIndex/SectionsReader.ts index ae4cd76..0ff995e 100644 --- a/plugins/rw-backend/src/siteIndex/SectionsReader.ts +++ b/plugins/rw-backend/src/siteIndex/SectionsReader.ts @@ -3,7 +3,7 @@ import { SectionRow } from "./types"; const TABLE = "sections"; -/** By-key reader for the dense `sections` registry. Used by the comment-event publisher +/** By-key reader for the dense `sections` registry. Used by CommentActivityResolver * to resolve a section's effective owner (`entity_owner_ref`), its owning entity * (`entity_ref`), and its owner-relative `section_path` for the deep link. One indexed * point-read on PK (site_ref, section_ref); no RwSite load. Kept separate from the diff --git a/plugins/rw-common/src/commentEvents.ts b/plugins/rw-common/src/commentEvents.ts deleted file mode 100644 index 00d5c50..0000000 --- a/plugins/rw-common/src/commentEvents.ts +++ /dev/null @@ -1,25 +0,0 @@ -export const RW_COMMENTS_TOPIC = "rw.comments"; - -export type CommentEventKind = "created" | "resolved"; -export type CommentEventAudience = "owner" | "participants"; - -export interface CommentEventPayload { - kind: CommentEventKind; - audience: CommentEventAudience; - occurredAt: string; // ISO-8601 - commentId: string; - rootId: string; // = parentId ?? commentId - parentId: string | null; - siteRef: string; - sectionRef: string; - pageRef: string; // identifies the page within the section ("#") - actorRef: string; // the user who triggered the event; forwarded as excludeEntityRef so the resolver drops them (see CommentNotifier) - recipients: string[]; // catalog entity refs, non-empty; raw owner/participants — may include actorRef - entityRef: string | null; // owning entity (sections.entity_ref); null = degraded link - // prefix-free deep-link suffix, always "/docs/#comment-". Normal: viewerPath = section_path + "/" + subpath. Degraded (no section row): viewerPath = subpath only (section prefix omitted). - deepLinkSuffix: string; - bodySnippet: string; - actorName: string; // display name of the actor (who did it) - pageTitle: string | null; // title of the page the comment is on - sectionTitle: string | null; // title of the section (its root page); shown as "Page · Section". From the siteIndex, not the catalog entity (entities are slugs). -} diff --git a/plugins/rw-common/src/index.ts b/plugins/rw-common/src/index.ts index bd570dd..50f90f3 100644 --- a/plugins/rw-common/src/index.ts +++ b/plugins/rw-common/src/index.ts @@ -7,5 +7,3 @@ export * from "./permissions"; export { iterateAnnotatedEntities, RW_ANNOTATION } from "./iterateAnnotatedEntities"; export type { InboxItem, InboxResponse, InboxQuery } from "./inboxTypes"; export { buildCommentDeepLinkSuffix } from "./commentLink"; -export { RW_COMMENTS_TOPIC } from "./commentEvents"; -export type { CommentEventPayload, CommentEventKind, CommentEventAudience } from "./commentEvents"; diff --git a/plugins/rw-node/.eslintrc.js b/plugins/rw-node/.eslintrc.js new file mode 100644 index 0000000..e2a53a6 --- /dev/null +++ b/plugins/rw-node/.eslintrc.js @@ -0,0 +1 @@ +module.exports = require('@backstage/cli/config/eslint-factory')(__dirname); diff --git a/plugins/rw-node/LICENSE-APACHE b/plugins/rw-node/LICENSE-APACHE new file mode 100644 index 0000000..a97422a --- /dev/null +++ b/plugins/rw-node/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 The RW Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/rw-node/LICENSE-MIT b/plugins/rw-node/LICENSE-MIT new file mode 100644 index 0000000..9cf18fa --- /dev/null +++ b/plugins/rw-node/LICENSE-MIT @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 The RW Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/rw-node/package.json b/plugins/rw-node/package.json new file mode 100644 index 0000000..f004c99 --- /dev/null +++ b/plugins/rw-node/package.json @@ -0,0 +1,52 @@ +{ + "name": "@rwdocs/backstage-plugin-rw-node", + "version": "0.1.7", + "license": "MIT OR Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/rwdocs/backstage-plugins.git", + "directory": "plugins/rw-node" + }, + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": "./src/index.ts", + "./package.json": "./package.json" + }, + "publishConfig": { + "access": "public", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "types": "dist/index.d.ts" + }, + "backstage": { + "role": "node-library", + "pluginId": "rw" + }, + "files": [ + "dist", + "LICENSE-MIT", + "LICENSE-APACHE" + ], + "scripts": { + "start": "backstage-cli package start", + "build": "backstage-cli package build", + "lint": "backstage-cli package lint", + "test": "backstage-cli package test", + "clean": "backstage-cli package clean", + "format": "prettier --write src/", + "format:check": "prettier --check src/", + "prepack": "backstage-cli package prepack", + "postpack": "backstage-cli package postpack" + }, + "dependencies": { + "@backstage/backend-plugin-api": "^1.0.0" + }, + "devDependencies": { + "@backstage/cli": "^0.36.0", + "@types/jest": "^30.0.0", + "jest": "^30.2.0", + "prettier": "^3.4.2", + "typescript": "^5.7.0" + } +} diff --git a/plugins/rw-node/src/CommentActivity.ts b/plugins/rw-node/src/CommentActivity.ts new file mode 100644 index 0000000..b948853 --- /dev/null +++ b/plugins/rw-node/src/CommentActivity.ts @@ -0,0 +1,25 @@ +export type CommentAction = "created" | "resolved"; + +/** + * A resolved comment activity (a comment created or a thread resolved), pushed to + * every registered CommentProcessor. Self-contained: a processor needs no DB access. + */ +export interface CommentActivity { + action: CommentAction; + occurredAt: string; // ISO-8601, from the row timestamp + commentId: string; + rootId: string; // parentId ?? commentId (created) / commentId (resolved) + parentId: string | null; // null => top-level + siteRef: string; + sectionRef: string; + pageRef: string; // "#" + 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 + entityRef: string | null; // deep-link target (null => no link) + pageTitle: string | null; + sectionTitle: string | null; + viewerPath: string; // section_path + subpath, for the deep link + bodySnippet: string; // plain-text preview of the triggering comment +} diff --git a/plugins/rw-node/src/CommentProcessor.ts b/plugins/rw-node/src/CommentProcessor.ts new file mode 100644 index 0000000..c340eb7 --- /dev/null +++ b/plugins/rw-node/src/CommentProcessor.ts @@ -0,0 +1,13 @@ +import { CommentActivity } from "./CommentActivity"; + +/** + * Registered by a backend module via rwCommentProcessingExtensionPoint; rw-backend + * invokes process() at runtime for each comment activity. Follows the same shape as + * NotificationProcessor in @backstage/plugin-notifications-node. + */ +export interface CommentProcessor { + /** Stable name for logging. */ + getName(): string; + /** React to a comment activity (e.g. send a notification). Should not throw. */ + process(comment: CommentActivity): Promise; +} diff --git a/plugins/rw-node/src/extensionPoints.test.ts b/plugins/rw-node/src/extensionPoints.test.ts new file mode 100644 index 0000000..4cf146c --- /dev/null +++ b/plugins/rw-node/src/extensionPoints.test.ts @@ -0,0 +1,9 @@ +import { rwCommentProcessingExtensionPoint } from "./extensionPoints"; + +describe("rwCommentProcessingExtensionPoint", () => { + it("has the rw.comment-processing id", () => { + // createExtensionPoint stores the id on the returned ref; its string form is the + // public, stable surface. Smoke test that the ref constructs with the right id. + expect(String(rwCommentProcessingExtensionPoint)).toContain("rw.comment-processing"); + }); +}); diff --git a/plugins/rw-node/src/extensionPoints.ts b/plugins/rw-node/src/extensionPoints.ts new file mode 100644 index 0000000..299746c --- /dev/null +++ b/plugins/rw-node/src/extensionPoints.ts @@ -0,0 +1,12 @@ +import { createExtensionPoint } from "@backstage/backend-plugin-api"; +import { CommentProcessor } from "./CommentProcessor"; + +export interface RwCommentProcessingExtensionPoint { + /** Register one or more processors invoked on every comment activity. */ + addProcessor(...processors: Array): void; +} + +export const rwCommentProcessingExtensionPoint = + createExtensionPoint({ + id: "rw.comment-processing", + }); diff --git a/plugins/rw-node/src/index.ts b/plugins/rw-node/src/index.ts new file mode 100644 index 0000000..2ab2ddf --- /dev/null +++ b/plugins/rw-node/src/index.ts @@ -0,0 +1,4 @@ +export type { CommentAction, CommentActivity } from "./CommentActivity"; +export type { CommentProcessor } from "./CommentProcessor"; +export type { RwCommentProcessingExtensionPoint } from "./extensionPoints"; +export { rwCommentProcessingExtensionPoint } from "./extensionPoints"; diff --git a/plugins/rw-node/tsconfig.json b/plugins/rw-node/tsconfig.json new file mode 100644 index 0000000..a086b14 --- /dev/null +++ b/plugins/rw-node/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json index d3d83c4..62a8227 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,6 +5,7 @@ "plugins/rw-common/src", "plugins/rw-backend/src", "plugins/rw-common/config.d.ts", + "plugins/rw-node/src", "plugins/rw-backend-module-notifications/src", "plugins/search-backend-module-rw/src", "plugins/search-backend-module-rw/config.d.ts", diff --git a/yarn.lock b/yarn.lock index cd3426a..49bea32 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7275,16 +7275,15 @@ __metadata: "@backstage/backend-test-utils": "npm:^1.11.0" "@backstage/catalog-model": "npm:^1.7.6" "@backstage/cli": "npm:^0.36.0" - "@backstage/plugin-events-node": "npm:^0.4.23" "@backstage/plugin-notifications-node": "npm:^0.2.27" "@rwdocs/backstage-plugin-rw-common": "workspace:^" + "@rwdocs/backstage-plugin-rw-node": "workspace:^" "@types/jest": "npm:^30.0.0" jest: "npm:^30.2.0" prettier: "npm:^3.4.2" typescript: "npm:^5.7.0" peerDependencies: "@backstage/backend-plugin-api": ^1.0.0 - "@backstage/plugin-events-node": ^0.4.23 "@backstage/plugin-notifications-node": ^0.2.27 languageName: unknown linkType: soft @@ -7301,11 +7300,11 @@ __metadata: "@backstage/config": "npm:^1.3.6" "@backstage/errors": "npm:^1.2.7" "@backstage/plugin-catalog-node": "npm:^2.2.2" - "@backstage/plugin-events-node": "npm:^0.4.23" "@backstage/plugin-permission-common": "npm:^0.9.9" "@backstage/plugin-permission-node": "npm:^0.11.1" "@backstage/types": "npm:^1.2.2" "@rwdocs/backstage-plugin-rw-common": "workspace:^" + "@rwdocs/backstage-plugin-rw-node": "workspace:^" "@rwdocs/core": "npm:^0.1.28" "@types/express": "npm:^4.17.0" "@types/jest": "npm:^30.0.0" @@ -7345,6 +7344,19 @@ __metadata: languageName: unknown linkType: soft +"@rwdocs/backstage-plugin-rw-node@workspace:^, @rwdocs/backstage-plugin-rw-node@workspace:plugins/rw-node": + version: 0.0.0-use.local + resolution: "@rwdocs/backstage-plugin-rw-node@workspace:plugins/rw-node" + dependencies: + "@backstage/backend-plugin-api": "npm:^1.0.0" + "@backstage/cli": "npm:^0.36.0" + "@types/jest": "npm:^30.0.0" + jest: "npm:^30.2.0" + prettier: "npm:^3.4.2" + typescript: "npm:^5.7.0" + languageName: unknown + linkType: soft + "@rwdocs/backstage-plugin-rw@workspace:*, @rwdocs/backstage-plugin-rw@workspace:plugins/rw": version: 0.0.0-use.local resolution: "@rwdocs/backstage-plugin-rw@workspace:plugins/rw"