@storyshelf/app 0.10.0-next-e151b58 → 0.10.0-next-8035d25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/dist/index.mjs +1 -1
  2. package/dist/jobs-setup.mjs +28 -2
  3. package/dist/jobs-setup.mjs.map +1 -1
  4. package/dist/mentions.mjs +95 -0
  5. package/dist/mentions.mjs.map +1 -0
  6. package/dist/notify.mjs +132 -45
  7. package/dist/notify.mjs.map +1 -1
  8. package/dist/pages/build-diff-comments.mjs +37 -10
  9. package/dist/pages/build-diff-comments.mjs.map +1 -1
  10. package/dist/pages/comment-body-field.mjs +102 -0
  11. package/dist/pages/comment-body-field.mjs.map +1 -0
  12. package/dist/pages/project-settings.mjs.map +1 -1
  13. package/dist/pages/review-changeset.mjs +1 -1
  14. package/dist/pages/review-changeset.mjs.map +1 -1
  15. package/dist/pages/review-comments.mjs +5 -8
  16. package/dist/pages/review-comments.mjs.map +1 -1
  17. package/dist/pages/review-verdict.mjs +54 -0
  18. package/dist/pages/review-verdict.mjs.map +1 -1
  19. package/dist/pages/settings-component-owners.mjs +90 -0
  20. package/dist/pages/settings-component-owners.mjs.map +1 -0
  21. package/dist/pages/settings-members.mjs +11 -0
  22. package/dist/pages/settings-members.mjs.map +1 -1
  23. package/dist/pages/settings-notifications.mjs +37 -53
  24. package/dist/pages/settings-notifications.mjs.map +1 -1
  25. package/dist/review-assignment.mjs +37 -0
  26. package/dist/review-assignment.mjs.map +1 -0
  27. package/dist/review-events.mjs +17 -7
  28. package/dist/review-events.mjs.map +1 -1
  29. package/dist/routers/comments.mjs +50 -10
  30. package/dist/routers/comments.mjs.map +1 -1
  31. package/dist/routers/mention-suggest.mjs +65 -0
  32. package/dist/routers/mention-suggest.mjs.map +1 -0
  33. package/dist/routers/notification-subscriptions.mjs +36 -3
  34. package/dist/routers/notification-subscriptions.mjs.map +1 -1
  35. package/dist/routers/profile.mjs +8 -4
  36. package/dist/routers/profile.mjs.map +1 -1
  37. package/dist/routers/review-forms.mjs +2 -0
  38. package/dist/routers/review-forms.mjs.map +1 -1
  39. package/dist/routers/review-section.handlers.mjs +8 -1
  40. package/dist/routers/review-section.handlers.mjs.map +1 -1
  41. package/dist/routers/schemas.mjs +10 -2
  42. package/dist/routers/schemas.mjs.map +1 -1
  43. package/dist/routers/settings-members.mjs +27 -1
  44. package/dist/routers/settings-members.mjs.map +1 -1
  45. package/dist/routers/settings-notifications.mjs +31 -4
  46. package/dist/routers/settings-notifications.mjs.map +1 -1
  47. package/dist/routers/settings.handlers.mjs +2 -1
  48. package/dist/routers/settings.handlers.mjs.map +1 -1
  49. package/dist/routes/page-routes.mjs +3 -0
  50. package/dist/routes/page-routes.mjs.map +1 -1
  51. package/dist/shell/client-script.mjs +11 -0
  52. package/dist/shell/client-script.mjs.map +1 -1
  53. package/dist/ui/forms.mjs +2 -1
  54. package/dist/ui/forms.mjs.map +1 -1
  55. package/dist/ui/styles/review.mjs +10 -1
  56. package/dist/ui/styles/review.mjs.map +1 -1
  57. package/package.json +6 -6
package/dist/index.mjs CHANGED
@@ -380,7 +380,7 @@ function registerDocs(app) {
380
380
  * from source (dev server, tests).
381
381
  */
382
382
  function packageVersion() {
383
- return "0.10.0-next-e151b58";
383
+ return "0.10.0-next-8035d25";
384
384
  }
385
385
  export { createAuthSystemHook, createShelfApp, createShelfLogger };
386
386
 
@@ -1,12 +1,14 @@
1
1
  import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
+ import { notifyAssignedWith } from "./notify.mjs";
4
5
  import { setupBuildState } from "./build-state-setup.mjs";
5
6
  import { setupCapture } from "./capture-setup.mjs";
6
7
  import { combineConsumers, jobMetrics, lazyConsumer } from "./consumer-setup.mjs";
7
8
  import { createJobCanceller } from "./job-canceller.mjs";
8
9
  import { createJobQueue } from "./job-queue.mjs";
9
10
  import { setupPublishing } from "./publish-setup.mjs";
11
+ import { assignedReviewersOf, assignmentReason } from "./review-assignment.mjs";
10
12
  import { REVIEW_EVENTS, announceReviewWith, reviewRequestedData } from "./review-events.mjs";
11
13
  import { isServerlessHost as isServerlessHost$1 } from "./serverless.mjs";
12
14
  import { DEFAULT_SCRATCH_DIR } from "@storyshelf/core/config";
@@ -112,10 +114,25 @@ function notifyConsumer(options, runtime, queue, handler) {
112
114
  await queue.built()?.lifecycle.teardown();
113
115
  }]);
114
116
  }
117
+ /**
118
+ * The reviewers the component owners assign to a build (#465). Best-effort: a failed lookup logs and
119
+ * leaves the request to the default recipients, so a broken rule never swallows a review request.
120
+ */
121
+ async function assignedOrNone(deps, project, build) {
122
+ try {
123
+ return await assignedReviewersOf(deps.db, project, build);
124
+ } catch (error) {
125
+ deps.logger.warn({
126
+ err: error,
127
+ buildId: build.id
128
+ }, "component owners could not be resolved");
129
+ return [];
130
+ }
131
+ }
115
132
  /** Tells the project's reviewers a published build waits for a verdict (webhook and notifications). */
