@checkstack/maintenance-backend 0.2.4 → 0.3.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,43 @@
1
1
  # @checkstack/maintenance-backend
2
2
 
3
+ ## 0.3.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 18fa8e3: Add notification suppression toggle for maintenance windows
8
+
9
+ **New Feature:** When creating or editing a maintenance window, you can now enable "Suppress health notifications" to prevent health status change notifications from being sent for affected systems while the maintenance is active (in_progress status). This is useful for planned downtime where health alerts are expected and would otherwise create noise.
10
+
11
+ **Changes:**
12
+
13
+ - Added `suppressNotifications` field to maintenance schema
14
+ - Added new service-to-service API `hasActiveMaintenanceWithSuppression`
15
+ - Healthcheck queue executor now checks for suppression before sending notifications
16
+ - MaintenanceEditor UI includes new toggle checkbox
17
+
18
+ **Bug Fix:** Fixed migration system to correctly set PostgreSQL search_path when running plugin migrations. Previously, migrations could fail with "relation does not exist" errors because the schema context wasn't properly set.
19
+
20
+ ### Patch Changes
21
+
22
+ - Updated dependencies [18fa8e3]
23
+ - @checkstack/maintenance-common@0.4.0
24
+
25
+ ## 0.2.5
26
+
27
+ ### Patch Changes
28
+
29
+ - Updated dependencies [83557c7]
30
+ - Updated dependencies [83557c7]
31
+ - @checkstack/backend-api@0.4.0
32
+ - @checkstack/common@0.4.0
33
+ - @checkstack/command-backend@0.1.4
34
+ - @checkstack/integration-backend@0.1.4
35
+ - @checkstack/catalog-common@1.2.2
36
+ - @checkstack/integration-common@0.2.1
37
+ - @checkstack/maintenance-common@0.3.2
38
+ - @checkstack/notification-common@0.2.1
39
+ - @checkstack/signal-common@0.1.2
40
+
3
41
  ## 0.2.4
4
42
 
5
43
  ### Patch Changes
