@porulle/plugin-notifications 0.1.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 (38) hide show
  1. package/README.md +61 -0
  2. package/dist/adapters/console.d.ts +17 -0
  3. package/dist/adapters/console.d.ts.map +1 -0
  4. package/dist/adapters/console.js +44 -0
  5. package/dist/adapters/types.d.ts +62 -0
  6. package/dist/adapters/types.d.ts.map +1 -0
  7. package/dist/adapters/types.js +1 -0
  8. package/dist/index.d.ts +24 -0
  9. package/dist/index.d.ts.map +1 -0
  10. package/dist/index.js +46 -0
  11. package/dist/routes/notifications.d.ts +11 -0
  12. package/dist/routes/notifications.d.ts.map +1 -0
  13. package/dist/routes/notifications.js +170 -0
  14. package/dist/schema.d.ts +624 -0
  15. package/dist/schema.d.ts.map +1 -0
  16. package/dist/schema.js +59 -0
  17. package/dist/services/notification-service.d.ts +52 -0
  18. package/dist/services/notification-service.d.ts.map +1 -0
  19. package/dist/services/notification-service.js +220 -0
  20. package/dist/services/preference-service.d.ts +23 -0
  21. package/dist/services/preference-service.d.ts.map +1 -0
  22. package/dist/services/preference-service.js +69 -0
  23. package/dist/services/print-service.d.ts +32 -0
  24. package/dist/services/print-service.d.ts.map +1 -0
  25. package/dist/services/print-service.js +91 -0
  26. package/dist/types.d.ts +15 -0
  27. package/dist/types.d.ts.map +1 -0
  28. package/dist/types.js +2 -0
  29. package/package.json +55 -0
  30. package/src/adapters/console.ts +52 -0
  31. package/src/adapters/types.ts +56 -0
  32. package/src/index.ts +52 -0
  33. package/src/routes/notifications.ts +199 -0
  34. package/src/schema.ts +67 -0
  35. package/src/services/notification-service.ts +270 -0
  36. package/src/services/preference-service.ts +92 -0
  37. package/src/services/print-service.ts +99 -0
  38. package/src/types.ts +16 -0
