@genesislcap/pbc-notify-ui 14.487.0 → 14.488.0

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.
Files changed (28) hide show
  1. package/dist/custom-elements.json +2957 -2063
  2. package/dist/dts/components/foundation-inbox/inbox-base/inbox-base.d.ts +16 -0
  3. package/dist/dts/components/foundation-inbox/inbox-base/inbox-base.d.ts.map +1 -1
  4. package/dist/dts/components/foundation-inbox/inbox.styles.d.ts.map +1 -1
  5. package/dist/dts/components/foundation-inbox/inbox.template.d.ts.map +1 -1
  6. package/dist/dts/components/foundation-inbox/inbox.utils.d.ts +1 -0
  7. package/dist/dts/components/foundation-inbox/inbox.utils.d.ts.map +1 -1
  8. package/dist/dts/components/foundation-notification-dashboard/notification-dashboard.utils.d.ts.map +1 -1
  9. package/dist/dts/react.d.ts +38 -38
  10. package/dist/dts/services/notify-route.service.d.ts +27 -0
  11. package/dist/dts/services/notify-route.service.d.ts.map +1 -0
  12. package/dist/esm/components/foundation-inbox/inbox-base/inbox-base.js +183 -0
  13. package/dist/esm/components/foundation-inbox/inbox.styles.js +27 -0
  14. package/dist/esm/components/foundation-inbox/inbox.template.js +35 -3
  15. package/dist/esm/components/foundation-inbox/inbox.utils.js +7 -0
  16. package/dist/esm/components/foundation-notification-dashboard/notification-dashboard.utils.js +3 -1
  17. package/dist/esm/services/notify-route.service.js +107 -0
  18. package/dist/pbc-notify-ui.d.ts +45 -0
  19. package/dist/react.cjs +44 -44
  20. package/dist/react.mjs +39 -39
  21. package/dist/tsconfig.tsbuildinfo +1 -1
  22. package/package.json +23 -23
  23. package/src/components/foundation-inbox/inbox-base/inbox-base.ts +206 -0
  24. package/src/components/foundation-inbox/inbox.styles.ts +27 -0
  25. package/src/components/foundation-inbox/inbox.template.ts +48 -3
  26. package/src/components/foundation-inbox/inbox.utils.ts +8 -0
  27. package/src/components/foundation-notification-dashboard/notification-dashboard.utils.ts +7 -1
  28. package/src/services/notify-route.service.ts +164 -0
@@ -16,11 +16,18 @@ import debounce from 'lodash.debounce';
16
16
  import { Subscription } from 'rxjs';
17
17
  import { AlertService } from '../../../services/alert.service';
18
18
  import { FoundationInboxService, INBOX_PAGE_SIZE } from '../../../services/inbox.service';
19
+ import {
20
+ NotifyChannel,
21
+ NotifyRoute,
22
+ NotifyRouteService,
23
+ } from '../../../services/notify-route.service';
19
24
  import { RuleService } from '../../../services/rule.service';
20
25
  import { TemplateService } from '../../../services/template.service';
21
26
  import { logger } from '../../../utils';
27
+ import { NotifyPermission } from '../../../utils/notifyPermissions';
22
28
  import { showNotificationError } from '../../foundation-notification-dashboard/notification-dashboard.utils';
23
29
  import { Alert, InboxTab, NotificationRuleTemplateReply, Rule, RuleTemplate } from '../inbox.types';
30
+ import { extractTopic } from '../inbox.utils';
24
31
 
25
32
  dayjs.extend(utc);
26
33
 
