@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.
Files changed (54) hide show
  1. package/dist/db/schema/index.d.ts +1 -0
  2. package/dist/db/schema/index.js +1 -0
  3. package/dist/db/schema/notification-preferences.d.ts +17 -0
  4. package/dist/db/schema/notification-preferences.js +1 -0
  5. package/dist/db/schema/notification-templates.d.ts +165 -0
  6. package/dist/db/schema/notification-templates.js +18 -0
  7. package/dist/email/default-templates.d.ts +15 -0
  8. package/dist/email/default-templates.js +443 -0
  9. package/dist/email/drizzle-notification-template-repository.d.ts +21 -0
  10. package/dist/email/drizzle-notification-template-repository.js +90 -0
  11. package/dist/email/handlebars-renderer.d.ts +17 -0
  12. package/dist/email/handlebars-renderer.js +56 -0
  13. package/dist/email/index.d.ts +4 -0
  14. package/dist/email/index.js +3 -0
  15. package/dist/email/notification-preferences-store.js +5 -0
  16. package/dist/email/notification-preferences-store.test.js +1 -0
  17. package/dist/email/notification-repository-types.d.ts +30 -0
  18. package/dist/email/notification-service.d.ts +2 -0
  19. package/dist/email/notification-service.js +22 -0
  20. package/dist/email/notification-template-repository.d.ts +7 -0
  21. package/dist/email/notification-template-repository.js +7 -0
  22. package/dist/email/notification-templates.d.ts +1 -1
  23. package/dist/email/notification-templates.js +52 -0
  24. package/dist/email/notification-worker.d.ts +3 -0
  25. package/dist/email/notification-worker.js +12 -2
  26. package/dist/fleet/fleet-notification-listener.d.ts +13 -0
  27. package/dist/fleet/fleet-notification-listener.js +65 -0
  28. package/dist/fleet/index.d.ts +2 -0
  29. package/dist/fleet/index.js +1 -0
  30. package/dist/trpc/index.d.ts +1 -0
  31. package/dist/trpc/index.js +1 -0
  32. package/dist/trpc/notification-template-router.d.ts +59 -0
  33. package/dist/trpc/notification-template-router.js +81 -0
  34. package/drizzle/migrations/0011_notification_templates.sql +14 -0
  35. package/drizzle/migrations/meta/_journal.json +7 -0
  36. package/package.json +2 -1
  37. package/src/db/schema/index.ts +1 -0
  38. package/src/db/schema/notification-preferences.ts +1 -0
  39. package/src/db/schema/notification-templates.ts +19 -0
  40. package/src/email/default-templates.ts +680 -0
  41. package/src/email/drizzle-notification-template-repository.ts +108 -0
  42. package/src/email/handlebars-renderer.ts +64 -0
  43. package/src/email/index.ts +4 -0
  44. package/src/email/notification-preferences-store.test.ts +1 -0
  45. package/src/email/notification-preferences-store.ts +4 -0
  46. package/src/email/notification-repository-types.ts +41 -0
  47. package/src/email/notification-service.ts +31 -0
  48. package/src/email/notification-template-repository.ts +8 -0
  49. package/src/email/notification-templates.ts +61 -0
  50. package/src/email/notification-worker.ts +15 -2
  51. package/src/fleet/fleet-notification-listener.ts +87 -0
  52. package/src/fleet/index.ts +2 -0
  53. package/src/trpc/index.ts +1 -0
  54. package/src/trpc/notification-template-router.ts +91 -0
@@ -10,7 +10,10 @@
10
10
  */
11
11
  export { BillingEmailService } from "./billing-emails.js";
12
12
  export { EmailClient, getEmailClient, resetEmailClient, setEmailClient } from "./client.js";
13
+ export { DEFAULT_TEMPLATES } from "./default-templates.js";
13
14
  export { DrizzleBillingEmailRepository } from "./drizzle-billing-email-repository.js";
15
+ export { DrizzleNotificationTemplateRepository } from "./drizzle-notification-template-repository.js";
16
+ export { HandlebarsRenderer } from "./handlebars-renderer.js";
14
17
  export { DrizzleNotificationPreferencesStore } from "./notification-preferences-store.js";
15
18
  export { DrizzleNotificationQueueStore } from "./notification-queue-store.js";
16
19
  export { NotificationService } from "./notification-service.js";
