@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.
- package/dist/custom-elements.json +2957 -2063
- package/dist/dts/components/foundation-inbox/inbox-base/inbox-base.d.ts +16 -0
- package/dist/dts/components/foundation-inbox/inbox-base/inbox-base.d.ts.map +1 -1
- package/dist/dts/components/foundation-inbox/inbox.styles.d.ts.map +1 -1
- package/dist/dts/components/foundation-inbox/inbox.template.d.ts.map +1 -1
- package/dist/dts/components/foundation-inbox/inbox.utils.d.ts +1 -0
- package/dist/dts/components/foundation-inbox/inbox.utils.d.ts.map +1 -1
- package/dist/dts/components/foundation-notification-dashboard/notification-dashboard.utils.d.ts.map +1 -1
- package/dist/dts/react.d.ts +38 -38
- package/dist/dts/services/notify-route.service.d.ts +27 -0
- package/dist/dts/services/notify-route.service.d.ts.map +1 -0
- package/dist/esm/components/foundation-inbox/inbox-base/inbox-base.js +183 -0
- package/dist/esm/components/foundation-inbox/inbox.styles.js +27 -0
- package/dist/esm/components/foundation-inbox/inbox.template.js +35 -3
- package/dist/esm/components/foundation-inbox/inbox.utils.js +7 -0
- package/dist/esm/components/foundation-notification-dashboard/notification-dashboard.utils.js +3 -1
- package/dist/esm/services/notify-route.service.js +107 -0
- package/dist/pbc-notify-ui.d.ts +45 -0
- package/dist/react.cjs +44 -44
- package/dist/react.mjs +39 -39
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +23 -23
- package/src/components/foundation-inbox/inbox-base/inbox-base.ts +206 -0
- package/src/components/foundation-inbox/inbox.styles.ts +27 -0
- package/src/components/foundation-inbox/inbox.template.ts +48 -3
- package/src/components/foundation-inbox/inbox.utils.ts +8 -0
- package/src/components/foundation-notification-dashboard/notification-dashboard.utils.ts +7 -1
- 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 {
|
|
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
|
+
);
|