@checkstack/notification-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,1090 @@
1
+ import { implement, ORPCError } from "@orpc/server";
2
+ import {
3
+ autoAuthMiddleware,
4
+ type RpcContext,
5
+ type RealUser,
6
+ type ConfigService,
7
+ toJsonSchema,
8
+ type NotificationStrategyRegistry,
9
+ type RpcClient,
10
+ type NotificationPayload,
11
+ type NotificationSendContext,
12
+ Logger,
13
+ } from "@checkstack/backend-api";
14
+ import {
15
+ notificationContract,
16
+ NOTIFICATION_RECEIVED,
17
+ NOTIFICATION_READ,
18
+ } from "@checkstack/notification-common";
19
+ import { AuthApi } from "@checkstack/auth-common";
20
+ import type { SignalService } from "@checkstack/signal-common";
21
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
22
+ import * as schema from "./schema";
23
+ import {
24
+ getUserNotifications,
25
+ getUnreadCount,
26
+ markAsRead,
27
+ deleteNotification,
28
+ getAllGroups,
29
+ getEnrichedUserSubscriptions,
30
+ subscribeToGroup,
31
+ unsubscribeFromGroup,
32
+ } from "./service";
33
+ import {
34
+ retentionConfigV1,
35
+ RETENTION_CONFIG_VERSION,
36
+ RETENTION_CONFIG_ID,
37
+ } from "./retention-config";
38
+ import {
39
+ createStrategyService,
40
+ type StrategyService,
41
+ } from "./strategy-service";
42
+
43
+ /**
44
+ * Helper: Resolve user contact information based on strategy's contactResolution type.
45
+ * Returns undefined if contact cannot be resolved.
46
+ */
47
+ function resolveContact({
48
+ strategy,
49
+ userEmail,
50
+ userPreference,
51
+ }: {
52
+ strategy: { contactResolution: { type: string; field?: string } };
53
+ userEmail?: string;
54
+ userPreference?: {
55
+ externalId?: string | null;
56
+ userConfig?: Record<string, unknown> | null;
57
+ } | null;
58
+ }): string | undefined {
59
+ const resType = strategy.contactResolution.type;
60
+
61
+ switch (resType) {
62
+ case "auth-email":
63
+ case "auth-provider": {
64
+ return userEmail;
65
+ }
66
+ case "oauth-link": {
67
+ return userPreference?.externalId ?? undefined;
68
+ }
69
+ case "user-config": {
70
+ const fieldName =
71
+ "field" in strategy.contactResolution
72
+ ? strategy.contactResolution.field
73
+ : undefined;
74
+ if (userPreference?.userConfig && fieldName) {
75
+ return String(userPreference.userConfig[fieldName]);
76
+ }
77
+ return undefined;
78
+ }
79
+ default: {
80
+ throw new Error(`Unknown contact resolution type: ${resType}`);
81
+ }
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Creates the notification router using contract-based implementation.
87
+ *
88
+ * Auth and permissions are automatically enforced via autoAuthMiddleware
89
+ * based on the contract's meta.userType and meta.permissions.
90
+ */
91
+ export const createNotificationRouter = (
92
+ database: NodePgDatabase<typeof schema>,
93
+ configService: ConfigService,
94
+ signalService: SignalService,
95
+ strategyRegistry: NotificationStrategyRegistry,
96
+ rpcApi: RpcClient,
97
+ logger: Logger
98
+ ) => {
99
+ // Create strategy service for config management
100
+ const strategyService: StrategyService = createStrategyService({
101
+ db: database,
102
+ configService,
103
+ strategyRegistry,
104
+ });
105
+
106
+ /**
107
+ * Helper: Send notification to all enabled external channels for a user.
108
+ * Silently skips channels that aren't configured or fail.
109
+ */
110
+ const sendToExternalChannels = async (
111
+ userId: string,
112
+ notification: {
113
+ title: string;
114
+ body?: string;
115
+ importance: string;
116
+ action?: { label: string; url: string };
117
+ }
118
+ ): Promise<void> => {
119
+ const authClient = rpcApi.forPlugin(AuthApi);
120
+
121
+ // Get user info
122
+ const user = await authClient.getUserById({ userId });
123
+ if (!user) return;
124
+
125
+ // Get all enabled strategies
126
+ const strategies = strategyRegistry.getStrategies();
127
+
128
+ for (const strategy of strategies) {
129
+ try {
130
+ logger.debug(
131
+ `[external-delivery] Checking strategy ${strategy.qualifiedId}...`
132
+ );
133
+
134
+ const meta = await strategyService.getStrategyMeta(
135
+ strategy.qualifiedId
136
+ );
137
+ if (!meta.enabled) {
138
+ logger.debug(
139
+ `[external-delivery] Strategy ${strategy.qualifiedId} is disabled, skipping`
140
+ );
141
+ continue;
142
+ }
143
+
144
+ // Check user preference - skip if user disabled this channel
145
+ const pref = await strategyService.getUserPreference(
146
+ userId,
147
+ strategy.qualifiedId
148
+ );
149
+ logger.debug(
150
+ `[external-delivery] User pref for ${strategy.qualifiedId}:`,
151
+ pref
152
+ );
153
+ if (pref && pref.enabled === false) {
154
+ logger.debug(
155
+ `[external-delivery] User disabled ${strategy.qualifiedId}, skipping`
156
+ );
157
+ continue;
158
+ }
159
+
160
+ // Resolve contact based on contactResolution type
161
+ const contact = resolveContact({
162
+ strategy,
163
+ userEmail: user.email,
164
+ userPreference: pref,
165
+ });
166
+
167
+ logger.debug(
168
+ `[external-delivery] Resolved contact for ${strategy.qualifiedId}: ${contact}`
169
+ );
170
+ if (!contact) {
171
+ logger.debug(
172
+ `[external-delivery] No contact for ${strategy.qualifiedId}, skipping`
173
+ );
174
+ continue;
175
+ }
176
+
177
+ // Get strategy config
178
+ const strategyConfig = await strategyService.getStrategyConfig(
179
+ strategy.qualifiedId
180
+ );
181
+ if (!strategyConfig) {
182
+ logger.debug(
183
+ `[external-delivery] No strategyConfig for ${strategy.qualifiedId}, skipping`
184
+ );
185
+ continue;
186
+ }
187
+
188
+ // Get optional configs
189
+ const layoutConfig = await strategyService.getLayoutConfig(
190
+ strategy.qualifiedId
191
+ );
192
+
193
+ const baseUrl = process.env.VITE_FRONTEND_URL;
194
+ if (!baseUrl) {
195
+ logger.error(
196
+ "[notification-backend] No frontend URL configured, but action included only a path"
197
+ );
198
+ continue;
199
+ }
200
+
201
+ const actionUrl = notification.action?.url;
202
+ if (actionUrl && !actionUrl.startsWith("http")) {
203
+ notification.action!.url = `${baseUrl.replace(/\/$/, "")}${
204
+ actionUrl.startsWith("/") ? "" : "/"
205
+ }${actionUrl}`;
206
+ }
207
+
208
+ // Build payload
209
+ const payload: NotificationPayload = {
210
+ title: notification.title,
211
+ body: notification.body,
212
+ importance: notification.importance as
213
+ | "info"
214
+ | "warning"
215
+ | "critical",
216
+ action: notification.action,
217
+ type: "notification",
218
+ };
219
+
220
+ // Build send context
221
+ const sendContext: NotificationSendContext<unknown, unknown, unknown> =
222
+ {
223
+ user: {
224
+ userId: user.id,
225
+ email: user.email,
226
+ displayName: user.name ?? undefined,
227
+ },
228
+ contact,
229
+ notification: payload,
230
+ strategyConfig,
231
+ userConfig: pref?.userConfig,
232
+ layoutConfig,
233
+ logger,
234
+ };
235
+
236
+ // Send (fire-and-forget, don't block on errors)
237
+ logger.debug(
238
+ `[external-delivery] Sending to ${strategy.qualifiedId} with contact ${contact}`
239
+ );
240
+ const result = await strategy.send(sendContext);
241
+ logger.debug(
242
+ `[external-delivery] Send result for ${strategy.qualifiedId}:`,
243
+ result
244
+ );
245
+ } catch (error) {
246
+ // Log error but continue - external delivery shouldn't block in-app
247
+ logger.error(
248
+ `[external-delivery] Error sending via ${strategy.qualifiedId}:`,
249
+ error
250
+ );
251
+ }
252
+ }
253
+ };
254
+
255
+ // Create contract implementer with context type AND auto auth middleware
256
+ const os = implement(notificationContract)
257
+ .$context<RpcContext>()
258
+ .use(autoAuthMiddleware);
259
+
260
+ return os.router({
261
+ // ==========================================================================
262
+ // USER NOTIFICATION ENDPOINTS
263
+ // Contract meta: userType: "user", permissions: [notificationRead]
264
+ // ==========================================================================
265
+
266
+ getNotifications: os.getNotifications.handler(
267
+ async ({ input, context }) => {
268
+ // context.user is guaranteed to be RealUser by contract meta + autoAuthMiddleware
269
+ const userId = (context.user as RealUser).id;
270
+
271
+ const result = await getUserNotifications(database, userId, {
272
+ limit: input.limit,
273
+ offset: input.offset,
274
+ unreadOnly: input.unreadOnly,
275
+ });
276
+
277
+ return {
278
+ notifications: result.notifications.map((n) => ({
279
+ id: n.id,
280
+ userId: n.userId,
281
+ title: n.title,
282
+ body: n.body,
283
+ action: n.action ?? undefined,
284
+ importance: n.importance as "info" | "warning" | "critical",
285
+ isRead: n.isRead,
286
+ groupId: n.groupId ?? undefined,
287
+ createdAt: n.createdAt,
288
+ })),
289
+ total: result.total,
290
+ };
291
+ }
292
+ ),
293
+
294
+ getUnreadCount: os.getUnreadCount.handler(async ({ context }) => {
295
+ const userId = (context.user as RealUser).id;
296
+ const count = await getUnreadCount(database, userId);
297
+ return { count };
298
+ }),
299
+
300
+ markAsRead: os.markAsRead.handler(async ({ input, context }) => {
301
+ const userId = (context.user as RealUser).id;
302
+ await markAsRead(database, userId, input.notificationId);
303
+
304
+ // Send signal to update NotificationBell in realtime
305
+ void signalService.sendToUser(NOTIFICATION_READ, userId, {
306
+ notificationId: input.notificationId,
307
+ });
308
+ }),
309
+
310
+ deleteNotification: os.deleteNotification.handler(
311
+ async ({ input, context }) => {
312
+ const userId = (context.user as RealUser).id;
313
+ await deleteNotification(database, userId, input.notificationId);
314
+ }
315
+ ),
316
+
317
+ // ==========================================================================
318
+ // GROUP & SUBSCRIPTION ENDPOINTS
319
+ // ==========================================================================
320
+
321
+ getGroups: os.getGroups.handler(async () => {
322
+ // userType: "both" - accessible by users and services
323
+ const groups = await getAllGroups(database);
324
+ return groups.map((g) => ({
325
+ id: g.id,
326
+ name: g.name,
327
+ description: g.description,
328
+ ownerPlugin: g.ownerPlugin,
329
+ createdAt: g.createdAt,
330
+ }));
331
+ }),
332
+
333
+ getSubscriptions: os.getSubscriptions.handler(async ({ context }) => {
334
+ const userId = (context.user as RealUser).id;
335
+ const subscriptions = await getEnrichedUserSubscriptions(
336
+ database,
337
+ userId
338
+ );
339
+ return subscriptions;
340
+ }),
341
+
342
+ subscribe: os.subscribe.handler(async ({ input, context }) => {
343
+ const userId = (context.user as RealUser).id;
344
+ try {
345
+ await subscribeToGroup(database, userId, input.groupId);
346
+ } catch (error) {
347
+ // Convert group-not-found errors to proper ORPC errors
348
+ if (
349
+ error instanceof Error &&
350
+ error.message.includes("does not exist")
351
+ ) {
352
+ throw new ORPCError("NOT_FOUND", {
353
+ message: `Notification group '${input.groupId}' does not exist. It may not have been created yet.`,
354
+ });
355
+ }
356
+ throw error;
357
+ }
358
+ }),
359
+
360
+ unsubscribe: os.unsubscribe.handler(async ({ input, context }) => {
361
+ const userId = (context.user as RealUser).id;
362
+ await unsubscribeFromGroup(database, userId, input.groupId);
363
+ }),
364
+
365
+ // ==========================================================================
366
+ // ADMIN SETTINGS ENDPOINTS
367
+ // Contract meta: userType: "user", permissions: [notificationAdmin]
368
+ // ==========================================================================
369
+
370
+ getRetentionSchema: os.getRetentionSchema.handler(() => {
371
+ return toJsonSchema(retentionConfigV1);
372
+ }),
373
+
374
+ getRetentionSettings: os.getRetentionSettings.handler(async () => {
375
+ const config = await configService.get(
376
+ RETENTION_CONFIG_ID,
377
+ retentionConfigV1,
378
+ RETENTION_CONFIG_VERSION
379
+ );
380
+ return config ?? { enabled: false, retentionDays: 30 };
381
+ }),
382
+
383
+ setRetentionSettings: os.setRetentionSettings.handler(async ({ input }) => {
384
+ await configService.set(
385
+ RETENTION_CONFIG_ID,
386
+ retentionConfigV1,
387
+ RETENTION_CONFIG_VERSION,
388
+ input
389
+ );
390
+ }),
391
+
392
+ // ==========================================================================
393
+ // BACKEND-TO-BACKEND GROUP MANAGEMENT
394
+ // Contract meta: userType: "service"
395
+ // ==========================================================================
396
+
397
+ createGroup: os.createGroup.handler(async ({ input }) => {
398
+ // Service-only - no user context needed
399
+ const namespacedId = `${input.ownerPlugin}.${input.groupId}`;
400
+
401
+ await database
402
+ .insert(schema.notificationGroups)
403
+ .values({
404
+ id: namespacedId,
405
+ name: input.name,
406
+ description: input.description,
407
+ ownerPlugin: input.ownerPlugin,
408
+ })
409
+ .onConflictDoUpdate({
410
+ target: [schema.notificationGroups.id],
411
+ set: {
412
+ name: input.name,
413
+ description: input.description,
414
+ },
415
+ });
416
+
417
+ return { id: namespacedId };
418
+ }),
419
+
420
+ deleteGroup: os.deleteGroup.handler(async ({ input }) => {
421
+ const { eq, and } = await import("drizzle-orm");
422
+
423
+ const result = await database
424
+ .delete(schema.notificationGroups)
425
+ .where(
426
+ and(
427
+ eq(schema.notificationGroups.id, input.groupId),
428
+ eq(schema.notificationGroups.ownerPlugin, input.ownerPlugin)
429
+ )
430
+ );
431
+
432
+ return { success: (result.rowCount ?? 0) > 0 };
433
+ }),
434
+
435
+ getGroupSubscribers: os.getGroupSubscribers.handler(async ({ input }) => {
436
+ const { eq } = await import("drizzle-orm");
437
+
438
+ const subscribers = await database
439
+ .select({ userId: schema.notificationSubscriptions.userId })
440
+ .from(schema.notificationSubscriptions)
441
+ .where(eq(schema.notificationSubscriptions.groupId, input.groupId));
442
+
443
+ return { userIds: subscribers.map((s) => s.userId) };
444
+ }),
445
+
446
+ notifyUsers: os.notifyUsers.handler(async ({ input }) => {
447
+ const { userIds, title, body, importance, action } = input;
448
+
449
+ if (userIds.length === 0) {
450
+ return { notifiedCount: 0 };
451
+ }
452
+
453
+ const notificationValues = userIds.map((userId) => ({
454
+ userId,
455
+ title,
456
+ body,
457
+ action,
458
+ importance: importance ?? "info",
459
+ }));
460
+
461
+ const inserted = await database
462
+ .insert(schema.notifications)
463
+ .values(notificationValues)
464
+ .returning({
465
+ id: schema.notifications.id,
466
+ userId: schema.notifications.userId,
467
+ });
468
+
469
+ // Send realtime signals to each user
470
+ for (const notification of inserted) {
471
+ void signalService.sendToUser(
472
+ NOTIFICATION_RECEIVED,
473
+ notification.userId,
474
+ {
475
+ id: notification.id,
476
+ title,
477
+ body,
478
+ importance: importance ?? "info",
479
+ }
480
+ );
481
+ }
482
+
483
+ // Also send to external channels (Telegram, SMTP, etc.) - fire and forget
484
+ for (const userId of userIds) {
485
+ void sendToExternalChannels(userId, {
486
+ title,
487
+ body,
488
+ importance: importance ?? "info",
489
+ action,
490
+ });
491
+ }
492
+
493
+ return { notifiedCount: userIds.length };
494
+ }),
495
+
496
+ // Notify all subscribers of multiple groups with internal deduplication
497
+ notifyGroups: os.notifyGroups.handler(async ({ input }) => {
498
+ const { groupIds, title, body, importance, action } = input;
499
+ const { inArray } = await import("drizzle-orm");
500
+
501
+ if (groupIds.length === 0) {
502
+ return { notifiedCount: 0 };
503
+ }
504
+
505
+ // Get all subscribers for all groups, deduplicated
506
+ const subscribers = await database
507
+ .selectDistinct({ userId: schema.notificationSubscriptions.userId })
508
+ .from(schema.notificationSubscriptions)
509
+ .where(inArray(schema.notificationSubscriptions.groupId, groupIds));
510
+
511
+ if (subscribers.length === 0) {
512
+ return { notifiedCount: 0 };
513
+ }
514
+
515
+ const notificationValues = subscribers.map((sub) => ({
516
+ userId: sub.userId,
517
+ title,
518
+ body,
519
+ action,
520
+ importance: importance ?? "info",
521
+ }));
522
+
523
+ const inserted = await database
524
+ .insert(schema.notifications)
525
+ .values(notificationValues)
526
+ .returning({
527
+ id: schema.notifications.id,
528
+ userId: schema.notifications.userId,
529
+ });
530
+
531
+ // Send realtime signals to each subscriber
532
+ for (const notification of inserted) {
533
+ void signalService.sendToUser(
534
+ NOTIFICATION_RECEIVED,
535
+ notification.userId,
536
+ {
537
+ id: notification.id,
538
+ title,
539
+ body,
540
+ importance: importance ?? "info",
541
+ }
542
+ );
543
+ }
544
+
545
+ // Also send to external channels (Telegram, SMTP, etc.) - fire and forget
546
+ for (const sub of subscribers) {
547
+ void sendToExternalChannels(sub.userId, {
548
+ title,
549
+ body,
550
+ importance: importance ?? "info",
551
+ action,
552
+ });
553
+ }
554
+
555
+ return { notifiedCount: subscribers.length };
556
+ }),
557
+
558
+ // Send transactional notification via ALL enabled strategies
559
+ // No internal notification created - sent directly via external channels
560
+ sendTransactional: os.sendTransactional.handler(async ({ input }) => {
561
+ const { userId, notification } = input;
562
+
563
+ // Get all strategies
564
+ const allStrategies = strategyRegistry.getStrategies();
565
+
566
+ // Get user info from auth backend
567
+ const authClient = rpcApi.forPlugin(AuthApi);
568
+ const user = await authClient.getUserById({ userId });
569
+
570
+ if (!user) {
571
+ return {
572
+ deliveredCount: 0,
573
+ results: [
574
+ {
575
+ strategyId: "none",
576
+ success: false,
577
+ error: "User not found",
578
+ },
579
+ ],
580
+ };
581
+ }
582
+
583
+ // Build results for each strategy
584
+ const results: Array<{
585
+ strategyId: string;
586
+ success: boolean;
587
+ error?: string;
588
+ }> = [];
589
+
590
+ for (const strategy of allStrategies) {
591
+ // Check if strategy is enabled
592
+ const meta = await strategyService.getStrategyMeta(
593
+ strategy.qualifiedId
594
+ );
595
+ if (!meta.enabled) {
596
+ continue; // Skip disabled strategies
597
+ }
598
+
599
+ // Get user preference for contact resolution
600
+ const pref = await strategyService.getUserPreference(
601
+ userId,
602
+ strategy.qualifiedId
603
+ );
604
+
605
+ // Resolve contact based on contactResolution type
606
+ const contact = resolveContact({
607
+ strategy,
608
+ userEmail: user.email,
609
+ userPreference: pref,
610
+ });
611
+
612
+ if (!contact) {
613
+ // Cannot resolve contact for this strategy, skip
614
+ results.push({
615
+ strategyId: strategy.qualifiedId,
616
+ success: false,
617
+ error: "Could not resolve user contact for this channel",
618
+ });
619
+ continue;
620
+ }
621
+
622
+ // Get strategy config
623
+ const strategyConfig = await strategyService.getStrategyConfig(
624
+ strategy.qualifiedId
625
+ );
626
+ if (!strategyConfig) {
627
+ results.push({
628
+ strategyId: strategy.qualifiedId,
629
+ success: false,
630
+ error: "Strategy not configured",
631
+ });
632
+ continue;
633
+ }
634
+
635
+ // Get layout config if supported
636
+ const layoutConfig = await strategyService.getLayoutConfig(
637
+ strategy.qualifiedId
638
+ );
639
+
640
+ // Get user config if strategy supports it
641
+ const userPref = await strategyService.getUserPreference(
642
+ userId,
643
+ strategy.qualifiedId
644
+ );
645
+
646
+ // Build notification payload
647
+ const payload: NotificationPayload = {
648
+ title: notification.title,
649
+ body: notification.body,
650
+ importance: "critical", // Transactional messages are always critical
651
+ action: notification.action,
652
+ type: "transactional",
653
+ };
654
+
655
+ // Build send context
656
+ const sendContext: NotificationSendContext<unknown, unknown, unknown> =
657
+ {
658
+ user: {
659
+ userId: user.id,
660
+ email: user.email,
661
+ displayName: user.name ?? undefined,
662
+ },
663
+ contact,
664
+ notification: payload,
665
+ strategyConfig,
666
+ userConfig: userPref?.userConfig,
667
+ layoutConfig,
668
+ logger,
669
+ };
670
+
671
+ // Send via strategy
672
+ try {
673
+ const result = await strategy.send(sendContext);
674
+ results.push({
675
+ strategyId: strategy.qualifiedId,
676
+ success: result.success,
677
+ error: result.error,
678
+ });
679
+ } catch (error) {
680
+ results.push({
681
+ strategyId: strategy.qualifiedId,
682
+ success: false,
683
+ error: error instanceof Error ? error.message : "Unknown error",
684
+ });
685
+ }
686
+ }
687
+
688
+ const deliveredCount = results.filter((r) => r.success).length;
689
+
690
+ return { deliveredCount, results };
691
+ }),
692
+
693
+ // ==========================================================================
694
+ // DELIVERY STRATEGY ADMIN ENDPOINTS
695
+ // ==========================================================================
696
+
697
+ getDeliveryStrategies: os.getDeliveryStrategies.handler(async () => {
698
+ const strategies = strategyRegistry.getStrategies();
699
+
700
+ const result = await Promise.all(
701
+ strategies.map(async (strategy) => {
702
+ // Get meta-config (enabled state)
703
+ const meta = await strategyService.getStrategyMeta(
704
+ strategy.qualifiedId
705
+ );
706
+
707
+ // Get redacted config (secrets stripped for frontend)
708
+ const config = await strategyService.getStrategyConfigRedacted(
709
+ strategy.qualifiedId
710
+ );
711
+
712
+ // Get redacted layout config (if strategy supports it)
713
+ const layoutConfig = await strategyService.getLayoutConfigRedacted(
714
+ strategy.qualifiedId
715
+ );
716
+
717
+ // Determine if strategy requires user config or OAuth
718
+ const requiresUserConfig = !!strategy.userConfig;
719
+ const requiresOAuthLink =
720
+ strategy.contactResolution.type === "oauth-link";
721
+
722
+ // Build JSON schema for DynamicForm
723
+ const configSchema = toJsonSchema(strategy.config.schema);
724
+ const userConfigSchema = strategy.userConfig
725
+ ? toJsonSchema(strategy.userConfig.schema)
726
+ : undefined;
727
+ const layoutConfigSchema = strategy.layoutConfig
728
+ ? toJsonSchema(strategy.layoutConfig.schema)
729
+ : undefined;
730
+
731
+ return {
732
+ qualifiedId: strategy.qualifiedId,
733
+ displayName: strategy.displayName,
734
+ description: strategy.description,
735
+ icon: strategy.icon,
736
+ ownerPluginId: strategy.ownerPluginId,
737
+ contactResolution: strategy.contactResolution as {
738
+ type:
739
+ | "auth-email"
740
+ | "auth-provider"
741
+ | "user-config"
742
+ | "oauth-link";
743
+ provider?: string;
744
+ field?: string;
745
+ },
746
+ requiresUserConfig,
747
+ requiresOAuthLink,
748
+ configSchema,
749
+ userConfigSchema,
750
+ layoutConfigSchema,
751
+ enabled: meta.enabled,
752
+ config: config as Record<string, unknown> | undefined,
753
+ layoutConfig: layoutConfig as Record<string, unknown> | undefined,
754
+ adminInstructions: strategy.adminInstructions,
755
+ };
756
+ })
757
+ );
758
+
759
+ return result;
760
+ }),
761
+
762
+ updateDeliveryStrategy: os.updateDeliveryStrategy.handler(
763
+ async ({ input }) => {
764
+ const { strategyId, enabled, config, layoutConfig } = input;
765
+
766
+ const strategy = strategyRegistry.getStrategy(strategyId);
767
+ if (!strategy) {
768
+ throw new ORPCError("NOT_FOUND", {
769
+ message: `Strategy not found: ${strategyId}`,
770
+ });
771
+ }
772
+
773
+ // Update meta-config (enabled state)
774
+ await strategyService.setStrategyMeta(strategyId, { enabled });
775
+
776
+ // Update config if provided
777
+ if (config !== undefined) {
778
+ await strategyService.setStrategyConfig(strategyId, config);
779
+ }
780
+
781
+ // Update layout config if provided
782
+ if (layoutConfig !== undefined && strategy.layoutConfig) {
783
+ await strategyService.setLayoutConfig(strategyId, layoutConfig);
784
+ }
785
+ }
786
+ ),
787
+
788
+ // ==========================================================================
789
+ // USER DELIVERY PREFERENCE ENDPOINTS
790
+ // ==========================================================================
791
+
792
+ getUserDeliveryChannels: os.getUserDeliveryChannels.handler(
793
+ async ({ context }) => {
794
+ const userId = (context.user as RealUser).id;
795
+ const strategies = strategyRegistry.getStrategies();
796
+
797
+ // Get user's preferences (redacted - no secrets)
798
+ const userPrefs = await strategyService.getAllUserPreferencesRedacted(
799
+ userId
800
+ );
801
+ const prefsMap = new Map(
802
+ userPrefs.map((p) => [p.strategyId, p.preference])
803
+ );
804
+
805
+ // Get enabled strategies only
806
+ const enabledStrategies = await Promise.all(
807
+ strategies.map(async (strategy) => {
808
+ const meta = await strategyService.getStrategyMeta(
809
+ strategy.qualifiedId
810
+ );
811
+ return { strategy, enabled: meta.enabled };
812
+ })
813
+ );
814
+
815
+ const result = enabledStrategies
816
+ .filter((s) => s.enabled)
817
+ .map(({ strategy }) => {
818
+ const pref = prefsMap.get(strategy.qualifiedId);
819
+
820
+ // Determine if channel is configured (ready to send)
821
+ let isConfigured = false;
822
+ const resType = strategy.contactResolution.type;
823
+
824
+ switch (resType) {
825
+ case "auth-email":
826
+ case "auth-provider": {
827
+ // These just need user's email - always configured
828
+ isConfigured = true;
829
+ break;
830
+ }
831
+ case "oauth-link": {
832
+ // Need to be linked
833
+ isConfigured = !!pref?.linkedAt;
834
+ break;
835
+ }
836
+ case "user-config": {
837
+ // Need user to provide config
838
+ isConfigured = !!pref?.userConfig;
839
+ break;
840
+ }
841
+ default: {
842
+ throw new Error(`Unknown contact resolution type: ${resType}`);
843
+ }
844
+ }
845
+
846
+ // Build JSON schema for user config (if applicable)
847
+ const userConfigSchema = strategy.userConfig
848
+ ? toJsonSchema(strategy.userConfig.schema)
849
+ : undefined;
850
+
851
+ return {
852
+ strategyId: strategy.qualifiedId,
853
+ displayName: strategy.displayName,
854
+ description: strategy.description,
855
+ icon: strategy.icon,
856
+ contactResolution: {
857
+ type: resType,
858
+ },
859
+ enabled: pref?.enabled ?? true,
860
+ isConfigured,
861
+ linkedAt: pref?.linkedAt ? new Date(pref.linkedAt) : undefined,
862
+ userConfigSchema,
863
+ userConfig: pref?.userConfig,
864
+ userInstructions: strategy.userInstructions,
865
+ };
866
+ });
867
+
868
+ return result;
869
+ }
870
+ ),
871
+
872
+ setUserDeliveryPreference: os.setUserDeliveryPreference.handler(
873
+ async ({ input, context }) => {
874
+ const userId = (context.user as RealUser).id;
875
+ const { strategyId, enabled, userConfig } = input;
876
+
877
+ const strategy = strategyRegistry.getStrategy(strategyId);
878
+ if (!strategy) {
879
+ throw new ORPCError("NOT_FOUND", {
880
+ message: `Strategy not found: ${strategyId}`,
881
+ });
882
+ }
883
+
884
+ await strategyService.setUserPreference(userId, strategyId, {
885
+ enabled,
886
+ userConfig: userConfig as Record<string, unknown> | undefined,
887
+ });
888
+ }
889
+ ),
890
+
891
+ getDeliveryOAuthUrl: os.getDeliveryOAuthUrl.handler(
892
+ async ({ input, context }) => {
893
+ const userId = (context.user as RealUser).id;
894
+ const { strategyId, returnUrl } = input;
895
+
896
+ const strategy = strategyRegistry.getStrategy(strategyId);
897
+ if (!strategy) {
898
+ throw new ORPCError("NOT_FOUND", {
899
+ message: `Strategy not found: ${strategyId}`,
900
+ });
901
+ }
902
+
903
+ if (!strategy.oauth) {
904
+ throw new ORPCError("BAD_REQUEST", {
905
+ message: `Strategy ${strategyId} does not support OAuth`,
906
+ });
907
+ }
908
+
909
+ // Get strategy config to pass to OAuth functions
910
+ const strategyConfig = await strategyService.getStrategyConfig(
911
+ strategyId
912
+ );
913
+ if (!strategyConfig) {
914
+ throw new ORPCError("BAD_REQUEST", {
915
+ message: `Strategy ${strategyId} is not configured. Please configure it in admin settings first.`,
916
+ });
917
+ }
918
+
919
+ // Build the OAuth authorization URL
920
+ const baseUrl = process.env.BASE_URL ?? "http://localhost:3000";
921
+ const callbackUrl = `${baseUrl}/api/notification/oauth/${strategyId}/callback`;
922
+ const defaultReturnUrl = "/notification/settings";
923
+
924
+ // Encode state for CSRF protection
925
+ const stateData = JSON.stringify({
926
+ userId,
927
+ returnUrl: returnUrl ?? defaultReturnUrl,
928
+ ts: Date.now(),
929
+ });
930
+ const state = btoa(stateData);
931
+
932
+ // Call OAuth config functions with strategy config
933
+ const clientId = strategy.oauth.clientId(strategyConfig);
934
+ const authorizationUrl =
935
+ strategy.oauth.authorizationUrl(strategyConfig);
936
+
937
+ // Build authorization URL
938
+ const url = new URL(authorizationUrl);
939
+ url.searchParams.set("client_id", clientId);
940
+ url.searchParams.set("redirect_uri", callbackUrl);
941
+ url.searchParams.set("scope", strategy.oauth.scopes.join(" "));
942
+ url.searchParams.set("state", state);
943
+ url.searchParams.set("response_type", "code");
944
+
945
+ return { authUrl: url.toString() };
946
+ }
947
+ ),
948
+
949
+ unlinkDeliveryChannel: os.unlinkDeliveryChannel.handler(
950
+ async ({ input, context }) => {
951
+ const userId = (context.user as RealUser).id;
952
+ const { strategyId } = input;
953
+
954
+ const strategy = strategyRegistry.getStrategy(strategyId);
955
+ if (!strategy) {
956
+ throw new ORPCError("NOT_FOUND", {
957
+ message: `Strategy not found: ${strategyId}`,
958
+ });
959
+ }
960
+
961
+ // Clear OAuth tokens
962
+ await strategyService.clearOAuthTokens(userId, strategyId);
963
+ }
964
+ ),
965
+
966
+ // Send a test notification to the current user via a specific strategy
967
+ sendTestNotification: os.sendTestNotification.handler(
968
+ async ({ input, context }) => {
969
+ const userId = (context.user as RealUser).id;
970
+ const { strategyId } = input;
971
+
972
+ const strategy = strategyRegistry.getStrategy(strategyId);
973
+ if (!strategy) {
974
+ return { success: false, error: `Strategy not found: ${strategyId}` };
975
+ }
976
+
977
+ // Check strategy is enabled
978
+ const meta = await strategyService.getStrategyMeta(strategyId);
979
+ if (!meta.enabled) {
980
+ return {
981
+ success: false,
982
+ error: "This channel is not enabled by your administrator",
983
+ };
984
+ }
985
+
986
+ // Get user info
987
+ const authClient = rpcApi.forPlugin(AuthApi);
988
+ const user = await authClient.getUserById({ userId });
989
+ if (!user) {
990
+ return { success: false, error: "User not found" };
991
+ }
992
+
993
+ // Get user preference to resolve contact
994
+ const pref = await strategyService.getUserPreference(
995
+ userId,
996
+ strategyId
997
+ );
998
+ const contact = resolveContact({
999
+ strategy,
1000
+ userEmail: user.email,
1001
+ userPreference: pref,
1002
+ });
1003
+
1004
+ if (!contact) {
1005
+ return {
1006
+ success: false,
1007
+ error:
1008
+ "Channel not configured - please set up your contact information first",
1009
+ };
1010
+ }
1011
+
1012
+ // Get strategy config
1013
+ const strategyConfig = await strategyService.getStrategyConfig(
1014
+ strategyId
1015
+ );
1016
+ if (!strategyConfig) {
1017
+ return {
1018
+ success: false,
1019
+ error: "Channel not configured by administrator",
1020
+ };
1021
+ }
1022
+
1023
+ const layoutConfig = await strategyService.getLayoutConfig(strategyId);
1024
+
1025
+ // Build test notification with markdown and action
1026
+ const testNotification: NotificationPayload = {
1027
+ title: "🧪 Test Notification",
1028
+ body: `This is a **test notification** from Checkstack!\n\nIf you're seeing this, your *${strategy.displayName}* channel is working correctly.\n\n✅ Markdown formatting\n✅ Emoji support\n✅ Action buttons (below)`,
1029
+ importance: "info",
1030
+ action: {
1031
+ label: "Open Notification Settings",
1032
+ url: "/notification/settings",
1033
+ },
1034
+ type: "notification",
1035
+ };
1036
+
1037
+ // Get base URL for action
1038
+ const baseUrl = process.env.VITE_FRONTEND_URL;
1039
+ if (baseUrl && testNotification.action) {
1040
+ // For localhost, use a demo URL to show action buttons work
1041
+ // (Telegram rejects localhost URLs in action buttons)
1042
+ const isLocalhost =
1043
+ baseUrl.includes("localhost") || baseUrl.includes("127.0.0.1");
1044
+ if (isLocalhost) {
1045
+ testNotification.action.url =
1046
+ "https://example.com/notification/settings";
1047
+ testNotification.body +=
1048
+ "\n\n_Note: Action button links to example\\.com in development since Telegram blocks localhost URLs\\._";
1049
+ } else {
1050
+ testNotification.action.url = `${baseUrl.replace(/\/$/, "")}${
1051
+ testNotification.action.url
1052
+ }`;
1053
+ }
1054
+ }
1055
+
1056
+ // Build send context
1057
+ const sendContext: NotificationSendContext<unknown, unknown, unknown> =
1058
+ {
1059
+ user: {
1060
+ userId: user.id,
1061
+ email: user.email,
1062
+ displayName: user.name ?? undefined,
1063
+ },
1064
+ contact,
1065
+ notification: testNotification,
1066
+ strategyConfig,
1067
+ userConfig: pref?.userConfig,
1068
+ layoutConfig,
1069
+ logger,
1070
+ };
1071
+
1072
+ // Send via strategy
1073
+ try {
1074
+ const result = await strategy.send(sendContext);
1075
+ return { success: result.success, error: result.error };
1076
+ } catch (error) {
1077
+ return {
1078
+ success: false,
1079
+ error:
1080
+ error instanceof Error
1081
+ ? error.message
1082
+ : "Failed to send test notification",
1083
+ };
1084
+ }
1085
+ }
1086
+ ),
1087
+ });
1088
+ };
1089
+
1090
+ export type NotificationRouter = ReturnType<typeof createNotificationRouter>;