116
133
  function reviewRequestedFor(options, runtime) {
117
134
  return async (project, build) => {
118
- await announceReviewWith({
135
+ const deps = {
119
136
  db: options.database,
120
137
  config: runtime.config,
121
138
  ui: runtime.ui,
@@ -123,7 +140,16 @@ function reviewRequestedFor(options, runtime) {
123
140
  notifiers: runtime.notifiers,
124
141
  notificationsConfig: options.notifications?.config,
125
142
  publicOrigin: runtime.publicOrigin
126
- }, project, REVIEW_EVENTS.requested, reviewRequestedData(build), build.id);
143
+ };
144
+ const assigned = await assignedOrNone(deps, project, build);
145
+ await announceReviewWith(deps, project, REVIEW_EVENTS.requested, reviewRequestedData(build), build.id, { skipUsers: new Set(assigned.map((reviewer) => reviewer.userId)) });
146
+ await Promise.all(assigned.map(async (reviewer) => {
147
+ const data = {
148
+ ...reviewRequestedData(build),
149
+ assignedBecause: assignmentReason(reviewer.components)
150
+ };
151
+ await notifyAssignedWith(deps, project, reviewer.userId, data, `/builds/${build.id}`);
152
+ }));
127
153
  };
128
154
  }
129
155
  export { setupJobs };
@@ -1 +1 @@
1
- {"version":3,"file":"jobs-setup.mjs","names":["isServerlessHost"],"sources":["../src/jobs-setup.ts"],"sourcesContent":["/**\n * Background jobs of the app (ADR 0006): the one database queue, the handlers of its job kinds\n * (`capture` with `testing`, `publish` on a long-running host, `notify` on a queue of its own) and the one consumer that runs\n * them.\n */\nimport {\n DEFAULT_RETRIES,\n NOTIFY_MAX_ATTEMPTS,\n type BuildStatePublisher,\n type CaptureConsumer,\n type KindHandler,\n} from \"@storyshelf/core/capture\";\nimport type { ShelfOptions } from \"@storyshelf/core/config\";\nimport { DEFAULT_SCRATCH_DIR } from \"@storyshelf/core/config\";\nimport type { Build, CaptureJobKind, Project } from \"@storyshelf/core/schema\";\nimport { setupBuildState, type BuildStateWiring } from \"./build-state-setup.ts\";\nimport { setupCapture, type CaptureWiring } from \"./capture-setup.ts\";\nimport { combineConsumers, jobMetrics, lazyConsumer } from \"./consumer-setup.ts\";\nimport { createJobCanceller, type JobCanceller } from \"./job-canceller.ts\";\nimport { createJobQueue, type JobQueue } from \"./job-queue.ts\";\nimport { setupPublishing, type PublishService, type PublishWiring } from \"./publish-setup.ts\";\nimport { announceReviewWith, REVIEW_EVENTS, reviewRequestedData } from \"./review-events.ts\";\nimport type { ServerRuntime } from \"./runtime.ts\";\nimport { isServerlessHost } from \"./serverless.ts\";\n\n/** What the rest of the app takes from the job wiring. */\nexport interface JobsWiring {\n /** Where captures queue; null without `testing`. */\n captureQueue: CaptureWiring[\"queue\"];\n enqueueCapture: CaptureWiring[\"enqueueCapture\"];\n /** Whether this instance runs the capture jobs (the retry and compute-jobs UI applies). */\n capturesHere: boolean;\n publish: PublishService;\n /** Asks for a build's current state to be shown on its git channels (ADR 0014). */\n buildState: BuildStatePublisher;\n /** Cancels every unfinished job of a build, of any kind (capture, publish, notify). */\n cancelBuildJobs: JobCanceller;\n /** Runs every handler this instance has; null when it consumes nothing. */\n consumer: CaptureConsumer | null;\n}\n\n/** Wire the queue, the handlers and the consumer for `options`. */\nexport function setupJobs(\n options: ShelfOptions,\n runtime: ServerRuntime,\n env: NodeJS.ProcessEnv = process.env,\n): JobsWiring {\n const { jobs, notifyJobs } = queuesFor(options, isServerlessHost(env));\n const buildState = setupBuildState(options, runtime, notifyJobs);\n const capture = setupCapture(\n options,\n runtime.config,\n {\n notifiers: runtime.notifiers,\n buildState: buildState.publisher,\n publicOrigin: async () => await runtime.publicOrigin.url(),\n },\n runtime.logger,\n jobs,\n );\n const publishing = setupPublishing(\n options,\n runtime.config,\n runtime.logger,\n capture.enqueueCapture !== undefined,\n {\n jobs,\n buildState: buildState.publisher,\n reviewRequested: reviewRequestedFor(options, runtime),\n },\n );\n const wiring = { capture, publishing, buildState, notifyJobs };\n return {\n captureQueue: capture.queue,\n enqueueCapture: capture.enqueueCapture,\n capturesHere: capture.handler !== null,\n publish: publishing,\n buildState: buildState.publisher,\n cancelBuildJobs: createJobCanceller([jobs.view]),\n consumer: combineConsumers(consumersOf(options, runtime, jobs, wiring)),\n };\n}\n\n/**\n * The database queue (captures with `testing`, publishing on a long-running host) and,\n * on a long-running host, a queue of its own for `notify` jobs: a status must not wait behind a\n * capture that holds every slot for minutes.\n */\nfunction queuesFor(\n options: ShelfOptions,\n serverless: boolean,\n): { jobs: JobQueue; notifyJobs: JobQueue | null } {\n const testing = options.testing;\n const databaseCaptures = testing?.runner !== undefined;\n const kinds: CaptureJobKind[] = [\n ...(databaseCaptures ? ([\"capture\"] as const) : []),\n ...(serverless ? [] : ([\"publish\"] as const)),\n ];\n return {\n jobs: createJobQueue(options, kinds, testing?.retries ?? DEFAULT_RETRIES),\n notifyJobs: serverless ? null : createJobQueue(options, [\"notify\"], NOTIFY_MAX_ATTEMPTS - 1),\n };\n}\n\n/** Every consumer this instance runs; none on a host that polls nothing. */\nfunction consumersOf(\n options: ShelfOptions,\n runtime: ServerRuntime,\n jobs: JobQueue,\n wiring: {\n capture: CaptureWiring;\n publishing: Pick<PublishWiring, \"handler\">;\n buildState: Pick<BuildStateWiring, \"handler\">;\n notifyJobs: JobQueue | null;\n },\n): CaptureConsumer[] {\n const { capture, publishing, buildState, notifyJobs } = wiring;\n return [\n ...(jobs.kinds.length > 0\n ? [databaseConsumer(options, runtime, jobs, { capture, publishing })]\n : []),\n ...(buildState.handler !== null && notifyJobs !== null\n ? [notifyConsumer(options, runtime, notifyJobs, buildState.handler)]\n : []),\n ];\n}\n\n/** The consumer of the database queue: capture and publish jobs. */\nfunction databaseConsumer(\n options: ShelfOptions,\n runtime: ServerRuntime,\n jobs: JobQueue,\n wiring: {\n capture: CaptureWiring;\n publishing: Pick<PublishWiring, \"handler\">;\n },\n): CaptureConsumer {\n const { capture, publishing } = wiring;\n const capturesHere = jobs.kinds.includes(\"capture\");\n return lazyConsumer(\n () => ({\n queue: jobs.get(),\n handlers: {\n ...(capturesHere && capture.handler ? { capture: capture.handler } : {}),\n ...(publishing.handler ? { publish: publishing.handler } : {}),\n },\n // Publishing alone is one job at a time, so a big zip never crowds out the web server.\n config: {\n concurrency: capturesHere ? options.testing?.concurrency : 1,\n maxRetries: options.testing?.retries ?? DEFAULT_RETRIES,\n },\n scratchDir: runtime.config.scratchDir ?? DEFAULT_SCRATCH_DIR,\n metrics: jobMetrics(),\n logger: runtime.logger.child({ component: \"job-consumer\" }),\n ...(options.testing?.queueClock === undefined\n ? {}\n : { now: options.testing.queueClock.now, timers: options.testing.queueClock.timers }),\n }),\n [\n async () => {\n if (capturesHere) {\n await capture.handler?.close?.();\n }\n await jobs.built()?.lifecycle.teardown();\n },\n ],\n );\n}\n\n/** Deliveries running at once: short HTTP calls to a git host, bounded to stay clear of its rate limits. */\nconst NOTIFY_CONCURRENCY = 4;\n\n/** The consumer of `notify` jobs, on its own queue so no capture or publish job can starve it. */\nfunction notifyConsumer(\n options: ShelfOptions,\n runtime: ServerRuntime,\n queue: JobQueue,\n handler: KindHandler,\n): CaptureConsumer {\n return lazyConsumer(\n () => ({\n queue: queue.get(),\n handlers: { notify: handler },\n config: { concurrency: NOTIFY_CONCURRENCY },\n metrics: jobMetrics(),\n logger: runtime.logger.child({ component: \"notify-consumer\" }),\n ...(options.testing?.queueClock === undefined\n ? {}\n : { now: options.testing.queueClock.now, timers: options.testing.queueClock.timers }),\n }),\n [\n async () => {\n await queue.built()?.lifecycle.teardown();\n },\n ],\n );\n}\n\n/** Tells the project's reviewers a published build waits for a verdict (webhook and notifications). */\nfunction reviewRequestedFor(\n options: ShelfOptions,\n runtime: ServerRuntime,\n): (project: Project, build: Build) => Promise<void> {\n return async (project, build) => {\n await announceReviewWith(\n {\n db: options.database,\n config: runtime.config,\n ui: runtime.ui,\n logger: runtime.logger,\n notifiers: runtime.notifiers,\n notificationsConfig: options.notifications?.config,\n publicOrigin: runtime.publicOrigin,\n },\n project,\n REVIEW_EVENTS.requested,\n reviewRequestedData(build),\n build.id,\n );\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"jobs-setup.mjs","names":["isServerlessHost"],"sources":["../src/jobs-setup.ts"],"sourcesContent":["/**\n * Background jobs of the app (ADR 0006): the one database queue, the handlers of its job kinds\n * (`capture` with `testing`, `publish` on a long-running host, `notify` on a queue of its own) and the one consumer that runs\n * them.\n */\nimport {\n DEFAULT_RETRIES,\n NOTIFY_MAX_ATTEMPTS,\n type BuildStatePublisher,\n type CaptureConsumer,\n type KindHandler,\n} from \"@storyshelf/core/capture\";\nimport type { ShelfOptions } from \"@storyshelf/core/config\";\nimport { DEFAULT_SCRATCH_DIR } from \"@storyshelf/core/config\";\nimport type { Build, CaptureJobKind, Project } from \"@storyshelf/core/schema\";\nimport { setupBuildState, type BuildStateWiring } from \"./build-state-setup.ts\";\nimport { setupCapture, type CaptureWiring } from \"./capture-setup.ts\";\nimport { combineConsumers, jobMetrics, lazyConsumer } from \"./consumer-setup.ts\";\nimport { createJobCanceller, type JobCanceller } from \"./job-canceller.ts\";\nimport { createJobQueue, type JobQueue } from \"./job-queue.ts\";\nimport { notifyAssignedWith, type NotifyDeps } from \"./notify.ts\";\nimport { setupPublishing, type PublishService, type PublishWiring } from \"./publish-setup.ts\";\nimport {\n assignedReviewersOf,\n assignmentReason,\n type AssignedReviewer,\n} from \"./review-assignment.ts\";\nimport { announceReviewWith, REVIEW_EVENTS, reviewRequestedData } from \"./review-events.ts\";\nimport type { ServerRuntime } from \"./runtime.ts\";\nimport { isServerlessHost } from \"./serverless.ts\";\n\n/** What the rest of the app takes from the job wiring. */\nexport interface JobsWiring {\n /** Where captures queue; null without `testing`. */\n captureQueue: CaptureWiring[\"queue\"];\n enqueueCapture: CaptureWiring[\"enqueueCapture\"];\n /** Whether this instance runs the capture jobs (the retry and compute-jobs UI applies). */\n capturesHere: boolean;\n publish: PublishService;\n /** Asks for a build's current state to be shown on its git channels (ADR 0014). */\n buildState: BuildStatePublisher;\n /** Cancels every unfinished job of a build, of any kind (capture, publish, notify). */\n cancelBuildJobs: JobCanceller;\n /** Runs every handler this instance has; null when it consumes nothing. */\n consumer: CaptureConsumer | null;\n}\n\n/** Wire the queue, the handlers and the consumer for `options`. */\nexport function setupJobs(\n options: ShelfOptions,\n runtime: ServerRuntime,\n env: NodeJS.ProcessEnv = process.env,\n): JobsWiring {\n const { jobs, notifyJobs } = queuesFor(options, isServerlessHost(env));\n const buildState = setupBuildState(options, runtime, notifyJobs);\n const capture = setupCapture(\n options,\n runtime.config,\n {\n notifiers: runtime.notifiers,\n buildState: buildState.publisher,\n publicOrigin: async () => await runtime.publicOrigin.url(),\n },\n runtime.logger,\n jobs,\n );\n const publishing = setupPublishing(\n options,\n runtime.config,\n runtime.logger,\n capture.enqueueCapture !== undefined,\n {\n jobs,\n buildState: buildState.publisher,\n reviewRequested: reviewRequestedFor(options, runtime),\n },\n );\n const wiring = { capture, publishing, buildState, notifyJobs };\n return {\n captureQueue: capture.queue,\n enqueueCapture: capture.enqueueCapture,\n capturesHere: capture.handler !== null,\n publish: publishing,\n buildState: buildState.publisher,\n cancelBuildJobs: createJobCanceller([jobs.view]),\n consumer: combineConsumers(consumersOf(options, runtime, jobs, wiring)),\n };\n}\n\n/**\n * The database queue (captures with `testing`, publishing on a long-running host) and,\n * on a long-running host, a queue of its own for `notify` jobs: a status must not wait behind a\n * capture that holds every slot for minutes.\n */\nfunction queuesFor(\n options: ShelfOptions,\n serverless: boolean,\n): { jobs: JobQueue; notifyJobs: JobQueue | null } {\n const testing = options.testing;\n const databaseCaptures = testing?.runner !== undefined;\n const kinds: CaptureJobKind[] = [\n ...(databaseCaptures ? ([\"capture\"] as const) : []),\n ...(serverless ? [] : ([\"publish\"] as const)),\n ];\n return {\n jobs: createJobQueue(options, kinds, testing?.retries ?? DEFAULT_RETRIES),\n notifyJobs: serverless ? null : createJobQueue(options, [\"notify\"], NOTIFY_MAX_ATTEMPTS - 1),\n };\n}\n\n/** Every consumer this instance runs; none on a host that polls nothing. */\nfunction consumersOf(\n options: ShelfOptions,\n runtime: ServerRuntime,\n jobs: JobQueue,\n wiring: {\n capture: CaptureWiring;\n publishing: Pick<PublishWiring, \"handler\">;\n buildState: Pick<BuildStateWiring, \"handler\">;\n notifyJobs: JobQueue | null;\n },\n): CaptureConsumer[] {\n const { capture, publishing, buildState, notifyJobs } = wiring;\n return [\n ...(jobs.kinds.length > 0\n ? [databaseConsumer(options, runtime, jobs, { capture, publishing })]\n : []),\n ...(buildState.handler !== null && notifyJobs !== null\n ? [notifyConsumer(options, runtime, notifyJobs, buildState.handler)]\n : []),\n ];\n}\n\n/** The consumer of the database queue: capture and publish jobs. */\nfunction databaseConsumer(\n options: ShelfOptions,\n runtime: ServerRuntime,\n jobs: JobQueue,\n wiring: {\n capture: CaptureWiring;\n publishing: Pick<PublishWiring, \"handler\">;\n },\n): CaptureConsumer {\n const { capture, publishing } = wiring;\n const capturesHere = jobs.kinds.includes(\"capture\");\n return lazyConsumer(\n () => ({\n queue: jobs.get(),\n handlers: {\n ...(capturesHere && capture.handler ? { capture: capture.handler } : {}),\n ...(publishing.handler ? { publish: publishing.handler } : {}),\n },\n // Publishing alone is one job at a time, so a big zip never crowds out the web server.\n config: {\n concurrency: capturesHere ? options.testing?.concurrency : 1,\n maxRetries: options.testing?.retries ?? DEFAULT_RETRIES,\n },\n scratchDir: runtime.config.scratchDir ?? DEFAULT_SCRATCH_DIR,\n metrics: jobMetrics(),\n logger: runtime.logger.child({ component: \"job-consumer\" }),\n ...(options.testing?.queueClock === undefined\n ? {}\n : { now: options.testing.queueClock.now, timers: options.testing.queueClock.timers }),\n }),\n [\n async () => {\n if (capturesHere) {\n await capture.handler?.close?.();\n }\n await jobs.built()?.lifecycle.teardown();\n },\n ],\n );\n}\n\n/** Deliveries running at once: short HTTP calls to a git host, bounded to stay clear of its rate limits. */\nconst NOTIFY_CONCURRENCY = 4;\n\n/** The consumer of `notify` jobs, on its own queue so no capture or publish job can starve it. */\nfunction notifyConsumer(\n options: ShelfOptions,\n runtime: ServerRuntime,\n queue: JobQueue,\n handler: KindHandler,\n): CaptureConsumer {\n return lazyConsumer(\n () => ({\n queue: queue.get(),\n handlers: { notify: handler },\n config: { concurrency: NOTIFY_CONCURRENCY },\n metrics: jobMetrics(),\n logger: runtime.logger.child({ component: \"notify-consumer\" }),\n ...(options.testing?.queueClock === undefined\n ? {}\n : { now: options.testing.queueClock.now, timers: options.testing.queueClock.timers }),\n }),\n [\n async () => {\n await queue.built()?.lifecycle.teardown();\n },\n ],\n );\n}\n\n/**\n * The reviewers the component owners assign to a build (#465). Best-effort: a failed lookup logs and\n * leaves the request to the default recipients, so a broken rule never swallows a review request.\n */\nasync function assignedOrNone(\n deps: Pick<NotifyDeps, \"db\" | \"logger\">,\n project: Project,\n build: Build,\n): Promise<AssignedReviewer[]> {\n try {\n return await assignedReviewersOf(deps.db, project, build);\n } catch (error) {\n deps.logger.warn({ err: error, buildId: build.id }, \"component owners could not be resolved\");\n return [];\n }\n}\n\n/** Tells the project's reviewers a published build waits for a verdict (webhook and notifications). */\nfunction reviewRequestedFor(\n options: ShelfOptions,\n runtime: ServerRuntime,\n): (project: Project, build: Build) => Promise<void> {\n return async (project, build) => {\n const deps = {\n db: options.database,\n config: runtime.config,\n ui: runtime.ui,\n logger: runtime.logger,\n notifiers: runtime.notifiers,\n notificationsConfig: options.notifications?.config,\n publicOrigin: runtime.publicOrigin,\n };\n const assigned = await assignedOrNone(deps, project, build);\n await announceReviewWith(\n deps,\n project,\n REVIEW_EVENTS.requested,\n reviewRequestedData(build),\n build.id,\n { skipUsers: new Set(assigned.map((reviewer) => reviewer.userId)) },\n );\n await Promise.all(\n assigned.map(async (reviewer) => {\n const data = {\n ...reviewRequestedData(build),\n assignedBecause: assignmentReason(reviewer.components),\n };\n await notifyAssignedWith(deps, project, reviewer.userId, data, `/builds/${build.id}`);\n }),\n );\n };\n}\n"],"mappings":"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"}
@@ -0,0 +1,95 @@
1
+ import "node:path";
2
+ import "node:url";
3
+ import.meta.url;
4
+ import { getStore } from "./store.mjs";
5
+ import { resolveMentions } from "@storyshelf/core/utils";
6
+ import { MemberModel, UserModel } from "@storyshelf/core/models";
7
+ // src/mentions.ts
8
+ /** How a member is shown: the name they set, else the one their sign-in gave. */
9
+ function displayNameOf(user) {
10
+ return user.displayNameOverride ?? user.name;
11
+ }
12
+ /** The handles that name a member: the part of their email before the `@`, and their name. */
13
+ function handlesOf(user) {
14
+ return [user.email.split("@")[0] ?? "", displayNameOf(user)].filter((handle) => handle.trim() !== "");
15
+ }
16
+ /** The members of a project a comment can mention; disabled accounts are not offered. */
17
+ async function mentionCandidates(db, projectId) {
18
+ const members = await new MemberModel(db).list(projectId);
19
+ const users = new UserModel(db);
20
+ return (await Promise.all(members.map(async (member) => await users.get(member.userId)))).flatMap((user) => user === null || user.disabled === true ? [] : [{
21
+ candidate: {
22
+ userId: user.id,
23
+ handles: handlesOf(user)
24
+ },
25
+ user
26
+ }]);
27
+ }
28
+ /**
29
+ * The payload of `comment:mentioned`. It carries the same comment id as `comment:created`;
30
+ * `authorName` and `message` are the fields the verbose notification styles already print.
31
+ */
32
+ function mentionData(comment, scope, authorName) {
33
+ return {
34
+ eventId: comment.id,
35
+ scope,
36
+ buildId: comment.buildId,
37
+ commentId: comment.id,
38
+ snapshotId: comment.snapshotId,
39
+ storyId: comment.storyId,
40
+ authorName,
41
+ message: comment.body.slice(0, 500)
42
+ };
43
+ }
44
+ const LONGEST_FRAGMENT = 40;
45
+ /**
46
+ * The `@fragment` the text ends with: the last `@` that starts a word (not the one in an email
47
+ * address), followed by at most a short stretch of one line. The suggest form posts the whole
48
+ * text and no caret position, so the end of the text is where the member is typing.
49
+ */
50
+ function typedMention(text) {
51
+ const start = text.lastIndexOf("@");
52
+ if (start === -1) return;
53
+ const before = text[start - 1];
54
+ const fragment = text.slice(start + 1);
55
+ return (before === void 0 || !/[\p{L}\p{N}_]/u.test(before)) && fragment.length <= LONGEST_FRAGMENT && !fragment.includes("\n") ? {
56
+ start,
57
+ fragment
58
+ } : void 0;
59
+ }
60
+ /**
61
+ * The members whose handle or name starts with what was typed, each with a handle that resolves
62
+ * to them alone (a handle two members share would be plain text once posted, so it is never
63
+ * offered). The list is only ever built from the project's own members.
64
+ */
65
+ function suggestMembers(people, fragment) {
66
+ const typed = fragment.trim().toLowerCase();
67
+ const candidates = people.map((person) => person.candidate);
68
+ const offered = [];
69
+ for (const { candidate, user } of people.toSorted((a, b) => displayNameOf(a.user).localeCompare(displayNameOf(b.user)))) {
70
+ const handle = candidate.handles.find((option) => option.toLowerCase().startsWith(typed) && resolveMentions(`@${option} `, candidates).some((found) => found.userId === user.id));
71
+ if (handle !== void 0) offered.push({
72
+ userId: user.id,
73
+ handle,
74
+ name: displayNameOf(user)
75
+ });
76
+ }
77
+ return offered.slice(0, 5);
78
+ }
79
+ /** The text with what was being typed replaced by the chosen handle and a space after it. */
80
+ function completeMention(text, typed, handle) {
81
+ return `${text.slice(0, typed.start)}@${handle} `;
82
+ }
83
+ /**
84
+ * The names to show for the members a comment mentions: their current display names, so a rename
85
+ * is followed and the stored mention (an id) keeps rendering. A member who no longer exists has
86
+ * no entry; the caller falls back to the handle as typed.
87
+ */
88
+ async function mentionNamesOf(mentions) {
89
+ const users = new UserModel(getStore().db);
90
+ const found = await Promise.all(mentions.map(async (mention) => await users.get(mention.userId)));
91
+ return new Map(found.flatMap((user) => user === null ? [] : [[user.id, displayNameOf(user)]]));
92
+ }
93
+ export { completeMention, displayNameOf, handlesOf, mentionCandidates, mentionData, mentionNamesOf, suggestMembers, typedMention };
94
+
95
+ //# sourceMappingURL=mentions.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mentions.mjs","names":[],"sources":["../src/mentions.ts"],"sourcesContent":["/**\n * Comment mentions at the app layer (#463): who a comment can mention (the project's members),\n * how they are named, and what a `comment:mentioned` notification carries. The parsing itself is\n * pure and lives in core (`resolveMentions`).\n */\nimport type { DatabaseAdapter } from \"@storyshelf/core/adapter/database\";\nimport { MemberModel, UserModel } from \"@storyshelf/core/models\";\nimport type { Comment, CommentMention, User } from \"@storyshelf/core/schema\";\nimport { resolveMentions, type MentionCandidate } from \"@storyshelf/core/utils\";\nimport { getStore } from \"./store.ts\";\n\n/** How a member is shown: the name they set, else the one their sign-in gave. */\nexport function displayNameOf(user: Pick<User, \"name\" | \"displayNameOverride\">): string {\n return user.displayNameOverride ?? user.name;\n}\n\n/** The handles that name a member: the part of their email before the `@`, and their name. */\nexport function handlesOf(user: Pick<User, \"email\" | \"name\" | \"displayNameOverride\">): string[] {\n const local = user.email.split(\"@\")[0] ?? \"\";\n return [local, displayNameOf(user)].filter((handle) => handle.trim() !== \"\");\n}\n\n/** The members of a project a comment can mention; disabled accounts are not offered. */\nexport async function mentionCandidates(\n db: DatabaseAdapter,\n projectId: string,\n): Promise<{ candidate: MentionCandidate; user: User }[]> {\n const members = await new MemberModel(db).list(projectId);\n const users = new UserModel(db);\n const found = await Promise.all(members.map(async (member) => await users.get(member.userId)));\n return found.flatMap((user) =>\n user === null || user.disabled === true\n ? []\n : [{ candidate: { userId: user.id, handles: handlesOf(user) }, user }],\n );\n}\n\n/**\n * The payload of `comment:mentioned`. It carries the same comment id as `comment:created`;\n * `authorName` and `message` are the fields the verbose notification styles already print.\n */\nexport function mentionData(\n comment: Comment,\n scope: \"build\" | \"story\" | \"snapshot\",\n authorName: string,\n): Record<string, unknown> {\n return {\n eventId: comment.id,\n scope,\n buildId: comment.buildId,\n commentId: comment.id,\n snapshotId: comment.snapshotId,\n storyId: comment.storyId,\n authorName,\n message: comment.body.slice(0, 500),\n };\n}\n\n/** What the text ends with after a mention-starting `@`: where it starts, and what was typed. */\nexport interface TypedMention {\n /** Index of the `@`. */\n start: number;\n /** The text after it (may be empty, may hold spaces: display names do). */\n fragment: string;\n}\n\nconst LONGEST_FRAGMENT = 40;\n\n/**\n * The `@fragment` the text ends with: the last `@` that starts a word (not the one in an email\n * address), followed by at most a short stretch of one line. The suggest form posts the whole\n * text and no caret position, so the end of the text is where the member is typing.\n */\nexport function typedMention(text: string): TypedMention | undefined {\n const start = text.lastIndexOf(\"@\");\n if (start === -1) {\n return undefined;\n }\n const before = text[start - 1];\n const fragment = text.slice(start + 1);\n const startsWord = before === undefined || !/[\\p{L}\\p{N}_]/u.test(before);\n return startsWord && fragment.length <= LONGEST_FRAGMENT && !fragment.includes(\"\\n\")\n ? { start, fragment }\n : undefined;\n}\n\n/** A member offered while typing: who they are, and the handle that will resolve to them. */\nexport interface MentionSuggestion {\n userId: string;\n handle: string;\n name: string;\n}\n\n/** How many members one suggestion list offers. */\nexport const MAX_SUGGESTIONS = 5;\n\n/**\n * The members whose handle or name starts with what was typed, each with a handle that resolves\n * to them alone (a handle two members share would be plain text once posted, so it is never\n * offered). The list is only ever built from the project's own members.\n */\nexport function suggestMembers(\n people: readonly { candidate: MentionCandidate; user: User }[],\n fragment: string,\n): MentionSuggestion[] {\n const typed = fragment.trim().toLowerCase();\n const candidates = people.map((person) => person.candidate);\n const offered: MentionSuggestion[] = [];\n for (const { candidate, user } of people.toSorted((a, b) =>\n displayNameOf(a.user).localeCompare(displayNameOf(b.user)),\n )) {\n const handle = candidate.handles.find(\n (option) =>\n option.toLowerCase().startsWith(typed) &&\n resolveMentions(`@${option} `, candidates).some((found) => found.userId === user.id),\n );\n if (handle !== undefined) {\n offered.push({ userId: user.id, handle, name: displayNameOf(user) });\n }\n }\n return offered.slice(0, MAX_SUGGESTIONS);\n}\n\n/** The text with what was being typed replaced by the chosen handle and a space after it. */\nexport function completeMention(text: string, typed: TypedMention, handle: string): string {\n return `${text.slice(0, typed.start)}@${handle} `;\n}\n\n/**\n * The names to show for the members a comment mentions: their current display names, so a rename\n * is followed and the stored mention (an id) keeps rendering. A member who no longer exists has\n * no entry; the caller falls back to the handle as typed.\n */\nexport async function mentionNamesOf(\n mentions: readonly CommentMention[],\n): Promise<Map<string, string>> {\n const users = new UserModel(getStore().db);\n const found = await Promise.all(mentions.map(async (mention) => await users.get(mention.userId)));\n return new Map(found.flatMap((user) => (user === null ? [] : [[user.id, displayNameOf(user)]])));\n}\n"],"mappings":";;;;;;;;AAYA,SAAgB,cAAc,MAA0D;CACtF,OAAO,KAAK,uBAAuB,KAAK;AAC1C;;AAGA,SAAgB,UAAU,MAAsE;CAE9F,OAAO,CADO,KAAK,MAAM,MAAM,GAAG,CAAC,CAAC,MAAM,IAC3B,cAAc,IAAI,CAAC,CAAC,CAAC,QAAQ,WAAW,OAAO,KAAK,MAAM,EAAE;AAC7E;;AAGA,eAAsB,kBACpB,IACA,WACwD;CACxD,MAAM,UAAU,MAAM,IAAI,YAAY,EAAE,CAAC,CAAC,KAAK,SAAS;CACxD,MAAM,QAAQ,IAAI,UAAU,EAAE;CAE9B,QAAO,MADa,QAAQ,IAAI,QAAQ,IAAI,OAAO,WAAW,MAAM,MAAM,IAAI,OAAO,MAAM,CAAC,CAAC,EAAA,CAChF,SAAS,SACpB,SAAS,QAAQ,KAAK,aAAa,OAC/B,CAAC,IACD,CAAC;EAAE,WAAW;GAAE,QAAQ,KAAK;GAAI,SAAS,UAAU,IAAI;EAAE;EAAG;CAAK,CAAC,CACzE;AACF;;;;;AAMA,SAAgB,YACd,SACA,OACA,YACyB;CACzB,OAAO;EACL,SAAS,QAAQ;EACjB;EACA,SAAS,QAAQ;EACjB,WAAW,QAAQ;EACnB,YAAY,QAAQ;EACpB,SAAS,QAAQ;EACjB;EACA,SAAS,QAAQ,KAAK,MAAM,GAAG,GAAG;CACpC;AACF;AAUA,MAAM,mBAAmB;;;;;;AAOzB,SAAgB,aAAa,MAAwC;CACnE,MAAM,QAAQ,KAAK,YAAY,GAAG;CAClC,IAAI,UAAU,IACZ;CAEF,MAAM,SAAS,KAAK,QAAQ;CAC5B,MAAM,WAAW,KAAK,MAAM,QAAQ,CAAC;CAErC,QADmB,WAAW,KAAA,KAAa,CAAC,iBAAiB,KAAK,MAAM,MACnD,SAAS,UAAU,oBAAoB,CAAC,SAAS,SAAS,IAAI,IAC/E;EAAE;EAAO;CAAS,IAClB,KAAA;AACN;;;;;;AAiBA,SAAgB,eACd,QACA,UACqB;CACrB,MAAM,QAAQ,SAAS,KAAK,CAAC,CAAC,YAAY;CAC1C,MAAM,aAAa,OAAO,KAAK,WAAW,OAAO,SAAS;CAC1D,MAAM,UAA+B,CAAC;CACtC,KAAK,MAAM,EAAE,WAAW,UAAU,OAAO,UAAU,GAAG,MACpD,cAAc,EAAE,IAAI,CAAC,CAAC,cAAc,cAAc,EAAE,IAAI,CAAC,CAC3D,GAAG;EACD,MAAM,SAAS,UAAU,QAAQ,MAC9B,WACC,OAAO,YAAY,CAAC,CAAC,WAAW,KAAK,KACrC,gBAAgB,IAAI,OAAO,IAAI,UAAU,CAAC,CAAC,MAAM,UAAU,MAAM,WAAW,KAAK,EAAE,CACvF;EACA,IAAI,WAAW,KAAA,GACb,QAAQ,KAAK;GAAE,QAAQ,KAAK;GAAI;GAAQ,MAAM,cAAc,IAAI;EAAE,CAAC;CAEvE;CACA,OAAO,QAAQ,MAAM,GAAA,CAAkB;AACzC;;AAGA,SAAgB,gBAAgB,MAAc,OAAqB,QAAwB;CACzF,OAAO,GAAG,KAAK,MAAM,GAAG,MAAM,KAAK,EAAE,GAAG,OAAO;AACjD;;;;;;AAOA,eAAsB,eACpB,UAC8B;CAC9B,MAAM,QAAQ,IAAI,UAAU,SAAS,CAAC,CAAC,EAAE;CACzC,MAAM,QAAQ,MAAM,QAAQ,IAAI,SAAS,IAAI,OAAO,YAAY,MAAM,MAAM,IAAI,QAAQ,MAAM,CAAC,CAAC;CAChG,OAAO,IAAI,IAAI,MAAM,SAAS,SAAU,SAAS,OAAO,CAAC,IAAI,CAAC,CAAC,KAAK,IAAI,cAAc,IAAI,CAAC,CAAC,CAAE,CAAC;AACjG"}
package/dist/notify.mjs CHANGED
@@ -4,7 +4,7 @@ import.meta.url;
4
4
  import { credentialKeys } from "./credential-keys.mjs";
5
5
  import { getStore } from "./store.mjs";
6
6
  import { clearPaused, notificationOrigin, warnPaused } from "./notifications-pause.mjs";
7
- import { matchesEvent } from "@storyshelf/core/utils";
7
+ import { COMMENT_MENTIONED, matchesEvent } from "@storyshelf/core/utils";
8
8
  import { MemberModel, NotificationChannelModel, NotificationSubscriptionModel, UserModel } from "@storyshelf/core/models";
9
9
  import { emitNotifications, emitSystemNotification } from "@storyshelf/core/adapter/notifier";
10
10
  // src/notify.ts
@@ -55,20 +55,46 @@ function recordOutcomes(deps) {
55
55
  });
