@blamejs/blamejs-shop 0.0.60 → 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 +4 -0
- package/lib/carrier-rates.js +683 -0
- package/lib/cart-bulk-ops.js +711 -0
- package/lib/cms-blocks.js +651 -0
- package/lib/code-minter.js +535 -0
- package/lib/customer-import.js +590 -0
- package/lib/discount-analytics.js +548 -0
- package/lib/dunning.js +700 -0
- package/lib/email-campaigns.js +844 -0
- package/lib/geolocation.js +651 -0
- package/lib/gift-card-ledger.js +483 -0
- package/lib/gift-registry.js +820 -0
- package/lib/index.js +21 -0
- package/lib/loyalty-redemption.js +673 -0
- package/lib/operator-audit-log.js +621 -0
- package/lib/plan-changes.js +508 -0
- package/lib/refund-policy.js +965 -0
- package/lib/search-facets.js +825 -0
- package/lib/sms-dispatcher.js +951 -0
- package/lib/stock-transfers.js +777 -0
- package/lib/storefront-dashboards.js +863 -0
- package/lib/storefront-forms.js +884 -0
- package/lib/vendors.js +797 -0
- package/package.json +1 -1
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.codeMinter
|
|
4
|
+
* @title Code minter — bulk-issue single-use discount codes against
|
|
5
|
+
* the coupons primitive.
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* Operators run a campaign and need N unique single-use discount
|
|
9
|
+
* codes (10000 codes for an influencer drop, 50000 for a printed-
|
|
10
|
+
* insert run). The minter generates each code from a confusion-
|
|
11
|
+
* resistant alphabet via `b.crypto.generateBytes`, persists the
|
|
12
|
+
* batch + per-code member rows, and registers every code with the
|
|
13
|
+
* `coupons` primitive in the same call. The coupons surface owns
|
|
14
|
+
* the redemption tier — single-use enforcement, expiry, per-code
|
|
15
|
+
* discount math live there. The minter owns batch identity,
|
|
16
|
+
* collision-safe generation, paginated read-back, and the operator
|
|
17
|
+
* void path.
|
|
18
|
+
*
|
|
19
|
+
* Collision-safe generation. The default alphabet (32 glyphs:
|
|
20
|
+
* `23456789ABCDEFGHJKLMNPQRSTUVWXYZ`) skips `0/1/I/O` so a code
|
|
21
|
+
* read off a printed insert or repeated over the phone doesn't
|
|
22
|
+
* collapse into ambiguous characters. 256 is a multiple of 32, so
|
|
23
|
+
* each random byte modulo-32 lands on a uniform alphabet draw with
|
|
24
|
+
* no modulo-bias correction. For alphabets where `256 % len !== 0`
|
|
25
|
+
* the minter applies rejection sampling — bytes that fall into the
|
|
26
|
+
* non-uniform tail are discarded and re-drawn.
|
|
27
|
+
*
|
|
28
|
+
* Each generated code passes through the per-mint dedupe set + the
|
|
29
|
+
* `coupon_code` UNIQUE constraint at insert time. On a unique-
|
|
30
|
+
* violation (collision against another batch's code, or — vanishing-
|
|
31
|
+
* probability — collision within the same batch's mint loop) the
|
|
32
|
+
* minter retries with a fresh draw, up to a bounded retry budget
|
|
33
|
+
* per code (default 16). If the budget is exhausted the call
|
|
34
|
+
* throws `CODE_MINTER_COLLISION_BUDGET_EXHAUSTED` rather than
|
|
35
|
+
* returning a partial batch — the storage row count and the
|
|
36
|
+
* returned `count_minted` always match.
|
|
37
|
+
*
|
|
38
|
+
* Composition:
|
|
39
|
+
* var cm = bShop.codeMinter.create({ query: q, coupons: cp });
|
|
40
|
+
* var batch = await cm.mintBatch({
|
|
41
|
+
* batch_label: "fall-2026-influencers",
|
|
42
|
+
* count: 10000,
|
|
43
|
+
* length: 10,
|
|
44
|
+
* prefix: "FALL-",
|
|
45
|
+
* coupon_template: {
|
|
46
|
+
* kind: "percent_off",
|
|
47
|
+
* value: 20,
|
|
48
|
+
* expires_at: Date.UTC(2026, 11, 31),
|
|
49
|
+
* },
|
|
50
|
+
* });
|
|
51
|
+
* // batch.batch_id, batch.count_minted, batch.sample_codes (up to 5)
|
|
52
|
+
*
|
|
53
|
+
* Surface:
|
|
54
|
+
* - mintBatch({ batch_label, count, alphabet?, length, prefix?,
|
|
55
|
+
* suffix?, coupon_template })
|
|
56
|
+
* Generates `count` distinct codes and persists each through
|
|
57
|
+
* `coupons.create({ code, ... coupon_template })`. The
|
|
58
|
+
* `coupon_template` payload is opaque to the minter — every
|
|
59
|
+
* key other than `code` is forwarded as-is, so operators
|
|
60
|
+
* choose the discount kind / value / expiry / single-use
|
|
61
|
+
* flag the coupons primitive expects. Returns
|
|
62
|
+
* `{ batch_id, count_minted, sample_codes }`.
|
|
63
|
+
*
|
|
64
|
+
* - getBatch(batch_id) -> batch row or null.
|
|
65
|
+
* - listBatches({ status? }) — ordered created_at DESC, id DESC.
|
|
66
|
+
* - codesForBatch({ batch_id, limit?, cursor? }) — paginated read
|
|
67
|
+
* of member rows. Cursor is the last row's `minted_at` epoch-ms.
|
|
68
|
+
* - voidBatch({ batch_id, reason }) — flips the batch to 'voided'
|
|
69
|
+
* and walks every member row, calling `coupons.archive(code)`
|
|
70
|
+
* on each. Idempotent: voiding a voided batch is a no-op.
|
|
71
|
+
* - exportBatchCsv({ batch_id }) — async-iterable yielding CSV
|
|
72
|
+
* chunks ready for label-printing. The header row is the first
|
|
73
|
+
* yield; each subsequent yield is one member row terminated by
|
|
74
|
+
* `\r\n`. Suitable for streaming to a Response body.
|
|
75
|
+
*
|
|
76
|
+
* Storage:
|
|
77
|
+
* - `code_batches` + `code_batch_members` (migration 0075).
|
|
78
|
+
*
|
|
79
|
+
* @primitive codeMinter
|
|
80
|
+
* @related coupons, b.crypto.generateBytes
|
|
81
|
+
*/
|
|
82
|
+
|
|
83
|
+
var bShop;
|
|
84
|
+
function _b() {
|
|
85
|
+
if (!bShop) bShop = require("./index");
|
|
86
|
+
return bShop.framework;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Confusion-resistant default alphabet — 32 glyphs, skips 0/1/I/O.
|
|
90
|
+
// 256 % 32 === 0, so each random byte modulo-32 is a uniform draw on
|
|
91
|
+
// the alphabet (no rejection sampling needed for the default).
|
|
92
|
+
var DEFAULT_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
|
|
93
|
+
|
|
94
|
+
var STATUSES = ["active", "voided", "exhausted"];
|
|
95
|
+
|
|
96
|
+
// Operator-facing caps. The mint loop is bounded — a 10M-code batch
|
|
97
|
+
// would dwarf the call timeout long before it failed any other gate.
|
|
98
|
+
var MIN_COUNT = 1;
|
|
99
|
+
var MAX_COUNT = 1000000;
|
|
100
|
+
var MIN_LENGTH = 4;
|
|
101
|
+
var MAX_LENGTH = 64;
|
|
102
|
+
var MAX_ALPHABET = 256;
|
|
103
|
+
var MIN_ALPHABET = 2;
|
|
104
|
+
var MAX_LABEL_LEN = 200;
|
|
105
|
+
var MAX_AFFIX_LEN = 32;
|
|
106
|
+
var MAX_REASON_LEN = 500;
|
|
107
|
+
var MAX_LIST_LIMIT = 500;
|
|
108
|
+
|
|
109
|
+
// Per-code collision retry budget. Even at 10M codes against a 32^10
|
|
110
|
+
// (≈1.1e15) space the birthday-collision rate inside a single batch
|
|
111
|
+
// stays below 1e-4; the budget protects against pathological alphabet
|
|
112
|
+
// + length combinations + cross-batch collisions.
|
|
113
|
+
var COLLISION_RETRY_BUDGET = 16;
|
|
114
|
+
|
|
115
|
+
// Random-byte draw size per attempt. Drawing a generous block per
|
|
116
|
+
// code amortizes the `generateBytes` call across the rejection-
|
|
117
|
+
// sampling loop without burning entropy.
|
|
118
|
+
function _bytesPerAttempt(length) { return length * 4; }
|
|
119
|
+
|
|
120
|
+
// ---- input validators ---------------------------------------------------
|
|
121
|
+
|
|
122
|
+
function _label(s) {
|
|
123
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_LABEL_LEN) {
|
|
124
|
+
throw new TypeError("codeMinter: batch_label must be a non-empty string ≤ " + MAX_LABEL_LEN + " chars");
|
|
125
|
+
}
|
|
126
|
+
if (/[\x00-\x1f\x7f]/.test(s)) {
|
|
127
|
+
throw new TypeError("codeMinter: batch_label must not contain control bytes");
|
|
128
|
+
}
|
|
129
|
+
return s;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function _count(n) {
|
|
133
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < MIN_COUNT || n > MAX_COUNT) {
|
|
134
|
+
throw new TypeError("codeMinter: count must be an integer in [" + MIN_COUNT + ", " + MAX_COUNT + "]");
|
|
135
|
+
}
|
|
136
|
+
return n;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function _length(n) {
|
|
140
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < MIN_LENGTH || n > MAX_LENGTH) {
|
|
141
|
+
throw new TypeError("codeMinter: length must be an integer in [" + MIN_LENGTH + ", " + MAX_LENGTH + "]");
|
|
142
|
+
}
|
|
143
|
+
return n;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function _alphabet(a) {
|
|
147
|
+
if (a == null) return DEFAULT_ALPHABET;
|
|
148
|
+
if (typeof a !== "string" || a.length < MIN_ALPHABET || a.length > MAX_ALPHABET) {
|
|
149
|
+
throw new TypeError("codeMinter: alphabet must be a string of " + MIN_ALPHABET + "-" + MAX_ALPHABET + " characters");
|
|
150
|
+
}
|
|
151
|
+
// Refuse control bytes + duplicate glyphs — a duplicate biases the
|
|
152
|
+
// mint distribution + makes the operator-visible alphabet a lie.
|
|
153
|
+
if (/[\x00-\x1f\x7f]/.test(a)) {
|
|
154
|
+
throw new TypeError("codeMinter: alphabet must not contain control bytes");
|
|
155
|
+
}
|
|
156
|
+
var seen = Object.create(null);
|
|
157
|
+
for (var i = 0; i < a.length; i += 1) {
|
|
158
|
+
var ch = a.charAt(i);
|
|
159
|
+
if (seen[ch]) {
|
|
160
|
+
throw new TypeError("codeMinter: alphabet must not contain duplicate characters");
|
|
161
|
+
}
|
|
162
|
+
seen[ch] = true;
|
|
163
|
+
}
|
|
164
|
+
return a;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function _affix(s, label) {
|
|
168
|
+
if (s == null) return "";
|
|
169
|
+
if (typeof s !== "string" || s.length > MAX_AFFIX_LEN) {
|
|
170
|
+
throw new TypeError("codeMinter: " + label + " must be a string ≤ " + MAX_AFFIX_LEN + " chars");
|
|
171
|
+
}
|
|
172
|
+
if (/[\x00-\x1f\x7f]/.test(s)) {
|
|
173
|
+
throw new TypeError("codeMinter: " + label + " must not contain control bytes");
|
|
174
|
+
}
|
|
175
|
+
return s;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function _reason(s) {
|
|
179
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_REASON_LEN) {
|
|
180
|
+
throw new TypeError("codeMinter: reason must be a non-empty string ≤ " + MAX_REASON_LEN + " chars");
|
|
181
|
+
}
|
|
182
|
+
if (/[\x00-\x1f\x7f]/.test(s)) {
|
|
183
|
+
throw new TypeError("codeMinter: reason must not contain control bytes");
|
|
184
|
+
}
|
|
185
|
+
return s;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function _batchId(s) {
|
|
189
|
+
if (typeof s !== "string" || !s.length) {
|
|
190
|
+
throw new TypeError("codeMinter: batch_id must be a non-empty string");
|
|
191
|
+
}
|
|
192
|
+
return s;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function _status(s) {
|
|
196
|
+
if (typeof s !== "string" || STATUSES.indexOf(s) === -1) {
|
|
197
|
+
throw new TypeError("codeMinter: status must be one of " + STATUSES.join(", "));
|
|
198
|
+
}
|
|
199
|
+
return s;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function _now() { return Date.now(); }
|
|
203
|
+
|
|
204
|
+
// ---- random-code generator ---------------------------------------------
|
|
205
|
+
|
|
206
|
+
// Draw one code from the alphabet using rejection sampling when
|
|
207
|
+
// 256 % alphabet.length !== 0. The default 32-glyph alphabet hits the
|
|
208
|
+
// fast path (no rejections); custom alphabets pay the rejection cost
|
|
209
|
+
// proportional to the modulo tail size.
|
|
210
|
+
function _drawCode(alphabet, length) {
|
|
211
|
+
var alen = alphabet.length;
|
|
212
|
+
var fastPath = (256 % alen) === 0;
|
|
213
|
+
var ceiling = fastPath ? 256 : (Math.floor(256 / alen) * alen);
|
|
214
|
+
var out = "";
|
|
215
|
+
var pos = 0;
|
|
216
|
+
var buf = null;
|
|
217
|
+
var bufLen = 0;
|
|
218
|
+
while (out.length < length) {
|
|
219
|
+
if (buf == null || pos >= bufLen) {
|
|
220
|
+
buf = _b().crypto.generateBytes(_bytesPerAttempt(length));
|
|
221
|
+
bufLen = buf.length;
|
|
222
|
+
pos = 0;
|
|
223
|
+
}
|
|
224
|
+
var byte = buf[pos];
|
|
225
|
+
pos += 1;
|
|
226
|
+
if (!fastPath && byte >= ceiling) {
|
|
227
|
+
continue; // reject tail draws to keep the distribution uniform
|
|
228
|
+
}
|
|
229
|
+
out += alphabet.charAt(byte % alen);
|
|
230
|
+
}
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ---- factory ------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
function create(opts) {
|
|
237
|
+
opts = opts || {};
|
|
238
|
+
if (!opts.coupons || typeof opts.coupons.create !== "function" || typeof opts.coupons.archive !== "function") {
|
|
239
|
+
throw new TypeError("codeMinter.create: opts.coupons must expose create + archive");
|
|
240
|
+
}
|
|
241
|
+
var coupons = opts.coupons;
|
|
242
|
+
var query = opts.query;
|
|
243
|
+
if (!query) {
|
|
244
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function _insertMember(memberId, batchId, code, ts) {
|
|
248
|
+
// Returns true on success, false on UNIQUE violation. Any other
|
|
249
|
+
// storage error rethrows.
|
|
250
|
+
try {
|
|
251
|
+
await query(
|
|
252
|
+
"INSERT INTO code_batch_members (id, batch_id, coupon_code, minted_at) VALUES (?1, ?2, ?3, ?4)",
|
|
253
|
+
[memberId, batchId, code, ts],
|
|
254
|
+
);
|
|
255
|
+
return true;
|
|
256
|
+
} catch (e) {
|
|
257
|
+
var msg = (e && e.message) || "";
|
|
258
|
+
if (/unique/i.test(msg)) return false;
|
|
259
|
+
throw e;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
DEFAULT_ALPHABET: DEFAULT_ALPHABET,
|
|
265
|
+
STATUSES: STATUSES,
|
|
266
|
+
|
|
267
|
+
mintBatch: async function (input) {
|
|
268
|
+
if (!input || typeof input !== "object") {
|
|
269
|
+
throw new TypeError("codeMinter.mintBatch: input object required");
|
|
270
|
+
}
|
|
271
|
+
_label(input.batch_label);
|
|
272
|
+
_count(input.count);
|
|
273
|
+
_length(input.length);
|
|
274
|
+
var alphabet = _alphabet(input.alphabet);
|
|
275
|
+
var prefix = _affix(input.prefix, "prefix");
|
|
276
|
+
var suffix = _affix(input.suffix, "suffix");
|
|
277
|
+
if (!input.coupon_template || typeof input.coupon_template !== "object") {
|
|
278
|
+
throw new TypeError("codeMinter.mintBatch: coupon_template object required");
|
|
279
|
+
}
|
|
280
|
+
// The minter forwards `coupon_template` keys other than `code` to
|
|
281
|
+
// `coupons.create` verbatim — the coupons primitive owns the
|
|
282
|
+
// shape validation. We only refuse a caller-supplied `code` that
|
|
283
|
+
// would override the minter's generated value.
|
|
284
|
+
if (Object.prototype.hasOwnProperty.call(input.coupon_template, "code")) {
|
|
285
|
+
throw new TypeError("codeMinter.mintBatch: coupon_template must not include 'code' (the minter generates it)");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
var batchId = _b().uuid.v7();
|
|
289
|
+
var ts = _now();
|
|
290
|
+
await query(
|
|
291
|
+
"INSERT INTO code_batches (id, label, status, count, prefix, suffix, alphabet, length, created_at) " +
|
|
292
|
+
"VALUES (?1, ?2, 'active', 0, ?3, ?4, ?5, ?6, ?7)",
|
|
293
|
+
[batchId, input.batch_label, prefix, suffix, alphabet, input.length, ts],
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
// Per-batch dedupe set short-circuits the rare in-batch
|
|
297
|
+
// collision before the SQL insert pays the UNIQUE check. The
|
|
298
|
+
// SQL UNIQUE remains the authoritative gate (cross-batch
|
|
299
|
+
// collisions land there).
|
|
300
|
+
var seen = Object.create(null);
|
|
301
|
+
var sampleCodes = [];
|
|
302
|
+
var minted = 0;
|
|
303
|
+
|
|
304
|
+
for (var i = 0; i < input.count; i += 1) {
|
|
305
|
+
var inserted = false;
|
|
306
|
+
var attempts = 0;
|
|
307
|
+
var code;
|
|
308
|
+
var lastErr;
|
|
309
|
+
while (!inserted && attempts < COLLISION_RETRY_BUDGET) {
|
|
310
|
+
attempts += 1;
|
|
311
|
+
var body = _drawCode(alphabet, input.length);
|
|
312
|
+
code = prefix + body + suffix;
|
|
313
|
+
if (seen[code]) { lastErr = "in_batch_collision"; continue; }
|
|
314
|
+
var memberId = _b().uuid.v7();
|
|
315
|
+
try {
|
|
316
|
+
inserted = await _insertMember(memberId, batchId, code, ts);
|
|
317
|
+
} catch (e) {
|
|
318
|
+
// Storage failure unrelated to UNIQUE — bubble.
|
|
319
|
+
throw e;
|
|
320
|
+
}
|
|
321
|
+
if (!inserted) { lastErr = "cross_batch_collision"; continue; }
|
|
322
|
+
seen[code] = true;
|
|
323
|
+
try {
|
|
324
|
+
await coupons.create(Object.assign({}, input.coupon_template, { code: code }));
|
|
325
|
+
} catch (e) {
|
|
326
|
+
// The coupons primitive refused (typically a duplicate
|
|
327
|
+
// code that lives in coupons but not in our member table,
|
|
328
|
+
// or a coupon_template shape error). Roll the member row
|
|
329
|
+
// back and surface the underlying error.
|
|
330
|
+
await query("DELETE FROM code_batch_members WHERE id = ?1", [memberId]);
|
|
331
|
+
delete seen[code];
|
|
332
|
+
throw e;
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
if (!inserted) {
|
|
336
|
+
var bust = new Error("codeMinter.mintBatch: collision retry budget exhausted at code " + (i + 1) + " of " + input.count + " (last cause: " + (lastErr || "unknown") + ")");
|
|
337
|
+
bust.code = "CODE_MINTER_COLLISION_BUDGET_EXHAUSTED";
|
|
338
|
+
throw bust;
|
|
339
|
+
}
|
|
340
|
+
minted += 1;
|
|
341
|
+
if (sampleCodes.length < 5) sampleCodes.push(code);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
await query(
|
|
345
|
+
"UPDATE code_batches SET count = ?1 WHERE id = ?2",
|
|
346
|
+
[minted, batchId],
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
return {
|
|
350
|
+
batch_id: batchId,
|
|
351
|
+
count_minted: minted,
|
|
352
|
+
sample_codes: sampleCodes,
|
|
353
|
+
};
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
getBatch: async function (batchId) {
|
|
357
|
+
_batchId(batchId);
|
|
358
|
+
var r = await query(
|
|
359
|
+
"SELECT id, label, status, count, prefix, suffix, alphabet, length, void_reason, created_at, voided_at " +
|
|
360
|
+
"FROM code_batches WHERE id = ?1",
|
|
361
|
+
[batchId],
|
|
362
|
+
);
|
|
363
|
+
return r.rows.length ? r.rows[0] : null;
|
|
364
|
+
},
|
|
365
|
+
|
|
366
|
+
listBatches: async function (opts2) {
|
|
367
|
+
opts2 = opts2 || {};
|
|
368
|
+
var sql, params;
|
|
369
|
+
if (opts2.status != null) {
|
|
370
|
+
_status(opts2.status);
|
|
371
|
+
sql = "SELECT id, label, status, count, prefix, suffix, alphabet, length, void_reason, created_at, voided_at " +
|
|
372
|
+
"FROM code_batches WHERE status = ?1 ORDER BY created_at DESC, id DESC";
|
|
373
|
+
params = [opts2.status];
|
|
374
|
+
} else {
|
|
375
|
+
sql = "SELECT id, label, status, count, prefix, suffix, alphabet, length, void_reason, created_at, voided_at " +
|
|
376
|
+
"FROM code_batches ORDER BY created_at DESC, id DESC";
|
|
377
|
+
params = [];
|
|
378
|
+
}
|
|
379
|
+
var r = await query(sql, params);
|
|
380
|
+
return r.rows;
|
|
381
|
+
},
|
|
382
|
+
|
|
383
|
+
codesForBatch: async function (opts3) {
|
|
384
|
+
if (!opts3 || typeof opts3 !== "object") {
|
|
385
|
+
throw new TypeError("codeMinter.codesForBatch: input object required");
|
|
386
|
+
}
|
|
387
|
+
_batchId(opts3.batch_id);
|
|
388
|
+
var limit = opts3.limit != null ? opts3.limit : 100;
|
|
389
|
+
if (typeof limit !== "number" || !Number.isInteger(limit) || limit < 1 || limit > MAX_LIST_LIMIT) {
|
|
390
|
+
throw new TypeError("codeMinter.codesForBatch: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
|
|
391
|
+
}
|
|
392
|
+
var sql = "SELECT id, batch_id, coupon_code, minted_at FROM code_batch_members WHERE batch_id = ?1";
|
|
393
|
+
var params = [opts3.batch_id];
|
|
394
|
+
if (opts3.cursor != null) {
|
|
395
|
+
if (typeof opts3.cursor !== "number" || !Number.isInteger(opts3.cursor) || opts3.cursor < 0) {
|
|
396
|
+
throw new TypeError("codeMinter.codesForBatch: cursor must be a non-negative integer epoch-ms");
|
|
397
|
+
}
|
|
398
|
+
// `id` is a UUIDv7 so the (minted_at, id) tuple is strictly
|
|
399
|
+
// increasing within a batch. We page on minted_at <= cursor
|
|
400
|
+
// with id < cursor_id; for the simpler "cursor = last
|
|
401
|
+
// minted_at" form we request rows with minted_at < cursor so a
|
|
402
|
+
// boundary on a tied timestamp doesn't double-return rows. In
|
|
403
|
+
// practice every member of one mintBatch call shares one
|
|
404
|
+
// minted_at, so the cursor for the second page falls strictly
|
|
405
|
+
// below it; ties only appear when an operator runs two
|
|
406
|
+
// mintBatch calls in the same millisecond, in which case the
|
|
407
|
+
// ORDER BY id tiebreak keeps the page boundary stable across
|
|
408
|
+
// requests.
|
|
409
|
+
sql += " AND minted_at < ?" + (params.length + 1);
|
|
410
|
+
params.push(opts3.cursor);
|
|
411
|
+
}
|
|
412
|
+
sql += " ORDER BY minted_at DESC, id DESC LIMIT ?" + (params.length + 1);
|
|
413
|
+
params.push(limit);
|
|
414
|
+
var r = await query(sql, params);
|
|
415
|
+
var rows = r.rows;
|
|
416
|
+
var nextCursor = rows.length === limit ? rows[rows.length - 1].minted_at : null;
|
|
417
|
+
return { rows: rows, next_cursor: nextCursor };
|
|
418
|
+
},
|
|
419
|
+
|
|
420
|
+
voidBatch: async function (input) {
|
|
421
|
+
if (!input || typeof input !== "object") {
|
|
422
|
+
throw new TypeError("codeMinter.voidBatch: input object required");
|
|
423
|
+
}
|
|
424
|
+
_batchId(input.batch_id);
|
|
425
|
+
_reason(input.reason);
|
|
426
|
+
var existing = await query(
|
|
427
|
+
"SELECT id, status FROM code_batches WHERE id = ?1",
|
|
428
|
+
[input.batch_id],
|
|
429
|
+
);
|
|
430
|
+
if (!existing.rows.length) {
|
|
431
|
+
var miss = new Error("codeMinter.voidBatch: batch_id not found");
|
|
432
|
+
miss.code = "CODE_BATCH_NOT_FOUND";
|
|
433
|
+
throw miss;
|
|
434
|
+
}
|
|
435
|
+
if (existing.rows[0].status === "voided") {
|
|
436
|
+
// Idempotent — operator double-clicked the void button.
|
|
437
|
+
return { batch_id: input.batch_id, status: "voided", archived: 0 };
|
|
438
|
+
}
|
|
439
|
+
var ts = _now();
|
|
440
|
+
// Walk every member and archive the backing coupon. The
|
|
441
|
+
// archive call is best-effort per code — a coupon that's
|
|
442
|
+
// already archived (because an operator hand-archived a single
|
|
443
|
+
// code) shouldn't block voiding the rest of the batch.
|
|
444
|
+
var members = await query(
|
|
445
|
+
"SELECT coupon_code FROM code_batch_members WHERE batch_id = ?1",
|
|
446
|
+
[input.batch_id],
|
|
447
|
+
);
|
|
448
|
+
var archived = 0;
|
|
449
|
+
for (var i = 0; i < members.rows.length; i += 1) {
|
|
450
|
+
var code = members.rows[i].coupon_code;
|
|
451
|
+
try {
|
|
452
|
+
await coupons.archive(code);
|
|
453
|
+
archived += 1;
|
|
454
|
+
} catch (_e) {
|
|
455
|
+
// Already-archived / not-found from the coupons surface is
|
|
456
|
+
// tolerated — the void operation's invariant is "every
|
|
457
|
+
// backing coupon is in the archived state when this returns
|
|
458
|
+
// successfully," not "this call performed every archive
|
|
459
|
+
// itself." A real storage failure surfaces on the next
|
|
460
|
+
// query below, which writes the batch state.
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
await query(
|
|
464
|
+
"UPDATE code_batches SET status = 'voided', void_reason = ?1, voided_at = ?2 WHERE id = ?3",
|
|
465
|
+
[input.reason, ts, input.batch_id],
|
|
466
|
+
);
|
|
467
|
+
return { batch_id: input.batch_id, status: "voided", archived: archived };
|
|
468
|
+
},
|
|
469
|
+
|
|
470
|
+
// Async-iterable yielding CSV chunks. First yield is the header
|
|
471
|
+
// row; each subsequent yield is one CRLF-terminated data row. The
|
|
472
|
+
// iterable walks members in (minted_at ASC, id ASC) order so two
|
|
473
|
+
// exports of the same batch produce byte-identical output (useful
|
|
474
|
+
// for re-printing a label sheet from the same source).
|
|
475
|
+
exportBatchCsv: function (input) {
|
|
476
|
+
if (!input || typeof input !== "object") {
|
|
477
|
+
throw new TypeError("codeMinter.exportBatchCsv: input object required");
|
|
478
|
+
}
|
|
479
|
+
_batchId(input.batch_id);
|
|
480
|
+
var pageSize = 500;
|
|
481
|
+
var batchId = input.batch_id;
|
|
482
|
+
var qHandle = query;
|
|
483
|
+
return {
|
|
484
|
+
[Symbol.asyncIterator]: function () {
|
|
485
|
+
var sentHeader = false;
|
|
486
|
+
var cursor = null;
|
|
487
|
+
var buffered = [];
|
|
488
|
+
var done = false;
|
|
489
|
+
return {
|
|
490
|
+
next: async function () {
|
|
491
|
+
if (!sentHeader) {
|
|
492
|
+
sentHeader = true;
|
|
493
|
+
return { value: "coupon_code,minted_at\r\n", done: false };
|
|
494
|
+
}
|
|
495
|
+
if (buffered.length === 0 && !done) {
|
|
496
|
+
var sql = "SELECT coupon_code, minted_at, id FROM code_batch_members WHERE batch_id = ?1";
|
|
497
|
+
var params = [batchId];
|
|
498
|
+
if (cursor != null) {
|
|
499
|
+
sql += " AND (minted_at > ?2 OR (minted_at = ?2 AND id > ?3))";
|
|
500
|
+
params.push(cursor.minted_at, cursor.id);
|
|
501
|
+
}
|
|
502
|
+
sql += " ORDER BY minted_at ASC, id ASC LIMIT ?" + (params.length + 1);
|
|
503
|
+
params.push(pageSize);
|
|
504
|
+
var r = await qHandle(sql, params);
|
|
505
|
+
if (r.rows.length < pageSize) done = true;
|
|
506
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
507
|
+
var row = r.rows[i];
|
|
508
|
+
// The coupon_code is constrained to the alphabet at
|
|
509
|
+
// mint time, so CSV escaping is a no-op for the
|
|
510
|
+
// value column — no commas, quotes, CR, or LF can
|
|
511
|
+
// appear. We still emit the minted_at as a bare
|
|
512
|
+
// integer (epoch-ms) so the CSV consumer can parse
|
|
513
|
+
// it without quote-stripping.
|
|
514
|
+
buffered.push(row.coupon_code + "," + row.minted_at + "\r\n");
|
|
515
|
+
cursor = { minted_at: row.minted_at, id: row.id };
|
|
516
|
+
}
|
|
517
|
+
if (r.rows.length === 0) done = true;
|
|
518
|
+
}
|
|
519
|
+
if (buffered.length) {
|
|
520
|
+
return { value: buffered.shift(), done: false };
|
|
521
|
+
}
|
|
522
|
+
return { value: undefined, done: true };
|
|
523
|
+
},
|
|
524
|
+
};
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
},
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
module.exports = {
|
|
532
|
+
create: create,
|
|
533
|
+
DEFAULT_ALPHABET: DEFAULT_ALPHABET,
|
|
534
|
+
STATUSES: STATUSES,
|
|
535
|
+
};
|