@@ -0,0 +1 @@
1
+ ALTER TABLE "maintenances" ADD COLUMN "suppress_notifications" boolean DEFAULT false NOT NULL;
@@ -0,0 +1,214 @@
1
+ {
2
+ "id": "d0a42a3a-4685-408b-8ad9-e2c0ac19e8a2",
3
+ "prevId": "2722e752-6079-4992-b4d1-da0a4bc0e01b",
4
+ "version": "7",
5
+ "dialect": "postgresql",
6
+ "tables": {
7
+ "public.maintenance_systems": {
8
+ "name": "maintenance_systems",
9
+ "schema": "",
10
+ "columns": {
11
+ "maintenance_id": {
12
+ "name": "maintenance_id",
13
+ "type": "text",
14
+ "primaryKey": false,
15
+ "notNull": true
16
+ },
17
+ "system_id": {
18
+ "name": "system_id",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": true
22
+ }
23
+ },
24
+ "indexes": {},
25
+ "foreignKeys": {
26
+ "maintenance_systems_maintenance_id_maintenances_id_fk": {
27
+ "name": "maintenance_systems_maintenance_id_maintenances_id_fk",
28
+ "tableFrom": "maintenance_systems",
29
+ "tableTo": "maintenances",
30
+ "columnsFrom": [
31
+ "maintenance_id"
32
+ ],
33
+ "columnsTo": [
34
+ "id"
35
+ ],
36
+ "onDelete": "cascade",
37
+ "onUpdate": "no action"
38
+ }
39
+ },
40
+ "compositePrimaryKeys": {
41
+ "maintenance_systems_maintenance_id_system_id_pk": {
42
+ "name": "maintenance_systems_maintenance_id_system_id_pk",
43
+ "columns": [
44
+ "maintenance_id",
45
+ "system_id"
46
+ ]
47
+ }
48
+ },
49
+ "uniqueConstraints": {},
50
+ "policies": {},
51
+ "checkConstraints": {},
52
+ "isRLSEnabled": false
53
+ },
54
+ "public.maintenance_updates": {
55
+ "name": "maintenance_updates",
56
+ "schema": "",
57
+ "columns": {
58
+ "id": {
59
+ "name": "id",
60
+ "type": "text",
61
+ "primaryKey": true,
62
+ "notNull": true
63
+ },
64
+ "maintenance_id": {
65
+ "name": "maintenance_id",
66
+ "type": "text",
67
+ "primaryKey": false,
68
+ "notNull": true
69
+ },
70
+ "message": {
71
+ "name": "message",
72
+ "type": "text",
73
+ "primaryKey": false,
74
+ "notNull": true
75
+ },
76
+ "status_change": {
77
+ "name": "status_change",
78
+ "type": "maintenance_status",
79
+ "typeSchema": "public",
80
+ "primaryKey": false,
81
+ "notNull": false
82
+ },
83
+ "created_at": {
84
+ "name": "created_at",
85
+ "type": "timestamp",
86
+ "primaryKey": false,
87
+ "notNull": true,
88
+ "default": "now()"
89
+ },
90
+ "created_by": {
91
+ "name": "created_by",
92
+ "type": "text",
93
+ "primaryKey": false,
94
+ "notNull": false
95
+ }
96
+ },
97
+ "indexes": {},
98
+ "foreignKeys": {
99
+ "maintenance_updates_maintenance_id_maintenances_id_fk": {
100
+ "name": "maintenance_updates_maintenance_id_maintenances_id_fk",
101
+ "tableFrom": "maintenance_updates",
102
+ "tableTo": "maintenances",
103
+ "columnsFrom": [
104
+ "maintenance_id"
105
+ ],
106
+ "columnsTo": [
107
+ "id"
108
+ ],
109
+ "onDelete": "cascade",
110
+ "onUpdate": "no action"
111
+ }
112
+ },
113
+ "compositePrimaryKeys": {},
114
+ "uniqueConstraints": {},
115
+ "policies": {},
116
+ "checkConstraints": {},
117
+ "isRLSEnabled": false
118
+ },
119
+ "public.maintenances": {
120
+ "name": "maintenances",
121
+ "schema": "",
122
+ "columns": {
123
+ "id": {
124
+ "name": "id",
125
+ "type": "text",
126
+ "primaryKey": true,
127
+ "notNull": true
128
+ },
129
+ "title": {
130
+ "name": "title",
131
+ "type": "text",
132
+ "primaryKey": false,
133
+ "notNull": true
134
+ },
135
+ "description": {
136
+ "name": "description",
137
+ "type": "text",
138
+ "primaryKey": false,
139
+ "notNull": false
140
+ },
141
+ "suppress_notifications": {
142
+ "name": "suppress_notifications",
143
+ "type": "boolean",
144
+ "primaryKey": false,
145
+ "notNull": true,
146
+ "default": false
147
+ },
148
+ "status": {
149
+ "name": "status",
150
+ "type": "maintenance_status",
151
+ "typeSchema": "public",
152
+ "primaryKey": false,
153
+ "notNull": true,
154
+ "default": "'scheduled'"
155
+ },
156
+ "start_at": {
157
+ "name": "start_at",
158
+ "type": "timestamp",
159
+ "primaryKey": false,
160
+ "notNull": true
161
+ },
162
+ "end_at": {
163
+ "name": "end_at",
164
+ "type": "timestamp",
165
+ "primaryKey": false,
166
+ "notNull": true
167
+ },
168
+ "created_at": {
169
+ "name": "created_at",
170
+ "type": "timestamp",
171
+ "primaryKey": false,
172
+ "notNull": true,
173
+ "default": "now()"
174
+ },
175
+ "updated_at": {
176
+ "name": "updated_at",
177
+ "type": "timestamp",
178
+ "primaryKey": false,
179
+ "notNull": true,
180
+ "default": "now()"
181
+ }
182
+ },
183
+ "indexes": {},
184
+ "foreignKeys": {},
185
+ "compositePrimaryKeys": {},
186
+ "uniqueConstraints": {},
187
+ "policies": {},
188
+ "checkConstraints": {},
189
+ "isRLSEnabled": false
190
+ }
191
+ },
192
+ "enums": {
193
+ "public.maintenance_status": {
194
+ "name": "maintenance_status",
195
+ "schema": "public",
196
+ "values": [
197
+ "scheduled",
198
+ "in_progress",
199
+ "completed",
200
+ "cancelled"
201
+ ]
202
+ }
203
+ },
204
+ "schemas": {},
205
+ "sequences": {},
206
+ "roles": {},
207
+ "policies": {},
208
+ "views": {},
209
+ "_meta": {
210
+ "columns": {},
211
+ "schemas": {},
212
+ "tables": {}
213
+ }
214
+ }
@@ -8,6 +8,13 @@
8
8
  "when": 1767319104427,
9
9
  "tag": "0000_numerous_shadow_king",
10
10
  "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "7",
15
+ "when": 1768688736163,
16
+ "tag": "0001_tough_star_brand",
17
+ "breakpoints": true
11
18
  }
12
19
  ]
13
20
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/maintenance-backend",
3
- "version": "0.2.4",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
package/src/router.ts CHANGED
@@ -20,7 +20,7 @@ export function createRouter(
20
20
  service: MaintenanceService,
21
21
  signalService: SignalService,
22
22
  catalogClient: InferClient<typeof CatalogApi>,
23
- logger: Logger
23
+ logger: Logger,
24
24
  ) {
25
25
  const os = implement(maintenanceContract)
26
26
  .$context<RpcContext>()
@@ -44,7 +44,7 @@ export function createRouter(
44
44
  maintenanceRoutes.routes.detail,
45
45
  {
46
46
  maintenanceId,
47
- }
47
+ },
48
48
  );
