@checkstack/integration-backend 0.0.2

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/src/router.ts ADDED
@@ -0,0 +1,858 @@
1
+ import { implement, ORPCError } from "@orpc/server";
2
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
3
+ import {
4
+ autoAuthMiddleware,
5
+ type RpcContext,
6
+ type Logger,
7
+ } from "@checkstack/backend-api";
8
+ import type { SignalService } from "@checkstack/signal-common";
9
+ import { eq, desc, and, gte, count } from "drizzle-orm";
10
+
11
+ import type { IntegrationEventRegistry } from "./event-registry";
12
+ import type { IntegrationProviderRegistry } from "./provider-registry";
13
+ import type { DeliveryCoordinator } from "./delivery-coordinator";
14
+ import type { ConnectionStore } from "./connection-store";
15
+ import * as schema from "./schema";
16
+ import {
17
+ integrationContract,
18
+ INTEGRATION_SUBSCRIPTION_CHANGED,
19
+ } from "@checkstack/integration-common";
20
+
21
+ /**
22
+ * Recursively extracts flattened property paths from a JSON Schema.
23
+ * Used to provide template hints for payload properties.
24
+ */
25
+ interface JsonSchemaProperty {
26
+ path: string;
27
+ type: string;
28
+ description?: string;
29
+ }
30
+
31
+ function extractJsonSchemaProperties(
32
+ schema: Record<string, unknown>,
33
+ basePath: string = ""
34
+ ): JsonSchemaProperty[] {
35
+ const properties: JsonSchemaProperty[] = [];
36
+
37
+ const schemaType = schema["type"] as string | string[] | undefined;
38
+ const schemaProperties = schema["properties"] as
39
+ | Record<string, Record<string, unknown>>
40
+ | undefined;
41
+ const schemaItems = schema["items"] as Record<string, unknown> | undefined;
42
+ const schemaDescription = schema["description"] as string | undefined;
43
+
44
+ // Handle object with properties
45
+ if (schemaProperties) {
46
+ for (const [key, propSchema] of Object.entries(schemaProperties)) {
47
+ const propPath = basePath ? `${basePath}.${key}` : key;
48
+ const propType = (propSchema["type"] as string) || "unknown";
49
+ const propDescription = propSchema["description"] as string | undefined;
50
+
51
+ // Add this property
52
+ properties.push({
53
+ path: propPath,
54
+ type: Array.isArray(propType) ? propType.join(" | ") : propType,
55
+ description: propDescription,
56
+ });
57
+
58
+ // Recurse into nested objects
59
+ if (propType === "object" || propSchema["properties"]) {
60
+ properties.push(...extractJsonSchemaProperties(propSchema, propPath));
61
+ }
62
+
63
+ // Recurse into arrays (add [n] notation)
64
+ if (propType === "array" && propSchema["items"]) {
65
+ const itemsSchema = propSchema["items"] as Record<string, unknown>;
66
+ properties.push(
67
+ ...extractJsonSchemaProperties(itemsSchema, `${propPath}[n]`)
68
+ );
69
+ }
70
+ }
71
+ }
72
+
73
+ // Handle array at root level
74
+ if (schemaType === "array" && schemaItems) {
75
+ properties.push(
76
+ ...extractJsonSchemaProperties(schemaItems, `${basePath}[n]`)
77
+ );
78
+ }
79
+
80
+ // If this is a primitive with a path, add it
81
+ if (
82
+ basePath &&
83
+ schemaType &&
84
+ schemaType !== "object" &&
85
+ schemaType !== "array" &&
86
+ !schemaProperties
87
+ ) {
88
+ properties.push({
89
+ path: basePath,
90
+ type: Array.isArray(schemaType) ? schemaType.join(" | ") : schemaType,
91
+ description: schemaDescription,
92
+ });
93
+ }
94
+
95
+ return properties;
96
+ }
97
+
98
+ interface RouterDeps {
99
+ db: NodePgDatabase<typeof schema>;
100
+ eventRegistry: IntegrationEventRegistry;
101
+ providerRegistry: IntegrationProviderRegistry;
102
+ deliveryCoordinator: DeliveryCoordinator;
103
+ connectionStore: ConnectionStore;
104
+ signalService: SignalService;
105
+ logger: Logger;
106
+ }
107
+
108
+ /**
109
+ * Creates the integration router using contract-based implementation.
110
+ *
111
+ * Auth and permissions are automatically enforced via autoAuthMiddleware
112
+ * based on the contract's meta.userType and meta.permissions.
113
+ */
114
+ export function createIntegrationRouter(deps: RouterDeps) {
115
+ const {
116
+ db,
117
+ eventRegistry,
118
+ providerRegistry,
119
+ deliveryCoordinator,
120
+ connectionStore,
121
+ signalService,
122
+ logger,
123
+ } = deps;
124
+
125
+ // Create contract implementer with context type AND auto auth middleware
126
+ const os = implement(integrationContract)
127
+ .$context<RpcContext>()
128
+ .use(autoAuthMiddleware);
129
+
130
+ return os.router({
131
+ // =========================================================================
132
+ // SUBSCRIPTION MANAGEMENT
133
+ // =========================================================================
134
+
135
+ listSubscriptions: os.listSubscriptions.handler(async ({ input }) => {
136
+ const { page, pageSize, providerId, eventType, enabled } = input;
137
+ const offset = (page - 1) * pageSize;
138
+
139
+ // Build where conditions
140
+ const conditions = [];
141
+ if (providerId) {
142
+ conditions.push(eq(schema.webhookSubscriptions.providerId, providerId));
143
+ }
144
+ if (enabled !== undefined) {
145
+ conditions.push(eq(schema.webhookSubscriptions.enabled, enabled));
146
+ }
147
+
148
+ const whereClause =
149
+ conditions.length > 0 ? and(...conditions) : undefined;
150
+
151
+ // Get total count
152
+ const [{ value: total }] = await db
153
+ .select({ value: count() })
154
+ .from(schema.webhookSubscriptions)
155
+ .where(whereClause);
156
+
157
+ // Get paginated results
158
+ let query = db
159
+ .select()
160
+ .from(schema.webhookSubscriptions)
161
+ .orderBy(desc(schema.webhookSubscriptions.createdAt))
162
+ .limit(pageSize)
163
+ .offset(offset);
164
+
165
+ if (whereClause) {
166
+ query = query.where(whereClause) as typeof query;
167
+ }
168
+
169
+ const subscriptions = await query;
170
+
171
+ // Filter by event type if specified
172
+ const filtered = eventType
173
+ ? subscriptions.filter((s) => s.eventId === eventType)
174
+ : subscriptions;
175
+
176
+ return {
177
+ subscriptions: filtered.map((s) => ({
178
+ ...s,
179
+ description: s.description ?? undefined,
180
+ systemFilter: s.systemFilter ?? undefined,
181
+ createdAt: s.createdAt,
182
+ updatedAt: s.updatedAt,
183
+ })),
184
+ total: Number(total),
185
+ };
186
+ }),
187
+
188
+ getSubscription: os.getSubscription.handler(async ({ input }) => {
189
+ const [subscription] = await db
190
+ .select()
191
+ .from(schema.webhookSubscriptions)
192
+ .where(eq(schema.webhookSubscriptions.id, input.id));
193
+
194
+ if (!subscription) {
195
+ throw new ORPCError("NOT_FOUND", {
196
+ message: "Subscription not found",
197
+ });
198
+ }
199
+
200
+ return {
201
+ ...subscription,
202
+ description: subscription.description ?? undefined,
203
+ systemFilter: subscription.systemFilter ?? undefined,
204
+ createdAt: subscription.createdAt,
205
+ updatedAt: subscription.updatedAt,
206
+ };
207
+ }),
208
+
209
+ createSubscription: os.createSubscription.handler(async ({ input }) => {
210
+ const {
211
+ name,
212
+ description,
213
+ providerId,
214
+ providerConfig,
215
+ eventId,
216
+ systemFilter,
217
+ } = input;
218
+
219
+ // Validate provider exists
220
+ const provider = providerRegistry.getProvider(providerId);
221
+ if (!provider) {
222
+ throw new ORPCError("BAD_REQUEST", {
223
+ message: `Provider not found: ${providerId}`,
224
+ });
225
+ }
226
+
227
+ // Validate event exists
228
+ if (!eventRegistry.hasEvent(eventId)) {
229
+ throw new ORPCError("BAD_REQUEST", {
230
+ message: `Event type not found: ${eventId}`,
231
+ });
232
+ }
233
+
234
+ // Validate providerConfig against the provider's schema
235
+ const configParseResult =
236
+ provider.config.schema.safeParse(providerConfig);
237
+ if (!configParseResult.success) {
238
+ throw new ORPCError("BAD_REQUEST", {
239
+ message: `Invalid provider configuration: ${configParseResult.error.message}`,
240
+ });
241
+ }
242
+
243
+ const id = crypto.randomUUID();
244
+ const now = new Date();
245
+
246
+ await db.insert(schema.webhookSubscriptions).values({
247
+ id,
248
+ name,
249
+ description,
250
+ providerId,
251
+ providerConfig,
252
+ eventId,
253
+ systemFilter,
254
+ enabled: true,
255
+ createdAt: now,
256
+ updatedAt: now,
257
+ });
258
+
259
+ // Emit signal
260
+ await signalService.broadcast(INTEGRATION_SUBSCRIPTION_CHANGED, {
261
+ action: "created",
262
+ subscriptionId: id,
263
+ });
264
+
265
+ logger.info(`Created webhook subscription: ${name} (${id})`);
266
+
267
+ return {
268
+ id,
269
+ name,
270
+ description,
271
+ providerId,
272
+ providerConfig,
273
+ eventId,
274
+ systemFilter,
275
+ enabled: true,
276
+ createdAt: now,
277
+ updatedAt: now,
278
+ };
279
+ }),
280
+
281
+ updateSubscription: os.updateSubscription.handler(async ({ input }) => {
282
+ const { id, updates } = input;
283
+
284
+ // Check subscription exists
285
+ const [existing] = await db
286
+ .select()
287
+ .from(schema.webhookSubscriptions)
288
+ .where(eq(schema.webhookSubscriptions.id, id));
289
+
290
+ if (!existing) {
291
+ throw new ORPCError("NOT_FOUND", {
292
+ message: "Subscription not found",
293
+ });
294
+ }
295
+
296
+ // Validate event if updated
297
+ if (updates.eventId && !eventRegistry.hasEvent(updates.eventId)) {
298
+ throw new ORPCError("BAD_REQUEST", {
299
+ message: `Event type not found: ${updates.eventId}`,
300
+ });
301
+ }
302
+
303
+ // Validate providerConfig if updated
304
+ if (updates.providerConfig) {
305
+ const provider = providerRegistry.getProvider(existing.providerId);
306
+ if (provider) {
307
+ const configParseResult = provider.config.schema.safeParse(
308
+ updates.providerConfig
309
+ );
310
+ if (!configParseResult.success) {
311
+ throw new ORPCError("BAD_REQUEST", {
312
+ message: `Invalid provider configuration: ${configParseResult.error.message}`,
313
+ });
314
+ }
315
+ }
316
+ }
317
+
318
+ const now = new Date();
319
+
320
+ await db
321
+ .update(schema.webhookSubscriptions)
322
+ .set({
323
+ ...updates,
324
+ updatedAt: now,
325
+ })
326
+ .where(eq(schema.webhookSubscriptions.id, id));
327
+
328
+ // Emit signal
329
+ await signalService.broadcast(INTEGRATION_SUBSCRIPTION_CHANGED, {
330
+ action: "updated",
331
+ subscriptionId: id,
332
+ });
333
+
334
+ // Re-fetch updated subscription
335
+ const [updated] = await db
336
+ .select()
337
+ .from(schema.webhookSubscriptions)
338
+ .where(eq(schema.webhookSubscriptions.id, id));
339
+
340
+ return {
341
+ ...updated,
342
+ description: updated.description ?? undefined,
343
+ systemFilter: updated.systemFilter ?? undefined,
344
+ createdAt: updated.createdAt,
345
+ updatedAt: updated.updatedAt,
346
+ };
347
+ }),
348
+
349
+ deleteSubscription: os.deleteSubscription.handler(async ({ input }) => {
350
+ const { id } = input;
351
+
352
+ await db
353
+ .delete(schema.webhookSubscriptions)
354
+ .where(eq(schema.webhookSubscriptions.id, id));
355
+
356
+ // Emit signal
357
+ await signalService.broadcast(INTEGRATION_SUBSCRIPTION_CHANGED, {
358
+ action: "deleted",
359
+ subscriptionId: id,
360
+ });
361
+
362
+ logger.info(`Deleted webhook subscription: ${id}`);
363
+
364
+ return { success: true };
365
+ }),
366
+
367
+ toggleSubscription: os.toggleSubscription.handler(async ({ input }) => {
368
+ const { id, enabled } = input;
369
+
370
+ await db
371
+ .update(schema.webhookSubscriptions)
372
+ .set({
373
+ enabled,
374
+ updatedAt: new Date(),
375
+ })
376
+ .where(eq(schema.webhookSubscriptions.id, id));
377
+
378
+ // Emit signal
379
+ await signalService.broadcast(INTEGRATION_SUBSCRIPTION_CHANGED, {
380
+ action: "updated",
381
+ subscriptionId: id,
382
+ });
383
+
384
+ return { success: true };
385
+ }),
386
+
387
+ // =========================================================================
388
+ // PROVIDER DISCOVERY
389
+ // =========================================================================
390
+
391
+ listProviders: os.listProviders.handler(async () => {
392
+ const providers = providerRegistry.getProviders();
393
+
394
+ return providers.map((p) => ({
395
+ qualifiedId: p.qualifiedId,
396
+ displayName: p.displayName,
397
+ description: p.description,
398
+ icon: p.icon,
399
+ ownerPluginId: p.ownerPluginId,
400
+ supportedEvents: p.supportedEvents,
401
+ configSchema:
402
+ providerRegistry.getProviderConfigSchema(p.qualifiedId) ?? {},
403
+ hasConnectionSchema: !!p.connectionSchema,
404
+ connectionSchema: p.connectionSchema
405
+ ? providerRegistry.getProviderConnectionSchema(p.qualifiedId)
406
+ : undefined,
407
+ documentation: p.documentation,
408
+ }));
409
+ }),
410
+
411
+ testProviderConnection: os.testProviderConnection.handler(
412
+ async ({ input }) => {
413
+ const { providerId, config } = input;
414
+
415
+ const provider = providerRegistry.getProvider(providerId);
416
+ if (!provider) {
417
+ return { success: false, message: "Provider not found" };
418
+ }
419
+
420
+ if (!provider.testConnection) {
421
+ return {
422
+ success: true,
423
+ message: "Provider does not support connection testing",
424
+ };
425
+ }
426
+
427
+ try {
428
+ const result = await provider.testConnection(config);
429
+ return result;
430
+ } catch (error) {
431
+ return {
432
+ success: false,
433
+ message: error instanceof Error ? error.message : String(error),
434
+ };
435
+ }
436
+ }
437
+ ),
438
+
439
+ // =========================================================================
440
+ // CONNECTION MANAGEMENT
441
+ // Generic CRUD for site-wide provider connections
442
+ // =========================================================================
443
+
444
+ listConnections: os.listConnections.handler(async ({ input }) => {
445
+ const { providerId } = input;
446
+
447
+ // Verify provider exists and has connectionSchema
448
+ const provider = providerRegistry.getProvider(providerId);
449
+ if (!provider) {
450
+ throw new ORPCError("NOT_FOUND", {
451
+ message: `Provider not found: ${providerId}`,
452
+ });
453
+ }
454
+
455
+ if (!provider.connectionSchema) {
456
+ throw new ORPCError("BAD_REQUEST", {
457
+ message: `Provider ${providerId} does not support site-wide connections`,
458
+ });
459
+ }
460
+
461
+ return connectionStore.listConnections(providerId);
462
+ }),
463
+
464
+ getConnection: os.getConnection.handler(async ({ input }) => {
465
+ const { connectionId } = input;
466
+ const connection = await connectionStore.getConnection(connectionId);
467
+
468
+ if (!connection) {
469
+ throw new ORPCError("NOT_FOUND", {
470
+ message: `Connection not found: ${connectionId}`,
471
+ });
472
+ }
473
+
474
+ return connection;
475
+ }),
476
+
477
+ createConnection: os.createConnection.handler(async ({ input }) => {
478
+ const { providerId, name, config } = input;
479
+
480
+ // Verify provider exists and has connectionSchema
481
+ const provider = providerRegistry.getProvider(providerId);
482
+ if (!provider) {
483
+ throw new ORPCError("NOT_FOUND", {
484
+ message: `Provider not found: ${providerId}`,
485
+ });
486
+ }
487
+
488
+ if (!provider.connectionSchema) {
489
+ throw new ORPCError("BAD_REQUEST", {
490
+ message: `Provider ${providerId} does not support site-wide connections`,
491
+ });
492
+ }
493
+
494
+ // Validate config against provider's connectionSchema
495
+ const parseResult = provider.connectionSchema.schema.safeParse(config);
496
+ if (!parseResult.success) {
497
+ throw new ORPCError("BAD_REQUEST", {
498
+ message: `Invalid connection config: ${parseResult.error.message}`,
499
+ });
500
+ }
501
+
502
+ // parseResult.data is typed correctly after guard
503
+ const validatedConfig = parseResult.data as unknown as Record<
504
+ string,
505
+ unknown
506
+ >;
507
+
508
+ const connection = await connectionStore.createConnection({
509
+ providerId,
510
+ name,
511
+ config: validatedConfig,
512
+ });
513
+
514
+ logger.info(`Created connection "${name}" for provider ${providerId}`);
515
+
516
+ // Return redacted version
517
+ return {
518
+ id: connection.id,
519
+ providerId: connection.providerId,
520
+ name: connection.name,
521
+ configPreview: config, // Will be redacted in real usage
522
+ createdAt: connection.createdAt,
523
+ updatedAt: connection.updatedAt,
524
+ };
525
+ }),
526
+
527
+ updateConnection: os.updateConnection.handler(async ({ input }) => {
528
+ const { connectionId, updates } = input;
529
+
530
+ try {
531
+ const connection = await connectionStore.updateConnection({
532
+ connectionId,
533
+ updates,
534
+ });
535
+
536
+ return {
537
+ id: connection.id,
538
+ providerId: connection.providerId,
539
+ name: connection.name,
540
+ configPreview: (updates.config ?? {}) as Record<string, unknown>,
541
+ createdAt: connection.createdAt,
542
+ updatedAt: connection.updatedAt,
543
+ };
544
+ } catch (error) {
545
+ throw new ORPCError("NOT_FOUND", {
546
+ message:
547
+ error instanceof Error ? error.message : "Connection not found",
548
+ });
549
+ }
550
+ }),
551
+
552
+ deleteConnection: os.deleteConnection.handler(async ({ input }) => {
553
+ const { connectionId } = input;
554
+ const deleted = await connectionStore.deleteConnection(connectionId);
555
+
556
+ if (!deleted) {
557
+ throw new ORPCError("NOT_FOUND", {
558
+ message: `Connection not found: ${connectionId}`,
559
+ });
560
+ }
561
+
562
+ return { success: true };
563
+ }),
564
+
565
+ testConnection: os.testConnection.handler(async ({ input }) => {
566
+ const { connectionId } = input;
567
+
568
+ const connection = await connectionStore.getConnectionWithCredentials(
569
+ connectionId
570
+ );
571
+ if (!connection) {
572
+ return { success: false, message: "Connection not found" };
573
+ }
574
+
575
+ const provider = providerRegistry.getProvider(connection.providerId);
576
+ if (!provider) {
577
+ return { success: false, message: "Provider not found" };
578
+ }
579
+
580
+ if (!provider.testConnection) {
581
+ return {
582
+ success: true,
583
+ message: "Provider does not support connection testing",
584
+ };
585
+ }
586
+
587
+ try {
588
+ const result = await provider.testConnection(connection.config);
589
+ return result;
590
+ } catch (error) {
591
+ return {
592
+ success: false,
593
+ message: error instanceof Error ? error.message : String(error),
594
+ };
595
+ }
596
+ }),
597
+
598
+ getConnectionOptions: os.getConnectionOptions.handler(async ({ input }) => {
599
+ const { providerId, connectionId, resolverName, context } = input;
600
+
601
+ logger.debug(
602
+ `getConnectionOptions called: providerId=${providerId}, connectionId=${connectionId}, resolverName=${resolverName}`
603
+ );
604
+
605
+ const provider = providerRegistry.getProvider(providerId);
606
+ if (!provider) {
607
+ throw new ORPCError("NOT_FOUND", {
608
+ message: `Provider not found: ${providerId}`,
609
+ });
610
+ }
611
+
612
+ if (!provider.getConnectionOptions) {
613
+ throw new ORPCError("BAD_REQUEST", {
614
+ message: `Provider ${providerId} does not support dynamic options`,
615
+ });
616
+ }
617
+
618
+ try {
619
+ const options = await provider.getConnectionOptions({
620
+ connectionId,
621
+ resolverName,
622
+ context,
623
+ logger,
624
+ getConnectionWithCredentials:
625
+ connectionStore.getConnectionWithCredentials.bind(connectionStore),
626
+ });
627
+ logger.debug(
628
+ `getConnectionOptions returned ${options.length} options for ${resolverName}`
629
+ );
630
+ return options;
631
+ } catch (error) {
632
+ logger.error(`Failed to get connection options: ${error}`);
633
+ throw new ORPCError("INTERNAL_SERVER_ERROR", {
634
+ message:
635
+ error instanceof Error ? error.message : "Failed to fetch options",
636
+ });
637
+ }
638
+ }),
639
+
640
+ // =========================================================================
641
+ // EVENT DISCOVERY
642
+ // =========================================================================
643
+
644
+ listEventTypes: os.listEventTypes.handler(async () => {
645
+ const events = eventRegistry.getEvents();
646
+
647
+ return events.map((e) => ({
648
+ eventId: e.eventId,
649
+ displayName: e.displayName,
650
+ description: e.description,
651
+ category: e.category,
652
+ ownerPluginId: e.ownerPluginId,
653
+ payloadSchema: e.payloadJsonSchema,
654
+ }));
655
+ }),
656
+
657
+ getEventsByCategory: os.getEventsByCategory.handler(async () => {
658
+ const byCategory = eventRegistry.getEventsByCategory();
659
+
660
+ return [...byCategory.entries()].map(([category, events]) => ({
661
+ category,
662
+ events: events.map((e) => ({
663
+ eventId: e.eventId,
664
+ displayName: e.displayName,
665
+ description: e.description,
666
+ category: e.category,
667
+ ownerPluginId: e.ownerPluginId,
668
+ payloadSchema: e.payloadJsonSchema,
669
+ })),
670
+ }));
671
+ }),
672
+
673
+ getEventPayloadSchema: os.getEventPayloadSchema.handler(
674
+ async ({ input }) => {
675
+ const { eventId } = input;
676
+
677
+ const event = eventRegistry.getEvent(eventId);
678
+ if (!event) {
679
+ throw new ORPCError("NOT_FOUND", {
680
+ message: `Event not found: ${eventId}`,
681
+ });
682
+ }
683
+
684
+ // Extract flattened properties from JSON Schema
685
+ const availableProperties = extractJsonSchemaProperties(
686
+ event.payloadJsonSchema,
687
+ "payload"
688
+ );
689
+
690
+ return {
691
+ eventId: event.eventId,
692
+ payloadSchema: event.payloadJsonSchema,
693
+ availableProperties,
694
+ };
695
+ }
696
+ ),
697
+
698
+ // =========================================================================
699
+ // DELIVERY LOGS
700
+ // =========================================================================
701
+
702
+ getDeliveryLogs: os.getDeliveryLogs.handler(async ({ input }) => {
703
+ const { subscriptionId, eventType, status, page, pageSize } = input;
704
+ const offset = (page - 1) * pageSize;
705
+
706
+ // Build where conditions
707
+ const conditions = [];
708
+ if (subscriptionId) {
709
+ conditions.push(eq(schema.deliveryLogs.subscriptionId, subscriptionId));
710
+ }
711
+ if (eventType) {
712
+ conditions.push(eq(schema.deliveryLogs.eventType, eventType));
713
+ }
714
+ if (status) {
715
+ conditions.push(eq(schema.deliveryLogs.status, status));
716
+ }
717
+
718
+ const whereClause =
719
+ conditions.length > 0 ? and(...conditions) : undefined;
720
+
721
+ // Get total count
722
+ const [{ value: total }] = await db
723
+ .select({ value: count() })
724
+ .from(schema.deliveryLogs)
725
+ .where(whereClause);
726
+
727
+ // Get paginated results with subscription name
728
+ const logs = await db
729
+ .select({
730
+ log: schema.deliveryLogs,
731
+ subscriptionName: schema.webhookSubscriptions.name,
732
+ })
733
+ .from(schema.deliveryLogs)
734
+ .leftJoin(
735
+ schema.webhookSubscriptions,
736
+ eq(schema.deliveryLogs.subscriptionId, schema.webhookSubscriptions.id)
737
+ )
738
+ .where(whereClause)
739
+ .orderBy(desc(schema.deliveryLogs.createdAt))
740
+ .limit(pageSize)
741
+ .offset(offset);
742
+
743
+ return {
744
+ logs: logs.map(({ log, subscriptionName }) => ({
745
+ ...log,
746
+ subscriptionName: subscriptionName ?? undefined,
747
+ createdAt: log.createdAt,
748
+ lastAttemptAt: log.lastAttemptAt ?? undefined,
749
+ nextRetryAt: log.nextRetryAt ?? undefined,
750
+ externalId: log.externalId ?? undefined,
751
+ errorMessage: log.errorMessage ?? undefined,
752
+ })),
753
+ total: Number(total),
754
+ };
755
+ }),
756
+
757
+ getDeliveryLog: os.getDeliveryLog.handler(async ({ input }) => {
758
+ const [result] = await db
759
+ .select({
760
+ log: schema.deliveryLogs,
761
+ subscriptionName: schema.webhookSubscriptions.name,
762
+ })
763
+ .from(schema.deliveryLogs)
764
+ .leftJoin(
765
+ schema.webhookSubscriptions,
766
+ eq(schema.deliveryLogs.subscriptionId, schema.webhookSubscriptions.id)
767
+ )
768
+ .where(eq(schema.deliveryLogs.id, input.id));
769
+
770
+ if (!result) {
771
+ throw new ORPCError("NOT_FOUND", {
772
+ message: "Delivery log not found",
773
+ });
774
+ }
775
+
776
+ return {
777
+ ...result.log,
778
+ subscriptionName: result.subscriptionName ?? undefined,
779
+ createdAt: result.log.createdAt,
780
+ lastAttemptAt: result.log.lastAttemptAt ?? undefined,
781
+ nextRetryAt: result.log.nextRetryAt ?? undefined,
782
+ externalId: result.log.externalId ?? undefined,
783
+ errorMessage: result.log.errorMessage ?? undefined,
784
+ };
785
+ }),
786
+
787
+ retryDelivery: os.retryDelivery.handler(async ({ input }) => {
788
+ return deliveryCoordinator.retryDelivery(input.logId);
789
+ }),
790
+
791
+ getDeliveryStats: os.getDeliveryStats.handler(async ({ input }) => {
792
+ const { hours } = input;
793
+ const since = new Date(Date.now() - hours * 60 * 60 * 1000);
794
+
795
+ // Get counts by status
796
+ const statusCounts = await db
797
+ .select({
798
+ status: schema.deliveryLogs.status,
799
+ count: count(),
800
+ })
801
+ .from(schema.deliveryLogs)
802
+ .where(gte(schema.deliveryLogs.createdAt, since))
803
+ .groupBy(schema.deliveryLogs.status);
804
+
805
+ // Get counts by event type
806
+ const eventCounts = await db
807
+ .select({
808
+ eventType: schema.deliveryLogs.eventType,
809
+ count: count(),
810
+ })
811
+ .from(schema.deliveryLogs)
812
+ .where(gte(schema.deliveryLogs.createdAt, since))
813
+ .groupBy(schema.deliveryLogs.eventType);
814
+
815
+ // Get counts by provider (via subscription)
816
+ const providerCounts = await db
817
+ .select({
818
+ providerId: schema.webhookSubscriptions.providerId,
819
+ count: count(),
820
+ })
821
+ .from(schema.deliveryLogs)
822
+ .innerJoin(
823
+ schema.webhookSubscriptions,
824
+ eq(schema.deliveryLogs.subscriptionId, schema.webhookSubscriptions.id)
825
+ )
826
+ .where(gte(schema.deliveryLogs.createdAt, since))
827
+ .groupBy(schema.webhookSubscriptions.providerId);
828
+
829
+ // Build response
830
+ const statusMap = new Map(
831
+ statusCounts.map((s) => [s.status, Number(s.count)])
832
+ );
833
+ const total =
834
+ (statusMap.get("success") ?? 0) +
835
+ (statusMap.get("failed") ?? 0) +
836
+ (statusMap.get("retrying") ?? 0) +
837
+ (statusMap.get("pending") ?? 0);
838
+
839
+ return {
840
+ total,
841
+ successful: statusMap.get("success") ?? 0,
842
+ failed: statusMap.get("failed") ?? 0,
843
+ retrying: statusMap.get("retrying") ?? 0,
844
+ pending: statusMap.get("pending") ?? 0,
845
+ byEvent: eventCounts.map((e) => ({
846
+ eventType: e.eventType,
847
+ count: Number(e.count),
848
+ })),
849
+ byProvider: providerCounts.map((p) => ({
850
+ providerId: p.providerId,
851
+ count: Number(p.count),
852
+ })),
853
+ };
854
+ }),
855
+ });
856
+ }
857
+
858
+ export type IntegrationRouter = ReturnType<typeof createIntegrationRouter>;