56
56
  };
57
57
  }
58
- /** Subscriptions matching an event (enabled + allowlist, alias included). */
58
+ /** The one default-on topic, which a subscription can switch off with `reviewRequests`. */
59
+ const REVIEW_REQUESTED = "review:requested";
60
+ /** Subscriptions matching an event (enabled + allowlist, alias included, review requests on). */
59
61
  function matchingSubs(rows, event, data) {
60
- return rows.filter((row) => row.enabled && matchesEvent(NotificationSubscriptionModel.eventsOf(row), event, data));
62
+ return rows.filter((row) => row.enabled && (event !== REVIEW_REQUESTED || row.reviewRequests) && matchesEvent(NotificationSubscriptionModel.eventsOf(row), event, data));
61
63
  }
62
64
  /** Stored channels that admit the event; the core fan-out then needs no filter of its own. */
63
65
  function admittedChannels(channels, event, data) {
64
66
  return channels.filter((channel) => matchesEvent(channel.events, event, data)).map((channel) => Object.assign({}, channel, { events: [] }));
65
67
  }
66
- /** Per-subscriber targets for one subscription (wired kinds only). */
67
- async function targetsForSub(db, sub, wired) {
68
+ /** Subscription kinds already logged as skipped, so each is reported once per process. */
69
+ const reportedSkips = /* @__PURE__ */ new Set();
70
+ /** The kinds of a fan-out, with the once-per-subscription-and-kind warning for room kinds. */
71
+ function deliveryKindsOf(deps) {
72
+ return {
73
+ wired: new Set(deps.notifiers.map((candidate) => candidate.metadata.kind)),
74
+ personal: new Set(deps.notifiers.filter((candidate) => candidate.metadata.audience === "person").map((candidate) => candidate.metadata.kind)),
75
+ skipped: (subscriptionId, kind) => {
76
+ const key = `${subscriptionId}:${kind}`;
77
+ if (!reportedSkips.has(key)) {
78
+ reportedSkips.add(key);
79
+ deps.logger.warn({
80
+ subscriptionId,
81
+ kind
82
+ }, "subscription skips a delivery kind that posts to a room; use a project channel");
83
+ }
84
+ }
85
+ };
86
+ }
87
+ /**
88
+ * Per-subscriber targets for one subscription: its personal kinds only. A kind that posts to a
89
+ * room (or is not wired) is skipped, the former with one warning, and the rest still deliver.
90
+ */
91
+ async function targetsForSub(db, sub, kinds) {
68
92
  const user = await new UserModel(db).get(sub.userId);
69
93
  if (!user || user.disabled) return [];
70
- const kinds = NotificationSubscriptionModel.viaOf(sub);
71
- return (kinds.length > 0 ? kinds : ["email"]).filter((kind) => wired.has(kind)).map((kind) => ({
94
+ const via = NotificationSubscriptionModel.viaOf(sub);
95
+ const wired = (via.length > 0 ? via : ["email"]).filter((kind) => kinds.wired.has(kind));
96
+ for (const kind of wired.filter((entry) => !kinds.personal.has(entry))) kinds.skipped(sub.id, kind);
97
+ return wired.filter((kind) => kinds.personal.has(kind)).map((kind) => ({
72
98
  id: `sub:${sub.id}:${kind}`,
73
99
  projectId: sub.projectId,
74
100
  provider: kind,
@@ -81,69 +107,130 @@ async function targetsForSub(db, sub, wired) {
81
107
  * Topics that reach a project's approvers and admins unless they opted out (ADR 0013, decision 4).
82
108
  * Every other topic (`design:changed` included) reaches only people who subscribed to it.
83
109
  */
84
- const DEFAULT_ON_EVENTS = /* @__PURE__ */ new Set(["review:requested"]);
110
+ const DEFAULT_ON_EVENTS = /* @__PURE__ */ new Set([REVIEW_REQUESTED]);
85
111
  /** Roles that review builds: the default recipients of {@link DEFAULT_ON_EVENTS}. */
86
112
  const REVIEWER_ROLES = /* @__PURE__ */ new Set(["approver", "admin"]);
87
113
  /**
88
114
  * Reviewers with no subscription row for the project: a default-on topic reaches them by email.
89
- * Anyone with a row decides for themselves (`enabled` off or a topic list without the event opts
90
- * out), so a row never adds a second message here.
115
+ * Anyone with a row decides for themselves (`enabled` off, `reviewRequests` off or a topic list
116
+ * without the event opts out), so a row never adds a second message here.
91
117
  */
92
- async function defaultTargets(db, projectId, rows, wired) {
93
- if (!wired.has("email")) return [];
118
+ async function defaultTargets(db, projectId, rows, kinds, skipUsers) {
119
+ if (!kinds.personal.has("email")) return [];
94
120
  const subscribed = new Set(rows.map((row) => row.userId));
95
- const members = (await new MemberModel(db).list(projectId)).filter((member) => REVIEWER_ROLES.has(member.role) && !subscribed.has(member.userId));
96
- return (await Promise.all(members.map(async (member) => {
97
- const user = await new UserModel(db).get(member.userId);
98
- return !user || user.disabled ? [] : [{
99
- id: `default:${projectId}:${member.userId}`,
100
- projectId,
101
- provider: "email",
102
- config: { to: user.email },
103
- events: [],
104
- enabled: true
105
- }];
106
- }))).flat();
121
+ const members = (await new MemberModel(db).list(projectId)).filter((member) => REVIEWER_ROLES.has(member.role) && !subscribed.has(member.userId) && !skipUsers.has(member.userId));
122
+ return (await Promise.all(members.map(async (member) => await emailTarget(db, projectId, member.userId)))).flat();
123
+ }
124
+ /** The default email target of one user (none for a missing or disabled account). */
125
+ async function emailTarget(db, projectId, userId) {
126
+ const user = await new UserModel(db).get(userId);
127
+ return !user || user.disabled ? [] : [{
128
+ id: `default:${projectId}:${userId}`,
129
+ projectId,
130
+ provider: "email",
131
+ config: { to: user.email },
132
+ events: [],
133
+ enabled: true
134
+ }];
107
135
  }
108
136
  /** Per-subscriber targets for an event (only kinds that are wired). */
109
- async function subscriberTargets(db, projectId, event, data, wired) {
110
- const rows = await new NotificationSubscriptionModel(db).list(projectId);
111
- const nested = await Promise.all(matchingSubs(rows, event, data).map(async (sub) => await targetsForSub(db, sub, wired)));
112
- const defaults = DEFAULT_ON_EVENTS.has(event) ? await defaultTargets(db, projectId, rows, wired) : [];
137
+ async function subscriberTargets(db, projectId, event, data, kinds, skipUsers) {
138
+ const rows = (await new NotificationSubscriptionModel(db).list(projectId)).filter((row) => !skipUsers.has(row.userId));
139
+ const nested = await Promise.all(matchingSubs(rows, event, data).map(async (sub) => await targetsForSub(db, sub, kinds)));
140
+ const defaults = DEFAULT_ON_EVENTS.has(event) ? await defaultTargets(db, projectId, rows, kinds, skipUsers) : [];
113
141
  return [...nested.flat(), ...defaults];
114
142
  }
143
+ /** Send one event to the given channels with the brand and a review link under `origin`. */
144
+ async function deliver(deps, project, message, channels) {
145
+ await emitNotifications({
146
+ event: message.event,
147
+ projectId: project.id,
148
+ projectSlug: project.slug,
149
+ data: message.data,
150
+ timestamp: now()
151
+ }, {
152
+ notifiers: deps.notifiers,
153
+ channels,
154
+ brand: brandFor(deps.ui, deps.notificationsConfig, message.reviewUrl),
155
+ logger: deps.logger,
156
+ onOutcome: recordOutcomes(deps)
157
+ });
158
+ }
115
159
  /**
116
160
  * Fan out a project event to stored channels plus opted-in subscribers.
117
161
  * Runs alongside (never instead of) webhook delivery.
118
162
  */
119
- async function notifyProjectWith(deps, project, event, data, reviewSuffix) {
163
+ async function notifyProjectWith(deps, project, event, data, reviewSuffix, options = {}) {
120
164
  if (deps.notifiers.length === 0) return;
121
165
  const origin = await originOrPause(deps, event);
122
166
  if (origin === void 0) return;
123
167
  const reviewUrl = reviewSuffix ? reviewUrlFor(origin, project.slug, reviewSuffix) : void 0;
124
- const wired = new Set(deps.notifiers.map((candidate) => candidate.metadata.kind));
168
+ const kinds = deliveryKindsOf(deps);
125
169
  const channels = admittedChannels(await loadChannels(deps, project.id), event, data);
126
- const targets = await subscriberTargets(deps.db, project.id, event, data, wired);
127
- await emitNotifications({
170
+ const targets = await subscriberTargets(deps.db, project.id, event, data, kinds, options.skipUsers ?? /* @__PURE__ */ new Set());
171
+ await deliver(deps, project, {
128
172
  event,
129
- projectId: project.id,
130
- projectSlug: project.slug,
131
173
  data,
132
- timestamp: now()
133
- }, {
134
- notifiers: deps.notifiers,
135
- channels: [...channels, ...targets],
136
- brand: brandFor(deps.ui, deps.notificationsConfig, reviewUrl),
137
- logger: deps.logger,
138
- onOutcome: recordOutcomes(deps)
139
- });
174
+ reviewUrl
175
+ }, [...channels, ...targets]);
176
+ }
177
+ /**
178
+ * Where a mention of `userId` is delivered (#463): their own subscription's channels when they
179
+ * have one and it is enabled (a disabled one opts them out), else a default email. Never a stored
180
+ * project channel: a mention is addressed to one person and must not reach a room.
181
+ */
182
+ async function mentionTargets(db, projectId, userId, kinds) {
183
+ const own = (await new NotificationSubscriptionModel(db).list(projectId)).find((row) => row.userId === userId);
184
+ if (own !== void 0 && !NotificationSubscriptionModel.isReviewOptOutOnly(own)) return own.enabled ? await targetsForSub(db, own, kinds) : [];
185
+ return kinds.personal.has("email") ? await emailTarget(db, projectId, userId) : [];
186
+ }
187
+ /**
188
+ * Where a review request is delivered to a member assigned as a component owner (#465): their own
189
+ * subscription's channels when it is enabled, else a default email. Being assigned overrides an
190
+ * opt-out (a disabled subscription, or a topic list without `review:requested`), because someone
191
+ * asked for this person by name. Never a stored project channel: the request is for one person.
192
+ */
193
+ async function assignedTargets(db, projectId, userId, kinds) {
194
+ const own = (await new NotificationSubscriptionModel(db).list(projectId)).find((row) => row.userId === userId);
195
+ const chosen = own?.enabled ? await targetsForSub(db, own, kinds) : [];
196
+ if (chosen.length > 0) return chosen;
197
+ return kinds.personal.has("email") ? await emailTarget(db, projectId, userId) : [];
198
+ }
199
+ /**
200
+ * Send a review request to one member assigned from the component owners. The regular fan-out
201
+ * skips them (see `NotifyOptions.skipUsers`), so they get exactly one message per cycle.
202
+ */
203
+ async function notifyAssignedWith(deps, project, userId, data, reviewSuffix) {
204
+ if (deps.notifiers.length === 0) return;
205
+ const event = "review:requested";
206
+ const origin = await originOrPause(deps, event);
207
+ if (origin === void 0) return;
208
+ const targets = await assignedTargets(deps.db, project.id, userId, deliveryKindsOf(deps));
209
+ await deliver(deps, project, {
210
+ event,
211
+ data,
212
+ reviewUrl: reviewUrlFor(origin, project.slug, reviewSuffix)
213
+ }, targets);
214
+ }
215
+ /** Notify one project member that a comment mentions them. Best-effort, like every fan-out. */
216
+ async function notifyMentionWith(deps, project, userId, data, reviewSuffix) {
217
+ if (deps.notifiers.length === 0) return;
218
+ const origin = await originOrPause(deps, COMMENT_MENTIONED);
219
+ if (origin === void 0) return;
220
+ const targets = await mentionTargets(deps.db, project.id, userId, deliveryKindsOf(deps));
221
+ const reviewUrl = reviewUrlFor(origin, project.slug, reviewSuffix);
222
+ await deliver(deps, project, {
223
+ event: COMMENT_MENTIONED,
224
+ data,
225
+ reviewUrl
226
+ }, targets);
140
227
  }
141
228
  /**
142
229
  * Fan out a project event to stored channels plus opted-in subscribers.
143
230
  * Runs alongside (never instead of) webhook delivery.
144
231
  */
145
- async function notifyProject(project, event, data, reviewSuffix) {
146
- await notifyProjectWith(getStore(), project, event, data, reviewSuffix);
232
+ async function notifyProject(project, event, data, reviewSuffix, options) {
233
+ await notifyProjectWith(getStore(), project, event, data, reviewSuffix, options);
147
234
  }
148
235
  /** Fan out a site-wide admin alert to project-less channels. */
149
236
  async function notifySystemWith(deps, event, data) {
@@ -178,6 +265,6 @@ function createAuthSystemHook() {
178
265
  function now() {
179
266
  return (/* @__PURE__ */ new Date()).toISOString();
180
267
  }
181
- export { createAuthSystemHook, notifyProject, notifyProjectWith, notifySystem, notifySystemWith };
268
+ export { createAuthSystemHook, notifyAssignedWith, notifyMentionWith, notifyProject, notifyProjectWith, notifySystem, notifySystemWith };
182
269
 
183
270
  //# sourceMappingURL=notify.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"notify.mjs","names":[],"sources":["../src/notify.ts"],"sourcesContent":["/**\n * Notification fan-out for routers: loads stored channels and subscriber\n * emails, then delegates to the wired notifier providers. Best-effort and\n * non-fatal — failures are logged inside the emitters, never thrown here.\n */\nimport type { DatabaseAdapter } from \"@storyshelf/core/adapter/database\";\nimport type { NotifierProvider } from \"@storyshelf/core/adapter/notifier\";\nimport {\n emitNotifications,\n emitSystemNotification,\n type ChannelOutcome,\n type NotificationBrand,\n type NotificationChannel,\n type SystemEventName,\n} from \"@storyshelf/core/adapter/notifier\";\nimport type { NotificationsConfig, ShelfConfig, UIConfig } from \"@storyshelf/core/config\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport {\n MemberModel,\n NotificationChannelModel,\n NotificationSubscriptionModel,\n UserModel,\n type PublicOriginResolver,\n} from \"@storyshelf/core/models\";\nimport type { NotificationSubscriptionRow } from \"@storyshelf/core/schema\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport { matchesEvent } from \"@storyshelf/core/utils\";\nimport { credentialKeys } from \"./credential-keys.ts\";\nimport { clearPaused, notificationOrigin, warnPaused } from \"./notifications-pause.ts\";\nimport { getStore } from \"./store.ts\";\n\n/** Explicit dependencies for background fan-out (no request scope). */\nexport interface NotifyDeps {\n db: DatabaseAdapter;\n config: ShelfConfig;\n ui: UIConfig;\n logger: Logger;\n notifiers: NotifierProvider[];\n /** Brand/sender settings (`notifications.config`). */\n notificationsConfig?: NotificationsConfig;\n /** The public origin resolver; absent ⇒ `config.publicBaseUrl` only. */\n publicOrigin?: Pick<PublicOriginResolver, \"url\"> | undefined;\n}\n\n/** Absolute review URL for a project page under the resolved public origin. */\nfunction reviewUrlFor(origin: string, slug: string, suffix: string): string {\n return `${origin}/projects/${slug}${suffix}`;\n}\n\n/**\n * The origin notifications link to; `undefined` (after one log line per pause) while none is\n * resolved, which pauses every notification (ADR 0014, decision 11).\n */\nasync function originOrPause(\n deps: Pick<NotifyDeps, \"config\" | \"publicOrigin\" | \"logger\">,\n event: string,\n): Promise<string | undefined> {\n const origin = await notificationOrigin(deps);\n if (origin === undefined) {\n warnPaused(deps.logger, event);\n } else {\n clearPaused(deps.logger);\n }\n return origin;\n}\n\n/** Brand inputs from UI config plus the notification sender settings. */\nfunction brandFor(\n ui: UIConfig,\n settings: NotificationsConfig | undefined,\n reviewUrl?: string,\n): NotificationBrand {\n return {\n name: ui.name,\n logo: ui.logo,\n reviewUrl,\n fromEmail: settings?.fromEmail,\n fromName: settings?.fromName ?? ui.name,\n footerText: settings?.footerText,\n };\n}\n\n/** Load stored channels as send-ready views (skips undecryptable secrets). */\nasync function loadChannels(\n deps: Pick<NotifyDeps, \"db\" | \"config\">,\n projectId: string | null,\n): Promise<NotificationChannel[]> {\n const model = channelModel(deps);\n const rows = projectId === null ? await model.listSystem() : await model.list(projectId);\n return rows.filter((row) => row.enabled).flatMap((row) => model.toView(row) ?? []);\n}\n\n/** The channel model bound to the server secret. */\nfunction channelModel(deps: Pick<NotifyDeps, \"db\" | \"config\">): NotificationChannelModel {\n return new NotificationChannelModel(deps.db, undefined, credentialKeys(deps.config));\n}\n\n/** Record each delivery outcome on its channel row (the last error shown in settings). */\nfunction recordOutcomes(\n deps: Pick<NotifyDeps, \"db\" | \"config\" | \"logger\">,\n): (outcome: ChannelOutcome) => Promise<void> {\n const model = channelModel(deps);\n return async (outcome) => {\n if (outcome.channelId.startsWith(\"sub:\") || outcome.channelId.startsWith(\"default:\")) {\n return;\n }\n await model.recordOutcome(outcome.channelId, outcome.error).catch((error: unknown) => {\n deps.logger.warn({ err: error, channelId: outcome.channelId }, \"failed to record outcome\");\n });\n };\n}\n\n/** Subscriptions matching an event (enabled + allowlist, alias included). */\nfunction matchingSubs(\n rows: NotificationSubscriptionRow[],\n event: string,\n data: Record<string, unknown>,\n): NotificationSubscriptionRow[] {\n return rows.filter(\n (row) => row.enabled && matchesEvent(NotificationSubscriptionModel.eventsOf(row), event, data),\n );\n}\n\n/** Stored channels that admit the event; the core fan-out then needs no filter of its own. */\nfunction admittedChannels(\n channels: NotificationChannel[],\n event: string,\n data: Record<string, unknown>,\n): NotificationChannel[] {\n return channels\n .filter((channel) => matchesEvent(channel.events, event, data))\n .map((channel) => Object.assign({}, channel, { events: [] }));\n}\n\n/** Per-subscriber targets for one subscription (wired kinds only). */\nasync function targetsForSub(\n db: DatabaseAdapter,\n sub: NotificationSubscriptionRow,\n wired: ReadonlySet<string>,\n): Promise<NotificationChannel[]> {\n const user = await new UserModel(db).get(sub.userId);\n if (!user || user.disabled) {\n return [];\n }\n const kinds = NotificationSubscriptionModel.viaOf(sub);\n const wants = kinds.length > 0 ? kinds : [\"email\"];\n return wants\n .filter((kind) => wired.has(kind))\n .map((kind) => ({\n id: `sub:${sub.id}:${kind}`,\n projectId: sub.projectId,\n provider: kind,\n config: { to: user.email },\n events: [],\n enabled: true,\n }));\n}\n\n/**\n * Topics that reach a project's approvers and admins unless they opted out (ADR 0013, decision 4).\n * Every other topic (`design:changed` included) reaches only people who subscribed to it.\n */\nconst DEFAULT_ON_EVENTS: ReadonlySet<string> = new Set([\"review:requested\"]);\n\n/** Roles that review builds: the default recipients of {@link DEFAULT_ON_EVENTS}. */\nconst REVIEWER_ROLES: ReadonlySet<string> = new Set([\"approver\", \"admin\"]);\n\n/**\n * Reviewers with no subscription row for the project: a default-on topic reaches them by email.\n * Anyone with a row decides for themselves (`enabled` off or a topic list without the event opts\n * out), so a row never adds a second message here.\n */\nasync function defaultTargets(\n db: DatabaseAdapter,\n projectId: string,\n rows: NotificationSubscriptionRow[],\n wired: ReadonlySet<string>,\n): Promise<NotificationChannel[]> {\n if (!wired.has(\"email\")) {\n return [];\n }\n const subscribed = new Set(rows.map((row) => row.userId));\n const members = (await new MemberModel(db).list(projectId)).filter(\n (member) => REVIEWER_ROLES.has(member.role) && !subscribed.has(member.userId),\n );\n const targets = await Promise.all(\n members.map(async (member): Promise<NotificationChannel[]> => {\n const user = await new UserModel(db).get(member.userId);\n return !user || user.disabled\n ? []\n : [\n {\n id: `default:${projectId}:${member.userId}`,\n projectId,\n provider: \"email\",\n config: { to: user.email },\n events: [],\n enabled: true,\n },\n ];\n }),\n );\n return targets.flat();\n}\n\n/** Per-subscriber targets for an event (only kinds that are wired). */\nasync function subscriberTargets(\n db: DatabaseAdapter,\n projectId: string,\n event: string,\n data: Record<string, unknown>,\n wired: ReadonlySet<string>,\n): Promise<NotificationChannel[]> {\n const rows = await new NotificationSubscriptionModel(db).list(projectId);\n const nested = await Promise.all(\n matchingSubs(rows, event, data).map(async (sub) => await targetsForSub(db, sub, wired)),\n );\n const defaults = DEFAULT_ON_EVENTS.has(event)\n ? await defaultTargets(db, projectId, rows, wired)\n : [];\n return [...nested.flat(), ...defaults];\n}\n\n/**\n * Fan out a project event to stored channels plus opted-in subscribers.\n * Runs alongside (never instead of) webhook delivery.\n */\nexport async function notifyProjectWith(\n deps: NotifyDeps,\n project: Pick<Project, \"id\" | \"slug\">,\n event: string,\n data: Record<string, unknown>,\n reviewSuffix?: string,\n): Promise<void> {\n if (deps.notifiers.length === 0) {\n return;\n }\n const origin = await originOrPause(deps, event);\n if (origin === undefined) {\n return;\n }\n const reviewUrl = reviewSuffix ? reviewUrlFor(origin, project.slug, reviewSuffix) : undefined;\n const wired = new Set(deps.notifiers.map((candidate) => candidate.metadata.kind));\n const channels = admittedChannels(await loadChannels(deps, project.id), event, data);\n const targets = await subscriberTargets(deps.db, project.id, event, data, wired);\n await emitNotifications(\n { event, projectId: project.id, projectSlug: project.slug, data, timestamp: now() },\n {\n notifiers: deps.notifiers,\n channels: [...channels, ...targets],\n brand: brandFor(deps.ui, deps.notificationsConfig, reviewUrl),\n logger: deps.logger,\n onOutcome: recordOutcomes(deps),\n },\n );\n}\n\n/**\n * Fan out a project event to stored channels plus opted-in subscribers.\n * Runs alongside (never instead of) webhook delivery.\n */\nexport async function notifyProject(\n project: Pick<Project, \"id\" | \"slug\">,\n event: string,\n data: Record<string, unknown>,\n reviewSuffix?: string,\n): Promise<void> {\n await notifyProjectWith(getStore(), project, event, data, reviewSuffix);\n}\n\n/** Fan out a site-wide admin alert to project-less channels. */\nexport async function notifySystemWith(\n deps: NotifyDeps,\n event: SystemEventName,\n data: Record<string, unknown>,\n): Promise<void> {\n if (deps.notifiers.length === 0 || (await originOrPause(deps, event)) === undefined) {\n return;\n }\n await emitSystemNotification(\n { event, data, timestamp: now() },\n {\n notifiers: deps.notifiers,\n channels: await loadChannels(deps, null),\n brand: brandFor(deps.ui, deps.notificationsConfig),\n logger: deps.logger,\n onOutcome: recordOutcomes(deps),\n },\n );\n}\n\n/** Fan out a site-wide admin alert to project-less channels. */\nexport async function notifySystem(\n event: SystemEventName,\n data: Record<string, unknown>,\n): Promise<void> {\n await notifySystemWith(getStore(), event, data);\n}\n\n/**\n * Host hook for the auth engine (`onAuthSystemEvent`): fans `sys:*` auth\n * events out to admin channels. Safe to call anywhere — outside a request\n * scope (e.g. boot) it resolves to a no-op instead of throwing.\n */\nexport function createAuthSystemHook(): (\n event: \"sys:user-created\" | \"sys:invite-issued\",\n data: Record<string, unknown>,\n) => Promise<void> {\n return async (event, data): Promise<void> => {\n await notifySystem(event, data).catch(() => {\n // Intentionally empty — best-effort fan-out must never break auth\n });\n };\n}\n\n/** Current timestamp for notification envelopes. */\nfunction now(): string {\n return new Date().toISOString();\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"notify.mjs","names":[],"sources":["../src/notify.ts"],"sourcesContent":["/**\n * Notification fan-out for routers: loads stored channels and subscriber\n * emails, then delegates to the wired notifier providers. Best-effort and\n * non-fatal — failures are logged inside the emitters, never thrown here.\n */\nimport type { DatabaseAdapter } from \"@storyshelf/core/adapter/database\";\nimport type { NotifierProvider } from \"@storyshelf/core/adapter/notifier\";\nimport {\n emitNotifications,\n emitSystemNotification,\n type ChannelOutcome,\n type NotificationBrand,\n type NotificationChannel,\n type SystemEventName,\n} from \"@storyshelf/core/adapter/notifier\";\nimport type { NotificationsConfig, ShelfConfig, UIConfig } from \"@storyshelf/core/config\";\nimport type { Logger } from \"@storyshelf/core/logger\";\nimport {\n MemberModel,\n NotificationChannelModel,\n NotificationSubscriptionModel,\n UserModel,\n type PublicOriginResolver,\n} from \"@storyshelf/core/models\";\nimport type { NotificationSubscriptionRow } from \"@storyshelf/core/schema\";\nimport type { Project } from \"@storyshelf/core/schema\";\nimport { COMMENT_MENTIONED, matchesEvent } from \"@storyshelf/core/utils\";\nimport { credentialKeys } from \"./credential-keys.ts\";\nimport { clearPaused, notificationOrigin, warnPaused } from \"./notifications-pause.ts\";\nimport { getStore } from \"./store.ts\";\n\n/** Explicit dependencies for background fan-out (no request scope). */\nexport interface NotifyDeps {\n db: DatabaseAdapter;\n config: ShelfConfig;\n ui: UIConfig;\n logger: Logger;\n notifiers: NotifierProvider[];\n /** Brand/sender settings (`notifications.config`). */\n notificationsConfig?: NotificationsConfig;\n /** The public origin resolver; absent ⇒ `config.publicBaseUrl` only. */\n publicOrigin?: Pick<PublicOriginResolver, \"url\"> | undefined;\n}\n\n/** Absolute review URL for a project page under the resolved public origin. */\nfunction reviewUrlFor(origin: string, slug: string, suffix: string): string {\n return `${origin}/projects/${slug}${suffix}`;\n}\n\n/**\n * The origin notifications link to; `undefined` (after one log line per pause) while none is\n * resolved, which pauses every notification (ADR 0014, decision 11).\n */\nasync function originOrPause(\n deps: Pick<NotifyDeps, \"config\" | \"publicOrigin\" | \"logger\">,\n event: string,\n): Promise<string | undefined> {\n const origin = await notificationOrigin(deps);\n if (origin === undefined) {\n warnPaused(deps.logger, event);\n } else {\n clearPaused(deps.logger);\n }\n return origin;\n}\n\n/** Brand inputs from UI config plus the notification sender settings. */\nfunction brandFor(\n ui: UIConfig,\n settings: NotificationsConfig | undefined,\n reviewUrl?: string,\n): NotificationBrand {\n return {\n name: ui.name,\n logo: ui.logo,\n reviewUrl,\n fromEmail: settings?.fromEmail,\n fromName: settings?.fromName ?? ui.name,\n footerText: settings?.footerText,\n };\n}\n\n/** Load stored channels as send-ready views (skips undecryptable secrets). */\nasync function loadChannels(\n deps: Pick<NotifyDeps, \"db\" | \"config\">,\n projectId: string | null,\n): Promise<NotificationChannel[]> {\n const model = channelModel(deps);\n const rows = projectId === null ? await model.listSystem() : await model.list(projectId);\n return rows.filter((row) => row.enabled).flatMap((row) => model.toView(row) ?? []);\n}\n\n/** The channel model bound to the server secret. */\nfunction channelModel(deps: Pick<NotifyDeps, \"db\" | \"config\">): NotificationChannelModel {\n return new NotificationChannelModel(deps.db, undefined, credentialKeys(deps.config));\n}\n\n/** Record each delivery outcome on its channel row (the last error shown in settings). */\nfunction recordOutcomes(\n deps: Pick<NotifyDeps, \"db\" | \"config\" | \"logger\">,\n): (outcome: ChannelOutcome) => Promise<void> {\n const model = channelModel(deps);\n return async (outcome) => {\n if (outcome.channelId.startsWith(\"sub:\") || outcome.channelId.startsWith(\"default:\")) {\n return;\n }\n await model.recordOutcome(outcome.channelId, outcome.error).catch((error: unknown) => {\n deps.logger.warn({ err: error, channelId: outcome.channelId }, \"failed to record outcome\");\n });\n };\n}\n\n/** The one default-on topic, which a subscription can switch off with `reviewRequests`. */\nconst REVIEW_REQUESTED = \"review:requested\";\n\n/** Subscriptions matching an event (enabled + allowlist, alias included, review requests on). */\nfunction matchingSubs(\n rows: NotificationSubscriptionRow[],\n event: string,\n data: Record<string, unknown>,\n): NotificationSubscriptionRow[] {\n return rows.filter(\n (row) =>\n row.enabled &&\n (event !== REVIEW_REQUESTED || row.reviewRequests) &&\n matchesEvent(NotificationSubscriptionModel.eventsOf(row), event, data),\n );\n}\n\n/** Stored channels that admit the event; the core fan-out then needs no filter of its own. */\nfunction admittedChannels(\n channels: NotificationChannel[],\n event: string,\n data: Record<string, unknown>,\n): NotificationChannel[] {\n return channels\n .filter((channel) => matchesEvent(channel.events, event, data))\n .map((channel) => Object.assign({}, channel, { events: [] }));\n}\n\n/** The notifier kinds a fan-out may use, and which of them address one person. */\ninterface DeliveryKinds {\n /** Every wired kind. */\n wired: ReadonlySet<string>;\n /** The wired kinds whose provider declares `audience: \"person\"`: all a subscription may use. */\n personal: ReadonlySet<string>;\n /** Called for a subscription kind that is wired but not personal, so it can be logged. */\n skipped: (subscriptionId: string, kind: string) => void;\n}\n\n/** Subscription kinds already logged as skipped, so each is reported once per process. */\nconst reportedSkips = new Set<string>();\n\n/** The kinds of a fan-out, with the once-per-subscription-and-kind warning for room kinds. */\nfunction deliveryKindsOf(deps: Pick<NotifyDeps, \"notifiers\" | \"logger\">): DeliveryKinds {\n return {\n wired: new Set(deps.notifiers.map((candidate) => candidate.metadata.kind)),\n personal: new Set(\n deps.notifiers\n .filter((candidate) => candidate.metadata.audience === \"person\")\n .map((candidate) => candidate.metadata.kind),\n ),\n skipped: (subscriptionId, kind) => {\n const key = `${subscriptionId}:${kind}`;\n if (!reportedSkips.has(key)) {\n reportedSkips.add(key);\n deps.logger.warn(\n { subscriptionId, kind },\n \"subscription skips a delivery kind that posts to a room; use a project channel\",\n );\n }\n },\n };\n}\n\n/**\n * Per-subscriber targets for one subscription: its personal kinds only. A kind that posts to a\n * room (or is not wired) is skipped, the former with one warning, and the rest still deliver.\n */\nasync function targetsForSub(\n db: DatabaseAdapter,\n sub: NotificationSubscriptionRow,\n kinds: DeliveryKinds,\n): Promise<NotificationChannel[]> {\n const user = await new UserModel(db).get(sub.userId);\n if (!user || user.disabled) {\n return [];\n }\n const via = NotificationSubscriptionModel.viaOf(sub);\n const wants = via.length > 0 ? via : [\"email\"];\n const wired = wants.filter((kind) => kinds.wired.has(kind));\n for (const kind of wired.filter((entry) => !kinds.personal.has(entry))) {\n kinds.skipped(sub.id, kind);\n }\n return wired\n .filter((kind) => kinds.personal.has(kind))\n .map((kind) => ({\n id: `sub:${sub.id}:${kind}`,\n projectId: sub.projectId,\n provider: kind,\n config: { to: user.email },\n events: [],\n enabled: true,\n }));\n}\n\n/**\n * Topics that reach a project's approvers and admins unless they opted out (ADR 0013, decision 4).\n * Every other topic (`design:changed` included) reaches only people who subscribed to it.\n */\nconst DEFAULT_ON_EVENTS: ReadonlySet<string> = new Set([REVIEW_REQUESTED]);\n\n/** Roles that review builds: the default recipients of {@link DEFAULT_ON_EVENTS}. */\nconst REVIEWER_ROLES: ReadonlySet<string> = new Set([\"approver\", \"admin\"]);\n\n/**\n * Reviewers with no subscription row for the project: a default-on topic reaches them by email.\n * Anyone with a row decides for themselves (`enabled` off, `reviewRequests` off or a topic list\n * without the event opts out), so a row never adds a second message here.\n */\nasync function defaultTargets(\n db: DatabaseAdapter,\n projectId: string,\n rows: NotificationSubscriptionRow[],\n kinds: DeliveryKinds,\n skipUsers: ReadonlySet<string>,\n): Promise<NotificationChannel[]> {\n if (!kinds.personal.has(\"email\")) {\n return [];\n }\n const subscribed = new Set(rows.map((row) => row.userId));\n const members = (await new MemberModel(db).list(projectId)).filter(\n (member) =>\n REVIEWER_ROLES.has(member.role) &&\n !subscribed.has(member.userId) &&\n !skipUsers.has(member.userId),\n );\n const targets = await Promise.all(\n members.map(async (member) => await emailTarget(db, projectId, member.userId)),\n );\n return targets.flat();\n}\n\n/** The default email target of one user (none for a missing or disabled account). */\nasync function emailTarget(\n db: DatabaseAdapter,\n projectId: string,\n userId: string,\n): Promise<NotificationChannel[]> {\n const user = await new UserModel(db).get(userId);\n return !user || user.disabled\n ? []\n : [\n {\n id: `default:${projectId}:${userId}`,\n projectId,\n provider: \"email\",\n config: { to: user.email },\n events: [],\n enabled: true,\n },\n ];\n}\n\n/** Per-subscriber targets for an event (only kinds that are wired). */\nasync function subscriberTargets(\n db: DatabaseAdapter,\n projectId: string,\n event: string,\n data: Record<string, unknown>,\n kinds: DeliveryKinds,\n skipUsers: ReadonlySet<string>,\n): Promise<NotificationChannel[]> {\n const all = await new NotificationSubscriptionModel(db).list(projectId);\n const rows = all.filter((row) => !skipUsers.has(row.userId));\n const nested = await Promise.all(\n matchingSubs(rows, event, data).map(async (sub) => await targetsForSub(db, sub, kinds)),\n );\n const defaults = DEFAULT_ON_EVENTS.has(event)\n ? await defaultTargets(db, projectId, rows, kinds, skipUsers)\n : [];\n return [...nested.flat(), ...defaults];\n}\n\n/** Narrowing options of a project fan-out. */\nexport interface NotifyOptions {\n /**\n * Users who already get this event another way (a mention, or an assignment from the component\n * owners): their subscriptions and the default recipients skip it.\n */\n skipUsers?: ReadonlySet<string>;\n}\n\n/** Send one event to the given channels with the brand and a review link under `origin`. */\nasync function deliver(\n deps: NotifyDeps,\n project: Pick<Project, \"id\" | \"slug\">,\n message: { event: string; data: Record<string, unknown>; reviewUrl: string | undefined },\n channels: NotificationChannel[],\n): Promise<void> {\n await emitNotifications(\n {\n event: message.event,\n projectId: project.id,\n projectSlug: project.slug,\n data: message.data,\n timestamp: now(),\n },\n {\n notifiers: deps.notifiers,\n channels,\n brand: brandFor(deps.ui, deps.notificationsConfig, message.reviewUrl),\n logger: deps.logger,\n onOutcome: recordOutcomes(deps),\n },\n );\n}\n\n/**\n * Fan out a project event to stored channels plus opted-in subscribers.\n * Runs alongside (never instead of) webhook delivery.\n */\nexport async function notifyProjectWith(\n deps: NotifyDeps,\n project: Pick<Project, \"id\" | \"slug\">,\n event: string,\n data: Record<string, unknown>,\n reviewSuffix?: string,\n options: NotifyOptions = {},\n): Promise<void> {\n if (deps.notifiers.length === 0) {\n return;\n }\n const origin = await originOrPause(deps, event);\n if (origin === undefined) {\n return;\n }\n const reviewUrl = reviewSuffix ? reviewUrlFor(origin, project.slug, reviewSuffix) : undefined;\n const kinds = deliveryKindsOf(deps);\n const channels = admittedChannels(await loadChannels(deps, project.id), event, data);\n const targets = await subscriberTargets(\n deps.db,\n project.id,\n event,\n data,\n kinds,\n options.skipUsers ?? new Set(),\n );\n await deliver(deps, project, { event, data, reviewUrl }, [...channels, ...targets]);\n}\n\n/**\n * Where a mention of `userId` is delivered (#463): their own subscription's channels when they\n * have one and it is enabled (a disabled one opts them out), else a default email. Never a stored\n * project channel: a mention is addressed to one person and must not reach a room.\n */\nasync function mentionTargets(\n db: DatabaseAdapter,\n projectId: string,\n userId: string,\n kinds: DeliveryKinds,\n): Promise<NotificationChannel[]> {\n const rows = await new NotificationSubscriptionModel(db).list(projectId);\n const own = rows.find((row) => row.userId === userId);\n // The record of \"no review requests\" opts out of those and nothing else: they still get a\n // mention by default email.\n if (own !== undefined && !NotificationSubscriptionModel.isReviewOptOutOnly(own)) {\n return own.enabled ? await targetsForSub(db, own, kinds) : [];\n }\n return kinds.personal.has(\"email\") ? await emailTarget(db, projectId, userId) : [];\n}\n\n/**\n * Where a review request is delivered to a member assigned as a component owner (#465): their own\n * subscription's channels when it is enabled, else a default email. Being assigned overrides an\n * opt-out (a disabled subscription, or a topic list without `review:requested`), because someone\n * asked for this person by name. Never a stored project channel: the request is for one person.\n */\nasync function assignedTargets(\n db: DatabaseAdapter,\n projectId: string,\n userId: string,\n kinds: DeliveryKinds,\n): Promise<NotificationChannel[]> {\n const own = (await new NotificationSubscriptionModel(db).list(projectId)).find(\n (row) => row.userId === userId,\n );\n // `targetsForSub` keeps personal kinds only, so a state or room kind never counts as a target.\n const chosen = own?.enabled ? await targetsForSub(db, own, kinds) : [];\n if (chosen.length > 0) {\n return chosen;\n }\n return kinds.personal.has(\"email\") ? await emailTarget(db, projectId, userId) : [];\n}\n\n/**\n * Send a review request to one member assigned from the component owners. The regular fan-out\n * skips them (see `NotifyOptions.skipUsers`), so they get exactly one message per cycle.\n */\nexport async function notifyAssignedWith(\n deps: NotifyDeps,\n project: Pick<Project, \"id\" | \"slug\">,\n userId: string,\n data: Record<string, unknown>,\n reviewSuffix: string,\n): Promise<void> {\n if (deps.notifiers.length === 0) {\n return;\n }\n const event = \"review:requested\";\n const origin = await originOrPause(deps, event);\n if (origin === undefined) {\n return;\n }\n const targets = await assignedTargets(deps.db, project.id, userId, deliveryKindsOf(deps));\n const reviewUrl = reviewUrlFor(origin, project.slug, reviewSuffix);\n await deliver(deps, project, { event, data, reviewUrl }, targets);\n}\n\n/** Notify one project member that a comment mentions them. Best-effort, like every fan-out. */\nexport async function notifyMentionWith(\n deps: NotifyDeps,\n project: Pick<Project, \"id\" | \"slug\">,\n userId: string,\n data: Record<string, unknown>,\n reviewSuffix: string,\n): Promise<void> {\n if (deps.notifiers.length === 0) {\n return;\n }\n const origin = await originOrPause(deps, COMMENT_MENTIONED);\n if (origin === undefined) {\n return;\n }\n const targets = await mentionTargets(deps.db, project.id, userId, deliveryKindsOf(deps));\n const reviewUrl = reviewUrlFor(origin, project.slug, reviewSuffix);\n await deliver(deps, project, { event: COMMENT_MENTIONED, data, reviewUrl }, targets);\n}\n\n/**\n * Fan out a project event to stored channels plus opted-in subscribers.\n * Runs alongside (never instead of) webhook delivery.\n */\nexport async function notifyProject(\n project: Pick<Project, \"id\" | \"slug\">,\n event: string,\n data: Record<string, unknown>,\n reviewSuffix?: string,\n options?: NotifyOptions,\n): Promise<void> {\n await notifyProjectWith(getStore(), project, event, data, reviewSuffix, options);\n}\n\n/** Fan out a site-wide admin alert to project-less channels. */\nexport async function notifySystemWith(\n deps: NotifyDeps,\n event: SystemEventName,\n data: Record<string, unknown>,\n): Promise<void> {\n if (deps.notifiers.length === 0 || (await originOrPause(deps, event)) === undefined) {\n return;\n }\n await emitSystemNotification(\n { event, data, timestamp: now() },\n {\n notifiers: deps.notifiers,\n channels: await loadChannels(deps, null),\n brand: brandFor(deps.ui, deps.notificationsConfig),\n logger: deps.logger,\n onOutcome: recordOutcomes(deps),\n },\n );\n}\n\n/** Fan out a site-wide admin alert to project-less channels. */\nexport async function notifySystem(\n event: SystemEventName,\n data: Record<string, unknown>,\n): Promise<void> {\n await notifySystemWith(getStore(), event, data);\n}\n\n/**\n * Host hook for the auth engine (`onAuthSystemEvent`): fans `sys:*` auth\n * events out to admin channels. Safe to call anywhere — outside a request\n * scope (e.g. boot) it resolves to a no-op instead of throwing.\n */\nexport function createAuthSystemHook(): (\n event: \"sys:user-created\" | \"sys:invite-issued\",\n data: Record<string, unknown>,\n) => Promise<void> {\n return async (event, data): Promise<void> => {\n await notifySystem(event, data).catch(() => {\n // Intentionally empty — best-effort fan-out must never break auth\n });\n };\n}\n\n/** Current timestamp for notification envelopes. */\nfunction now(): string {\n return new Date().toISOString();\n}\n"],"mappings":"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"}