49
49
 
50
50
  for (const systemId of systemIds) {
@@ -61,7 +61,7 @@ export function createRouter(
61
61
  // Log but don't fail the operation - notifications are best-effort
62
62
  logger.warn(
63
63
  `Failed to notify subscribers for system ${systemId}:`,
64
- error
64
+ error,
65
65
  );
66
66
  }
67
67
  }
@@ -81,7 +81,7 @@ export function createRouter(
81
81
  getMaintenancesForSystem: os.getMaintenancesForSystem.handler(
82
82
  async ({ input }) => {
83
83
  return service.getMaintenancesForSystem(input.systemId);
84
- }
84
+ },
85
85
  ),
86
86
 
87
87
  getBulkMaintenancesForSystems: os.getBulkMaintenancesForSystems.handler(
@@ -94,14 +94,13 @@ export function createRouter(
94
94
  // Fetch maintenances for each system in parallel
95
95
  await Promise.all(
96
96
  input.systemIds.map(async (systemId) => {
97
- maintenances[systemId] = await service.getMaintenancesForSystem(
98
- systemId
99
- );
100
- })
97
+ maintenances[systemId] =
98
+ await service.getMaintenancesForSystem(systemId);
99
+ }),
101
100
  );
102
101
 
103
102
  return { maintenances };
104
- }
103
+ },
105
104
  ),
106
105
 
107
106
  createMaintenance: os.createMaintenance.handler(
@@ -135,7 +134,7 @@ export function createRouter(
135
134
  });
136
135
 
137
136
  return result;
138
- }
137
+ },
139
138
  ),
140
139
 
141
140
  updateMaintenance: os.updateMaintenance.handler(
@@ -175,7 +174,7 @@ export function createRouter(
175
174
  });
176
175
 
177
176
  return result;
178
- }
177
+ },
179
178
  ),
180
179
 
181
180
  addUpdate: os.addUpdate.handler(async ({ input, context }) => {
@@ -213,7 +212,7 @@ export function createRouter(
213
212
  const result = await service.closeMaintenance(
214
213
  input.id,
215
214
  input.message,
216
- userId
215
+ userId,
217
216
  );
218
217
  if (!result) {
219
218
  throw new ORPCError("NOT_FOUND", {
@@ -240,7 +239,7 @@ export function createRouter(
240
239
  });
241
240
 
242
241
  return result;
243
- }
242
+ },
244
243
  ),
245
244
 
246
245
  deleteMaintenance: os.deleteMaintenance.handler(async ({ input }) => {
@@ -256,5 +255,13 @@ export function createRouter(
256
255
  }
257
256
  return { success };
258
257
  }),
258
+
259
+ hasActiveMaintenanceWithSuppression:
260
+ os.hasActiveMaintenanceWithSuppression.handler(async ({ input }) => {
261
+ const suppressed = await service.hasActiveMaintenanceWithSuppression(
262
+ input.systemId,
263
+ );
264
+ return { suppressed };
265
+ }),
259
266
  });
260
267
  }
package/src/schema.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  text,
5
5
  timestamp,
6
6
  primaryKey,
7
+ boolean,
7
8
  } from "drizzle-orm/pg-core";
8
9
 
9
10
  /**
@@ -23,6 +24,9 @@ export const maintenances = pgTable("maintenances", {
23
24
  id: text("id").primaryKey(),
24
25
  title: text("title").notNull(),
25
26
  description: text("description"),
27
+ suppressNotifications: boolean("suppress_notifications")
28
+ .notNull()
29
+ .default(false),
26
30
  status: maintenanceStatusEnum("status").notNull().default("scheduled"),
27
31
  startAt: timestamp("start_at").notNull(),
28
32
  endAt: timestamp("end_at").notNull(),
@@ -43,7 +47,7 @@ export const maintenanceSystems = pgTable(
43
47
  },
44
48
  (t) => ({
45
49
  pk: primaryKey(t.maintenanceId, t.systemId),
46
- })
50
+ }),
47
51
  );
48
52
 
49
53
  /**
package/src/service.ts CHANGED
@@ -46,15 +46,17 @@ export class MaintenanceService {
46
46
  .where(
47
47
  and(
48
48
  inArray(maintenances.id, ids),
49
- filters.status ? eq(maintenances.status, filters.status) : undefined
50
- )
49
+ filters.status
50
+ ? eq(maintenances.status, filters.status)
51
+ : undefined,
52
+ ),
51
53
  );
52
54
  } else {
53
55
  maintenanceRows = await this.db
54
56
  .select()
55
57
  .from(maintenances)
56
58
  .where(
57
- filters?.status ? eq(maintenances.status, filters.status) : undefined
59
+ filters?.status ? eq(maintenances.status, filters.status) : undefined,
58
60
  );
59
61
  }
60
62
 
@@ -113,7 +115,7 @@ export class MaintenanceService {
113
115
  * Get active/upcoming maintenances for a system
114
116
  */
