@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.
- package/dist/index.mjs +1 -1
- package/dist/jobs-setup.mjs +28 -2
- package/dist/jobs-setup.mjs.map +1 -1
- package/dist/mentions.mjs +95 -0
- package/dist/mentions.mjs.map +1 -0
- package/dist/notify.mjs +132 -45
- package/dist/notify.mjs.map +1 -1
- package/dist/pages/build-diff-comments.mjs +37 -10
- package/dist/pages/build-diff-comments.mjs.map +1 -1
- package/dist/pages/comment-body-field.mjs +102 -0
- package/dist/pages/comment-body-field.mjs.map +1 -0
- package/dist/pages/project-settings.mjs.map +1 -1
- package/dist/pages/review-changeset.mjs +1 -1
- package/dist/pages/review-changeset.mjs.map +1 -1
- package/dist/pages/review-comments.mjs +5 -8
- package/dist/pages/review-comments.mjs.map +1 -1
- package/dist/pages/review-verdict.mjs +54 -0
- package/dist/pages/review-verdict.mjs.map +1 -1
- package/dist/pages/settings-component-owners.mjs +90 -0
- package/dist/pages/settings-component-owners.mjs.map +1 -0
- package/dist/pages/settings-members.mjs +11 -0
- package/dist/pages/settings-members.mjs.map +1 -1
- package/dist/pages/settings-notifications.mjs +37 -53
- package/dist/pages/settings-notifications.mjs.map +1 -1
- package/dist/review-assignment.mjs +37 -0
- package/dist/review-assignment.mjs.map +1 -0
- package/dist/review-events.mjs +17 -7
- package/dist/review-events.mjs.map +1 -1
- package/dist/routers/comments.mjs +50 -10
- package/dist/routers/comments.mjs.map +1 -1
- package/dist/routers/mention-suggest.mjs +65 -0
- package/dist/routers/mention-suggest.mjs.map +1 -0
- package/dist/routers/notification-subscriptions.mjs +36 -3
- package/dist/routers/notification-subscriptions.mjs.map +1 -1
- package/dist/routers/profile.mjs +8 -4
- package/dist/routers/profile.mjs.map +1 -1
- package/dist/routers/review-forms.mjs +2 -0
- package/dist/routers/review-forms.mjs.map +1 -1
- package/dist/routers/review-section.handlers.mjs +8 -1
- package/dist/routers/review-section.handlers.mjs.map +1 -1
- package/dist/routers/schemas.mjs +10 -2
- package/dist/routers/schemas.mjs.map +1 -1
- package/dist/routers/settings-members.mjs +27 -1
- package/dist/routers/settings-members.mjs.map +1 -1
- package/dist/routers/settings-notifications.mjs +31 -4
- package/dist/routers/settings-notifications.mjs.map +1 -1
- package/dist/routers/settings.handlers.mjs +2 -1
- package/dist/routers/settings.handlers.mjs.map +1 -1
- package/dist/routes/page-routes.mjs +3 -0
- package/dist/routes/page-routes.mjs.map +1 -1
- package/dist/shell/client-script.mjs +11 -0
- package/dist/shell/client-script.mjs.map +1 -1
- package/dist/ui/forms.mjs +2 -1
- package/dist/ui/forms.mjs.map +1 -1
- package/dist/ui/styles/review.mjs +10 -1
- package/dist/ui/styles/review.mjs.map +1 -1
- 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-
|
|
383
|
+
return "0.10.0-next-8035d25";
|
|
384
384
|
}
|
|
385
385
|
export { createAuthSystemHook, createShelfApp, createShelfLogger };
|
|
386
386
|
|
package/dist/jobs-setup.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
}
|
|
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 };
|
package/dist/jobs-setup.mjs.map
CHANGED
|
@@ -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":";;;;;;;;;;;;;;;;;;;;AA0CA,SAAgB,UACd,SACA,SACA,MAAyB,QAAQ,KACrB;CACZ,MAAM,EAAE,MAAM,eAAe,UAAU,SAASA,mBAAiB,GAAG,CAAC;CACrE,MAAM,aAAa,gBAAgB,SAAS,SAAS,UAAU;CAC/D,MAAM,UAAU,aACd,SACA,QAAQ,QACR;EACE,WAAW,QAAQ;EACnB,YAAY,WAAW;EACvB,cAAc,YAAY,MAAM,QAAQ,aAAa,IAAI;CAC3D,GACA,QAAQ,QACR,IACF;CACA,MAAM,aAAa,gBACjB,SACA,QAAQ,QACR,QAAQ,QACR,QAAQ,mBAAmB,KAAA,GAC3B;EACE;EACA,YAAY,WAAW;EACvB,iBAAiB,mBAAmB,SAAS,OAAO;CACtD,CACF;CACA,MAAM,SAAS;EAAE;EAAS;EAAY;EAAY;CAAW;CAC7D,OAAO;EACL,cAAc,QAAQ;EACtB,gBAAgB,QAAQ;EACxB,cAAc,QAAQ,YAAY;EAClC,SAAS;EACT,YAAY,WAAW;EACvB,iBAAiB,mBAAmB,CAAC,KAAK,IAAI,CAAC;EAC/C,UAAU,iBAAiB,YAAY,SAAS,SAAS,MAAM,MAAM,CAAC;CACxE;AACF;;;;;;AAOA,SAAS,UACP,SACA,YACiD;CACjD,MAAM,UAAU,QAAQ;CAExB,MAAM,QAA0B,CAC9B,GAFuB,SAAS,WAAW,KAAA,IAEnB,CAAC,SAAS,IAAc,CAAC,GACjD,GAAI,aAAa,CAAC,IAAK,CAAC,SAAS,CACnC;CACA,OAAO;EACL,MAAM,eAAe,SAAS,OAAO,SAAS,WAAW,eAAe;EACxE,YAAY,aAAa,OAAO,eAAe,SAAS,CAAC,QAAQ,GAAG,sBAAsB,CAAC;CAC7F;AACF;;AAGA,SAAS,YACP,SACA,SACA,MACA,QAMmB;CACnB,MAAM,EAAE,SAAS,YAAY,YAAY,eAAe;CACxD,OAAO,CACL,GAAI,KAAK,MAAM,SAAS,IACpB,CAAC,iBAAiB,SAAS,SAAS,MAAM;EAAE;EAAS;CAAW,CAAC,CAAC,IAClE,CAAC,GACL,GAAI,WAAW,YAAY,QAAQ,eAAe,OAC9C,CAAC,eAAe,SAAS,SAAS,YAAY,WAAW,OAAO,CAAC,IACjE,CAAC,CACP;AACF;;AAGA,SAAS,iBACP,SACA,SACA,MACA,QAIiB;CACjB,MAAM,EAAE,SAAS,eAAe;CAChC,MAAM,eAAe,KAAK,MAAM,SAAS,SAAS;CAClD,OAAO,oBACE;EACL,OAAO,KAAK,IAAI;EAChB,UAAU;GACR,GAAI,gBAAgB,QAAQ,UAAU,EAAE,SAAS,QAAQ,QAAQ,IAAI,CAAC;GACtE,GAAI,WAAW,UAAU,EAAE,SAAS,WAAW,QAAQ,IAAI,CAAC;EAC9D;EAEA,QAAQ;GACN,aAAa,eAAe,QAAQ,SAAS,cAAc;GAC3D,YAAY,QAAQ,SAAS,WAAW;EAC1C;EACA,YAAY,QAAQ,OAAO,cAAc;EACzC,SAAS,WAAW;EACpB,QAAQ,QAAQ,OAAO,MAAM,EAAE,WAAW,eAAe,CAAC;EAC1D,GAAI,QAAQ,SAAS,eAAe,KAAA,IAChC,CAAC,IACD;GAAE,KAAK,QAAQ,QAAQ,WAAW;GAAK,QAAQ,QAAQ,QAAQ,WAAW;EAAO;CACvF,IACA,CACE,YAAY;EACV,IAAI,cACF,MAAM,QAAQ,SAAS,QAAQ;EAEjC,MAAM,KAAK,MAAM,CAAC,EAAE,UAAU,SAAS;CACzC,CACF,CACF;AACF;;AAGA,MAAM,qBAAqB;;AAG3B,SAAS,eACP,SACA,SACA,OACA,SACiB;CACjB,OAAO,oBACE;EACL,OAAO,MAAM,IAAI;EACjB,UAAU,EAAE,QAAQ,QAAQ;EAC5B,QAAQ,EAAE,aAAa,mBAAmB;EAC1C,SAAS,WAAW;EACpB,QAAQ,QAAQ,OAAO,MAAM,EAAE,WAAW,kBAAkB,CAAC;EAC7D,GAAI,QAAQ,SAAS,eAAe,KAAA,IAChC,CAAC,IACD;GAAE,KAAK,QAAQ,QAAQ,WAAW;GAAK,QAAQ,QAAQ,QAAQ,WAAW;EAAO;CACvF,IACA,CACE,YAAY;EACV,MAAM,MAAM,MAAM,CAAC,EAAE,UAAU,SAAS;CAC1C,CACF,CACF;AACF;;AAGA,SAAS,mBACP,SACA,SACmD;CACnD,OAAO,OAAO,SAAS,UAAU;EAC/B,MAAM,mBACJ;GACE,IAAI,QAAQ;GACZ,QAAQ,QAAQ;GAChB,IAAI,QAAQ;GACZ,QAAQ,QAAQ;GAChB,WAAW,QAAQ;GACnB,qBAAqB,QAAQ,eAAe;GAC5C,cAAc,QAAQ;EACxB,GACA,SACA,cAAc,WACd,oBAAoB,KAAK,GACzB,MAAM,EACR;CACF;AACF"}
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
67
|
-
|
|
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
|
|
71
|
-
|
|
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([
|
|
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
|
|
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,
|
|
93
|
-
if (!
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
}
|
|
106
|
-
|
|
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,
|
|
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,
|
|
112
|
-
const defaults = DEFAULT_ON_EVENTS.has(event) ? await defaultTargets(db, projectId, rows,
|
|
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
|
|
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,
|
|
127
|
-
await
|
|
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
|
-
|
|
133
|
-
},
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
package/dist/notify.mjs.map
CHANGED
|
@@ -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":";;;;;;;;;;;AA6CA,SAAS,aAAa,QAAgB,MAAc,QAAwB;CAC1E,OAAO,GAAG,OAAO,YAAY,OAAO;AACtC;;;;;AAMA,eAAe,cACb,MACA,OAC6B;CAC7B,MAAM,SAAS,MAAM,mBAAmB,IAAI;CAC5C,IAAI,WAAW,KAAA,GACb,WAAW,KAAK,QAAQ,KAAK;MAE7B,YAAY,KAAK,MAAM;CAEzB,OAAO;AACT;;AAGA,SAAS,SACP,IACA,UACA,WACmB;CACnB,OAAO;EACL,MAAM,GAAG;EACT,MAAM,GAAG;EACT;EACA,WAAW,UAAU;EACrB,UAAU,UAAU,YAAY,GAAG;EACnC,YAAY,UAAU;CACxB;AACF;;AAGA,eAAe,aACb,MACA,WACgC;CAChC,MAAM,QAAQ,aAAa,IAAI;CAE/B,QADa,cAAc,OAAO,MAAM,MAAM,WAAW,IAAI,MAAM,MAAM,KAAK,SAAS,EAAA,CAC3E,QAAQ,QAAQ,IAAI,OAAO,CAAC,CAAC,SAAS,QAAQ,MAAM,OAAO,GAAG,KAAK,CAAC,CAAC;AACnF;;AAGA,SAAS,aAAa,MAAmE;CACvF,OAAO,IAAI,yBAAyB,KAAK,IAAI,KAAA,GAAW,eAAe,KAAK,MAAM,CAAC;AACrF;;AAGA,SAAS,eACP,MAC4C;CAC5C,MAAM,QAAQ,aAAa,IAAI;CAC/B,OAAO,OAAO,YAAY;EACxB,IAAI,QAAQ,UAAU,WAAW,MAAM,KAAK,QAAQ,UAAU,WAAW,UAAU,GACjF;EAEF,MAAM,MAAM,cAAc,QAAQ,WAAW,QAAQ,KAAK,CAAC,CAAC,OAAO,UAAmB;GACpF,KAAK,OAAO,KAAK;IAAE,KAAK;IAAO,WAAW,QAAQ;GAAU,GAAG,0BAA0B;EAC3F,CAAC;CACH;AACF;;AAGA,MAAM,mBAAmB;;AAGzB,SAAS,aACP,MACA,OACA,MAC+B;CAC/B,OAAO,KAAK,QACT,QACC,IAAI,YACH,UAAU,oBAAoB,IAAI,mBACnC,aAAa,8BAA8B,SAAS,GAAG,GAAG,OAAO,IAAI,CACzE;AACF;;AAGA,SAAS,iBACP,UACA,OACA,MACuB;CACvB,OAAO,SACJ,QAAQ,YAAY,aAAa,QAAQ,QAAQ,OAAO,IAAI,CAAC,CAAC,CAC9D,KAAK,YAAY,OAAO,OAAO,CAAC,GAAG,SAAS,EAAE,QAAQ,CAAC,EAAE,CAAC,CAAC;AAChE;;AAaA,MAAM,gCAAgB,IAAI,IAAY;;AAGtC,SAAS,gBAAgB,MAA+D;CACtF,OAAO;EACL,OAAO,IAAI,IAAI,KAAK,UAAU,KAAK,cAAc,UAAU,SAAS,IAAI,CAAC;EACzE,UAAU,IAAI,IACZ,KAAK,UACF,QAAQ,cAAc,UAAU,SAAS,aAAa,QAAQ,CAAC,CAC/D,KAAK,cAAc,UAAU,SAAS,IAAI,CAC/C;EACA,UAAU,gBAAgB,SAAS;GACjC,MAAM,MAAM,GAAG,eAAe,GAAG;GACjC,IAAI,CAAC,cAAc,IAAI,GAAG,GAAG;IAC3B,cAAc,IAAI,GAAG;IACrB,KAAK,OAAO,KACV;KAAE;KAAgB;IAAK,GACvB,gFACF;GACF;EACF;CACF;AACF;;;;;AAMA,eAAe,cACb,IACA,KACA,OACgC;CAChC,MAAM,OAAO,MAAM,IAAI,UAAU,EAAE,CAAC,CAAC,IAAI,IAAI,MAAM;CACnD,IAAI,CAAC,QAAQ,KAAK,UAChB,OAAO,CAAC;CAEV,MAAM,MAAM,8BAA8B,MAAM,GAAG;CAEnD,MAAM,SADQ,IAAI,SAAS,IAAI,MAAM,CAAC,OAAO,EAAA,CACzB,QAAQ,SAAS,MAAM,MAAM,IAAI,IAAI,CAAC;CAC1D,KAAK,MAAM,QAAQ,MAAM,QAAQ,UAAU,CAAC,MAAM,SAAS,IAAI,KAAK,CAAC,GACnE,MAAM,QAAQ,IAAI,IAAI,IAAI;CAE5B,OAAO,MACJ,QAAQ,SAAS,MAAM,SAAS,IAAI,IAAI,CAAC,CAAC,CAC1C,KAAK,UAAU;EACd,IAAI,OAAO,IAAI,GAAG,GAAG;EACrB,WAAW,IAAI;EACf,UAAU;EACV,QAAQ,EAAE,IAAI,KAAK,MAAM;EACzB,QAAQ,CAAC;EACT,SAAS;CACX,EAAE;AACN;;;;;AAMA,MAAM,oCAAyC,IAAI,IAAI,CAAC,gBAAgB,CAAC;;AAGzE,MAAM,iCAAsC,IAAI,IAAI,CAAC,YAAY,OAAO,CAAC;;;;;;AAOzE,eAAe,eACb,IACA,WACA,MACA,OACA,WACgC;CAChC,IAAI,CAAC,MAAM,SAAS,IAAI,OAAO,GAC7B,OAAO,CAAC;CAEV,MAAM,aAAa,IAAI,IAAI,KAAK,KAAK,QAAQ,IAAI,MAAM,CAAC;CACxD,MAAM,WAAW,MAAM,IAAI,YAAY,EAAE,CAAC,CAAC,KAAK,SAAS,EAAA,CAAG,QACzD,WACC,eAAe,IAAI,OAAO,IAAI,KAC9B,CAAC,WAAW,IAAI,OAAO,MAAM,KAC7B,CAAC,UAAU,IAAI,OAAO,MAAM,CAChC;CAIA,QAAO,MAHe,QAAQ,IAC5B,QAAQ,IAAI,OAAO,WAAW,MAAM,YAAY,IAAI,WAAW,OAAO,MAAM,CAAC,CAC/E,EAAA,CACe,KAAK;AACtB;;AAGA,eAAe,YACb,IACA,WACA,QACgC;CAChC,MAAM,OAAO,MAAM,IAAI,UAAU,EAAE,CAAC,CAAC,IAAI,MAAM;CAC/C,OAAO,CAAC,QAAQ,KAAK,WACjB,CAAC,IACD,CACE;EACE,IAAI,WAAW,UAAU,GAAG;EAC5B;EACA,UAAU;EACV,QAAQ,EAAE,IAAI,KAAK,MAAM;EACzB,QAAQ,CAAC;EACT,SAAS;CACX,CACF;AACN;;AAGA,eAAe,kBACb,IACA,WACA,OACA,MACA,OACA,WACgC;CAEhC,MAAM,QAAO,MADK,IAAI,8BAA8B,EAAE,CAAC,CAAC,KAAK,SAAS,EAAA,CACrD,QAAQ,QAAQ,CAAC,UAAU,IAAI,IAAI,MAAM,CAAC;CAC3D,MAAM,SAAS,MAAM,QAAQ,IAC3B,aAAa,MAAM,OAAO,IAAI,CAAC,CAAC,IAAI,OAAO,QAAQ,MAAM,cAAc,IAAI,KAAK,KAAK,CAAC,CACxF;CACA,MAAM,WAAW,kBAAkB,IAAI,KAAK,IACxC,MAAM,eAAe,IAAI,WAAW,MAAM,OAAO,SAAS,IAC1D,CAAC;CACL,OAAO,CAAC,GAAG,OAAO,KAAK,GAAG,GAAG,QAAQ;AACvC;;AAYA,eAAe,QACb,MACA,SACA,SACA,UACe;CACf,MAAM,kBACJ;EACE,OAAO,QAAQ;EACf,WAAW,QAAQ;EACnB,aAAa,QAAQ;EACrB,MAAM,QAAQ;EACd,WAAW,IAAI;CACjB,GACA;EACE,WAAW,KAAK;EAChB;EACA,OAAO,SAAS,KAAK,IAAI,KAAK,qBAAqB,QAAQ,SAAS;EACpE,QAAQ,KAAK;EACb,WAAW,eAAe,IAAI;CAChC,CACF;AACF;;;;;AAMA,eAAsB,kBACpB,MACA,SACA,OACA,MACA,cACA,UAAyB,CAAC,GACX;CACf,IAAI,KAAK,UAAU,WAAW,GAC5B;CAEF,MAAM,SAAS,MAAM,cAAc,MAAM,KAAK;CAC9C,IAAI,WAAW,KAAA,GACb;CAEF,MAAM,YAAY,eAAe,aAAa,QAAQ,QAAQ,MAAM,YAAY,IAAI,KAAA;CACpF,MAAM,QAAQ,gBAAgB,IAAI;CAClC,MAAM,WAAW,iBAAiB,MAAM,aAAa,MAAM,QAAQ,EAAE,GAAG,OAAO,IAAI;CACnF,MAAM,UAAU,MAAM,kBACpB,KAAK,IACL,QAAQ,IACR,OACA,MACA,OACA,QAAQ,6BAAa,IAAI,IAAI,CAC/B;CACA,MAAM,QAAQ,MAAM,SAAS;EAAE;EAAO;EAAM;CAAU,GAAG,CAAC,GAAG,UAAU,GAAG,OAAO,CAAC;AACpF;;;;;;AAOA,eAAe,eACb,IACA,WACA,QACA,OACgC;CAEhC,MAAM,OAAM,MADO,IAAI,8BAA8B,EAAE,CAAC,CAAC,KAAK,SAAS,EAAA,CACtD,MAAM,QAAQ,IAAI,WAAW,MAAM;CAGpD,IAAI,QAAQ,KAAA,KAAa,CAAC,8BAA8B,mBAAmB,GAAG,GAC5E,OAAO,IAAI,UAAU,MAAM,cAAc,IAAI,KAAK,KAAK,IAAI,CAAC;CAE9D,OAAO,MAAM,SAAS,IAAI,OAAO,IAAI,MAAM,YAAY,IAAI,WAAW,MAAM,IAAI,CAAC;AACnF;;;;;;;AAQA,eAAe,gBACb,IACA,WACA,QACA,OACgC;CAChC,MAAM,OAAO,MAAM,IAAI,8BAA8B,EAAE,CAAC,CAAC,KAAK,SAAS,EAAA,CAAG,MACvE,QAAQ,IAAI,WAAW,MAC1B;CAEA,MAAM,SAAS,KAAK,UAAU,MAAM,cAAc,IAAI,KAAK,KAAK,IAAI,CAAC;CACrE,IAAI,OAAO,SAAS,GAClB,OAAO;CAET,OAAO,MAAM,SAAS,IAAI,OAAO,IAAI,MAAM,YAAY,IAAI,WAAW,MAAM,IAAI,CAAC;AACnF;;;;;AAMA,eAAsB,mBACpB,MACA,SACA,QACA,MACA,cACe;CACf,IAAI,KAAK,UAAU,WAAW,GAC5B;CAEF,MAAM,QAAQ;CACd,MAAM,SAAS,MAAM,cAAc,MAAM,KAAK;CAC9C,IAAI,WAAW,KAAA,GACb;CAEF,MAAM,UAAU,MAAM,gBAAgB,KAAK,IAAI,QAAQ,IAAI,QAAQ,gBAAgB,IAAI,CAAC;CAExF,MAAM,QAAQ,MAAM,SAAS;EAAE;EAAO;EAAM,WAD1B,aAAa,QAAQ,QAAQ,MAAM,YACD;CAAE,GAAG,OAAO;AAClE;;AAGA,eAAsB,kBACpB,MACA,SACA,QACA,MACA,cACe;CACf,IAAI,KAAK,UAAU,WAAW,GAC5B;CAEF,MAAM,SAAS,MAAM,cAAc,MAAM,iBAAiB;CAC1D,IAAI,WAAW,KAAA,GACb;CAEF,MAAM,UAAU,MAAM,eAAe,KAAK,IAAI,QAAQ,IAAI,QAAQ,gBAAgB,IAAI,CAAC;CACvF,MAAM,YAAY,aAAa,QAAQ,QAAQ,MAAM,YAAY;CACjE,MAAM,QAAQ,MAAM,SAAS;EAAE,OAAO;EAAmB;EAAM;CAAU,GAAG,OAAO;AACrF;;;;;AAMA,eAAsB,cACpB,SACA,OACA,MACA,cACA,SACe;CACf,MAAM,kBAAkB,SAAS,GAAG,SAAS,OAAO,MAAM,cAAc,OAAO;AACjF;;AAGA,eAAsB,iBACpB,MACA,OACA,MACe;CACf,IAAI,KAAK,UAAU,WAAW,KAAM,MAAM,cAAc,MAAM,KAAK,MAAO,KAAA,GACxE;CAEF,MAAM,uBACJ;EAAE;EAAO;EAAM,WAAW,IAAI;CAAE,GAChC;EACE,WAAW,KAAK;EAChB,UAAU,MAAM,aAAa,MAAM,IAAI;EACvC,OAAO,SAAS,KAAK,IAAI,KAAK,mBAAmB;EACjD,QAAQ,KAAK;EACb,WAAW,eAAe,IAAI;CAChC,CACF;AACF;;AAGA,eAAsB,aACpB,OACA,MACe;CACf,MAAM,iBAAiB,SAAS,GAAG,OAAO,IAAI;AAChD;;;;;;AAOA,SAAgB,uBAGG;CACjB,OAAO,OAAO,OAAO,SAAwB;EAC3C,MAAM,aAAa,OAAO,IAAI,CAAC,CAAC,YAAY,CAE5C,CAAC;CACH;AACF;;AAGA,SAAS,MAAc;CACrB,wBAAO,IAAI,KAAK,EAAA,CAAE,YAAY;AAChC"}
|