@@ -11,6 +11,7 @@ const DEFAULTS = {
11
11
  agent_status_changes: false,
12
12
  account_role_changes: true,
13
13
  account_team_invites: true,
14
+ fleet_updates: true,
14
15
  };
15
16
  export class DrizzleNotificationPreferencesStore {
16
17
  db;
@@ -34,6 +35,7 @@ export class DrizzleNotificationPreferencesStore {
34
35
  agent_status_changes: row.agentStatusChanges,
35
36
  account_role_changes: row.accountRoleChanges,
36
37
  account_team_invites: row.accountTeamInvites,
38
+ fleet_updates: row.fleetUpdates,
37
39
  };
38
40
  }
39
41
  /** Update preferences for a tenant. Upserts — creates row if missing. */
@@ -53,6 +55,8 @@ export class DrizzleNotificationPreferencesStore {
53
55
  values.accountRoleChanges = prefs.account_role_changes;
54
56
  if (prefs.account_team_invites !== undefined)
55
57
  values.accountTeamInvites = prefs.account_team_invites;
58
+ if (prefs.fleet_updates !== undefined)
59
+ values.fleetUpdates = prefs.fleet_updates;
56
60
  // Get existing row to merge with defaults
57
61
  const existing = await this.db
58
62
  .select()
@@ -73,6 +77,7 @@ export class DrizzleNotificationPreferencesStore {
73
77
  agentStatusChanges: DEFAULTS.agent_status_changes,
74
78
  accountRoleChanges: DEFAULTS.account_role_changes,
75
79
  accountTeamInvites: DEFAULTS.account_team_invites,
80
+ fleetUpdates: DEFAULTS.fleet_updates,
76
81
  updatedAt: Math.floor(Date.now() / 1000),
77
82
  ...values,
78
83
  };
@@ -28,6 +28,7 @@ describe("DrizzleNotificationPreferencesStore", () => {
28
28
  agent_status_changes: false,
29
29
  account_role_changes: true,
30
30
  account_team_invites: true,
31
+ fleet_updates: true,
31
32
  });
32
33
  });
