@checkstack/healthcheck-backend 1.18.0 → 1.19.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 CHANGED
@@ -1,5 +1,299 @@
1
1
  # @checkstack/healthcheck-backend
2
2
 
3
+ ## 1.19.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 43e4484: Fix an N+1 in the catalog manager: the per-system "Health Checks" count badge
8
+ fired one `getSystemAssociations` request per system row, each holding a pooled
9
+ Postgres connection that contended with the background health-check run
10
+ executor and could exhaust the pool on large catalogs.
11
+
12
+ - Add `getBulkAssignedHealthCheckCounts({ systemIds })` to healthcheck, which
13
+ returns per-system assignment counts (0 for systems with no assignments) from
14
+ ONE grouped `COUNT(*) ... GROUP BY system_id` query. Read authorization
15
+ matches the per-system endpoint it replaces (`configuration.read` +
16
+ `catalog.system` read via `recordKey`), so a team-scoped user only sees counts
17
+ for systems they may read.
18
+ - `CatalogSystemActionsSlot` now passes `visibleSystemIds` (every system id in
19
+ the row's list) so a per-row filler can bulk-fetch for the whole visible set
20
+ in a single deduped request instead of one request per row. This mirrors how
21
+ `CatalogBrowseHealthSlot` / `SystemSignalsSlot` already pass `systemIds`.
22
+ - The health-check count badge now reads its count from that one deduped bulk
23
+ query. N visible rows cause 1 request instead of N.
24
+
25
+ State & scale: the counts are derived on read from the shared
26
+ `system_health_checks` table, so every pod returns the same answer; no
27
+ process-local or duplicated state is introduced.
28
+
29
+ - 43e4484: Name the failing health check in system-health notifications. The notification
30
+ body now names the check that drove the transition (in addition to the system
31
+ and environment), and a `healthcheck.healthcheck` subject is pushed alongside
32
+ the `catalog.system` subject, deep-linked to the check's run history. Recovery
33
+ notifications stay system-level. Adds a `createHealthcheckSubject` builder to
34
+ `healthcheck-common`.
35
+
36
+ Thanks to [@stuajnht](https://github.com/stuajnht) for the valuable feedback.
37
+
38
+ - 43e4484: Status pages can now publish only a subset of catalog environments. The page
39
+ builder gains a "Published environments" picker (empty = all environments, the
40
+ backward-compatible default). When a non-empty set is selected, the page omits
41
+ status, incidents, maintenances and uptime for systems that belong to none of
42
+ the selected environments.
43
+
44
+ - Status pages store an optional `publishedEnvironmentIds` set (new nullable
45
+ `published_environment_ids` column; NULL = all environments, so existing pages
46
+ are unchanged) exposed on `StatusPage`, `createStatusPage`, and
47
+ `updateStatusPage`.
48
+ - The scope is threaded onto `WidgetResolveContext.publishedEnvironmentIds` as
49
+ opaque strings and passed identically to `resolvePublic`,
50
+ `resolveScopedSystems`, and `resolveScopedSystemsDetailed` (and the email
51
+ subscribe clamp + fan-out), so what a page shows, offers for subscription, and
52
+ emails about all agree.
53
+ - Health widgets recompute per environment: they read the per-environment health
54
+ matrix and roll up only the selected environments. `getBulkRunStats` and
55
+ `getRunStats` gain an optional `environmentIds` filter so uptime counts only
56
+ runs recorded in the selected environments.
57
+ - Incident and maintenance widgets filter their feed and scope by intersecting
58
+ each item's affected systems with the environment-visible systems. Incidents
59
+ and maintenance windows carry no environment of their own, so a system in
60
+ several environments makes its items visible on a page publishing ANY of them
61
+ (the multi-environment caveat).
62
+
63
+ ### Patch Changes
64
+
65
+ - 43e4484: fix(healthcheck): disabling an environment for an assignment now clears its stale slice from the rollup and overview immediately
66
+
67
+ Disabling an environment for a health-check assignment (removing it from the
68
+ assignment's `environmentIds`) stopped that environment from fanning out, but a
69
+ check that was FAILING there kept dragging the system health rollup/badge to
70
+ unhealthy and kept showing as a live failing row in the system overview. Because
71
+ the rollup is recomputed by an event-driven consumer subscribed to per-env health
72
+ CHANGES, and a disabled env produces no further runs (so no change event fires),
73
+ the stale unhealthy status was never recomputed away - it only cleared
74
+ incidentally, once the disabled env's runs aged out of the bounded run window
75
+ (which needs the assignment's OTHER active environments to produce enough newer
76
+ runs first). With a single active/failing env, it could persist until retention.
77
+
78
+ Scope: this reconciles environments DISABLED/removed ON THE ASSIGNMENT (its
79
+ `systemHealthChecks.environmentIds` selector - switching to Specific and
80
+ deselecting, or None).
81
+
82
+ Fixes:
83
+
84
+ - The rollup aggregation (`getSystemHealthStatus`) and the per-check status in
85
+ `getSystemHealthOverview` now consider only CURRENTLY-EFFECTIVE environment
86
+ slices, derived from the durable `systemHealthChecks.environmentIds` selector
87
+ (catalog-free, identical on every pod). A slice whose environment was disabled
88
+ for the assignment, or the stale env-less slice of a check that now fans out,
89
+ no longer contributes.
90
+
91
+ Known limitation: under an "all-environments" assignment (`environmentIds` is
92
+ `null`), an environment removed only from the system's CATALOG MEMBERSHIP (rather
93
+ than disabled on the assignment) can still contribute to the backend rollup/badge
94
+ until the assignment is re-evaluated, because the rollup read path is
95
+ intentionally catalog-free for horizontal-scale correctness (it must return the
96
+ same answer on every pod without a per-read catalog lookup). This is pre-existing;
97
+ the frontend overview, which can see membership, still orphans such a slice.
98
+
99
+ - Each environment is now windowed by its OWN query in the rollup, instead of a
100
+ single shared `LIMIT` across the mixed-env pool. The old shared window
101
+ truncated per-env evaluation for checks that fan out to many environments (or
102
+ with large threshold windows); every environment now gets its full evaluation
103
+ depth.
104
+ - Changing an assignment's environment set now triggers an immediate rollup
105
+ recompute for that system, so the persisted `health` entity (badge + SLO
106
+ downtime) converges at once rather than waiting for stale runs to age out.
107
+ - The system-overview frontend tucks a slice whose environment was disabled for
108
+ the assignment under "Old checks" (system membership alone could not detect it,
109
+ since the environment is still part of the system). `getSystemHealthOverview`
110
+ now returns each check's `environmentIds` selector to drive this.
111
+
112
+ Shared pure helpers `selectorIncludesEnvironment` / `isEnvSliceEffective` /
113
+ `selectEffectiveEnvKeys` are added to `@checkstack/healthcheck-common` so the
114
+ backend and frontend agree on effective-slice detection.
115
+
116
+ - 43e4484: Batch hot-path scoped-db reads/writes into single transactions to cut per-query round-trips.
117
+
118
+ The scoped-db proxy wraps every standalone query in its own `BEGIN → SET LOCAL search_path → query → COMMIT`, so a path issuing N sequential queries paid N round-trips and checked out a connection N times. These reads/writes now run under one `withScopedTransaction`, collapsing the batch to a single `SET LOCAL` on one connection. Behavior is unchanged:
119
+
120
+ - healthcheck: `getSystemHealthOverview`'s `1 + N·(2+E)` read fan-out.
121
+ - incident/maintenance: `getIncident`/`getMaintenance` (4 reads), `getManyEntityStates`, `listOpenIncidentsBySystem` / `getActiveMaintenancesBySystem`, `getMaintenanceWindowsForRange`; the `list*` / `*ForSystem` per-row `N+1` system lookups collapsed to a single set-based `inArray` read; maintenance `transitionStatus` update+insert made atomic; `addUpdate`/`editUpdate`/`addLink` use `.returning()` instead of a follow-up re-select.
122
+ - ai: `appendMessage`, memory `saveOrUpdate`.
123
+ - notification: `resolveInheritedGroups`.
124
+ - status-page: subscriber `verify` (4 reads) and `unsubscribe` (3 reads).
125
+ - announcement: `getActiveAnnouncements` / `dismissAnnouncement` / `createAnnouncement`.
126
+ - gitops: `upsertProvenance`.
127
+
128
+ - 43e4484: Eliminate N+1 RPC fan-outs in the public status-page widget resolvers.
129
+
130
+ Each of these widgets renders a PUBLIC page, so every per-item RPC was real
131
+ external DB load. Three bulk-by-id endpoints replace the per-item fetches:
132
+
133
+ - `healthcheck-common`: new `getBulkRunStats({ systemIds, startDate, endDate,
134
+ maxBuckets })` -> `{ stats: Record<systemId, RunStats> }`. The `systemHealth`
135
+ widget's uptime column now issues ONE request for all systems instead of one
136
+ `getRunStats` per system. Systems with no runs in the window are omitted, so
137
+ the resolver's output is unchanged.
138
+ - `incident-common`: new `getBulkIncidentUpdates({ incidentIds })` ->
139
+ `{ updates: Record<incidentId, IncidentUpdate[]> }`. The incidents widget now
140
+ fetches every selected incident's update timeline in ONE request instead of
141
+ one `getIncident` per incident.
142
+ - `maintenance-common`: new `getBulkMaintenanceUpdates({ maintenanceIds })` ->
143
+ `{ updates: Record<maintenanceId, MaintenanceUpdate[]> }` (symmetric with the
144
+ incident endpoint) for the maintenance widget.
145
+
146
+ The new update endpoints apply the same per-item audience filter as
147
+ `getIncident` / `getMaintenance`, so internal/logged-in updates and author
148
+ identity never leak to a non-manager caller. Each endpoint is keyed by the
149
+ resource id and gated with the record post-filter (`recordKey`) matching the
150
+ single endpoint's read scope, mirroring `getBulkSystemHealthStatus` /
151
+ `getBulkIncidentsForSystems`. Widget DTO output is unchanged - this is a pure
152
+ request-count optimization.
153
+
154
+ - 43e4484: Status page enhancements:
155
+
156
+ - Group-status widget can collapse its member rows while every member is
157
+ operational (auto-expanding on any issue or maintenance).
158
+ - New "Announcements" status-page widget, contributed fully externally by the
159
+ announcement plugin: it surfaces active `visibility: "all"` announcements
160
+ through a public-safe DTO (title/message/severity/timestamps only) and never
161
+ affects the page status rollup.
162
+ - Incident and maintenance widgets can scope by catalog GROUPS with per-system
163
+ exceptions. Scope is resolved at read time (`(systemIds ∪ members(groupIds)) −
164
+ excludedSystemIds`), so members added to a group later are reflected
165
+ automatically. The builder gets a nested group/system picker.
166
+ - Incident and maintenance items on a public page link to dedicated public
167
+ detail pages, gated server-side to items the page's published widgets actually
168
+ surface (no enumeration, no internal-field leak). The custom-domain public
169
+ bundle gains a minimal in-memory router for the two detail pages.
170
+ - Fix the custom-domain "Cannot connect to Checkstack backend" screen: a
171
+ configured-but-not-servable custom domain now serves the lean public
172
+ "not available" page instead of the admin shell; the public bundle skips the
173
+ cross-origin `/api/config` probe; CORS admits resolved custom domains; the
174
+ request origin is normalized for proxy scheme/port variance; and re-saving an
175
+ unchanged custom domain no longer clears its verification.
176
+ - Anonymous email subscriptions (double opt-in) for incident updates, opt-in per
177
+ status page (`emailSubscriptionsEnabled`, default off): a new
178
+ `status_page_subscribers` table, public subscribe/verify/unsubscribe
179
+ procedures with constant-time responses that fail closed when the page has not
180
+ enabled subscriptions, and team-scoped admin list/remove + an enable toggle in
181
+ the builder. Emails are delivered through a new `sendRawEmail` primitive in
182
+ notification-backend that sends to an arbitrary external address (no auth
183
+ account) via every enabled email strategy (SMTP), with a mandatory unsubscribe
184
+ link.
185
+ - Incident/maintenance update fan-out to subscribers via a new
186
+ `notificationAudienceExtensionPoint` in notification-backend. Every
187
+ notification funnelled through `notifyForSubscription` (incident, maintenance,
188
+ health - all unchanged) now also invokes each registered audience sink exactly
189
+ once, enriched with the affected systems and their catalog groups (resolved
190
+ from notification-backend's own resource-parent graph, never a domain import).
191
+ status-page-backend contributes a sink that, AT SEND TIME, matches each
192
+ notification's affected systems against the systems each published + public +
193
+ email-enabled page currently surfaces in its incident/maintenance widgets
194
+ (honoring group membership and per-system exclusions) and emails that page's
195
+ verified subscribers. Send-time scoping against the live layout is the privacy
196
+ boundary: a page only ever emails about systems its widgets surface right now.
197
+ Because `notifyForSubscription` is a single-pod point RPC, each notification
198
+ fans out exactly once cluster-wide.
199
+ - Subscriber reconcile on page deletion: the subscriber FK is `ON DELETE
200
+ CASCADE` and page deletion also explicitly purges subscribers (invalidating
201
+ pending verify/unsubscribe tokens) - no orphan rows, no post-deletion send.
202
+ Removing all systems from a page or disabling email is intentionally NOT a
203
+ prune: send-time scoping plus the email-enabled gate make those subscribers
204
+ dormant with no data loss, and re-enabling restores the audience without a
205
+ re-subscribe.
206
+ - Send-time scoping is single-source: the fan-out asks each event-feed widget for
207
+ its CURRENT effective system scope (the same live catalog group expansion the
208
+ widget renders from) instead of a parallel copy of group membership, so it can
209
+ never over- or under-deliver relative to what the page shows.
210
+ - `sendRawEmail` in notification-backend is now `userType: "service"` (was an
211
+ authenticated procedure gated on `notification.send`). Sending to an arbitrary
212
+ address is an open-relay / email-bomb primitive, so it is callable only by a
213
+ trusted backend-to-backend caller (the status-page subscriber mailer), never by
214
+ an end user.
215
+ - Incident/maintenance widgets gain an optional per-system PUBLIC label override
216
+ (`systemLabels`), the same override path the system-health widget uses, so the
217
+ public incident/maintenance detail pages present clean labels instead of raw
218
+ catalog names.
219
+ - The anonymous subscribe endpoint adds a coarse per-page quota (max new
220
+ subscribers per rolling hour, counted over durable rows so it holds across
221
+ pods) on top of the per-(page,email) cooldown, capping verification-email
222
+ amplification. The quota is CONFIGURABLE per status page (new nullable
223
+ `email_subscribers_hourly_quota` column; null uses the default of 50, so
224
+ existing pages are unchanged), validated as a positive integer up to 5000,
225
+ editable in the builder next to the email opt-in toggle and gated by the same
226
+ page-manage capability.
227
+ - Email verification is now per-page configurable and backed by a platform-global
228
+ once-per-address registry:
229
+ - New `email_verification_required` column (boolean, default true) on
230
+ `status_pages`, exposed on the admin StatusPage DTO + `updateStatusPage`
231
+ input (same page-manage gate) with a builder toggle. When OFF, a new
232
+ subscriber is created active immediately - no verification email, and the
233
+ address is NOT written to the global registry (the operator's trust choice
234
+ for e.g. an internal page).
235
+ - New `status_page_verified_emails` table: one row per normalized address that
236
+ has completed verification on ANY page. When a verification-required page is
237
+ subscribed by an already-globally-verified address, the row is created active
238
+ immediately and a COURTESY email (with one-click unsubscribe) is sent instead
239
+ of a verification email, so a malicious add is always caught. `verify` upserts
240
+ the address into this registry and activates every other pending row for the
241
+ same address in one update (confirm once, all pages).
242
+ - Fan-out is unchanged: it still gates on the per-row `verified` flag; the
243
+ registry only governs whether a NEW subscribe short-circuits to active.
244
+
245
+ BREAKING CHANGE: `sendRawEmail` is now service-only. Any (non-existent in-tree)
246
+ authenticated caller must invoke it through a trusted service client instead.
247
+
248
+ Thanks to [@stuajnht](https://github.com/stuajnht) for the valuable feedback.
249
+
250
+ - Updated dependencies [43e4484]
251
+ - Updated dependencies [43e4484]
252
+ - Updated dependencies [43e4484]
253
+ - Updated dependencies [43e4484]
254
+ - Updated dependencies [43e4484]
255
+ - Updated dependencies [43e4484]
256
+ - Updated dependencies [43e4484]
257
+ - Updated dependencies [43e4484]
258
+ - Updated dependencies [43e4484]
259
+ - Updated dependencies [43e4484]
260
+ - Updated dependencies [43e4484]
261
+ - Updated dependencies [43e4484]
262
+ - Updated dependencies [43e4484]
263
+ - Updated dependencies [43e4484]
264
+ - Updated dependencies [43e4484]
265
+ - Updated dependencies [43e4484]
266
+ - Updated dependencies [43e4484]
267
+ - Updated dependencies [43e4484]
268
+ - Updated dependencies [43e4484]
269
+ - Updated dependencies [43e4484]
270
+ - Updated dependencies [43e4484]
271
+ - Updated dependencies [43e4484]
272
+ - Updated dependencies [43e4484]
273
+ - Updated dependencies [43e4484]
274
+ - Updated dependencies [43e4484]
275
+ - Updated dependencies [43e4484]
276
+ - Updated dependencies [43e4484]
277
+ - Updated dependencies [43e4484]
278
+ - @checkstack/ai-backend@0.10.10
279
+ - @checkstack/automation-backend@0.11.1
280
+ - @checkstack/catalog-common@2.7.0
281
+ - @checkstack/catalog-backend@1.7.0
282
+ - @checkstack/healthcheck-common@1.16.0
283
+ - @checkstack/backend-api@0.31.1
284
+ - @checkstack/incident-common@1.10.0
285
+ - @checkstack/incident-backend@1.12.0
286
+ - @checkstack/maintenance-common@1.10.0
287
+ - @checkstack/notification-common@1.6.0
288
+ - @checkstack/status-page-backend@0.5.0
289
+ - @checkstack/gitops-backend@0.5.22
290
+ - @checkstack/secrets-backend@0.3.4
291
+ - @checkstack/status-page-common@0.6.0
292
+ - @checkstack/satellite-backend@0.8.4
293
+ - @checkstack/sdk@0.127.1
294
+ - @checkstack/command-backend@0.2.22
295
+ - @checkstack/script-packages-backend@0.4.1
296
+
3
297
  ## 1.18.0
4
298
 
5
299
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/healthcheck-backend",
3
- "version": "1.18.0",
3
+ "version": "1.19.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -14,32 +14,32 @@
14
14
  "lint:code": "eslint . --max-warnings 0"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/ai-backend": "0.10.9",
17
+ "@checkstack/ai-backend": "0.10.10",
18
18
  "@checkstack/ai-common": "0.6.6",
19
- "@checkstack/automation-backend": "0.11.0",
20
- "@checkstack/backend-api": "0.31.0",
19
+ "@checkstack/automation-backend": "0.11.1",
20
+ "@checkstack/backend-api": "0.31.1",
21
21
  "@checkstack/cache-api": "0.3.19",
22
22
  "@checkstack/cache-utils": "0.2.24",
23
- "@checkstack/catalog-backend": "1.6.9",
24
- "@checkstack/catalog-common": "2.6.3",
25
- "@checkstack/command-backend": "0.2.21",
23
+ "@checkstack/catalog-backend": "1.7.0",
24
+ "@checkstack/catalog-common": "2.7.0",
25
+ "@checkstack/command-backend": "0.2.22",
26
26
  "@checkstack/common": "0.22.0",
27
- "@checkstack/gitops-backend": "0.5.21",
27
+ "@checkstack/gitops-backend": "0.5.22",
28
28
  "@checkstack/gitops-common": "0.7.3",
29
- "@checkstack/healthcheck-common": "1.15.0",
30
- "@checkstack/incident-backend": "1.11.0",
31
- "@checkstack/incident-common": "1.9.0",
32
- "@checkstack/maintenance-common": "1.9.0",
33
- "@checkstack/notification-common": "1.5.3",
29
+ "@checkstack/healthcheck-common": "1.16.0",
30
+ "@checkstack/incident-backend": "1.12.0",
31
+ "@checkstack/incident-common": "1.10.0",
32
+ "@checkstack/maintenance-common": "1.10.0",
33
+ "@checkstack/notification-common": "1.6.0",
34
34
  "@checkstack/queue-api": "0.3.19",
35
- "@checkstack/satellite-backend": "0.8.3",
36
- "@checkstack/script-packages-backend": "0.4.0",
37
- "@checkstack/sdk": "0.126.1",
38
- "@checkstack/secrets-backend": "0.3.3",
35
+ "@checkstack/satellite-backend": "0.8.4",
36
+ "@checkstack/script-packages-backend": "0.4.1",
37
+ "@checkstack/sdk": "0.127.1",
38
+ "@checkstack/secrets-backend": "0.3.4",
39
39
  "@checkstack/secrets-common": "0.3.2",
40
40
  "@checkstack/signal-common": "0.2.17",
41
- "@checkstack/status-page-backend": "0.4.8",
42
- "@checkstack/status-page-common": "0.5.3",
41
+ "@checkstack/status-page-backend": "0.5.0",
42
+ "@checkstack/status-page-common": "0.6.0",
43
43
  "@hono/zod-validator": "^0.7.6",
44
44
  "@orpc/contract": "^1.14.4",
45
45
  "@orpc/server": "^1.14.4",
@@ -53,13 +53,15 @@
53
53
  "devDependencies": {
54
54
  "@checkstack/drizzle-helper": "0.0.6",
55
55
  "@checkstack/scripts": "0.7.3",
56
- "@checkstack/test-utils-backend": "0.1.55",
56
+ "@checkstack/test-utils-backend": "0.1.56",
57
57
  "@checkstack/tsconfig": "0.0.7",
58
58
  "@types/bun": "^1.0.0",
59
+ "@types/pg": "^8.20.0",
59
60
  "@types/tdigest": "^0.1.5",
60
61
  "bullmq": "^5.66.4",
61
62
  "date-fns": "^4.4.0",
62
63
  "drizzle-kit": "^0.31.10",
64
+ "pg": "^8.21.0",
63
65
  "typescript": "^5.0.0"
64
66
  }
65
67
  }
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { buildHealthTransitionNotification } from "./health-notification-content";
3
+
4
+ describe("buildHealthTransitionNotification", () => {
5
+ const base = {
6
+ systemId: "sys-1",
7
+ systemName: "Payments API",
8
+ configurationId: "cfg-9",
9
+ checkName: "HTTP 200 probe",
10
+ newStatus: "unhealthy" as const,
11
+ };
12
+
13
+ it("names the failing check in the body for an unhealthy transition", () => {
14
+ const payload = buildHealthTransitionNotification({
15
+ ...base,
16
+ transition: "escalation",
17
+ });
18
+ expect(payload.body).toContain('Health check **"HTTP 200 probe"**');
19
+ expect(payload.body).toContain("**Payments API**");
20
+ expect(payload.importance).toBe("critical");
21
+ });
22
+
23
+ it("names the failing check for a degraded transition", () => {
24
+ const payload = buildHealthTransitionNotification({
25
+ ...base,
26
+ newStatus: "degraded",
27
+ transition: "escalation",
28
+ });
29
+ expect(payload.body).toContain('Health check **"HTTP 200 probe"**');
30
+ expect(payload.importance).toBe("warning");
31
+ });
32
+
33
+ it("pushes a healthcheck.healthcheck subject alongside the system subject", () => {
34
+ const payload = buildHealthTransitionNotification({
35
+ ...base,
36
+ transition: "escalation",
37
+ });
38
+ const subjects = payload.subjects ?? [];
39
+ expect(subjects).toHaveLength(2);
40
+ expect(subjects[0]).toMatchObject({
41
+ kind: "catalog.system",
42
+ id: "sys-1",
43
+ name: "Payments API",
44
+ });
45
+ expect(subjects[1]).toMatchObject({
46
+ kind: "healthcheck.healthcheck",
47
+ id: "cfg-9",
48
+ name: "HTTP 200 probe",
49
+ status: "unhealthy",
50
+ });
51
+ // Check subject deep-links to its run history.
52
+ expect(subjects[1]?.url).toContain("sys-1");
53
+ expect(subjects[1]?.url).toContain("cfg-9");
54
+ });
55
+
56
+ it("falls back to the configuration id when no name is resolved", () => {
57
+ const payload = buildHealthTransitionNotification({
58
+ ...base,
59
+ checkName: "cfg-9",
60
+ transition: "escalation",
61
+ });
62
+ expect(payload.body).toContain('Health check **"cfg-9"**');
63
+ expect((payload.subjects ?? [])[1]).toMatchObject({ name: "cfg-9" });
64
+ });
65
+
66
+ it("qualifies the body with the environment name when env-scoped", () => {
67
+ const payload = buildHealthTransitionNotification({
68
+ ...base,
69
+ transition: "escalation",
70
+ environmentId: "env-prod",
71
+ environmentName: "Production",
72
+ });
73
+ expect(payload.body).toContain("in environment **Production**");
74
+ expect(payload.title).toContain("(Production)");
75
+ });
76
+
77
+ it("stays system-level and omits the check subject on recovery", () => {
78
+ const payload = buildHealthTransitionNotification({
79
+ ...base,
80
+ newStatus: "healthy",
81
+ transition: "recovery",
82
+ });
83
+ expect(payload.body).not.toContain("Health check **");
84
+ expect(payload.importance).toBe("info");
85
+ const subjects = payload.subjects ?? [];
86
+ expect(subjects).toHaveLength(1);
87
+ expect(subjects[0]).toMatchObject({ kind: "catalog.system" });
88
+ });
89
+ });
@@ -0,0 +1,138 @@
1
+ import { resolveRoute, type InferClient } from "@checkstack/common";
2
+ import { catalogRoutes, createSystemSubject } from "@checkstack/catalog-common";
3
+ import type { NotificationApi } from "@checkstack/notification-common";
4
+ import {
5
+ createHealthcheckSubject,
6
+ healthcheckRoutes,
7
+ systemHealthCollapseKey,
8
+ healthcheckSystemSubscription,
9
+ type HealthCheckStatus,
10
+ } from "@checkstack/healthcheck-common";
11
+ import type { TransitionKind } from "./notification-policy";
12
+
13
+ /** The subset of `notifyForSubscription`'s input this builder produces. */
14
+ type NotifyForSubscriptionInput = Parameters<
15
+ InferClient<typeof NotificationApi>["notifyForSubscription"]
16
+ >[0];
17
+
18
+ /**
19
+ * Inputs to {@link buildHealthTransitionNotification}. Pure data only - the
20
+ * catalog client is unused here (parents are resolved server-side) and thus
21
+ * omitted; every field is derived before the call site in the queue executor.
22
+ */
23
+ export interface HealthTransitionNotificationInput {
24
+ transition: Exclude<TransitionKind, "none">;
25
+ systemId: string;
26
+ systemName: string;
27
+ configurationId: string;
28
+ /** Resolved display name of the check that drove the transition. */
29
+ checkName: string;
30
+ newStatus: HealthCheckStatus;
31
+ /** Concrete env id for a per-env slice, null/undefined for the system rollup. */
32
+ environmentId?: string | null;
33
+ /** Human-readable env name for the body/title. */
34
+ environmentName?: string;
35
+ }
36
+
37
+ /**
38
+ * Build the notification payload for a health-state transition. Pure and
39
+ * side-effect free so it can be unit-tested directly. Extracted from
40
+ * `notifyStateChange` so the body/title/subject wording (which now NAMES the
41
+ * failing check and pushes a `healthcheck.healthcheck` subject) is verifiable
42
+ * without booting the whole queue executor.
43
+ *
44
+ * Recovery bodies stay system-level (the whole system is green again; naming
45
+ * one check would mislead) and omit the check subject. Failing transitions
46
+ * (escalation / de-escalation) name the check in the body and add it as a
47
+ * subject deep-linked to its run history.
48
+ */
49
+ export function buildHealthTransitionNotification(
50
+ input: HealthTransitionNotificationInput,
51
+ ): NotifyForSubscriptionInput {
52
+ const {
53
+ transition,
54
+ systemId,
55
+ systemName,
56
+ configurationId,
57
+ checkName,
58
+ newStatus,
59
+ environmentId,
60
+ environmentName,
61
+ } = input;
62
+
63
+ const envScoped = typeof environmentId === "string";
64
+ const envSuffix = envScoped && environmentName ? ` (${environmentName})` : "";
65
+ const envQualifier = envScoped
66
+ ? ` in environment **${environmentName ?? environmentId}**`
67
+ : "";
68
+
69
+ let title: string;
70
+ let body: string;
71
+ let importance: "info" | "warning" | "critical";
72
+
73
+ if (transition === "recovery") {
74
+ title = `System health restored${envSuffix}: ${systemName}`;
75
+ body = envScoped
76
+ ? `Health checks for **${systemName}** in environment **${environmentName ?? environmentId}** are now passing. The system has returned to normal operation in that environment.`
77
+ : `All health checks for **${systemName}** are now passing. The system has returned to normal operation.`;
78
+ importance = "info";
79
+ } else if (newStatus === "unhealthy") {
80
+ title = `System health critical${envSuffix}: ${systemName}`;
81
+ body = `Health check **"${checkName}"** on **${systemName}**${envQualifier} is failing. The system is unhealthy and may be down${envScoped ? " in that environment" : ""}.`;
82
+ importance = "critical";
83
+ } else {
84
+ // degraded - either an escalation from healthy or a partial recovery
85
+ title = `System health degraded${envSuffix}: ${systemName}`;
86
+ body = `Health check **"${checkName}"** on **${systemName}**${envQualifier} is failing. The system may be experiencing issues${envScoped ? " in that environment" : ""}.`;
87
+ importance = "warning";
88
+ }
89
+
90
+ const systemDetailPath = resolveRoute(catalogRoutes.routes.systemDetail, {
91
+ systemId,
92
+ });
93
+ // Recovery lands on the default (all) view; failing transitions deep-link
94
+ // operators into the failing-checks filter so they can debug immediately.
95
+ const actionUrl =
96
+ transition === "recovery"
97
+ ? systemDetailPath
98
+ : `${systemDetailPath}?filter=failing`;
99
+ const actionLabel =
100
+ transition === "recovery" ? "View System" : "View failing checks";
101
+
102
+ return {
103
+ specId: healthcheckSystemSubscription.specId,
104
+ resourceKeys: [systemId],
105
+ title,
106
+ body,
107
+ importance,
108
+ action: { label: actionLabel, url: actionUrl },
109
+ // Env-qualified collapse key so two failing envs of one system generate
110
+ // two independent notification cards (one per env) instead of merging.
111
+ collapseKey: envScoped
112
+ ? systemHealthCollapseKey(systemId, environmentId)
113
+ : systemHealthCollapseKey(systemId),
114
+ subjects: [
115
+ createSystemSubject({
116
+ id: systemId,
117
+ name: systemName,
118
+ url: systemDetailPath,
119
+ status: newStatus,
120
+ }),
121
+ // Name the failing check as its own subject for every non-recovery
122
+ // transition, deep-linked to its run history. Omitted on recovery.
123
+ ...(transition === "recovery"
124
+ ? []
125
+ : [
126
+ createHealthcheckSubject({
127
+ id: configurationId,
128
+ name: checkName,
129
+ url: resolveRoute(healthcheckRoutes.routes.historyDetail, {
130
+ systemId,
131
+ configurationId,
132
+ }),
133
+ status: newStatus,
134
+ }),
135
+ ]),
136
+ ],
137
+ };
138
+ }