@blamejs/blamejs-shop 0.0.57 → 0.0.59
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 +4 -0
- package/lib/affiliates.js +1025 -0
- package/lib/collections.js +916 -0
- package/lib/customer-segments.js +817 -0
- package/lib/gift-options.js +596 -0
- package/lib/index.js +16 -0
- package/lib/mailing-audiences.js +855 -0
- package/lib/order-timeline.js +1073 -0
- package/lib/promo-banners.js +726 -0
- package/lib/quantity-discounts.js +781 -0
- package/lib/recently-viewed.js +511 -0
- package/lib/return-labels.js +477 -0
- package/lib/sales-reports.js +843 -0
- package/lib/search-synonyms.js +792 -0
- package/lib/shipping-labels.js +603 -0
- package/lib/stock-alerts.js +563 -0
- package/lib/subscription-controls.js +723 -0
- package/lib/support-tickets.js +898 -0
- package/package.json +1 -1
|
@@ -0,0 +1,855 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.mailingAudiences
|
|
4
|
+
* @title Mailing audiences — operator-defined newsletter segmentation
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Operators broadcast release notes, abandoned-cart nudges, and
|
|
8
|
+
* review-request prompts to slices of the `newsletter_signups`
|
|
9
|
+
* table. This primitive owns the definition + resolution of those
|
|
10
|
+
* slices ("audiences"). Each audience is a named rule set
|
|
11
|
+
* (subscribed-at age, source membership, tag filters, country,
|
|
12
|
+
* language, customer status, double-opt-in confirmation) the
|
|
13
|
+
* operator authors once; the broadcast pipeline resolves the
|
|
14
|
+
* audience to a recipient list per campaign.
|
|
15
|
+
*
|
|
16
|
+
* Composition:
|
|
17
|
+
* var aud = bShop.mailingAudiences.create({
|
|
18
|
+
* query: q,
|
|
19
|
+
* newsletter: bShop.newsletter.create({ query: q }),
|
|
20
|
+
* emailSuppressions: bShop.emailSuppressions.create({ query: q }),
|
|
21
|
+
* });
|
|
22
|
+
* await aud.defineAudience({
|
|
23
|
+
* slug: "release-watchers-eu",
|
|
24
|
+
* title: "Release watchers — EU/UK",
|
|
25
|
+
* rules: {
|
|
26
|
+
* subscribed_after: Date.now() - 365 * 86400000,
|
|
27
|
+
* double_opt_in: true,
|
|
28
|
+
* country_in: ["DE", "FR", "GB", "NL"],
|
|
29
|
+
* tag_any: ["release-notes", "security"],
|
|
30
|
+
* },
|
|
31
|
+
* });
|
|
32
|
+
* var page = await aud.resolve({ slug: "release-watchers-eu", limit: 100 });
|
|
33
|
+
* // page.emails_hashed — always populated; hashes only.
|
|
34
|
+
* // page.emails_normalised — empty unless include_plaintext.
|
|
35
|
+
* // page.next_cursor — HMAC-tagged page cursor.
|
|
36
|
+
*
|
|
37
|
+
* Suppression composition:
|
|
38
|
+
* When `opts.emailSuppressions` is supplied and `resolve` is
|
|
39
|
+
* called with `skip_suppressed: true` (the default), every
|
|
40
|
+
* candidate's `email_hash` is checked against the suppression
|
|
41
|
+
* table under the `marketing` scope. Hits drop out of the page +
|
|
42
|
+
* bump the `suppressed_count` the caller reports back through
|
|
43
|
+
* `auditDelivery`. Without an injected suppressions handle the
|
|
44
|
+
* filter is a no-op and the page returns the raw membership.
|
|
45
|
+
*
|
|
46
|
+
* Plaintext disclosure:
|
|
47
|
+
* `resolve` returns `emails_hashed` always — the primary key the
|
|
48
|
+
* downstream send pipeline uses to dedupe + correlate with the
|
|
49
|
+
* suppressions / consent / per-recipient tracking tables.
|
|
50
|
+
* `emails_normalised` is the plaintext recipient list and is
|
|
51
|
+
* gated behind `include_plaintext: true` on the resolve call.
|
|
52
|
+
* The operator wires that flag through their own admin auth
|
|
53
|
+
* gate (verifier role check, MFA-stepped-up session, etc.); this
|
|
54
|
+
* primitive surfaces the toggle but doesn't enforce the policy
|
|
55
|
+
* — the verifier check lives at the route layer that calls in.
|
|
56
|
+
*
|
|
57
|
+
* Cache freshness:
|
|
58
|
+
* `recompute()` walks every non-archived audience, re-evaluates
|
|
59
|
+
* its rules against the current `newsletter_signups` snapshot,
|
|
60
|
+
* and replaces the per-audience rows in
|
|
61
|
+
* `mailing_audience_membership_cache`. The scheduler runs this on
|
|
62
|
+
* a cron cadence the operator picks; `resolve` reads off the
|
|
63
|
+
* cache so a 100k-signup list paginates in O(page) rather than
|
|
64
|
+
* O(signups). The trade-off is operator-visible staleness — a
|
|
65
|
+
* signup that lands between recomputes won't appear in the next
|
|
66
|
+
* campaign send. Operators that need stricter freshness call
|
|
67
|
+
* `recompute()` synchronously before each send.
|
|
68
|
+
*
|
|
69
|
+
* Audit ledger:
|
|
70
|
+
* `auditDelivery({ slug, campaign_id, sent_count, suppressed_count })`
|
|
71
|
+
* writes a row per send. The compliance export reads
|
|
72
|
+
* `mailing_audience_deliveries` to demonstrate per-audience send
|
|
73
|
+
* volume + suppression honoring over a reporting window.
|
|
74
|
+
*
|
|
75
|
+
* @primitive mailingAudiences
|
|
76
|
+
* @related shop.newsletter, shop.emailSuppressions, b.pagination
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
// ---- constants ----------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
var SLUG_RE = /^[a-z0-9][a-z0-9._-]{0,62}[a-z0-9]$|^[a-z0-9]$/;
|
|
82
|
+
var TAG_RE = /^[a-z0-9][a-z0-9._-]{0,62}[a-z0-9]$|^[a-z0-9]$/;
|
|
83
|
+
var COUNTRY_RE = /^[A-Z]{2}$/;
|
|
84
|
+
var LANGUAGE_RE = /^[a-z]{2}(-[A-Z]{2})?$/;
|
|
85
|
+
var CUSTOMER_STATUS_RE = /^[a-z][a-z0-9_-]{0,31}$/;
|
|
86
|
+
var SOURCE_RE = /^[a-z0-9][a-z0-9._-]{0,62}[a-z0-9]$|^[a-z0-9]$/;
|
|
87
|
+
var MAX_TITLE_LEN = 200;
|
|
88
|
+
var MAX_RULE_LIST_LEN = 64;
|
|
89
|
+
var MAX_LIST_LIMIT = 500;
|
|
90
|
+
var DEFAULT_LIST_LIMIT = 100;
|
|
91
|
+
var LIST_ORDER_KEY = ["signup_id:asc"];
|
|
92
|
+
var KNOWN_RULE_KEYS = [
|
|
93
|
+
"subscribed_after",
|
|
94
|
+
"subscribed_before",
|
|
95
|
+
"source_in",
|
|
96
|
+
"tag_any",
|
|
97
|
+
"tag_all",
|
|
98
|
+
"country_in",
|
|
99
|
+
"double_opt_in",
|
|
100
|
+
"language_in",
|
|
101
|
+
"customer_status_in",
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
var bShop;
|
|
105
|
+
function _b() {
|
|
106
|
+
if (!bShop) bShop = require("./index");
|
|
107
|
+
return bShop.framework;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ---- validators ---------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
function _validateSlug(s) {
|
|
113
|
+
if (typeof s !== "string" || !s.length) {
|
|
114
|
+
throw new TypeError("mailingAudiences: slug must be a non-empty string");
|
|
115
|
+
}
|
|
116
|
+
if (!SLUG_RE.test(s)) {
|
|
117
|
+
throw new TypeError(
|
|
118
|
+
"mailingAudiences: slug must match /[a-z0-9][a-z0-9._-]*[a-z0-9]/"
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
return s;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function _validateTitle(s) {
|
|
125
|
+
if (typeof s !== "string" || !s.length) {
|
|
126
|
+
throw new TypeError("mailingAudiences: title must be a non-empty string");
|
|
127
|
+
}
|
|
128
|
+
if (s.length > MAX_TITLE_LEN) {
|
|
129
|
+
throw new TypeError(
|
|
130
|
+
"mailingAudiences: title must be <= " + MAX_TITLE_LEN + " chars"
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
if (/[\r\n\0]/.test(s)) {
|
|
134
|
+
throw new TypeError("mailingAudiences: title must not contain CR / LF / NUL");
|
|
135
|
+
}
|
|
136
|
+
return s;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function _validateTs(ts, label) {
|
|
140
|
+
if (typeof ts !== "number" || !Number.isInteger(ts) || ts < 0) {
|
|
141
|
+
throw new TypeError(
|
|
142
|
+
"mailingAudiences: " + label + " must be a non-negative integer epoch-ms"
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return ts;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function _validateStringList(arr, label, pattern) {
|
|
149
|
+
if (!Array.isArray(arr)) {
|
|
150
|
+
throw new TypeError(
|
|
151
|
+
"mailingAudiences: " + label + " must be an array of strings"
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (arr.length === 0) {
|
|
155
|
+
throw new TypeError(
|
|
156
|
+
"mailingAudiences: " + label + " must be a non-empty array"
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
if (arr.length > MAX_RULE_LIST_LEN) {
|
|
160
|
+
throw new TypeError(
|
|
161
|
+
"mailingAudiences: " + label + " must have <= " +
|
|
162
|
+
MAX_RULE_LIST_LEN + " entries"
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
var seen = Object.create(null);
|
|
166
|
+
var out = [];
|
|
167
|
+
for (var i = 0; i < arr.length; i += 1) {
|
|
168
|
+
var v = arr[i];
|
|
169
|
+
if (typeof v !== "string" || !v.length) {
|
|
170
|
+
throw new TypeError(
|
|
171
|
+
"mailingAudiences: " + label + "[" + i + "] must be a non-empty string"
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
if (!pattern.test(v)) {
|
|
175
|
+
throw new TypeError(
|
|
176
|
+
"mailingAudiences: " + label + "[" + i + "] does not match " + pattern.source
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
if (seen[v]) continue;
|
|
180
|
+
seen[v] = true;
|
|
181
|
+
out.push(v);
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Each known rule key is validated against its shape. Unknown keys
|
|
187
|
+
// are refused outright — operators should never silently lose a
|
|
188
|
+
// predicate to a typo, and a forward-compat rule key lands with an
|
|
189
|
+
// explicit primitive update (not a silent acceptance).
|
|
190
|
+
function _validateRules(rules) {
|
|
191
|
+
if (rules == null || typeof rules !== "object" || Array.isArray(rules)) {
|
|
192
|
+
throw new TypeError("mailingAudiences: rules must be an object");
|
|
193
|
+
}
|
|
194
|
+
var keys = Object.keys(rules);
|
|
195
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
196
|
+
if (KNOWN_RULE_KEYS.indexOf(keys[i]) === -1) {
|
|
197
|
+
throw new TypeError(
|
|
198
|
+
"mailingAudiences: unknown rule key '" + keys[i] + "' — allowed: " +
|
|
199
|
+
KNOWN_RULE_KEYS.join(", ")
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
var out = {};
|
|
204
|
+
if (rules.subscribed_after != null) {
|
|
205
|
+
out.subscribed_after = _validateTs(rules.subscribed_after, "rules.subscribed_after");
|
|
206
|
+
}
|
|
207
|
+
if (rules.subscribed_before != null) {
|
|
208
|
+
out.subscribed_before = _validateTs(rules.subscribed_before, "rules.subscribed_before");
|
|
209
|
+
}
|
|
210
|
+
if (out.subscribed_after != null && out.subscribed_before != null &&
|
|
211
|
+
out.subscribed_after > out.subscribed_before) {
|
|
212
|
+
throw new TypeError(
|
|
213
|
+
"mailingAudiences: rules.subscribed_after must be <= subscribed_before"
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
if (rules.source_in != null) {
|
|
217
|
+
out.source_in = _validateStringList(rules.source_in, "rules.source_in", SOURCE_RE);
|
|
218
|
+
}
|
|
219
|
+
if (rules.tag_any != null) {
|
|
220
|
+
out.tag_any = _validateStringList(rules.tag_any, "rules.tag_any", TAG_RE);
|
|
221
|
+
}
|
|
222
|
+
if (rules.tag_all != null) {
|
|
223
|
+
out.tag_all = _validateStringList(rules.tag_all, "rules.tag_all", TAG_RE);
|
|
224
|
+
}
|
|
225
|
+
if (rules.country_in != null) {
|
|
226
|
+
out.country_in = _validateStringList(rules.country_in, "rules.country_in", COUNTRY_RE);
|
|
227
|
+
}
|
|
228
|
+
if (rules.double_opt_in != null) {
|
|
229
|
+
if (typeof rules.double_opt_in !== "boolean") {
|
|
230
|
+
throw new TypeError(
|
|
231
|
+
"mailingAudiences: rules.double_opt_in must be a boolean"
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
out.double_opt_in = rules.double_opt_in;
|
|
235
|
+
}
|
|
236
|
+
if (rules.language_in != null) {
|
|
237
|
+
out.language_in = _validateStringList(rules.language_in, "rules.language_in", LANGUAGE_RE);
|
|
238
|
+
}
|
|
239
|
+
if (rules.customer_status_in != null) {
|
|
240
|
+
out.customer_status_in = _validateStringList(
|
|
241
|
+
rules.customer_status_in, "rules.customer_status_in", CUSTOMER_STATUS_RE
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
return out;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function _validateLimit(n) {
|
|
248
|
+
if (n == null) return DEFAULT_LIST_LIMIT;
|
|
249
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
|
|
250
|
+
throw new TypeError(
|
|
251
|
+
"mailingAudiences: limit must be 1..." + MAX_LIST_LIMIT
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
return n;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function _validateCampaignId(s) {
|
|
258
|
+
if (typeof s !== "string" || !s.length) {
|
|
259
|
+
throw new TypeError(
|
|
260
|
+
"mailingAudiences: campaign_id must be a non-empty string"
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
if (s.length > 128) {
|
|
264
|
+
throw new TypeError("mailingAudiences: campaign_id must be <= 128 chars");
|
|
265
|
+
}
|
|
266
|
+
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]*[A-Za-z0-9]$|^[A-Za-z0-9]$/.test(s)) {
|
|
267
|
+
throw new TypeError(
|
|
268
|
+
"mailingAudiences: campaign_id must match /[A-Za-z0-9][A-Za-z0-9._:-]*[A-Za-z0-9]/"
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return s;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function _validateNonNegInt(n, label) {
|
|
275
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
|
|
276
|
+
throw new TypeError(
|
|
277
|
+
"mailingAudiences: " + label + " must be a non-negative integer"
|
|
278
|
+
);
|
|
279
|
+
}
|
|
280
|
+
return n;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ---- in-memory rule evaluator ------------------------------------------
|
|
284
|
+
//
|
|
285
|
+
// Used by `recompute()` and `resolve()` (cold path — when no cache row
|
|
286
|
+
// exists yet). The hot path reads `mailing_audience_membership_cache`
|
|
287
|
+
// directly; this evaluator only runs over the full signup row set
|
|
288
|
+
// during a recompute pass. The cost-bound stays operator-visible
|
|
289
|
+
// because the scheduler controls when recompute happens.
|
|
290
|
+
|
|
291
|
+
function _csvHasAny(csv, needles) {
|
|
292
|
+
if (!csv) return false;
|
|
293
|
+
var have = csv.split(",");
|
|
294
|
+
for (var i = 0; i < have.length; i += 1) {
|
|
295
|
+
var t = have[i];
|
|
296
|
+
if (!t) continue;
|
|
297
|
+
if (needles.indexOf(t) !== -1) return true;
|
|
298
|
+
}
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function _csvHasAll(csv, needles) {
|
|
303
|
+
if (!csv) return false;
|
|
304
|
+
var have = csv.split(",");
|
|
305
|
+
for (var i = 0; i < needles.length; i += 1) {
|
|
306
|
+
if (have.indexOf(needles[i]) === -1) return false;
|
|
307
|
+
}
|
|
308
|
+
return true;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function _matches(row, rules) {
|
|
312
|
+
if (rules.subscribed_after != null &&
|
|
313
|
+
!(Number(row.created_at) >= rules.subscribed_after)) {
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
if (rules.subscribed_before != null &&
|
|
317
|
+
!(Number(row.created_at) <= rules.subscribed_before)) {
|
|
318
|
+
return false;
|
|
319
|
+
}
|
|
320
|
+
if (rules.source_in != null &&
|
|
321
|
+
rules.source_in.indexOf(row.source) === -1) {
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
if (rules.tag_any != null &&
|
|
325
|
+
!_csvHasAny(row.tags_csv || "", rules.tag_any)) {
|
|
326
|
+
return false;
|
|
327
|
+
}
|
|
328
|
+
if (rules.tag_all != null &&
|
|
329
|
+
!_csvHasAll(row.tags_csv || "", rules.tag_all)) {
|
|
330
|
+
return false;
|
|
331
|
+
}
|
|
332
|
+
if (rules.country_in != null &&
|
|
333
|
+
(!row.country || rules.country_in.indexOf(row.country) === -1)) {
|
|
334
|
+
return false;
|
|
335
|
+
}
|
|
336
|
+
if (rules.double_opt_in === true && row.double_opt_in_at == null) {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
if (rules.double_opt_in === false && row.double_opt_in_at != null) {
|
|
340
|
+
return false;
|
|
341
|
+
}
|
|
342
|
+
if (rules.language_in != null &&
|
|
343
|
+
(!row.language || rules.language_in.indexOf(row.language) === -1)) {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
if (rules.customer_status_in != null &&
|
|
347
|
+
(!row.customer_status ||
|
|
348
|
+
rules.customer_status_in.indexOf(row.customer_status) === -1)) {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
// Unsubscribed rows are never audience members regardless of rule
|
|
352
|
+
// shape — opt-out is the universal short-circuit. The operator can
|
|
353
|
+
// re-include via `newsletter.resubscribe`.
|
|
354
|
+
if (row.unsubscribed_at != null) return false;
|
|
355
|
+
return true;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// ---- factory ------------------------------------------------------------
|
|
359
|
+
|
|
360
|
+
function create(opts) {
|
|
361
|
+
opts = opts || {};
|
|
362
|
+
var query = opts.query;
|
|
363
|
+
if (!query) {
|
|
364
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
365
|
+
}
|
|
366
|
+
// The `newsletter` handle is optional — the primitive composes
|
|
367
|
+
// `newsletter_signups` directly via `query`. Operators pass the
|
|
368
|
+
// handle when they want to short-circuit a resolve into a fresh
|
|
369
|
+
// recompute (e.g. count() against a freshly-mutated table).
|
|
370
|
+
var newsletterHandle = opts.newsletter || null;
|
|
371
|
+
var suppressionsHandle = opts.emailSuppressions || null;
|
|
372
|
+
|
|
373
|
+
if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
|
|
374
|
+
if (process.env.NODE_ENV === "production") {
|
|
375
|
+
throw new Error(
|
|
376
|
+
"mailingAudiences.create: opts.cursorSecret is required in production"
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
opts.cursorSecret = "mailing-audiences-cursor-dev-only";
|
|
380
|
+
}
|
|
381
|
+
var cursorSecret = opts.cursorSecret;
|
|
382
|
+
|
|
383
|
+
// Cold-path resolver — walks every signup row, applies the rule
|
|
384
|
+
// evaluator, replaces the cache rows for this slug. Returns the
|
|
385
|
+
// member id list so `recompute()` can roll up counts.
|
|
386
|
+
async function _recomputeOne(slug, rules, now) {
|
|
387
|
+
var r = await query(
|
|
388
|
+
"SELECT id, email_hash, email_normalized, source, created_at, " +
|
|
389
|
+
"unsubscribed_at, tags_csv, country, language, customer_status, " +
|
|
390
|
+
"double_opt_in_at " +
|
|
391
|
+
"FROM newsletter_signups",
|
|
392
|
+
[],
|
|
393
|
+
);
|
|
394
|
+
var members = [];
|
|
395
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
396
|
+
if (_matches(r.rows[i], rules)) members.push(r.rows[i].id);
|
|
397
|
+
}
|
|
398
|
+
// Replace the slug's cache rows in two steps — SQLite has no
|
|
399
|
+
// multi-row UPSERT that's portable across the D1 SQLite dialect
|
|
400
|
+
// we run on, so the safer route is DELETE + INSERT. Bounded by
|
|
401
|
+
// the audience size (operator picks); a 100k-member audience is
|
|
402
|
+
// still ~10ms on D1.
|
|
403
|
+
await query(
|
|
404
|
+
"DELETE FROM mailing_audience_membership_cache WHERE slug = ?1",
|
|
405
|
+
[slug],
|
|
406
|
+
);
|
|
407
|
+
for (var j = 0; j < members.length; j += 1) {
|
|
408
|
+
await query(
|
|
409
|
+
"INSERT INTO mailing_audience_membership_cache " +
|
|
410
|
+
"(slug, signup_id, refreshed_at) VALUES (?1, ?2, ?3)",
|
|
411
|
+
[slug, members[j], now],
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
return members;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function _decodeCursor(cursor) {
|
|
418
|
+
if (cursor == null) return null;
|
|
419
|
+
if (typeof cursor !== "string") {
|
|
420
|
+
throw new TypeError(
|
|
421
|
+
"mailingAudiences: cursor must be an opaque string or null"
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
try {
|
|
425
|
+
var state = _b().pagination.decodeCursor(cursor, cursorSecret);
|
|
426
|
+
if (JSON.stringify(state.orderKey) !== JSON.stringify(LIST_ORDER_KEY)) {
|
|
427
|
+
throw new TypeError(
|
|
428
|
+
"mailingAudiences: cursor orderKey mismatch"
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
return state.vals;
|
|
432
|
+
} catch (e) {
|
|
433
|
+
if (e instanceof TypeError) throw e;
|
|
434
|
+
throw new TypeError(
|
|
435
|
+
"mailingAudiences: cursor — " + (e && e.message || "malformed")
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function _encodeCursor(lastSignupId) {
|
|
441
|
+
return _b().pagination.encodeCursor({
|
|
442
|
+
orderKey: LIST_ORDER_KEY,
|
|
443
|
+
vals: [lastSignupId],
|
|
444
|
+
forward: true,
|
|
445
|
+
}, cursorSecret);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function _getAudienceRow(slug) {
|
|
449
|
+
var r = await query(
|
|
450
|
+
"SELECT slug, title, rules_json, archived_at, created_at, updated_at " +
|
|
451
|
+
"FROM mailing_audiences WHERE slug = ?1 LIMIT 1",
|
|
452
|
+
[slug],
|
|
453
|
+
);
|
|
454
|
+
return r.rows[0] || null;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
return {
|
|
458
|
+
KNOWN_RULE_KEYS: KNOWN_RULE_KEYS,
|
|
459
|
+
|
|
460
|
+
// Define (or upsert) an audience. The rules object is normalised
|
|
461
|
+
// before persisting — unknown keys refused, lists deduped, types
|
|
462
|
+
// checked. Re-defining the same slug bumps `updated_at` + replaces
|
|
463
|
+
// rules; archived audiences un-archive on re-define so an
|
|
464
|
+
// operator can revive an audience without inventing a new slug.
|
|
465
|
+
defineAudience: async function (input) {
|
|
466
|
+
if (!input || typeof input !== "object") {
|
|
467
|
+
throw new TypeError("mailingAudiences.defineAudience: input object required");
|
|
468
|
+
}
|
|
469
|
+
var slug = _validateSlug(input.slug);
|
|
470
|
+
var title = _validateTitle(input.title);
|
|
471
|
+
var rules = _validateRules(input.rules);
|
|
472
|
+
var now = Date.now();
|
|
473
|
+
|
|
474
|
+
var existing = await _getAudienceRow(slug);
|
|
475
|
+
var rulesJson;
|
|
476
|
+
try {
|
|
477
|
+
rulesJson = JSON.stringify(rules);
|
|
478
|
+
} catch (_e) {
|
|
479
|
+
// Validators above only emit JSON-safe primitives; this is
|
|
480
|
+
// defensive — a circular ref would have to come from an
|
|
481
|
+
// operator-built proxy, which the validators wouldn't touch.
|
|
482
|
+
throw new TypeError("mailingAudiences.defineAudience: rules not serialisable");
|
|
483
|
+
}
|
|
484
|
+
if (existing) {
|
|
485
|
+
await query(
|
|
486
|
+
"UPDATE mailing_audiences SET title = ?1, rules_json = ?2, " +
|
|
487
|
+
"archived_at = NULL, updated_at = ?3 WHERE slug = ?4",
|
|
488
|
+
[title, rulesJson, now, slug],
|
|
489
|
+
);
|
|
490
|
+
return {
|
|
491
|
+
slug: slug,
|
|
492
|
+
title: title,
|
|
493
|
+
rules: rules,
|
|
494
|
+
archived_at: null,
|
|
495
|
+
created_at: Number(existing.created_at),
|
|
496
|
+
updated_at: now,
|
|
497
|
+
status: "updated",
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
await query(
|
|
501
|
+
"INSERT INTO mailing_audiences " +
|
|
502
|
+
"(slug, title, rules_json, archived_at, created_at, updated_at) " +
|
|
503
|
+
"VALUES (?1, ?2, ?3, NULL, ?4, ?4)",
|
|
504
|
+
[slug, title, rulesJson, now],
|
|
505
|
+
);
|
|
506
|
+
return {
|
|
507
|
+
slug: slug,
|
|
508
|
+
title: title,
|
|
509
|
+
rules: rules,
|
|
510
|
+
archived_at: null,
|
|
511
|
+
created_at: now,
|
|
512
|
+
updated_at: now,
|
|
513
|
+
status: "new",
|
|
514
|
+
};
|
|
515
|
+
},
|
|
516
|
+
|
|
517
|
+
// Resolve an audience to a page of recipients. The cache rows
|
|
518
|
+
// are joined back to `newsletter_signups` for the hash +
|
|
519
|
+
// normalised columns; suppressions (if injected) drop hits per
|
|
520
|
+
// hash. `emails_normalised` is empty unless `include_plaintext:
|
|
521
|
+
// true`. Pagination is cursor-based and HMAC-tagged so an
|
|
522
|
+
// operator can't hand-craft one to skip past hidden rows.
|
|
523
|
+
resolve: async function (resolveOpts) {
|
|
524
|
+
resolveOpts = resolveOpts || {};
|
|
525
|
+
var slug = _validateSlug(resolveOpts.slug);
|
|
526
|
+
var limit = _validateLimit(resolveOpts.limit);
|
|
527
|
+
var cursorVals = _decodeCursor(resolveOpts.cursor);
|
|
528
|
+
var skipSuppressed = resolveOpts.skip_suppressed !== false;
|
|
529
|
+
var includePlaintext = resolveOpts.include_plaintext === true;
|
|
530
|
+
|
|
531
|
+
var audience = await _getAudienceRow(slug);
|
|
532
|
+
if (!audience) {
|
|
533
|
+
throw new TypeError(
|
|
534
|
+
"mailingAudiences.resolve: audience '" + slug + "' not found"
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
if (audience.archived_at != null) {
|
|
538
|
+
throw new TypeError(
|
|
539
|
+
"mailingAudiences.resolve: audience '" + slug + "' is archived"
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
var sql = "SELECT s.id AS signup_id, s.email_hash, s.email_normalized " +
|
|
543
|
+
"FROM mailing_audience_membership_cache c " +
|
|
544
|
+
"JOIN newsletter_signups s ON s.id = c.signup_id " +
|
|
545
|
+
"WHERE c.slug = ?1";
|
|
546
|
+
var params = [slug];
|
|
547
|
+
var p = 2;
|
|
548
|
+
if (cursorVals) {
|
|
549
|
+
sql += " AND c.signup_id > ?" + p;
|
|
550
|
+
params.push(cursorVals[0]);
|
|
551
|
+
p += 1;
|
|
552
|
+
}
|
|
553
|
+
sql += " ORDER BY c.signup_id ASC LIMIT ?" + p;
|
|
554
|
+
// Over-fetch by the configured limit so the suppression filter
|
|
555
|
+
// doesn't underfill the page on a high-suppression slug.
|
|
556
|
+
// Bounded by `MAX_LIST_LIMIT * 2` so a pathological audience
|
|
557
|
+
// can't blow memory; if the over-fetch saturates and the page
|
|
558
|
+
// still underfills, the caller paginates to the next cursor.
|
|
559
|
+
var overfetch = Math.min(MAX_LIST_LIMIT * 2, limit * 2);
|
|
560
|
+
params.push(overfetch);
|
|
561
|
+
|
|
562
|
+
var rows = (await query(sql, params)).rows;
|
|
563
|
+
var hashes = [];
|
|
564
|
+
var emails = [];
|
|
565
|
+
var lastId = null;
|
|
566
|
+
var droppedSuppressed = 0;
|
|
567
|
+
|
|
568
|
+
for (var i = 0; i < rows.length && hashes.length < limit; i += 1) {
|
|
569
|
+
var row = rows[i];
|
|
570
|
+
lastId = row.signup_id;
|
|
571
|
+
if (skipSuppressed && suppressionsHandle) {
|
|
572
|
+
var ssView = await suppressionsHandle.isSuppressed({
|
|
573
|
+
email: row.email_normalized,
|
|
574
|
+
scope: "marketing",
|
|
575
|
+
});
|
|
576
|
+
if (ssView.suppressed) {
|
|
577
|
+
droppedSuppressed += 1;
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
hashes.push(row.email_hash);
|
|
582
|
+
if (includePlaintext) emails.push(row.email_normalized);
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
var next = null;
|
|
586
|
+
// If the over-fetch returned at least one more row past the
|
|
587
|
+
// last we emitted, a next page exists. The cursor anchors on
|
|
588
|
+
// the last cache row we examined — including dropped
|
|
589
|
+
// suppressions — so the next page resumes past them.
|
|
590
|
+
if (rows.length > 0 && hashes.length === limit) {
|
|
591
|
+
next = _encodeCursor(lastId);
|
|
592
|
+
}
|
|
593
|
+
return {
|
|
594
|
+
slug: slug,
|
|
595
|
+
emails_hashed: hashes,
|
|
596
|
+
emails_normalised: emails,
|
|
597
|
+
suppressed_count: droppedSuppressed,
|
|
598
|
+
next_cursor: next,
|
|
599
|
+
};
|
|
600
|
+
},
|
|
601
|
+
|
|
602
|
+
// Count of cached members for an audience. Reads off
|
|
603
|
+
// `mailing_audience_membership_cache` — does NOT re-resolve.
|
|
604
|
+
// Operators that need cache-fresh counts call `recompute()`
|
|
605
|
+
// first.
|
|
606
|
+
count: async function (slug) {
|
|
607
|
+
_validateSlug(slug);
|
|
608
|
+
var audience = await _getAudienceRow(slug);
|
|
609
|
+
if (!audience) {
|
|
610
|
+
throw new TypeError(
|
|
611
|
+
"mailingAudiences.count: audience '" + slug + "' not found"
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
var r = await query(
|
|
615
|
+
"SELECT COUNT(*) AS n FROM mailing_audience_membership_cache WHERE slug = ?1",
|
|
616
|
+
[slug],
|
|
617
|
+
);
|
|
618
|
+
return Number((r.rows[0] || {}).n || 0);
|
|
619
|
+
},
|
|
620
|
+
|
|
621
|
+
// Single-audience operator dashboard view. Returns the row + the
|
|
622
|
+
// parsed rules object (not the JSON string) for ergonomic
|
|
623
|
+
// rendering. Returns null on miss rather than throwing — the
|
|
624
|
+
// dashboard renders a "not found" view; throwing would force the
|
|
625
|
+
// caller to wrap every read in try/catch.
|
|
626
|
+
getAudience: async function (slug) {
|
|
627
|
+
_validateSlug(slug);
|
|
628
|
+
var row = await _getAudienceRow(slug);
|
|
629
|
+
if (!row) return null;
|
|
630
|
+
var rules;
|
|
631
|
+
try { rules = JSON.parse(row.rules_json); }
|
|
632
|
+
catch (_e) { rules = {}; }
|
|
633
|
+
return {
|
|
634
|
+
slug: row.slug,
|
|
635
|
+
title: row.title,
|
|
636
|
+
rules: rules,
|
|
637
|
+
archived_at: row.archived_at == null ? null : Number(row.archived_at),
|
|
638
|
+
created_at: Number(row.created_at),
|
|
639
|
+
updated_at: Number(row.updated_at),
|
|
640
|
+
};
|
|
641
|
+
},
|
|
642
|
+
|
|
643
|
+
// Operator dashboard list. Active-only by default; pass
|
|
644
|
+
// `include_archived: true` to surface the soft-deleted entries
|
|
645
|
+
// for compliance / undo-style flows.
|
|
646
|
+
listAudiences: async function (listOpts) {
|
|
647
|
+
listOpts = listOpts || {};
|
|
648
|
+
var includeArchived = listOpts.include_archived === true;
|
|
649
|
+
var sql = "SELECT slug, title, rules_json, archived_at, created_at, updated_at " +
|
|
650
|
+
"FROM mailing_audiences";
|
|
651
|
+
if (!includeArchived) sql += " WHERE archived_at IS NULL";
|
|
652
|
+
sql += " ORDER BY slug ASC";
|
|
653
|
+
var rows = (await query(sql, [])).rows;
|
|
654
|
+
var out = [];
|
|
655
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
656
|
+
var rules;
|
|
657
|
+
try { rules = JSON.parse(rows[i].rules_json); }
|
|
658
|
+
catch (_e) { rules = {}; }
|
|
659
|
+
out.push({
|
|
660
|
+
slug: rows[i].slug,
|
|
661
|
+
title: rows[i].title,
|
|
662
|
+
rules: rules,
|
|
663
|
+
archived_at: rows[i].archived_at == null ? null : Number(rows[i].archived_at),
|
|
664
|
+
created_at: Number(rows[i].created_at),
|
|
665
|
+
updated_at: Number(rows[i].updated_at),
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
return out;
|
|
669
|
+
},
|
|
670
|
+
|
|
671
|
+
// Update an audience's title and / or rules. Either or both may
|
|
672
|
+
// appear in the patch; absent keys leave the persisted value
|
|
673
|
+
// alone. Archived audiences refuse updates — the operator
|
|
674
|
+
// un-archives via `defineAudience` (which is the upsert path).
|
|
675
|
+
update: async function (slug, patch) {
|
|
676
|
+
_validateSlug(slug);
|
|
677
|
+
if (!patch || typeof patch !== "object") {
|
|
678
|
+
throw new TypeError("mailingAudiences.update: patch object required");
|
|
679
|
+
}
|
|
680
|
+
var existing = await _getAudienceRow(slug);
|
|
681
|
+
if (!existing) {
|
|
682
|
+
throw new TypeError(
|
|
683
|
+
"mailingAudiences.update: audience '" + slug + "' not found"
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
if (existing.archived_at != null) {
|
|
687
|
+
throw new TypeError(
|
|
688
|
+
"mailingAudiences.update: audience '" + slug + "' is archived — " +
|
|
689
|
+
"use defineAudience to revive"
|
|
690
|
+
);
|
|
691
|
+
}
|
|
692
|
+
var title;
|
|
693
|
+
if (patch.title != null) title = _validateTitle(patch.title);
|
|
694
|
+
else title = existing.title;
|
|
695
|
+
var rules;
|
|
696
|
+
if (patch.rules != null) rules = _validateRules(patch.rules);
|
|
697
|
+
else rules = JSON.parse(existing.rules_json);
|
|
698
|
+
var now = Date.now();
|
|
699
|
+
await query(
|
|
700
|
+
"UPDATE mailing_audiences SET title = ?1, rules_json = ?2, updated_at = ?3 " +
|
|
701
|
+
"WHERE slug = ?4",
|
|
702
|
+
[title, JSON.stringify(rules), now, slug],
|
|
703
|
+
);
|
|
704
|
+
return {
|
|
705
|
+
slug: slug,
|
|
706
|
+
title: title,
|
|
707
|
+
rules: rules,
|
|
708
|
+
archived_at: null,
|
|
709
|
+
created_at: Number(existing.created_at),
|
|
710
|
+
updated_at: now,
|
|
711
|
+
status: "updated",
|
|
712
|
+
};
|
|
713
|
+
},
|
|
714
|
+
|
|
715
|
+
// Soft-delete — stamps `archived_at`. The membership cache is
|
|
716
|
+
// dropped at the same time so a stale page can't resolve against
|
|
717
|
+
// a dormant audience; the audit ledger (deliveries) stays intact
|
|
718
|
+
// so the compliance export keeps reading the historical sends.
|
|
719
|
+
archive: async function (slug) {
|
|
720
|
+
_validateSlug(slug);
|
|
721
|
+
var existing = await _getAudienceRow(slug);
|
|
722
|
+
if (!existing) {
|
|
723
|
+
throw new TypeError(
|
|
724
|
+
"mailingAudiences.archive: audience '" + slug + "' not found"
|
|
725
|
+
);
|
|
726
|
+
}
|
|
727
|
+
if (existing.archived_at != null) {
|
|
728
|
+
return {
|
|
729
|
+
slug: slug,
|
|
730
|
+
archived_at: Number(existing.archived_at),
|
|
731
|
+
status: "already-archived",
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
var now = Date.now();
|
|
735
|
+
await query(
|
|
736
|
+
"UPDATE mailing_audiences SET archived_at = ?1, updated_at = ?1 " +
|
|
737
|
+
"WHERE slug = ?2",
|
|
738
|
+
[now, slug],
|
|
739
|
+
);
|
|
740
|
+
await query(
|
|
741
|
+
"DELETE FROM mailing_audience_membership_cache WHERE slug = ?1",
|
|
742
|
+
[slug],
|
|
743
|
+
);
|
|
744
|
+
return { slug: slug, archived_at: now, status: "archived" };
|
|
745
|
+
},
|
|
746
|
+
|
|
747
|
+
// Scheduler entry point. Walks every non-archived audience and
|
|
748
|
+
// replaces its membership cache rows. Returns the per-slug
|
|
749
|
+
// member counts so the scheduler can log a recompute summary.
|
|
750
|
+
recompute: async function () {
|
|
751
|
+
var rows = (await query(
|
|
752
|
+
"SELECT slug, rules_json FROM mailing_audiences WHERE archived_at IS NULL",
|
|
753
|
+
[],
|
|
754
|
+
)).rows;
|
|
755
|
+
var now = Date.now();
|
|
756
|
+
var counts = {};
|
|
757
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
758
|
+
var rules;
|
|
759
|
+
try { rules = JSON.parse(rows[i].rules_json); }
|
|
760
|
+
catch (_e) {
|
|
761
|
+
// Persisted JSON failed to parse — operator-visible drift.
|
|
762
|
+
// Skip the slug rather than crash the whole sweep so one
|
|
763
|
+
// bad row doesn't stall the scheduler; the count surfaces
|
|
764
|
+
// as 0 in the return so the operator notices.
|
|
765
|
+
counts[rows[i].slug] = 0;
|
|
766
|
+
continue;
|
|
767
|
+
}
|
|
768
|
+
var members = await _recomputeOne(rows[i].slug, rules, now);
|
|
769
|
+
counts[rows[i].slug] = members.length;
|
|
770
|
+
}
|
|
771
|
+
return { refreshed_at: now, counts: counts };
|
|
772
|
+
},
|
|
773
|
+
|
|
774
|
+
// Append-only delivery audit. One row per send the broadcast
|
|
775
|
+
// pipeline performs. The compliance export reads
|
|
776
|
+
// `mailing_audience_deliveries` to demonstrate per-audience
|
|
777
|
+
// send volume + suppression honoring.
|
|
778
|
+
auditDelivery: async function (input) {
|
|
779
|
+
if (!input || typeof input !== "object") {
|
|
780
|
+
throw new TypeError(
|
|
781
|
+
"mailingAudiences.auditDelivery: input object required"
|
|
782
|
+
);
|
|
783
|
+
}
|
|
784
|
+
var slug = _validateSlug(input.slug);
|
|
785
|
+
var campaignId = _validateCampaignId(input.campaign_id);
|
|
786
|
+
var sentCount = _validateNonNegInt(input.sent_count, "sent_count");
|
|
787
|
+
var suppressedCount = _validateNonNegInt(input.suppressed_count, "suppressed_count");
|
|
788
|
+
var occurredAt;
|
|
789
|
+
if (input.occurred_at == null) {
|
|
790
|
+
occurredAt = Date.now();
|
|
791
|
+
} else {
|
|
792
|
+
occurredAt = _validateTs(input.occurred_at, "occurred_at");
|
|
793
|
+
}
|
|
794
|
+
// Audience existence is a soft check — the audit row records
|
|
795
|
+
// what the operator sent under the slug they used. An archived
|
|
796
|
+
// or deleted audience still gets logged so the compliance
|
|
797
|
+
// export sees the historical activity. The slug-existence
|
|
798
|
+
// refusal is reserved for resolve / count where stale data
|
|
799
|
+
// would mislead the caller.
|
|
800
|
+
var id = _b().uuid.v7();
|
|
801
|
+
await query(
|
|
802
|
+
"INSERT INTO mailing_audience_deliveries " +
|
|
803
|
+
"(id, slug, campaign_id, sent_count, suppressed_count, occurred_at) " +
|
|
804
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
|
|
805
|
+
[id, slug, campaignId, sentCount, suppressedCount, occurredAt],
|
|
806
|
+
);
|
|
807
|
+
return {
|
|
808
|
+
id: id,
|
|
809
|
+
slug: slug,
|
|
810
|
+
campaign_id: campaignId,
|
|
811
|
+
sent_count: sentCount,
|
|
812
|
+
suppressed_count: suppressedCount,
|
|
813
|
+
occurred_at: occurredAt,
|
|
814
|
+
};
|
|
815
|
+
},
|
|
816
|
+
|
|
817
|
+
// Operator compliance read — paginated delivery audit log,
|
|
818
|
+
// optionally narrowed to a slug. Sorted (occurred_at DESC,
|
|
819
|
+
// id DESC) so the most-recent send surfaces first. Uses the
|
|
820
|
+
// primitive's cursorSecret so the same HMAC tag protects cross-
|
|
821
|
+
// surface pagination.
|
|
822
|
+
listDeliveries: async function (listOpts) {
|
|
823
|
+
listOpts = listOpts || {};
|
|
824
|
+
var limit = _validateLimit(listOpts.limit);
|
|
825
|
+
var slug = null;
|
|
826
|
+
if (listOpts.slug != null) slug = _validateSlug(listOpts.slug);
|
|
827
|
+
var where = [];
|
|
828
|
+
var params = [];
|
|
829
|
+
var p = 1;
|
|
830
|
+
if (slug) {
|
|
831
|
+
where.push("slug = ?" + p);
|
|
832
|
+
params.push(slug);
|
|
833
|
+
p += 1;
|
|
834
|
+
}
|
|
835
|
+
var sql = "SELECT id, slug, campaign_id, sent_count, suppressed_count, occurred_at " +
|
|
836
|
+
"FROM mailing_audience_deliveries" +
|
|
837
|
+
(where.length ? " WHERE " + where.join(" AND ") : "") +
|
|
838
|
+
" ORDER BY occurred_at DESC, id DESC LIMIT ?" + p;
|
|
839
|
+
params.push(limit);
|
|
840
|
+
var rows = (await query(sql, params)).rows;
|
|
841
|
+
return { rows: rows };
|
|
842
|
+
},
|
|
843
|
+
|
|
844
|
+
// Optional accessor — surfaces whether the primitive was wired
|
|
845
|
+
// with a newsletter handle. Tests + operator-debugging surfaces
|
|
846
|
+
// use this to assert composition; the resolve / recompute paths
|
|
847
|
+
// don't need it (they go through `query` directly).
|
|
848
|
+
_newsletterHandle: function () { return newsletterHandle; },
|
|
849
|
+
};
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
module.exports = {
|
|
853
|
+
create: create,
|
|
854
|
+
KNOWN_RULE_KEYS: KNOWN_RULE_KEYS,
|
|
855
|
+
};
|