33
34
  it("returns stored values when row exists", async () => {
@@ -20,6 +20,7 @@ export interface NotificationPrefs {
20
20
  agent_status_changes: boolean;
21
21
  account_role_changes: boolean;
22
22
  account_team_invites: boolean;
23
+ fleet_updates: boolean;
23
24
  }
24
25
  export type NotificationEmailType = "low_balance" | "grace_entered" | "suspended" | "receipt" | "welcome" | "reactivated";
25
26
  export interface NotificationInput {
@@ -48,6 +49,35 @@ export interface NotificationRow {
48
49
  /** Unix epoch ms */
49
50
  sentAt: number | null;
50
51
  }
52
+ /** Row shape returned by the template repository. */
53
+ export interface NotificationTemplateRow {
54
+ id: string;
55
+ name: string;
56
+ description: string | null;
57
+ subject: string;
58
+ htmlBody: string;
59
+ textBody: string;
60
+ active: boolean;
61
+ createdAt: number;
62
+ updatedAt: number;
63
+ }
64
+ /** Repository contract for notification templates. */
65
+ export interface INotificationTemplateRepository {
66
+ getByName(name: string): Promise<NotificationTemplateRow | null>;
67
+ list(): Promise<NotificationTemplateRow[]>;
68
+ upsert(name: string, template: Omit<NotificationTemplateRow, "id" | "name" | "createdAt" | "updatedAt">): Promise<void>;
69
+ /**
70
+ * Seed default templates — INSERT OR IGNORE so admin edits are not overwritten.
71
+ * @returns number of templates inserted (not already present).
72
+ */
73
+ seed(templates: Array<{
74
+ name: string;
75
+ description: string;
76
+ subject: string;
77
+ htmlBody: string;
78
+ textBody: string;
79
+ }>): Promise<number>;
80
+ }
51
81
  /** Repository interface for notification preferences. */
52
82
  export interface INotificationPreferencesRepository {
53
83
  get(tenantId: string): Promise<NotificationPrefs>;
@@ -37,5 +37,7 @@ export declare class NotificationService {
37
37
  notifyAccountDeletionRequested(tenantId: string, email: string, deleteAfterDate: string): void;
38
38
  notifyAccountDeletionCancelled(tenantId: string, email: string): void;
39
39
  notifyAccountDeletionCompleted(tenantId: string, email: string): void;
40
+ notifyFleetUpdateAvailable(tenantId: string, email: string, version: string, changelogDate: string, changelogSummary: string): void;
41
+ notifyFleetUpdateComplete(tenantId: string, email: string, version: string, succeeded: number, failed: number): void;
40
42
  sendCustomEmail(tenantId: string, email: string, subject: string, bodyText: string): void;
41
43
  }
@@ -188,6 +188,28 @@ export class NotificationService {
188
188
  this.queue.enqueue(tenantId, "account-deletion-completed", { email });
189
189
  }
190
190
  // ---------------------------------------------------------------------------
191
+ // Fleet Updates
192
+ // ---------------------------------------------------------------------------
193
+ notifyFleetUpdateAvailable(tenantId, email, version, changelogDate, changelogSummary) {
194
+ this.queue.enqueue(tenantId, "fleet-update-available", {
195
+ email,
196
+ version,
197
+ changelogDate,
198
+ changelogSummary,
199
+ fleetUrl: `${this.appBaseUrl}/fleet`,
200
+ });
201
+ }
202
+ notifyFleetUpdateComplete(tenantId, email, version, succeeded, failed) {
203
+ this.queue.enqueue(tenantId, "fleet-update-complete", {
204
+ email,
205
+ version,
206
+ succeeded,
207
+ failed,
208
+ total: succeeded + failed,
209
+ fleetUrl: `${this.appBaseUrl}/fleet`,
210
+ });
211
+ }
212
+ // ---------------------------------------------------------------------------
191
213
  // Admin custom email
192
214
  // ---------------------------------------------------------------------------
193
215
  sendCustomEmail(tenantId, email, subject, bodyText) {
@@ -0,0 +1,7 @@
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
+ export type { INotificationTemplateRepository, NotificationTemplateRow } from "./notification-repository-types.js";
@@ -0,0 +1,7 @@
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
+ export {};
@@ -7,7 +7,7 @@
7
7
  * Export: renderNotificationTemplate(template, data) → TemplateResult
8
8
  */
9
9
  import type { TemplateResult } from "./templates.js";
10
- export type NotificationTemplateName = "credits-depleted" | "grace-period-start" | "grace-period-warning" | "auto-suspended" | "auto-topup-success" | "auto-topup-failed" | "crypto-payment-confirmed" | "admin-suspended" | "admin-reactivated" | "credits-granted" | "role-changed" | "team-invite" | "agent-created" | "channel-connected" | "channel-disconnected" | "agent-suspended" | "custom" | "account-deletion-requested" | "account-deletion-cancelled" | "account-deletion-completed" | "dividend-weekly-digest" | "affiliate-credit-match" | "spend-alert" | "low-balance" | "credit-purchase-receipt" | "welcome" | "password-reset";
10
+ export type NotificationTemplateName = "credits-depleted" | "grace-period-start" | "grace-period-warning" | "auto-suspended" | "auto-topup-success" | "auto-topup-failed" | "crypto-payment-confirmed" | "admin-suspended" | "admin-reactivated" | "credits-granted" | "role-changed" | "team-invite" | "agent-created" | "channel-connected" | "channel-disconnected" | "agent-suspended" | "custom" | "account-deletion-requested" | "account-deletion-cancelled" | "account-deletion-completed" | "dividend-weekly-digest" | "affiliate-credit-match" | "spend-alert" | "fleet-update-available" | "fleet-update-complete" | "low-balance" | "credit-purchase-receipt" | "welcome" | "password-reset";
11
11
  export type TemplateName = NotificationTemplateName;
12
12
  /**
13
13
  * Render a notification template by name.
@@ -520,6 +520,58 @@ export function renderNotificationTemplate(template, data) {
520
520
  return accountDeletionCancelledTemplate(data);
521
521
  case "account-deletion-completed":
522
522
  return accountDeletionCompletedTemplate(data);
523
+ case "fleet-update-available": {
524
+ const version = escapeHtml(data.version || "");
525
+ const changelogDate = escapeHtml(data.changelogDate || "");
526
+ const changelogSummary = escapeHtml(data.changelogSummary || "");
527
+ const fleetUrl = data.fleetUrl || "";
528
+ const faParts = [
529
+ heading(`Fleet Update Available: ${version}`),
530
+ paragraph(`<p>A new version <strong>${version}</strong> is available for your fleet.</p>` +
531
+ (changelogDate ? `<p style="color: #718096; font-size: 14px;">Released: ${changelogDate}</p>` : "") +
532
+ (changelogSummary
533
+ ? `<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>`
534
+ : "")),
535
+ ];
536
+ if (fleetUrl)
537
+ faParts.push(button(fleetUrl, "View Fleet Dashboard"));
538
+ faParts.push(footer("Review the changelog and update when ready."));
539
+ return {
540
+ subject: `Fleet update available: ${data.version}`,
541
+ html: wrapHtml("Fleet Update Available", faParts.join("\n")),
542
+ 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()}`,
543
+ };
544
+ }
545
+ case "fleet-update-complete": {
546
+ const fcVersion = escapeHtml(data.version || "");
547
+ const succeeded = Number(data.succeeded) || 0;
548
+ const failed = Number(data.failed) || 0;
549
+ const total = succeeded + failed;
550
+ const fleetUrl2 = data.fleetUrl || "";
551
+ const statusText = failed === 0 ? "all instances healthy" : `${failed} instance(s) failed`;
552
+ const fcParts = [
553
+ heading(`Fleet Updated to ${fcVersion}`),
554
+ paragraph(`<p>Your fleet update to <strong>${fcVersion}</strong> has completed.</p>` +
555
+ `<table style="width: 100%; border-collapse: collapse; margin: 16px 0;">` +
556
+ `<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>` +
557
+ `<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>` +
558
+ `<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>` +
559
+ `</table>` +
560
+ (failed > 0
561
+ ? `<p style="color: #dc2626;">Some instances failed to update. Check the fleet dashboard for details.</p>`
562
+ : "")),
563
+ ];
564
+ if (fleetUrl2)
565
+ fcParts.push(button(fleetUrl2, "View Fleet Dashboard"));
566
+ fcParts.push(footer(failed === 0
567
+ ? "All instances are running the latest version."
568
+ : "Review failed instances and retry if needed."));
569
+ return {
570
+ subject: `Fleet updated to ${data.version} \u2014 ${statusText}`,
571
+ html: wrapHtml("Fleet Update Complete", fcParts.join("\n")),
572
+ 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()}`,
573
+ };
574
+ }
523
575
  case "low-balance":
524
576
  return {
525
577
  subject: "Your WOPR credits are running low",
@@ -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
- // Render the template
78
- const rendered = renderNotificationTemplate(notif.template, data);
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
+ }
@@ -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";
@@ -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";
@@ -1,2 +1,3 @@
1
1
  export { createFleetUpdateConfigRouter } from "./fleet-update-config-router.js";
2
2
  export { adminProcedure, createCallerFactory, orgAdminProcedure, orgMemberProcedure, protectedProcedure, publicProcedure, router, setTrpcOrgMemberRepo, type TRPCContext, tenantProcedure, } from "./init.js";
3
+ export { createNotificationTemplateRouter } from "./notification-template-router.js";
@@ -1,2 +1,3 @@
1
1
  export { createFleetUpdateConfigRouter } from "./fleet-update-config-router.js";
2
2
  export { adminProcedure, createCallerFactory, orgAdminProcedure, orgMemberProcedure, protectedProcedure, publicProcedure, router, setTrpcOrgMemberRepo, tenantProcedure, } from "./init.js";
3
+ export { createNotificationTemplateRouter } from "./notification-template-router.js";
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Admin tRPC router for managing DB-driven notification templates.
3
+ *
4
+ * All procedures require platform_admin role via adminProcedure.
5
+ */
6
+ import type { INotificationTemplateRepository } from "../email/notification-template-repository.js";
7
+ export declare function createNotificationTemplateRouter(getRepo: () => INotificationTemplateRepository): import("@trpc/server").TRPCBuiltRouter<{
8
+ ctx: import("./init.js").TRPCContext;
9
+ meta: object;
10
+ errorShape: import("@trpc/server").TRPCDefaultErrorShape;
11
+ transformer: false;
12
+ }, import("@trpc/server").TRPCDecorateCreateRouterOptions<{
13
+ listTemplates: import("@trpc/server").TRPCQueryProcedure<{
14
+ input: void;
15
+ output: import("../email/notification-repository-types.js").NotificationTemplateRow[];
16
+ meta: object;
17
+ }>;
18
+ getTemplate: import("@trpc/server").TRPCQueryProcedure<{
19
+ input: {
20
+ name: string;
21
+ };
22
+ output: import("../email/notification-repository-types.js").NotificationTemplateRow | null;
23
+ meta: object;
24
+ }>;
25
+ updateTemplate: import("@trpc/server").TRPCMutationProcedure<{
26
+ input: {
27
+ name: string;
28
+ subject?: string | undefined;
29
+ htmlBody?: string | undefined;
30
+ textBody?: string | undefined;
31
+ description?: string | undefined;
32
+ active?: boolean | undefined;
33
+ };
34
+ output: void;
35
+ meta: object;
36
+ }>;
37
+ previewTemplate: import("@trpc/server").TRPCMutationProcedure<{
38
+ input: {
39
+ subject: string;
40
+ htmlBody: string;
41
+ textBody: string;
42
+ data: Record<string, unknown>;
43
+ };
44
+ output: {
45
+ subject: string;
46
+ html: string;
47
+ text: string;
48
+ };
49
+ meta: object;
50
+ }>;
51
+ seedDefaults: import("@trpc/server").TRPCMutationProcedure<{
52
+ input: void;
53
+ output: {
54
+ inserted: number;
55
+ total: number;
56
+ };
57
+ meta: object;
58
+ }>;
59
+ }>>;
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Admin tRPC router for managing DB-driven notification templates.
3
+ *
4
+ * All procedures require platform_admin role via adminProcedure.
5
+ */
6
+ import { TRPCError } from "@trpc/server";
7
+ import Handlebars from "handlebars";
8
+ import { z } from "zod";
9
+ import { logger } from "../config/logger.js";
10
+ import { DEFAULT_TEMPLATES } from "../email/default-templates.js";
11
+ import { adminProcedure, router } from "./init.js";
12
+ export function createNotificationTemplateRouter(getRepo) {
13
+ return router({
14
+ listTemplates: adminProcedure.query(async () => {
15
+ const repo = getRepo();
16
+ return repo.list();
17
+ }),
18
+ getTemplate: adminProcedure.input(z.object({ name: z.string() })).query(async ({ input }) => {
19
+ const repo = getRepo();
20
+ return repo.getByName(input.name);
21
+ }),
22
+ updateTemplate: adminProcedure
23
+ .input(z.object({
24
+ name: z.string(),
25
+ subject: z.string().optional(),
26
+ htmlBody: z.string().optional(),
27
+ textBody: z.string().optional(),
28
+ description: z.string().optional(),
29
+ active: z.boolean().optional(),
30
+ }))
31
+ .mutation(async ({ input }) => {
32
+ const repo = getRepo();
33
+ const existing = await repo.getByName(input.name);
34
+ if (!existing) {
35
+ throw new TRPCError({ code: "NOT_FOUND", message: `Template "${input.name}" not found` });
36
+ }
37
+ await repo.upsert(input.name, {
38
+ subject: input.subject ?? existing.subject,
39
+ htmlBody: input.htmlBody ?? existing.htmlBody,
40
+ textBody: input.textBody ?? existing.textBody,
41
+ description: input.description ?? existing.description,
42
+ active: input.active ?? existing.active,
43
+ });
44
+ logger.info("Notification template updated", {
45
+ action: "notification_template.update",
46
+ templateName: input.name,
47
+ });
48
+ }),
49
+ previewTemplate: adminProcedure
50
+ .input(z.object({
51
+ subject: z.string(),
52
+ htmlBody: z.string(),
53
+ textBody: z.string(),
54
+ data: z.record(z.string(), z.unknown()),
55
+ }))
56
+ .mutation(({ input }) => {
57
+ try {
58
+ const subject = Handlebars.compile(input.subject)(input.data);
59
+ const html = Handlebars.compile(input.htmlBody)(input.data);
60
+ const text = Handlebars.compile(input.textBody)(input.data);
61
+ return { subject, html, text };
62
+ }
63
+ catch (err) {
64
+ throw new TRPCError({
65
+ code: "BAD_REQUEST",
66
+ message: `Template render error: ${err instanceof Error ? err.message : String(err)}`,
67
+ });
68
+ }
69
+ }),
70
+ seedDefaults: adminProcedure.mutation(async () => {
71
+ const repo = getRepo();
72
+ const inserted = await repo.seed(DEFAULT_TEMPLATES);
73
+ logger.info("Notification template defaults seeded", {
74
+ action: "notification_template.seed",
75
+ inserted,
76
+ total: DEFAULT_TEMPLATES.length,
77
+ });
78
+ return { inserted, total: DEFAULT_TEMPLATES.length };
79
+ }),
80
+ });
81
+ }
@@ -0,0 +1,14 @@
1
+ CREATE TABLE IF NOT EXISTS "notification_templates" (
2
+ "id" text PRIMARY KEY NOT NULL,
3
+ "name" text NOT NULL,
4
+ "description" text,
5
+ "subject" text NOT NULL,
6
+ "html_body" text NOT NULL,
7
+ "text_body" text NOT NULL,
8
+ "active" boolean DEFAULT true NOT NULL,
9
+ "created_at" bigint DEFAULT (extract(epoch from now()))::bigint NOT NULL,
10
+ "updated_at" bigint DEFAULT (extract(epoch from now()))::bigint NOT NULL,
11
+ CONSTRAINT "notification_templates_name_unique" UNIQUE("name")
12
+ );
13
+ --> statement-breakpoint
14
+ ALTER TABLE "notification_preferences" ADD COLUMN "fleet_updates" boolean DEFAULT true NOT NULL;
@@ -78,6 +78,13 @@
78
78
  "when": 1742486400000,
79
79
  "tag": "0010_oracle_address",
80
80
  "breakpoints": true
81
+ },
82
+ {
83
+ "idx": 11,
84
+ "version": "7",
85
+ "when": 1742572800000,
86
+ "tag": "0011_notification_templates",
87
+ "breakpoints": true
81
88
  }
82
89
  ]
83
90
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/platform-core",
3
- "version": "1.28.0",
3
+ "version": "1.30.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -132,6 +132,7 @@
132
132
  "@scure/base": "^2.0.0",
133
133
  "@scure/bip32": "^2.0.1",
134
134
  "@scure/bip39": "^2.0.1",
135
+ "handlebars": "^4.7.8",
135
136
  "js-yaml": "^4.1.1",
136
137
  "viem": "^2.47.4",
137
138
  "yaml": "^2.8.2"
@@ -34,6 +34,7 @@ export * from "./node-transitions.js";
34
34
  export * from "./nodes.js";
35
35
  export * from "./notification-preferences.js";
36
36
  export * from "./notification-queue.js";
37
+ export * from "./notification-templates.js";
37
38
  export * from "./oauth-states.js";
38
39
  export * from "./onboarding-scripts.js";
39
40
  export * from "./onboarding-sessions.js";
@@ -15,5 +15,6 @@ export const notificationPreferences = pgTable("notification_preferences", {
15
15
  agentStatusChanges: boolean("agent_status_changes").notNull().default(false),
16
16
  accountRoleChanges: boolean("account_role_changes").notNull().default(true),
17
17
  accountTeamInvites: boolean("account_team_invites").notNull().default(true),
18
+ fleetUpdates: boolean("fleet_updates").notNull().default(true),
18
19
  updatedAt: bigint("updated_at", { mode: "number" }).notNull().default(sql`(extract(epoch from now()))::bigint`),
19
20
  });
@@ -0,0 +1,19 @@
1
+ import { sql } from "drizzle-orm";
2
+ import { bigint, boolean, pgTable, text } from "drizzle-orm/pg-core";
3
+
4
+ /**
5
+ * DB-driven email templates with Handlebars syntax.
6
+ * Each row stores a named template (subject + HTML + text bodies).
7
+ * Admins can edit these at runtime without code deploys.
8
+ */
9
+ export const notificationTemplates = pgTable("notification_templates", {
10
+ id: text("id").primaryKey(),
11
+ name: text("name").notNull().unique(),
12
+ description: text("description"),
13
+ subject: text("subject").notNull(),
14
+ htmlBody: text("html_body").notNull(),
15
+ textBody: text("text_body").notNull(),
16
+ active: boolean("active").notNull().default(true),
17
+ createdAt: bigint("created_at", { mode: "number" }).notNull().default(sql`(extract(epoch from now()))::bigint`),
18
+ updatedAt: bigint("updated_at", { mode: "number" }).notNull().default(sql`(extract(epoch from now()))::bigint`),
19
+ });