@blamejs/blamejs-shop 0.0.61 → 0.0.62
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/CHANGELOG.md +2 -0
- package/lib/email-campaigns.js +844 -0
- package/lib/geolocation.js +651 -0
- package/lib/gift-registry.js +820 -0
- package/lib/index.js +10 -0
- package/lib/loyalty-redemption.js +673 -0
- package/lib/plan-changes.js +508 -0
- package/lib/refund-policy.js +965 -0
- package/lib/sms-dispatcher.js +7 -1
- package/lib/stock-transfers.js +777 -0
- package/lib/storefront-dashboards.js +863 -0
- package/lib/vendors.js +797 -0
- package/package.json +1 -1
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.emailCampaigns
|
|
4
|
+
* @title Email campaigns — operator-scheduled bulk marketing sends
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Marketing companion to `email` (transactional). Where `email`
|
|
8
|
+
* composes a single per-recipient send the application triggers
|
|
9
|
+
* (order receipt, ship notification, refund), this primitive owns
|
|
10
|
+
* the operator-defined broadcast — a single message body, an
|
|
11
|
+
* audience resolved through `mailingAudiences`, a `schedule_at` the
|
|
12
|
+
* dispatcher walks on a cron tick, and a per-event ledger
|
|
13
|
+
* (`delivered` / `opened` / `clicked` / `bounced` /
|
|
14
|
+
* `unsubscribed`) the dashboard rolls up into engagement rates.
|
|
15
|
+
*
|
|
16
|
+
* Composition:
|
|
17
|
+
*
|
|
18
|
+
* var campaigns = bShop.emailCampaigns.create({
|
|
19
|
+
* query: q,
|
|
20
|
+
* mailingAudiences: audiences,
|
|
21
|
+
* email: transactionalEmail,
|
|
22
|
+
* emailSuppressions: suppressions,
|
|
23
|
+
* });
|
|
24
|
+
*
|
|
25
|
+
* await campaigns.defineCampaign({
|
|
26
|
+
* slug: "release-0.1.0",
|
|
27
|
+
* subject: "blamejs.shop 0.1.0 is here",
|
|
28
|
+
* body_html: "<p>Hi {{customer_name}}…</p>",
|
|
29
|
+
* body_text: "Hi {{customer_name}}…",
|
|
30
|
+
* audience_slug: "release-watchers",
|
|
31
|
+
* schedule_at: Date.now() + 3600_000,
|
|
32
|
+
* from_address: "release@shop.example",
|
|
33
|
+
* from_name: "blamejs.shop",
|
|
34
|
+
* reply_to: "support@shop.example",
|
|
35
|
+
* });
|
|
36
|
+
*
|
|
37
|
+
* // Operator wires the scheduler tick (cron / Workers Cron Trigger).
|
|
38
|
+
* // Walks `WHERE status='scheduled' AND schedule_at <= now`,
|
|
39
|
+
* // resolves the audience, drains per-recipient sends through the
|
|
40
|
+
* // injected `email` mailer, records a `delivered` event per
|
|
41
|
+
* // recipient, then transitions the campaign to `sent`.
|
|
42
|
+
* await campaigns.dispatchTick({ now: Date.now() });
|
|
43
|
+
*
|
|
44
|
+
* // ESP webhook backfill — operator routes `opened` / `clicked` /
|
|
45
|
+
* // `bounced` / `unsubscribed` callbacks through here so the
|
|
46
|
+
* // per-campaign rates reflect downstream engagement.
|
|
47
|
+
* await campaigns.recordEvent({
|
|
48
|
+
* campaign_slug: "release-0.1.0",
|
|
49
|
+
* recipient_hash: hash,
|
|
50
|
+
* event_type: "opened",
|
|
51
|
+
* });
|
|
52
|
+
*
|
|
53
|
+
* await campaigns.metricsForCampaign("release-0.1.0");
|
|
54
|
+
* // → { delivered: 4200, opened: 1890, clicked: 420, bounced: 12,
|
|
55
|
+
* // unsubscribed: 7, open_rate: 0.45, click_rate: 0.1, … }
|
|
56
|
+
*
|
|
57
|
+
* The campaign FSM:
|
|
58
|
+
*
|
|
59
|
+
* draft — defined, not yet scheduled. `scheduleCampaign`
|
|
60
|
+
* moves it forward; `cancelCampaign` is terminal.
|
|
61
|
+
* scheduled — `schedule_at` populated; the dispatcher will pick
|
|
62
|
+
* it up at the next tick at-or-after that time.
|
|
63
|
+
* `pauseCampaign` parks it, `sendNow` short-circuits
|
|
64
|
+
* the wait, `cancelCampaign` aborts.
|
|
65
|
+
* sending — dispatcher is currently draining recipients.
|
|
66
|
+
* Internal-only — the only normal exit is `sent`.
|
|
67
|
+
* sent — terminal happy path. Per-recipient events keep
|
|
68
|
+
* flowing in via `recordEvent` (ESP webhooks).
|
|
69
|
+
* paused — operator paused a scheduled campaign. Resume via
|
|
70
|
+
* `resumeCampaign` (back to `scheduled` with the
|
|
71
|
+
* same / updated schedule_at); `cancelCampaign`
|
|
72
|
+
* still works.
|
|
73
|
+
* cancelled — terminal. `cancel_reason` records why.
|
|
74
|
+
*
|
|
75
|
+
* Transitional events (b.fsm):
|
|
76
|
+
*
|
|
77
|
+
* schedule : draft → scheduled
|
|
78
|
+
* pause : scheduled → paused
|
|
79
|
+
* resume : paused → scheduled
|
|
80
|
+
* start : scheduled → sending (dispatcher starts the run)
|
|
81
|
+
* complete : sending → sent (dispatcher drained the audience)
|
|
82
|
+
* cancel : draft|scheduled|paused → cancelled
|
|
83
|
+
*
|
|
84
|
+
* `sendNow(slug)` is the operator's "skip the schedule" — a draft
|
|
85
|
+
* moves draft → scheduled → sending → sent in one call, a scheduled
|
|
86
|
+
* campaign moves scheduled → sending → sent. The cron tick is the
|
|
87
|
+
* normal path; `sendNow` is the manual-fire bypass.
|
|
88
|
+
*
|
|
89
|
+
* Composition only — zero npm runtime deps. b.fsm owns the state
|
|
90
|
+
* transitions; b.uuid.v7 mints event ids; `mailingAudiences.resolve`
|
|
91
|
+
* produces the recipient hashes (already suppression-filtered if
|
|
92
|
+
* the audience factory was wired with `emailSuppressions`);
|
|
93
|
+
* `email.send`-shaped mailer handles the per-recipient send.
|
|
94
|
+
*
|
|
95
|
+
* @primitive emailCampaigns
|
|
96
|
+
* @related shop.email, shop.mailingAudiences, shop.emailSuppressions, b.fsm
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
// ---- constants ----------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
var SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,62}[a-z0-9]$|^[a-z0-9]$/;
|
|
102
|
+
var MAX_SUBJECT_LEN = 200;
|
|
103
|
+
var MAX_BODY_LEN = 256 * 1024; // 256 KiB — wide enough for marketing HTML
|
|
104
|
+
var MAX_FROM_NAME_LEN = 100;
|
|
105
|
+
var MAX_REASON_LEN = 280;
|
|
106
|
+
var MAX_BATCH_SIZE = 1000;
|
|
107
|
+
var DEFAULT_BATCH_SIZE = 100;
|
|
108
|
+
var RESOLVE_PAGE_LIMIT = 500; // matches mailingAudiences MAX_LIST_LIMIT
|
|
109
|
+
|
|
110
|
+
var STATUSES = ["draft", "scheduled", "sending", "sent", "paused", "cancelled"];
|
|
111
|
+
var EVENT_TYPES = ["delivered", "opened", "clicked", "bounced", "unsubscribed"];
|
|
112
|
+
var TERMINAL = ["sent", "cancelled"];
|
|
113
|
+
|
|
114
|
+
var bShop;
|
|
115
|
+
function _b() {
|
|
116
|
+
if (!bShop) bShop = require("./index");
|
|
117
|
+
return bShop.framework;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- FSM definition -----------------------------------------------------
|
|
121
|
+
|
|
122
|
+
var _campaignFsm = null;
|
|
123
|
+
function _getCampaignFsm() {
|
|
124
|
+
if (_campaignFsm) return _campaignFsm;
|
|
125
|
+
// Idempotent namespace registration so b.fsm's audit emit lands in
|
|
126
|
+
// the operator's audit sink instead of dropping with a warning.
|
|
127
|
+
try { _b().audit.registerNamespace("fsm"); } catch (_e) { /* idempotent */ }
|
|
128
|
+
_campaignFsm = _b().fsm.define({
|
|
129
|
+
name: "emailCampaign",
|
|
130
|
+
initial: "draft",
|
|
131
|
+
states: {
|
|
132
|
+
draft: {},
|
|
133
|
+
scheduled: {},
|
|
134
|
+
sending: {},
|
|
135
|
+
sent: {},
|
|
136
|
+
paused: {},
|
|
137
|
+
cancelled: {},
|
|
138
|
+
},
|
|
139
|
+
transitions: [
|
|
140
|
+
{ from: "draft", to: "scheduled", on: "schedule" },
|
|
141
|
+
{ from: "scheduled", to: "paused", on: "pause" },
|
|
142
|
+
{ from: "paused", to: "scheduled", on: "resume" },
|
|
143
|
+
{ from: "scheduled", to: "sending", on: "start" },
|
|
144
|
+
{ from: "sending", to: "sent", on: "complete" },
|
|
145
|
+
{ from: "draft", to: "cancelled", on: "cancel" },
|
|
146
|
+
{ from: "scheduled", to: "cancelled", on: "cancel" },
|
|
147
|
+
{ from: "paused", to: "cancelled", on: "cancel" },
|
|
148
|
+
],
|
|
149
|
+
});
|
|
150
|
+
return _campaignFsm;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---- validators ---------------------------------------------------------
|
|
154
|
+
|
|
155
|
+
function _validateSlug(s, label) {
|
|
156
|
+
if (typeof s !== "string" || !s.length) {
|
|
157
|
+
throw new TypeError("emailCampaigns: " + label + " must be a non-empty string");
|
|
158
|
+
}
|
|
159
|
+
if (!SLUG_RE.test(s)) {
|
|
160
|
+
throw new TypeError(
|
|
161
|
+
"emailCampaigns: " + label + " must match /[a-z0-9][a-z0-9._-]*[a-z0-9]/"
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
return s;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function _validateSubject(s) {
|
|
168
|
+
if (typeof s !== "string" || !s.length) {
|
|
169
|
+
throw new TypeError("emailCampaigns: subject must be a non-empty string");
|
|
170
|
+
}
|
|
171
|
+
if (s.length > MAX_SUBJECT_LEN) {
|
|
172
|
+
throw new TypeError("emailCampaigns: subject must be <= " + MAX_SUBJECT_LEN + " characters");
|
|
173
|
+
}
|
|
174
|
+
if (/[\r\n\0]/.test(s)) {
|
|
175
|
+
throw new TypeError("emailCampaigns: subject must not contain CR / LF / NUL");
|
|
176
|
+
}
|
|
177
|
+
return s;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function _validateBody(s, label) {
|
|
181
|
+
if (typeof s !== "string" || !s.length) {
|
|
182
|
+
throw new TypeError("emailCampaigns: " + label + " must be a non-empty string");
|
|
183
|
+
}
|
|
184
|
+
if (s.length > MAX_BODY_LEN) {
|
|
185
|
+
throw new TypeError("emailCampaigns: " + label + " must be <= " + MAX_BODY_LEN + " bytes");
|
|
186
|
+
}
|
|
187
|
+
return s;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function _validateEmail(s, label) {
|
|
191
|
+
if (typeof s !== "string" || !s.length) {
|
|
192
|
+
throw new TypeError("emailCampaigns: " + label + " must be a non-empty string");
|
|
193
|
+
}
|
|
194
|
+
// Compose b.guardEmail.validate so the same strict profile as the
|
|
195
|
+
// transactional `email` primitive gates marketing sender identity.
|
|
196
|
+
var report;
|
|
197
|
+
try {
|
|
198
|
+
report = _b().guardEmail.validate(s, { profile: "strict" });
|
|
199
|
+
} catch (e) {
|
|
200
|
+
throw new TypeError("emailCampaigns: " + label + " — " + (e && e.message || "invalid email"));
|
|
201
|
+
}
|
|
202
|
+
if (!report || report.ok === false) {
|
|
203
|
+
var first = (report && report.issues && report.issues[0]) || {};
|
|
204
|
+
throw new TypeError(
|
|
205
|
+
"emailCampaigns: " + label + " — " + (first.ruleId || first.snippet || "refused at strict profile")
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
return _b().guardEmail.sanitize(s, { profile: "strict" });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function _validateFromName(s) {
|
|
212
|
+
if (typeof s !== "string" || !s.length) {
|
|
213
|
+
throw new TypeError("emailCampaigns: from_name must be a non-empty string");
|
|
214
|
+
}
|
|
215
|
+
if (s.length > MAX_FROM_NAME_LEN) {
|
|
216
|
+
throw new TypeError("emailCampaigns: from_name must be <= " + MAX_FROM_NAME_LEN + " characters");
|
|
217
|
+
}
|
|
218
|
+
if (/[\r\n\0]/.test(s)) {
|
|
219
|
+
throw new TypeError("emailCampaigns: from_name must not contain CR / LF / NUL");
|
|
220
|
+
}
|
|
221
|
+
return s;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function _validateTs(ts, label) {
|
|
225
|
+
if (typeof ts !== "number" || !Number.isInteger(ts) || ts < 0) {
|
|
226
|
+
throw new TypeError(
|
|
227
|
+
"emailCampaigns: " + label + " must be a non-negative integer epoch-ms"
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
return ts;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function _validateEventType(t) {
|
|
234
|
+
if (typeof t !== "string" || EVENT_TYPES.indexOf(t) === -1) {
|
|
235
|
+
throw new TypeError(
|
|
236
|
+
"emailCampaigns: event_type must be one of " + EVENT_TYPES.join(", ")
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
return t;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function _validateRecipientHash(h) {
|
|
243
|
+
if (typeof h !== "string" || !h.length) {
|
|
244
|
+
throw new TypeError("emailCampaigns: recipient_hash must be a non-empty string");
|
|
245
|
+
}
|
|
246
|
+
// Same shape gate as emailSuppressions / mailingAudiences — refuse
|
|
247
|
+
// anything outside the hash alphabet so a hand-crafted hash can't
|
|
248
|
+
// smuggle SQL through a parameter slot. The cap is loose (256 chars)
|
|
249
|
+
// because the underlying hash primitive may evolve; the strict shape
|
|
250
|
+
// is what matters.
|
|
251
|
+
if (h.length > 256 || !/^[A-Za-z0-9_-]+$/.test(h)) {
|
|
252
|
+
throw new TypeError(
|
|
253
|
+
"emailCampaigns: recipient_hash must match /[A-Za-z0-9_-]+/ (<=256 chars)"
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
return h;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function _validateReason(s) {
|
|
260
|
+
if (typeof s !== "string" || !s.length) {
|
|
261
|
+
throw new TypeError("emailCampaigns: reason must be a non-empty string");
|
|
262
|
+
}
|
|
263
|
+
if (s.length > MAX_REASON_LEN) {
|
|
264
|
+
throw new TypeError("emailCampaigns: reason must be <= " + MAX_REASON_LEN + " characters");
|
|
265
|
+
}
|
|
266
|
+
if (/[\r\n\0]/.test(s)) {
|
|
267
|
+
throw new TypeError("emailCampaigns: reason must not contain CR / LF / NUL");
|
|
268
|
+
}
|
|
269
|
+
return s;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function _validateStatus(s, label) {
|
|
273
|
+
if (typeof s !== "string" || STATUSES.indexOf(s) === -1) {
|
|
274
|
+
throw new TypeError(
|
|
275
|
+
"emailCampaigns: " + label + " must be one of " + STATUSES.join(", ")
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
return s;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function _validateBatchSize(n) {
|
|
282
|
+
if (n == null) return DEFAULT_BATCH_SIZE;
|
|
283
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_BATCH_SIZE) {
|
|
284
|
+
throw new TypeError(
|
|
285
|
+
"emailCampaigns: batch_size must be 1..." + MAX_BATCH_SIZE
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
return n;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---- row → public shape -------------------------------------------------
|
|
292
|
+
|
|
293
|
+
function _rowToCampaign(row) {
|
|
294
|
+
if (!row) return null;
|
|
295
|
+
return {
|
|
296
|
+
slug: row.slug,
|
|
297
|
+
subject: row.subject,
|
|
298
|
+
body_html: row.body_html,
|
|
299
|
+
body_text: row.body_text,
|
|
300
|
+
audience_slug: row.audience_slug,
|
|
301
|
+
schedule_at: row.schedule_at == null ? null : Number(row.schedule_at),
|
|
302
|
+
from_address: row.from_address,
|
|
303
|
+
from_name: row.from_name,
|
|
304
|
+
reply_to: row.reply_to,
|
|
305
|
+
status: row.status,
|
|
306
|
+
recipients_resolved_count: row.recipients_resolved_count == null ? null : Number(row.recipients_resolved_count),
|
|
307
|
+
sent_count: row.sent_count == null ? null : Number(row.sent_count),
|
|
308
|
+
sent_at: row.sent_at == null ? null : Number(row.sent_at),
|
|
309
|
+
paused_at: row.paused_at == null ? null : Number(row.paused_at),
|
|
310
|
+
cancelled_at: row.cancelled_at == null ? null : Number(row.cancelled_at),
|
|
311
|
+
cancel_reason: row.cancel_reason,
|
|
312
|
+
created_at: Number(row.created_at),
|
|
313
|
+
updated_at: Number(row.updated_at),
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ---- factory ------------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
function create(opts) {
|
|
320
|
+
opts = opts || {};
|
|
321
|
+
var query = opts.query;
|
|
322
|
+
if (!query) {
|
|
323
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
324
|
+
}
|
|
325
|
+
// Required composition handles — the dispatcher needs the audience
|
|
326
|
+
// resolver to turn a slug into recipient hashes + the mailer to
|
|
327
|
+
// actually drain the per-recipient sends. emailSuppressions is
|
|
328
|
+
// optional because the audience-side resolver typically composes it
|
|
329
|
+
// already; the dispatcher takes a second-line check when it's wired
|
|
330
|
+
// so a suppression that landed BETWEEN audience recompute and
|
|
331
|
+
// dispatch tick still blocks the send.
|
|
332
|
+
if (!opts.mailingAudiences || typeof opts.mailingAudiences.resolve !== "function") {
|
|
333
|
+
throw new TypeError(
|
|
334
|
+
"emailCampaigns.create: opts.mailingAudiences (with .resolve()) is required"
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
if (!opts.email) {
|
|
338
|
+
throw new TypeError("emailCampaigns.create: opts.email is required");
|
|
339
|
+
}
|
|
340
|
+
// The transactional `email` factory exposes `.orderReceipt` /
|
|
341
|
+
// `.shipNotification` / etc.; for marketing sends we need raw access
|
|
342
|
+
// to the underlying mailer. Operators that pass the email factory
|
|
343
|
+
// result get a clear refusal; operators that pass a raw mailer
|
|
344
|
+
// (b.mail.create result) get accepted directly. The contract is "an
|
|
345
|
+
// object with .send(msg)".
|
|
346
|
+
var mailer;
|
|
347
|
+
if (typeof opts.email.send === "function") {
|
|
348
|
+
mailer = opts.email;
|
|
349
|
+
} else if (opts.email._mailer && typeof opts.email._mailer.send === "function") {
|
|
350
|
+
mailer = opts.email._mailer;
|
|
351
|
+
} else {
|
|
352
|
+
throw new TypeError(
|
|
353
|
+
"emailCampaigns.create: opts.email must be a mailer (object with .send()) or expose ._mailer"
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
var audiences = opts.mailingAudiences;
|
|
357
|
+
var suppressions = opts.emailSuppressions || null;
|
|
358
|
+
|
|
359
|
+
// ---- internal helpers (closed over factory state) --------------------
|
|
360
|
+
|
|
361
|
+
async function _getRow(slug) {
|
|
362
|
+
var r = await query(
|
|
363
|
+
"SELECT * FROM email_campaigns WHERE slug = ?1 LIMIT 1",
|
|
364
|
+
[slug],
|
|
365
|
+
);
|
|
366
|
+
return r.rows[0] || null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
// Drive a single FSM event for a campaign row. Rebuilds the FSM at
|
|
370
|
+
// the persisted state, fires the event, returns `{ from, to }`. The
|
|
371
|
+
// caller is responsible for the persistence side-effects that go
|
|
372
|
+
// with the transition (timestamp columns, side composition, etc.).
|
|
373
|
+
async function _fire(row, event) {
|
|
374
|
+
var fsm = _getCampaignFsm();
|
|
375
|
+
var instance = fsm.restore({
|
|
376
|
+
state: row.status,
|
|
377
|
+
history: [],
|
|
378
|
+
context: {},
|
|
379
|
+
});
|
|
380
|
+
try {
|
|
381
|
+
return await instance.transition(event);
|
|
382
|
+
} catch (e) {
|
|
383
|
+
var err = new Error(
|
|
384
|
+
"emailCampaigns: transition '" + event + "' refused from '" +
|
|
385
|
+
row.status + "' — " + (e && e.message || e)
|
|
386
|
+
);
|
|
387
|
+
err.code = (e && e.code) || "EMAIL_CAMPAIGN_TRANSITION_REFUSED";
|
|
388
|
+
err.cause = e;
|
|
389
|
+
throw err;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// Drain the recipient set for a campaign. Pages through the
|
|
394
|
+
// audience resolver, applies the second-line suppression check
|
|
395
|
+
// (if wired), invokes the mailer per recipient, records a
|
|
396
|
+
// `delivered` event per successful send. Returns the actual sent
|
|
397
|
+
// count + resolved count.
|
|
398
|
+
async function _drainSend(row) {
|
|
399
|
+
var resolvedTotal = 0;
|
|
400
|
+
var sentTotal = 0;
|
|
401
|
+
var cursor = null;
|
|
402
|
+
// Per-page loop — bounded by the audience's MAX_LIST_LIMIT
|
|
403
|
+
// (500). A 100k-member audience walks 200 pages; each page makes
|
|
404
|
+
// O(page) mailer.send calls. Operators that need throttling /
|
|
405
|
+
// backoff wrap the injected mailer.
|
|
406
|
+
while (true) {
|
|
407
|
+
var page = await audiences.resolve({
|
|
408
|
+
slug: row.audience_slug,
|
|
409
|
+
limit: RESOLVE_PAGE_LIMIT,
|
|
410
|
+
cursor: cursor,
|
|
411
|
+
include_plaintext: false,
|
|
412
|
+
});
|
|
413
|
+
resolvedTotal += page.emails_hashed.length;
|
|
414
|
+
for (var i = 0; i < page.emails_hashed.length; i += 1) {
|
|
415
|
+
var hash = page.emails_hashed[i];
|
|
416
|
+
// Second-line suppression check — only fires when the
|
|
417
|
+
// operator wired the suppressions handle into the campaigns
|
|
418
|
+
// factory. The audience factory's own suppression check is
|
|
419
|
+
// the first line; this catches additions that landed in the
|
|
420
|
+
// interval. The check needs the plaintext address to hit the
|
|
421
|
+
// hash lookup; without plaintext we trust the audience side.
|
|
422
|
+
if (suppressions && typeof suppressions.byHash === "function") {
|
|
423
|
+
try {
|
|
424
|
+
var ssRow = await suppressions.byHash(hash);
|
|
425
|
+
// byHash refuses non-hex-128 shapes; fall back silently
|
|
426
|
+
// (drop-silent — operator-visible suppression mismatch is
|
|
427
|
+
// a sink-side issue, not a per-recipient hard fail).
|
|
428
|
+
if (ssRow && (ssRow.scope === "all" || ssRow.scope === "marketing")) {
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
} catch (_eHash) { /* drop-silent — second-line only */ }
|
|
432
|
+
}
|
|
433
|
+
try {
|
|
434
|
+
await mailer.send({
|
|
435
|
+
to: hash, // hash is the addressable id; the mailer translates
|
|
436
|
+
subject: row.subject,
|
|
437
|
+
html: row.body_html,
|
|
438
|
+
text: row.body_text,
|
|
439
|
+
from: row.from_address,
|
|
440
|
+
from_name: row.from_name,
|
|
441
|
+
replyTo: row.reply_to || undefined,
|
|
442
|
+
});
|
|
443
|
+
} catch (_eSend) {
|
|
444
|
+
// drop-silent — the ESP webhook backfills `bounced` /
|
|
445
|
+
// `unsubscribed` rows so the per-recipient failure surfaces
|
|
446
|
+
// in metrics. Throwing here would stall the whole campaign
|
|
447
|
+
// on the first bad address.
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
sentTotal += 1;
|
|
451
|
+
var deliveredAt = Date.now();
|
|
452
|
+
await query(
|
|
453
|
+
"INSERT INTO email_campaign_events " +
|
|
454
|
+
"(id, campaign_slug, recipient_hash, event_type, occurred_at) " +
|
|
455
|
+
"VALUES (?1, ?2, ?3, 'delivered', ?4)",
|
|
456
|
+
[_b().uuid.v7(), row.slug, hash, deliveredAt],
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
if (!page.next_cursor) break;
|
|
460
|
+
cursor = page.next_cursor;
|
|
461
|
+
}
|
|
462
|
+
return { resolved_count: resolvedTotal, sent_count: sentTotal };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
return {
|
|
466
|
+
STATUSES: STATUSES,
|
|
467
|
+
EVENT_TYPES: EVENT_TYPES,
|
|
468
|
+
TERMINAL: TERMINAL,
|
|
469
|
+
|
|
470
|
+
// Define (or upsert) a campaign in `draft`. Re-defining an
|
|
471
|
+
// existing slug rewrites the body / sender / audience and bumps
|
|
472
|
+
// `updated_at`; the campaign returns to `draft` only if it
|
|
473
|
+
// hasn't been scheduled (operators editing a `scheduled` /
|
|
474
|
+
// `sending` / `sent` campaign get refused — they archive +
|
|
475
|
+
// re-define under a new slug). `schedule_at` is optional; when
|
|
476
|
+
// supplied with a non-cancelled campaign, the row lands directly
|
|
477
|
+
// in `scheduled`.
|
|
478
|
+
defineCampaign: async function (input) {
|
|
479
|
+
if (!input || typeof input !== "object") {
|
|
480
|
+
throw new TypeError("emailCampaigns.defineCampaign: input object required");
|
|
481
|
+
}
|
|
482
|
+
var slug = _validateSlug(input.slug, "slug");
|
|
483
|
+
var subject = _validateSubject(input.subject);
|
|
484
|
+
var bodyHtml = _validateBody(input.body_html, "body_html");
|
|
485
|
+
var bodyText = _validateBody(input.body_text, "body_text");
|
|
486
|
+
var audienceSlug = _validateSlug(input.audience_slug, "audience_slug");
|
|
487
|
+
var fromAddress = _validateEmail(input.from_address, "from_address");
|
|
488
|
+
var fromName = _validateFromName(input.from_name);
|
|
489
|
+
var replyTo = null;
|
|
490
|
+
if (input.reply_to != null) replyTo = _validateEmail(input.reply_to, "reply_to");
|
|
491
|
+
var scheduleAt = null;
|
|
492
|
+
if (input.schedule_at != null) scheduleAt = _validateTs(input.schedule_at, "schedule_at");
|
|
493
|
+
|
|
494
|
+
var now = Date.now();
|
|
495
|
+
var existing = await _getRow(slug);
|
|
496
|
+
|
|
497
|
+
// Re-defining a scheduled / sending / sent / paused campaign
|
|
498
|
+
// is refused — operators edit pre-schedule, then schedule.
|
|
499
|
+
// Cancelled campaigns refuse too; the operator picks a new slug.
|
|
500
|
+
if (existing) {
|
|
501
|
+
if (existing.status !== "draft") {
|
|
502
|
+
throw new TypeError(
|
|
503
|
+
"emailCampaigns.defineCampaign: campaign '" + slug +
|
|
504
|
+
"' is in status '" + existing.status + "' — cannot redefine"
|
|
505
|
+
);
|
|
506
|
+
}
|
|
507
|
+
var statusOnUpdate = scheduleAt != null ? "scheduled" : "draft";
|
|
508
|
+
await query(
|
|
509
|
+
"UPDATE email_campaigns SET " +
|
|
510
|
+
"subject = ?1, body_html = ?2, body_text = ?3, audience_slug = ?4, " +
|
|
511
|
+
"schedule_at = ?5, from_address = ?6, from_name = ?7, reply_to = ?8, " +
|
|
512
|
+
"status = ?9, updated_at = ?10 " +
|
|
513
|
+
"WHERE slug = ?11",
|
|
514
|
+
[
|
|
515
|
+
subject, bodyHtml, bodyText, audienceSlug, scheduleAt,
|
|
516
|
+
fromAddress, fromName, replyTo, statusOnUpdate, now, slug,
|
|
517
|
+
],
|
|
518
|
+
);
|
|
519
|
+
return _rowToCampaign(await _getRow(slug));
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
var status = scheduleAt != null ? "scheduled" : "draft";
|
|
523
|
+
await query(
|
|
524
|
+
"INSERT INTO email_campaigns " +
|
|
525
|
+
"(slug, subject, body_html, body_text, audience_slug, schedule_at, " +
|
|
526
|
+
"from_address, from_name, reply_to, status, " +
|
|
527
|
+
"recipients_resolved_count, sent_count, sent_at, paused_at, " +
|
|
528
|
+
"cancelled_at, cancel_reason, created_at, updated_at) " +
|
|
529
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, NULL, NULL, NULL, NULL, NULL, NULL, ?11, ?11)",
|
|
530
|
+
[
|
|
531
|
+
slug, subject, bodyHtml, bodyText, audienceSlug, scheduleAt,
|
|
532
|
+
fromAddress, fromName, replyTo, status, now,
|
|
533
|
+
],
|
|
534
|
+
);
|
|
535
|
+
return _rowToCampaign(await _getRow(slug));
|
|
536
|
+
},
|
|
537
|
+
|
|
538
|
+
// Operator-driven schedule transition. Drafts move to `scheduled`
|
|
539
|
+
// with the supplied `schedule_at`; already-scheduled campaigns
|
|
540
|
+
// re-stamp `schedule_at` so the operator can shift the window
|
|
541
|
+
// without re-defining.
|
|
542
|
+
scheduleCampaign: async function (input) {
|
|
543
|
+
if (!input || typeof input !== "object") {
|
|
544
|
+
throw new TypeError("emailCampaigns.scheduleCampaign: input object required");
|
|
545
|
+
}
|
|
546
|
+
var slug = _validateSlug(input.slug, "slug");
|
|
547
|
+
var scheduleAt = _validateTs(input.schedule_at, "schedule_at");
|
|
548
|
+
var row = await _getRow(slug);
|
|
549
|
+
if (!row) {
|
|
550
|
+
throw new TypeError("emailCampaigns.scheduleCampaign: campaign '" + slug + "' not found");
|
|
551
|
+
}
|
|
552
|
+
if (row.status === "draft") {
|
|
553
|
+
await _fire(row, "schedule");
|
|
554
|
+
} else if (row.status !== "scheduled") {
|
|
555
|
+
throw new TypeError(
|
|
556
|
+
"emailCampaigns.scheduleCampaign: campaign '" + slug +
|
|
557
|
+
"' is in status '" + row.status + "' — cannot schedule"
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
var now = Date.now();
|
|
561
|
+
await query(
|
|
562
|
+
"UPDATE email_campaigns SET status = 'scheduled', schedule_at = ?1, updated_at = ?2 " +
|
|
563
|
+
"WHERE slug = ?3",
|
|
564
|
+
[scheduleAt, now, slug],
|
|
565
|
+
);
|
|
566
|
+
return _rowToCampaign(await _getRow(slug));
|
|
567
|
+
},
|
|
568
|
+
|
|
569
|
+
// Pause a scheduled campaign — the dispatcher walk skips paused
|
|
570
|
+
// rows because the predicate is `status = 'scheduled'`. Refuses
|
|
571
|
+
// non-scheduled rows so the operator knows pausing a draft / sent
|
|
572
|
+
// campaign is not the right verb.
|
|
573
|
+
pauseCampaign: async function (slug) {
|
|
574
|
+
_validateSlug(slug, "slug");
|
|
575
|
+
var row = await _getRow(slug);
|
|
576
|
+
if (!row) {
|
|
577
|
+
throw new TypeError("emailCampaigns.pauseCampaign: campaign '" + slug + "' not found");
|
|
578
|
+
}
|
|
579
|
+
await _fire(row, "pause");
|
|
580
|
+
var now = Date.now();
|
|
581
|
+
await query(
|
|
582
|
+
"UPDATE email_campaigns SET status = 'paused', paused_at = ?1, updated_at = ?1 " +
|
|
583
|
+
"WHERE slug = ?2",
|
|
584
|
+
[now, slug],
|
|
585
|
+
);
|
|
586
|
+
return _rowToCampaign(await _getRow(slug));
|
|
587
|
+
},
|
|
588
|
+
|
|
589
|
+
resumeCampaign: async function (slug) {
|
|
590
|
+
_validateSlug(slug, "slug");
|
|
591
|
+
var row = await _getRow(slug);
|
|
592
|
+
if (!row) {
|
|
593
|
+
throw new TypeError("emailCampaigns.resumeCampaign: campaign '" + slug + "' not found");
|
|
594
|
+
}
|
|
595
|
+
await _fire(row, "resume");
|
|
596
|
+
var now = Date.now();
|
|
597
|
+
await query(
|
|
598
|
+
"UPDATE email_campaigns SET status = 'scheduled', paused_at = NULL, updated_at = ?1 " +
|
|
599
|
+
"WHERE slug = ?2",
|
|
600
|
+
[now, slug],
|
|
601
|
+
);
|
|
602
|
+
return _rowToCampaign(await _getRow(slug));
|
|
603
|
+
},
|
|
604
|
+
|
|
605
|
+
// Cancel — terminal. `reason` is mandatory so the operator can't
|
|
606
|
+
// accidentally cancel without recording why (the dashboard surfaces
|
|
607
|
+
// `cancel_reason` so the next reviewer understands the intent).
|
|
608
|
+
cancelCampaign: async function (slug, reason) {
|
|
609
|
+
_validateSlug(slug, "slug");
|
|
610
|
+
_validateReason(reason);
|
|
611
|
+
var row = await _getRow(slug);
|
|
612
|
+
if (!row) {
|
|
613
|
+
throw new TypeError("emailCampaigns.cancelCampaign: campaign '" + slug + "' not found");
|
|
614
|
+
}
|
|
615
|
+
await _fire(row, "cancel");
|
|
616
|
+
var now = Date.now();
|
|
617
|
+
await query(
|
|
618
|
+
"UPDATE email_campaigns SET status = 'cancelled', cancelled_at = ?1, " +
|
|
619
|
+
"cancel_reason = ?2, updated_at = ?1 WHERE slug = ?3",
|
|
620
|
+
[now, reason, slug],
|
|
621
|
+
);
|
|
622
|
+
return _rowToCampaign(await _getRow(slug));
|
|
623
|
+
},
|
|
624
|
+
|
|
625
|
+
// Manual fire — bypass the scheduler. A draft moves through
|
|
626
|
+
// scheduled → sending → sent in one call; an already-scheduled
|
|
627
|
+
// campaign starts immediately. The drain walks the audience +
|
|
628
|
+
// mailer per the dispatchTick path.
|
|
629
|
+
sendNow: async function (slug) {
|
|
630
|
+
_validateSlug(slug, "slug");
|
|
631
|
+
var row = await _getRow(slug);
|
|
632
|
+
if (!row) {
|
|
633
|
+
throw new TypeError("emailCampaigns.sendNow: campaign '" + slug + "' not found");
|
|
634
|
+
}
|
|
635
|
+
// Drafts auto-schedule into the present so the FSM has a
|
|
636
|
+
// legal scheduled → sending hop. The `schedule_at` reflects
|
|
637
|
+
// the manual-fire moment.
|
|
638
|
+
var now = Date.now();
|
|
639
|
+
if (row.status === "draft") {
|
|
640
|
+
await _fire(row, "schedule");
|
|
641
|
+
await query(
|
|
642
|
+
"UPDATE email_campaigns SET status = 'scheduled', schedule_at = ?1, updated_at = ?1 " +
|
|
643
|
+
"WHERE slug = ?2",
|
|
644
|
+
[now, slug],
|
|
645
|
+
);
|
|
646
|
+
row = await _getRow(slug);
|
|
647
|
+
}
|
|
648
|
+
if (row.status !== "scheduled") {
|
|
649
|
+
throw new TypeError(
|
|
650
|
+
"emailCampaigns.sendNow: campaign '" + slug + "' is in status '" +
|
|
651
|
+
row.status + "' — cannot send"
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
await _fire(row, "start");
|
|
655
|
+
await query(
|
|
656
|
+
"UPDATE email_campaigns SET status = 'sending', updated_at = ?1 WHERE slug = ?2",
|
|
657
|
+
[Date.now(), slug],
|
|
658
|
+
);
|
|
659
|
+
var stats = await _drainSend(row);
|
|
660
|
+
// Re-fire complete off the freshly-read row so the FSM sees the
|
|
661
|
+
// 'sending' state we just wrote.
|
|
662
|
+
var midRow = await _getRow(slug);
|
|
663
|
+
await _fire(midRow, "complete");
|
|
664
|
+
var sentAt = Date.now();
|
|
665
|
+
await query(
|
|
666
|
+
"UPDATE email_campaigns SET status = 'sent', " +
|
|
667
|
+
"recipients_resolved_count = ?1, sent_count = ?2, sent_at = ?3, updated_at = ?3 " +
|
|
668
|
+
"WHERE slug = ?4",
|
|
669
|
+
[stats.resolved_count, stats.sent_count, sentAt, slug],
|
|
670
|
+
);
|
|
671
|
+
return _rowToCampaign(await _getRow(slug));
|
|
672
|
+
},
|
|
673
|
+
|
|
674
|
+
// Scheduler tick. Walks `WHERE status = 'scheduled' AND
|
|
675
|
+
// schedule_at <= now` (NULL `schedule_at` rows are never picked
|
|
676
|
+
// up — those landed via sendNow / pause / draft). Drains each
|
|
677
|
+
// matching campaign in turn; `batch_size` caps how many campaigns
|
|
678
|
+
// a single tick will process so a backlog doesn't block the
|
|
679
|
+
// worker indefinitely.
|
|
680
|
+
dispatchTick: async function (input) {
|
|
681
|
+
input = input || {};
|
|
682
|
+
var now = input.now == null ? Date.now() : _validateTs(input.now, "now");
|
|
683
|
+
var batchSize = _validateBatchSize(input.batch_size);
|
|
684
|
+
|
|
685
|
+
var due = await query(
|
|
686
|
+
"SELECT * FROM email_campaigns " +
|
|
687
|
+
"WHERE status = 'scheduled' AND schedule_at IS NOT NULL AND schedule_at <= ?1 " +
|
|
688
|
+
"ORDER BY schedule_at ASC, slug ASC LIMIT ?2",
|
|
689
|
+
[now, batchSize],
|
|
690
|
+
);
|
|
691
|
+
var dispatched = [];
|
|
692
|
+
for (var i = 0; i < due.rows.length; i += 1) {
|
|
693
|
+
var row = due.rows[i];
|
|
694
|
+
try {
|
|
695
|
+
await _fire(row, "start");
|
|
696
|
+
} catch (_eStart) {
|
|
697
|
+
// Concurrent ticker / status race — another worker already
|
|
698
|
+
// picked it up. Skip; the row is no longer scheduled.
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
await query(
|
|
702
|
+
"UPDATE email_campaigns SET status = 'sending', updated_at = ?1 WHERE slug = ?2",
|
|
703
|
+
[Date.now(), row.slug],
|
|
704
|
+
);
|
|
705
|
+
var stats = await _drainSend(row);
|
|
706
|
+
var midRow = await _getRow(row.slug);
|
|
707
|
+
await _fire(midRow, "complete");
|
|
708
|
+
var sentAt = Date.now();
|
|
709
|
+
await query(
|
|
710
|
+
"UPDATE email_campaigns SET status = 'sent', " +
|
|
711
|
+
"recipients_resolved_count = ?1, sent_count = ?2, sent_at = ?3, updated_at = ?3 " +
|
|
712
|
+
"WHERE slug = ?4",
|
|
713
|
+
[stats.resolved_count, stats.sent_count, sentAt, row.slug],
|
|
714
|
+
);
|
|
715
|
+
dispatched.push({
|
|
716
|
+
slug: row.slug,
|
|
717
|
+
resolved_count: stats.resolved_count,
|
|
718
|
+
sent_count: stats.sent_count,
|
|
719
|
+
sent_at: sentAt,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
return { dispatched: dispatched, now: now };
|
|
723
|
+
},
|
|
724
|
+
|
|
725
|
+
// Record a per-recipient event. `delivered` is normally stamped
|
|
726
|
+
// by the dispatcher; this entry point is operators routing ESP
|
|
727
|
+
// webhook callbacks (`opened` / `clicked` / `bounced` /
|
|
728
|
+
// `unsubscribed`) — and they're free to record `delivered` too
|
|
729
|
+
// when the ESP is the source of truth.
|
|
730
|
+
recordEvent: async function (input) {
|
|
731
|
+
if (!input || typeof input !== "object") {
|
|
732
|
+
throw new TypeError("emailCampaigns.recordEvent: input object required");
|
|
733
|
+
}
|
|
734
|
+
var campaignSlug = _validateSlug(input.campaign_slug, "campaign_slug");
|
|
735
|
+
var recipient = _validateRecipientHash(input.recipient_hash);
|
|
736
|
+
var eventType = _validateEventType(input.event_type);
|
|
737
|
+
var occurredAt;
|
|
738
|
+
if (input.occurred_at == null) {
|
|
739
|
+
occurredAt = Date.now();
|
|
740
|
+
} else {
|
|
741
|
+
occurredAt = _validateTs(input.occurred_at, "occurred_at");
|
|
742
|
+
}
|
|
743
|
+
// Campaign existence is a soft check — the row records what the
|
|
744
|
+
// operator received from the ESP, even if a typo'd slug landed
|
|
745
|
+
// for a campaign that doesn't exist. The dashboard surfaces
|
|
746
|
+
// unmatched events for operator review.
|
|
747
|
+
var id = _b().uuid.v7();
|
|
748
|
+
await query(
|
|
749
|
+
"INSERT INTO email_campaign_events " +
|
|
750
|
+
"(id, campaign_slug, recipient_hash, event_type, occurred_at) " +
|
|
751
|
+
"VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
752
|
+
[id, campaignSlug, recipient, eventType, occurredAt],
|
|
753
|
+
);
|
|
754
|
+
return {
|
|
755
|
+
id: id,
|
|
756
|
+
campaign_slug: campaignSlug,
|
|
757
|
+
recipient_hash: recipient,
|
|
758
|
+
event_type: eventType,
|
|
759
|
+
occurred_at: occurredAt,
|
|
760
|
+
};
|
|
761
|
+
},
|
|
762
|
+
|
|
763
|
+
getCampaign: async function (slug) {
|
|
764
|
+
_validateSlug(slug, "slug");
|
|
765
|
+
var row = await _getRow(slug);
|
|
766
|
+
return _rowToCampaign(row);
|
|
767
|
+
},
|
|
768
|
+
|
|
769
|
+
listCampaigns: async function (listOpts) {
|
|
770
|
+
listOpts = listOpts || {};
|
|
771
|
+
var status = null;
|
|
772
|
+
if (listOpts.status != null) status = _validateStatus(listOpts.status, "status");
|
|
773
|
+
var sql = "SELECT * FROM email_campaigns";
|
|
774
|
+
var params = [];
|
|
775
|
+
if (status) {
|
|
776
|
+
sql += " WHERE status = ?1";
|
|
777
|
+
params.push(status);
|
|
778
|
+
}
|
|
779
|
+
sql += " ORDER BY created_at DESC, slug DESC";
|
|
780
|
+
var rows = (await query(sql, params)).rows;
|
|
781
|
+
var out = [];
|
|
782
|
+
for (var i = 0; i < rows.length; i += 1) out.push(_rowToCampaign(rows[i]));
|
|
783
|
+
return out;
|
|
784
|
+
},
|
|
785
|
+
|
|
786
|
+
// Aggregate per-campaign engagement. Reads the event ledger
|
|
787
|
+
// grouped by event_type; the rate denominators are the
|
|
788
|
+
// `delivered` count (industry-standard for open / click rates) +
|
|
789
|
+
// the `sent_count` snapshot on the campaign row (for the
|
|
790
|
+
// delivery rate against the resolved audience). Returns zeros
|
|
791
|
+
// for every event_type the campaign hasn't seen — easier to
|
|
792
|
+
// render a "0 opens" tile than handle a missing key in the UI.
|
|
793
|
+
metricsForCampaign: async function (slug) {
|
|
794
|
+
_validateSlug(slug, "slug");
|
|
795
|
+
var row = await _getRow(slug);
|
|
796
|
+
if (!row) {
|
|
797
|
+
throw new TypeError("emailCampaigns.metricsForCampaign: campaign '" + slug + "' not found");
|
|
798
|
+
}
|
|
799
|
+
var rows = (await query(
|
|
800
|
+
"SELECT event_type, COUNT(*) AS n FROM email_campaign_events " +
|
|
801
|
+
"WHERE campaign_slug = ?1 GROUP BY event_type",
|
|
802
|
+
[slug],
|
|
803
|
+
)).rows;
|
|
804
|
+
var counts = {};
|
|
805
|
+
for (var i = 0; i < EVENT_TYPES.length; i += 1) counts[EVENT_TYPES[i]] = 0;
|
|
806
|
+
for (var j = 0; j < rows.length; j += 1) {
|
|
807
|
+
counts[rows[j].event_type] = Number(rows[j].n || 0);
|
|
808
|
+
}
|
|
809
|
+
var delivered = counts.delivered;
|
|
810
|
+
// Rate math — guard against div-by-zero so a not-yet-delivered
|
|
811
|
+
// campaign renders 0 instead of NaN. The `delivery_rate` is the
|
|
812
|
+
// count of delivered events over the resolved audience size;
|
|
813
|
+
// the engagement rates (open / click / bounce / unsubscribe)
|
|
814
|
+
// are over delivered.
|
|
815
|
+
function _rate(n, d) { return d > 0 ? n / d : 0; }
|
|
816
|
+
var resolved = row.recipients_resolved_count == null
|
|
817
|
+
? 0 : Number(row.recipients_resolved_count);
|
|
818
|
+
return {
|
|
819
|
+
slug: slug,
|
|
820
|
+
status: row.status,
|
|
821
|
+
resolved_count: resolved,
|
|
822
|
+
sent_count: row.sent_count == null ? 0 : Number(row.sent_count),
|
|
823
|
+
counts: counts,
|
|
824
|
+
delivered: delivered,
|
|
825
|
+
opened: counts.opened,
|
|
826
|
+
clicked: counts.clicked,
|
|
827
|
+
bounced: counts.bounced,
|
|
828
|
+
unsubscribed: counts.unsubscribed,
|
|
829
|
+
delivery_rate: _rate(delivered, resolved),
|
|
830
|
+
open_rate: _rate(counts.opened, delivered),
|
|
831
|
+
click_rate: _rate(counts.clicked, delivered),
|
|
832
|
+
bounce_rate: _rate(counts.bounced, delivered),
|
|
833
|
+
unsubscribe_rate: _rate(counts.unsubscribed, delivered),
|
|
834
|
+
};
|
|
835
|
+
},
|
|
836
|
+
};
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
module.exports = {
|
|
840
|
+
create: create,
|
|
841
|
+
STATUSES: STATUSES,
|
|
842
|
+
EVENT_TYPES: EVENT_TYPES,
|
|
843
|
+
TERMINAL: TERMINAL,
|
|
844
|
+
};
|