@checkstack/maintenance-backend 0.2.5 → 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,51 @@
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
+
27
+ ## 0.3.0
28
+
29
+ ### Minor Changes
30
+
31
+ - 18fa8e3: Add notification suppression toggle for maintenance windows
32
+
33
+ **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.
34
+
35
+ **Changes:**
36
+
37
+ - Added `suppressNotifications` field to maintenance schema
38
+ - Added new service-to-service API `hasActiveMaintenanceWithSuppression`
39
+ - Healthcheck queue executor now checks for suppression before sending notifications
40
+ - MaintenanceEditor UI includes new toggle checkbox
41
+
42
+ **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.
43
+
44
+ ### Patch Changes
45
+
46
+ - Updated dependencies [18fa8e3]
47
+ - @checkstack/maintenance-common@0.4.0
48
+
3
49
  ## 0.2.5
4
50
 
5
51
  ### 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.5",
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/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,142 @@ 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
+ }
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
+ }
328
471
  }