@wopr-network/platform-core 1.28.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/db/schema/index.d.ts +1 -0
- package/dist/db/schema/index.js +1 -0
- package/dist/db/schema/notification-preferences.d.ts +17 -0
- package/dist/db/schema/notification-preferences.js +1 -0
- package/dist/db/schema/notification-templates.d.ts +165 -0
- package/dist/db/schema/notification-templates.js +18 -0
- package/dist/email/default-templates.d.ts +15 -0
- package/dist/email/default-templates.js +443 -0
- package/dist/email/drizzle-notification-template-repository.d.ts +21 -0
- package/dist/email/drizzle-notification-template-repository.js +90 -0
- package/dist/email/handlebars-renderer.d.ts +17 -0
- package/dist/email/handlebars-renderer.js +56 -0
- package/dist/email/index.d.ts +4 -0
- package/dist/email/index.js +3 -0
- package/dist/email/notification-preferences-store.js +5 -0
- package/dist/email/notification-preferences-store.test.js +1 -0
- package/dist/email/notification-repository-types.d.ts +30 -0
- package/dist/email/notification-service.d.ts +2 -0
- package/dist/email/notification-service.js +22 -0
- package/dist/email/notification-template-repository.d.ts +7 -0
- package/dist/email/notification-template-repository.js +7 -0
- package/dist/email/notification-templates.d.ts +1 -1
- package/dist/email/notification-templates.js +52 -0
- 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/dist/trpc/index.d.ts +1 -0
- package/dist/trpc/index.js +1 -0
- package/dist/trpc/notification-template-router.d.ts +59 -0
- package/dist/trpc/notification-template-router.js +81 -0
- package/drizzle/migrations/0011_notification_templates.sql +14 -0
- package/drizzle/migrations/meta/_journal.json +7 -0
- package/package.json +2 -1
- package/src/db/schema/index.ts +1 -0
- package/src/db/schema/notification-preferences.ts +1 -0
- package/src/db/schema/notification-templates.ts +19 -0
- package/src/email/default-templates.ts +680 -0
- package/src/email/drizzle-notification-template-repository.ts +108 -0
- package/src/email/handlebars-renderer.ts +64 -0
- package/src/email/index.ts +4 -0
- package/src/email/notification-preferences-store.test.ts +1 -0
- package/src/email/notification-preferences-store.ts +4 -0
- package/src/email/notification-repository-types.ts +41 -0
- package/src/email/notification-service.ts +31 -0
- package/src/email/notification-template-repository.ts +8 -0
- package/src/email/notification-templates.ts +61 -0
- package/src/email/notification-worker.ts +15 -2
- package/src/fleet/fleet-notification-listener.ts +87 -0
- package/src/fleet/index.ts +2 -0
- package/src/trpc/index.ts +1 -0
- package/src/trpc/notification-template-router.ts +91 -0
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DrizzleNotificationTemplateRepository — Drizzle ORM implementation
|
|
3
|
+
* of INotificationTemplateRepository.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import crypto from "node:crypto";
|
|
7
|
+
import { eq } from "drizzle-orm";
|
|
8
|
+
import type { PgDatabase } from "drizzle-orm/pg-core";
|
|
9
|
+
import { notificationTemplates } from "../db/schema/notification-templates.js";
|
|
10
|
+
import type { INotificationTemplateRepository, NotificationTemplateRow } from "./notification-repository-types.js";
|
|
11
|
+
|
|
12
|
+
export class DrizzleNotificationTemplateRepository implements INotificationTemplateRepository {
|
|
13
|
+
constructor(private readonly db: PgDatabase<never>) {}
|
|
14
|
+
|
|
15
|
+
async getByName(name: string): Promise<NotificationTemplateRow | null> {
|
|
16
|
+
const rows = await this.db
|
|
17
|
+
.select()
|
|
18
|
+
.from(notificationTemplates)
|
|
19
|
+
.where(eq(notificationTemplates.name, name))
|
|
20
|
+
.limit(1);
|
|
21
|
+
if (rows.length === 0) return null;
|
|
22
|
+
return this.toRow(rows[0]);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async list(): Promise<NotificationTemplateRow[]> {
|
|
26
|
+
const rows = await this.db.select().from(notificationTemplates);
|
|
27
|
+
return rows.map((r) => this.toRow(r));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async upsert(
|
|
31
|
+
name: string,
|
|
32
|
+
template: Omit<NotificationTemplateRow, "id" | "name" | "createdAt" | "updatedAt">,
|
|
33
|
+
): Promise<void> {
|
|
34
|
+
const now = Math.floor(Date.now() / 1000);
|
|
35
|
+
await this.db
|
|
36
|
+
.insert(notificationTemplates)
|
|
37
|
+
.values({
|
|
38
|
+
id: crypto.randomUUID(),
|
|
39
|
+
name,
|
|
40
|
+
description: template.description,
|
|
41
|
+
subject: template.subject,
|
|
42
|
+
htmlBody: template.htmlBody,
|
|
43
|
+
textBody: template.textBody,
|
|
44
|
+
active: template.active,
|
|
45
|
+
createdAt: now,
|
|
46
|
+
updatedAt: now,
|
|
47
|
+
})
|
|
48
|
+
.onConflictDoUpdate({
|
|
49
|
+
target: notificationTemplates.name,
|
|
50
|
+
set: {
|
|
51
|
+
description: template.description,
|
|
52
|
+
subject: template.subject,
|
|
53
|
+
htmlBody: template.htmlBody,
|
|
54
|
+
textBody: template.textBody,
|
|
55
|
+
active: template.active,
|
|
56
|
+
updatedAt: now,
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async seed(
|
|
62
|
+
templates: Array<{
|
|
63
|
+
name: string;
|
|
64
|
+
description: string;
|
|
65
|
+
subject: string;
|
|
66
|
+
htmlBody: string;
|
|
67
|
+
textBody: string;
|
|
68
|
+
}>,
|
|
69
|
+
): Promise<number> {
|
|
70
|
+
if (templates.length === 0) return 0;
|
|
71
|
+
|
|
72
|
+
const now = Math.floor(Date.now() / 1000);
|
|
73
|
+
const values = templates.map((t) => ({
|
|
74
|
+
id: crypto.randomUUID(),
|
|
75
|
+
name: t.name,
|
|
76
|
+
description: t.description,
|
|
77
|
+
subject: t.subject,
|
|
78
|
+
htmlBody: t.htmlBody,
|
|
79
|
+
textBody: t.textBody,
|
|
80
|
+
active: true,
|
|
81
|
+
createdAt: now,
|
|
82
|
+
updatedAt: now,
|
|
83
|
+
}));
|
|
84
|
+
|
|
85
|
+
// INSERT ... ON CONFLICT DO NOTHING — preserves admin edits
|
|
86
|
+
const result = await this.db
|
|
87
|
+
.insert(notificationTemplates)
|
|
88
|
+
.values(values)
|
|
89
|
+
.onConflictDoNothing({ target: notificationTemplates.name })
|
|
90
|
+
.returning({ id: notificationTemplates.id });
|
|
91
|
+
|
|
92
|
+
return result.length;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private toRow(r: typeof notificationTemplates.$inferSelect): NotificationTemplateRow {
|
|
96
|
+
return {
|
|
97
|
+
id: r.id,
|
|
98
|
+
name: r.name,
|
|
99
|
+
description: r.description,
|
|
100
|
+
subject: r.subject,
|
|
101
|
+
htmlBody: r.htmlBody,
|
|
102
|
+
textBody: r.textBody,
|
|
103
|
+
active: r.active,
|
|
104
|
+
createdAt: r.createdAt,
|
|
105
|
+
updatedAt: r.updatedAt,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HandlebarsRenderer — compiles DB-driven templates with Handlebars.
|
|
3
|
+
*
|
|
4
|
+
* Registers shared helpers (eq, gt, formatDate, escapeHtml) at module load
|
|
5
|
+
* so every compiled template can use them.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import Handlebars from "handlebars";
|
|
9
|
+
import type { INotificationTemplateRepository } from "./notification-template-repository.js";
|
|
10
|
+
import type { TemplateResult } from "./templates.js";
|
|
11
|
+
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
// Register global helpers
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
Handlebars.registerHelper("eq", (a: unknown, b: unknown) => a === b);
|
|
17
|
+
|
|
18
|
+
Handlebars.registerHelper("gt", (a: unknown, b: unknown) => Number(a) > Number(b));
|
|
19
|
+
|
|
20
|
+
Handlebars.registerHelper("formatDate", (timestamp: unknown) => {
|
|
21
|
+
const ms = Number(timestamp);
|
|
22
|
+
if (Number.isNaN(ms)) return String(timestamp);
|
|
23
|
+
return new Date(ms).toLocaleDateString("en-US", {
|
|
24
|
+
year: "numeric",
|
|
25
|
+
month: "long",
|
|
26
|
+
day: "numeric",
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
Handlebars.registerHelper("escapeHtml", (text: unknown) => {
|
|
31
|
+
const str = String(text ?? "");
|
|
32
|
+
const map: Record<string, string> = {
|
|
33
|
+
"&": "&",
|
|
34
|
+
"<": "<",
|
|
35
|
+
">": ">",
|
|
36
|
+
'"': """,
|
|
37
|
+
"'": "'",
|
|
38
|
+
};
|
|
39
|
+
return new Handlebars.SafeString(str.replace(/[&<>"']/g, (c) => map[c] ?? c));
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ---------------------------------------------------------------------------
|
|
43
|
+
// Renderer
|
|
44
|
+
// ---------------------------------------------------------------------------
|
|
45
|
+
|
|
46
|
+
export class HandlebarsRenderer {
|
|
47
|
+
constructor(private readonly templateRepo: INotificationTemplateRepository) {}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Render a named template with the given data.
|
|
51
|
+
* Returns null if the template does not exist or is inactive.
|
|
52
|
+
*/
|
|
53
|
+
async render(templateName: string, data: Record<string, unknown>): Promise<TemplateResult | null> {
|
|
54
|
+
const template = await this.templateRepo.getByName(templateName);
|
|
55
|
+
if (!template || !template.active) return null;
|
|
56
|
+
|
|
57
|
+
const ctx = { currentYear: new Date().getFullYear(), ...data };
|
|
58
|
+
const subject = Handlebars.compile(template.subject)(ctx);
|
|
59
|
+
const html = Handlebars.compile(template.htmlBody)(ctx);
|
|
60
|
+
const text = Handlebars.compile(template.textBody)(ctx);
|
|
61
|
+
|
|
62
|
+
return { subject, html, text };
|
|
63
|
+
}
|
|
64
|
+
}
|
package/src/email/index.ts
CHANGED
|
@@ -13,8 +13,11 @@ export type { BillingEmailServiceConfig, BillingEmailType } from "./billing-emai
|
|
|
13
13
|
export { BillingEmailService } from "./billing-emails.js";
|
|
14
14
|
export type { EmailClientConfig, EmailSendResult, SendTemplateEmailOpts } from "./client.js";
|
|
15
15
|
export { EmailClient, getEmailClient, resetEmailClient, setEmailClient } from "./client.js";
|
|
16
|
+
export { DEFAULT_TEMPLATES } from "./default-templates.js";
|
|
16
17
|
export type { IBillingEmailRepository } from "./drizzle-billing-email-repository.js";
|
|
17
18
|
export { DrizzleBillingEmailRepository } from "./drizzle-billing-email-repository.js";
|
|
19
|
+
export { DrizzleNotificationTemplateRepository } from "./drizzle-notification-template-repository.js";
|
|
20
|
+
export { HandlebarsRenderer } from "./handlebars-renderer.js";
|
|
18
21
|
export type { INotificationPreferencesRepository } from "./notification-preferences-store.js";
|
|
19
22
|
export { DrizzleNotificationPreferencesStore } from "./notification-preferences-store.js";
|
|
20
23
|
export type { INotificationQueueRepository } from "./notification-queue-store.js";
|
|
@@ -28,6 +31,7 @@ export type {
|
|
|
28
31
|
QueuedNotification,
|
|
29
32
|
} from "./notification-repository-types.js";
|
|
30
33
|
export { NotificationService } from "./notification-service.js";
|
|
34
|
+
export type { INotificationTemplateRepository, NotificationTemplateRow } from "./notification-template-repository.js";
|
|
31
35
|
export type { TemplateName as NotificationTemplateName } from "./notification-templates.js";
|
|
32
36
|
export { renderNotificationTemplate } from "./notification-templates.js";
|
|
33
37
|
export { NotificationWorker } from "./notification-worker.js";
|
|
@@ -23,6 +23,7 @@ const DEFAULTS: NotificationPrefs = {
|
|
|
23
23
|
agent_status_changes: false,
|
|
24
24
|
account_role_changes: true,
|
|
25
25
|
account_team_invites: true,
|
|
26
|
+
fleet_updates: true,
|
|
26
27
|
};
|
|
27
28
|
|
|
28
29
|
export class DrizzleNotificationPreferencesStore implements INotificationPreferencesRepository {
|
|
@@ -46,6 +47,7 @@ export class DrizzleNotificationPreferencesStore implements INotificationPrefere
|
|
|
46
47
|
agent_status_changes: row.agentStatusChanges,
|
|
47
48
|
account_role_changes: row.accountRoleChanges,
|
|
48
49
|
account_team_invites: row.accountTeamInvites,
|
|
50
|
+
fleet_updates: row.fleetUpdates,
|
|
49
51
|
};
|
|
50
52
|
}
|
|
51
53
|
|
|
@@ -60,6 +62,7 @@ export class DrizzleNotificationPreferencesStore implements INotificationPrefere
|
|
|
60
62
|
if (prefs.agent_status_changes !== undefined) values.agentStatusChanges = prefs.agent_status_changes;
|
|
61
63
|
if (prefs.account_role_changes !== undefined) values.accountRoleChanges = prefs.account_role_changes;
|
|
62
64
|
if (prefs.account_team_invites !== undefined) values.accountTeamInvites = prefs.account_team_invites;
|
|
65
|
+
if (prefs.fleet_updates !== undefined) values.fleetUpdates = prefs.fleet_updates;
|
|
63
66
|
|
|
64
67
|
// Get existing row to merge with defaults
|
|
65
68
|
const existing = await this.db
|
|
@@ -81,6 +84,7 @@ export class DrizzleNotificationPreferencesStore implements INotificationPrefere
|
|
|
81
84
|
agentStatusChanges: DEFAULTS.agent_status_changes,
|
|
82
85
|
accountRoleChanges: DEFAULTS.account_role_changes,
|
|
83
86
|
accountTeamInvites: DEFAULTS.account_team_invites,
|
|
87
|
+
fleetUpdates: DEFAULTS.fleet_updates,
|
|
84
88
|
updatedAt: Math.floor(Date.now() / 1000),
|
|
85
89
|
...values,
|
|
86
90
|
};
|
|
@@ -36,6 +36,7 @@ export interface NotificationPrefs {
|
|
|
36
36
|
agent_status_changes: boolean;
|
|
37
37
|
account_role_changes: boolean;
|
|
38
38
|
account_team_invites: boolean;
|
|
39
|
+
fleet_updates: boolean;
|
|
39
40
|
}
|
|
40
41
|
|
|
41
42
|
// ---------------------------------------------------------------------------
|
|
@@ -78,6 +79,46 @@ export interface NotificationRow {
|
|
|
78
79
|
sentAt: number | null;
|
|
79
80
|
}
|
|
80
81
|
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Notification Template Types
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/** Row shape returned by the template repository. */
|
|
87
|
+
export interface NotificationTemplateRow {
|
|
88
|
+
id: string;
|
|
89
|
+
name: string;
|
|
90
|
+
description: string | null;
|
|
91
|
+
subject: string;
|
|
92
|
+
htmlBody: string;
|
|
93
|
+
textBody: string;
|
|
94
|
+
active: boolean;
|
|
95
|
+
createdAt: number;
|
|
96
|
+
updatedAt: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Repository contract for notification templates. */
|
|
100
|
+
export interface INotificationTemplateRepository {
|
|
101
|
+
getByName(name: string): Promise<NotificationTemplateRow | null>;
|
|
102
|
+
list(): Promise<NotificationTemplateRow[]>;
|
|
103
|
+
upsert(
|
|
104
|
+
name: string,
|
|
105
|
+
template: Omit<NotificationTemplateRow, "id" | "name" | "createdAt" | "updatedAt">,
|
|
106
|
+
): Promise<void>;
|
|
107
|
+
/**
|
|
108
|
+
* Seed default templates — INSERT OR IGNORE so admin edits are not overwritten.
|
|
109
|
+
* @returns number of templates inserted (not already present).
|
|
110
|
+
*/
|
|
111
|
+
seed(
|
|
112
|
+
templates: Array<{
|
|
113
|
+
name: string;
|
|
114
|
+
description: string;
|
|
115
|
+
subject: string;
|
|
116
|
+
htmlBody: string;
|
|
117
|
+
textBody: string;
|
|
118
|
+
}>,
|
|
119
|
+
): Promise<number>;
|
|
120
|
+
}
|
|
121
|
+
|
|
81
122
|
// ---------------------------------------------------------------------------
|
|
82
123
|
// Repository Interfaces
|
|
83
124
|
// ---------------------------------------------------------------------------
|
|
@@ -255,6 +255,37 @@ export class NotificationService {
|
|
|
255
255
|
this.queue.enqueue(tenantId, "account-deletion-completed", { email });
|
|
256
256
|
}
|
|
257
257
|
|
|
258
|
+
// ---------------------------------------------------------------------------
|
|
259
|
+
// Fleet Updates
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
notifyFleetUpdateAvailable(
|
|
263
|
+
tenantId: string,
|
|
264
|
+
email: string,
|
|
265
|
+
version: string,
|
|
266
|
+
changelogDate: string,
|
|
267
|
+
changelogSummary: string,
|
|
268
|
+
): void {
|
|
269
|
+
this.queue.enqueue(tenantId, "fleet-update-available", {
|
|
270
|
+
email,
|
|
271
|
+
version,
|
|
272
|
+
changelogDate,
|
|
273
|
+
changelogSummary,
|
|
274
|
+
fleetUrl: `${this.appBaseUrl}/fleet`,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
notifyFleetUpdateComplete(tenantId: string, email: string, version: string, succeeded: number, failed: number): void {
|
|
279
|
+
this.queue.enqueue(tenantId, "fleet-update-complete", {
|
|
280
|
+
email,
|
|
281
|
+
version,
|
|
282
|
+
succeeded,
|
|
283
|
+
failed,
|
|
284
|
+
total: succeeded + failed,
|
|
285
|
+
fleetUrl: `${this.appBaseUrl}/fleet`,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
|
|
258
289
|
// ---------------------------------------------------------------------------
|
|
259
290
|
// Admin custom email
|
|
260
291
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* INotificationTemplateRepository — interface for DB-driven email templates.
|
|
3
|
+
*
|
|
4
|
+
* Types live in notification-repository-types.ts; this file re-exports them
|
|
5
|
+
* so existing imports continue to work.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type { INotificationTemplateRepository, NotificationTemplateRow } from "./notification-repository-types.js";
|
|
@@ -39,6 +39,9 @@ export type NotificationTemplateName =
|
|
|
39
39
|
| "dividend-weekly-digest"
|
|
40
40
|
| "affiliate-credit-match"
|
|
41
41
|
| "spend-alert"
|
|
42
|
+
// Fleet updates
|
|
43
|
+
| "fleet-update-available"
|
|
44
|
+
| "fleet-update-complete"
|
|
42
45
|
// Passthrough to existing templates
|
|
43
46
|
| "low-balance"
|
|
44
47
|
| "credit-purchase-receipt"
|
|
@@ -657,6 +660,64 @@ export function renderNotificationTemplate(template: TemplateName, data: Record<
|
|
|
657
660
|
return accountDeletionCancelledTemplate(data);
|
|
658
661
|
case "account-deletion-completed":
|
|
659
662
|
return accountDeletionCompletedTemplate(data);
|
|
663
|
+
case "fleet-update-available": {
|
|
664
|
+
const version = escapeHtml((data.version as string) || "");
|
|
665
|
+
const changelogDate = escapeHtml((data.changelogDate as string) || "");
|
|
666
|
+
const changelogSummary = escapeHtml((data.changelogSummary as string) || "");
|
|
667
|
+
const fleetUrl = (data.fleetUrl as string) || "";
|
|
668
|
+
const faParts = [
|
|
669
|
+
heading(`Fleet Update Available: ${version}`),
|
|
670
|
+
paragraph(
|
|
671
|
+
`<p>A new version <strong>${version}</strong> is available for your fleet.</p>` +
|
|
672
|
+
(changelogDate ? `<p style="color: #718096; font-size: 14px;">Released: ${changelogDate}</p>` : "") +
|
|
673
|
+
(changelogSummary
|
|
674
|
+
? `<div style="background: #f7fafc; border-left: 4px solid #2563eb; padding: 12px 16px; margin: 16px 0; color: #4a5568; font-size: 14px; line-height: 22px;">${changelogSummary}</div>`
|
|
675
|
+
: ""),
|
|
676
|
+
),
|
|
677
|
+
];
|
|
678
|
+
if (fleetUrl) faParts.push(button(fleetUrl, "View Fleet Dashboard"));
|
|
679
|
+
faParts.push(footer("Review the changelog and update when ready."));
|
|
680
|
+
return {
|
|
681
|
+
subject: `Fleet update available: ${data.version}`,
|
|
682
|
+
html: wrapHtml("Fleet Update Available", faParts.join("\n")),
|
|
683
|
+
text: `Fleet Update Available: ${data.version}\n\nA new version ${data.version} is available for your fleet.\n${changelogDate ? `Released: ${data.changelogDate}\n` : ""}${changelogSummary ? `Changelog: ${data.changelogSummary}\n` : ""}${fleetUrl ? `\nFleet dashboard: ${fleetUrl}\n` : ""}${copyright()}`,
|
|
684
|
+
};
|
|
685
|
+
}
|
|
686
|
+
case "fleet-update-complete": {
|
|
687
|
+
const fcVersion = escapeHtml((data.version as string) || "");
|
|
688
|
+
const succeeded = Number(data.succeeded) || 0;
|
|
689
|
+
const failed = Number(data.failed) || 0;
|
|
690
|
+
const total = succeeded + failed;
|
|
691
|
+
const fleetUrl2 = (data.fleetUrl as string) || "";
|
|
692
|
+
const statusText = failed === 0 ? "all instances healthy" : `${failed} instance(s) failed`;
|
|
693
|
+
const fcParts = [
|
|
694
|
+
heading(`Fleet Updated to ${fcVersion}`),
|
|
695
|
+
paragraph(
|
|
696
|
+
`<p>Your fleet update to <strong>${fcVersion}</strong> has completed.</p>` +
|
|
697
|
+
`<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">` +
|
|
698
|
+
`<tr><td style="padding: 8px 0; color: #4a5568;">Succeeded</td><td style="padding: 8px 0; text-align: right; font-weight: 600; color: #22c55e;">${succeeded}</td></tr>` +
|
|
699
|
+
`<tr><td style="padding: 8px 0; color: #4a5568;">Failed</td><td style="padding: 8px 0; text-align: right; font-weight: 600; color: ${failed > 0 ? "#dc2626" : "#22c55e"};">${failed}</td></tr>` +
|
|
700
|
+
`<tr style="border-top: 2px solid #e2e8f0;"><td style="padding: 12px 0; color: #4a5568; font-weight: 600;">Total</td><td style="padding: 12px 0; text-align: right; font-weight: 700; color: #1a1a1a;">${total}</td></tr>` +
|
|
701
|
+
`</table>` +
|
|
702
|
+
(failed > 0
|
|
703
|
+
? `<p style="color: #dc2626;">Some instances failed to update. Check the fleet dashboard for details.</p>`
|
|
704
|
+
: ""),
|
|
705
|
+
),
|
|
706
|
+
];
|
|
707
|
+
if (fleetUrl2) fcParts.push(button(fleetUrl2, "View Fleet Dashboard"));
|
|
708
|
+
fcParts.push(
|
|
709
|
+
footer(
|
|
710
|
+
failed === 0
|
|
711
|
+
? "All instances are running the latest version."
|
|
712
|
+
: "Review failed instances and retry if needed.",
|
|
713
|
+
),
|
|
714
|
+
);
|
|
715
|
+
return {
|
|
716
|
+
subject: `Fleet updated to ${data.version} \u2014 ${statusText}`,
|
|
717
|
+
html: wrapHtml("Fleet Update Complete", fcParts.join("\n")),
|
|
718
|
+
text: `Fleet Updated to ${data.version}\n\nSucceeded: ${succeeded}\nFailed: ${failed}\nTotal: ${total}\n${failed > 0 ? "\nSome instances failed to update.\n" : ""}${fleetUrl2 ? `\nFleet dashboard: ${fleetUrl2}\n` : ""}${copyright()}`,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
660
721
|
case "low-balance":
|
|
661
722
|
return {
|
|
662
723
|
subject: "Your WOPR credits are running low",
|
|
@@ -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";
|
package/src/trpc/index.ts
CHANGED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin tRPC router for managing DB-driven notification templates.
|
|
3
|
+
*
|
|
4
|
+
* All procedures require platform_admin role via adminProcedure.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { TRPCError } from "@trpc/server";
|
|
8
|
+
import Handlebars from "handlebars";
|
|
9
|
+
import { z } from "zod";
|
|
10
|
+
import { logger } from "../config/logger.js";
|
|
11
|
+
import { DEFAULT_TEMPLATES } from "../email/default-templates.js";
|
|
12
|
+
import type { INotificationTemplateRepository } from "../email/notification-template-repository.js";
|
|
13
|
+
import { adminProcedure, router } from "./init.js";
|
|
14
|
+
|
|
15
|
+
export function createNotificationTemplateRouter(getRepo: () => INotificationTemplateRepository) {
|
|
16
|
+
return router({
|
|
17
|
+
listTemplates: adminProcedure.query(async () => {
|
|
18
|
+
const repo = getRepo();
|
|
19
|
+
return repo.list();
|
|
20
|
+
}),
|
|
21
|
+
|
|
22
|
+
getTemplate: adminProcedure.input(z.object({ name: z.string() })).query(async ({ input }) => {
|
|
23
|
+
const repo = getRepo();
|
|
24
|
+
return repo.getByName(input.name);
|
|
25
|
+
}),
|
|
26
|
+
|
|
27
|
+
updateTemplate: adminProcedure
|
|
28
|
+
.input(
|
|
29
|
+
z.object({
|
|
30
|
+
name: z.string(),
|
|
31
|
+
subject: z.string().optional(),
|
|
32
|
+
htmlBody: z.string().optional(),
|
|
33
|
+
textBody: z.string().optional(),
|
|
34
|
+
description: z.string().optional(),
|
|
35
|
+
active: z.boolean().optional(),
|
|
36
|
+
}),
|
|
37
|
+
)
|
|
38
|
+
.mutation(async ({ input }) => {
|
|
39
|
+
const repo = getRepo();
|
|
40
|
+
const existing = await repo.getByName(input.name);
|
|
41
|
+
if (!existing) {
|
|
42
|
+
throw new TRPCError({ code: "NOT_FOUND", message: `Template "${input.name}" not found` });
|
|
43
|
+
}
|
|
44
|
+
await repo.upsert(input.name, {
|
|
45
|
+
subject: input.subject ?? existing.subject,
|
|
46
|
+
htmlBody: input.htmlBody ?? existing.htmlBody,
|
|
47
|
+
textBody: input.textBody ?? existing.textBody,
|
|
48
|
+
description: input.description ?? existing.description,
|
|
49
|
+
active: input.active ?? existing.active,
|
|
50
|
+
});
|
|
51
|
+
logger.info("Notification template updated", {
|
|
52
|
+
action: "notification_template.update",
|
|
53
|
+
templateName: input.name,
|
|
54
|
+
});
|
|
55
|
+
}),
|
|
56
|
+
|
|
57
|
+
previewTemplate: adminProcedure
|
|
58
|
+
.input(
|
|
59
|
+
z.object({
|
|
60
|
+
subject: z.string(),
|
|
61
|
+
htmlBody: z.string(),
|
|
62
|
+
textBody: z.string(),
|
|
63
|
+
data: z.record(z.string(), z.unknown()),
|
|
64
|
+
}),
|
|
65
|
+
)
|
|
66
|
+
.mutation(({ input }) => {
|
|
67
|
+
try {
|
|
68
|
+
const subject = Handlebars.compile(input.subject)(input.data);
|
|
69
|
+
const html = Handlebars.compile(input.htmlBody)(input.data);
|
|
70
|
+
const text = Handlebars.compile(input.textBody)(input.data);
|
|
71
|
+
return { subject, html, text };
|
|
72
|
+
} catch (err) {
|
|
73
|
+
throw new TRPCError({
|
|
74
|
+
code: "BAD_REQUEST",
|
|
75
|
+
message: `Template render error: ${err instanceof Error ? err.message : String(err)}`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}),
|
|
79
|
+
|
|
80
|
+
seedDefaults: adminProcedure.mutation(async () => {
|
|
81
|
+
const repo = getRepo();
|
|
82
|
+
const inserted = await repo.seed(DEFAULT_TEMPLATES);
|
|
83
|
+
logger.info("Notification template defaults seeded", {
|
|
84
|
+
action: "notification_template.seed",
|
|
85
|
+
inserted,
|
|
86
|
+
total: DEFAULT_TEMPLATES.length,
|
|
87
|
+
});
|
|
88
|
+
return { inserted, total: DEFAULT_TEMPLATES.length };
|
|
89
|
+
}),
|
|
90
|
+
});
|
|
91
|
+
}
|