@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 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
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1767319104427,
9
+ "tag": "0000_numerous_shadow_king",
10
+ "breakpoints": true
11
+ }
12
+ ]
13
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "drizzle-kit";
2
+
3
+ export default defineConfig({
4
+ schema: "./src/schema.ts",
5
+ out: "./drizzle",
6
+ dialect: "postgresql",
7
+ });
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
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkmate-monitor/tsconfig/backend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }