@unifiedcommerce/plugin-notifications 0.0.1
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/adapters/console.d.ts +17 -0
- package/dist/adapters/console.d.ts.map +1 -0
- package/dist/adapters/console.js +44 -0
- package/dist/adapters/types.d.ts +62 -0
- package/dist/adapters/types.d.ts.map +1 -0
- package/dist/adapters/types.js +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +46 -0
- package/dist/routes/notifications.d.ts +11 -0
- package/dist/routes/notifications.d.ts.map +1 -0
- package/dist/routes/notifications.js +170 -0
- package/dist/schema.d.ts +624 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +59 -0
- package/dist/services/notification-service.d.ts +52 -0
- package/dist/services/notification-service.d.ts.map +1 -0
- package/dist/services/notification-service.js +220 -0
- package/dist/services/preference-service.d.ts +23 -0
- package/dist/services/preference-service.d.ts.map +1 -0
- package/dist/services/preference-service.js +69 -0
- package/dist/services/print-service.d.ts +32 -0
- package/dist/services/print-service.d.ts.map +1 -0
- package/dist/services/print-service.js +91 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/types.d.ts +15 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/package.json +37 -0
- package/src/adapters/console.ts +52 -0
- package/src/adapters/types.ts +56 -0
- package/src/index.ts +53 -0
- package/src/routes/notifications.ts +199 -0
- package/src/schema.ts +67 -0
- package/src/services/notification-service.ts +270 -0
- package/src/services/preference-service.ts +92 -0
- package/src/services/print-service.ts +99 -0
- package/src/types.ts +16 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import { eq, and } from "drizzle-orm";
|
|
2
|
+
import { notificationTemplates, customerNotificationPrefs, notificationLog } from "../schema";
|
|
3
|
+
import type {
|
|
4
|
+
Db, NotificationTemplate, NotificationLogEntry, Channel, NotificationStatus,
|
|
5
|
+
Result,
|
|
6
|
+
} from "../types";
|
|
7
|
+
import { Ok, Err } from "../types";
|
|
8
|
+
import type { SMSAdapter, PushAdapter, NotificationAdapters } from "../adapters/types";
|
|
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
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { eq, and } from "drizzle-orm";
|
|
2
|
+
import { customerNotificationPrefs, notificationTemplates } from "../schema";
|
|
3
|
+
import type { Db, CustomerNotificationPref, PrefChannel, Result } from "../types";
|
|
4
|
+
import { Ok } from "../types";
|
|
5
|
+
|
|
6
|
+
export class PreferenceService {
|
|
7
|
+
constructor(private db: Db) {}
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Upsert a customer notification preference for a specific channel.
|
|
11
|
+
* If a preference already exists for this org+customer+channel, it is updated.
|
|
12
|
+
*/
|
|
13
|
+
async setPreference(orgId: string, customerId: string, channel: PrefChannel, isEnabled: boolean, destination?: string): Promise<Result<CustomerNotificationPref>> {
|
|
14
|
+
const existing = await this.db.select().from(customerNotificationPrefs)
|
|
15
|
+
.where(and(
|
|
16
|
+
eq(customerNotificationPrefs.organizationId, orgId),
|
|
17
|
+
eq(customerNotificationPrefs.customerId, customerId),
|
|
18
|
+
eq(customerNotificationPrefs.channel, channel),
|
|
19
|
+
));
|
|
20
|
+
|
|
21
|
+
if (existing.length > 0) {
|
|
22
|
+
const rows = await this.db.update(customerNotificationPrefs).set({
|
|
23
|
+
isEnabled,
|
|
24
|
+
...(destination !== undefined ? { destination } : {}),
|
|
25
|
+
updatedAt: new Date(),
|
|
26
|
+
}).where(eq(customerNotificationPrefs.id, existing[0]!.id)).returning();
|
|
27
|
+
return Ok(rows[0]!);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const rows = await this.db.insert(customerNotificationPrefs).values({
|
|
31
|
+
organizationId: orgId,
|
|
32
|
+
customerId,
|
|
33
|
+
channel,
|
|
34
|
+
isEnabled,
|
|
35
|
+
destination,
|
|
36
|
+
}).returning();
|
|
37
|
+
return Ok(rows[0]!);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Get all notification preferences for a customer. */
|
|
41
|
+
async getPreferences(orgId: string, customerId: string): Promise<Result<CustomerNotificationPref[]>> {
|
|
42
|
+
const rows = await this.db.select().from(customerNotificationPrefs)
|
|
43
|
+
.where(and(
|
|
44
|
+
eq(customerNotificationPrefs.organizationId, orgId),
|
|
45
|
+
eq(customerNotificationPrefs.customerId, customerId),
|
|
46
|
+
));
|
|
47
|
+
return Ok(rows);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Determine which channels should fire for a given customer+event.
|
|
52
|
+
*
|
|
53
|
+
* Logic:
|
|
54
|
+
* 1. Find all active templates for the event.
|
|
55
|
+
* 2. For each template channel (email/sms/push), check customer preferences.
|
|
56
|
+
* 3. If no preference exists, the channel is considered enabled (opt-out model).
|
|
57
|
+
* 4. Returns channels that have an active template AND are not disabled by the customer.
|
|
58
|
+
*/
|
|
59
|
+
async getActiveChannels(orgId: string, customerId: string, event: string): Promise<Result<PrefChannel[]>> {
|
|
60
|
+
// Find active templates for this event (email, sms, push only — not print)
|
|
61
|
+
const templates = await this.db.select().from(notificationTemplates)
|
|
62
|
+
.where(and(
|
|
63
|
+
eq(notificationTemplates.organizationId, orgId),
|
|
64
|
+
eq(notificationTemplates.event, event),
|
|
65
|
+
eq(notificationTemplates.isActive, true),
|
|
66
|
+
));
|
|
67
|
+
|
|
68
|
+
const templateChannels = templates
|
|
69
|
+
.map((t) => t.channel)
|
|
70
|
+
.filter((ch): ch is PrefChannel => ch === "email" || ch === "sms" || ch === "push");
|
|
71
|
+
|
|
72
|
+
if (templateChannels.length === 0) return Ok([]);
|
|
73
|
+
|
|
74
|
+
// Get customer preferences
|
|
75
|
+
const prefs = await this.db.select().from(customerNotificationPrefs)
|
|
76
|
+
.where(and(
|
|
77
|
+
eq(customerNotificationPrefs.organizationId, orgId),
|
|
78
|
+
eq(customerNotificationPrefs.customerId, customerId),
|
|
79
|
+
));
|
|
80
|
+
|
|
81
|
+
const prefMap = new Map(prefs.map((p) => [p.channel, p.isEnabled]));
|
|
82
|
+
|
|
83
|
+
// Filter: include channel if no preference (opt-out model) or explicitly enabled
|
|
84
|
+
const active = templateChannels.filter((ch) => {
|
|
85
|
+
const enabled = prefMap.get(ch);
|
|
86
|
+
return enabled !== false; // undefined (no pref) → allowed; true → allowed; false → blocked
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Deduplicate
|
|
90
|
+
return Ok([...new Set(active)]);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { eq, and } from "drizzle-orm";
|
|
2
|
+
import { printJobs } from "../schema";
|
|
3
|
+
import type { Db, PrintJob, PrintJobStatus, PrintJobType, Result } from "../types";
|
|
4
|
+
import { Ok, Err } from "../types";
|
|
5
|
+
import type { PrintAdapter } from "../adapters/types";
|
|
6
|
+
|
|
7
|
+
export class PrintService {
|
|
8
|
+
constructor(private db: Db, private adapter?: PrintAdapter) {}
|
|
9
|
+
|
|
10
|
+
/** Submit a new print job. Dispatches to the print adapter if configured. */
|
|
11
|
+
async submitJob(orgId: string, input: {
|
|
12
|
+
type: PrintJobType;
|
|
13
|
+
printerId: string;
|
|
14
|
+
content: Record<string, unknown>;
|
|
15
|
+
format?: "esc_pos" | "star_line" | "label";
|
|
16
|
+
}): Promise<Result<PrintJob>> {
|
|
17
|
+
const rows = await this.db.insert(printJobs).values({
|
|
18
|
+
organizationId: orgId,
|
|
19
|
+
type: input.type,
|
|
20
|
+
printerId: input.printerId,
|
|
21
|
+
content: input.content,
|
|
22
|
+
}).returning();
|
|
23
|
+
const job = rows[0]!;
|
|
24
|
+
|
|
25
|
+
// Dispatch to adapter if available
|
|
26
|
+
if (this.adapter) {
|
|
27
|
+
const result = await this.adapter.print({
|
|
28
|
+
printerId: input.printerId,
|
|
29
|
+
content: input.content,
|
|
30
|
+
format: input.format ?? "esc_pos",
|
|
31
|
+
});
|
|
32
|
+
if (!result.ok) {
|
|
33
|
+
// Mark as failed
|
|
34
|
+
const updated = await this.db.update(printJobs).set({
|
|
35
|
+
status: "failed" as const,
|
|
36
|
+
error: result.error,
|
|
37
|
+
updatedAt: new Date(),
|
|
38
|
+
}).where(eq(printJobs.id, job.id)).returning();
|
|
39
|
+
return Ok(updated[0]!);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return Ok(job);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Get a single print job by ID. */
|
|
47
|
+
async getJob(orgId: string, id: string): Promise<Result<PrintJob>> {
|
|
48
|
+
const rows = await this.db.select().from(printJobs)
|
|
49
|
+
.where(and(eq(printJobs.organizationId, orgId), eq(printJobs.id, id)));
|
|
50
|
+
if (rows.length === 0) return Err("Print job not found");
|
|
51
|
+
return Ok(rows[0]!);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** List print jobs with optional filters. */
|
|
55
|
+
async listJobs(orgId: string, filters?: {
|
|
56
|
+
status?: PrintJobStatus; printerId?: string; type?: PrintJobType; limit?: number;
|
|
57
|
+
}): Promise<Result<PrintJob[]>> {
|
|
58
|
+
const conditions = [eq(printJobs.organizationId, orgId)];
|
|
59
|
+
if (filters?.status) conditions.push(eq(printJobs.status, filters.status));
|
|
60
|
+
if (filters?.printerId) conditions.push(eq(printJobs.printerId, filters.printerId));
|
|
61
|
+
if (filters?.type) conditions.push(eq(printJobs.type, filters.type));
|
|
62
|
+
let query = this.db.select().from(printJobs).where(and(...conditions)).$dynamic();
|
|
63
|
+
if (filters?.limit) query = query.limit(filters.limit);
|
|
64
|
+
const rows = await query;
|
|
65
|
+
return Ok(rows);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Update the status of a print job.
|
|
70
|
+
* Valid transitions:
|
|
71
|
+
* queued → printing → printed
|
|
72
|
+
* queued → failed
|
|
73
|
+
* printing → failed
|
|
74
|
+
*/
|
|
75
|
+
async updateJobStatus(orgId: string, id: string, status: PrintJobStatus, error?: string): Promise<Result<PrintJob>> {
|
|
76
|
+
const existing = await this.db.select().from(printJobs)
|
|
77
|
+
.where(and(eq(printJobs.organizationId, orgId), eq(printJobs.id, id)));
|
|
78
|
+
if (existing.length === 0) return Err("Print job not found");
|
|
79
|
+
|
|
80
|
+
const current = existing[0]!.status;
|
|
81
|
+
const validTransitions: Record<string, string[]> = {
|
|
82
|
+
queued: ["printing", "failed"],
|
|
83
|
+
printing: ["printed", "failed"],
|
|
84
|
+
printed: [],
|
|
85
|
+
failed: [],
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
if (!validTransitions[current]?.includes(status)) {
|
|
89
|
+
return Err(`Cannot transition from '${current}' to '${status}'`);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const rows = await this.db.update(printJobs).set({
|
|
93
|
+
status,
|
|
94
|
+
...(error !== undefined ? { error } : {}),
|
|
95
|
+
updatedAt: new Date(),
|
|
96
|
+
}).where(eq(printJobs.id, id)).returning();
|
|
97
|
+
return Ok(rows[0]!);
|
|
98
|
+
}
|
|
99
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { notificationTemplates, customerNotificationPrefs, notificationLog, printJobs } from "./schema";
|
|
2
|
+
|
|
3
|
+
export type { PluginDb as Db } from "@unifiedcommerce/core";
|
|
4
|
+
export type NotificationTemplate = typeof notificationTemplates.$inferSelect;
|
|
5
|
+
export type CustomerNotificationPref = typeof customerNotificationPrefs.$inferSelect;
|
|
6
|
+
export type NotificationLogEntry = typeof notificationLog.$inferSelect;
|
|
7
|
+
export type PrintJob = typeof printJobs.$inferSelect;
|
|
8
|
+
export type Channel = "email" | "sms" | "push" | "print";
|
|
9
|
+
export type PrefChannel = "email" | "sms" | "push";
|
|
10
|
+
export type NotificationStatus = "queued" | "sent" | "delivered" | "failed";
|
|
11
|
+
export type PrintJobStatus = "queued" | "printing" | "printed" | "failed";
|
|
12
|
+
export type PrintJobType = "receipt" | "label" | "sticker" | "kot";
|
|
13
|
+
|
|
14
|
+
/** Result type re-exports from core. */
|
|
15
|
+
export { Ok, Err } from "@unifiedcommerce/core";
|
|
16
|
+
export type { PluginResult as Result, PluginResultErr as ResultErr } from "@unifiedcommerce/core";
|