@checkstack/catalog-backend 0.2.9 → 0.2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # @checkstack/catalog-backend
2
2
 
3
+ ## 0.2.11
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [f676e11]
8
+ - Updated dependencies [48c2080]
9
+ - @checkstack/common@0.6.2
10
+ - @checkstack/backend-api@0.6.0
11
+ - @checkstack/auth-backend@0.4.6
12
+ - @checkstack/auth-common@0.5.5
13
+ - @checkstack/catalog-common@1.2.7
14
+ - @checkstack/command-backend@0.1.9
15
+ - @checkstack/notification-common@0.2.5
16
+
17
+ ## 0.2.10
18
+
19
+ ### Patch Changes
20
+
21
+ - Updated dependencies [e5079e1]
22
+ - @checkstack/catalog-common@1.2.6
23
+
3
24
  ## 0.2.9
4
25
 
5
26
  ### Patch Changes
@@ -0,0 +1,13 @@
1
+ CREATE TYPE "contact_type" AS ENUM('user', 'mailbox');--> statement-breakpoint
2
+ CREATE TABLE "system_contacts" (
3
+ "id" text PRIMARY KEY NOT NULL,
4
+ "system_id" text NOT NULL,
5
+ "type" "contact_type" NOT NULL,
6
+ "user_id" text,
7
+ "email" text,
8
+ "label" text,
9
+ "created_at" timestamp DEFAULT now() NOT NULL
10
+ );
11
+ --> statement-breakpoint
12
+ ALTER TABLE "system_contacts" ADD CONSTRAINT "system_contacts_system_id_systems_id_fk" FOREIGN KEY ("system_id") REFERENCES "systems"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
13
+ ALTER TABLE "systems" DROP COLUMN "owner";
@@ -0,0 +1,309 @@
1
+ {
2
+ "id": "72cd2f27-3627-475c-96f6-778b75a35418",
3
+ "prevId": "79e86bc7-76a2-429f-815c-2d6c263aab8f",
4
+ "version": "7",
5
+ "dialect": "postgresql",
6
+ "tables": {
7
+ "public.groups": {
8
+ "name": "groups",
9
+ "schema": "",
10
+ "columns": {
11
+ "id": {
12
+ "name": "id",
13
+ "type": "text",
14
+ "primaryKey": true,
15
+ "notNull": true
16
+ },
17
+ "name": {
18
+ "name": "name",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": true
22
+ },
23
+ "metadata": {
24
+ "name": "metadata",
25
+ "type": "json",
26
+ "primaryKey": false,
27
+ "notNull": false,
28
+ "default": "'{}'::json"
29
+ },
30
+ "created_at": {
31
+ "name": "created_at",
32
+ "type": "timestamp",
33
+ "primaryKey": false,
34
+ "notNull": true,
35
+ "default": "now()"
36
+ },
37
+ "updated_at": {
38
+ "name": "updated_at",
39
+ "type": "timestamp",
40
+ "primaryKey": false,
41
+ "notNull": true,
42
+ "default": "now()"
43
+ }
44
+ },
45
+ "indexes": {},
46
+ "foreignKeys": {},
47
+ "compositePrimaryKeys": {},
48
+ "uniqueConstraints": {},
49
+ "policies": {},
50
+ "checkConstraints": {},
51
+ "isRLSEnabled": false
52
+ },
53
+ "public.system_contacts": {
54
+ "name": "system_contacts",
55
+ "schema": "",
56
+ "columns": {
57
+ "id": {
58
+ "name": "id",
59
+ "type": "text",
60
+ "primaryKey": true,
61
+ "notNull": true
62
+ },
63
+ "system_id": {
64
+ "name": "system_id",
65
+ "type": "text",
66
+ "primaryKey": false,
67
+ "notNull": true
68
+ },
69
+ "type": {
70
+ "name": "type",
71
+ "type": "contact_type",
72
+ "typeSchema": "public",
73
+ "primaryKey": false,
74
+ "notNull": true
75
+ },
76
+ "user_id": {
77
+ "name": "user_id",
78
+ "type": "text",
79
+ "primaryKey": false,
80
+ "notNull": false
81
+ },
82
+ "email": {
83
+ "name": "email",
84
+ "type": "text",
85
+ "primaryKey": false,
86
+ "notNull": false
87
+ },
88
+ "label": {
89
+ "name": "label",
90
+ "type": "text",
91
+ "primaryKey": false,
92
+ "notNull": false
93
+ },
94
+ "created_at": {
95
+ "name": "created_at",
96
+ "type": "timestamp",
97
+ "primaryKey": false,
98
+ "notNull": true,
99
+ "default": "now()"
100
+ }
101
+ },
102
+ "indexes": {},
103
+ "foreignKeys": {
104
+ "system_contacts_system_id_systems_id_fk": {
105
+ "name": "system_contacts_system_id_systems_id_fk",
106
+ "tableFrom": "system_contacts",
107
+ "tableTo": "systems",
108
+ "columnsFrom": [
109
+ "system_id"
110
+ ],
111
+ "columnsTo": [
112
+ "id"
113
+ ],
114
+ "onDelete": "cascade",
115
+ "onUpdate": "no action"
116
+ }
117
+ },
118
+ "compositePrimaryKeys": {},
119
+ "uniqueConstraints": {},
120
+ "policies": {},
121
+ "checkConstraints": {},
122
+ "isRLSEnabled": false
123
+ },
124
+ "public.systems": {
125
+ "name": "systems",
126
+ "schema": "",
127
+ "columns": {
128
+ "id": {
129
+ "name": "id",
130
+ "type": "text",
131
+ "primaryKey": true,
132
+ "notNull": true
133
+ },
134
+ "name": {
135
+ "name": "name",
136
+ "type": "text",
137
+ "primaryKey": false,
138
+ "notNull": true
139
+ },
140
+ "description": {
141
+ "name": "description",
142
+ "type": "text",
143
+ "primaryKey": false,
144
+ "notNull": false
145
+ },
146
+ "metadata": {
147
+ "name": "metadata",
148
+ "type": "json",
149
+ "primaryKey": false,
150
+ "notNull": false,
151
+ "default": "'{}'::json"
152
+ },
153
+ "created_at": {
154
+ "name": "created_at",
155
+ "type": "timestamp",
156
+ "primaryKey": false,
157
+ "notNull": true,
158
+ "default": "now()"
159
+ },
160
+ "updated_at": {
161
+ "name": "updated_at",
162
+ "type": "timestamp",
163
+ "primaryKey": false,
164
+ "notNull": true,
165
+ "default": "now()"
166
+ }
167
+ },
168
+ "indexes": {},
169
+ "foreignKeys": {},
170
+ "compositePrimaryKeys": {},
171
+ "uniqueConstraints": {},
172
+ "policies": {},
173
+ "checkConstraints": {},
174
+ "isRLSEnabled": false
175
+ },
176
+ "public.systems_groups": {
177
+ "name": "systems_groups",
178
+ "schema": "",
179
+ "columns": {
180
+ "system_id": {
181
+ "name": "system_id",
182
+ "type": "text",
183
+ "primaryKey": false,
184
+ "notNull": true
185
+ },
186
+ "group_id": {
187
+ "name": "group_id",
188
+ "type": "text",
189
+ "primaryKey": false,
190
+ "notNull": true
191
+ }
192
+ },
193
+ "indexes": {},
194
+ "foreignKeys": {
195
+ "systems_groups_system_id_systems_id_fk": {
196
+ "name": "systems_groups_system_id_systems_id_fk",
197
+ "tableFrom": "systems_groups",
198
+ "tableTo": "systems",
199
+ "columnsFrom": [
200
+ "system_id"
201
+ ],
202
+ "columnsTo": [
203
+ "id"
204
+ ],
205
+ "onDelete": "cascade",
206
+ "onUpdate": "no action"
207
+ },
208
+ "systems_groups_group_id_groups_id_fk": {
209
+ "name": "systems_groups_group_id_groups_id_fk",
210
+ "tableFrom": "systems_groups",
211
+ "tableTo": "groups",
212
+ "columnsFrom": [
213
+ "group_id"
214
+ ],
215
+ "columnsTo": [
216
+ "id"
217
+ ],
218
+ "onDelete": "cascade",
219
+ "onUpdate": "no action"
220
+ }
221
+ },
222
+ "compositePrimaryKeys": {
223
+ "systems_groups_system_id_group_id_pk": {
224
+ "name": "systems_groups_system_id_group_id_pk",
225
+ "columns": [
226
+ "system_id",
227
+ "group_id"
228
+ ]
229
+ }
230
+ },
231
+ "uniqueConstraints": {},
232
+ "policies": {},
233
+ "checkConstraints": {},
234
+ "isRLSEnabled": false
235
+ },
236
+ "public.views": {
237
+ "name": "views",
238
+ "schema": "",
239
+ "columns": {
240
+ "id": {
241
+ "name": "id",
242
+ "type": "text",
243
+ "primaryKey": true,
244
+ "notNull": true
245
+ },
246
+ "name": {
247
+ "name": "name",
248
+ "type": "text",
249
+ "primaryKey": false,
250
+ "notNull": true
251
+ },
252
+ "description": {
253
+ "name": "description",
254
+ "type": "text",
255
+ "primaryKey": false,
256
+ "notNull": false
257
+ },
258
+ "configuration": {
259
+ "name": "configuration",
260
+ "type": "json",
261
+ "primaryKey": false,
262
+ "notNull": true,
263
+ "default": "'[]'::json"
264
+ },
265
+ "created_at": {
266
+ "name": "created_at",
267
+ "type": "timestamp",
268
+ "primaryKey": false,
269
+ "notNull": true,
270
+ "default": "now()"
271
+ },
272
+ "updated_at": {
273
+ "name": "updated_at",
274
+ "type": "timestamp",
275
+ "primaryKey": false,
276
+ "notNull": true,
277
+ "default": "now()"
278
+ }
279
+ },
280
+ "indexes": {},
281
+ "foreignKeys": {},
282
+ "compositePrimaryKeys": {},
283
+ "uniqueConstraints": {},
284
+ "policies": {},
285
+ "checkConstraints": {},
286
+ "isRLSEnabled": false
287
+ }
288
+ },
289
+ "enums": {
290
+ "public.contact_type": {
291
+ "name": "contact_type",
292
+ "schema": "public",
293
+ "values": [
294
+ "user",
295
+ "mailbox"
296
+ ]
297
+ }
298
+ },
299
+ "schemas": {},
300
+ "sequences": {},
301
+ "roles": {},
302
+ "policies": {},
303
+ "views": {},
304
+ "_meta": {
305
+ "columns": {},
306
+ "schemas": {},
307
+ "tables": {}
308
+ }
309
+ }
@@ -8,6 +8,13 @@
8
8
  "when": 1767319091249,
