@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.
@@ -0,0 +1,596 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.giftOptions
4
+ * @title Gift options — per-order wrap / message / recipient / hide-prices
5
+ *
6
+ * @intro
7
+ * Three independent concerns folded into one primitive because
8
+ * they all live on the same packing slip and clear together when
9
+ * the order is cancelled:
10
+ *
11
+ * wrap — operator pre-defines a catalog SKU as a
12
+ * wrap option with a wrap fee. wrap_sku is a
13
+ * real catalog SKU so inventory + cost flow
14
+ * through the normal channels.
15
+ * gift_message — short customer-authored prose (≤ 500 chars,
16
+ * control-byte + zero-width-char free)
17
+ * rendered on the packing slip.
18
+ * recipient_name — for gift-to-someone-else orders (≤ 120
19
+ * chars, same hygiene as gift_message).
20
+ * hide_prices — toggle that suppresses prices on the slip
21
+ * (the "gift receipt" pattern).
22
+ *
23
+ * Composes:
24
+ * - `b.guardUuid` — order_id is UUID-shape-validated at every
25
+ * entry point; bad shape throws TypeError the calling route
26
+ * handler translates to HTTP 400.
27
+ * - `b.template.escapeHtml` — `renderPackingSlipLine` returns
28
+ * HTML-escaped strings ready for inline insertion into the
29
+ * packing-slip template.
30
+ *
31
+ * Surface:
32
+ * - `defineWrap({ wrap_sku, title, fee_minor, image_url?,
33
+ * max_per_order?, active })` — register a wrap
34
+ * option. Refuses unless wrap_sku is a real catalog variant.
35
+ * - `listWraps({ active_only? })` / `getWrap(wrap_sku)` /
36
+ * `updateWrap(wrap_sku, patch)` / `archiveWrap(wrap_sku)`.
37
+ * - `setForOrder({ order_id, wrap_sku?, gift_message?,
38
+ * recipient_name?, hide_prices? })` — UPSERT.
39
+ * - `getForOrder(order_id)` / `clearForOrder(order_id)`.
40
+ * - `feeForOrder(order_id)` — wrap fee_minor or 0.
41
+ * - `renderPackingSlipLine({ order_id, locale })` — returns
42
+ * `{ message_lines, recipient_name, hide_prices }` with
43
+ * HTML-escaped strings.
44
+ * - `analytics({ from, to })` — count of orders with options,
45
+ * top wrap_skus, gift-message rate.
46
+ *
47
+ * Storage:
48
+ * - `gift_wraps` + `gift_options` (migration
49
+ * `0046_gift_options.sql`).
50
+ *
51
+ * @primitive giftOptions
52
+ * @related b.guardUuid, b.template.escapeHtml
53
+ */
54
+
55
+ var MAX_TITLE_LEN = 200;
56
+ var MAX_IMAGE_URL_LEN = 2048;
57
+ var MAX_MESSAGE_LEN = 500;
58
+ var MAX_RECIPIENT_LEN = 120;
59
+
60
+ // SKU shape mirrors catalog.js — alnum + . _ -, ≤ 128 chars,
61
+ // leading char must be alnum so a wrap_sku can never start with a
62
+ // hyphen / dot (sidesteps shell-arg-style ambiguity in downstream
63
+ // CSV exports + the "looks like a flag" class of operator slips).
64
+ var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
65
+
66
+ // Refuse C0 control bytes + DEL. The gift message + recipient name
67
+ // render onto a packing slip and (potentially) a printer queue;
68
+ // embedded control bytes have caused header-injection-class slips
69
+ // in adjacent ecosystems. Newlines are allowed in gift_message
70
+ // (people write multi-line messages); the recipient name is a
71
+ // single line and refuses LF / CR too.
72
+ var CONTROL_BYTE_MSG_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
73
+ var CONTROL_BYTE_NAME_RE = /[\x00-\x1f\x7f]/;
74
+
75
+ // Zero-width / direction-override family — mirrors the order-notes
76
+ // primitive's catalogue: ZWSP/ZWNJ/ZWJ (U+200B-200D), LRM/RLM
77
+ // (U+200E/U+200F), the bidi-formatting block (U+202A-U+202E), the
78
+ // invisible-math block (U+2060-U+2064), the LRI/RLI/FSI/PDI block
79
+ // (U+2066-U+2069), the BOM (U+FEFF), and the Arabic letter mark
80
+ // (U+061C). Spelled with \u-escapes so ESLint's
81
+ // no-irregular-whitespace stays happy.
82
+ var ZERO_WIDTH_RE = new RegExp(
83
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
84
+ );
85
+
86
+ var ALLOWED_WRAP_COLUMNS = Object.freeze([
87
+ "title", "fee_minor", "image_url", "max_per_order", "active",
88
+ ]);
89
+
90
+ var bShop;
91
+ function _b() {
92
+ if (!bShop) bShop = require("./index");
93
+ return bShop.framework;
94
+ }
95
+
96
+ // ---- validators ---------------------------------------------------------
97
+
98
+ function _uuid(s, label) {
99
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
100
+ catch (e) { throw new TypeError("giftOptions: " + label + " — " + (e && e.message || "invalid UUID")); }
101
+ }
102
+
103
+ function _sku(s, label) {
104
+ if (typeof s !== "string" || !SKU_RE.test(s)) {
105
+ throw new TypeError("giftOptions: " + label + " must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, ≤ 128 chars)");
106
+ }
107
+ return s;
108
+ }
109
+
110
+ function _title(s) {
111
+ if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
112
+ throw new TypeError("giftOptions: title must be a non-empty string ≤ " + MAX_TITLE_LEN + " chars");
113
+ }
114
+ if (CONTROL_BYTE_NAME_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
115
+ throw new TypeError("giftOptions: title contains control / zero-width bytes");
116
+ }
117
+ return s;
118
+ }
119
+
120
+ function _feeMinor(n) {
121
+ if (!Number.isInteger(n) || n < 0) {
122
+ throw new TypeError("giftOptions: fee_minor must be a non-negative integer");
123
+ }
124
+ return n;
125
+ }
126
+
127
+ function _imageUrl(s) {
128
+ if (s == null) return null;
129
+ if (typeof s !== "string" || !s.length || s.length > MAX_IMAGE_URL_LEN) {
130
+ throw new TypeError("giftOptions: image_url must be a non-empty string ≤ " + MAX_IMAGE_URL_LEN + " chars");
131
+ }
132
+ // Restrict to https:// or // (protocol-relative) — http:// is a
133
+ // mixed-content liability on a storefront served over https, and
134
+ // javascript: / data: must never reach an <img src> on the
135
+ // checkout page. The browser-side CSP catches these too; this
136
+ // gate is defense-in-depth at the persistence layer.
137
+ if (!(/^https:\/\//.test(s) || /^\/\//.test(s) || /^\//.test(s))) {
138
+ throw new TypeError("giftOptions: image_url must be https://, // (protocol-relative), or / (absolute path)");
139
+ }
140
+ if (CONTROL_BYTE_NAME_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
141
+ throw new TypeError("giftOptions: image_url contains control / zero-width bytes");
142
+ }
143
+ return s;
144
+ }
145
+
146
+ function _maxPerOrder(n) {
147
+ if (n == null) return null;
148
+ if (!Number.isInteger(n) || n <= 0) {
149
+ throw new TypeError("giftOptions: max_per_order must be a positive integer or null");
150
+ }
151
+ return n;
152
+ }
153
+
154
+ function _bool(v, label) {
155
+ if (typeof v !== "boolean") {
156
+ throw new TypeError("giftOptions: " + label + " must be a boolean");
157
+ }
158
+ return v ? 1 : 0;
159
+ }
160
+
161
+ function _giftMessage(s) {
162
+ if (s == null) return null;
163
+ if (typeof s !== "string") {
164
+ throw new TypeError("giftOptions: gift_message must be a string");
165
+ }
166
+ if (s.length > MAX_MESSAGE_LEN) {
167
+ throw new TypeError("giftOptions: gift_message must be ≤ " + MAX_MESSAGE_LEN + " chars");
168
+ }
169
+ if (CONTROL_BYTE_MSG_RE.test(s)) {
170
+ throw new TypeError("giftOptions: gift_message contains control bytes");
171
+ }
172
+ if (ZERO_WIDTH_RE.test(s)) {
173
+ throw new TypeError("giftOptions: gift_message contains zero-width / direction-override characters");
174
+ }
175
+ return s;
176
+ }
177
+
178
+ function _recipientName(s) {
179
+ if (s == null) return null;
180
+ if (typeof s !== "string") {
181
+ throw new TypeError("giftOptions: recipient_name must be a string");
182
+ }
183
+ if (s.length > MAX_RECIPIENT_LEN) {
184
+ throw new TypeError("giftOptions: recipient_name must be ≤ " + MAX_RECIPIENT_LEN + " chars");
185
+ }
186
+ if (CONTROL_BYTE_NAME_RE.test(s)) {
187
+ throw new TypeError("giftOptions: recipient_name contains control bytes (incl. CR/LF)");
188
+ }
189
+ if (ZERO_WIDTH_RE.test(s)) {
190
+ throw new TypeError("giftOptions: recipient_name contains zero-width / direction-override characters");
191
+ }
192
+ return s;
193
+ }
194
+
195
+ function _epochMs(n, label) {
196
+ if (!Number.isInteger(n) || n < 0) {
197
+ throw new TypeError("giftOptions: " + label + " must be a non-negative integer (epoch ms)");
198
+ }
199
+ return n;
200
+ }
201
+
202
+ function _now() { return Date.now(); }
203
+
204
+ // ---- factory ------------------------------------------------------------
205
+
206
+ function create(opts) {
207
+ opts = opts || {};
208
+ var query = opts.query;
209
+ if (!query) {
210
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
211
+ }
212
+ // The catalog handle is required so `defineWrap` can verify that
213
+ // wrap_sku resolves to a real variant before persisting. Operators
214
+ // who'd run without a catalog handle would silently get wraps that
215
+ // point at no inventory row — refuse at factory time.
216
+ if (!opts.catalog) {
217
+ throw new TypeError("giftOptions.create: opts.catalog required (composes catalog.variants.bySku for wrap_sku resolution)");
218
+ }
219
+ var catalog = opts.catalog;
220
+
221
+ // -- gift_wraps surface -------------------------------------------------
222
+
223
+ async function defineWrap(input) {
224
+ if (!input || typeof input !== "object") {
225
+ throw new TypeError("giftOptions.defineWrap: input object required");
226
+ }
227
+ var wrapSku = _sku(input.wrap_sku, "wrap_sku");
228
+ var title = _title(input.title);
229
+ var feeMinor = _feeMinor(input.fee_minor);
230
+ var imageUrl = _imageUrl(input.image_url);
231
+ var maxPerOrder = _maxPerOrder(input.max_per_order);
232
+ var active = _bool(input.active, "active");
233
+
234
+ // wrap_sku must resolve to a real catalog variant so inventory
235
+ // + cost can flow through normal channels. Look it up via the
236
+ // catalog handle the factory was given.
237
+ var variant = await catalog.variants.bySku(wrapSku);
238
+ if (!variant) {
239
+ throw new TypeError("giftOptions.defineWrap: wrap_sku " + JSON.stringify(wrapSku) + " is not a known catalog variant");
240
+ }
241
+
242
+ var ts = _now();
243
+ await query(
244
+ "INSERT INTO gift_wraps (wrap_sku, title, fee_minor, image_url, max_per_order, active, archived_at, created_at, updated_at) " +
245
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?7)",
246
+ [wrapSku, title, feeMinor, imageUrl, maxPerOrder, active, ts],
247
+ );
248
+ return {
249
+ wrap_sku: wrapSku,
250
+ title: title,
251
+ fee_minor: feeMinor,
252
+ image_url: imageUrl,
253
+ max_per_order: maxPerOrder,
254
+ active: Boolean(active),
255
+ archived_at: null,
256
+ created_at: ts,
257
+ updated_at: ts,
258
+ };
259
+ }
260
+
261
+ function _hydrateWrapRow(r) {
262
+ if (!r) return null;
263
+ return {
264
+ wrap_sku: r.wrap_sku,
265
+ title: r.title,
266
+ fee_minor: Number(r.fee_minor),
267
+ image_url: r.image_url,
268
+ max_per_order: r.max_per_order == null ? null : Number(r.max_per_order),
269
+ active: Number(r.active) === 1,
270
+ archived_at: r.archived_at == null ? null : Number(r.archived_at),
271
+ created_at: Number(r.created_at),
272
+ updated_at: Number(r.updated_at),
273
+ };
274
+ }
275
+
276
+ async function listWraps(listOpts) {
277
+ listOpts = listOpts || {};
278
+ var activeOnly = false;
279
+ if (listOpts.active_only != null) {
280
+ if (typeof listOpts.active_only !== "boolean") {
281
+ throw new TypeError("giftOptions.listWraps: active_only must be a boolean");
282
+ }
283
+ activeOnly = listOpts.active_only;
284
+ }
285
+ var sql, params;
286
+ if (activeOnly) {
287
+ sql = "SELECT * FROM gift_wraps WHERE active = 1 AND archived_at IS NULL ORDER BY created_at ASC, wrap_sku ASC";
288
+ params = [];
289
+ } else {
290
+ sql = "SELECT * FROM gift_wraps ORDER BY created_at ASC, wrap_sku ASC";
291
+ params = [];
292
+ }
293
+ var rows = (await query(sql, params)).rows;
294
+ return rows.map(_hydrateWrapRow);
295
+ }
296
+
297
+ async function getWrap(wrapSku) {
298
+ _sku(wrapSku, "wrap_sku");
299
+ var r = (await query(
300
+ "SELECT * FROM gift_wraps WHERE wrap_sku = ?1 LIMIT 1",
301
+ [wrapSku],
302
+ )).rows[0];
303
+ return _hydrateWrapRow(r);
304
+ }
305
+
306
+ async function updateWrap(wrapSku, patch) {
307
+ _sku(wrapSku, "wrap_sku");
308
+ if (!patch || typeof patch !== "object") {
309
+ throw new TypeError("giftOptions.updateWrap: patch object required");
310
+ }
311
+ var keys = Object.keys(patch);
312
+ if (!keys.length) {
313
+ throw new TypeError("giftOptions.updateWrap: patch must include at least one column");
314
+ }
315
+ var sets = [];
316
+ var params = [];
317
+ var idx = 1;
318
+ for (var i = 0; i < keys.length; i += 1) {
319
+ var col = keys[i];
320
+ // Lock the patch surface to a known column set — composing
321
+ // b.safeSql.assertOneOf would be the canonical path, but the
322
+ // primitive's column count is tiny enough that an explicit
323
+ // Object.freeze list is just as defensible and keeps the
324
+ // detector greps for unsafe column-name concatenation green.
325
+ if (ALLOWED_WRAP_COLUMNS.indexOf(col) === -1) {
326
+ throw new TypeError("giftOptions.updateWrap: unsupported column " + JSON.stringify(col));
327
+ }
328
+ var v;
329
+ if (col === "title") v = _title(patch[col]);
330
+ else if (col === "fee_minor") v = _feeMinor(patch[col]);
331
+ else if (col === "image_url") v = _imageUrl(patch[col]);
332
+ else if (col === "max_per_order") v = _maxPerOrder(patch[col]);
333
+ else /* active */ v = _bool(patch[col], "active");
334
+ sets.push(col + " = ?" + idx);
335
+ params.push(v);
336
+ idx += 1;
337
+ }
338
+ sets.push("updated_at = ?" + idx);
339
+ params.push(_now());
340
+ idx += 1;
341
+ params.push(wrapSku);
342
+ var r = await query(
343
+ "UPDATE gift_wraps SET " + sets.join(", ") + " WHERE wrap_sku = ?" + idx,
344
+ params,
345
+ );
346
+ if (Number(r.rowCount || 0) === 0) {
347
+ throw new TypeError("giftOptions.updateWrap: wrap_sku " + JSON.stringify(wrapSku) + " not found");
348
+ }
349
+ return await getWrap(wrapSku);
350
+ }
351
+
352
+ async function archiveWrap(wrapSku) {
353
+ _sku(wrapSku, "wrap_sku");
354
+ var ts = _now();
355
+ var r = await query(
356
+ "UPDATE gift_wraps SET active = 0, archived_at = ?1, updated_at = ?1 WHERE wrap_sku = ?2",
357
+ [ts, wrapSku],
358
+ );
359
+ if (Number(r.rowCount || 0) === 0) {
360
+ throw new TypeError("giftOptions.archiveWrap: wrap_sku " + JSON.stringify(wrapSku) + " not found");
361
+ }
362
+ return await getWrap(wrapSku);
363
+ }
364
+
365
+ // -- gift_options surface ----------------------------------------------
366
+
367
+ function _hydrateOptionsRow(r) {
368
+ if (!r) return null;
369
+ return {
370
+ order_id: r.order_id,
371
+ wrap_sku: r.wrap_sku,
372
+ gift_message: r.gift_message,
373
+ recipient_name: r.recipient_name,
374
+ hide_prices: Number(r.hide_prices) === 1,
375
+ set_at: Number(r.set_at),
376
+ updated_at: Number(r.updated_at),
377
+ };
378
+ }
379
+
380
+ async function setForOrder(input) {
381
+ if (!input || typeof input !== "object") {
382
+ throw new TypeError("giftOptions.setForOrder: input object required");
383
+ }
384
+ var orderId = _uuid(input.order_id, "order_id");
385
+
386
+ var wrapSku = null;
387
+ if (input.wrap_sku != null) {
388
+ _sku(input.wrap_sku, "wrap_sku");
389
+ // Refuse if the wrap_sku isn't a defined, active, non-archived
390
+ // wrap. Archived wraps stay reachable via getForOrder for
391
+ // older orders, but setForOrder can't attach a new order to a
392
+ // wrap the operator has retired.
393
+ var wrap = (await query(
394
+ "SELECT * FROM gift_wraps WHERE wrap_sku = ?1 LIMIT 1",
395
+ [input.wrap_sku],
396
+ )).rows[0];
397
+ if (!wrap) {
398
+ throw new TypeError("giftOptions.setForOrder: wrap_sku " + JSON.stringify(input.wrap_sku) + " is not a defined wrap");
399
+ }
400
+ if (Number(wrap.active) !== 1 || wrap.archived_at != null) {
401
+ throw new TypeError("giftOptions.setForOrder: wrap_sku " + JSON.stringify(input.wrap_sku) + " is archived / inactive");
402
+ }
403
+ wrapSku = input.wrap_sku;
404
+ }
405
+
406
+ var giftMessage = _giftMessage(input.gift_message);
407
+ var recipientName = _recipientName(input.recipient_name);
408
+
409
+ var hidePrices = 0;
410
+ if (input.hide_prices != null) {
411
+ hidePrices = _bool(input.hide_prices, "hide_prices");
412
+ }
413
+
414
+ var ts = _now();
415
+ // UPSERT — one gift_options row per order. Re-running with
416
+ // different inputs replaces every column (the operator UI
417
+ // re-submits the full state each time the customer edits gift
418
+ // options, so a partial UPSERT would silently retain stale
419
+ // fields).
420
+ await query(
421
+ "INSERT INTO gift_options (order_id, wrap_sku, gift_message, recipient_name, hide_prices, set_at, updated_at) " +
422
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6) " +
423
+ "ON CONFLICT (order_id) DO UPDATE SET " +
424
+ " wrap_sku = excluded.wrap_sku, " +
425
+ " gift_message = excluded.gift_message, " +
426
+ " recipient_name = excluded.recipient_name, " +
427
+ " hide_prices = excluded.hide_prices, " +
428
+ " updated_at = excluded.updated_at",
429
+ [orderId, wrapSku, giftMessage, recipientName, hidePrices, ts],
430
+ );
431
+ return await getForOrder(orderId);
432
+ }
433
+
434
+ async function getForOrder(orderId) {
435
+ var oid = _uuid(orderId, "order_id");
436
+ var r = (await query(
437
+ "SELECT * FROM gift_options WHERE order_id = ?1 LIMIT 1",
438
+ [oid],
439
+ )).rows[0];
440
+ return _hydrateOptionsRow(r);
441
+ }
442
+
443
+ async function clearForOrder(orderId) {
444
+ var oid = _uuid(orderId, "order_id");
445
+ var r = await query(
446
+ "DELETE FROM gift_options WHERE order_id = ?1",
447
+ [oid],
448
+ );
449
+ return { cleared: Number(r.rowCount || 0) > 0 };
450
+ }
451
+
452
+ async function feeForOrder(orderId) {
453
+ var oid = _uuid(orderId, "order_id");
454
+ // Single JOIN read — pulls the wrap fee in one round-trip
455
+ // rather than two reads (gift_options then gift_wraps).
456
+ var r = (await query(
457
+ "SELECT gw.fee_minor AS fee_minor " +
458
+ "FROM gift_options go " +
459
+ "JOIN gift_wraps gw ON gw.wrap_sku = go.wrap_sku " +
460
+ "WHERE go.order_id = ?1 LIMIT 1",
461
+ [oid],
462
+ )).rows[0];
463
+ return r ? Number(r.fee_minor) : 0;
464
+ }
465
+
466
+ // -- packing-slip render ------------------------------------------------
467
+
468
+ async function renderPackingSlipLine(input) {
469
+ if (!input || typeof input !== "object") {
470
+ throw new TypeError("giftOptions.renderPackingSlipLine: input object required");
471
+ }
472
+ var oid = _uuid(input.order_id, "order_id");
473
+ // Locale is captured for future per-locale renderers — today
474
+ // the message itself is operator-authored prose and isn't
475
+ // translated, but the renderer surfaces the locale so a
476
+ // downstream slip template can pick a language-appropriate
477
+ // header. Refuse anything that isn't a BCP-47-shape string.
478
+ var locale = input.locale;
479
+ if (locale == null || typeof locale !== "string" || !/^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8})*$/.test(locale)) {
480
+ throw new TypeError("giftOptions.renderPackingSlipLine: locale must be a BCP-47-shape string (e.g. 'en-US')");
481
+ }
482
+
483
+ var row = (await query(
484
+ "SELECT * FROM gift_options WHERE order_id = ?1 LIMIT 1",
485
+ [oid],
486
+ )).rows[0];
487
+ if (!row) {
488
+ return {
489
+ message_lines: [],
490
+ recipient_name: null,
491
+ hide_prices: false,
492
+ };
493
+ }
494
+
495
+ var escapeHtml = _b().template.escapeHtml;
496
+
497
+ // Split the message on LF (and the rare CRLF) so the packing-
498
+ // slip template can emit one <div> per line without parsing
499
+ // raw newlines downstream. Trailing empty lines are dropped to
500
+ // keep the slip tidy when the customer typed an extra newline
501
+ // at the end.
502
+ var messageLines = [];
503
+ if (row.gift_message) {
504
+ var raw = String(row.gift_message).replace(/\r\n/g, "\n").split("\n");
505
+ while (raw.length && raw[raw.length - 1] === "") raw.pop();
506
+ messageLines = raw.map(function (line) { return escapeHtml(line); });
507
+ }
508
+
509
+ return {
510
+ message_lines: messageLines,
511
+ recipient_name: row.recipient_name ? escapeHtml(row.recipient_name) : null,
512
+ hide_prices: Number(row.hide_prices) === 1,
513
+ locale: locale,
514
+ };
515
+ }
516
+
517
+ // -- analytics ----------------------------------------------------------
518
+
519
+ async function analytics(input) {
520
+ if (!input || typeof input !== "object") {
521
+ throw new TypeError("giftOptions.analytics: input object required");
522
+ }
523
+ var from = _epochMs(input.from, "from");
524
+ var to = _epochMs(input.to, "to");
525
+ if (to < from) {
526
+ throw new TypeError("giftOptions.analytics: to must be >= from");
527
+ }
528
+
529
+ // Total orders with any gift option in the window — counts
530
+ // every row regardless of which field(s) are set.
531
+ var totalRow = (await query(
532
+ "SELECT COUNT(*) AS n FROM gift_options WHERE set_at >= ?1 AND set_at < ?2",
533
+ [from, to],
534
+ )).rows[0];
535
+ var totalOrders = Number((totalRow || {}).n || 0);
536
+
537
+ // Top wrap_skus — orders with a non-NULL wrap_sku grouped by
538
+ // the wrap. Capped at 10 because the operator dashboard
539
+ // doesn't need an unbounded list.
540
+ var topWraps = (await query(
541
+ "SELECT wrap_sku, COUNT(*) AS n FROM gift_options " +
542
+ "WHERE set_at >= ?1 AND set_at < ?2 AND wrap_sku IS NOT NULL " +
543
+ "GROUP BY wrap_sku ORDER BY n DESC, wrap_sku ASC LIMIT 10",
544
+ [from, to],
545
+ )).rows.map(function (r) {
546
+ return { wrap_sku: r.wrap_sku, count: Number(r.n) };
547
+ });
548
+
549
+ // Gift-message rate — fraction of gift_options rows in the
550
+ // window that carry a non-NULL message. Returned as a float in
551
+ // [0, 1] so the dashboard can render as a percentage without
552
+ // worrying about division-by-zero.
553
+ var messageRow = (await query(
554
+ "SELECT COUNT(*) AS n FROM gift_options " +
555
+ "WHERE set_at >= ?1 AND set_at < ?2 AND gift_message IS NOT NULL AND gift_message != ''",
556
+ [from, to],
557
+ )).rows[0];
558
+ var messageCount = Number((messageRow || {}).n || 0);
559
+ var messageRate = totalOrders > 0 ? messageCount / totalOrders : 0;
560
+
561
+ return {
562
+ from: from,
563
+ to: to,
564
+ orders_with_gift: totalOrders,
565
+ top_wrap_skus: topWraps,
566
+ gift_message_count: messageCount,
567
+ gift_message_rate: messageRate,
568
+ };
569
+ }
570
+
571
+ return {
572
+ MAX_TITLE_LEN: MAX_TITLE_LEN,
573
+ MAX_MESSAGE_LEN: MAX_MESSAGE_LEN,
574
+ MAX_RECIPIENT_LEN: MAX_RECIPIENT_LEN,
575
+
576
+ defineWrap: defineWrap,
577
+ listWraps: listWraps,
578
+ getWrap: getWrap,
579
+ updateWrap: updateWrap,
580
+ archiveWrap: archiveWrap,
581
+
582
+ setForOrder: setForOrder,
583
+ getForOrder: getForOrder,
584
+ clearForOrder: clearForOrder,
585
+ feeForOrder: feeForOrder,
586
+ renderPackingSlipLine: renderPackingSlipLine,
587
+ analytics: analytics,
588
+ };
589
+ }
590
+
591
+ module.exports = {
592
+ create: create,
593
+ MAX_TITLE_LEN: MAX_TITLE_LEN,
594
+ MAX_MESSAGE_LEN: MAX_MESSAGE_LEN,
595
+ MAX_RECIPIENT_LEN: MAX_RECIPIENT_LEN,
596
+ };
package/lib/index.js CHANGED
@@ -79,4 +79,20 @@ module.exports = {
79
79
  orderExport: require("./order-export"),
80
80
  printOnDemand: require("./print-on-demand"),
81
81
  saveForLater: require("./save-for-later"),
82
+ salesReports: require("./sales-reports"),
83
+ quantityDiscounts: require("./quantity-discounts"),
84
+ subscriptionControls: require("./subscription-controls"),
85
+ giftOptions: require("./gift-options"),
86
+ supportTickets: require("./support-tickets"),
87
+ collections: require("./collections"),
88
+ customerSegments: require("./customer-segments"),
89
+ recentlyViewed: require("./recently-viewed"),
90
+ stockAlerts: require("./stock-alerts"),
91
+ shippingLabels: require("./shipping-labels"),
92
+ returnLabels: require("./return-labels"),
93
+ promoBanners: require("./promo-banners"),
94
+ searchSynonyms: require("./search-synonyms"),
95
+ affiliates: require("./affiliates"),
96
+ mailingAudiences: require("./mailing-audiences"),
97
+ orderTimeline: require("./order-timeline"),
82
98
  };