@checkstack/maintenance-backend 0.3.0 → 0.5.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 +56 -0
- package/package.json +1 -1
- package/src/index.ts +102 -10
- package/src/notifications.ts +58 -0
- package/src/router.ts +41 -53
- package/src/service.ts +108 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,61 @@
|
|
|
1
1
|
# @checkstack/maintenance-backend
|
|
2
2
|
|
|
3
|
+
## 0.5.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 2c0822d: ### Queue System
|
|
8
|
+
|
|
9
|
+
- Added cron pattern support to `scheduleRecurring()` - accepts either `intervalSeconds` or `cronPattern`
|
|
10
|
+
- BullMQ backend uses native cron scheduling via `pattern` option
|
|
11
|
+
- InMemoryQueue implements wall-clock cron scheduling with `cron-parser`
|
|
12
|
+
|
|
13
|
+
### Maintenance Backend
|
|
14
|
+
|
|
15
|
+
- Auto status transitions now use cron pattern `* * * * *` for precise second-0 scheduling
|
|
16
|
+
- User notifications are now sent for auto-started and auto-completed maintenances
|
|
17
|
+
- Refactored to call `addUpdate` RPC for status changes, centralizing hook/signal/notification logic
|
|
18
|
+
|
|
19
|
+
### UI
|
|
20
|
+
|
|
21
|
+
- DateTimePicker now resets seconds and milliseconds to 0 when time is changed
|
|
22
|
+
|
|
23
|
+
### Patch Changes
|
|
24
|
+
|
|
25
|
+
- 66a3963: Update database types to use SafeDatabase
|
|
26
|
+
|
|
27
|
+
- Updated all database type declarations from `NodePgDatabase` to `SafeDatabase` for compile-time safety
|
|
28
|
+
|
|
29
|
+
- Updated dependencies [66a3963]
|
|
30
|
+
- Updated dependencies [66a3963]
|
|
31
|
+
- @checkstack/integration-backend@0.1.6
|
|
32
|
+
- @checkstack/backend-api@0.5.0
|
|
33
|
+
- @checkstack/command-backend@0.1.6
|
|
34
|
+
|
|
35
|
+
## 0.4.0
|
|
36
|
+
|
|
37
|
+
### Minor Changes
|
|
38
|
+
|
|
39
|
+
- 65aa47e: Add automatic maintenance status transitions
|
|
40
|
+
|
|
41
|
+
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.
|
|
42
|
+
|
|
43
|
+
### Patch Changes
|
|
44
|
+
|
|
45
|
+
- Updated dependencies [8a87cd4]
|
|
46
|
+
- Updated dependencies [8a87cd4]
|
|
47
|
+
- Updated dependencies [8a87cd4]
|
|
48
|
+
- Updated dependencies [8a87cd4]
|
|
49
|
+
- @checkstack/backend-api@0.4.1
|
|
50
|
+
- @checkstack/catalog-common@1.2.3
|
|
51
|
+
- @checkstack/common@0.5.0
|
|
52
|
+
- @checkstack/maintenance-common@0.4.1
|
|
53
|
+
- @checkstack/command-backend@0.1.5
|
|
54
|
+
- @checkstack/integration-backend@0.1.5
|
|
55
|
+
- @checkstack/integration-common@0.2.2
|
|
56
|
+
- @checkstack/notification-common@0.2.2
|
|
57
|
+
- @checkstack/signal-common@0.1.3
|
|
58
|
+
|
|
3
59
|
## 0.3.0
|
|
4
60
|
|
|
5
61
|
### Minor Changes
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import * as schema from "./schema";
|
|
2
|
-
import type {
|
|
2
|
+
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import {
|
|
5
5
|
maintenanceAccessRules,
|
|
@@ -7,14 +7,16 @@ import {
|
|
|
7
7
|
pluginMetadata,
|
|
8
8
|
maintenanceContract,
|
|
9
9
|
maintenanceRoutes,
|
|
10
|
+
MaintenanceApi,
|
|
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";
|
|
14
16
|
import { createRouter } from "./router";
|
|
15
17
|
import { CatalogApi } from "@checkstack/catalog-common";
|
|
16
18
|
import { registerSearchProvider } from "@checkstack/command-backend";
|
|
17
|
-
import { resolveRoute } from "@checkstack/common";
|
|
19
|
+
import { resolveRoute, type InferClient } from "@checkstack/common";
|
|
18
20
|
import { maintenanceHooks } from "./hooks";
|
|
19
21
|
|
|
20
22
|
// =============================================================================
|
|
@@ -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,15 @@ 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
|
+
// Store clients for afterPluginsReady
|
|
91
|
+
let catalogClient: InferClient<typeof CatalogApi>;
|
|
92
|
+
let maintenanceClient: InferClient<typeof MaintenanceApi>;
|
|
93
|
+
|
|
81
94
|
env.registerInit({
|
|
82
95
|
schema,
|
|
83
96
|
deps: {
|
|
@@ -85,20 +98,22 @@ export default createBackendPlugin({
|
|
|
85
98
|
rpc: coreServices.rpc,
|
|
86
99
|
rpcClient: coreServices.rpcClient,
|
|
87
100
|
signalService: coreServices.signalService,
|
|
101
|
+
queueManager: coreServices.queueManager,
|
|
88
102
|
},
|
|
89
103
|
init: async ({ logger, database, rpc, rpcClient, signalService }) => {
|
|
90
104
|
logger.debug("🔧 Initializing Maintenance Backend...");
|
|
91
105
|
|
|
92
|
-
|
|
106
|
+
catalogClient = rpcClient.forPlugin(CatalogApi);
|
|
107
|
+
maintenanceClient = rpcClient.forPlugin(MaintenanceApi);
|
|
93
108
|
|
|
94
|
-
|
|
95
|
-
database as
|
|
109
|
+
maintenanceService = new MaintenanceService(
|
|
110
|
+
database as SafeDatabase<typeof schema>,
|
|
96
111
|
);
|
|
97
112
|
const router = createRouter(
|
|
98
|
-
|
|
113
|
+
maintenanceService,
|
|
99
114
|
signalService,
|
|
100
115
|
catalogClient,
|
|
101
|
-
logger
|
|
116
|
+
logger,
|
|
102
117
|
);
|
|
103
118
|
rpc.registerRouter(router, maintenanceContract);
|
|
104
119
|
|
|
@@ -130,6 +145,83 @@ export default createBackendPlugin({
|
|
|
130
145
|
|
|
131
146
|
logger.debug("✅ Maintenance Backend initialized.");
|
|
132
147
|
},
|
|
148
|
+
afterPluginsReady: async ({ queueManager, logger }) => {
|
|
149
|
+
// Schedule the recurring status transition check job
|
|
150
|
+
const queue = queueManager.getQueue<Record<string, never>>(
|
|
151
|
+
STATUS_TRANSITION_QUEUE,
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
// Subscribe to process status transition check jobs
|
|
155
|
+
await queue.consume(
|
|
156
|
+
async () => {
|
|
157
|
+
logger.debug("⏰ Checking maintenance status transitions...");
|
|
158
|
+
|
|
159
|
+
// Get maintenances that need to start
|
|
160
|
+
const toStart = await maintenanceService.getMaintenancesToStart();
|
|
161
|
+
for (const maintenance of toStart) {
|
|
162
|
+
try {
|
|
163
|
+
// Call addUpdate via RPC - this handles hooks, signals, and notifications
|
|
164
|
+
await maintenanceClient.addUpdate({
|
|
165
|
+
maintenanceId: maintenance.id,
|
|
166
|
+
message: "Maintenance started automatically",
|
|
167
|
+
statusChange: "in_progress",
|
|
168
|
+
});
|
|
169
|
+
logger.info(
|
|
170
|
+
`Maintenance "${maintenance.title}" transitioned to in_progress`,
|
|
171
|
+
);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
logger.error(
|
|
174
|
+
`Failed to transition maintenance ${maintenance.id}:`,
|
|
175
|
+
error,
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Get maintenances that need to complete
|
|
181
|
+
const toComplete =
|
|
182
|
+
await maintenanceService.getMaintenancesToComplete();
|
|
183
|
+
for (const maintenance of toComplete) {
|
|
184
|
+
try {
|
|
185
|
+
// Call addUpdate via RPC - this handles hooks, signals, and notifications
|
|
186
|
+
await maintenanceClient.addUpdate({
|
|
187
|
+
maintenanceId: maintenance.id,
|
|
188
|
+
message: "Maintenance completed automatically",
|
|
189
|
+
statusChange: "completed",
|
|
190
|
+
});
|
|
191
|
+
logger.info(
|
|
192
|
+
`Maintenance "${maintenance.title}" transitioned to completed`,
|
|
193
|
+
);
|
|
194
|
+
} catch (error) {
|
|
195
|
+
logger.error(
|
|
196
|
+
`Failed to transition maintenance ${maintenance.id}:`,
|
|
197
|
+
error,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (toStart.length > 0 || toComplete.length > 0) {
|
|
203
|
+
logger.debug(
|
|
204
|
+
`Status transitions: ${toStart.length} started, ${toComplete.length} completed`,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
{
|
|
209
|
+
consumerGroup: WORKER_GROUP,
|
|
210
|
+
maxRetries: 0, // Status checks should not retry
|
|
211
|
+
},
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
// Schedule to run every minute at second 0 (cron-based for precise timing)
|
|
215
|
+
await queue.scheduleRecurring(
|
|
216
|
+
{}, // Empty payload - the job just triggers a check
|
|
217
|
+
{
|
|
218
|
+
jobId: STATUS_TRANSITION_JOB_ID,
|
|
219
|
+
cronPattern: "* * * * *", // Every minute at :00 seconds
|
|
220
|
+
},
|
|
221
|
+
);
|
|
222
|
+
|
|
223
|
+
logger.debug("✅ Maintenance status transition job scheduled.");
|
|
224
|
+
},
|
|
133
225
|
});
|
|
134
226
|
},
|
|
135
227
|
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { CatalogApi } from "@checkstack/catalog-common";
|
|
2
|
+
import type { Logger } from "@checkstack/backend-api";
|
|
3
|
+
import type { InferClient } from "@checkstack/common";
|
|
4
|
+
import { resolveRoute } from "@checkstack/common";
|
|
5
|
+
import { maintenanceRoutes } from "@checkstack/maintenance-common";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Helper to notify subscribers of affected systems about a maintenance event.
|
|
9
|
+
* Each system triggers a separate notification call, but within each call
|
|
10
|
+
* the subscribers are deduplicated (system + its groups).
|
|
11
|
+
*/
|
|
12
|
+
export async function notifyAffectedSystems(props: {
|
|
13
|
+
catalogClient: InferClient<typeof CatalogApi>;
|
|
14
|
+
logger: Logger;
|
|
15
|
+
maintenanceId: string;
|
|
16
|
+
maintenanceTitle: string;
|
|
17
|
+
systemIds: string[];
|
|
18
|
+
action: "created" | "updated" | "started" | "completed";
|
|
19
|
+
}): Promise<void> {
|
|
20
|
+
const {
|
|
21
|
+
catalogClient,
|
|
22
|
+
logger,
|
|
23
|
+
maintenanceId,
|
|
24
|
+
maintenanceTitle,
|
|
25
|
+
systemIds,
|
|
26
|
+
action,
|
|
27
|
+
} = props;
|
|
28
|
+
|
|
29
|
+
const actionText = {
|
|
30
|
+
created: "scheduled",
|
|
31
|
+
updated: "updated",
|
|
32
|
+
started: "started",
|
|
33
|
+
completed: "completed",
|
|
34
|
+
}[action];
|
|
35
|
+
|
|
36
|
+
const maintenanceDetailPath = resolveRoute(maintenanceRoutes.routes.detail, {
|
|
37
|
+
maintenanceId,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
for (const systemId of systemIds) {
|
|
41
|
+
try {
|
|
42
|
+
await catalogClient.notifySystemSubscribers({
|
|
43
|
+
systemId,
|
|
44
|
+
title: `Maintenance ${actionText}`,
|
|
45
|
+
body: `A maintenance **"${maintenanceTitle}"** has been ${actionText} for a system you're subscribed to.`,
|
|
46
|
+
importance: "info",
|
|
47
|
+
action: { label: "View Maintenance", url: maintenanceDetailPath },
|
|
48
|
+
includeGroupSubscribers: true,
|
|
49
|
+
});
|
|
50
|
+
} catch (error) {
|
|
51
|
+
// Log but don't fail the operation - notifications are best-effort
|
|
52
|
+
logger.warn(
|
|
53
|
+
`Failed to notify subscribers for system ${systemId}:`,
|
|
54
|
+
error,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
package/src/router.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { implement, ORPCError } from "@orpc/server";
|
|
|
2
2
|
import {
|
|
3
3
|
maintenanceContract,
|
|
4
4
|
MAINTENANCE_UPDATED,
|
|
5
|
-
maintenanceRoutes,
|
|
6
5
|
} from "@checkstack/maintenance-common";
|
|
7
6
|
import {
|
|
8
7
|
autoAuthMiddleware,
|
|
@@ -13,8 +12,8 @@ import type { SignalService } from "@checkstack/signal-common";
|
|
|
13
12
|
import type { MaintenanceService } from "./service";
|
|
14
13
|
import { CatalogApi } from "@checkstack/catalog-common";
|
|
15
14
|
import type { InferClient } from "@checkstack/common";
|
|
16
|
-
import { resolveRoute } from "@checkstack/common";
|
|
17
15
|
import { maintenanceHooks } from "./hooks";
|
|
16
|
+
import { notifyAffectedSystems } from "./notifications";
|
|
18
17
|
|
|
19
18
|
export function createRouter(
|
|
20
19
|
service: MaintenanceService,
|
|
@@ -26,47 +25,6 @@ export function createRouter(
|
|
|
26
25
|
.$context<RpcContext>()
|
|
27
26
|
.use(autoAuthMiddleware);
|
|
28
27
|
|
|
29
|
-
/**
|
|
30
|
-
* Helper to notify subscribers of affected systems about a maintenance event.
|
|
31
|
-
* Each system triggers a separate notification call, but within each call
|
|
32
|
-
* the subscribers are deduplicated (system + its groups).
|
|
33
|
-
*/
|
|
34
|
-
const notifyAffectedSystems = async (props: {
|
|
35
|
-
maintenanceId: string;
|
|
36
|
-
maintenanceTitle: string;
|
|
37
|
-
systemIds: string[];
|
|
38
|
-
action: "created" | "updated";
|
|
39
|
-
}) => {
|
|
40
|
-
const { maintenanceId, maintenanceTitle, systemIds, action } = props;
|
|
41
|
-
|
|
42
|
-
const actionText = action === "created" ? "scheduled" : "updated";
|
|
43
|
-
const maintenanceDetailPath = resolveRoute(
|
|
44
|
-
maintenanceRoutes.routes.detail,
|
|
45
|
-
{
|
|
46
|
-
maintenanceId,
|
|
47
|
-
},
|
|
48
|
-
);
|
|
49
|
-
|
|
50
|
-
for (const systemId of systemIds) {
|
|
51
|
-
try {
|
|
52
|
-
await catalogClient.notifySystemSubscribers({
|
|
53
|
-
systemId,
|
|
54
|
-
title: `Maintenance ${actionText}`,
|
|
55
|
-
body: `A maintenance **"${maintenanceTitle}"** has been ${actionText} for a system you're subscribed to.`,
|
|
56
|
-
importance: "info",
|
|
57
|
-
action: { label: "View Maintenance", url: maintenanceDetailPath },
|
|
58
|
-
includeGroupSubscribers: true,
|
|
59
|
-
});
|
|
60
|
-
} catch (error) {
|
|
61
|
-
// Log but don't fail the operation - notifications are best-effort
|
|
62
|
-
logger.warn(
|
|
63
|
-
`Failed to notify subscribers for system ${systemId}:`,
|
|
64
|
-
error,
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
};
|
|
69
|
-
|
|
70
28
|
return os.router({
|
|
71
29
|
listMaintenances: os.listMaintenances.handler(async ({ input }) => {
|
|
72
30
|
return { maintenances: await service.listMaintenances(input ?? {}) };
|
|
@@ -127,6 +85,8 @@ export function createRouter(
|
|
|
127
85
|
|
|
128
86
|
// Send notifications to system subscribers
|
|
129
87
|
await notifyAffectedSystems({
|
|
88
|
+
catalogClient,
|
|
89
|
+
logger,
|
|
130
90
|
maintenanceId: result.id,
|
|
131
91
|
maintenanceTitle: result.title,
|
|
132
92
|
systemIds: result.systemIds,
|
|
@@ -165,14 +125,6 @@ export function createRouter(
|
|
|
165
125
|
action: "updated",
|
|
166
126
|
});
|
|
167
127
|
|
|
168
|
-
// Send notifications to system subscribers
|
|
169
|
-
await notifyAffectedSystems({
|
|
170
|
-
maintenanceId: result.id,
|
|
171
|
-
maintenanceTitle: result.title,
|
|
172
|
-
systemIds: result.systemIds,
|
|
173
|
-
action: "updated",
|
|
174
|
-
});
|
|
175
|
-
|
|
176
128
|
return result;
|
|
177
129
|
},
|
|
178
130
|
),
|
|
@@ -180,14 +132,25 @@ export function createRouter(
|
|
|
180
132
|
addUpdate: os.addUpdate.handler(async ({ input, context }) => {
|
|
181
133
|
const userId =
|
|
182
134
|
context.user && "id" in context.user ? context.user.id : undefined;
|
|
135
|
+
|
|
136
|
+
// Get previous status before update for comparison
|
|
137
|
+
const previousMaintenance = input.statusChange
|
|
138
|
+
? await service.getMaintenance(input.maintenanceId)
|
|
139
|
+
: undefined;
|
|
140
|
+
const previousStatus = previousMaintenance?.status;
|
|
141
|
+
|
|
183
142
|
const result = await service.addUpdate(input, userId);
|
|
184
143
|
// Get maintenance to broadcast with correct systemIds
|
|
185
144
|
const maintenance = await service.getMaintenance(input.maintenanceId);
|
|
186
145
|
if (maintenance) {
|
|
146
|
+
// Determine action based on status change
|
|
147
|
+
const action =
|
|
148
|
+
input.statusChange === "completed" ? "closed" : "updated";
|
|
149
|
+
|
|
187
150
|
await signalService.broadcast(MAINTENANCE_UPDATED, {
|
|
188
151
|
maintenanceId: input.maintenanceId,
|
|
189
152
|
systemIds: maintenance.systemIds,
|
|
190
|
-
action
|
|
153
|
+
action,
|
|
191
154
|
});
|
|
192
155
|
|
|
193
156
|
// Emit hook for cross-plugin coordination and integrations
|
|
@@ -199,8 +162,33 @@ export function createRouter(
|
|
|
199
162
|
status: maintenance.status,
|
|
200
163
|
startAt: maintenance.startAt.toISOString(),
|
|
201
164
|
endAt: maintenance.endAt.toISOString(),
|
|
202
|
-
action
|
|
165
|
+
action,
|
|
203
166
|
});
|
|
167
|
+
|
|
168
|
+
// Send notifications when status actually changes
|
|
169
|
+
if (input.statusChange && previousStatus !== input.statusChange) {
|
|
170
|
+
// Determine notification action based on the actual status transition
|
|
171
|
+
let notificationAction: "started" | "completed" | "updated";
|
|
172
|
+
if (
|
|
173
|
+
input.statusChange === "in_progress" &&
|
|
174
|
+
previousStatus !== "in_progress"
|
|
175
|
+
) {
|
|
176
|
+
notificationAction = "started";
|
|
177
|
+
} else if (input.statusChange === "completed") {
|
|
178
|
+
notificationAction = "completed";
|
|
179
|
+
} else {
|
|
180
|
+
notificationAction = "updated";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
await notifyAffectedSystems({
|
|
184
|
+
catalogClient,
|
|
185
|
+
logger,
|
|
186
|
+
maintenanceId: input.maintenanceId,
|
|
187
|
+
maintenanceTitle: maintenance.title,
|
|
188
|
+
systemIds: maintenance.systemIds,
|
|
189
|
+
action: notificationAction,
|
|
190
|
+
});
|
|
191
|
+
}
|
|
204
192
|
}
|
|
205
193
|
return result;
|
|
206
194
|
}),
|
package/src/service.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { eq, and, or, inArray } from "drizzle-orm";
|
|
2
|
-
import type {
|
|
2
|
+
import type { SafeDatabase } from "@checkstack/backend-api";
|
|
3
3
|
import * as schema from "./schema";
|
|
4
4
|
import { maintenances, maintenanceSystems, maintenanceUpdates } from "./schema";
|
|
5
5
|
import type {
|
|
@@ -12,7 +12,7 @@ import type {
|
|
|
12
12
|
MaintenanceStatus,
|
|
13
13
|
} from "@checkstack/maintenance-common";
|
|
14
14
|
|
|
15
|
-
type Db =
|
|
15
|
+
type Db = SafeDatabase<typeof schema>;
|
|
16
16
|
|
|
17
17
|
function generateId(): string {
|
|
18
18
|
return crypto.randomUUID();
|
|
@@ -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
|
}
|