@wopr-network/platform-core 1.29.0 → 1.30.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/email/notification-worker.d.ts +3 -0
- package/dist/email/notification-worker.js +12 -2
- package/dist/fleet/fleet-notification-listener.d.ts +13 -0
- package/dist/fleet/fleet-notification-listener.js +65 -0
- package/dist/fleet/index.d.ts +2 -0
- package/dist/fleet/index.js +1 -0
- package/package.json +1 -1
- package/src/email/notification-worker.ts +15 -2
- package/src/fleet/fleet-notification-listener.ts +87 -0
- package/src/fleet/index.ts +2 -0
|
@@ -5,18 +5,21 @@
|
|
|
5
5
|
* Do NOT put the interval inside this class.
|
|
6
6
|
*/
|
|
7
7
|
import type { EmailClient } from "./client.js";
|
|
8
|
+
import type { HandlebarsRenderer } from "./handlebars-renderer.js";
|
|
8
9
|
import type { INotificationPreferencesRepository, INotificationQueueRepository } from "./notification-repository-types.js";
|
|
9
10
|
export interface NotificationWorkerConfig {
|
|
10
11
|
queue: INotificationQueueRepository;
|
|
11
12
|
emailClient: EmailClient;
|
|
12
13
|
preferences: INotificationPreferencesRepository;
|
|
13
14
|
batchSize?: number;
|
|
15
|
+
handlebarsRenderer?: HandlebarsRenderer;
|
|
14
16
|
}
|
|
15
17
|
export declare class NotificationWorker {
|
|
16
18
|
private readonly queue;
|
|
17
19
|
private readonly emailClient;
|
|
18
20
|
private readonly preferences;
|
|
19
21
|
private readonly batchSize;
|
|
22
|
+
private readonly handlebarsRenderer;
|
|
20
23
|
constructor(config: NotificationWorkerConfig);
|
|
21
24
|
/** Process one batch of pending notifications. Returns count of processed items. */
|
|
22
25
|
processBatch(): Promise<number>;
|
|
@@ -35,17 +35,21 @@ const PREF_MAP = {
|
|
|
35
35
|
"dividend-weekly-digest": "billing_receipts",
|
|
36
36
|
"role-changed": "account_role_changes",
|
|
37
37
|
"team-invite": "account_team_invites",
|
|
38
|
+
"fleet-update-available": "fleet_updates",
|
|
39
|
+
"fleet-update-complete": "fleet_updates",
|
|
38
40
|
};
|
|
39
41
|
export class NotificationWorker {
|
|
40
42
|
queue;
|
|
41
43
|
emailClient;
|
|
42
44
|
preferences;
|
|
43
45
|
batchSize;
|
|
46
|
+
handlebarsRenderer;
|
|
44
47
|
constructor(config) {
|
|
45
48
|
this.queue = config.queue;
|
|
46
49
|
this.emailClient = config.emailClient;
|
|
47
50
|
this.preferences = config.preferences;
|
|
48
51
|
this.batchSize = config.batchSize ?? 10;
|
|
52
|
+
this.handlebarsRenderer = config.handlebarsRenderer;
|
|
49
53
|
}
|
|
50
54
|
/** Process one batch of pending notifications. Returns count of processed items. */
|
|
51
55
|
async processBatch() {
|
|
@@ -74,8 +78,14 @@ export class NotificationWorker {
|
|
|
74
78
|
continue;
|
|
75
79
|
}
|
|
76
80
|
}
|
|
77
|
-
//
|
|
78
|
-
|
|
81
|
+
// Try DB-driven Handlebars first, fall back to code templates
|
|
82
|
+
let rendered = null;
|
|
83
|
+
if (this.handlebarsRenderer) {
|
|
84
|
+
rendered = await this.handlebarsRenderer.render(notif.template, data);
|
|
85
|
+
}
|
|
86
|
+
if (!rendered) {
|
|
87
|
+
rendered = renderNotificationTemplate(notif.template, data);
|
|
88
|
+
}
|
|
79
89
|
// Send via email client
|
|
80
90
|
await this.emailClient.send({
|
|
81
91
|
to: email,
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { INotificationPreferencesRepository } from "../email/notification-repository-types.js";
|
|
2
|
+
import type { NotificationService } from "../email/notification-service.js";
|
|
3
|
+
import type { FleetEventEmitter } from "./fleet-event-emitter.js";
|
|
4
|
+
export interface FleetNotificationListenerDeps {
|
|
5
|
+
eventEmitter: FleetEventEmitter;
|
|
6
|
+
notificationService: NotificationService;
|
|
7
|
+
preferences: INotificationPreferencesRepository;
|
|
8
|
+
/** Resolve tenant ID to owner email. Return null if no email found. */
|
|
9
|
+
resolveEmail: (tenantId: string) => Promise<string | null>;
|
|
10
|
+
/** Debounce window in ms before sending summary email. Default 60_000. */
|
|
11
|
+
debounceMs?: number;
|
|
12
|
+
}
|
|
13
|
+
export declare function initFleetNotificationListener(deps: FleetNotificationListenerDeps): () => void;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { logger } from "../config/logger.js";
|
|
2
|
+
export function initFleetNotificationListener(deps) {
|
|
3
|
+
const { eventEmitter, notificationService, preferences, resolveEmail } = deps;
|
|
4
|
+
const debounceMs = deps.debounceMs ?? 60_000;
|
|
5
|
+
const pending = new Map();
|
|
6
|
+
async function flush(tenantId) {
|
|
7
|
+
const rollout = pending.get(tenantId);
|
|
8
|
+
if (!rollout)
|
|
9
|
+
return;
|
|
10
|
+
pending.delete(tenantId);
|
|
11
|
+
try {
|
|
12
|
+
const prefs = await preferences.get(tenantId);
|
|
13
|
+
if (!prefs.fleet_updates)
|
|
14
|
+
return;
|
|
15
|
+
const email = await resolveEmail(tenantId);
|
|
16
|
+
if (!email) {
|
|
17
|
+
logger.warn("Fleet notification skipped: no email for tenant", { tenantId });
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
// TODO: Surface actual target version from RolloutOrchestrator context.
|
|
21
|
+
// BotFleetEvent doesn't carry version info; "latest" is a placeholder.
|
|
22
|
+
notificationService.notifyFleetUpdateComplete(tenantId, email, "latest", rollout.succeeded, rollout.failed);
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
logger.error("Fleet notification flush error", { err, tenantId });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const unsubscribe = eventEmitter.subscribe((event) => {
|
|
29
|
+
if (!("tenantId" in event))
|
|
30
|
+
return;
|
|
31
|
+
const botEvent = event;
|
|
32
|
+
if (botEvent.type !== "bot.updated" && botEvent.type !== "bot.update_failed")
|
|
33
|
+
return;
|
|
34
|
+
let rollout = pending.get(botEvent.tenantId);
|
|
35
|
+
if (!rollout) {
|
|
36
|
+
rollout = {
|
|
37
|
+
tenantId: botEvent.tenantId,
|
|
38
|
+
succeeded: 0,
|
|
39
|
+
failed: 0,
|
|
40
|
+
timer: setTimeout(() => flush(botEvent.tenantId), debounceMs),
|
|
41
|
+
};
|
|
42
|
+
pending.set(botEvent.tenantId, rollout);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
// Reset timer on each new event (sliding window)
|
|
46
|
+
clearTimeout(rollout.timer);
|
|
47
|
+
rollout.timer = setTimeout(() => flush(botEvent.tenantId), debounceMs);
|
|
48
|
+
}
|
|
49
|
+
if (botEvent.type === "bot.updated") {
|
|
50
|
+
rollout.succeeded++;
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
rollout.failed++;
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
return () => {
|
|
57
|
+
unsubscribe();
|
|
58
|
+
// Flush all pending on shutdown
|
|
59
|
+
for (const [tenantId, rollout] of pending) {
|
|
60
|
+
clearTimeout(rollout.timer);
|
|
61
|
+
void flush(tenantId);
|
|
62
|
+
}
|
|
63
|
+
pending.clear();
|
|
64
|
+
};
|
|
65
|
+
}
|
package/dist/fleet/index.d.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export * from "./drizzle-tenant-update-config-repository.js";
|
|
2
|
+
export type { FleetNotificationListenerDeps } from "./fleet-notification-listener.js";
|
|
3
|
+
export { initFleetNotificationListener } from "./fleet-notification-listener.js";
|
|
2
4
|
export * from "./init-fleet-updater.js";
|
|
3
5
|
export * from "./repository-types.js";
|
|
4
6
|
export * from "./rollout-orchestrator.js";
|
package/dist/fleet/index.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
export * from "./drizzle-tenant-update-config-repository.js";
|
|
2
|
+
export { initFleetNotificationListener } from "./fleet-notification-listener.js";
|
|
2
3
|
export * from "./init-fleet-updater.js";
|
|
3
4
|
export * from "./repository-types.js";
|
|
4
5
|
export * from "./rollout-orchestrator.js";
|
package/package.json
CHANGED
|
@@ -7,17 +7,20 @@
|
|
|
7
7
|
|
|
8
8
|
import { logger } from "../config/logger.js";
|
|
9
9
|
import type { EmailClient } from "./client.js";
|
|
10
|
+
import type { HandlebarsRenderer } from "./handlebars-renderer.js";
|
|
10
11
|
import type {
|
|
11
12
|
INotificationPreferencesRepository,
|
|
12
13
|
INotificationQueueRepository,
|
|
13
14
|
} from "./notification-repository-types.js";
|
|
14
15
|
import { renderNotificationTemplate, type TemplateName } from "./notification-templates.js";
|
|
16
|
+
import type { TemplateResult } from "./templates.js";
|
|
15
17
|
|
|
16
18
|
export interface NotificationWorkerConfig {
|
|
17
19
|
queue: INotificationQueueRepository;
|
|
18
20
|
emailClient: EmailClient;
|
|
19
21
|
preferences: INotificationPreferencesRepository;
|
|
20
22
|
batchSize?: number;
|
|
23
|
+
handlebarsRenderer?: HandlebarsRenderer;
|
|
21
24
|
}
|
|
22
25
|
|
|
23
26
|
/** Templates that bypass user preference checks — always sent. */
|
|
@@ -50,6 +53,8 @@ const PREF_MAP: Record<string, string> = {
|
|
|
50
53
|
"dividend-weekly-digest": "billing_receipts",
|
|
51
54
|
"role-changed": "account_role_changes",
|
|
52
55
|
"team-invite": "account_team_invites",
|
|
56
|
+
"fleet-update-available": "fleet_updates",
|
|
57
|
+
"fleet-update-complete": "fleet_updates",
|
|
53
58
|
};
|
|
54
59
|
|
|
55
60
|
export class NotificationWorker {
|
|
@@ -57,12 +62,14 @@ export class NotificationWorker {
|
|
|
57
62
|
private readonly emailClient: EmailClient;
|
|
58
63
|
private readonly preferences: INotificationPreferencesRepository;
|
|
59
64
|
private readonly batchSize: number;
|
|
65
|
+
private readonly handlebarsRenderer: HandlebarsRenderer | undefined;
|
|
60
66
|
|
|
61
67
|
constructor(config: NotificationWorkerConfig) {
|
|
62
68
|
this.queue = config.queue;
|
|
63
69
|
this.emailClient = config.emailClient;
|
|
64
70
|
this.preferences = config.preferences;
|
|
65
71
|
this.batchSize = config.batchSize ?? 10;
|
|
72
|
+
this.handlebarsRenderer = config.handlebarsRenderer;
|
|
66
73
|
}
|
|
67
74
|
|
|
68
75
|
/** Process one batch of pending notifications. Returns count of processed items. */
|
|
@@ -96,8 +103,14 @@ export class NotificationWorker {
|
|
|
96
103
|
}
|
|
97
104
|
}
|
|
98
105
|
|
|
99
|
-
//
|
|
100
|
-
|
|
106
|
+
// Try DB-driven Handlebars first, fall back to code templates
|
|
107
|
+
let rendered: TemplateResult | null = null;
|
|
108
|
+
if (this.handlebarsRenderer) {
|
|
109
|
+
rendered = await this.handlebarsRenderer.render(notif.template, data);
|
|
110
|
+
}
|
|
111
|
+
if (!rendered) {
|
|
112
|
+
rendered = renderNotificationTemplate(notif.template as TemplateName, data);
|
|
113
|
+
}
|
|
101
114
|
|
|
102
115
|
// Send via email client
|
|
103
116
|
await this.emailClient.send({
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { logger } from "../config/logger.js";
|
|
2
|
+
import type { INotificationPreferencesRepository } from "../email/notification-repository-types.js";
|
|
3
|
+
import type { NotificationService } from "../email/notification-service.js";
|
|
4
|
+
import type { BotFleetEvent, FleetEventEmitter } from "./fleet-event-emitter.js";
|
|
5
|
+
|
|
6
|
+
export interface FleetNotificationListenerDeps {
|
|
7
|
+
eventEmitter: FleetEventEmitter;
|
|
8
|
+
notificationService: NotificationService;
|
|
9
|
+
preferences: INotificationPreferencesRepository;
|
|
10
|
+
/** Resolve tenant ID to owner email. Return null if no email found. */
|
|
11
|
+
resolveEmail: (tenantId: string) => Promise<string | null>;
|
|
12
|
+
/** Debounce window in ms before sending summary email. Default 60_000. */
|
|
13
|
+
debounceMs?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface PendingRollout {
|
|
17
|
+
tenantId: string;
|
|
18
|
+
succeeded: number;
|
|
19
|
+
failed: number;
|
|
20
|
+
timer: ReturnType<typeof setTimeout>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function initFleetNotificationListener(deps: FleetNotificationListenerDeps): () => void {
|
|
24
|
+
const { eventEmitter, notificationService, preferences, resolveEmail } = deps;
|
|
25
|
+
const debounceMs = deps.debounceMs ?? 60_000;
|
|
26
|
+
const pending = new Map<string, PendingRollout>();
|
|
27
|
+
|
|
28
|
+
async function flush(tenantId: string): Promise<void> {
|
|
29
|
+
const rollout = pending.get(tenantId);
|
|
30
|
+
if (!rollout) return;
|
|
31
|
+
pending.delete(tenantId);
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const prefs = await preferences.get(tenantId);
|
|
35
|
+
if (!prefs.fleet_updates) return;
|
|
36
|
+
|
|
37
|
+
const email = await resolveEmail(tenantId);
|
|
38
|
+
if (!email) {
|
|
39
|
+
logger.warn("Fleet notification skipped: no email for tenant", { tenantId });
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// TODO: Surface actual target version from RolloutOrchestrator context.
|
|
44
|
+
// BotFleetEvent doesn't carry version info; "latest" is a placeholder.
|
|
45
|
+
notificationService.notifyFleetUpdateComplete(tenantId, email, "latest", rollout.succeeded, rollout.failed);
|
|
46
|
+
} catch (err) {
|
|
47
|
+
logger.error("Fleet notification flush error", { err, tenantId });
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const unsubscribe = eventEmitter.subscribe((event) => {
|
|
52
|
+
if (!("tenantId" in event)) return;
|
|
53
|
+
const botEvent = event as BotFleetEvent;
|
|
54
|
+
if (botEvent.type !== "bot.updated" && botEvent.type !== "bot.update_failed") return;
|
|
55
|
+
|
|
56
|
+
let rollout = pending.get(botEvent.tenantId);
|
|
57
|
+
if (!rollout) {
|
|
58
|
+
rollout = {
|
|
59
|
+
tenantId: botEvent.tenantId,
|
|
60
|
+
succeeded: 0,
|
|
61
|
+
failed: 0,
|
|
62
|
+
timer: setTimeout(() => flush(botEvent.tenantId), debounceMs),
|
|
63
|
+
};
|
|
64
|
+
pending.set(botEvent.tenantId, rollout);
|
|
65
|
+
} else {
|
|
66
|
+
// Reset timer on each new event (sliding window)
|
|
67
|
+
clearTimeout(rollout.timer);
|
|
68
|
+
rollout.timer = setTimeout(() => flush(botEvent.tenantId), debounceMs);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (botEvent.type === "bot.updated") {
|
|
72
|
+
rollout.succeeded++;
|
|
73
|
+
} else {
|
|
74
|
+
rollout.failed++;
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return () => {
|
|
79
|
+
unsubscribe();
|
|
80
|
+
// Flush all pending on shutdown
|
|
81
|
+
for (const [tenantId, rollout] of pending) {
|
|
82
|
+
clearTimeout(rollout.timer);
|
|
83
|
+
void flush(tenantId);
|
|
84
|
+
}
|
|
85
|
+
pending.clear();
|
|
86
|
+
};
|
|
87
|
+
}
|
package/src/fleet/index.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export * from "./drizzle-tenant-update-config-repository.js";
|
|
2
|
+
export type { FleetNotificationListenerDeps } from "./fleet-notification-listener.js";
|
|
3
|
+
export { initFleetNotificationListener } from "./fleet-notification-listener.js";
|
|
2
4
|
export * from "./init-fleet-updater.js";
|
|
3
5
|
export * from "./repository-types.js";
|
|
4
6
|
export * from "./rollout-orchestrator.js";
|