@checkstack/notification-common 0.3.0 → 1.0.0
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/CHANGELOG.md +200 -0
- package/package.json +2 -2
- package/src/builders.test.ts +82 -0
- package/src/builders.ts +86 -0
- package/src/index.ts +3 -0
- package/src/rpc-contract.ts +246 -24
- package/src/schemas.ts +49 -1
- package/src/subscriptions.ts +123 -0
- package/src/targets.ts +164 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,205 @@
|
|
|
1
1
|
# @checkstack/notification-common
|
|
2
2
|
|
|
3
|
+
## 1.0.0
|
|
4
|
+
|
|
5
|
+
### Major Changes
|
|
6
|
+
|
|
7
|
+
- 32d52c6: feat: notification target pattern + per-spec subscriptions
|
|
8
|
+
|
|
9
|
+
Replaces the all-or-nothing catalog system/group notification model with a
|
|
10
|
+
platform-level target pattern. Each notification-emitting plugin declares
|
|
11
|
+
_subscription specs_ against typed _target_ objects exported from the
|
|
12
|
+
target's owning plugin (catalog ships `catalogSystemTarget` and
|
|
13
|
+
`catalogGroupTarget`). Notification-backend handles every per-resource
|
|
14
|
+
group lifecycle, parent-edge inheritance, and legacy-subscription seeding
|
|
15
|
+
— plugins never author groupId helpers, lifecycle hooks, or migration
|
|
16
|
+
code again.
|
|
17
|
+
|
|
18
|
+
**Plugin-author surface area is now ~12 lines per emitter:**
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
// <plugin>-common
|
|
22
|
+
const { defineSubscription } = createSubscriptionFactory(pluginMetadata);
|
|
23
|
+
export const fooSystemSubscription = defineSubscription({
|
|
24
|
+
localId: "system",
|
|
25
|
+
target: catalogSystemTarget,
|
|
26
|
+
display: { title: "Foo Alerts", description: "...", iconName: "Bell" },
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
// <plugin>-backend register()
|
|
30
|
+
env.registerSubscriptionSpecs([fooSystemSubscription]);
|
|
31
|
+
// ^ feeds the plugin loader's dependency sorter — each spec's
|
|
32
|
+
// target.ownerPlugin becomes an implicit init-order dep, so this
|
|
33
|
+
// plugin automatically waits for catalog (the target owner) to
|
|
34
|
+
// finish init + afterPluginsReady before its own runs.
|
|
35
|
+
|
|
36
|
+
// <plugin>-backend afterPluginsReady
|
|
37
|
+
await notificationClient.registerSubscriptionSpec(
|
|
38
|
+
specToRegistration(fooSystemSubscription)
|
|
39
|
+
);
|
|
40
|
+
// dispatch
|
|
41
|
+
await notificationClient.notifyForSubscription({
|
|
42
|
+
specId: fooSystemSubscription.specId,
|
|
43
|
+
resourceKeys: [systemId],
|
|
44
|
+
title,
|
|
45
|
+
body,
|
|
46
|
+
importance,
|
|
47
|
+
action,
|
|
48
|
+
collapseKey,
|
|
49
|
+
subjects,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// <plugin>-frontend
|
|
53
|
+
createNotificationSubscriptionExtension({ spec: fooSystemSubscription });
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
**Migrated plugins**: anomaly, incident, maintenance, healthcheck,
|
|
57
|
+
dependency. Each lost its bespoke `notification-groups.ts`,
|
|
58
|
+
`bootstrap*NotificationGroups`, `ensure*Group`, and inheritance walk —
|
|
59
|
+
all of that is now centralized in notification-backend's
|
|
60
|
+
`subscription-engine`.
|
|
61
|
+
|
|
62
|
+
**Plugin loader change** (`@checkstack/backend-api`,
|
|
63
|
+
`@checkstack/backend`): the register-time API gains
|
|
64
|
+
`env.registerSubscriptionSpecs([...specs])`. The dependency sorter
|
|
65
|
+
walks `spec.target.ownerPlugin` for every declared spec and adds the
|
|
66
|
+
target owner as an init-order dependency of the emitting plugin. This
|
|
67
|
+
guarantees that catalog (the owner of the platform's `system` and
|
|
68
|
+
`group` targets) completes init + afterPluginsReady before any
|
|
69
|
+
emitting plugin tries to register its specs against the notification
|
|
70
|
+
service — no string-prefix heuristics, no manual `dependsOnPlugins`
|
|
71
|
+
list, no stub rows. Plugins that fail to declare their specs at
|
|
72
|
+
register time get a clear `Target type X is not registered. Did the
|
|
73
|
+
emitting plugin declare this spec via env.registerSubscriptionSpecs?`
|
|
74
|
+
error from the dispatcher.
|
|
75
|
+
|
|
76
|
+
**Removed** (no backwards compat):
|
|
77
|
+
|
|
78
|
+
- `catalogClient.notifySystemSubscribers` and
|
|
79
|
+
`catalogClient.notifyManySystemSubscribers`
|
|
80
|
+
- `notificationClient.notifyUsers` and `notificationClient.notifyGroups`
|
|
81
|
+
as direct dispatch primitives — replaced by spec-bound
|
|
82
|
+
`notifyForSubscription`
|
|
83
|
+
- catalog's `bootstrapNotificationGroups` (replaced by
|
|
84
|
+
`bootstrapNotificationTargets`)
|
|
85
|
+
|
|
86
|
+
**Enforcement**: the dispatcher rejects calls referencing unregistered
|
|
87
|
+
specIds, specs owned by other plugins, or resourceKeys that haven't been
|
|
88
|
+
pushed via `upsertNotificationResource`. Display metadata for any
|
|
89
|
+
groupId is recoverable via the spec registry, so audit lists render
|
|
90
|
+
correct labels even when an emitter's frontend isn't loaded.
|
|
91
|
+
|
|
92
|
+
**Per-field anomaly mute** keeps working — it now lives inside the
|
|
93
|
+
generic SubscriptionRow's optional `SubControls` panel
|
|
94
|
+
(`AnomalyFieldMuteList`), exposed through the catalog system detail
|
|
95
|
+
page's notifications card.
|
|
96
|
+
|
|
97
|
+
The catalog system detail page renders a "Notifications" card hosting
|
|
98
|
+
`SystemNotificationSubscriptionsSlot`. The matching group surface is
|
|
99
|
+
not yet rendered — group-level subscriptions are wired end-to-end on
|
|
100
|
+
the backend; a follow-up will add the host UI.
|
|
101
|
+
|
|
102
|
+
**Migration of existing subscribers**: target types declare a
|
|
103
|
+
`legacyGroupIdTemplate`; on first registration of each spec,
|
|
104
|
+
notification-backend reads subscribers from the legacy
|
|
105
|
+
`catalog.system.<id>` / `catalog.group.<id>` groups and seeds the new
|
|
106
|
+
spec groups exactly once per (spec × resource) pair, tracked in
|
|
107
|
+
`subscription_migrations`. Anomaly stays opt-in (its target also
|
|
108
|
+
declares the template, but the user-explicit nature of the original
|
|
109
|
+
opt-in flow means the seeding produces the same set of subscribers
|
|
110
|
+
they already had).
|
|
111
|
+
|
|
112
|
+
### Minor Changes
|
|
113
|
+
|
|
114
|
+
- 32d52c6: feat(anomaly): per-system and per-field notification mute
|
|
115
|
+
|
|
116
|
+
Anomaly notifications now flow through their own subscription group
|
|
117
|
+
(`anomaly.system.<systemId>`) instead of the shared catalog system group, so
|
|
118
|
+
users can opt out of anomaly noise without losing incident or healthcheck
|
|
119
|
+
alerts for the same system. On first deploy, existing subscribers of each
|
|
120
|
+
`catalog.system.<id>` group are seeded onto the new anomaly group so no one
|
|
121
|
+
silently stops getting alerts.
|
|
122
|
+
|
|
123
|
+
A new mute table (`anomaly_notification_mutes`) backs two granularities:
|
|
124
|
+
|
|
125
|
+
- **Per-field**: silence a single noisy metric on one system.
|
|
126
|
+
- **Per-system**: silence every anomaly for one system in one click.
|
|
127
|
+
|
|
128
|
+
The system anomaly widget now exposes a bell icon on each anomaly row plus a
|
|
129
|
+
`Mute all` toggle in the card header. Mutes are user-scoped and persist
|
|
130
|
+
across sessions.
|
|
131
|
+
|
|
132
|
+
Catalog gains a `systemCreated` hook so anomaly (and any future plugin) can
|
|
133
|
+
provision per-system state on creation rather than waiting for a restart.
|
|
134
|
+
The notification service gains a `bulkSubscribe` service-RPC used by the
|
|
135
|
+
one-time migration described above.
|
|
136
|
+
|
|
137
|
+
- 32d52c6: Bulk notifications affecting multiple systems and collapse lifecycle events into a single card.
|
|
138
|
+
|
|
139
|
+
Notifications now carry an optional `subjects` array (the entities they affect) and an optional `collapseKey` (so related notifications collapse into one row per recipient). Incidents, maintenances, anomalies, healthchecks, and dependency-impact events route through these new fields, so an incident affecting three systems produces one in-app notification + one external send per subscriber instead of three. Lifecycle updates for the same entity (created → updated → resolved) also collapse, with an expandable "+N updates" timeline.
|
|
140
|
+
|
|
141
|
+
Subject kinds are namespaced as `<pluginId>.<localKind>` and built via type-safe helpers exported from each domain's common package (`createSystemSubject`, `incidentCollapseKey`, etc.). The frontend kind registry (`registerSubjectKind`) lets plugins bind icon + label for their kinds; unknown kinds fall back to a generic chip.
|
|
142
|
+
|
|
143
|
+
All notification strategies (SMTP, Slack, Discord, Teams, Telegram, Pushover, Gotify, Webex, Backstage) render the affected subjects natively in their format (HTML cards, Slack blocks, Discord embed fields, adaptive cards, markdown lists, etc.).
|
|
144
|
+
|
|
145
|
+
- 32d52c6: feat: unified notification-subscription manager dialog driven by spec registry
|
|
146
|
+
|
|
147
|
+
Replaces the bell-toggle UX (which only managed a single legacy
|
|
148
|
+
catalog group) with a modal that lists every notification type
|
|
149
|
+
registered against a target — system or group — and exposes both
|
|
150
|
+
per-type toggles and a bulk "Subscribe to all / Unsubscribe from all"
|
|
151
|
+
action. Both surfaces (system detail page header bell, dashboard group
|
|
152
|
+
header bell) now open the same `NotificationSubscriptionsManager`
|
|
153
|
+
component.
|
|
154
|
+
|
|
155
|
+
**Key change vs. the prior slot-based approach**: rows are now driven
|
|
156
|
+
by `notificationClient.listSubscriptionSpecs` — the backend's spec
|
|
157
|
+
registry is the single source of truth. Previously, a row only
|
|
158
|
+
appeared if a frontend plugin had remembered to register a
|
|
159
|
+
`createNotificationSubscriptionExtension`; this caused silent drift
|
|
160
|
+
(healthcheck and dependency registered backend specs without frontend
|
|
161
|
+
extensions, so the dialog counted them but never rendered rows). Now,
|
|
162
|
+
every spec the platform knows about renders a row using the spec's
|
|
163
|
+
`display` metadata (title, description, iconName resolved via
|
|
164
|
+
`DynamicIcon`).
|
|
165
|
+
|
|
166
|
+
**Sub-controls registry** (`@checkstack/notification-frontend`):
|
|
167
|
+
plugins that want sub-granularity (anomaly's per-field mute list,
|
|
168
|
+
future severity / channel filters) call
|
|
169
|
+
`registerSubscriptionSubControls(spec, Component)` at module load —
|
|
170
|
+
the manager looks the component up by `specId` when expanding a row.
|
|
171
|
+
|
|
172
|
+
**Removed (no compat)**:
|
|
173
|
+
|
|
174
|
+
- `createNotificationSubscriptionExtension` (replaced by the
|
|
175
|
+
spec-driven manager + the SubControls registry)
|
|
176
|
+
- `target.slot` field on `NotificationTarget` and the
|
|
177
|
+
`NotificationTargetInput.slot` parameter on
|
|
178
|
+
`defineNotificationTarget`
|
|
179
|
+
- `SystemNotificationSubscriptionsSlot` and
|
|
180
|
+
`GroupNotificationSubscriptionsSlot` from `@checkstack/catalog-common`
|
|
181
|
+
- `SystemNotificationsCard` from the system detail page's main column
|
|
182
|
+
- `SubscribeButton` wiring on dashboard group cards and the system
|
|
183
|
+
detail page header
|
|
184
|
+
|
|
185
|
+
**Migrated frontends**: anomaly (now registers `AnomalyFieldMuteList`
|
|
186
|
+
via the SubControls registry), incident, maintenance — all dropped
|
|
187
|
+
their `createNotificationSubscriptionExtension` calls. healthcheck and
|
|
188
|
+
dependency now show up automatically via the spec registry — no
|
|
189
|
+
frontend changes needed for them to render.
|
|
190
|
+
|
|
191
|
+
The trigger button reflects aggregate state — filled bell when at
|
|
192
|
+
least one spec is subscribed for the resource, ghost bell when none.
|
|
193
|
+
|
|
194
|
+
### Patch Changes
|
|
195
|
+
|
|
196
|
+
- 32d52c6: Fix and improve password reset flow + email branding:
|
|
197
|
+
|
|
198
|
+
- **Fix**: password reset emails were failing with "Malformed password reset URL: missing token parameter". Better-auth puts the reset token in the URL path (`/reset-password/{token}`), not as a `?token=` query param, so the previous URL-parsing logic always failed. Now uses the `token` argument better-auth passes to `sendResetPassword` directly.
|
|
199
|
+
- **UX**: the reset password page now validates the token on load via a new anonymous `validateResetToken` endpoint, so users see "Invalid Link" / "Link Expired" before typing a password rather than after submitting. Tokens are 24-char nanoid-style values (~143 bits of entropy), so exposing validity does not enable enumeration.
|
|
200
|
+
- **Fix**: transactional notifications were hardcoded to `importance: "critical"`, causing password reset emails to display a misleading "CRITICAL" badge. The `sendTransactional` contract now accepts an optional `importance` field that defaults to `"info"`.
|
|
201
|
+
- **Branding**: redesigned the email layout (`wrapInEmailLayout`) with a Checkstack-style engineering aesthetic — dark header with grid pattern, monospace importance badge, hardened CTA button (Outlook VML fallback + explicit text color), and force-light color scheme to prevent client auto-inversion from breaking text legibility.
|
|
202
|
+
|
|
3
203
|
## 0.3.0
|
|
4
204
|
|
|
5
205
|
### Minor Changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@checkstack/notification-common",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"exports": {
|
|
6
6
|
".": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@checkstack/common": "0.7.0",
|
|
13
|
-
"@checkstack/signal-common": "0.
|
|
13
|
+
"@checkstack/signal-common": "0.2.0",
|
|
14
14
|
"@orpc/contract": "^1.13.14",
|
|
15
15
|
"zod": "^4.0.0"
|
|
16
16
|
},
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createSubjectKindBuilder,
|
|
4
|
+
createCollapseKeyBuilder,
|
|
5
|
+
} from "./builders";
|
|
6
|
+
import { NotificationSubjectSchema } from "./schemas";
|
|
7
|
+
|
|
8
|
+
describe("createSubjectKindBuilder", () => {
|
|
9
|
+
const pluginMetadata = { pluginId: "catalog" };
|
|
10
|
+
const createSystemSubject = createSubjectKindBuilder(
|
|
11
|
+
pluginMetadata,
|
|
12
|
+
"system",
|
|
13
|
+
);
|
|
14
|
+
|
|
15
|
+
it("namespaces the kind as '<pluginId>.<localKind>'", () => {
|
|
16
|
+
const subject = createSystemSubject({ id: "sys-1", name: "Test" });
|
|
17
|
+
expect(subject.kind).toBe("catalog.system");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("preserves all entity fields", () => {
|
|
21
|
+
const subject = createSystemSubject({
|
|
22
|
+
id: "sys-1",
|
|
23
|
+
name: "Test",
|
|
24
|
+
url: "/systems/sys-1",
|
|
25
|
+
status: "unhealthy",
|
|
26
|
+
});
|
|
27
|
+
expect(subject).toEqual({
|
|
28
|
+
kind: "catalog.system",
|
|
29
|
+
id: "sys-1",
|
|
30
|
+
name: "Test",
|
|
31
|
+
url: "/systems/sys-1",
|
|
32
|
+
status: "unhealthy",
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("produces values that pass NotificationSubjectSchema validation", () => {
|
|
37
|
+
const subject = createSystemSubject({ id: "sys-1", name: "Test" });
|
|
38
|
+
expect(() => NotificationSubjectSchema.parse(subject)).not.toThrow();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("each builder is independent across plugins and local kinds", () => {
|
|
42
|
+
const incidentSubject = createSubjectKindBuilder(
|
|
43
|
+
{ pluginId: "incident" },
|
|
44
|
+
"incident",
|
|
45
|
+
)({ id: "inc-1", name: "Outage" });
|
|
46
|
+
const groupSubject = createSubjectKindBuilder(
|
|
47
|
+
pluginMetadata,
|
|
48
|
+
"group",
|
|
49
|
+
)({ id: "grp-1", name: "Team" });
|
|
50
|
+
|
|
51
|
+
expect(incidentSubject.kind).toBe("incident.incident");
|
|
52
|
+
expect(groupSubject.kind).toBe("catalog.group");
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("createCollapseKeyBuilder", () => {
|
|
57
|
+
const pluginMetadata = { pluginId: "incident" };
|
|
58
|
+
const incidentCollapseKey = createCollapseKeyBuilder(
|
|
59
|
+
pluginMetadata,
|
|
60
|
+
"incident",
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
it("returns '<pluginId>.<localKind>.<entityId>' for single ids", () => {
|
|
64
|
+
expect(incidentCollapseKey("inc-1")).toBe("incident.incident.inc-1");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("joins multiple id components with dots", () => {
|
|
68
|
+
const anomalyKey = createCollapseKeyBuilder(
|
|
69
|
+
{ pluginId: "anomaly" },
|
|
70
|
+
"anomaly",
|
|
71
|
+
);
|
|
72
|
+
expect(anomalyKey("sys-1", "cpu.usage")).toBe(
|
|
73
|
+
"anomaly.anomaly.sys-1.cpu.usage",
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("uses the same prefix for every call", () => {
|
|
78
|
+
const a = incidentCollapseKey("inc-1");
|
|
79
|
+
const b = incidentCollapseKey("inc-2");
|
|
80
|
+
expect(a.split(".").slice(0, 2)).toEqual(b.split(".").slice(0, 2));
|
|
81
|
+
});
|
|
82
|
+
});
|
package/src/builders.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import type { NotificationSubject } from "./schemas";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Plugin-metadata shape relied on by the builders below. Mirrors the
|
|
5
|
+
* `pluginId` field on the `definePluginMetadata` return type without
|
|
6
|
+
* importing the rest of `@checkstack/common` into this file.
|
|
7
|
+
*/
|
|
8
|
+
interface PluginMetadataLike {
|
|
9
|
+
pluginId: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Returns a typed builder that produces `NotificationSubject` instances for
|
|
14
|
+
* a specific plugin's local entity kind. The resulting `kind` string is
|
|
15
|
+
* always namespaced as `<pluginId>.<localKind>`, so callers can never
|
|
16
|
+
* forget the prefix and the namespace updates automatically if a plugin's
|
|
17
|
+
* id changes.
|
|
18
|
+
*
|
|
19
|
+
* Example:
|
|
20
|
+
* ```ts
|
|
21
|
+
* import { createSubjectKindBuilder } from "@checkstack/notification-common";
|
|
22
|
+
* import { pluginMetadata } from "./plugin-metadata";
|
|
23
|
+
*
|
|
24
|
+
* export const createSystemSubject = createSubjectKindBuilder(
|
|
25
|
+
* pluginMetadata,
|
|
26
|
+
* "system",
|
|
27
|
+
* );
|
|
28
|
+
*
|
|
29
|
+
* // Elsewhere:
|
|
30
|
+
* const subject = createSystemSubject({
|
|
31
|
+
* id: "sys-1",
|
|
32
|
+
* name: "Production DB",
|
|
33
|
+
* url: "/system/sys-1",
|
|
34
|
+
* });
|
|
35
|
+
* // -> { kind: "catalog.system", id: "sys-1", name: "Production DB", url: ... }
|
|
36
|
+
* ```
|
|
37
|
+
*/
|
|
38
|
+
export function createSubjectKindBuilder(
|
|
39
|
+
pluginMetadata: PluginMetadataLike,
|
|
40
|
+
localKind: string,
|
|
41
|
+
): (props: {
|
|
42
|
+
id: string;
|
|
43
|
+
name: string;
|
|
44
|
+
url?: string;
|
|
45
|
+
status?: NotificationSubject["status"];
|
|
46
|
+
}) => NotificationSubject {
|
|
47
|
+
const kind = `${pluginMetadata.pluginId}.${localKind}`;
|
|
48
|
+
return (props) => ({
|
|
49
|
+
kind,
|
|
50
|
+
id: props.id,
|
|
51
|
+
name: props.name,
|
|
52
|
+
url: props.url,
|
|
53
|
+
status: props.status,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Returns a builder that produces collapse keys for a specific plugin's
|
|
59
|
+
* local entity kind. Output is `<pluginId>.<localKind>.<entityId>`, the
|
|
60
|
+
* convention used by the frontend to collapse related notifications into a
|
|
61
|
+
* single card.
|
|
62
|
+
*
|
|
63
|
+
* Example:
|
|
64
|
+
* ```ts
|
|
65
|
+
* import { createCollapseKeyBuilder } from "@checkstack/notification-common";
|
|
66
|
+
* import { pluginMetadata } from "./plugin-metadata";
|
|
67
|
+
*
|
|
68
|
+
* export const incidentCollapseKey = createCollapseKeyBuilder(
|
|
69
|
+
* pluginMetadata,
|
|
70
|
+
* "incident",
|
|
71
|
+
* );
|
|
72
|
+
*
|
|
73
|
+
* // Elsewhere:
|
|
74
|
+
* incidentCollapseKey("inc-42"); // -> "incident.incident.inc-42"
|
|
75
|
+
* ```
|
|
76
|
+
*
|
|
77
|
+
* The variadic suffix lets you scope keys further when the entity isn't a
|
|
78
|
+
* single id (e.g., `(systemId, fieldPath)` for an anomaly).
|
|
79
|
+
*/
|
|
80
|
+
export function createCollapseKeyBuilder(
|
|
81
|
+
pluginMetadata: PluginMetadataLike,
|
|
82
|
+
localKind: string,
|
|
83
|
+
): (...entityIds: [string, ...string[]]) => string {
|
|
84
|
+
const prefix = `${pluginMetadata.pluginId}.${localKind}`;
|
|
85
|
+
return (...entityIds) => `${prefix}.${entityIds.join(".")}`;
|
|
86
|
+
}
|
package/src/index.ts
CHANGED
package/src/rpc-contract.ts
CHANGED
|
@@ -8,8 +8,64 @@ import {
|
|
|
8
8
|
EnrichedSubscriptionSchema,
|
|
9
9
|
RetentionSettingsSchema,
|
|
10
10
|
PaginationInputSchema,
|
|
11
|
+
NotificationSubjectSchema,
|
|
11
12
|
} from "./schemas";
|
|
12
13
|
|
|
14
|
+
// Shared input fragments for the notify* procedures.
|
|
15
|
+
const NotificationActionInput = z
|
|
16
|
+
.object({
|
|
17
|
+
label: z.string(),
|
|
18
|
+
url: z.string(),
|
|
19
|
+
})
|
|
20
|
+
.optional();
|
|
21
|
+
const NotificationCollapseKeyInput = z
|
|
22
|
+
.string()
|
|
23
|
+
.min(1)
|
|
24
|
+
.optional()
|
|
25
|
+
.describe(
|
|
26
|
+
"Optional collapse key. Notifications with the same (userId, collapseKey) collapse into one card on the frontend. Convention: '<pluginId>.<entityKind>.<entityId>'.",
|
|
27
|
+
);
|
|
28
|
+
const NotificationSubjectsInput = z
|
|
29
|
+
.array(NotificationSubjectSchema)
|
|
30
|
+
.min(1)
|
|
31
|
+
.describe(
|
|
32
|
+
"Affected entities. Required so every dispatched notification can be cross-referenced with its subscription spec / resource. Renders as chips in-app and as native rich elements per strategy.",
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
// ─── Subscription-spec / target contract types ───────────────────────────────
|
|
36
|
+
|
|
37
|
+
const SubscriptionDisplaySchema = z.object({
|
|
38
|
+
title: z.string(),
|
|
39
|
+
description: z.string(),
|
|
40
|
+
iconName: z.string().optional(),
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const SubscriptionSpecRecordSchema = z.object({
|
|
44
|
+
specId: z.string(),
|
|
45
|
+
ownerPlugin: z.string(),
|
|
46
|
+
localId: z.string(),
|
|
47
|
+
targetTypeId: z.string(),
|
|
48
|
+
display: SubscriptionDisplaySchema,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const NotificationTargetRecordSchema = z.object({
|
|
52
|
+
targetTypeId: z.string(),
|
|
53
|
+
ownerPlugin: z.string(),
|
|
54
|
+
resourceKind: z.string(),
|
|
55
|
+
parentTargetTypeId: z.string().optional(),
|
|
56
|
+
legacyGroupIdTemplate: z
|
|
57
|
+
.string()
|
|
58
|
+
.optional()
|
|
59
|
+
.describe(
|
|
60
|
+
"Template like 'catalog.system.{resourceKey}'. Backend substitutes {resourceKey} once per (spec × resource) to seed initial subscribers.",
|
|
61
|
+
),
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const NotificationResourceSchema = z.object({
|
|
65
|
+
resourceKey: z.string(),
|
|
66
|
+
displayLabel: z.string(),
|
|
67
|
+
});
|
|
68
|
+
|
|
13
69
|
// Notification RPC Contract
|
|
14
70
|
export const notificationContract = {
|
|
15
71
|
// ==========================================================================
|
|
@@ -95,6 +151,22 @@ export const notificationContract = {
|
|
|
95
151
|
.input(z.object({ groupId: z.string() }))
|
|
96
152
|
.output(z.void()),
|
|
97
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Bulk subscription-status lookup for the current user. Used by the
|
|
156
|
+
* generic `<SubscriptionRow>` component when several specs from
|
|
157
|
+
* different plugins render against the same resource — each spec's row
|
|
158
|
+
* needs to know "am I subscribed to this groupId?" but doing N
|
|
159
|
+
* roundtrips would be wasteful. Pass every candidate groupId in one
|
|
160
|
+
* call and receive a map back.
|
|
161
|
+
*/
|
|
162
|
+
getMySubscriptionStatus: proc({
|
|
163
|
+
operationType: "query",
|
|
164
|
+
userType: "user",
|
|
165
|
+
access: [],
|
|
166
|
+
})
|
|
167
|
+
.input(z.object({ groupIds: z.array(z.string()) }))
|
|
168
|
+
.output(z.record(z.string(), z.boolean())),
|
|
169
|
+
|
|
98
170
|
// ==========================================================================
|
|
99
171
|
// ADMIN SETTINGS ENDPOINTS (userType: "user" with admin access)
|
|
100
172
|
// ==========================================================================
|
|
@@ -179,49 +251,195 @@ export const notificationContract = {
|
|
|
179
251
|
)
|
|
180
252
|
.output(z.object({ userIds: z.array(z.string()) })),
|
|
181
253
|
|
|
182
|
-
|
|
183
|
-
|
|
254
|
+
/**
|
|
255
|
+
* Subscribe a batch of users to a group in one call. Used by plugins
|
|
256
|
+
* during bootstrap/migration when establishing default subscribers for
|
|
257
|
+
* a newly-introduced group (e.g. mirroring existing catalog system
|
|
258
|
+
* subscribers onto a derived anomaly group). Idempotent.
|
|
259
|
+
*/
|
|
260
|
+
bulkSubscribe: proc({
|
|
184
261
|
operationType: "mutation",
|
|
185
262
|
userType: "service",
|
|
186
263
|
access: [],
|
|
187
264
|
})
|
|
188
265
|
.input(
|
|
189
266
|
z.object({
|
|
267
|
+
groupId: z.string().describe("Full namespaced group ID"),
|
|
190
268
|
userIds: z.array(z.string()),
|
|
191
|
-
title: z.string(),
|
|
192
|
-
body: z.string().describe("Notification body (supports markdown)"),
|
|
193
|
-
importance: z.enum(["info", "warning", "critical"]).optional(),
|
|
194
|
-
action: z
|
|
195
|
-
.object({
|
|
196
|
-
label: z.string(),
|
|
197
|
-
url: z.string(),
|
|
198
|
-
})
|
|
199
|
-
.optional(),
|
|
200
269
|
})
|
|
201
270
|
)
|
|
202
|
-
.output(z.object({
|
|
271
|
+
.output(z.object({ subscribedCount: z.number() })),
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Register (or update) a notification target type. Target owners call
|
|
275
|
+
* this on startup. notification-backend persists the metadata + tracks
|
|
276
|
+
* registered targets so it can route resource lifecycle events and
|
|
277
|
+
* resolve dispatch parents. Idempotent.
|
|
278
|
+
*/
|
|
279
|
+
registerNotificationTarget: proc({
|
|
280
|
+
operationType: "mutation",
|
|
281
|
+
userType: "service",
|
|
282
|
+
access: [],
|
|
283
|
+
})
|
|
284
|
+
.input(NotificationTargetRecordSchema)
|
|
285
|
+
.output(z.object({ success: z.boolean() })),
|
|
286
|
+
|
|
287
|
+
/** Lists every registered target type — used by audit/settings UIs. */
|
|
288
|
+
listNotificationTargets: proc({
|
|
289
|
+
operationType: "query",
|
|
290
|
+
userType: "authenticated",
|
|
291
|
+
access: [],
|
|
292
|
+
}).output(z.array(NotificationTargetRecordSchema)),
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Push (or refresh) a single resource of a target type. Owners call
|
|
296
|
+
* this on resource creation and on rename. notification-backend
|
|
297
|
+
* provisions a notification group for every registered spec whose
|
|
298
|
+
* target matches, runs the legacy-migration seed if declared, and
|
|
299
|
+
* stores the display label for audit UIs.
|
|
300
|
+
*/
|
|
301
|
+
upsertNotificationResource: proc({
|
|
302
|
+
operationType: "mutation",
|
|
303
|
+
userType: "service",
|
|
304
|
+
access: [],
|
|
305
|
+
})
|
|
306
|
+
.input(
|
|
307
|
+
z.object({
|
|
308
|
+
targetTypeId: z.string(),
|
|
309
|
+
resource: NotificationResourceSchema,
|
|
310
|
+
}),
|
|
311
|
+
)
|
|
312
|
+
.output(z.object({ success: z.boolean() })),
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Bulk variant. Used by target owners on platform startup to seed all
|
|
316
|
+
* existing resources at once without N round-trips.
|
|
317
|
+
*/
|
|
318
|
+
upsertNotificationResources: proc({
|
|
319
|
+
operationType: "mutation",
|
|
320
|
+
userType: "service",
|
|
321
|
+
access: [],
|
|
322
|
+
})
|
|
323
|
+
.input(
|
|
324
|
+
z.object({
|
|
325
|
+
targetTypeId: z.string(),
|
|
326
|
+
resources: z.array(NotificationResourceSchema),
|
|
327
|
+
}),
|
|
328
|
+
)
|
|
329
|
+
.output(z.object({ upserted: z.number() })),
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Remove a resource — notification-backend deletes every group derived
|
|
333
|
+
* from it across every registered spec whose target matches.
|
|
334
|
+
*/
|
|
335
|
+
removeNotificationResource: proc({
|
|
336
|
+
operationType: "mutation",
|
|
337
|
+
userType: "service",
|
|
338
|
+
access: [],
|
|
339
|
+
})
|
|
340
|
+
.input(
|
|
341
|
+
z.object({
|
|
342
|
+
targetTypeId: z.string(),
|
|
343
|
+
resourceKey: z.string(),
|
|
344
|
+
}),
|
|
345
|
+
)
|
|
346
|
+
.output(z.object({ removedGroups: z.number() })),
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Replace the full parent set for a child resource. Owners call this
|
|
350
|
+
* whenever a child's parents change — catalog calls it on
|
|
351
|
+
* `addSystemToGroup` / `removeSystemFromGroup` / system create. The
|
|
352
|
+
* dispatcher reads these edges (plus the spec→target mapping) at
|
|
353
|
+
* dispatch time to compute inherited group ids without re-walking
|
|
354
|
+
* through the owner.
|
|
355
|
+
*/
|
|
356
|
+
setNotificationResourceParents: proc({
|
|
357
|
+
operationType: "mutation",
|
|
358
|
+
userType: "service",
|
|
359
|
+
access: [],
|
|
360
|
+
})
|
|
361
|
+
.input(
|
|
362
|
+
z.object({
|
|
363
|
+
childTargetTypeId: z.string(),
|
|
364
|
+
childResourceKey: z.string(),
|
|
365
|
+
parents: z.array(
|
|
366
|
+
z.object({
|
|
367
|
+
parentTargetTypeId: z.string(),
|
|
368
|
+
parentResourceKey: z.string(),
|
|
369
|
+
}),
|
|
370
|
+
),
|
|
371
|
+
}),
|
|
372
|
+
)
|
|
373
|
+
.output(z.object({ success: z.boolean() })),
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* List known resources for a target type. Read-only convenience used
|
|
377
|
+
* by the settings page audit and by the spec-registration flow during
|
|
378
|
+
* group provisioning.
|
|
379
|
+
*/
|
|
380
|
+
listNotificationResources: proc({
|
|
381
|
+
operationType: "query",
|
|
382
|
+
userType: "authenticated",
|
|
383
|
+
access: [],
|
|
384
|
+
})
|
|
385
|
+
.input(z.object({ targetTypeId: z.string() }))
|
|
386
|
+
.output(z.array(NotificationResourceSchema)),
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Register (or update) a notification subscription spec. Plugins call
|
|
390
|
+
* this on startup once per spec they own. notification-backend joins
|
|
391
|
+
* the spec against every existing resource of `targetTypeId` and
|
|
392
|
+
* provisions per-resource groups. Idempotent.
|
|
393
|
+
*/
|
|
394
|
+
registerSubscriptionSpec: proc({
|
|
395
|
+
operationType: "mutation",
|
|
396
|
+
userType: "service",
|
|
397
|
+
access: [],
|
|
398
|
+
})
|
|
399
|
+
.input(SubscriptionSpecRecordSchema)
|
|
400
|
+
.output(z.object({ success: z.boolean() })),
|
|
203
401
|
|
|
204
|
-
|
|
205
|
-
|
|
402
|
+
/**
|
|
403
|
+
* Returns every currently-registered spec. Used by the settings UI to
|
|
404
|
+
* decorate subscription rows with display metadata even for plugins
|
|
405
|
+
* whose frontend isn't loaded.
|
|
406
|
+
*/
|
|
407
|
+
listSubscriptionSpecs: proc({
|
|
408
|
+
operationType: "query",
|
|
409
|
+
userType: "authenticated",
|
|
410
|
+
access: [],
|
|
411
|
+
}).output(z.array(SubscriptionSpecRecordSchema)),
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* The sanctioned dispatch path. Caller supplies a registered specId
|
|
415
|
+
* and one or more resource keys; notification-backend resolves
|
|
416
|
+
* primary group ids, walks the target's parent chain to compute
|
|
417
|
+
* inherited group ids (joined against the same plugin's specs whose
|
|
418
|
+
* target matches the parent target), unions subscribers, applies
|
|
419
|
+
* `excludeUserIds`, and delivers.
|
|
420
|
+
*
|
|
421
|
+
* Enforcement:
|
|
422
|
+
* - specId must exist and be owned by the calling service plugin.
|
|
423
|
+
* - resourceKeys must reference resources currently registered for
|
|
424
|
+
* the spec's target — backend rejects unknown keys.
|
|
425
|
+
*/
|
|
426
|
+
notifyForSubscription: proc({
|
|
206
427
|
operationType: "mutation",
|
|
207
428
|
userType: "service",
|
|
208
429
|
access: [],
|
|
209
430
|
})
|
|
210
431
|
.input(
|
|
211
432
|
z.object({
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
433
|
+
specId: z.string(),
|
|
434
|
+
resourceKeys: z.array(z.string()).min(1),
|
|
435
|
+
excludeUserIds: z.array(z.string()).optional(),
|
|
215
436
|
title: z.string(),
|
|
216
437
|
body: z.string().describe("Notification body (supports markdown)"),
|
|
217
438
|
importance: z.enum(["info", "warning", "critical"]).optional(),
|
|
218
|
-
action:
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
})
|
|
223
|
-
.optional(),
|
|
224
|
-
})
|
|
439
|
+
action: NotificationActionInput,
|
|
440
|
+
collapseKey: NotificationCollapseKeyInput,
|
|
441
|
+
subjects: NotificationSubjectsInput,
|
|
442
|
+
}),
|
|
225
443
|
)
|
|
226
444
|
.output(z.object({ notifiedCount: z.number() })),
|
|
227
445
|
|
|
@@ -237,6 +455,10 @@ export const notificationContract = {
|
|
|
237
455
|
notification: z.object({
|
|
238
456
|
title: z.string(),
|
|
239
457
|
body: z.string().describe("Notification body (supports markdown)"),
|
|
458
|
+
importance: z
|
|
459
|
+
.enum(["info", "warning", "critical"])
|
|
460
|
+
.optional()
|
|
461
|
+
.describe("Severity of the message; defaults to 'info'"),
|
|
240
462
|
action: z
|
|
241
463
|
.object({
|
|
242
464
|
label: z.string(),
|
package/src/schemas.ts
CHANGED
|
@@ -11,6 +11,47 @@ export const NotificationActionSchema = z.object({
|
|
|
11
11
|
});
|
|
12
12
|
export type NotificationAction = z.infer<typeof NotificationActionSchema>;
|
|
13
13
|
|
|
14
|
+
/**
|
|
15
|
+
* An entity affected by a notification (e.g., a system impacted by an
|
|
16
|
+
* incident, or a system on which a shared healthcheck is failing).
|
|
17
|
+
*
|
|
18
|
+
* Subjects render as chips in-app and as native rich elements per
|
|
19
|
+
* notification strategy (e.g., Slack section, Discord embed field, SMTP
|
|
20
|
+
* card). When a notification has no canonical parent CTA (`action` is
|
|
21
|
+
* null), the subjects' URLs are the user's only navigation path.
|
|
22
|
+
*
|
|
23
|
+
* `kind` is an open string so plugins can introduce their own subject
|
|
24
|
+
* types. To prevent clashes between plugins, kinds MUST be namespaced as
|
|
25
|
+
* `<pluginId>.<localKind>` (e.g., `catalog.system`, `incident.incident`,
|
|
26
|
+
* `healthcheck.healthcheck`). Frontend rendering metadata (icon, label)
|
|
27
|
+
* is provided via the plugin-extensible subject-kind registry in
|
|
28
|
+
* `@checkstack/notification-frontend`; unknown kinds fall back to a
|
|
29
|
+
* generic chip with the subject's `name`.
|
|
30
|
+
*/
|
|
31
|
+
const NAMESPACED_KIND_PATTERN = /^[a-z][a-z0-9_-]*\.[a-z][a-z0-9_-]*$/;
|
|
32
|
+
|
|
33
|
+
export const NotificationSubjectSchema = z.object({
|
|
34
|
+
/**
|
|
35
|
+
* Plugin-namespaced discriminator: `<pluginId>.<localKind>`.
|
|
36
|
+
* Plugins register icon/label for their kinds in the frontend registry.
|
|
37
|
+
*/
|
|
38
|
+
kind: z.string().regex(NAMESPACED_KIND_PATTERN, {
|
|
39
|
+
message: "Subject kind must be namespaced as '<pluginId>.<localKind>'",
|
|
40
|
+
}),
|
|
41
|
+
/** Stable identifier for the subject within its kind. */
|
|
42
|
+
id: z.string(),
|
|
43
|
+
/** Human-readable display name. */
|
|
44
|
+
name: z.string(),
|
|
45
|
+
/** Optional deep link to the subject. Recipients without UI access (text channels) just show the name. */
|
|
46
|
+
url: z.string().optional(),
|
|
47
|
+
/**
|
|
48
|
+
* Optional health/status hint, used to color the chip and add an icon.
|
|
49
|
+
* Strategies that cannot render color simply ignore it.
|
|
50
|
+
*/
|
|
51
|
+
status: z.enum(["healthy", "unhealthy", "degraded", "unknown"]).optional(),
|
|
52
|
+
});
|
|
53
|
+
export type NotificationSubject = z.infer<typeof NotificationSubjectSchema>;
|
|
54
|
+
|
|
14
55
|
// Core notification schema
|
|
15
56
|
export const NotificationSchema = z.object({
|
|
16
57
|
id: z.string().uuid(),
|
|
@@ -22,7 +63,14 @@ export const NotificationSchema = z.object({
|
|
|
22
63
|
action: NotificationActionSchema.optional(),
|
|
23
64
|
importance: ImportanceSchema,
|
|
24
65
|
isRead: z.boolean(),
|
|
25
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Collapse key shared by related notifications. Frontend collapses rows
|
|
68
|
+
* with the same (userId, collapseKey) into one card with a "+N updates"
|
|
69
|
+
* badge. Examples: "incident.<id>", "healthcheck.<id>".
|
|
70
|
+
*/
|
|
71
|
+
collapseKey: z.string().optional(),
|
|
72
|
+
/** Affected entities (renders as chips / native rich elements). */
|
|
73
|
+
subjects: z.array(NotificationSubjectSchema).optional(),
|
|
26
74
|
createdAt: z.coerce.date(),
|
|
27
75
|
});
|
|
28
76
|
export type Notification = z.infer<typeof NotificationSchema>;
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-plugin notification-subscription pattern.
|
|
3
|
+
*
|
|
4
|
+
* A subscription spec describes one *kind* of notification a plugin
|
|
5
|
+
* offers (e.g. anomaly's "alert me about this system"). The spec
|
|
6
|
+
* references a `NotificationTarget` — a typed handle on a resource type
|
|
7
|
+
* (e.g. `catalogSystemTarget`) — and supplies display metadata. The
|
|
8
|
+
* platform handles everything else:
|
|
9
|
+
*
|
|
10
|
+
* - notification-backend materializes one notification group per
|
|
11
|
+
* (registered spec × known resource of the spec's target type),
|
|
12
|
+
* keyed `<ownerPlugin>.<spec.localId>.<resourceKey>`.
|
|
13
|
+
* - The frontend extension factory derives the slot from
|
|
14
|
+
* `spec.target.slot` so plugin authors never re-pass it.
|
|
15
|
+
* - At dispatch time, callers supply `(specId, resourceKeys)`;
|
|
16
|
+
* notification-backend resolves group ids, walks the target's
|
|
17
|
+
* `parents` chain for inheritance, and delivers.
|
|
18
|
+
*
|
|
19
|
+
* Plugin-author surface area is intentionally minimal: a `target`
|
|
20
|
+
* reference, a `localId`, and display metadata. No groupIdFor, no
|
|
21
|
+
* resourceKind (carried by the target), no slot at the registration
|
|
22
|
+
* site, no per-plugin lifecycle code.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import type { NotificationTarget } from "./targets";
|
|
26
|
+
|
|
27
|
+
interface PluginMetadataLike {
|
|
28
|
+
pluginId: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SubscriptionDisplayMeta {
|
|
32
|
+
title: string;
|
|
33
|
+
description: string;
|
|
34
|
+
iconName?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Declarative description of a subscription a plugin offers for the
|
|
39
|
+
* resources of a given target. Group ids are derived from the
|
|
40
|
+
* convention `<ownerPlugin>.<spec.localId>.<resourceKey>` — there is no
|
|
41
|
+
* escape hatch. Plugins that need an exotic group structure should
|
|
42
|
+
* register a different target type instead.
|
|
43
|
+
*/
|
|
44
|
+
export interface NotificationSubscriptionSpec<TResource> {
|
|
45
|
+
/** `<ownerPlugin>.<localId>` — namespaced like the target ids. */
|
|
46
|
+
readonly specId: string;
|
|
47
|
+
readonly ownerPlugin: string;
|
|
48
|
+
readonly localId: string;
|
|
49
|
+
readonly target: NotificationTarget<TResource>;
|
|
50
|
+
readonly display: SubscriptionDisplayMeta;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Derive the namespaced groupId for a given spec + resourceKey. Single
|
|
55
|
+
* source of truth — both notification-backend and dispatch callers go
|
|
56
|
+
* through this helper. Defined here in common so plugin code never
|
|
57
|
+
* computes it.
|
|
58
|
+
*/
|
|
59
|
+
export function subscriptionGroupId(
|
|
60
|
+
spec: { ownerPlugin: string; localId: string },
|
|
61
|
+
resourceKey: string,
|
|
62
|
+
): string {
|
|
63
|
+
return `${spec.ownerPlugin}.${spec.localId}.${resourceKey}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface SubscriptionSpecInput<TResource> {
|
|
67
|
+
localId: string;
|
|
68
|
+
target: NotificationTarget<TResource>;
|
|
69
|
+
display: SubscriptionDisplayMeta;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Bind a plugin's id once and return a `defineSubscription` helper that
|
|
74
|
+
* stamps every spec with `<pluginId>.<localId>`. Mirrors the existing
|
|
75
|
+
* factory pattern in `builders.ts`.
|
|
76
|
+
*/
|
|
77
|
+
export function createSubscriptionFactory(pluginMetadata: PluginMetadataLike) {
|
|
78
|
+
const pluginId = pluginMetadata.pluginId;
|
|
79
|
+
|
|
80
|
+
function defineSubscription<TResource>(
|
|
81
|
+
input: SubscriptionSpecInput<TResource>,
|
|
82
|
+
): NotificationSubscriptionSpec<TResource> {
|
|
83
|
+
if (input.localId.includes(".")) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
`Subscription localId must not contain '.', got ${JSON.stringify(input.localId)}`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
specId: `${pluginId}.${input.localId}`,
|
|
90
|
+
ownerPlugin: pluginId,
|
|
91
|
+
localId: input.localId,
|
|
92
|
+
target: input.target,
|
|
93
|
+
display: input.display,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { defineSubscription };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Wire-format for spec registration. Includes the target type id so the
|
|
102
|
+
* backend can join known resources of that target onto the spec when
|
|
103
|
+
* provisioning groups, without re-walking type info.
|
|
104
|
+
*/
|
|
105
|
+
export interface RegisteredSubscriptionSpecRecord {
|
|
106
|
+
specId: string;
|
|
107
|
+
ownerPlugin: string;
|
|
108
|
+
localId: string;
|
|
109
|
+
targetTypeId: string;
|
|
110
|
+
display: SubscriptionDisplayMeta;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function specToRegistration<TResource>(
|
|
114
|
+
spec: NotificationSubscriptionSpec<TResource>,
|
|
115
|
+
): RegisteredSubscriptionSpecRecord {
|
|
116
|
+
return {
|
|
117
|
+
specId: spec.specId,
|
|
118
|
+
ownerPlugin: spec.ownerPlugin,
|
|
119
|
+
localId: spec.localId,
|
|
120
|
+
targetTypeId: spec.target.targetTypeId,
|
|
121
|
+
display: spec.display,
|
|
122
|
+
};
|
|
123
|
+
}
|
package/src/targets.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notification target types — the platform abstraction that lets emitting
|
|
3
|
+
* plugins declare *what kind of resource* their subscriptions are about
|
|
4
|
+
* without dealing with notification-group lifecycle themselves.
|
|
5
|
+
*
|
|
6
|
+
* A target type ("system", "group", future kinds…) is owned by exactly
|
|
7
|
+
* one plugin. The owner:
|
|
8
|
+
* 1. Defines + exports the target object via `defineNotificationTarget`.
|
|
9
|
+
* 2. Tells notification-backend about every resource of this type that
|
|
10
|
+
* exists (`upsertNotificationResource` on creation/rename,
|
|
11
|
+
* `removeNotificationResource` on deletion).
|
|
12
|
+
* 3. Renders an extension slot on the resource's detail page so emitting
|
|
13
|
+
* plugins can drop their subscription rows in.
|
|
14
|
+
*
|
|
15
|
+
* notification-backend does the rest:
|
|
16
|
+
* - Materializes one notification group per (registered spec × known
|
|
17
|
+
* resource), keyed `<emitterPluginId>.<spec.localId>.<resourceKey>`.
|
|
18
|
+
* - Provisions/cleans up these groups automatically as resources and
|
|
19
|
+
* specs come and go.
|
|
20
|
+
* - At dispatch time, resolves spec + resourceKey(s) into the actual
|
|
21
|
+
* group ids (primary + inherited via the target's `parents` chain),
|
|
22
|
+
* unions subscribers, and delivers.
|
|
23
|
+
*
|
|
24
|
+
* Emitting plugins (anomaly, incident, maintenance, healthcheck, …) stop
|
|
25
|
+
* carrying their own per-system / per-group lifecycle — they reference
|
|
26
|
+
* a target object and supply display metadata. Convention-driven, typed,
|
|
27
|
+
* and centralized.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
interface PluginMetadataLike {
|
|
31
|
+
pluginId: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Declarative parent reference. The target owner declares what type
|
|
36
|
+
* its parent resources have ("catalogSystemTarget's parents are
|
|
37
|
+
* catalogGroupTarget"); the actual parent edges live in
|
|
38
|
+
* notification-backend's `notification_resource_parents` table and are
|
|
39
|
+
* kept in sync by the target owner via `setNotificationResourceParents`
|
|
40
|
+
* (called whenever a child's parent set changes — e.g. addSystemToGroup
|
|
41
|
+
* in catalog).
|
|
42
|
+
*
|
|
43
|
+
* No callback at dispatch time — notification-backend reads the edges
|
|
44
|
+
* from its own DB. Keeps dispatch fully server-local.
|
|
45
|
+
*/
|
|
46
|
+
export interface NotificationTargetParents {
|
|
47
|
+
targetTypeId: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Backwards-compatibility hook. If users were already subscribed to a
|
|
52
|
+
* legacy notification group (e.g. catalog's pre-pattern
|
|
53
|
+
* `catalog.system.<id>` group), the target type declares the template.
|
|
54
|
+
* notification-backend substitutes `{resourceKey}` and seeds new spec
|
|
55
|
+
* groups from the legacy group's subscribers — exactly once per
|
|
56
|
+
* (spec × resource) pair, tracked in `subscription_migrations`.
|
|
57
|
+
*
|
|
58
|
+
* Encoded as a string so the backend can run it without a callback to
|
|
59
|
+
* the target owner.
|
|
60
|
+
*/
|
|
61
|
+
export interface NotificationTargetLegacyMigration {
|
|
62
|
+
/** e.g. `"catalog.system.{resourceKey}"`. */
|
|
63
|
+
legacyGroupIdTemplate: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The on-disk representation of a known resource. Plugins owning a
|
|
68
|
+
* target type push these to notification-backend; the backend persists
|
|
69
|
+
* them so the audit/settings UI can render display labels and dispatch
|
|
70
|
+
* can iterate resources without round-tripping the owner.
|
|
71
|
+
*/
|
|
72
|
+
export interface NotificationTargetResourceRecord {
|
|
73
|
+
/** Stable id within the target type. */
|
|
74
|
+
resourceKey: string;
|
|
75
|
+
/** Human-friendly label shown in subscription UIs. */
|
|
76
|
+
displayLabel: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The full shape of a notification target. Plugins consume this via
|
|
81
|
+
* named imports and reference it from their subscription specs — never
|
|
82
|
+
* by string id.
|
|
83
|
+
*/
|
|
84
|
+
export interface NotificationTarget<TResource> {
|
|
85
|
+
/** `<ownerPluginId>.<localId>` — namespacing prevents collisions. */
|
|
86
|
+
readonly targetTypeId: string;
|
|
87
|
+
readonly ownerPlugin: string;
|
|
88
|
+
readonly localId: string;
|
|
89
|
+
readonly resourceKind: string;
|
|
90
|
+
/** Pulls the stable key out of a resource shape — used everywhere. */
|
|
91
|
+
keyOf(resource: TResource): string;
|
|
92
|
+
/**
|
|
93
|
+
* Pulls a human-friendly label out of a resource shape — used for
|
|
94
|
+
* upsertNotificationResource calls and the settings audit page.
|
|
95
|
+
*/
|
|
96
|
+
labelOf(resource: TResource): string;
|
|
97
|
+
/** Optional parent target. Edges populated via setNotificationResourceParents. */
|
|
98
|
+
readonly parents?: NotificationTargetParents;
|
|
99
|
+
/** Optional legacy-migration declaration. Read once at startup. */
|
|
100
|
+
readonly legacy?: NotificationTargetLegacyMigration;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Plugin-bound input to `defineNotificationTarget`. The plugin author
|
|
105
|
+
* supplies a *local* id; the factory namespaces it with the plugin's
|
|
106
|
+
* id, mirroring `createSubscriptionFactory` and the existing
|
|
107
|
+
* `createCollapseKeyBuilder` pattern.
|
|
108
|
+
*/
|
|
109
|
+
export interface NotificationTargetInput<TResource> {
|
|
110
|
+
pluginMetadata: PluginMetadataLike;
|
|
111
|
+
localId: string;
|
|
112
|
+
resourceKind: string;
|
|
113
|
+
keyOf: NotificationTarget<TResource>["keyOf"];
|
|
114
|
+
labelOf: NotificationTarget<TResource>["labelOf"];
|
|
115
|
+
parents?: NotificationTargetParents;
|
|
116
|
+
legacy?: NotificationTargetLegacyMigration;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function defineNotificationTarget<TResource>(
|
|
120
|
+
input: NotificationTargetInput<TResource>,
|
|
121
|
+
): NotificationTarget<TResource> {
|
|
122
|
+
if (input.localId.includes(".")) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Notification target localId must not contain '.', got ${JSON.stringify(input.localId)}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
targetTypeId: `${input.pluginMetadata.pluginId}.${input.localId}`,
|
|
129
|
+
ownerPlugin: input.pluginMetadata.pluginId,
|
|
130
|
+
localId: input.localId,
|
|
131
|
+
resourceKind: input.resourceKind,
|
|
132
|
+
keyOf: input.keyOf,
|
|
133
|
+
labelOf: input.labelOf,
|
|
134
|
+
parents: input.parents,
|
|
135
|
+
legacy: input.legacy,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Wire-format used when a target owner registers its target type with
|
|
141
|
+
* notification-backend on startup. Excludes the live functions
|
|
142
|
+
* (`keyOf` / `labelOf` / `parents.resolve` / `legacy.legacyGroupIdFor`)
|
|
143
|
+
* because those run in the owner's process — backend only stores
|
|
144
|
+
* metadata.
|
|
145
|
+
*/
|
|
146
|
+
export interface RegisteredNotificationTargetRecord {
|
|
147
|
+
targetTypeId: string;
|
|
148
|
+
ownerPlugin: string;
|
|
149
|
+
resourceKind: string;
|
|
150
|
+
parentTargetTypeId?: string;
|
|
151
|
+
legacyGroupIdTemplate?: string;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function targetToRegistration<TResource>(
|
|
155
|
+
target: NotificationTarget<TResource>,
|
|
156
|
+
): RegisteredNotificationTargetRecord {
|
|
157
|
+
return {
|
|
158
|
+
targetTypeId: target.targetTypeId,
|
|
159
|
+
ownerPlugin: target.ownerPlugin,
|
|
160
|
+
resourceKind: target.resourceKind,
|
|
161
|
+
parentTargetTypeId: target.parents?.targetTypeId,
|
|
162
|
+
legacyGroupIdTemplate: target.legacy?.legacyGroupIdTemplate,
|
|
163
|
+
};
|
|
164
|
+
}
|