115
117
  async getMaintenancesForSystem(
116
- systemId: string
118
+ systemId: string,
117
119
  ): Promise<MaintenanceWithSystems[]> {
118
120
  const _now = new Date();
119
121
 
@@ -135,9 +137,9 @@ export class MaintenanceService {
135
137
  inArray(maintenances.id, ids),
136
138
  or(
137
139
  eq(maintenances.status, "scheduled"),
138
- eq(maintenances.status, "in_progress")
139
- )
140
- )
140
+ eq(maintenances.status, "in_progress"),
141
+ ),
142
+ ),
141
143
  );
142
144
 
143
145
  // Fetch system IDs for each
@@ -162,7 +164,7 @@ export class MaintenanceService {
162
164
  * Create a new maintenance
163
165
  */
164
166
  async createMaintenance(
165
- input: CreateMaintenanceInput
167
+ input: CreateMaintenanceInput,
166
168
  ): Promise<MaintenanceWithSystems> {
167
169
  const id = generateId();
168
170
 
@@ -170,6 +172,7 @@ export class MaintenanceService {
170
172
  id,
171
173
  title: input.title,
172
174
  description: input.description,
175
+ suppressNotifications: input.suppressNotifications ?? false,
173
176
  status: "scheduled",
174
177
  startAt: input.startAt,
175
178
  endAt: input.endAt,
@@ -190,7 +193,7 @@ export class MaintenanceService {
190
193
  * Update an existing maintenance
191
194
  */
192
195
  async updateMaintenance(
193
- input: UpdateMaintenanceInput
196
+ input: UpdateMaintenanceInput,
194
197
  ): Promise<MaintenanceWithSystems | undefined> {
195
198
  const [existing] = await this.db
196
199
  .select()
@@ -206,6 +209,8 @@ export class MaintenanceService {
206
209
  if (input.title !== undefined) updateData.title = input.title;
207
210
  if (input.description !== undefined)
208
211
  updateData.description = input.description;
212
+ if (input.suppressNotifications !== undefined)
213
+ updateData.suppressNotifications = input.suppressNotifications;
209
214
  if (input.startAt !== undefined) updateData.startAt = input.startAt;
210
215
  if (input.endAt !== undefined) updateData.endAt = input.endAt;
211
216
 
@@ -236,7 +241,7 @@ export class MaintenanceService {
236
241
  */
237
242
  async addUpdate(
238
243
  input: AddMaintenanceUpdateInput,
239
- userId?: string
244
+ userId?: string,
240
245
  ): Promise<MaintenanceUpdate> {
241
246
  const id = generateId();
242
247
 
@@ -274,7 +279,7 @@ export class MaintenanceService {
274
279
  async closeMaintenance(
275
280
  id: string,
276
281
  message?: string,
277
- userId?: string
282
+ userId?: string,
278
283
  ): Promise<MaintenanceWithSystems | undefined> {
279
284
  const [existing] = await this.db
280
285
  .select()
@@ -325,4 +330,36 @@ export class MaintenanceService {
325
330
  // For now, return empty set
326
331
  return new Set();
327
332
  }
333
+
334
+ /**
335
+ * Check if a system has an active maintenance with notification suppression enabled.
336
+ * A maintenance is considered "active" if its status is "in_progress".
337
+ */
338
+ async hasActiveMaintenanceWithSuppression(
339
+ systemId: string,
340
+ ): Promise<boolean> {
341
+ // Get maintenance IDs for this system
342
+ const systemMaintenances = await this.db
343
+ .select({ maintenanceId: maintenanceSystems.maintenanceId })
344
+ .from(maintenanceSystems)
345
+ .where(eq(maintenanceSystems.systemId, systemId));
346
+
347
+ const ids = systemMaintenances.map((r) => r.maintenanceId);
348
+ if (ids.length === 0) return false;
349
+
350
+ // Check if any of these maintenances are in_progress with suppressNotifications enabled
351
+ const [match] = await this.db
352
+ .select({ id: maintenances.id })
353
+ .from(maintenances)
354
+ .where(
355
+ and(
356
+ inArray(maintenances.id, ids),
357
+ eq(maintenances.status, "in_progress"),
358
+ eq(maintenances.suppressNotifications, true),
359
+ ),
360
+ )
361
+ .limit(1);
362
+
363
+ return !!match;
364
+ }
328
365
  }