@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/CHANGELOG.md +133 -0
- package/drizzle/0000_tan_stryfe.sql +28 -0
- package/drizzle/0001_futuristic_apocalypse.sql +11 -0
- package/drizzle/0002_early_the_spike.sql +3 -0
- package/drizzle/0003_tiny_wendell_vaughn.sql +1 -0
- package/drizzle/0004_regular_corsair.sql +4 -0
- package/drizzle/meta/0000_snapshot.json +188 -0
- package/drizzle/meta/0001_snapshot.json +260 -0
- package/drizzle/meta/0002_snapshot.json +278 -0
- package/drizzle/meta/0003_snapshot.json +188 -0
- package/drizzle/meta/0004_snapshot.json +188 -0
- package/drizzle/meta/_journal.json +41 -0
- package/drizzle.config.ts +8 -0
- package/package.json +37 -0
- package/src/index.ts +280 -0
- package/src/oauth-callback-handler.ts +209 -0
- package/src/retention-config.ts +30 -0
- package/src/router.test.ts +38 -0
- package/src/router.ts +1090 -0
- package/src/schema.ts +54 -0
- package/src/service.ts +216 -0
- package/src/strategy-service.test.ts +478 -0
- package/src/strategy-service.ts +551 -0
- package/tsconfig.json +6 -0
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>;
|