9
9
  "tag": "0000_purple_doomsday",
10
10
  "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "7",
15
+ "when": 1769034170052,
16
+ "tag": "0001_early_madrox",
17
+ "breakpoints": true
11
18
  }
12
19
  ]
13
20
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/catalog-backend",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "type": "module",
5
5
  "main": "src/index.ts",
6
6
  "scripts": {
@@ -10,21 +10,23 @@
10
10
  "lint:code": "eslint . --max-warnings 0"
11
11
  },
12
12
  "dependencies": {
13
- "@checkstack/backend-api": "0.5.1",
14
- "@checkstack/catalog-common": "1.2.4",
15
- "@checkstack/command-backend": "0.1.7",
16
- "@checkstack/notification-common": "0.2.3",
13
+ "@checkstack/backend-api": "0.5.2",
14
+ "@checkstack/auth-common": "0.5.4",
15
+ "@checkstack/catalog-common": "1.2.6",
16
+ "@checkstack/command-backend": "0.1.8",
17
+ "@checkstack/auth-backend": "0.4.5",
18
+ "@checkstack/notification-common": "0.2.4",
17
19
  "@orpc/server": "^1.13.2",
18
20
  "drizzle-orm": "^0.45.1",
19
21
  "hono": "^4.0.0",
20
22
  "uuid": "^13.0.0",
21
23
  "zod": "^4.2.1",
22
- "@checkstack/common": "0.6.0"
24
+ "@checkstack/common": "0.6.1"
23
25
  },
