@checkstack/maintenance-backend 0.3.0 → 0.4.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,29 @@
1
1
  # @checkstack/maintenance-backend
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 65aa47e: Add automatic maintenance status transitions
8
+
9
+ Maintenances now automatically transition from `scheduled` to `in_progress` when their `startAt` time is reached, and from `in_progress` to `completed` when their `endAt` time is reached. A recurring queue job runs every minute to check and transition statuses. Integration hooks and real-time signals are emitted upon each transition.
10
+
11
+ ### Patch Changes
12
+
13
+ - Updated dependencies [8a87cd4]
14
+ - Updated dependencies [8a87cd4]
15
+ - Updated dependencies [8a87cd4]
16
+ - Updated dependencies [8a87cd4]
17
+ - @checkstack/backend-api@0.4.1
18
+ - @checkstack/catalog-common@1.2.3
19
+ - @checkstack/common@0.5.0
20
+ - @checkstack/maintenance-common@0.4.1
21
+ - @checkstack/command-backend@0.1.5
22
+ - @checkstack/integration-backend@0.1.5
23
+ - @checkstack/integration-common@0.2.2
24
+ - @checkstack/notification-common@0.2.2
25
+ - @checkstack/signal-common@0.1.3
26
+
3
27
  ## 0.3.0
4
28
 
5
29
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/maintenance-backend",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
package/src/index.ts CHANGED
@@ -7,7 +7,9 @@ import {
7
7
  pluginMetadata,
8
8
  maintenanceContract,
9
9
  maintenanceRoutes,
10
+ MAINTENANCE_UPDATED,
10
11
  } from "@checkstack/maintenance-common";
12
+
11
13
  import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
12
14
  import { integrationEventExtensionPoint } from "@checkstack/integration-backend";
13
15
  import { MaintenanceService } from "./service";
@@ -42,6 +44,11 @@ const maintenanceUpdatedPayloadSchema = z.object({
42
44
  action: z.enum(["updated", "closed"]),
43
45
  });
44
46
 
47
+ // Queue and job constants
48
+ const STATUS_TRANSITION_QUEUE = "maintenance-status-transitions";
49
+ const STATUS_TRANSITION_JOB_ID = "maintenance-status-transition-check";
50
+ const WORKER_GROUP = "maintenance-status-worker";
51
+
45
52
  // =============================================================================
46
53
  // Plugin Definition
47
54
  // =============================================================================
