@checkmate-monitor/maintenance-backend 0.1.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 +33 -0
- package/drizzle/0000_numerous_shadow_king.sql +29 -0
- package/drizzle/meta/0000_snapshot.json +207 -0
- package/drizzle/meta/_journal.json +13 -0
- package/drizzle.config.ts +7 -0
- package/package.json +35 -0
- package/src/hooks.ts +37 -0
- package/src/index.ts +141 -0
- package/src/router.ts +240 -0
- package/src/schema.ts +61 -0
- package/src/service.ts +328 -0
- package/tsconfig.json +6 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# @checkmate-monitor/maintenance-backend
|
|
2
|
+
|
|
3
|
+
## 0.1.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- eff5b4e: Add standalone maintenance scheduling plugin
|
|
8
|
+
|
|
9
|
+
- New `@checkmate-monitor/maintenance-common` package with Zod schemas, permissions, oRPC contract, and extension slots
|
|
10
|
+
- New `@checkmate-monitor/maintenance-backend` package with Drizzle schema, service, and oRPC router
|
|
11
|
+
- New `@checkmate-monitor/maintenance-frontend` package with admin page and system detail panel
|
|
12
|
+
- Shared `DateTimePicker` component added to `@checkmate-monitor/ui`
|
|
13
|
+
- Database migrations for maintenances, maintenance_systems, and maintenance_updates tables
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- Updated dependencies [eff5b4e]
|
|
18
|
+
- Updated dependencies [ffc28f6]
|
|
19
|
+
- Updated dependencies [4dd644d]
|
|
20
|
+
- Updated dependencies [71275dd]
|
|
21
|
+
- Updated dependencies [ae19ff6]
|
|
22
|
+
- Updated dependencies [b55fae6]
|
|
23
|
+
- Updated dependencies [b354ab3]
|
|
24
|
+
- Updated dependencies [81f3f85]
|
|
25
|
+
- @checkmate-monitor/maintenance-common@0.1.0
|
|
26
|
+
- @checkmate-monitor/common@0.1.0
|
|
27
|
+
- @checkmate-monitor/backend-api@1.0.0
|
|
28
|
+
- @checkmate-monitor/catalog-common@0.1.0
|
|
29
|
+
- @checkmate-monitor/notification-common@0.1.0
|
|
30
|
+
- @checkmate-monitor/integration-common@0.1.0
|
|
31
|
+
- @checkmate-monitor/signal-common@0.1.0
|
|
32
|
+
- @checkmate-monitor/command-backend@0.0.2
|
|
33
|
+
- @checkmate-monitor/integration-backend@0.0.2
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
CREATE TYPE "maintenance_status" AS ENUM('scheduled', 'in_progress', 'completed', 'cancelled');--> statement-breakpoint
|
|
2
|
+
CREATE TABLE "maintenance_systems" (
|
|
3
|
+
"maintenance_id" text NOT NULL,
|
|
4
|
+
"system_id" text NOT NULL,
|
|
5
|
+
CONSTRAINT "maintenance_systems_maintenance_id_system_id_pk" PRIMARY KEY("maintenance_id","system_id")
|
|
6
|
+
);
|
|
7
|
+
--> statement-breakpoint
|
|
8
|
+
CREATE TABLE "maintenance_updates" (
|
|
9
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
10
|
+
"maintenance_id" text NOT NULL,
|
|
11
|
+
"message" text NOT NULL,
|
|
12
|
+
"status_change" "maintenance_status",
|
|
13
|
+
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
14
|
+
"created_by" text
|
|
15
|
+
);
|
|
16
|
+
--> statement-breakpoint
|
|
17
|
+
CREATE TABLE "maintenances" (
|
|
18
|
+
"id" text PRIMARY KEY NOT NULL,
|
|
19
|
+
"title" text NOT NULL,
|
|
20
|
+
"description" text,
|
|
21
|
+
"status" "maintenance_status" DEFAULT 'scheduled' NOT NULL,
|
|
22
|
+
"start_at" timestamp NOT NULL,
|
|
23
|
+
"end_at" timestamp NOT NULL,
|
|
24
|
+
"created_at" timestamp DEFAULT now() NOT NULL,
|
|
25
|
+
"updated_at" timestamp DEFAULT now() NOT NULL
|
|
26
|
+
);
|
|
27
|
+
--> statement-breakpoint
|
|
28
|
+
ALTER TABLE "maintenance_systems" ADD CONSTRAINT "maintenance_systems_maintenance_id_maintenances_id_fk" FOREIGN KEY ("maintenance_id") REFERENCES "maintenances"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
|
29
|
+
ALTER TABLE "maintenance_updates" ADD CONSTRAINT "maintenance_updates_maintenance_id_maintenances_id_fk" FOREIGN KEY ("maintenance_id") REFERENCES "maintenances"("id") ON DELETE cascade ON UPDATE no action;
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "2722e752-6079-4992-b4d1-da0a4bc0e01b",
|
|
3
|
+
"prevId": "00000000-0000-0000-0000-000000000000",
|
|
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
|
+
"status": {
|
|
142
|
+
"name": "status",
|
|
143
|
+
"type": "maintenance_status",
|
|
144
|
+
"typeSchema": "public",
|
|
145
|
+
"primaryKey": false,
|
|
146
|
+
"notNull": true,
|
|
147
|
+
"default": "'scheduled'"
|
|
148
|
+
},
|
|
149
|
+
"start_at": {
|
|
150
|
+
"name": "start_at",
|
|
151
|
+
"type": "timestamp",
|
|
152
|
+
"primaryKey": false,
|
|
153
|
+
"notNull": true
|
|
154
|
+
},
|
|
155
|
+
"end_at": {
|
|
156
|
+
"name": "end_at",
|
|
157
|
+
"type": "timestamp",
|
|
158
|
+
"primaryKey": false,
|
|
159
|
+
"notNull": true
|
|
160
|
+
},
|
|
161
|
+
"created_at": {
|
|
162
|
+
"name": "created_at",
|
|
163
|
+
"type": "timestamp",
|
|
164
|
+
"primaryKey": false,
|
|
165
|
+
"notNull": true,
|
|
166
|
+
"default": "now()"
|
|
167
|
+
},
|
|
168
|
+
"updated_at": {
|
|
169
|
+
"name": "updated_at",
|
|
170
|
+
"type": "timestamp",
|
|
171
|
+
"primaryKey": false,
|
|
172
|
+
"notNull": true,
|
|
173
|
+
"default": "now()"
|
|
174
|
+
}
|
|
175
|
+
},
|
|
176
|
+
"indexes": {},
|
|
177
|
+
"foreignKeys": {},
|
|
178
|
+
"compositePrimaryKeys": {},
|
|
179
|
+
"uniqueConstraints": {},
|
|
180
|
+
"policies": {},
|
|
181
|
+
"checkConstraints": {},
|
|
182
|
+
"isRLSEnabled": false
|
|
183
|
+
}
|
|
184
|
+
},
|
|
185
|
+
"enums": {
|
|
186
|
+
"public.maintenance_status": {
|
|
187
|
+
"name": "maintenance_status",
|
|
188
|
+
"schema": "public",
|
|
189
|
+
"values": [
|
|
190
|
+
"scheduled",
|
|
191
|
+
"in_progress",
|
|
192
|
+
"completed",
|
|
193
|
+
"cancelled"
|
|
194
|
+
]
|
|
195
|
+
}
|
|
196
|
+
},
|
|
197
|
+
"schemas": {},
|
|
198
|
+
"sequences": {},
|
|
199
|
+
"roles": {},
|
|
200
|
+
"policies": {},
|
|
201
|
+
"views": {},
|
|
202
|
+
"_meta": {
|
|
203
|
+
"columns": {},
|
|
204
|
+
"schemas": {},
|
|
205
|
+
"tables": {}
|
|
206
|
+
}
|
|
207
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@checkmate-monitor/maintenance-backend",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"scripts": {
|
|
7
|
+
"typecheck": "tsc --noEmit",
|
|
8
|
+
"generate": "drizzle-kit generate",
|
|
9
|
+
"lint": "bun run lint:code",
|
|
10
|
+
"lint:code": "eslint . --max-warnings 0"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@checkmate-monitor/backend-api": "workspace:*",
|
|
14
|
+
"@checkmate-monitor/maintenance-common": "workspace:*",
|
|
15
|
+
"@checkmate-monitor/notification-common": "workspace:*",
|
|
16
|
+
"@checkmate-monitor/catalog-common": "workspace:*",
|
|
17
|
+
"@checkmate-monitor/command-backend": "workspace:*",
|
|
18
|
+
"@checkmate-monitor/signal-common": "workspace:*",
|
|
19
|
+
"@checkmate-monitor/integration-backend": "workspace:*",
|
|
20
|
+
"@checkmate-monitor/integration-common": "workspace:*",
|
|
21
|
+
"drizzle-orm": "^0.45.1",
|
|
22
|
+
"zod": "^4.2.1",
|
|
23
|
+
"@checkmate-monitor/common": "workspace:*"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"@checkmate-monitor/drizzle-helper": "workspace:*",
|
|
27
|
+
"@checkmate-monitor/scripts": "workspace:*",
|
|
28
|
+
"@checkmate-monitor/test-utils-backend": "workspace:*",
|
|
29
|
+
"@checkmate-monitor/tsconfig": "workspace:*",
|
|
30
|
+
"@orpc/server": "^1.13.2",
|
|
31
|
+
"@types/bun": "^1.0.0",
|
|
32
|
+
"drizzle-kit": "^0.31.8",
|
|
33
|
+
"typescript": "^5.0.0"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createHook } from "@checkmate-monitor/backend-api";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Maintenance hooks for cross-plugin communication.
|
|
5
|
+
* Other plugins can subscribe to these hooks to react to maintenance lifecycle events.
|
|
6
|
+
* These hooks are registered as integration events for webhook subscriptions.
|
|
7
|
+
*/
|
|
8
|
+
export const maintenanceHooks = {
|
|
9
|
+
/**
|
|
10
|
+
* Emitted when a new maintenance is created.
|
|
11
|
+
* Plugins can subscribe (work-queue mode) to react to new maintenances.
|
|
12
|
+
*/
|
|
13
|
+
maintenanceCreated: createHook<{
|
|
14
|
+
maintenanceId: string;
|
|
15
|
+
systemIds: string[];
|
|
16
|
+
title: string;
|
|
17
|
+
description?: string;
|
|
18
|
+
status: string;
|
|
19
|
+
startAt: string;
|
|
20
|
+
endAt: string;
|
|
21
|
+
}>("maintenance.created"),
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Emitted when a maintenance is updated (info or status change).
|
|
25
|
+
* Plugins can subscribe (work-queue mode) to react to updates.
|
|
26
|
+
*/
|
|
27
|
+
maintenanceUpdated: createHook<{
|
|
28
|
+
maintenanceId: string;
|
|
29
|
+
systemIds: string[];
|
|
30
|
+
title: string;
|
|
31
|
+
description?: string;
|
|
32
|
+
status: string;
|
|
33
|
+
startAt: string;
|
|
34
|
+
endAt: string;
|
|
35
|
+
action: "updated" | "closed";
|
|
36
|
+
}>("maintenance.updated"),
|
|
37
|
+
} as const;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import * as schema from "./schema";
|
|
2
|
+
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import {
|
|
5
|
+
permissionList,
|
|
6
|
+
pluginMetadata,
|
|
7
|
+
maintenanceContract,
|
|
8
|
+
maintenanceRoutes,
|
|
9
|
+
permissions,
|
|
10
|
+
} from "@checkmate-monitor/maintenance-common";
|
|
11
|
+
import {
|
|
12
|
+
createBackendPlugin,
|
|
13
|
+
coreServices,
|
|
14
|
+
} from "@checkmate-monitor/backend-api";
|
|
15
|
+
import { integrationEventExtensionPoint } from "@checkmate-monitor/integration-backend";
|
|
16
|
+
import { MaintenanceService } from "./service";
|
|
17
|
+
import { createRouter } from "./router";
|
|
18
|
+
import { CatalogApi } from "@checkmate-monitor/catalog-common";
|
|
19
|
+
import { registerSearchProvider } from "@checkmate-monitor/command-backend";
|
|
20
|
+
import { resolveRoute } from "@checkmate-monitor/common";
|
|
21
|
+
import { maintenanceHooks } from "./hooks";
|
|
22
|
+
|
|
23
|
+
// =============================================================================
|
|
24
|
+
// Integration Event Payload Schemas
|
|
25
|
+
// =============================================================================
|
|
26
|
+
|
|
27
|
+
const maintenanceCreatedPayloadSchema = z.object({
|
|
28
|
+
maintenanceId: z.string(),
|
|
29
|
+
systemIds: z.array(z.string()),
|
|
30
|
+
title: z.string(),
|
|
31
|
+
description: z.string().optional(),
|
|
32
|
+
status: z.string(),
|
|
33
|
+
startAt: z.string(),
|
|
34
|
+
endAt: z.string(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const maintenanceUpdatedPayloadSchema = z.object({
|
|
38
|
+
maintenanceId: z.string(),
|
|
39
|
+
systemIds: z.array(z.string()),
|
|
40
|
+
title: z.string(),
|
|
41
|
+
description: z.string().optional(),
|
|
42
|
+
status: z.string(),
|
|
43
|
+
startAt: z.string(),
|
|
44
|
+
endAt: z.string(),
|
|
45
|
+
action: z.enum(["updated", "closed"]),
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// =============================================================================
|
|
49
|
+
// Plugin Definition
|
|
50
|
+
// =============================================================================
|
|
51
|
+
|
|
52
|
+
export default createBackendPlugin({
|
|
53
|
+
metadata: pluginMetadata,
|
|
54
|
+
register(env) {
|
|
55
|
+
env.registerPermissions(permissionList);
|
|
56
|
+
|
|
57
|
+
// Register hooks as integration events
|
|
58
|
+
const integrationEvents = env.getExtensionPoint(
|
|
59
|
+
integrationEventExtensionPoint
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
integrationEvents.registerEvent(
|
|
63
|
+
{
|
|
64
|
+
hook: maintenanceHooks.maintenanceCreated,
|
|
65
|
+
displayName: "Maintenance Created",
|
|
66
|
+
description: "Fired when a new maintenance is scheduled",
|
|
67
|
+
category: "Maintenance",
|
|
68
|
+
payloadSchema: maintenanceCreatedPayloadSchema,
|
|
69
|
+
},
|
|
70
|
+
pluginMetadata
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
integrationEvents.registerEvent(
|
|
74
|
+
{
|
|
75
|
+
hook: maintenanceHooks.maintenanceUpdated,
|
|
76
|
+
displayName: "Maintenance Updated",
|
|
77
|
+
description: "Fired when a maintenance is updated or closed",
|
|
78
|
+
category: "Maintenance",
|
|
79
|
+
payloadSchema: maintenanceUpdatedPayloadSchema,
|
|
80
|
+
},
|
|
81
|
+
pluginMetadata
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
env.registerInit({
|
|
85
|
+
schema,
|
|
86
|
+
deps: {
|
|
87
|
+
logger: coreServices.logger,
|
|
88
|
+
rpc: coreServices.rpc,
|
|
89
|
+
rpcClient: coreServices.rpcClient,
|
|
90
|
+
signalService: coreServices.signalService,
|
|
91
|
+
},
|
|
92
|
+
init: async ({ logger, database, rpc, rpcClient, signalService }) => {
|
|
93
|
+
logger.debug("🔧 Initializing Maintenance Backend...");
|
|
94
|
+
|
|
95
|
+
const catalogClient = rpcClient.forPlugin(CatalogApi);
|
|
96
|
+
|
|
97
|
+
const service = new MaintenanceService(
|
|
98
|
+
database as NodePgDatabase<typeof schema>
|
|
99
|
+
);
|
|
100
|
+
const router = createRouter(
|
|
101
|
+
service,
|
|
102
|
+
signalService,
|
|
103
|
+
catalogClient,
|
|
104
|
+
logger
|
|
105
|
+
);
|
|
106
|
+
rpc.registerRouter(router, maintenanceContract);
|
|
107
|
+
|
|
108
|
+
// Register "Create Maintenance" command in the command palette
|
|
109
|
+
registerSearchProvider({
|
|
110
|
+
pluginMetadata,
|
|
111
|
+
commands: [
|
|
112
|
+
{
|
|
113
|
+
id: "create",
|
|
114
|
+
title: "Create Maintenance",
|
|
115
|
+
subtitle: "Schedule a maintenance window",
|
|
116
|
+
iconName: "Wrench",
|
|
117
|
+
route:
|
|
118
|
+
resolveRoute(maintenanceRoutes.routes.config) +
|
|
119
|
+
"?action=create",
|
|
120
|
+
requiredPermissions: [permissions.maintenanceManage],
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
id: "manage",
|
|
124
|
+
title: "Manage Maintenance",
|
|
125
|
+
subtitle: "Manage maintenance windows",
|
|
126
|
+
iconName: "Wrench",
|
|
127
|
+
shortcuts: ["meta+shift+m", "ctrl+shift+m"],
|
|
128
|
+
route: resolveRoute(maintenanceRoutes.routes.config),
|
|
129
|
+
requiredPermissions: [permissions.maintenanceManage],
|
|
130
|
+
},
|
|
131
|
+
],
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
logger.debug("✅ Maintenance Backend initialized.");
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
// Re-export hooks for other plugins to use
|
|
141
|
+
export { maintenanceHooks } from "./hooks";
|
package/src/router.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { implement, ORPCError } from "@orpc/server";
|
|
2
|
+
import {
|
|
3
|
+
maintenanceContract,
|
|
4
|
+
MAINTENANCE_UPDATED,
|
|
5
|
+
maintenanceRoutes,
|
|
6
|
+
} from "@checkmate-monitor/maintenance-common";
|
|
7
|
+
import {
|
|
8
|
+
autoAuthMiddleware,
|
|
9
|
+
Logger,
|
|
10
|
+
type RpcContext,
|
|
11
|
+
} from "@checkmate-monitor/backend-api";
|
|
12
|
+
import type { SignalService } from "@checkmate-monitor/signal-common";
|
|
13
|
+
import type { MaintenanceService } from "./service";
|
|
14
|
+
import { CatalogApi } from "@checkmate-monitor/catalog-common";
|
|
15
|
+
import type { InferClient } from "@checkmate-monitor/common";
|
|
16
|
+
import { resolveRoute } from "@checkmate-monitor/common";
|
|
17
|
+
import { maintenanceHooks } from "./hooks";
|
|
18
|
+
|
|
19
|
+
export function createRouter(
|
|
20
|
+
service: MaintenanceService,
|
|
21
|
+
signalService: SignalService,
|
|
22
|
+
catalogClient: InferClient<typeof CatalogApi>,
|
|
23
|
+
logger: Logger
|
|
24
|
+
) {
|
|
25
|
+
const os = implement(maintenanceContract)
|
|
26
|
+
.$context<RpcContext>()
|
|
27
|
+
.use(autoAuthMiddleware);
|
|
28
|
+
|
|
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
|
+
return os.router({
|
|
71
|
+
listMaintenances: os.listMaintenances.handler(async ({ input }) => {
|
|
72
|
+
return service.listMaintenances(input ?? {});
|
|
73
|
+
}),
|
|
74
|
+
|
|
75
|
+
getMaintenance: os.getMaintenance.handler(async ({ input }) => {
|
|
76
|
+
const result = await service.getMaintenance(input.id);
|
|
77
|
+
// eslint-disable-next-line unicorn/no-null -- oRPC contract requires null for missing values
|
|
78
|
+
return result ?? null;
|
|
79
|
+
}),
|
|
80
|
+
|
|
81
|
+
getMaintenancesForSystem: os.getMaintenancesForSystem.handler(
|
|
82
|
+
async ({ input }) => {
|
|
83
|
+
return service.getMaintenancesForSystem(input.systemId);
|
|
84
|
+
}
|
|
85
|
+
),
|
|
86
|
+
|
|
87
|
+
createMaintenance: os.createMaintenance.handler(
|
|
88
|
+
async ({ input, context }) => {
|
|
89
|
+
const result = await service.createMaintenance(input);
|
|
90
|
+
|
|
91
|
+
// Broadcast signal for realtime updates
|
|
92
|
+
await signalService.broadcast(MAINTENANCE_UPDATED, {
|
|
93
|
+
maintenanceId: result.id,
|
|
94
|
+
systemIds: result.systemIds,
|
|
95
|
+
action: "created",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Emit hook for cross-plugin coordination and integrations
|
|
99
|
+
await context.emitHook(maintenanceHooks.maintenanceCreated, {
|
|
100
|
+
maintenanceId: result.id,
|
|
101
|
+
systemIds: result.systemIds,
|
|
102
|
+
title: result.title,
|
|
103
|
+
description: result.description,
|
|
104
|
+
status: result.status,
|
|
105
|
+
startAt: result.startAt.toISOString(),
|
|
106
|
+
endAt: result.endAt.toISOString(),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// Send notifications to system subscribers
|
|
110
|
+
await notifyAffectedSystems({
|
|
111
|
+
maintenanceId: result.id,
|
|
112
|
+
maintenanceTitle: result.title,
|
|
113
|
+
systemIds: result.systemIds,
|
|
114
|
+
action: "created",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
),
|
|
120
|
+
|
|
121
|
+
updateMaintenance: os.updateMaintenance.handler(
|
|
122
|
+
async ({ input, context }) => {
|
|
123
|
+
const result = await service.updateMaintenance(input);
|
|
124
|
+
if (!result) {
|
|
125
|
+
throw new ORPCError("NOT_FOUND", {
|
|
126
|
+
message: "Maintenance not found",
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Broadcast signal for realtime updates
|
|
131
|
+
await signalService.broadcast(MAINTENANCE_UPDATED, {
|
|
132
|
+
maintenanceId: result.id,
|
|
133
|
+
systemIds: result.systemIds,
|
|
134
|
+
action: "updated",
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// Emit hook for cross-plugin coordination and integrations
|
|
138
|
+
await context.emitHook(maintenanceHooks.maintenanceUpdated, {
|
|
139
|
+
maintenanceId: result.id,
|
|
140
|
+
systemIds: result.systemIds,
|
|
141
|
+
title: result.title,
|
|
142
|
+
description: result.description,
|
|
143
|
+
status: result.status,
|
|
144
|
+
startAt: result.startAt.toISOString(),
|
|
145
|
+
endAt: result.endAt.toISOString(),
|
|
146
|
+
action: "updated",
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// Send notifications to system subscribers
|
|
150
|
+
await notifyAffectedSystems({
|
|
151
|
+
maintenanceId: result.id,
|
|
152
|
+
maintenanceTitle: result.title,
|
|
153
|
+
systemIds: result.systemIds,
|
|
154
|
+
action: "updated",
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
return result;
|
|
158
|
+
}
|
|
159
|
+
),
|
|
160
|
+
|
|
161
|
+
addUpdate: os.addUpdate.handler(async ({ input, context }) => {
|
|
162
|
+
const userId =
|
|
163
|
+
context.user && "id" in context.user ? context.user.id : undefined;
|
|
164
|
+
const result = await service.addUpdate(input, userId);
|
|
165
|
+
// Get maintenance to broadcast with correct systemIds
|
|
166
|
+
const maintenance = await service.getMaintenance(input.maintenanceId);
|
|
167
|
+
if (maintenance) {
|
|
168
|
+
await signalService.broadcast(MAINTENANCE_UPDATED, {
|
|
169
|
+
maintenanceId: input.maintenanceId,
|
|
170
|
+
systemIds: maintenance.systemIds,
|
|
171
|
+
action: "updated",
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
// Emit hook for cross-plugin coordination and integrations
|
|
175
|
+
await context.emitHook(maintenanceHooks.maintenanceUpdated, {
|
|
176
|
+
maintenanceId: input.maintenanceId,
|
|
177
|
+
systemIds: maintenance.systemIds,
|
|
178
|
+
title: maintenance.title,
|
|
179
|
+
description: maintenance.description,
|
|
180
|
+
status: maintenance.status,
|
|
181
|
+
startAt: maintenance.startAt.toISOString(),
|
|
182
|
+
endAt: maintenance.endAt.toISOString(),
|
|
183
|
+
action: "updated",
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
return result;
|
|
187
|
+
}),
|
|
188
|
+
|
|
189
|
+
closeMaintenance: os.closeMaintenance.handler(
|
|
190
|
+
async ({ input, context }) => {
|
|
191
|
+
const userId =
|
|
192
|
+
context.user && "id" in context.user ? context.user.id : undefined;
|
|
193
|
+
const result = await service.closeMaintenance(
|
|
194
|
+
input.id,
|
|
195
|
+
input.message,
|
|
196
|
+
userId
|
|
197
|
+
);
|
|
198
|
+
if (!result) {
|
|
199
|
+
throw new ORPCError("NOT_FOUND", {
|
|
200
|
+
message: "Maintenance not found",
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
// Broadcast signal for realtime updates
|
|
204
|
+
await signalService.broadcast(MAINTENANCE_UPDATED, {
|
|
205
|
+
maintenanceId: result.id,
|
|
206
|
+
systemIds: result.systemIds,
|
|
207
|
+
action: "closed",
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// Emit hook for cross-plugin coordination and integrations
|
|
211
|
+
await context.emitHook(maintenanceHooks.maintenanceUpdated, {
|
|
212
|
+
maintenanceId: result.id,
|
|
213
|
+
systemIds: result.systemIds,
|
|
214
|
+
title: result.title,
|
|
215
|
+
description: result.description,
|
|
216
|
+
status: result.status,
|
|
217
|
+
startAt: result.startAt.toISOString(),
|
|
218
|
+
endAt: result.endAt.toISOString(),
|
|
219
|
+
action: "closed",
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
return result;
|
|
223
|
+
}
|
|
224
|
+
),
|
|
225
|
+
|
|
226
|
+
deleteMaintenance: os.deleteMaintenance.handler(async ({ input }) => {
|
|
227
|
+
// Get maintenance before deleting to get systemIds
|
|
228
|
+
const maintenance = await service.getMaintenance(input.id);
|
|
229
|
+
const success = await service.deleteMaintenance(input.id);
|
|
230
|
+
if (success && maintenance) {
|
|
231
|
+
await signalService.broadcast(MAINTENANCE_UPDATED, {
|
|
232
|
+
maintenanceId: input.id,
|
|
233
|
+
systemIds: maintenance.systemIds,
|
|
234
|
+
action: "closed", // Use "closed" for delete as well
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
return { success };
|
|
238
|
+
}),
|
|
239
|
+
});
|
|
240
|
+
}
|
package/src/schema.ts
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import {
|
|
2
|
+
pgTable,
|
|
3
|
+
pgEnum,
|
|
4
|
+
text,
|
|
5
|
+
timestamp,
|
|
6
|
+
primaryKey,
|
|
7
|
+
} from "drizzle-orm/pg-core";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Maintenance status enum
|
|
11
|
+
*/
|
|
12
|
+
export const maintenanceStatusEnum = pgEnum("maintenance_status", [
|
|
13
|
+
"scheduled",
|
|
14
|
+
"in_progress",
|
|
15
|
+
"completed",
|
|
16
|
+
"cancelled",
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Main maintenance table
|
|
21
|
+
*/
|
|
22
|
+
export const maintenances = pgTable("maintenances", {
|
|
23
|
+
id: text("id").primaryKey(),
|
|
24
|
+
title: text("title").notNull(),
|
|
25
|
+
description: text("description"),
|
|
26
|
+
status: maintenanceStatusEnum("status").notNull().default("scheduled"),
|
|
27
|
+
startAt: timestamp("start_at").notNull(),
|
|
28
|
+
endAt: timestamp("end_at").notNull(),
|
|
29
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
30
|
+
updatedAt: timestamp("updated_at").defaultNow().notNull(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Junction table for maintenance-system many-to-many relationship
|
|
35
|
+
*/
|
|
36
|
+
export const maintenanceSystems = pgTable(
|
|
37
|
+
"maintenance_systems",
|
|
38
|
+
{
|
|
39
|
+
maintenanceId: text("maintenance_id")
|
|
40
|
+
.notNull()
|
|
41
|
+
.references(() => maintenances.id, { onDelete: "cascade" }),
|
|
42
|
+
systemId: text("system_id").notNull(),
|
|
43
|
+
},
|
|
44
|
+
(t) => ({
|
|
45
|
+
pk: primaryKey(t.maintenanceId, t.systemId),
|
|
46
|
+
})
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Status updates for maintenances
|
|
51
|
+
*/
|
|
52
|
+
export const maintenanceUpdates = pgTable("maintenance_updates", {
|
|
53
|
+
id: text("id").primaryKey(),
|
|
54
|
+
maintenanceId: text("maintenance_id")
|
|
55
|
+
.notNull()
|
|
56
|
+
.references(() => maintenances.id, { onDelete: "cascade" }),
|
|
57
|
+
message: text("message").notNull(),
|
|
58
|
+
statusChange: maintenanceStatusEnum("status_change"),
|
|
59
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
60
|
+
createdBy: text("created_by"),
|
|
61
|
+
});
|
package/src/service.ts
ADDED
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { eq, and, or, inArray } from "drizzle-orm";
|
|
2
|
+
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
3
|
+
import * as schema from "./schema";
|
|
4
|
+
import { maintenances, maintenanceSystems, maintenanceUpdates } from "./schema";
|
|
5
|
+
import type {
|
|
6
|
+
MaintenanceWithSystems,
|
|
7
|
+
MaintenanceDetail,
|
|
8
|
+
MaintenanceUpdate,
|
|
9
|
+
CreateMaintenanceInput,
|
|
10
|
+
UpdateMaintenanceInput,
|
|
11
|
+
AddMaintenanceUpdateInput,
|
|
12
|
+
MaintenanceStatus,
|
|
13
|
+
} from "@checkmate-monitor/maintenance-common";
|
|
14
|
+
|
|
15
|
+
type Db = NodePgDatabase<typeof schema>;
|
|
16
|
+
|
|
17
|
+
function generateId(): string {
|
|
18
|
+
return crypto.randomUUID();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export class MaintenanceService {
|
|
22
|
+
constructor(private db: Db) {}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* List maintenances with optional filters
|
|
26
|
+
*/
|
|
27
|
+
async listMaintenances(filters?: {
|
|
28
|
+
status?: MaintenanceStatus;
|
|
29
|
+
systemId?: string;
|
|
30
|
+
}): Promise<MaintenanceWithSystems[]> {
|
|
31
|
+
let maintenanceRows;
|
|
32
|
+
|
|
33
|
+
if (filters?.systemId) {
|
|
34
|
+
// Filter by system - need to join
|
|
35
|
+
const systemMaintenanceIds = await this.db
|
|
36
|
+
.select({ maintenanceId: maintenanceSystems.maintenanceId })
|
|
37
|
+
.from(maintenanceSystems)
|
|
38
|
+
.where(eq(maintenanceSystems.systemId, filters.systemId));
|
|
39
|
+
|
|
40
|
+
const ids = systemMaintenanceIds.map((r) => r.maintenanceId);
|
|
41
|
+
if (ids.length === 0) return [];
|
|
42
|
+
|
|
43
|
+
maintenanceRows = await this.db
|
|
44
|
+
.select()
|
|
45
|
+
.from(maintenances)
|
|
46
|
+
.where(
|
|
47
|
+
and(
|
|
48
|
+
inArray(maintenances.id, ids),
|
|
49
|
+
filters.status ? eq(maintenances.status, filters.status) : undefined
|
|
50
|
+
)
|
|
51
|
+
);
|
|
52
|
+
} else {
|
|
53
|
+
maintenanceRows = await this.db
|
|
54
|
+
.select()
|
|
55
|
+
.from(maintenances)
|
|
56
|
+
.where(
|
|
57
|
+
filters?.status ? eq(maintenances.status, filters.status) : undefined
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Fetch all system associations
|
|
62
|
+
const result: MaintenanceWithSystems[] = [];
|
|
63
|
+
for (const m of maintenanceRows) {
|
|
64
|
+
const systems = await this.db
|
|
65
|
+
.select({ systemId: maintenanceSystems.systemId })
|
|
66
|
+
.from(maintenanceSystems)
|
|
67
|
+
.where(eq(maintenanceSystems.maintenanceId, m.id));
|
|
68
|
+
|
|
69
|
+
result.push({
|
|
70
|
+
...m,
|
|
71
|
+
description: m.description ?? undefined,
|
|
72
|
+
systemIds: systems.map((s) => s.systemId),
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return result;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Get single maintenance with full details
|
|
81
|
+
*/
|
|
82
|
+
async getMaintenance(id: string): Promise<MaintenanceDetail | undefined> {
|
|
83
|
+
const [maintenance] = await this.db
|
|
84
|
+
.select()
|
|
85
|
+
.from(maintenances)
|
|
86
|
+
.where(eq(maintenances.id, id));
|
|
87
|
+
|
|
88
|
+
if (!maintenance) return undefined;
|
|
89
|
+
|
|
90
|
+
const systems = await this.db
|
|
91
|
+
.select({ systemId: maintenanceSystems.systemId })
|
|
92
|
+
.from(maintenanceSystems)
|
|
93
|
+
.where(eq(maintenanceSystems.maintenanceId, id));
|
|
94
|
+
|
|
95
|
+
const updates = await this.db
|
|
96
|
+
.select()
|
|
97
|
+
.from(maintenanceUpdates)
|
|
98
|
+
.where(eq(maintenanceUpdates.maintenanceId, id));
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
...maintenance,
|
|
102
|
+
description: maintenance.description ?? undefined,
|
|
103
|
+
systemIds: systems.map((s) => s.systemId),
|
|
104
|
+
updates: updates.map((u) => ({
|
|
105
|
+
...u,
|
|
106
|
+
statusChange: u.statusChange ?? undefined,
|
|
107
|
+
createdBy: u.createdBy ?? undefined,
|
|
108
|
+
})),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Get active/upcoming maintenances for a system
|
|
114
|
+
*/
|
|
115
|
+
async getMaintenancesForSystem(
|
|
116
|
+
systemId: string
|
|
117
|
+
): Promise<MaintenanceWithSystems[]> {
|
|
118
|
+
const _now = new Date();
|
|
119
|
+
|
|
120
|
+
// Get maintenance IDs for this system
|
|
121
|
+
const systemMaintenances = await this.db
|
|
122
|
+
.select({ maintenanceId: maintenanceSystems.maintenanceId })
|
|
123
|
+
.from(maintenanceSystems)
|
|
124
|
+
.where(eq(maintenanceSystems.systemId, systemId));
|
|
125
|
+
|
|
126
|
+
const ids = systemMaintenances.map((r) => r.maintenanceId);
|
|
127
|
+
if (ids.length === 0) return [];
|
|
128
|
+
|
|
129
|
+
// Get only scheduled or in_progress maintenances ending in the future
|
|
130
|
+
const rows = await this.db
|
|
131
|
+
.select()
|
|
132
|
+
.from(maintenances)
|
|
133
|
+
.where(
|
|
134
|
+
and(
|
|
135
|
+
inArray(maintenances.id, ids),
|
|
136
|
+
or(
|
|
137
|
+
eq(maintenances.status, "scheduled"),
|
|
138
|
+
eq(maintenances.status, "in_progress")
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
// Fetch system IDs for each
|
|
144
|
+
const result: MaintenanceWithSystems[] = [];
|
|
145
|
+
for (const m of rows) {
|
|
146
|
+
const systems = await this.db
|
|
147
|
+
.select({ systemId: maintenanceSystems.systemId })
|
|
148
|
+
.from(maintenanceSystems)
|
|
149
|
+
.where(eq(maintenanceSystems.maintenanceId, m.id));
|
|
150
|
+
|
|
151
|
+
result.push({
|
|
152
|
+
...m,
|
|
153
|
+
description: m.description ?? undefined,
|
|
154
|
+
systemIds: systems.map((s) => s.systemId),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Create a new maintenance
|
|
163
|
+
*/
|
|
164
|
+
async createMaintenance(
|
|
165
|
+
input: CreateMaintenanceInput
|
|
166
|
+
): Promise<MaintenanceWithSystems> {
|
|
167
|
+
const id = generateId();
|
|
168
|
+
|
|
169
|
+
await this.db.insert(maintenances).values({
|
|
170
|
+
id,
|
|
171
|
+
title: input.title,
|
|
172
|
+
description: input.description,
|
|
173
|
+
status: "scheduled",
|
|
174
|
+
startAt: input.startAt,
|
|
175
|
+
endAt: input.endAt,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
// Insert system associations
|
|
179
|
+
for (const systemId of input.systemIds) {
|
|
180
|
+
await this.db.insert(maintenanceSystems).values({
|
|
181
|
+
maintenanceId: id,
|
|
182
|
+
systemId,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return (await this.getMaintenance(id))!;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Update an existing maintenance
|
|
191
|
+
*/
|
|
192
|
+
async updateMaintenance(
|
|
193
|
+
input: UpdateMaintenanceInput
|
|
194
|
+
): Promise<MaintenanceWithSystems | undefined> {
|
|
195
|
+
const [existing] = await this.db
|
|
196
|
+
.select()
|
|
197
|
+
.from(maintenances)
|
|
198
|
+
.where(eq(maintenances.id, input.id));
|
|
199
|
+
|
|
200
|
+
if (!existing) return undefined;
|
|
201
|
+
|
|
202
|
+
// Build update object
|
|
203
|
+
const updateData: Partial<typeof maintenances.$inferInsert> = {
|
|
204
|
+
updatedAt: new Date(),
|
|
205
|
+
};
|
|
206
|
+
if (input.title !== undefined) updateData.title = input.title;
|
|
207
|
+
if (input.description !== undefined)
|
|
208
|
+
updateData.description = input.description;
|
|
209
|
+
if (input.startAt !== undefined) updateData.startAt = input.startAt;
|
|
210
|
+
if (input.endAt !== undefined) updateData.endAt = input.endAt;
|
|
211
|
+
|
|
212
|
+
await this.db
|
|
213
|
+
.update(maintenances)
|
|
214
|
+
.set(updateData)
|
|
215
|
+
.where(eq(maintenances.id, input.id));
|
|
216
|
+
|
|
217
|
+
// Update system associations if provided
|
|
218
|
+
if (input.systemIds !== undefined) {
|
|
219
|
+
await this.db
|
|
220
|
+
.delete(maintenanceSystems)
|
|
221
|
+
.where(eq(maintenanceSystems.maintenanceId, input.id));
|
|
222
|
+
|
|
223
|
+
for (const systemId of input.systemIds) {
|
|
224
|
+
await this.db.insert(maintenanceSystems).values({
|
|
225
|
+
maintenanceId: input.id,
|
|
226
|
+
systemId,
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return (await this.getMaintenance(input.id))!;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Add a status update to a maintenance
|
|
236
|
+
*/
|
|
237
|
+
async addUpdate(
|
|
238
|
+
input: AddMaintenanceUpdateInput,
|
|
239
|
+
userId?: string
|
|
240
|
+
): Promise<MaintenanceUpdate> {
|
|
241
|
+
const id = generateId();
|
|
242
|
+
|
|
243
|
+
// If status change is provided, update the maintenance status
|
|
244
|
+
if (input.statusChange) {
|
|
245
|
+
await this.db
|
|
246
|
+
.update(maintenances)
|
|
247
|
+
.set({ status: input.statusChange, updatedAt: new Date() })
|
|
248
|
+
.where(eq(maintenances.id, input.maintenanceId));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
await this.db.insert(maintenanceUpdates).values({
|
|
252
|
+
id,
|
|
253
|
+
maintenanceId: input.maintenanceId,
|
|
254
|
+
message: input.message,
|
|
255
|
+
statusChange: input.statusChange,
|
|
256
|
+
createdBy: userId,
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const [update] = await this.db
|
|
260
|
+
.select()
|
|
261
|
+
.from(maintenanceUpdates)
|
|
262
|
+
.where(eq(maintenanceUpdates.id, id));
|
|
263
|
+
|
|
264
|
+
return {
|
|
265
|
+
...update,
|
|
266
|
+
statusChange: update.statusChange ?? undefined,
|
|
267
|
+
createdBy: update.createdBy ?? undefined,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Close a maintenance early
|
|
273
|
+
*/
|
|
274
|
+
async closeMaintenance(
|
|
275
|
+
id: string,
|
|
276
|
+
message?: string,
|
|
277
|
+
userId?: string
|
|
278
|
+
): Promise<MaintenanceWithSystems | undefined> {
|
|
279
|
+
const [existing] = await this.db
|
|
280
|
+
.select()
|
|
281
|
+
.from(maintenances)
|
|
282
|
+
.where(eq(maintenances.id, id));
|
|
283
|
+
|
|
284
|
+
if (!existing) return undefined;
|
|
285
|
+
|
|
286
|
+
await this.db
|
|
287
|
+
.update(maintenances)
|
|
288
|
+
.set({ status: "completed", updatedAt: new Date() })
|
|
289
|
+
.where(eq(maintenances.id, id));
|
|
290
|
+
|
|
291
|
+
// Add update entry
|
|
292
|
+
await this.db.insert(maintenanceUpdates).values({
|
|
293
|
+
id: generateId(),
|
|
294
|
+
maintenanceId: id,
|
|
295
|
+
message: message ?? "Maintenance completed early",
|
|
296
|
+
statusChange: "completed",
|
|
297
|
+
createdBy: userId,
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
return (await this.getMaintenance(id))!;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Delete a maintenance
|
|
305
|
+
*/
|
|
306
|
+
async deleteMaintenance(id: string): Promise<boolean> {
|
|
307
|
+
const [existing] = await this.db
|
|
308
|
+
.select()
|
|
309
|
+
.from(maintenances)
|
|
310
|
+
.where(eq(maintenances.id, id));
|
|
311
|
+
|
|
312
|
+
if (!existing) return false;
|
|
313
|
+
|
|
314
|
+
// Cascade delete handles junctions and updates
|
|
315
|
+
await this.db.delete(maintenances).where(eq(maintenances.id, id));
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Get unique subscriber user IDs for a maintenance's systems
|
|
321
|
+
* Uses the notification system to get subscribers and deduplicate
|
|
322
|
+
*/
|
|
323
|
+
async getSystemSubscribers(_systemIds: string[]): Promise<Set<string>> {
|
|
324
|
+
// This will be implemented with notification integration
|
|
325
|
+
// For now, return empty set
|
|
326
|
+
return new Set();
|
|
327
|
+
}
|
|
328
|
+
}
|