@@ -54,6 +61,15 @@ export class FoundationInboxBase extends GenesisElement {
54
61
  @observable templates: RuleTemplate[] = [];
55
62
  @observable templatesFilter: RuleTemplate[] = [];
56
63
 
64
+ @NotifyRouteService routeService: NotifyRouteService;
65
+ @observable channelAvailability: Record<string, Record<NotifyChannel, boolean>> = {};
66
+ @observable userChannelRoutes: Record<string, Record<NotifyChannel, NotifyRoute | null>> = {};
67
+ @observable channelTogglePending: Record<string, Partial<Record<NotifyChannel, boolean>>> = {};
68
+
69
+ // Bumped on each refreshChannelRoutes call so a slower earlier run can detect it
70
+ // has been superseded and skip writing stale results.
71
+ private channelRoutesQueryId = 0;
72
+
57
73
  @observable searchAlertLog: string = null;
58
74
  @observable titleSearchValue: string = null;
59
75
  @observable bodySearchValue: string = null;
@@ -297,9 +313,67 @@ export class FoundationInboxBase extends GenesisElement {
297
313
  });
298
314
  this.subscribedTemplateIds = subscribed;
299
315
 
316
+ await this.refreshChannelRoutes();
317
+
300
318
  this.templatesChanged();
301
319
  }
302
320
 
321
+ private async refreshChannelRoutes() {
322
+ if (!this.auth.currentUser?.hasPermission(NotifyPermission.NotificationRouteView)) {
323
+ this.channelAvailability = {};
324
+ this.userChannelRoutes = {};
325
+ return;
326
+ }
327
+
328
+ const username = this.auth.currentUser?.username;
329
+ this.channelRoutesQueryId += 1;
330
+ const queryId = this.channelRoutesQueryId;
331
+
332
+ // Two snapshots total (one per channel) instead of two per template: fetch every
333
+ // route once and match it to templates by TOPIC_MATCH in memory below.
334
+ const [popupRoutes, emailRoutes] = await Promise.all([
335
+ this.routeService.getAllRoutes('popup'),
336
+ this.routeService.getAllRoutes('email'),
337
+ ]);
338
+
339
+ // A newer refresh started while this one was in flight; its data is fresher, so
340
+ // drop ours rather than clobber it with a now-stale snapshot.
341
+ if (queryId !== this.channelRoutesQueryId) {
342
+ return;
343
+ }
344
+
345
+ const availability: Record<string, Record<NotifyChannel, boolean>> = {};
346
+ const userRoutes: Record<string, Record<NotifyChannel, NotifyRoute | null>> = {};
347
+
348
+ this.templates.forEach((template) => {
349
+ const topic = extractTopic(template.RESULT_EXPRESSION);
350
+ if (!topic) {
351
+ availability[template.ID] = { popup: false, email: false };
352
+ userRoutes[template.ID] = { popup: null, email: null };
353
+ return;
354
+ }
355
+
356
+ const popupForTopic = popupRoutes.filter((route) => route.TOPIC_MATCH === topic);
357
+ const emailForTopic = emailRoutes.filter((route) => route.TOPIC_MATCH === topic);
358
+
359
+ availability[template.ID] = {
360
+ popup: popupForTopic.length > 0,
361
+ email: emailForTopic.length > 0,
362
+ };
363
+ userRoutes[template.ID] = {
364
+ popup: popupForTopic.find((route) => this.isOwnRoute(route, username)) ?? null,
365
+ email: emailForTopic.find((route) => this.isOwnRoute(route, username)) ?? null,
366
+ };
367
+ });
368
+
369
+ this.channelAvailability = availability;
370
+ this.userChannelRoutes = userRoutes;
371
+ }
372
+
373
+ private isOwnRoute(route: NotifyRoute, username: string): boolean {
374
+ return !!username && route.ENTITY_ID_TYPE === 'USER_NAME' && route.ENTITY_ID === username;
375
+ }
376
+
303
377
  findSubscribedRule(template: RuleTemplate): Rule | undefined {
304
378
  return this.rules.find((rule) => rule.NAME === template.NAME);
305
379
  }
@@ -308,6 +382,94 @@ export class FoundationInboxBase extends GenesisElement {
308
382
  return !!this.subscribedTemplateIds[template.ID];
309
383
  }
310
384
 
