@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.
@@ -0,0 +1,551 @@
1
+ /**
2
+ * Strategy Service
3
+ *
4
+ * Manages notification strategy configuration (admin) and user preferences (per-user).
5
+ * Uses ConfigService for automatic secret encryption on OAuth tokens.
6
+ */
7
+
8
+ import { z } from "zod";
9
+ import {
10
+ configBoolean,
11
+ configString,
12
+ type ConfigService,
13
+ } from "@checkstack/backend-api";
14
+ import type { NodePgDatabase } from "drizzle-orm/node-postgres";
15
+ import type { NotificationStrategyRegistry } from "@checkstack/backend-api";
16
+ import type * as schema from "./schema";
17
+
18
+ // Config ID patterns (module-level for lint compliance)
19
+ const strategyConfigId = (strategyId: string): string =>
20
+ `strategy.${strategyId}.config`;
21
+ const strategyLayoutConfigId = (strategyId: string): string =>
22
+ `strategy.${strategyId}.layoutConfig`;
23
+ const strategyMetaId = (strategyId: string): string =>
24
+ `strategy.${strategyId}.meta`;
25
+ const userPreferenceId = (userId: string, strategyId: string): string =>
26
+ `user-pref.${userId}.${strategyId}`;
27
+
28
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
29
+ // User Preference Schema (with secret-branded tokens)
30
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
31
+
32
+ /**
33
+ * Schema for user notification preferences stored via ConfigService.
34
+ * Tokens are marked with x-secret for automatic encryption.
35
+ */
36
+ export const UserPreferenceConfigSchema = z.object({
37
+ /** Whether user has enabled this channel */
38
+ enabled: configBoolean({}).default(true),
39
+ /** User's strategy-specific config (validated via strategy.userConfig) */
40
+ userConfig: z.record(z.string(), z.unknown()).optional(),
41
+ /** External user ID from OAuth linking (e.g., Slack user ID) */
42
+ externalId: configString({}).optional(),
43
+ /** Encrypted access token for OAuth strategies */
44
+ accessToken: configString({ "x-secret": true })
45
+ .describe("Access token")
46
+ .optional(),
47
+ /** Encrypted refresh token for OAuth strategies */
48
+ refreshToken: configString({ "x-secret": true })
49
+ .describe("Refresh token")
50
+ .optional(),
51
+ /** Token expiration timestamp (ISO string) */
52
+ tokenExpiresAt: configString({}).optional(),
53
+ /** When the external account was linked (ISO string) */
54
+ linkedAt: configString({}).optional(),
55
+ });
56
+
57
+ export type UserPreferenceConfig = z.infer<typeof UserPreferenceConfigSchema>;
58
+
59
+ const USER_PREFERENCE_VERSION = 1;
60
+
61
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
62
+ // Strategy Config Schema (admin settings)
63
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
64
+
65
+ /**
66
+ * Meta-config for strategy enabled state (follows auth strategy pattern).
67
+ */
68
+ export const StrategyMetaConfigSchema = z.object({
69
+ enabled: z.boolean().default(false),
70
+ });
71
+
72
+ export type StrategyMetaConfig = z.infer<typeof StrategyMetaConfigSchema>;
73
+
74
+ const STRATEGY_META_VERSION = 1;
75
+
76
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
77
+ // Service Interface
78
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
79
+
80
+ export interface StrategyServiceDeps {
81
+ db: NodePgDatabase<typeof schema>;
82
+ configService: ConfigService;
83
+ strategyRegistry: NotificationStrategyRegistry;
84
+ }
85
+
86
+ export interface StrategyService {
87
+ // ─────────────────────────────────────────────────────────────────────────
88
+ // Admin Strategy Management
89
+ // ─────────────────────────────────────────────────────────────────────────
90
+
91
+ /** Get meta-config (enabled state) for a strategy */
92
+ getStrategyMeta(strategyId: string): Promise<StrategyMetaConfig>;
93
+
94
+ /** Update meta-config (enabled state) for a strategy */
95
+ setStrategyMeta(strategyId: string, meta: StrategyMetaConfig): Promise<void>;
96
+
97
+ /** Get strategy config (parsed via strategy's Versioned config) */
98
+ getStrategyConfig<T>(strategyId: string): Promise<T | undefined>;
99
+
100
+ /** Get strategy config redacted (for frontend - secrets stripped) */
101
+ getStrategyConfigRedacted<T>(
102
+ strategyId: string
103
+ ): Promise<Partial<T> | undefined>;
104
+
105
+ /** Set strategy config (stored via ConfigService) */
106
+ setStrategyConfig<T>(strategyId: string, config: T): Promise<void>;
107
+
108
+ /** Get layout config (parsed via strategy's layoutConfig schema) */
109
+ getLayoutConfig<T>(strategyId: string): Promise<T | undefined>;
110
+
111
+ /** Get layout config redacted (for frontend - secrets stripped) */
112
+ getLayoutConfigRedacted<T>(
113
+ strategyId: string
114
+ ): Promise<Partial<T> | undefined>;
115
+
116
+ /** Set layout config (stored via ConfigService) */
117
+ setLayoutConfig<T>(strategyId: string, config: T): Promise<void>;
118
+
119
+ // ─────────────────────────────────────────────────────────────────────────
120
+ // User Preferences
121
+ // ─────────────────────────────────────────────────────────────────────────
122
+
123
+ /** Get user's preference for a specific strategy (internal use - includes decrypted tokens) */
124
+ getUserPreference(
125
+ userId: string,
126
+ strategyId: string
127
+ ): Promise<UserPreferenceConfig | undefined>;
128
+
129
+ /** Get user's preference redacted (for frontend - secrets stripped) */
130
+ getUserPreferenceRedacted(
131
+ userId: string,
132
+ strategyId: string
133
+ ): Promise<Partial<UserPreferenceConfig> | undefined>;
134
+
135
+ /** Set/update user's preference for a specific strategy */
136
+ setUserPreference(
137
+ userId: string,
138
+ strategyId: string,
139
+ preference: Partial<UserPreferenceConfig>
140
+ ): Promise<void>;
141
+
142
+ /** Get all preferences for a user redacted (for frontend - secrets stripped) */
143
+ getAllUserPreferencesRedacted(userId: string): Promise<
144
+ Array<{
145
+ strategyId: string;
146
+ preference: Partial<UserPreferenceConfig>;
147
+ }>
148
+ >;
149
+
150
+ /** Get all preferences for a user (includes decrypted tokens - internal use) */
151
+ getAllUserPreferences(userId: string): Promise<
152
+ Array<{
153
+ strategyId: string;
154
+ preference: UserPreferenceConfig;
155
+ }>
156
+ >;
157
+
158
+ /** Delete all preferences for a user (for cleanup on user deletion) */
159
+ deleteUserPreferences(userId: string): Promise<void>;
160
+
161
+ // ─────────────────────────────────────────────────────────────────────────
162
+ // OAuth Token Storage
163
+ // ─────────────────────────────────────────────────────────────────────────
164
+
165
+ /** Store OAuth tokens for a user+strategy (encrypted via ConfigService) */
166
+ storeOAuthTokens(params: {
167
+ userId: string;
168
+ strategyId: string;
169
+ externalId: string;
170
+ accessToken: string;
171
+ refreshToken?: string;
172
+ expiresAt?: Date;
173
+ }): Promise<void>;
174
+
175
+ /** Clear OAuth tokens for a user+strategy */
176
+ clearOAuthTokens(userId: string, strategyId: string): Promise<void>;
177
+
178
+ /** Get decrypted OAuth tokens for sending notifications */
179
+ getOAuthTokens(
180
+ userId: string,
181
+ strategyId: string
182
+ ): Promise<
183
+ | {
184
+ externalId: string;
185
+ accessToken: string;
186
+ refreshToken?: string;
187
+ expiresAt?: Date;
188
+ }
189
+ | undefined
190
+ >;
191
+
192
+ // ─────────────────────────────────────────────────────────────────────────
193
+ // Strategy Cleanup (for strategy removal/unregistration)
194
+ // ─────────────────────────────────────────────────────────────────────────
195
+
196
+ /** Delete admin config for a specific strategy */
197
+ deleteStrategyConfig(strategyId: string): Promise<void>;
198
+
199
+ /** Delete all configs (meta + config) for a strategy - used when unregistering */
200
+ deleteAllStrategyConfigs(strategyId: string): Promise<void>;
201
+
202
+ /** Delete all user preferences for a specific strategy - used when removing strategy */
203
+ deleteAllUserPreferencesForStrategy(strategyId: string): Promise<void>;
204
+ }
205
+
206
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
207
+ // Service Implementation
208
+ // ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
209
+
210
+ /**
211
+ * Creates a StrategyService instance.
212
+ */
213
+ export function createStrategyService(
214
+ deps: StrategyServiceDeps
215
+ ): StrategyService {
216
+ const { configService, strategyRegistry } = deps;
217
+
218
+ return {
219
+ // ─────────────────────────────────────────────────────────────────────────
220
+ // Admin Strategy Management
221
+ // ─────────────────────────────────────────────────────────────────────────
222
+
223
+ async getStrategyMeta(strategyId: string): Promise<StrategyMetaConfig> {
224
+ const meta = await configService.get(
225
+ strategyMetaId(strategyId),
226
+ StrategyMetaConfigSchema,
227
+ STRATEGY_META_VERSION
228
+ );
229
+ return meta ?? { enabled: false };
230
+ },
231
+
232
+ async setStrategyMeta(
233
+ strategyId: string,
234
+ meta: StrategyMetaConfig
235
+ ): Promise<void> {
236
+ await configService.set(
237
+ strategyMetaId(strategyId),
238
+ StrategyMetaConfigSchema,
239
+ STRATEGY_META_VERSION,
240
+ meta
241
+ );
242
+ },
243
+
244
+ async getStrategyConfig<T>(strategyId: string): Promise<T | undefined> {
245
+ const strategy = strategyRegistry.getStrategy(strategyId);
246
+ if (!strategy) return undefined;
247
+
248
+ const versioned = strategy.config;
249
+ const config = await configService.get(
250
+ strategyConfigId(strategyId),
251
+ versioned.schema,
252
+ versioned.version,
253
+ versioned.migrations
254
+ );
255
+ return config as T | undefined;
256
+ },
257
+
258
+ async getStrategyConfigRedacted<T>(
259
+ strategyId: string
260
+ ): Promise<Partial<T> | undefined> {
261
+ const strategy = strategyRegistry.getStrategy(strategyId);
262
+ if (!strategy) return undefined;
263
+
264
+ const versioned = strategy.config;
265
+ const config = await configService.getRedacted(
266
+ strategyConfigId(strategyId),
267
+ versioned.schema,
268
+ versioned.version,
269
+ versioned.migrations
270
+ );
271
+ return config as Partial<T> | undefined;
272
+ },
273
+
274
+ async setStrategyConfig<T>(strategyId: string, config: T): Promise<void> {
275
+ const strategy = strategyRegistry.getStrategy(strategyId);
276
+ if (!strategy) {
277
+ throw new Error(`Strategy not found: ${strategyId}`);
278
+ }
279
+
280
+ const versioned = strategy.config;
281
+ await configService.set(
282
+ strategyConfigId(strategyId),
283
+ versioned.schema,
284
+ versioned.version,
285
+ config,
286
+ versioned.migrations
287
+ );
288
+ },
289
+
290
+ async getLayoutConfig<T>(strategyId: string): Promise<T | undefined> {
291
+ const strategy = strategyRegistry.getStrategy(strategyId);
292
+ if (!strategy?.layoutConfig) return undefined;
293
+
294
+ const versioned = strategy.layoutConfig;
295
+ const config = await configService.get(
296
+ strategyLayoutConfigId(strategyId),
297
+ versioned.schema,
298
+ versioned.version,
299
+ versioned.migrations
300
+ );
301
+ return config as T | undefined;
302
+ },
303
+
304
+ async getLayoutConfigRedacted<T>(
305
+ strategyId: string
306
+ ): Promise<Partial<T> | undefined> {
307
+ const strategy = strategyRegistry.getStrategy(strategyId);
308
+ if (!strategy?.layoutConfig) return undefined;
309
+
310
+ const versioned = strategy.layoutConfig;
311
+ const config = await configService.getRedacted(
312
+ strategyLayoutConfigId(strategyId),
313
+ versioned.schema,
314
+ versioned.version,
315
+ versioned.migrations
316
+ );
317
+ return config as Partial<T> | undefined;
318
+ },
319
+
320
+ async setLayoutConfig<T>(strategyId: string, config: T): Promise<void> {
321
+ const strategy = strategyRegistry.getStrategy(strategyId);
322
+ if (!strategy?.layoutConfig) {
323
+ throw new Error(
324
+ `Strategy ${strategyId} does not support layout configuration`
325
+ );
326
+ }
327
+
328
+ const versioned = strategy.layoutConfig;
329
+ await configService.set(
330
+ strategyLayoutConfigId(strategyId),
331
+ versioned.schema,
332
+ versioned.version,
333
+ config,
334
+ versioned.migrations
335
+ );
336
+ },
337
+
338
+ // ─────────────────────────────────────────────────────────────────────────
339
+ // User Preferences
340
+ // ─────────────────────────────────────────────────────────────────────────
341
+
342
+ async getUserPreference(
343
+ userId: string,
344
+ strategyId: string
345
+ ): Promise<UserPreferenceConfig | undefined> {
346
+ return configService.get(
347
+ userPreferenceId(userId, strategyId),
348
+ UserPreferenceConfigSchema,
349
+ USER_PREFERENCE_VERSION
350
+ );
351
+ },
352
+
353
+ async getUserPreferenceRedacted(
354
+ userId: string,
355
+ strategyId: string
356
+ ): Promise<Partial<UserPreferenceConfig> | undefined> {
357
+ return configService.getRedacted(
358
+ userPreferenceId(userId, strategyId),
359
+ UserPreferenceConfigSchema,
360
+ USER_PREFERENCE_VERSION
361
+ );
362
+ },
363
+
364
+ async setUserPreference(
365
+ userId: string,
366
+ strategyId: string,
367
+ preference: Partial<UserPreferenceConfig>
368
+ ): Promise<void> {
369
+ const existing = await this.getUserPreference(userId, strategyId);
370
+ const merged: UserPreferenceConfig = {
371
+ enabled: true,
372
+ ...existing,
373
+ ...preference,
374
+ };
375
+
376
+ await configService.set(
377
+ userPreferenceId(userId, strategyId),
378
+ UserPreferenceConfigSchema,
379
+ USER_PREFERENCE_VERSION,
380
+ merged
381
+ );
382
+ },
383
+
384
+ async getAllUserPreferences(userId: string): Promise<
385
+ Array<{
386
+ strategyId: string;
387
+ preference: UserPreferenceConfig;
388
+ }>
389
+ > {
390
+ // List all config IDs and filter for user preferences
391
+ const allIds = await configService.list();
392
+ const prefix = `user-pref.${userId}.`;
393
+ const userPrefIds = allIds.filter((id) => id.startsWith(prefix));
394
+
395
+ const results: Array<{
396
+ strategyId: string;
397
+ preference: UserPreferenceConfig;
398
+ }> = [];
399
+
400
+ for (const id of userPrefIds) {
401
+ const strategyId = id.slice(prefix.length);
402
+ const pref = await configService.get(
403
+ id,
404
+ UserPreferenceConfigSchema,
405
+ USER_PREFERENCE_VERSION
406
+ );
407
+ if (pref) {
408
+ results.push({ strategyId, preference: pref });
409
+ }
410
+ }
411
+
412
+ return results;
413
+ },
414
+
415
+ async getAllUserPreferencesRedacted(userId: string): Promise<
416
+ Array<{
417
+ strategyId: string;
418
+ preference: Partial<UserPreferenceConfig>;
419
+ }>
420
+ > {
421
+ // List all config IDs and filter for user preferences
422
+ const allIds = await configService.list();
423
+ const prefix = `user-pref.${userId}.`;
424
+ const userPrefIds = allIds.filter((id) => id.startsWith(prefix));
425
+
426
+ const results: Array<{
427
+ strategyId: string;
428
+ preference: Partial<UserPreferenceConfig>;
429
+ }> = [];
430
+
431
+ for (const id of userPrefIds) {
432
+ const strategyId = id.slice(prefix.length);
433
+ const pref = await configService.getRedacted(
434
+ id,
435
+ UserPreferenceConfigSchema,
436
+ USER_PREFERENCE_VERSION
437
+ );
438
+ if (pref) {
439
+ results.push({ strategyId, preference: pref });
440
+ }
441
+ }
442
+
443
+ return results;
444
+ },
445
+
446
+ async deleteUserPreferences(userId: string): Promise<void> {
447
+ const allIds = await configService.list();
448
+ const prefix = `user-pref.${userId}.`;
449
+ const userPrefIds = allIds.filter((id) => id.startsWith(prefix));
450
+
451
+ for (const id of userPrefIds) {
452
+ await configService.delete(id);
453
+ }
454
+ // Note: Legacy userNotificationPreferences table is deprecated
455
+ // Cleanup of that table can be done as a separate migration task
456
+ },
457
+
458
+ // ─────────────────────────────────────────────────────────────────────────
459
+ // OAuth Token Storage
460
+ // ─────────────────────────────────────────────────────────────────────────
461
+
462
+ async storeOAuthTokens(params: {
463
+ userId: string;
464
+ strategyId: string;
465
+ externalId: string;
466
+ accessToken: string;
467
+ refreshToken?: string;
468
+ expiresAt?: Date;
469
+ }): Promise<void> {
470
+ await this.setUserPreference(params.userId, params.strategyId, {
471
+ enabled: true,
472
+ externalId: params.externalId,
473
+ accessToken: params.accessToken,
474
+ refreshToken: params.refreshToken,
475
+ tokenExpiresAt: params.expiresAt?.toISOString(),
476
+ linkedAt: new Date().toISOString(),
477
+ });
478
+ },
479
+
480
+ async clearOAuthTokens(userId: string, strategyId: string): Promise<void> {
481
+ const existing = await this.getUserPreference(userId, strategyId);
482
+ if (existing) {
483
+ await this.setUserPreference(userId, strategyId, {
484
+ ...existing,
485
+ externalId: undefined,
486
+ accessToken: undefined,
487
+ refreshToken: undefined,
488
+ tokenExpiresAt: undefined,
489
+ linkedAt: undefined,
490
+ });
491
+ }
492
+ },
493
+
494
+ async getOAuthTokens(
495
+ userId: string,
496
+ strategyId: string
497
+ ): Promise<
498
+ | {
499
+ externalId: string;
500
+ accessToken: string;
501
+ refreshToken?: string;
502
+ expiresAt?: Date;
503
+ }
504
+ | undefined
505
+ > {
506
+ const pref = await this.getUserPreference(userId, strategyId);
507
+ if (!pref?.externalId || !pref?.accessToken) {
508
+ return undefined;
509
+ }
510
+
511
+ return {
512
+ externalId: pref.externalId,
513
+ accessToken: pref.accessToken,
514
+ refreshToken: pref.refreshToken,
515
+ expiresAt: pref.tokenExpiresAt
516
+ ? new Date(pref.tokenExpiresAt)
517
+ : undefined,
518
+ };
519
+ },
520
+
521
+ // ─────────────────────────────────────────────────────────────────────────
522
+ // Strategy Cleanup (for strategy removal/unregistration)
523
+ // ─────────────────────────────────────────────────────────────────────────
524
+
525
+ async deleteStrategyConfig(strategyId: string): Promise<void> {
526
+ await configService.delete(strategyConfigId(strategyId));
527
+ },
528
+
529
+ async deleteAllStrategyConfigs(strategyId: string): Promise<void> {
530
+ // Delete meta-config (enabled state)
531
+ await configService.delete(strategyMetaId(strategyId));
532
+ // Delete strategy config
533
+ await configService.delete(strategyConfigId(strategyId));
534
+ },
535
+
536
+ async deleteAllUserPreferencesForStrategy(
537
+ strategyId: string
538
+ ): Promise<void> {
539
+ const allIds = await configService.list();
540
+ // User preference IDs are: user-pref.{userId}.{strategyId}
541
+ const suffix = `.${strategyId}`;
542
+ const prefIdsForStrategy = allIds.filter(
543
+ (id) => id.startsWith("user-pref.") && id.endsWith(suffix)
544
+ );
545
+
546
+ for (const id of prefIdsForStrategy) {
547
+ await configService.delete(id);
548
+ }
549
+ },
550
+ };
551
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,6 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json",
3
+ "include": [
4
+ "src"
5
+ ]
6
+ }