@@ -0,0 +1,56 @@
1
+ import type { Result } from "../types.js";
2
+
3
+ /**
4
+ * SMS Adapter interface.
5
+ *
6
+ * Implement this interface to integrate with an SMS provider (e.g. Twilio, Vonage).
7
+ * The `send` method delivers a text message to the given phone number.
8
+ */
9
+ export interface SMSAdapter {
10
+ /** Unique provider identifier (e.g. "twilio", "vonage", "console"). */
11
+ providerId: string;
12
+ /** Send an SMS message to the given phone number. */
13
+ send(params: { to: string; body: string }): Promise<Result<{ messageId: string }>>;
14
+ }
15
+
16
+ /**
17
+ * Push Notification Adapter interface.
18
+ *
19
+ * Implement this interface to integrate with a push notification service
20
+ * (e.g. Firebase Cloud Messaging, Apple Push Notification Service).
21
+ */
22
+ export interface PushAdapter {
23
+ /** Unique provider identifier (e.g. "fcm", "apns", "console"). */
24
+ providerId: string;
25
+ /** Send a push notification to the given device token. */
26
+ send(params: {
27
+ deviceToken: string;
28
+ title: string;
29
+ body: string;
30
+ data?: Record<string, unknown>;
31
+ }): Promise<Result<{ messageId: string }>>;
32
+ }
33
+
34
+ /**
35
+ * Print Adapter interface.
36
+ *
37
+ * Implement this interface to integrate with a receipt/label printer
38
+ * (e.g. Epson ESC/POS, Star Line Mode printers).
39
+ */
40
+ export interface PrintAdapter {
41
+ /** Unique provider identifier (e.g. "esc_pos", "star", "console"). */
42
+ providerId: string;
43
+ /** Send a print job to the given printer. */
44
+ print(params: {
45
+ printerId: string;
46
+ content: Record<string, unknown>;
47
+ format: "esc_pos" | "star_line" | "label";
48
+ }): Promise<Result<{ jobId: string }>>;
49
+ }
50
+
51
+ /** Configuration object for notification adapters. */
52
+ export interface NotificationAdapters {
53
+ sms?: SMSAdapter;
54
+ push?: PushAdapter;
55
+ print?: PrintAdapter;
56
+ }
package/src/index.ts ADDED
@@ -0,0 +1,52 @@
1
+ import { defineCommercePlugin } from "@porulle/core";
2
+ import { notificationTemplates, customerNotificationPrefs, notificationLog, printJobs } from "./schema.js";
3
+ import { NotificationService } from "./services/notification-service.js";
4
+ import { PreferenceService } from "./services/preference-service.js";
5
+ import { PrintService } from "./services/print-service.js";
6
+ import { buildNotificationRoutes } from "./routes/notifications.js";
7
+ import type { NotificationAdapters } from "./adapters/types.js";
8
+
9
+ // Re-exports for consumers
10
+ export type { Db } from "./types.js";
11
+ export type { NotificationTemplate, CustomerNotificationPref, NotificationLogEntry, PrintJob, Result, ResultErr, Channel, PrefChannel, NotificationStatus, PrintJobStatus, PrintJobType } from "./types.js";
12
+ export type { SMSAdapter, PushAdapter, PrintAdapter, NotificationAdapters } from "./adapters/types.js";
13
+ export { NotificationService } from "./services/notification-service.js";
14
+ export { PreferenceService } from "./services/preference-service.js";
15
+ export { PrintService } from "./services/print-service.js";
16
+ export { consoleSMSAdapter, consolePushAdapter, consolePrintAdapter } from "./adapters/console.js";
17
+
18
+ /**
19
+ * Notifications Plugin (RFC-030)
20
+ *
21
+ * Provides:
22
+ * - Notification template CRUD with Handlebars-style rendering
23
+ * - Multi-channel dispatch (email, SMS, push, print)
24
+ * - Customer notification preferences (opt-out model)
25
+ * - Notification log with status tracking
26
+ * - Print job management (receipt, label, sticker, KOT)
27
+ * - Pluggable adapter architecture for SMS, Push, and Print providers
28
+ *
29
+ * @param adapters - Optional adapter configuration. Pass console adapters for dev,
30
+ * or real adapters (Twilio, FCM, ESC/POS) for production.
31
+ */
32
+ export function notificationsPlugin(adapters?: NotificationAdapters) {
33
+ return defineCommercePlugin({
34
+ id: "notifications",
35
+ version: "1.0.0",
36
+ permissions: [
37
+ { scope: "notifications:admin", description: "Manage notification templates, send notifications, view log, manage print jobs." },
38
+ { scope: "notifications:write", description: "Set customer notification preferences." },
39
+ { scope: "notifications:read", description: "View customer notification preferences." },
40
+ ],
41
+ schema: () => ({ notificationTemplates, customerNotificationPrefs, notificationLog, printJobs }),
42
+ hooks: () => [],
43
+ routes: (ctx) => {
44
+ const db = ctx.database.db;
45
+ if (!db) return [];
46
+ const notifService = new NotificationService(db, adapters);
47
+ const prefService = new PreferenceService(db);
48
+ const printService = new PrintService(db, adapters?.print);
49
+ return buildNotificationRoutes(notifService, prefService, printService, ctx);
50
+ },
51
+ });
52
+ }
@@ -0,0 +1,199 @@
1
+ import { router } from "@porulle/core";
2
+ import { z } from "@hono/zod-openapi";
3
+ import type { NotificationService } from "../services/notification-service.js";
4
+ import type { PreferenceService } from "../services/preference-service.js";
5
+ import type { PrintService } from "../services/print-service.js";
6
+ import type { PluginRouteRegistration } from "@porulle/core";
7
+
8
+ export function buildNotificationRoutes(
9
+ notifService: NotificationService,
10
+ prefService: PreferenceService,
11
+ printService: PrintService,
12
+ ctx: { services?: Record<string, unknown>; database?: { db: unknown } },
13
+ ): PluginRouteRegistration[] {
14
+ // ── Template Routes ────────────────────────────────────────────────
15
+ const tmpl = router("Notification Templates", "/notifications/templates", ctx);
16
+
17
+ tmpl.post("/").summary("Create notification template").permission("notifications:admin")
18
+ .input(z.object({
19
+ event: z.string().min(1),
20
+ channel: z.enum(["email", "sms", "push", "print"]),
21
+ subject: z.string().optional(),
22
+ bodyTemplate: z.string().min(1),
23
+ }))
24
+ .handler(async ({ input, orgId }) => {
25
+ const body = input as { event: string; channel: "email" | "sms" | "push" | "print"; subject?: string; bodyTemplate: string };
26
+ const result = await notifService.createTemplate(orgId, body);
27
+ if (!result.ok) throw new Error(result.error);
28
+ return result.value;
29
+ });
30
+
31
+ tmpl.get("/").summary("List notification templates").permission("notifications:admin")
32
+ .query(z.object({
33
+ event: z.string().optional(),
34
+ channel: z.enum(["email", "sms", "push", "print"]).optional(),
35
+ }))
36
+ .handler(async ({ query, orgId }) => {
37
+ const q = query as { event?: string; channel?: "email" | "sms" | "push" | "print" };
38
+ const result = await notifService.listTemplates(orgId, q);
39
+ if (!result.ok) throw new Error(result.error);
40
+ return result.value;
41
+ });
42
+
43
+ tmpl.get("/{id}").summary("Get notification template").permission("notifications:admin")
44
+ .handler(async ({ params, orgId }) => {
45
+ const result = await notifService.getTemplate(orgId, params.id!);
46
+ if (!result.ok) throw new Error(result.error);
47
+ return result.value;
48
+ });
49
+
50
+ tmpl.patch("/{id}").summary("Update notification template").permission("notifications:admin")
51
+ .input(z.object({
52
+ subject: z.string().optional(),
53
+ bodyTemplate: z.string().optional(),
54
+ isActive: z.boolean().optional(),
55
+ }))
56
+ .handler(async ({ params, input, orgId }) => {
57
+ const body = input as { subject?: string; bodyTemplate?: string; isActive?: boolean };
58
+ const result = await notifService.updateTemplate(orgId, params.id!, body);
59
+ if (!result.ok) throw new Error(result.error);
60
+ return result.value;
61
+ });
62
+
63
+ tmpl.delete("/{id}").summary("Soft-delete notification template").permission("notifications:admin")
64
+ .handler(async ({ params, orgId }) => {
65
+ const result = await notifService.deleteTemplate(orgId, params.id!);
66
+ if (!result.ok) throw new Error(result.error);
67
+ return result.value;
68
+ });
69
+
70
+ // ── Send Route ─────────────────────────────────────────────────────
71
+ const send = router("Notifications", "/notifications", ctx);
72
+
73
+ send.post("/send").summary("Send notification").permission("notifications:admin")
74
+ .input(z.object({
75
+ event: z.string().min(1),
76
+ recipient: z.string().min(1),
77
+ channel: z.enum(["email", "sms", "push", "print"]),
78
+ customerId: z.string().uuid().optional(),
79
+ data: z.record(z.string(), z.unknown()).optional(),
80
+ metadata: z.record(z.string(), z.unknown()).optional(),
81
+ }))
82
+ .handler(async ({ input, orgId }) => {
83
+ const body = input as {
84
+ event: string; recipient: string; channel: "email" | "sms" | "push" | "print";
85
+ customerId?: string; data?: Record<string, unknown>; metadata?: Record<string, unknown>;
86
+ };
87
+ const result = await notifService.send(orgId, body);
88
+ if (!result.ok) throw new Error(result.error);
89
+ return result.value;
90
+ });
91
+
92
+ // ── Log Route ──────────────────────────────────────────────────────
93
+ send.get("/log").summary("Query notification log").permission("notifications:admin")
94
+ .query(z.object({
95
+ channel: z.string().optional(),
96
+ event: z.string().optional(),
97
+ status: z.enum(["queued", "sent", "delivered", "failed"]).optional(),
98
+ limit: z.coerce.number().int().positive().optional(),
99
+ }))
100
+ .handler(async ({ query, orgId }) => {
101
+ const q = query as { channel?: string; event?: string; status?: "queued" | "sent" | "delivered" | "failed"; limit?: number };
102
+ const result = await notifService.listLog(orgId, q);
103
+ if (!result.ok) throw new Error(result.error);
104
+ return result.value;
105
+ });
106
+
107
+ // ── Preference Routes ──────────────────────────────────────────────
108
+ const pref = router("Notification Preferences", "/notifications/preferences", ctx);
109
+
110
+ pref.post("/").summary("Set customer notification preference").permission("notifications:write")
111
+ .input(z.object({
112
+ customerId: z.string().uuid(),
113
+ channel: z.enum(["email", "sms", "push"]),
114
+ isEnabled: z.boolean(),
115
+ destination: z.string().optional(),
116
+ }))
117
+ .handler(async ({ input, orgId }) => {
118
+ const body = input as { customerId: string; channel: "email" | "sms" | "push"; isEnabled: boolean; destination?: string };
119
+ const result = await prefService.setPreference(
120
+ orgId, body.customerId, body.channel, body.isEnabled, body.destination,
121
+ );
122
+ if (!result.ok) throw new Error(result.error);
123
+ return result.value;
124
+ });
125
+
126
+ pref.get("/{customerId}").summary("Get customer notification preferences").permission("notifications:read")
127
+ .handler(async ({ params, orgId }) => {
128
+ const result = await prefService.getPreferences(orgId, params.customerId!);
129
+ if (!result.ok) throw new Error(result.error);
130
+ return result.value;
131
+ });
132
+
133
+ // ── Print Routes ───────────────────────────────────────────────────
134
+ const print = router("Print Jobs", "/notifications/print", ctx);
135
+
136
+ print.post("/").summary("Submit print job").permission("notifications:admin")
137
+ .input(z.object({
138
+ type: z.enum(["receipt", "label", "sticker", "kot"]),
139
+ printerId: z.string().min(1),
140
+ content: z.record(z.string(), z.unknown()),
141
+ format: z.enum(["esc_pos", "star_line", "label"]).optional(),
142
+ }))
143
+ .handler(async ({ input, orgId }) => {
144
+ const body = input as {
145
+ type: "receipt" | "label" | "sticker" | "kot";
146
+ printerId: string;
147
+ content: Record<string, unknown>;
148
+ format?: "esc_pos" | "star_line" | "label";
149
+ };
150
+ const result = await printService.submitJob(orgId, body);
151
+ if (!result.ok) throw new Error(result.error);
152
+ return result.value;
153
+ });
154
+
155
+ print.get("/{id}").summary("Get print job").permission("notifications:admin")
156
+ .handler(async ({ params, orgId }) => {
157
+ const result = await printService.getJob(orgId, params.id!);
158
+ if (!result.ok) throw new Error(result.error);
159
+ return result.value;
160
+ });
161
+
162
+ print.get("/").summary("List print jobs").permission("notifications:admin")
163
+ .query(z.object({
164
+ status: z.enum(["queued", "printing", "printed", "failed"]).optional(),
165
+ printerId: z.string().optional(),
166
+ type: z.enum(["receipt", "label", "sticker", "kot"]).optional(),
167
+ limit: z.coerce.number().int().positive().optional(),
168
+ }))
169
+ .handler(async ({ query, orgId }) => {
170
+ const q = query as {
171
+ status?: "queued" | "printing" | "printed" | "failed";
172
+ printerId?: string;
173
+ type?: "receipt" | "label" | "sticker" | "kot";
174
+ limit?: number;
175
+ };
176
+ const result = await printService.listJobs(orgId, q);
177
+ if (!result.ok) throw new Error(result.error);
178
+ return result.value;
179
+ });
180
+
181
+ print.patch("/{id}/status").summary("Update print job status").permission("notifications:admin")
182
+ .input(z.object({
183
+ status: z.enum(["queued", "printing", "printed", "failed"]),
184
+ error: z.string().optional(),
185
+ }))
186
+ .handler(async ({ params, input, orgId }) => {
187
+ const body = input as { status: "queued" | "printing" | "printed" | "failed"; error?: string };
188
+ const result = await printService.updateJobStatus(orgId, params.id!, body.status, body.error);
189
+ if (!result.ok) throw new Error(result.error);
190
+ return result.value;
191
+ });
192
+
193
+ return [
194
+ ...tmpl.routes(),
195
+ ...send.routes(),
196
+ ...pref.routes(),
197
+ ...print.routes(),
198
+ ];
199
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,67 @@
1
+ import { pgTable, uuid, text, boolean, timestamp, index, uniqueIndex, jsonb } from "@porulle/core/drizzle";
2
+
3
+ export const notificationTemplates = pgTable("notification_templates", {
4
+ id: uuid("id").defaultRandom().primaryKey(),
5
+ organizationId: text("organization_id").notNull(),
6
+ event: text("event").notNull(),
7
+ channel: text("channel", { enum: ["email", "sms", "push", "print"] }).notNull(),
8
+ subject: text("subject"),
9
+ bodyTemplate: text("body_template").notNull(),
10
+ isActive: boolean("is_active").notNull().default(true),
11
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
12
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
13
+ }, (table) => ({
14
+ orgIdx: index("idx_notification_templates_org").on(table.organizationId),
15
+ orgEventChannelUnique: uniqueIndex("notification_templates_org_event_channel_unique").on(
16
+ table.organizationId, table.event, table.channel,
17
+ ),
18
+ }));
19
+
20
+ export const customerNotificationPrefs = pgTable("customer_notification_prefs", {
21
+ id: uuid("id").defaultRandom().primaryKey(),
22
+ organizationId: text("organization_id").notNull(),
23
+ customerId: uuid("customer_id").notNull(),
24
+ channel: text("channel", { enum: ["email", "sms", "push"] }).notNull(),
25
+ isEnabled: boolean("is_enabled").notNull().default(true),
26
+ destination: text("destination"),
27
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
28
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
29
+ }, (table) => ({
30
+ orgIdx: index("idx_customer_notification_prefs_org").on(table.organizationId),
31
+ orgCustomerChannelUnique: uniqueIndex("customer_notification_prefs_org_cust_channel_unique").on(
32
+ table.organizationId, table.customerId, table.channel,
33
+ ),
34
+ }));
35
+
36
+ export const notificationLog = pgTable("notification_log", {
37
+ id: uuid("id").defaultRandom().primaryKey(),
38
+ organizationId: text("organization_id").notNull(),
39
+ channel: text("channel").notNull(),
40
+ event: text("event").notNull(),
41
+ recipient: text("recipient").notNull(),
42
+ status: text("status", { enum: ["queued", "sent", "delivered", "failed"] }).notNull().default("queued"),
43
+ error: text("error"),
44
+ metadata: jsonb("metadata").notNull().default({}),
45
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
46
+ }, (table) => ({
47
+ orgIdx: index("idx_notification_log_org").on(table.organizationId),
48
+ channelIdx: index("idx_notification_log_channel").on(table.channel),
49
+ eventIdx: index("idx_notification_log_event").on(table.event),
50
+ statusIdx: index("idx_notification_log_status").on(table.status),
51
+ }));
52
+
53
+ export const printJobs = pgTable("print_jobs", {
54
+ id: uuid("id").defaultRandom().primaryKey(),
55
+ organizationId: text("organization_id").notNull(),
56
+ type: text("type", { enum: ["receipt", "label", "sticker", "kot"] }).notNull(),
57
+ printerId: text("printer_id").notNull(),
58
+ content: jsonb("content").notNull().default({}),
59
+ status: text("status", { enum: ["queued", "printing", "printed", "failed"] }).notNull().default("queued"),
60
+ error: text("error"),
61
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
62
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
63
+ }, (table) => ({
64
+ orgIdx: index("idx_print_jobs_org").on(table.organizationId),
65
+ statusIdx: index("idx_print_jobs_status").on(table.status),
66
+ printerIdx: index("idx_print_jobs_printer").on(table.printerId),
67
+ }));
@@ -0,0 +1,270 @@
1
+ import { eq, and } from "@porulle/core/drizzle";
2
+ import { notificationTemplates, customerNotificationPrefs, notificationLog } from "../schema.js";
3
+ import type {
4
+ Db, NotificationTemplate, NotificationLogEntry, Channel, NotificationStatus,
5
+ Result,
6
+ } from "../types.js";
7
+ import { Ok, Err } from "../types.js";
8
+ import type { SMSAdapter, PushAdapter, NotificationAdapters } from "../adapters/types.js";
9
+
10
+ export class NotificationService {
11
+ private smsAdapter: SMSAdapter | undefined;
12
+ private pushAdapter: PushAdapter | undefined;
13
+
14
+ constructor(private db: Db, adapters?: NotificationAdapters) {
15
+ this.smsAdapter = adapters?.sms;
16
+ this.pushAdapter = adapters?.push;
17
+ }
18
+
19
+ // ── Template CRUD ──────────────────────────────────────────────────
20
+
21
+ async createTemplate(orgId: string, input: {
22
+ event: string; channel: Channel; subject?: string; bodyTemplate: string;
23
+ }): Promise<Result<NotificationTemplate>> {
24
+ const existing = await this.db.select().from(notificationTemplates)
25
+ .where(and(
26
+ eq(notificationTemplates.organizationId, orgId),
27
+ eq(notificationTemplates.event, input.event),
28
+ eq(notificationTemplates.channel, input.channel),
29
+ ));
30
+ if (existing.length > 0) return Err(`Template for '${input.event}' on '${input.channel}' already exists`);
31
+ const rows = await this.db.insert(notificationTemplates).values({
32
+ organizationId: orgId,
33
+ event: input.event,
34
+ channel: input.channel,
35
+ subject: input.subject,
36
+ bodyTemplate: input.bodyTemplate,
37
+ }).returning();
38
+ return Ok(rows[0]!);
39
+ }
40
+
41
+ async listTemplates(orgId: string, filters?: {
42
+ event?: string; channel?: Channel;
43
+ }): Promise<Result<NotificationTemplate[]>> {
44
+ const conditions = [eq(notificationTemplates.organizationId, orgId)];
45
+ if (filters?.event) conditions.push(eq(notificationTemplates.event, filters.event));
46
+ if (filters?.channel) conditions.push(eq(notificationTemplates.channel, filters.channel));
47
+ const rows = await this.db.select().from(notificationTemplates).where(and(...conditions));
48
+ return Ok(rows);
49
+ }
50
+
51
+ async getTemplate(orgId: string, id: string): Promise<Result<NotificationTemplate>> {
52
+ const rows = await this.db.select().from(notificationTemplates)
53
+ .where(and(eq(notificationTemplates.organizationId, orgId), eq(notificationTemplates.id, id)));
54
+ if (rows.length === 0) return Err("Template not found");
55
+ return Ok(rows[0]!);
56
+ }
57
+
58
+ async updateTemplate(orgId: string, id: string, input: {
59
+ subject?: string; bodyTemplate?: string; isActive?: boolean;
60
+ }): Promise<Result<NotificationTemplate>> {
61
+ const existing = await this.db.select().from(notificationTemplates)
62
+ .where(and(eq(notificationTemplates.organizationId, orgId), eq(notificationTemplates.id, id)));
63
+ if (existing.length === 0) return Err("Template not found");
64
+ const rows = await this.db.update(notificationTemplates).set({
65
+ ...(input.subject !== undefined ? { subject: input.subject } : {}),
66
+ ...(input.bodyTemplate !== undefined ? { bodyTemplate: input.bodyTemplate } : {}),
67
+ ...(input.isActive !== undefined ? { isActive: input.isActive } : {}),
68
+ updatedAt: new Date(),
69
+ }).where(eq(notificationTemplates.id, id)).returning();
70
+ return Ok(rows[0]!);
71
+ }
72
+
73
+ async deleteTemplate(orgId: string, id: string): Promise<Result<NotificationTemplate>> {
74
+ const existing = await this.db.select().from(notificationTemplates)
75
+ .where(and(eq(notificationTemplates.organizationId, orgId), eq(notificationTemplates.id, id)));
76
+ if (existing.length === 0) return Err("Template not found");
77
+ const rows = await this.db.update(notificationTemplates).set({
78
+ isActive: false,
79
+ updatedAt: new Date(),
80
+ }).where(eq(notificationTemplates.id, id)).returning();
81
+ return Ok(rows[0]!);
82
+ }
83
+
84
+ // ── Template Rendering ─────────────────────────────────────────────
85
+
86
+ /**
87
+ * Simple Handlebars-style template rendering.
88
+ * Replaces {{key}} with values from the data object.
89
+ * Supports nested keys via dot notation: {{order.id}}.
90
+ */
91
+ renderTemplate(template: string, data: Record<string, unknown>): string {
92
+ return template.replace(/\{\{(\w+(?:\.\w+)*)\}\}/g, (_match, key: string) => {
93
+ const parts = key.split(".");
94
+ let value: unknown = data;
95
+ for (const part of parts) {
96
+ if (value == null || typeof value !== "object") return "";
97
+ value = (value as Record<string, unknown>)[part];
98
+ }
99
+ return value != null ? String(value) : "";
100
+ });
101
+ }
102
+
103
+ // ── Send Notification ──────────────────────────────────────────────
104
+
105
+ /**
106
+ * Unified send: resolves template, checks customer preferences,
107
+ * dispatches to the correct channel adapter, and logs the result.
108
+ */
109
+ async send(orgId: string, input: {
110
+ event: string;
111
+ recipient: string;
112
+ channel: Channel;
113
+ customerId?: string;
114
+ data?: Record<string, unknown>;
115
+ metadata?: Record<string, unknown>;
116
+ }): Promise<Result<NotificationLogEntry>> {
117
+ // Check customer preference if customerId is provided and channel is not "print"
118
+ if (input.customerId && input.channel !== "print") {
119
+ const prefChannel = input.channel as "email" | "sms" | "push";
120
+ const prefs = await this.db.select().from(customerNotificationPrefs)
121
+ .where(and(
122
+ eq(customerNotificationPrefs.organizationId, orgId),
123
+ eq(customerNotificationPrefs.customerId, input.customerId),
124
+ eq(customerNotificationPrefs.channel, prefChannel),
125
+ ));
126
+ if (prefs.length > 0 && !prefs[0]!.isEnabled) {
127
+ return Err(`Customer has disabled ${input.channel} notifications`);
128
+ }
129
+ }
130
+
131
+ // Resolve template if one exists for this event+channel
132
+ let body = "";
133
+ let subject: string | undefined;
134
+ const templates = await this.db.select().from(notificationTemplates)
135
+ .where(and(
136
+ eq(notificationTemplates.organizationId, orgId),
137
+ eq(notificationTemplates.event, input.event),
138
+ eq(notificationTemplates.channel, input.channel),
139
+ eq(notificationTemplates.isActive, true),
140
+ ));
141
+
142
+ if (templates.length > 0) {
143
+ const tmpl = templates[0]!;
144
+ body = this.renderTemplate(tmpl.bodyTemplate, input.data ?? {});
145
+ if (tmpl.subject) {
146
+ subject = this.renderTemplate(tmpl.subject, input.data ?? {});
147
+ }
148
+ }
149
+
150
+ // Dispatch to adapter
151
+ let adapterError: string | undefined;
152
+ let adapterMessageId: string | undefined;
153
+
154
+ if (input.channel === "sms" && this.smsAdapter) {
155
+ const result = await this.smsAdapter.send({ to: input.recipient, body });
156
+ if (!result.ok) {
157
+ adapterError = result.error;
158
+ } else {
159
+ adapterMessageId = result.value.messageId;
160
+ }
161
+ } else if (input.channel === "push" && this.pushAdapter) {
162
+ const result = await this.pushAdapter.send({
163
+ deviceToken: input.recipient,
164
+ title: subject ?? input.event,
165
+ body,
166
+ ...(input.data != null ? { data: input.data } : {}),
167
+ });
168
+ if (!result.ok) {
169
+ adapterError = result.error;
170
+ } else {
171
+ adapterMessageId = result.value.messageId;
172
+ }
173
+ }
174
+
175
+ // Log the result
176
+ const status: NotificationStatus = adapterError ? "failed" : "sent";
177
+ const logRows = await this.db.insert(notificationLog).values({
178
+ organizationId: orgId,
179
+ channel: input.channel,
180
+ event: input.event,
181
+ recipient: input.recipient,
182
+ status,
183
+ error: adapterError,
184
+ metadata: {
185
+ ...input.metadata,
186
+ ...(adapterMessageId ? { adapterMessageId } : {}),
187
+ ...(input.data ? { templateData: input.data } : {}),
188
+ },
189
+ }).returning();
190
+
191
+ return Ok(logRows[0]!);
192
+ }
193
+
194
+ // ── Direct Channel Sends ───────────────────────────────────────────
195
+
196
+ async sendSMS(orgId: string, to: string, body: string): Promise<Result<NotificationLogEntry>> {
197
+ let adapterError: string | undefined;
198
+ let adapterMessageId: string | undefined;
199
+
200
+ if (this.smsAdapter) {
201
+ const result = await this.smsAdapter.send({ to, body });
202
+ if (!result.ok) {
203
+ adapterError = result.error;
204
+ } else {
205
+ adapterMessageId = result.value.messageId;
206
+ }
207
+ }
208
+
209
+ const status: NotificationStatus = adapterError ? "failed" : "sent";
210
+ const rows = await this.db.insert(notificationLog).values({
211
+ organizationId: orgId,
212
+ channel: "sms",
213
+ event: "direct.sms",
214
+ recipient: to,
215
+ status,
216
+ error: adapterError,
217
+ metadata: adapterMessageId ? { adapterMessageId } : {},
218
+ }).returning();
219
+
220
+ return Ok(rows[0]!);
221
+ }
222
+
223
+ async sendPush(
224
+ orgId: string,
225
+ deviceToken: string,
226
+ title: string,
227
+ body: string,
228
+ data?: Record<string, unknown>,
229
+ ): Promise<Result<NotificationLogEntry>> {
230
+ let adapterError: string | undefined;
231
+ let adapterMessageId: string | undefined;
232
+
233
+ if (this.pushAdapter) {
234
+ const result = await this.pushAdapter.send({ deviceToken, title, body, ...(data != null ? { data } : {}) });
235
+ if (!result.ok) {
236
+ adapterError = result.error;
237
+ } else {
238
+ adapterMessageId = result.value.messageId;
239
+ }
240
+ }
241
+
242
+ const status: NotificationStatus = adapterError ? "failed" : "sent";
243
+ const rows = await this.db.insert(notificationLog).values({
244
+ organizationId: orgId,
245
+ channel: "push",
246
+ event: "direct.push",
247
+ recipient: deviceToken,
248
+ status,
249
+ error: adapterError,
250
+ metadata: adapterMessageId ? { adapterMessageId } : {},
251
+ }).returning();
252
+
253
+ return Ok(rows[0]!);
254
+ }
255
+
256
+ // ── Log Queries ────────────────────────────────────────────────────
257
+
258
+ async listLog(orgId: string, filters?: {
259
+ channel?: string; event?: string; status?: NotificationStatus; limit?: number;
260
+ }): Promise<Result<NotificationLogEntry[]>> {
261
+ const conditions = [eq(notificationLog.organizationId, orgId)];
262
+ if (filters?.channel) conditions.push(eq(notificationLog.channel, filters.channel));
263
+ if (filters?.event) conditions.push(eq(notificationLog.event, filters.event));
264
+ if (filters?.status) conditions.push(eq(notificationLog.status, filters.status));
265
+ let query = this.db.select().from(notificationLog).where(and(...conditions)).$dynamic();
266
+ if (filters?.limit) query = query.limit(filters.limit);
267
+ const rows = await query;
268
+ return Ok(rows);
269
+ }
270
+ }