@@ -53,7 +60,7 @@ export default createBackendPlugin({
53
60
 
54
61
  // Register hooks as integration events
55
62
  const integrationEvents = env.getExtensionPoint(
56
- integrationEventExtensionPoint
63
+ integrationEventExtensionPoint,
57
64
  );
58
65
 
59
66
  integrationEvents.registerEvent(
@@ -64,7 +71,7 @@ export default createBackendPlugin({
64
71
  category: "Maintenance",
65
72
  payloadSchema: maintenanceCreatedPayloadSchema,
66
73
  },
67
- pluginMetadata
74
+ pluginMetadata,
68
75
  );
69
76
 
70
77
  integrationEvents.registerEvent(
@@ -75,9 +82,12 @@ export default createBackendPlugin({
75
82
  category: "Maintenance",
76
83
  payloadSchema: maintenanceUpdatedPayloadSchema,
77
84
  },
78
- pluginMetadata
85
+ pluginMetadata,
79
86
  );
80
87
 
88
+ // Store service reference for afterPluginsReady
89
+ let maintenanceService: MaintenanceService;
90
+
81
91
  env.registerInit({
82
92
  schema,
83
93
  deps: {
@@ -85,20 +95,21 @@ export default createBackendPlugin({
85
95
  rpc: coreServices.rpc,
86
96
  rpcClient: coreServices.rpcClient,
87
97
  signalService: coreServices.signalService,
98
+ queueManager: coreServices.queueManager,
88
99
  },
89
100
  init: async ({ logger, database, rpc, rpcClient, signalService }) => {
90
101
  logger.debug("🔧 Initializing Maintenance Backend...");
91
102
 
92
103
  const catalogClient = rpcClient.forPlugin(CatalogApi);
93
104
 
94
- const service = new MaintenanceService(
95
- database as NodePgDatabase<typeof schema>
105
+ maintenanceService = new MaintenanceService(
106
+ database as NodePgDatabase<typeof schema>,
96
107
  );
97
108
  const router = createRouter(
98
- service,
109
+ maintenanceService,
99
110
  signalService,
100
111
  catalogClient,
101
- logger
112
+ logger,
102
113
  );
103
114
  rpc.registerRouter(router, maintenanceContract);
104
115
 
@@ -130,6 +141,116 @@ export default createBackendPlugin({
130
141
 
131
142
  logger.debug("✅ Maintenance Backend initialized.");
132
143
  },
144
+ afterPluginsReady: async ({
145
+ queueManager,
146
+ emitHook,
147
+ logger,
148
+ signalService,
149
+ }) => {
150
+ // Schedule the recurring status transition check job
151
+ const queue = queueManager.getQueue<Record<string, never>>(
152
+ STATUS_TRANSITION_QUEUE,
153
+ );
154
+
155
+ // Subscribe to process status transition check jobs
156
+ await queue.consume(
157
+ async () => {
158
+ logger.debug("⏰ Checking maintenance status transitions...");
159
+
160
+ // Get maintenances that need to start
161
+ const toStart = await maintenanceService.getMaintenancesToStart();
162
+ for (const maintenance of toStart) {
163
+ const updated = await maintenanceService.transitionStatus(
164
+ maintenance.id,
165
+ "in_progress",
166
+ "Maintenance started automatically",
167
+ );
168
+
169
+ if (updated) {
170
+ logger.info(
171
+ `Maintenance "${updated.title}" transitioned to in_progress`,
172
+ );
173
+
174
+ // Emit hook for integrations
175
+ await emitHook(maintenanceHooks.maintenanceUpdated, {
176
+ maintenanceId: updated.id,
177
+ systemIds: updated.systemIds,
178
+ title: updated.title,
179
+ description: updated.description,
180
+ status: updated.status,
181
+ startAt: updated.startAt.toISOString(),
182
+ endAt: updated.endAt.toISOString(),
183
+ action: "updated" as const,
184
+ });
185
+
186
+ // Send signal for real-time UI updates
187
+ await signalService.broadcast(MAINTENANCE_UPDATED, {
188
+ maintenanceId: updated.id,
189
+ systemIds: updated.systemIds,
190
+ action: "updated",
191
+ });
192
+ }
193
+ }
194
+
195
+ // Get maintenances that need to complete
196
+ const toComplete =
197
+ await maintenanceService.getMaintenancesToComplete();
198
+ for (const maintenance of toComplete) {
199
+ const updated = await maintenanceService.transitionStatus(
200
+ maintenance.id,
201
+ "completed",
202
+ "Maintenance completed automatically",
203
+ );
204
+
205
+ if (updated) {
206
+ logger.info(
207
+ `Maintenance "${updated.title}" transitioned to completed`,
208
+ );
209
+
210
+ // Emit hook for integrations
211
+ await emitHook(maintenanceHooks.maintenanceUpdated, {
212
+ maintenanceId: updated.id,
213
+ systemIds: updated.systemIds,
214
+ title: updated.title,
215
+ description: updated.description,
216
+ status: updated.status,
217
+ startAt: updated.startAt.toISOString(),
218
+ endAt: updated.endAt.toISOString(),
219
+ action: "closed" as const,
220
+ });
221
+
222
+ // Send signal for real-time UI updates
223
+ await signalService.broadcast(MAINTENANCE_UPDATED, {
224
+ maintenanceId: updated.id,
225
+ systemIds: updated.systemIds,
226
+ action: "closed",
227
+ });
228
+ }
229
+ }
230
+
231
+ if (toStart.length > 0 || toComplete.length > 0) {
232
+ logger.debug(
233
+ `Status transitions: ${toStart.length} started, ${toComplete.length} completed`,
234
+ );
235
+ }
236
+ },
237
+ {
238
+ consumerGroup: WORKER_GROUP,
239
+ maxRetries: 0, // Status checks should not retry
240
+ },
241
+ );
242
+
243
+ // Schedule to run every minute (60 seconds)
244
+ await queue.scheduleRecurring(
245
+ {}, // Empty payload - the job just triggers a check
246
+ {
247
+ jobId: STATUS_TRANSITION_JOB_ID,
248
+ intervalSeconds: 60,
249
+ },
250
+ );
251
+
252
+ logger.debug("✅ Maintenance status transition job scheduled.");
253
+ },
133
254
  });
134
255
  },
135
256
  });
package/src/service.ts CHANGED
@@ -362,4 +362,110 @@ export class MaintenanceService {
362
362
 
363
363
  return !!match;
364
364
  }
365
+
366
+ /**
367
+ * Get maintenances that should transition from 'scheduled' to 'in_progress'.
368
+ * These are maintenances where status = 'scheduled' AND startAt <= now.
369
+ */
370
+ async getMaintenancesToStart(): Promise<MaintenanceWithSystems[]> {
371
+ const now = new Date();
372
+
373
+ const rows = await this.db
374
+ .select()
375
+ .from(maintenances)
376
+ .where(
377
+ and(
378
+ eq(maintenances.status, "scheduled"),
379
+ // startAt is in the past or now
380
+ // Using SQL comparison - startAt <= now
381
+ ),
382
+ );
383
+
384
+ // Filter in JS since Drizzle SQL comparison can be tricky with dates
385
+ const startable = rows.filter((m) => m.startAt <= now);
386
+
387
+ // Fetch system IDs for each
388
+ const result: MaintenanceWithSystems[] = [];
389
+ for (const m of startable) {
390
+ const systems = await this.db
391
+ .select({ systemId: maintenanceSystems.systemId })
392
+ .from(maintenanceSystems)
393
+ .where(eq(maintenanceSystems.maintenanceId, m.id));
394
+
395
+ result.push({
396
+ ...m,
397
+ description: m.description ?? undefined,
398
+ systemIds: systems.map((s) => s.systemId),
399
+ });
400
+ }
401
+
402
+ return result;
403
+ }
404
+
405
+ /**
406
+ * Get maintenances that should transition from 'in_progress' to 'completed'.
407
+ * These are maintenances where status = 'in_progress' AND endAt <= now.
408
+ */
409
+ async getMaintenancesToComplete(): Promise<MaintenanceWithSystems[]> {
410
+ const now = new Date();
411
+
412
+ const rows = await this.db
413
+ .select()
414
+ .from(maintenances)
415
+ .where(eq(maintenances.status, "in_progress"));
416
+
417
+ // Filter in JS for those that have ended
418
+ const completable = rows.filter((m) => m.endAt <= now);
419
+
420
+ // Fetch system IDs for each
421
+ const result: MaintenanceWithSystems[] = [];
422
+ for (const m of completable) {
423
+ const systems = await this.db
424
+ .select({ systemId: maintenanceSystems.systemId })
425
+ .from(maintenanceSystems)
426
+ .where(eq(maintenanceSystems.maintenanceId, m.id));
427
+
428
+ result.push({
429
+ ...m,
430
+ description: m.description ?? undefined,
431
+ systemIds: systems.map((s) => s.systemId),
432
+ });
433
+ }
434
+
435
+ return result;
436
+ }
437
+
438
+ /**
439
+ * Transition a maintenance to a new status with an automatic update entry.
440
+ * Used by the scheduled job for automatic status transitions.
441
+ */
442
+ async transitionStatus(
443
+ id: string,
444
+ newStatus: MaintenanceStatus,
445
+ message: string,
446
+ ): Promise<MaintenanceWithSystems | undefined> {
447
+ const [existing] = await this.db
448
+ .select()
449
+ .from(maintenances)
450
+ .where(eq(maintenances.id, id));
451
+
452
+ if (!existing) return undefined;
453
+
454
+ // Update the maintenance status
455
+ await this.db
456
+ .update(maintenances)
457
+ .set({ status: newStatus, updatedAt: new Date() })
458
+ .where(eq(maintenances.id, id));
459
+
460
+ // Add update entry (no user - system-generated)
461
+ await this.db.insert(maintenanceUpdates).values({
462
+ id: generateId(),
463
+ maintenanceId: id,
464
+ message,
465
+ statusChange: newStatus,
466
+ createdBy: undefined, // System-generated, no user
467
+ });
468
+
469
+ return (await this.getMaintenance(id))!;
470
+ }
365
471
  }