385
+ isChannelAvailable(template: RuleTemplate, channel: NotifyChannel): boolean {
386
+ return !!this.channelAvailability[template.ID]?.[channel];
387
+ }
388
+
389
+ isChannelEnabled(template: RuleTemplate, channel: NotifyChannel): boolean {
390
+ return !!this.userChannelRoutes[template.ID]?.[channel];
391
+ }
392
+
393
+ private isLastEnabledChannel(template: RuleTemplate, channel: NotifyChannel): boolean {
394
+ const otherChannel: NotifyChannel = channel === 'popup' ? 'email' : 'popup';
395
+ return !(
396
+ this.isChannelAvailable(template, otherChannel) &&
397
+ this.isChannelEnabled(template, otherChannel)
398
+ );
399
+ }
400
+
401
+ isChannelTogglePending(template: RuleTemplate, channel: NotifyChannel): boolean {
402
+ return !!this.channelTogglePending[template.ID]?.[channel];
403
+ }
404
+
405
+ private setChannelRoute(templateId: string, channel: NotifyChannel, route: NotifyRoute | null) {
406
+ this.userChannelRoutes = {
407
+ ...this.userChannelRoutes,
408
+ [templateId]: { ...this.userChannelRoutes[templateId], [channel]: route },
409
+ };
410
+ this.channelAvailability = {
411
+ ...this.channelAvailability,
412
+ [templateId]: {
413
+ ...this.channelAvailability[templateId],
414
+ [channel]: this.channelAvailability[templateId]?.[channel] || !!route,
415
+ },
416
+ };
417
+ }
418
+
419
+ private setChannelTogglePending(templateId: string, channel: NotifyChannel, pending: boolean) {
420
+ this.channelTogglePending = {
421
+ ...this.channelTogglePending,
422
+ [templateId]: { ...this.channelTogglePending[templateId], [channel]: pending },
423
+ };
424
+ }
425
+
426
+ async toggleChannel(template: RuleTemplate, channel: NotifyChannel) {
427
+ if (!this.isTemplateSubscribed(template) || this.isChannelTogglePending(template, channel)) {
428
+ return;
429
+ }
430
+
431
+ const topic = extractTopic(template.RESULT_EXPRESSION);
432
+ if (!topic) {
433
+ return;
434
+ }
435
+
436
+ const existingRoute = this.userChannelRoutes[template.ID]?.[channel];
437
+
438
+ if (existingRoute && this.isLastEnabledChannel(template, channel)) {
439
+ showNotificationError([
440
+ {
441
+ CODE: 'VALIDATION_ERROR',
442
+ TEXT: 'At least one channel (Pop-up or Email) must stay enabled.',
443
+ },
444
+ ]);
445
+ return;
446
+ }
447
+
448
+ // Flip the chip instantly and let the network call catch up in the background —
449
+ // waiting on the round trip before updating the UI reads as an unresponsive click.
450
+ const optimisticRoute = existingRoute ? null : ({ NOTIFY_ROUTE_ID: 'pending' } as NotifyRoute);
451
+ this.setChannelRoute(template.ID, channel, optimisticRoute);
452
+ this.setChannelTogglePending(template.ID, channel, true);
453
+
454
+ const username = this.auth.currentUser?.username;
455
+ let updatedRoute: NotifyRoute | null;
456
+ try {
457
+ updatedRoute = existingRoute
458
+ ? await this.routeService
459
+ .deleteRoute(channel, existingRoute.NOTIFY_ROUTE_ID)
460
+ .then(() => null)
461
+ : await this.routeService.createUserRoute(channel, topic, username);
462
+ } catch (error) {
463
+ this.setChannelRoute(template.ID, channel, existingRoute ?? null);
464
+ this.setChannelTogglePending(template.ID, channel, false);
465
+ showNotificationError([{ CODE: 'ROUTE_ERROR', TEXT: (error as Error).message }]);
466
+ return;
467
+ }
468
+
469
+ this.setChannelRoute(template.ID, channel, updatedRoute);
470
+ this.setChannelTogglePending(template.ID, channel, false);
471
+ }
472
+
311
473
  async handleTemplateToggle(template: RuleTemplate, event: Event) {
312
474
  const target = event.target as HTMLInputElement;
313
475
  const checked = target.checked;
@@ -327,6 +489,7 @@ export class FoundationInboxBase extends GenesisElement {
327
489
  if (parameters.length === 0) {
328
490
  await this.ruleService.subscribeRule(template.ID, {});
329
491
  await this.refreshSubscribeData();
492
+ await this.ensureDefaultChannel(template);
330
493
  return;
331
494
  }
332
495
 
@@ -358,6 +521,49 @@ export class FoundationInboxBase extends GenesisElement {
358
521
  this.pendingSubscribeTemplateId = null;
359
522
  this.ruleTemplateDetails = null;
360
523
  await this.refreshSubscribeData();
524
+
525
+ const template = id && this.templates.find((t) => t.ID === id);
526
+ if (template) {
527
+ await this.ensureDefaultChannel(template);
528
+ }
529
+ }
530
+
531
+ // First-time subscribers start with no channel routes at all, which means no
532
+ // delivery until they notice and pick one themselves — default the first available
533
+ // channel (Pop-up preferred, else Email) on so a fresh subscription is never
534
+ // silently inert.
535
+ private async ensureDefaultChannel(template: RuleTemplate) {
536
+ if (!this.auth.currentUser?.hasPermission(NotifyPermission.NotificationRouteView)) {
537
+ return;
538
+ }
539
+
540
+ if (this.isChannelEnabled(template, 'popup') || this.isChannelEnabled(template, 'email')) {
541
+ return;
542
+ }
543
+
544
+ let defaultChannel: NotifyChannel | null = null;
545
+ if (this.isChannelAvailable(template, 'popup')) {
546
+ defaultChannel = 'popup';
547
+ } else if (this.isChannelAvailable(template, 'email')) {
548
+ defaultChannel = 'email';
549
+ }
550
+
551
+ if (!defaultChannel) {
552
+ return;
553
+ }
554
+
555
+ const topic = extractTopic(template.RESULT_EXPRESSION);
556
+ const username = this.auth.currentUser?.username;
557
+ if (!topic || !username) {
558
+ return;
559
+ }
560
+
561
+ try {
562
+ const route = await this.routeService.createUserRoute(defaultChannel, topic, username);
563
+ this.setChannelRoute(template.ID, defaultChannel, route);
564
+ } catch (error) {
565
+ logger.error(`Failed to enable default ${defaultChannel} channel:`, error);
566
+ }
361
567
  }
362
568
 
363
569
  cancelPendingSubscribe() {
@@ -326,6 +326,33 @@ export const ruleAndTemplate = css`
326
326
  font-size: var(--type-ramp-minus-1-font-size);
327
327
  color: var(--neutral-foreground-hint);
328
328
  }
329
+
330
+ .channel-chip-row {
331
+ display: flex;
332
+ align-items: center;
333
+ gap: calc(var(--design-unit) * 1px);
334
+ margin-top: calc(var(--design-unit) * 1px);
335
+ justify-content: flex-end;
336
+ }
337
+
338
+ .channel-chip {
339
+ border: calc(var(--stroke-width) * 1px) solid var(--neutral-stroke-divider-rest);
340
+ border-radius: calc(var(--control-corner-radius) * 999px);
341
+ font-size: var(--type-ramp-minus-2-font-size);
342
+
343
+ &[appearance='selected'] {
344
+ background-color: color-mix(in srgb, #7acc79, transparent 20%);
345
+ color: #fff;
346
+ border-color: transparent;
347
+ }
348
+
349
+ &[disabled] {
350
+ opacity: 50%;
351
+ background-color: var(--neutral-layer-3);
352
+ color: var(--neutral-foreground-hint);
353
+ cursor: not-allowed;
354
+ }
355
+ }
329
356
  }
330
357
 
331
358
  .rule {
@@ -267,9 +267,6 @@ export const FoundationInboxTemplate = html<FoundationInbox>`
267
267
  <div class="template-details">
268
268
  <div class="template-name">${(x) => x.NAME}</div>
269
269
  <div class="template-description">${(x) => x.DESCRIPTION}</div>
270
- <div class="template-datetime">
271
- ${(x, c) => getFormattedDate(x.DATETIME)}
272
- </div>
273
270
  </div>
274
271
  <rapid-switch
275
272
  class="template-subscribe-toggle"
@@ -278,6 +275,54 @@ export const FoundationInboxTemplate = html<FoundationInbox>`
278
275
  @click=${(_, c) => c.event.stopPropagation()}
279
276
  ></rapid-switch>
280
277
  </div>
278
+ ${when(
279
+ (x, c) =>
280
+ c.parent.auth.currentUser?.hasPermission(
281
+ NotifyPermission.NotificationRouteView,
282
+ ),
283
+ html<RuleTemplate, FoundationInbox>`
284
+ <div class="channel-chip-row">
285
+ ${when(
286
+ (x, c) => c.parent.isChannelAvailable(x, 'popup'),
287
+ html<RuleTemplate, FoundationInbox>`
288
+ <rapid-button
289
+ class="channel-chip"
290
+ appearance=${(x, c) =>
291
+ c.parent.isChannelEnabled(x, 'popup') ? 'selected' : ''}
292
+ ?disabled=${(x, c) =>
293
+ !c.parent.isTemplateSubscribed(x) ||
294
+ c.parent.isChannelTogglePending(x, 'popup')}
295
+ @click=${(x, c) => {
296
+ c.event.stopPropagation();
297
+ c.parent.toggleChannel(x, 'popup');
298
+ }}
299
+ >
300
+ Pop-up
301
+ </rapid-button>
302
+ `,
303
+ )}
304
+ ${when(
305
+ (x, c) => c.parent.isChannelAvailable(x, 'email'),
306
+ html<RuleTemplate, FoundationInbox>`
307
+ <rapid-button
308
+ class="channel-chip"
309
+ appearance=${(x, c) =>
310
+ c.parent.isChannelEnabled(x, 'email') ? 'selected' : ''}
311
+ ?disabled=${(x, c) =>
312
+ !c.parent.isTemplateSubscribed(x) ||
313
+ c.parent.isChannelTogglePending(x, 'email')}
314
+ @click=${(x, c) => {
315
+ c.event.stopPropagation();
316
+ c.parent.toggleChannel(x, 'email');
317
+ }}
318
+ >
319
+ Email
320
+ </rapid-button>
321
+ `,
322
+ )}
323
+ </div>
324
+ `,
325
+ )}
281
326
  ${when(
282
327
  (x, c) => c.parent.pendingSubscribeTemplateId === x.ID,
283
328
  html`
@@ -5,6 +5,14 @@ import {
5
5
  SEVERITY_WARNING,
6
6
  } from '../foundation-notification-dashboard/types/severity';
7
7
 
8
+ // Matches TOPIC = "VALUE" or TOPIC = 'VALUE'; the backreference keeps the
9
+ // opening and closing quote the same so mismatched quotes don't match.
10
+ const TOPIC_ASSIGNMENT = /TOPIC\s*=\s*(['"])([^'"]+)\1/;
11
+
12
+ export function extractTopic(resultExpression: string): string | null {
13
+ return resultExpression?.match(TOPIC_ASSIGNMENT)?.[2] ?? null;
14
+ }
15
+
8
16
  export const getFormattedDate = (dateTimeInMills) => {
9
17
  const dateTime = new Date(dateTimeInMills);
10
18
  return `${dateTime.toLocaleDateString()} ${dateTime.toLocaleTimeString()}`;
@@ -1,4 +1,8 @@
1
- import { showNotification } from '@genesislcap/foundation-notifications';
1
+ import {
2
+ showNotification,
3
+ ToastVerticalEdge,
4
+ ToastHorizontalEdge,
5
+ } from '@genesislcap/foundation-notifications';
2
6
  import { UUID } from '@genesislcap/foundation-utils';
3
7
  import {
4
8
  ConditionBuilderEntity,
@@ -764,6 +768,8 @@ export function showNotificationError(error) {
764
768
  config: {
765
769
  snackbar: {
766
770
  type: 'error',
771
+ verticalEdge: ToastVerticalEdge.Top,
772
+ horizontalEdge: ToastHorizontalEdge.Right,
767
773
  },
768
774
  },
769
775
  },
@@ -0,0 +1,164 @@
1
+ import { Connect } from '@genesislcap/foundation-comms';
2
+ import { DI } from '@genesislcap/web-core';
3
+
4
+ export type NotifyChannel = 'popup' | 'email';
5
+
6
+ // CREATE events don't return the generated NOTIFY_ROUTE_ID, so the new row is
7
+ // polled for by its identifying fields: retry a few times with a short delay.
8
+ const ROUTE_LOOKUP_MAX_ATTEMPTS = 8;
9
+ const ROUTE_LOOKUP_RETRY_DELAY_MS = 250;
10
+
11
+ export interface NotifyRoute {
12
+ NOTIFY_ROUTE_ID: string;
13
+ TOPIC_MATCH: string;
14
+ GATEWAY_ID: string;
15
+ ROUTE_ENABLED: boolean;
16
+ ENTITY_ID: string;
17
+ ENTITY_ID_TYPE: string;
18
+ }
19
+
20
+ const GATEWAY_ID: Record<NotifyChannel, string> = {
21
+ popup: 'Screen',
22
+ email: 'Email',
23
+ };
24
+
25
+ const RESOURCE_NAME: Record<NotifyChannel, string> = {
26
+ popup: 'ALL_SCREEN_ROUTES',
27
+ email: 'ALL_EMAIL_USER_ROUTES',
28
+ };
29
+
30
+ const CREATE_EVENT: Record<NotifyChannel, string> = {
31
+ popup: 'EVENT_SCREEN_NOTIFY_ROUTE_CREATE',
32
+ email: 'EVENT_EMAIL_USER_ROUTE_CREATE',
33
+ };
34
+
35
+ const DELETE_EVENT: Record<NotifyChannel, string> = {
36
+ popup: 'EVENT_SCREEN_NOTIFY_ROUTE_DELETE',
37
+ email: 'EVENT_EMAIL_USER_ROUTE_DELETE',
38
+ };
39
+
40
+ /**
41
+ * Per-user notification channel routing, backed by the platform's own
42
+ * NOTIFY_ROUTE / SCREEN_NOTIFY_ROUTE_EXT / EMAIL_USER_NOTIFY_ROUTE_EXT
43
+ * tables — the same CRUD events the admin Route Management screens use.
44
+ *
45
+ * A topic "supports" a channel when any route exists for that
46
+ * TOPIC_MATCH + GATEWAY_ID (admin-configured marker rows, entity-blank).
47
+ * A specific user has that channel enabled when a route additionally
48
+ * exists scoped to ENTITY_ID_TYPE=USER_NAME, ENTITY_ID=<username>.
49
+ */
50
+ export interface NotifyRouteService {
51
+ getAllRoutes(channel: NotifyChannel): Promise<NotifyRoute[]>;
52
+ findUserRoute(
53
+ channel: NotifyChannel,
54
+ topic: string,
55
+ username: string,
56
+ ): Promise<NotifyRoute | null>;
57
+ createUserRoute(channel: NotifyChannel, topic: string, username: string): Promise<NotifyRoute>;
58
+ deleteRoute(channel: NotifyChannel, notifyRouteId: string): Promise<void>;
59
+ }
60
+
61
+ class NotifyRouteServiceImpl implements NotifyRouteService {
62
+ @Connect private connect: Connect;
63
+
64
+ // Fetch every route for a channel in one snapshot, so callers can index many
65
+ // topics in memory instead of issuing one query per topic.
66
+ public async getAllRoutes(channel: NotifyChannel): Promise<NotifyRoute[]> {
67
+ return this.queryRoutes(channel);
68
+ }
69
+
70
+ public async findUserRoute(
71
+ channel: NotifyChannel,
72
+ topic: string,
73
+ username: string,
74
+ ): Promise<NotifyRoute | null> {
75
+ const criteriaMatch = `TOPIC_MATCH == '${this.escape(topic)}' && ENTITY_ID == '${this.escape(username)}' && ENTITY_ID_TYPE == 'USER_NAME'`;
76
+ const [route] = await this.queryRoutes(channel, criteriaMatch);
77
+ return route ?? null;
78
+ }
79
+
80
+ public async createUserRoute(
81
+ channel: NotifyChannel,
82
+ topic: string,
83
+ username: string,
84
+ ): Promise<NotifyRoute> {
85
+ // The dataserver can lag briefly behind an ACK, so a route that already exists
86
+ // (e.g. a prior create whose post-create lookup below timed out) must be reused
87
+ // instead of blindly creating a duplicate.
88
+ const existing = await this.findUserRoute(channel, topic, username);
89
+ if (existing) {
90
+ return existing;
91
+ }
92
+
93
+ const response = await this.connect.commitEvent(CREATE_EVENT[channel], {
94
+ DETAILS: {
95
+ TOPIC_MATCH: topic,
96
+ GATEWAY_ID: GATEWAY_ID[channel],
97
+ ROUTE_ENABLED: true,
98
+ ENTITY_ID: username,
99
+ ENTITY_ID_TYPE: 'USER_NAME',
100
+ EXCLUDE_SENDER: false,
101
+ },
102
+ IGNORE_WARNINGS: true,
103
+ VALIDATE: false,
104
+ });
105
+
106
+ if (response.MESSAGE_TYPE !== 'EVENT_ACK') {
107
+ throw new Error(
108
+ `Failed to create ${channel} route for ${topic}: ${JSON.stringify(response.ERROR)}`,
109
+ );
110
+ }
111
+
112
+ // These CREATE events don't return the generated NOTIFY_ROUTE_ID in GENERATED,
113
+ // so the newly created row has to be looked up by its own identifying fields.
114
+ for (let attempt = 0; attempt < ROUTE_LOOKUP_MAX_ATTEMPTS; attempt += 1) {
115
+ // eslint-disable-next-line no-await-in-loop -- sequential poll: each retry waits for the previous lookup + delay
116
+ const created = await this.findUserRoute(channel, topic, username);
117
+ if (created) {
118
+ return created;
119
+ }
120
+ // eslint-disable-next-line no-await-in-loop -- intentional back-off between lookup attempts
121
+ await new Promise((resolve) => {
122
+ setTimeout(resolve, ROUTE_LOOKUP_RETRY_DELAY_MS);
123
+ });
124
+ }
125
+
126
+ throw new Error(`Created ${channel} route for ${topic} but could not find it afterwards`);
127
+ }
128
+
129
+ public async deleteRoute(channel: NotifyChannel, notifyRouteId: string): Promise<void> {
130
+ const response = await this.connect.commitEvent(DELETE_EVENT[channel], {
131
+ DETAILS: {
132
+ NOTIFY_ROUTE_ID: notifyRouteId,
133
+ },
134
+ IGNORE_WARNINGS: true,
135
+ VALIDATE: false,
136
+ });
137
+
138
+ if (response.MESSAGE_TYPE !== 'EVENT_ACK') {
139
+ throw new Error(
140
+ `Failed to delete ${channel} route ${notifyRouteId}: ${JSON.stringify(response.ERROR)}`,
141
+ );
142
+ }
143
+ }
144
+
145
+ private async queryRoutes(
146
+ channel: NotifyChannel,
147
+ criteriaMatch?: string,
148
+ ): Promise<NotifyRoute[]> {
149
+ const response = await this.connect.snapshot(
150
+ RESOURCE_NAME[channel],
151
+ criteriaMatch ? { CRITERIA_MATCH: criteriaMatch } : {},
152
+ );
153
+
154
+ return (response.ROW ?? []) as NotifyRoute[];
155
+ }
156
+
157
+ private escape(value: string): string {
158
+ return value ? value.replace(/'/g, "\\'") : '';
159
+ }
160
+ }
161
+
162
+ export const NotifyRouteService = DI.createInterface<NotifyRouteService>((x) =>
163
+ x.singleton(NotifyRouteServiceImpl),
164
+ );