24
26
  "devDependencies": {
25
- "@checkstack/drizzle-helper": "0.0.2",
26
- "@checkstack/scripts": "0.1.0",
27
- "@checkstack/tsconfig": "0.0.2",
27
+ "@checkstack/drizzle-helper": "0.0.3",
28
+ "@checkstack/scripts": "0.1.1",
29
+ "@checkstack/tsconfig": "0.0.3",
28
30
  "@types/bun": "^1.3.5",
29
31
  "@types/node": "^20.0.0",
30
32
  "@types/uuid": "^11.0.0",
package/src/index.ts CHANGED
@@ -12,8 +12,11 @@ import {
12
12
  } from "@checkstack/catalog-common";
13
13
  import { createCatalogRouter } from "./router";
14
14
  import { NotificationApi } from "@checkstack/notification-common";
15
+ import { AuthApi } from "@checkstack/auth-common";
16
+ import { authHooks } from "@checkstack/auth-backend";
15
17
  import { resolveRoute, type InferClient } from "@checkstack/common";
16
18
  import { registerSearchProvider } from "@checkstack/command-backend";
19
+ import { EntityService } from "./services/entity-service";
17
20
 
18
21
  // Database schema is still needed for types in creating the router
19
22
  import * as schema from "./schema";
@@ -43,11 +46,13 @@ export default createBackendPlugin({
43
46
 
44
47
  // Get notification client for group management and sending notifications
45
48
  const notificationClient = rpcClient.forPlugin(NotificationApi);
49
+ const authClient = rpcClient.forPlugin(AuthApi);
46
50
 
47
- // Register oRPC router with notification client
51
+ // Register oRPC router with notification client and auth client
48
52
  const catalogRouter = createCatalogRouter({
49
53
  database: typedDb,
50
54
  notificationClient,
55
+ authClient,
51
56
  pluginId: pluginMetadata.pluginId,
52
57
  });
53
58
  rpc.registerRouter(catalogRouter, catalogContract);
@@ -108,12 +113,24 @@ export default createBackendPlugin({
108
113
  logger.debug("✅ Catalog Backend initialized.");
109
114
  },
110
115
  // Phase 3: Safe to make RPC calls after all plugins are ready
111
- afterPluginsReady: async ({ database, rpcClient, logger }) => {
116
+ afterPluginsReady: async ({ database, rpcClient, logger, onHook }) => {
112
117
  const typedDb = database as SafeDatabase<typeof schema>;
113
118
  const notificationClient = rpcClient.forPlugin(NotificationApi);
114
119
 
115
120
  // Bootstrap: Create notification groups for existing systems and groups
116
121
  await bootstrapNotificationGroups(typedDb, notificationClient, logger);
122
+
123
+ // Subscribe to user deletion to clean up user contacts
124
+ onHook(
125
+ authHooks.userDeleted,
126
+ async ({ userId }) => {
127
+ logger.debug(`Cleaning up contacts for deleted user: ${userId}`);
128
+ const entityService = new EntityService(typedDb);
129
+ await entityService.deleteContactsByUserId(userId);
130
+ logger.debug(`Cleaned up contacts for user: ${userId}`);
131
+ },
132
+ { mode: "work-queue", workerGroup: "user-cleanup" },
133
+ );
117
134
  },
118
135
  });
119
136
  },
package/src/router.ts CHANGED
@@ -1,10 +1,14 @@
1
1
  import { implement, ORPCError } from "@orpc/server";
2
2
  import { autoAuthMiddleware, type RpcContext } from "@checkstack/backend-api";
3
- import { catalogContract } from "@checkstack/catalog-common";
3
+ import {
4
+ catalogContract,
5
+ type SystemContact,
6
+ } from "@checkstack/catalog-common";
4
7
  import { EntityService } from "./services/entity-service";
5
8
  import type { SafeDatabase } from "@checkstack/backend-api";
6
9
  import * as schema from "./schema";
7
10
  import { NotificationApi } from "@checkstack/notification-common";
11
+ import { AuthApi } from "@checkstack/auth-common";
8
12
  import type { InferClient } from "@checkstack/common";
9
13
  import { catalogHooks } from "./hooks";
10
14
  import { eq } from "drizzle-orm";
@@ -22,12 +26,14 @@ const os = implement(catalogContract)
22
26
  export interface CatalogRouterDeps {
23
27
  database: SafeDatabase<typeof schema>;
24
28
  notificationClient: InferClient<typeof NotificationApi>;
29
+ authClient: InferClient<typeof AuthApi>;
25
30
  pluginId: string;
26
31
  }
27
32
 
28
33
  export const createCatalogRouter = ({
29
34
  database,
30
35
  notificationClient,
36
+ authClient,
31
37
  pluginId,
32
38
  }: CatalogRouterDeps) => {
33
39
  const entityService = new EntityService(database);
@@ -36,7 +42,7 @@ export const createCatalogRouter = ({
36
42
  const createNotificationGroup = async (
37
43
  type: "system" | "group",
38
44
  id: string,
39
- name: string
45
+ name: string,
40
46
  ) => {
41
47
  try {
42
48
  await notificationClient.createGroup({
@@ -49,7 +55,7 @@ export const createCatalogRouter = ({
49
55
  // Log but don't fail the operation
50
56
  console.warn(
51
57
  `Failed to create notification group for ${type} ${id}:`,
52
- error
58
+ error,
53
59
  );
54
60
  }
55
61
  };
@@ -57,7 +63,7 @@ export const createCatalogRouter = ({
57
63
  // Helper to delete notification group for an entity
58
64
  const deleteNotificationGroup = async (
59
65
  type: "system" | "group",
60
- id: string
66
+ id: string,
61
67
  ) => {
62
68
  try {
63
69
  await notificationClient.deleteGroup({
@@ -68,7 +74,7 @@ export const createCatalogRouter = ({
68
74
  // Log but don't fail the operation
69
75
  console.warn(
70
76
  `Failed to delete notification group for ${type} ${id}:`,
71
- error
77
+ error,
72
78
  );
73
79
  }
74
80
  };
@@ -134,14 +140,11 @@ export const createCatalogRouter = ({
134
140
  const cleanData: Partial<{
135
141
  name: string;
136
142
  description?: string;
137
- owner?: string;
138
143
  metadata?: Record<string, unknown>;
139
144
  }> = {};
140
145
  if (input.data.name !== undefined) cleanData.name = input.data.name;
141
146
  if (input.data.description !== undefined)
142
147
  cleanData.description = input.data.description ?? undefined;
143
- if (input.data.owner !== undefined)
144
- cleanData.owner = input.data.owner ?? undefined;
145
148
  if (input.data.metadata !== undefined)
146
149
  cleanData.metadata = input.data.metadata ?? undefined;
147
150
 
@@ -231,7 +234,7 @@ export const createCatalogRouter = ({
231
234
  async ({ input }) => {
232
235
  await entityService.removeSystemFromGroup(input);
233
236
  return { success: true };
234
- }
237
+ },
235
238
  );
236
239
 
237
240
  const getViews = os.getViews.handler(async () => entityService.getViews());
@@ -244,6 +247,97 @@ export const createCatalogRouter = ({
244
247
  });
245
248
  });
246
249
 
250
+ // System Contacts handlers
251
+ const getSystemContacts = os.getSystemContacts.handler(async ({ input }) => {
252
+ const rawContacts = await entityService.getContactsForSystem(
253
+ input.systemId,
254
+ );
255
+
256
+ // Resolve user profiles for user-type contacts
257
+ const enrichedContacts: SystemContact[] = await Promise.all(
258
+ rawContacts.map(async (contact) => {
259
+ if (contact.type === "user" && contact.userId) {
260
+ // Resolve user profile via auth service
261
+ const user = await authClient.getUserById({ userId: contact.userId });
262
+ return {
263
+ id: contact.id,
264
+ systemId: contact.systemId,
265
+ type: "user" as const,
266
+ userId: contact.userId,
267
+ label: contact.label,
268
+ userName: user?.name ?? undefined,
269
+ userEmail: user?.email ?? undefined,
270
+ createdAt: contact.createdAt,
271
+ };
272
+ }
273
+ // Mailbox contact
274
+ return {
275
+ id: contact.id,
276
+ systemId: contact.systemId,
277
+ type: "mailbox" as const,
278
+ email: contact.email ?? "",
279
+ label: contact.label,
280
+ createdAt: contact.createdAt,
281
+ };
282
+ }),
283
+ );
284
+
285
+ return enrichedContacts;
286
+ });
287
+
288
+ const addSystemContact = os.addSystemContact.handler(async ({ input }) => {
289
+ // Validate input based on type
290
+ if (input.type === "user" && !input.userId) {
291
+ throw new ORPCError("BAD_REQUEST", {
292
+ message: "userId is required for user-type contacts",
293
+ });
294
+ }
295
+ if (input.type === "mailbox" && !input.email) {
296
+ throw new ORPCError("BAD_REQUEST", {
297
+ message: "email is required for mailbox-type contacts",
298
+ });
299
+ }
300
+
301
+ const result = await entityService.addContact({
302
+ systemId: input.systemId,
303
+ type: input.type,
304
+ userId: input.type === "user" ? input.userId : undefined,
305
+ email: input.type === "mailbox" ? input.email : undefined,
306
+ label: input.label,
307
+ });
308
+
309
+ // Return the enriched contact
310
+ if (result.type === "user" && result.userId) {
311
+ const user = await authClient.getUserById({ userId: result.userId });
312
+ return {
313
+ id: result.id,
314
+ systemId: result.systemId,
315
+ type: "user" as const,
316
+ userId: result.userId,
317
+ label: result.label,
318
+ userName: user?.name ?? undefined,
319
+ userEmail: user?.email ?? undefined,
320
+ createdAt: result.createdAt,
321
+ };
322
+ }
323
+
324
+ return {
325
+ id: result.id,
326
+ systemId: result.systemId,
327
+ type: "mailbox" as const,
328
+ email: result.email ?? "",
329
+ label: result.label,
330
+ createdAt: result.createdAt,
331
+ };
332
+ });
333
+
334
+ const removeSystemContact = os.removeSystemContact.handler(
335
+ async ({ input }) => {
336
+ await entityService.removeContact(input);
337
+ return { success: true };
338
+ },
339
+ );
340
+
247
341
  /**
248
342
  * Notify all users subscribed to a system (and optionally its groups).
249
343
  * Delegates deduplication to notification-backend via notifyGroups RPC.
@@ -272,7 +366,7 @@ export const createCatalogRouter = ({
272
366
 
273
367
  // Spread to avoid mutation
274
368
  groupIds.push(
275
- ...systemGroups.map(({ groupId }) => `${pluginId}.group.${groupId}`)
369
+ ...systemGroups.map(({ groupId }) => `${pluginId}.group.${groupId}`),
276
370
  );
277
371
  }
278
372
 
@@ -286,7 +380,7 @@ export const createCatalogRouter = ({
286
380
  });
287
381
 
288
382
  return { notifiedCount: result.notifiedCount };
289
- }
383
+ },
290
384
  );
291
385
 
292
386
  // Build and return the router
@@ -298,6 +392,9 @@ export const createCatalogRouter = ({
298
392
  createSystem,
299
393
  updateSystem,
300
394
  deleteSystem,
395
+ getSystemContacts,
396
+ addSystemContact,
397
+ removeSystemContact,
301
398
  createGroup,
302
399
  updateGroup,
303
400
  deleteGroup,
package/src/schema.ts CHANGED
@@ -1,22 +1,40 @@
1
1
  import {
2
2
  pgTable,
3
+ pgEnum,
3
4
  text,
4
5
  timestamp,
5
6
  json,
6
7
  primaryKey,
7
8
  } from "drizzle-orm/pg-core";
8
9
 
10
+ // Enums
11
+ export const contactTypeEnum = pgEnum("contact_type", ["user", "mailbox"]);
12
+
9
13
  // Tables use pgTable (schemaless) - runtime schema is set via search_path
10
14
  export const systems = pgTable("systems", {
11
15
  id: text("id").primaryKey(),
12
16
  name: text("name").notNull(),
13
17
  description: text("description"),
14
- owner: text("owner"), // user_id or group_id reference? Keeping as text for now.
15
18
  metadata: json("metadata").default({}),
16
19
  createdAt: timestamp("created_at").defaultNow().notNull(),
17
20
  updatedAt: timestamp("updated_at").defaultNow().notNull(),
18
21
  });
19
22
 
23
+ export const systemContacts = pgTable("system_contacts", {
24
+ id: text("id").primaryKey(),
25
+ systemId: text("system_id")
26
+ .notNull()
27
+ .references(() => systems.id, { onDelete: "cascade" }),
28
+ type: contactTypeEnum("type").notNull(),
29
+ // For type="user": userId references auth user
30
+ userId: text("user_id"),
31
+ // For type="mailbox": store email directly
32
+ email: text("email"),
33
+ // Optional label for display (e.g., "On-Call", "Team Lead")
34
+ label: text("label"),
35
+ createdAt: timestamp("created_at").defaultNow().notNull(),
36
+ });
37
+
20
38
  export const groups = pgTable("groups", {
21
39
  id: text("id").primaryKey(),
22
40
  name: text("name").notNull(),
@@ -38,7 +56,7 @@ export const systemsGroups = pgTable(
38
56
  },
39
57
  (t) => ({
40
58
  pk: primaryKey(t.systemId, t.groupId),
41
- })
59
+ }),
42
60
  );
43
61
 
44
62
  export const views = pgTable("views", {
@@ -37,7 +37,6 @@ describe("EntityService", () => {
37
37
  const fullSystem = {
38
38
  ...data,
39
39
  description: null,
40
- owner: null,
41
40
  status: "healthy" as "healthy" | "degraded" | "unhealthy",
42
41
  metadata: {},
43
42
  createdAt: new Date(),
@@ -60,7 +59,6 @@ describe("EntityService", () => {
60
59
  id: "test",
61
60
  name: "Updated",
62
61
  description: null,
63
- owner: null,
64
62
  status: "healthy" as "healthy" | "degraded" | "unhealthy",
65
63
  metadata: {},
66
64
  createdAt: new Date(),
@@ -7,10 +7,17 @@ import { v4 as uuidv4 } from "uuid";
7
7
  type NewSystem = {
8
8
  name: string;
9
9
  description?: string;
10
- owner?: string;
11
10
  metadata?: Record<string, unknown>;
12
11
  };
13
12
 
13
+ type NewContact = {
14
+ systemId: string;
15
+ type: "user" | "mailbox";
16
+ userId?: string;
17
+ email?: string;
18
+ label?: string;
19
+ };
20
+
14
21
  type NewGroup = {
15
22
  name: string;
16
23
  metadata?: Record<string, unknown>;
@@ -63,6 +70,34 @@ export class EntityService {
63
70
  await this.database.delete(schema.systems).where(eq(schema.systems.id, id));
64
71
  }
65
72
 
73
+ // System Contacts
74
+ async getContactsForSystem(systemId: string) {
75
+ return this.database
76
+ .select()
77
+ .from(schema.systemContacts)
78
+ .where(eq(schema.systemContacts.systemId, systemId));
79
+ }
80
+
81
+ async addContact(data: NewContact) {
82
+ const result = await this.database
83
+ .insert(schema.systemContacts)
84
+ .values({ id: uuidv4(), ...data })
85
+ .returning();
86
+ return result[0];
87
+ }
88
+
89
+ async removeContact(contactId: string) {
90
+ await this.database
91
+ .delete(schema.systemContacts)
92
+ .where(eq(schema.systemContacts.id, contactId));
93
+ }
94
+
95
+ async deleteContactsByUserId(userId: string) {
96
+ await this.database
97
+ .delete(schema.systemContacts)
98
+ .where(eq(schema.systemContacts.userId, userId));
99
+ }
100
+
66
101
  // Groups
67
102
  async getGroups() {
68
103
  // Fetch all groups
@@ -124,8 +159,8 @@ export class EntityService {
124
159
  .where(
125
160
  and(
126
161
  eq(schema.systemsGroups.groupId, groupId),
127
- eq(schema.systemsGroups.systemId, systemId)
128
- )
162
+ eq(schema.systemsGroups.systemId, systemId),
163
+ ),
129
164
  );
130
165
  }
131
166