@blamejs/blamejs-shop 0.0.65 → 0.0.70

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.
Files changed (54) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/lib/assembly-instructions.js +777 -0
  3. package/lib/auto-replenish.js +933 -0
  4. package/lib/business-hours.js +980 -0
  5. package/lib/click-and-collect.js +711 -0
  6. package/lib/clickstream.js +713 -0
  7. package/lib/cost-layers.js +774 -0
  8. package/lib/credit-limits.js +752 -0
  9. package/lib/currency-rounding.js +525 -0
  10. package/lib/customer-activity.js +862 -0
  11. package/lib/customer-notes.js +712 -0
  12. package/lib/customer-risk-profile.js +593 -0
  13. package/lib/customer-surveys.js +1012 -0
  14. package/lib/damage-photos.js +473 -0
  15. package/lib/discount-allocation.js +557 -0
  16. package/lib/dropship-forwarding.js +645 -0
  17. package/lib/email-templates.js +817 -0
  18. package/lib/index.js +45 -0
  19. package/lib/inventory-allocations.js +559 -0
  20. package/lib/inventory-writeoffs.js +636 -0
  21. package/lib/knowledge-base.js +1104 -0
  22. package/lib/locale-router.js +1077 -0
  23. package/lib/operator-roles.js +768 -0
  24. package/lib/order-escalation.js +951 -0
  25. package/lib/order-ratings.js +495 -0
  26. package/lib/order-tags.js +944 -0
  27. package/lib/packing-slips.js +810 -0
  28. package/lib/payment-retries.js +816 -0
  29. package/lib/pick-lists.js +639 -0
  30. package/lib/pixel-events.js +995 -0
  31. package/lib/preorder.js +595 -0
  32. package/lib/print-queue.js +681 -0
  33. package/lib/product-qa.js +749 -0
  34. package/lib/promo-bundles.js +835 -0
  35. package/lib/push-notifications.js +937 -0
  36. package/lib/refund-automation.js +853 -0
  37. package/lib/reorder-reminders.js +798 -0
  38. package/lib/robots-config.js +753 -0
  39. package/lib/seller-signup.js +1052 -0
  40. package/lib/site-redirects.js +690 -0
  41. package/lib/sitemap-generator.js +717 -0
  42. package/lib/subscription-gifts.js +710 -0
  43. package/lib/tax-cert-renewals.js +632 -0
  44. package/lib/theme-assets.js +711 -0
  45. package/lib/tier-benefits.js +776 -0
  46. package/lib/vendor/MANIFEST.json +2 -2
  47. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  48. package/lib/vendor/blamejs/api-snapshot.json +2 -2
  49. package/lib/vendor/blamejs/lib/metrics.js +68 -4
  50. package/lib/vendor/blamejs/package.json +1 -1
  51. package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
  52. package/lib/wishlist-alerts.js +842 -0
  53. package/lib/wishlist-sharing.js +718 -0
  54. package/package.json +1 -1
@@ -0,0 +1,645 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.dropshipForwarding
4
+ * @title Dropship forwarding — general-purpose drop-ship vendor
5
+ * binding + per-order forward-to-vendor record
6
+ *
7
+ * @intro
8
+ * Operators selling drop-shipped goods (no inventory on hand;
9
+ * the vendor stocks + ships) bind each storefront SKU to a
10
+ * vendor `slug` + per-unit `cost_minor` (wholesale) + lead-time
11
+ * + return-policy snapshot. When an order containing those SKUs
12
+ * completes, `forwardOrder` writes one `dropship_forwardings`
13
+ * row per (order, vendor, SKU) so each line carries its own
14
+ * vendor_ref + carrier + tracking_number on the return leg —
15
+ * the vendor's downstream system stamps a per-line PO and the
16
+ * shop's worker reconciles the per-line shipment back to the
17
+ * customer order.
18
+ *
19
+ * Distinct from `printOnDemand` (which bundles every line from
20
+ * one supplier into a single row + carries supplier-product-id
21
+ * + supplier-variant-id + artwork-url + position for the print
22
+ * pipeline). This primitive is the supplier-agnostic
23
+ * counterpart: any vendor that fulfills physical goods on the
24
+ * operator's behalf can be bound, with no print-specific
25
+ * metadata in the way.
26
+ *
27
+ * The HTTP call to the vendor lives in the operator's worker —
28
+ * each vendor has its own auth shape + endpoint surface, and
29
+ * pinning a wire-format choice into the framework would shut
30
+ * out the operator whose vendor isn't yet wired. The worker
31
+ * reads `pendingForwardings`, calls the vendor, then calls back
32
+ * into this primitive's `markVendorAccepted` /
33
+ * `markVendorShipped` / `markVendorDelivered` /
34
+ * `markVendorFailed` / `markVendorReturned` to record each
35
+ * beat.
36
+ *
37
+ * Lifecycle (six-state FSM):
38
+ *
39
+ * queued — written by forwardOrder
40
+ * accepted — vendor acknowledged
41
+ * shipped — vendor handed to carrier (carrier + tracking)
42
+ * delivered — carrier confirmed delivery
43
+ * failed — terminal; vendor refused or aborted
44
+ * returned — terminal; customer returned
45
+ *
46
+ * Allowed transitions:
47
+ * queued -> accepted, failed
48
+ * accepted -> shipped, failed
49
+ * shipped -> delivered, failed, returned
50
+ * delivered -> returned
51
+ *
52
+ * Composition:
53
+ * - b.uuid.v7 — forwarding row PKs (sortable so audit
54
+ * queries sort cleanly without an extra
55
+ * index)
56
+ * - b.guardUuid — strict UUID validation on read verbs +
57
+ * transition entry points
58
+ * - b.fsm — dropship_forwarding status FSM with the
59
+ * transitions above
60
+ * - b.audit — FSM emits transition events under "fsm"
61
+ *
62
+ * Storage: `migrations-d1/0154_dropship_forwarding.sql`.
63
+ *
64
+ * @primitive dropshipForwarding
65
+ * @related b.fsm, b.uuid, shop.printOnDemand, shop.vendors
66
+ */
67
+
68
+ var bShop;
69
+ function _b() {
70
+ if (!bShop) bShop = require("./index");
71
+ return bShop.framework;
72
+ }
73
+
74
+ // ---- constants ----------------------------------------------------------
75
+
76
+ var FORWARDING_STATUSES = Object.freeze([
77
+ "queued", "accepted", "shipped", "delivered", "failed", "returned",
78
+ ]);
79
+
80
+ // SKU shape matches the catalog + print-on-demand convention: alnum
81
+ // + . _ - with a 128-char ceiling. Vendor slug uses the lowercase
82
+ // alnum + dash convention shared with the vendors primitive (the
83
+ // two registries don't share a FK but they share an operator's
84
+ // muscle memory).
85
+ var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
86
+ var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
87
+ var CURRENCY_RE = /^[A-Z]{3}$/;
88
+ var CONTROL_BYTE_RE = /[\x00-\x1f\x7f]/;
89
+
90
+ var MAX_RETURN_POLICY_LEN = 4000;
91
+ var MAX_VENDOR_REF_LEN = 256;
92
+ var MAX_CARRIER_LEN = 64;
93
+ var MAX_TRACKING_LEN = 128;
94
+ var MAX_FAIL_REASON_LEN = 4000;
95
+ var MAX_ADDRESS_JSON_LEN = 8192;
96
+ var MAX_LEAD_TIME_DAYS = 365;
97
+ var MAX_LIST_LIMIT = 200;
98
+ var MAX_LINES = 1000;
99
+
100
+ // ---- FSM definition -----------------------------------------------------
101
+
102
+ var _dsFsm = null;
103
+ function _getDropshipFsm() {
104
+ if (_dsFsm) return _dsFsm;
105
+ // b.fsm emits audit events under the "fsm" namespace — register
106
+ // idempotently so the audit sink keeps the events instead of
107
+ // dropping them with a noisy warning.
108
+ try { _b().audit.registerNamespace("fsm"); } catch (_e) { /* idempotent — sink already knows about the namespace */ }
109
+ _dsFsm = _b().fsm.define({
110
+ name: "dropship_forwarding",
111
+ initial: "queued",
112
+ states: {
113
+ queued: {},
114
+ accepted: {},
115
+ shipped: {},
116
+ delivered: {},
117
+ failed: {},
118
+ returned: {},
119
+ },
120
+ transitions: [
121
+ { from: "queued", to: "accepted", on: "accept" },
122
+ { from: "queued", to: "failed", on: "fail" },
123
+ { from: "accepted", to: "shipped", on: "ship" },
124
+ { from: "accepted", to: "failed", on: "fail" },
125
+ { from: "shipped", to: "delivered", on: "deliver" },
126
+ { from: "shipped", to: "failed", on: "fail" },
127
+ { from: "shipped", to: "returned", on: "return" },
128
+ { from: "delivered", to: "returned", on: "return" },
129
+ ],
130
+ });
131
+ return _dsFsm;
132
+ }
133
+
134
+ // ---- validators ---------------------------------------------------------
135
+
136
+ function _uuid(s, label) {
137
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
138
+ catch (e) {
139
+ throw new TypeError("dropship-forwarding: " + label + " — " + (e && e.message || "invalid UUID"));
140
+ }
141
+ }
142
+ function _sku(s) {
143
+ if (typeof s !== "string" || !SKU_RE.test(s)) {
144
+ throw new TypeError("dropship-forwarding: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
145
+ }
146
+ }
147
+ function _slug(s, label) {
148
+ if (typeof s !== "string" || !SLUG_RE.test(s)) {
149
+ throw new TypeError("dropship-forwarding: " + (label || "vendor_slug") +
150
+ " must be lowercase alnum + dash, no leading/trailing dash, 1..64 chars");
151
+ }
152
+ }
153
+ function _costMinor(n) {
154
+ if (!Number.isInteger(n) || n < 0) {
155
+ throw new TypeError("dropship-forwarding: cost_minor must be a non-negative integer (minor units)");
156
+ }
157
+ }
158
+ function _currency(s) {
159
+ if (typeof s !== "string" || !CURRENCY_RE.test(s)) {
160
+ throw new TypeError("dropship-forwarding: currency must be a 3-letter uppercase ISO-4217 code");
161
+ }
162
+ }
163
+ function _leadTimeDays(n) {
164
+ if (!Number.isInteger(n) || n < 0 || n > MAX_LEAD_TIME_DAYS) {
165
+ throw new TypeError("dropship-forwarding: lead_time_days must be an integer 0.." + MAX_LEAD_TIME_DAYS);
166
+ }
167
+ }
168
+ function _returnPolicy(s) {
169
+ if (typeof s !== "string" || !s.length || s.length > MAX_RETURN_POLICY_LEN) {
170
+ throw new TypeError("dropship-forwarding: return_policy must be a non-empty string <= " + MAX_RETURN_POLICY_LEN + " chars");
171
+ }
172
+ if (CONTROL_BYTE_RE.test(s)) {
173
+ throw new TypeError("dropship-forwarding: return_policy must not contain control bytes");
174
+ }
175
+ }
176
+ function _positiveInt(n, label) {
177
+ if (!Number.isInteger(n) || n <= 0) {
178
+ throw new TypeError("dropship-forwarding: " + label + " must be a positive integer");
179
+ }
180
+ }
181
+ function _shippingAddress(addr) {
182
+ if (!addr || typeof addr !== "object" || Array.isArray(addr)) {
183
+ throw new TypeError("dropship-forwarding: shipping_address must be a plain object");
184
+ }
185
+ if (typeof addr.country !== "string" || !/^[A-Z]{2}$/.test(addr.country)) {
186
+ throw new TypeError("dropship-forwarding: shipping_address.country must be a 2-letter ISO 3166-1 code (uppercase)");
187
+ }
188
+ var encoded;
189
+ try { encoded = JSON.stringify(addr); }
190
+ catch (_e) {
191
+ throw new TypeError("dropship-forwarding: shipping_address must be JSON-serialisable");
192
+ }
193
+ if (encoded.length > MAX_ADDRESS_JSON_LEN) {
194
+ throw new TypeError("dropship-forwarding: shipping_address JSON must be <= " +
195
+ MAX_ADDRESS_JSON_LEN + " characters serialised");
196
+ }
197
+ return encoded;
198
+ }
199
+ function _shortText(s, label, max) {
200
+ if (typeof s !== "string" || !s.length || s.length > max) {
201
+ throw new TypeError("dropship-forwarding: " + label + " must be a non-empty string <= " + max + " chars");
202
+ }
203
+ if (CONTROL_BYTE_RE.test(s)) {
204
+ throw new TypeError("dropship-forwarding: " + label + " must not contain control bytes");
205
+ }
206
+ }
207
+ function _optTimestamp(n, label) {
208
+ if (n == null) return null;
209
+ if (!Number.isInteger(n) || n < 0) {
210
+ throw new TypeError("dropship-forwarding: " + label + " must be a non-negative integer (epoch ms) when provided");
211
+ }
212
+ return n;
213
+ }
214
+ function _limit(n) {
215
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
216
+ throw new TypeError("dropship-forwarding: limit must be an integer in 1..." + MAX_LIST_LIMIT);
217
+ }
218
+ }
219
+ function _windowTs(n, label) {
220
+ if (!Number.isInteger(n) || n < 0) {
221
+ throw new TypeError("dropship-forwarding: " + label + " must be a non-negative integer (epoch ms)");
222
+ }
223
+ return n;
224
+ }
225
+
226
+ // ---- monotonic clock ---------------------------------------------------
227
+ //
228
+ // Operator-driven FSM transitions can land in the same millisecond
229
+ // on fast machines (markVendorAccepted immediately followed by
230
+ // markVendorShipped in a test, for instance). Bumping by 1ms on a
231
+ // tie keeps the timeline strictly increasing so a sort-by-occurred_at
232
+ // read returns the events in the order they were issued and the
233
+ // per-vendor metrics window contains the writes the test just made
234
+ // without a wall-clock race.
235
+
236
+ var _lastTs = 0;
237
+ function _now() {
238
+ var t = Date.now();
239
+ if (t <= _lastTs) { t = _lastTs + 1; }
240
+ _lastTs = t;
241
+ return t;
242
+ }
243
+
244
+ // ---- factory ------------------------------------------------------------
245
+
246
+ function create(opts) {
247
+ opts = opts || {};
248
+ var query = opts.query;
249
+ if (!query) {
250
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
251
+ }
252
+ // Optional handles. `vendors` lets the primitive refuse a bind
253
+ // against an unknown / archived vendor slug when the operator is
254
+ // running the vendors registry. `orderTracking` is reserved for
255
+ // future composition — accepted at the factory so the surface
256
+ // doesn't break when a downstream operator wires it for an event
257
+ // emission on each FSM beat.
258
+ var vendors = opts.vendors || null;
259
+ var orderTracking = opts.orderTracking || null;
260
+ void orderTracking;
261
+
262
+ // Hydrate a binding row into the operator-facing shape (no
263
+ // wire-format columns leak; the table layout IS the read shape).
264
+ async function _getBindingRow(sku) {
265
+ var r = await query("SELECT * FROM dropship_bindings WHERE sku = ?1", [sku]);
266
+ if (!r.rows.length) return null;
267
+ return r.rows[0];
268
+ }
269
+
270
+ async function _getForwardingRow(id) {
271
+ var r = await query("SELECT * FROM dropship_forwardings WHERE id = ?1", [id]);
272
+ if (!r.rows.length) return null;
273
+ var row = r.rows[0];
274
+ row.shipping_address = row.shipping_address_json
275
+ ? JSON.parse(row.shipping_address_json) : {};
276
+ return row;
277
+ }
278
+
279
+ // Replay the FSM against the row's current status, dispatch the
280
+ // transition, and apply the matching column patch. Surfaces b.fsm
281
+ // refusal as a typed error with the original cause attached so a
282
+ // worker can distinguish "wrong state" from "unknown row" without
283
+ // string-sniffing.
284
+ async function _transitionForwarding(forwardingId, event, patch) {
285
+ var current = await _getForwardingRow(forwardingId);
286
+ if (!current) {
287
+ throw new TypeError("dropship-forwarding: forwarding " + forwardingId + " not found");
288
+ }
289
+ var fsm = _getDropshipFsm();
290
+ var instance = fsm.restore({
291
+ state: current.status,
292
+ history: [],
293
+ context: {},
294
+ });
295
+ try {
296
+ await instance.transition(event, null);
297
+ } catch (e) {
298
+ var err = new Error("dropship-forwarding: forwarding transition refused — " + (e && e.message || e));
299
+ err.code = (e && e.code) || "DROPSHIP_FORWARDING_TRANSITION_REFUSED";
300
+ err.cause = e;
301
+ throw err;
302
+ }
303
+ var sets = ["status = ?1"];
304
+ var params = [instance.state];
305
+ var p = 2;
306
+ var keys = Object.keys(patch || {});
307
+ for (var i = 0; i < keys.length; i += 1) {
308
+ var k = keys[i];
309
+ // Each transition writes a known fixed set of columns; the
310
+ // patch keys come from the primitive itself, never from
311
+ // operator-controlled input. safeSql.assertOneOf is the
312
+ // belt-and-braces gate so a future patch can't slip in an
313
+ // operator-controlled column name.
314
+ _b().safeSql.assertOneOf(k, [
315
+ "vendor_ref", "eta", "carrier", "tracking_number",
316
+ "shipped_at", "delivered_at", "failed_at", "returned_at",
317
+ "fail_reason",
318
+ ]);
319
+ sets.push(k + " = ?" + p);
320
+ params.push(patch[k]);
321
+ p += 1;
322
+ }
323
+ params.push(forwardingId);
324
+ await query(
325
+ "UPDATE dropship_forwardings SET " + sets.join(", ") + " WHERE id = ?" + p,
326
+ params,
327
+ );
328
+ return await _getForwardingRow(forwardingId);
329
+ }
330
+
331
+ return {
332
+ FORWARDING_STATUSES: FORWARDING_STATUSES,
333
+
334
+ bindSkuToVendor: async function (input) {
335
+ if (!input || typeof input !== "object") {
336
+ throw new TypeError("dropship-forwarding.bindSkuToVendor: input object required");
337
+ }
338
+ _sku(input.sku);
339
+ _slug(input.vendor_slug, "vendor_slug");
340
+ _costMinor(input.cost_minor);
341
+ _currency(input.currency);
342
+ _leadTimeDays(input.lead_time_days);
343
+ _returnPolicy(input.return_policy);
344
+
345
+ // Optional vendors-registry consistency: when the operator
346
+ // wires the vendors primitive at factory time, refuse a bind
347
+ // against an unknown or archived vendor slug. Without a
348
+ // registry the primitive trusts the operator-supplied slug
349
+ // (operators may run a flat vendor list outside the
350
+ // registry).
351
+ if (vendors && typeof vendors.getVendor === "function") {
352
+ var v = await vendors.getVendor(input.vendor_slug);
353
+ if (!v) {
354
+ throw new TypeError("dropship-forwarding.bindSkuToVendor: vendor_slug " +
355
+ JSON.stringify(input.vendor_slug) + " not found in vendors registry");
356
+ }
357
+ if (v.status === "archived") {
358
+ throw new TypeError("dropship-forwarding.bindSkuToVendor: vendor_slug " +
359
+ JSON.stringify(input.vendor_slug) + " is archived");
360
+ }
361
+ }
362
+
363
+ // Refuse rebind via bindSkuToVendor — operators that want to
364
+ // switch vendor for an existing SKU explicitly unbind first
365
+ // (a future verb; until then operators DELETE the row out of
366
+ // band when migrating vendors). The refusal keeps the create
367
+ // path single-purpose and surfaces the "switching vendor"
368
+ // choice explicitly.
369
+ var existing = await _getBindingRow(input.sku);
370
+ if (existing) {
371
+ throw new TypeError("dropship-forwarding.bindSkuToVendor: sku " +
372
+ JSON.stringify(input.sku) + " already bound to " + JSON.stringify(existing.vendor_slug) +
373
+ " — DELETE the binding row before rebinding");
374
+ }
375
+
376
+ var ts = _now();
377
+ await query(
378
+ "INSERT INTO dropship_bindings (sku, vendor_slug, cost_minor, currency, " +
379
+ "lead_time_days, return_policy, archived_at, created_at, updated_at) " +
380
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, ?7, ?7)",
381
+ [
382
+ input.sku, input.vendor_slug, input.cost_minor, input.currency,
383
+ input.lead_time_days, input.return_policy, ts,
384
+ ],
385
+ );
386
+ return await _getBindingRow(input.sku);
387
+ },
388
+
389
+ // Write one forwarding row per (order, vendor, SKU). Lines
390
+ // whose SKU has no binding are returned on `unbound_skus` and
391
+ // do NOT create a row — the operator's pre-flight is expected
392
+ // to refuse the checkout in that case; this primitive is
393
+ // defensive in case it slips through. Lines whose binding is
394
+ // archived are surfaced separately on `archived_skus`.
395
+ forwardOrder: async function (input) {
396
+ if (!input || typeof input !== "object") {
397
+ throw new TypeError("dropship-forwarding.forwardOrder: input object required");
398
+ }
399
+ _uuid(input.order_id, "order_id");
400
+ if (!Array.isArray(input.lines) || input.lines.length === 0) {
401
+ throw new TypeError("dropship-forwarding.forwardOrder: lines must be a non-empty array");
402
+ }
403
+ if (input.lines.length > MAX_LINES) {
404
+ throw new TypeError("dropship-forwarding.forwardOrder: lines must contain <= " + MAX_LINES + " entries");
405
+ }
406
+ // Each line carries its own shipping address (drop-ship
407
+ // commonly splits across customers — gift-with-purchase,
408
+ // multi-recipient orders). The line-level address is the
409
+ // forwarding row's binding; if the operator wants every line
410
+ // to share the same address, they pass the same object on
411
+ // every line.
412
+ var normalized = [];
413
+ for (var i = 0; i < input.lines.length; i += 1) {
414
+ var l = input.lines[i];
415
+ if (!l || typeof l !== "object") {
416
+ throw new TypeError("dropship-forwarding.forwardOrder: lines[" + i + "] must be an object");
417
+ }
418
+ _sku(l.sku);
419
+ _positiveInt(l.quantity, "lines[" + i + "].quantity");
420
+ var encoded = _shippingAddress(l.shipping_address);
421
+ normalized.push({ sku: l.sku, quantity: l.quantity, address_json: encoded });
422
+ }
423
+
424
+ var forwardingIds = [];
425
+ var unbound = [];
426
+ var archived = [];
427
+ for (var j = 0; j < normalized.length; j += 1) {
428
+ var line = normalized[j];
429
+ var binding = await _getBindingRow(line.sku);
430
+ if (!binding) { unbound.push(line.sku); continue; }
431
+ if (binding.archived_at != null) { archived.push(line.sku); continue; }
432
+
433
+ var id = _b().uuid.v7();
434
+ var ts = _now();
435
+ await query(
436
+ "INSERT INTO dropship_forwardings (id, order_id, vendor_slug, sku, quantity, " +
437
+ "shipping_address_json, status, occurred_at) " +
438
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'queued', ?7)",
439
+ [
440
+ id, input.order_id, binding.vendor_slug, line.sku, line.quantity,
441
+ line.address_json, ts,
442
+ ],
443
+ );
444
+ forwardingIds.push(id);
445
+ }
446
+ return {
447
+ forwarding_ids: forwardingIds,
448
+ unbound_skus: unbound,
449
+ archived_skus: archived,
450
+ };
451
+ },
452
+
453
+ markVendorAccepted: async function (input) {
454
+ if (!input || typeof input !== "object") {
455
+ throw new TypeError("dropship-forwarding.markVendorAccepted: input object required");
456
+ }
457
+ _uuid(input.forwarding_id, "forwarding_id");
458
+ _shortText(input.vendor_ref, "vendor_ref", MAX_VENDOR_REF_LEN);
459
+ var eta = _optTimestamp(input.eta, "eta");
460
+ var patch = { vendor_ref: input.vendor_ref };
461
+ if (eta != null) patch.eta = eta;
462
+ return await _transitionForwarding(input.forwarding_id, "accept", patch);
463
+ },
464
+
465
+ markVendorShipped: async function (input) {
466
+ if (!input || typeof input !== "object") {
467
+ throw new TypeError("dropship-forwarding.markVendorShipped: input object required");
468
+ }
469
+ _uuid(input.forwarding_id, "forwarding_id");
470
+ _shortText(input.carrier, "carrier", MAX_CARRIER_LEN);
471
+ _shortText(input.tracking_number, "tracking_number", MAX_TRACKING_LEN);
472
+ var shippedAt = _optTimestamp(input.shipped_at, "shipped_at");
473
+ if (shippedAt == null) shippedAt = _now();
474
+ return await _transitionForwarding(input.forwarding_id, "ship", {
475
+ carrier: input.carrier,
476
+ tracking_number: input.tracking_number,
477
+ shipped_at: shippedAt,
478
+ });
479
+ },
480
+
481
+ markVendorDelivered: async function (input) {
482
+ if (!input || typeof input !== "object") {
483
+ throw new TypeError("dropship-forwarding.markVendorDelivered: input object required");
484
+ }
485
+ _uuid(input.forwarding_id, "forwarding_id");
486
+ var deliveredAt = _optTimestamp(input.delivered_at, "delivered_at");
487
+ if (deliveredAt == null) deliveredAt = _now();
488
+ return await _transitionForwarding(input.forwarding_id, "deliver", {
489
+ delivered_at: deliveredAt,
490
+ });
491
+ },
492
+
493
+ markVendorFailed: async function (input) {
494
+ if (!input || typeof input !== "object") {
495
+ throw new TypeError("dropship-forwarding.markVendorFailed: input object required");
496
+ }
497
+ _uuid(input.forwarding_id, "forwarding_id");
498
+ _shortText(input.reason, "reason", MAX_FAIL_REASON_LEN);
499
+ return await _transitionForwarding(input.forwarding_id, "fail", {
500
+ fail_reason: input.reason,
501
+ failed_at: _now(),
502
+ });
503
+ },
504
+
505
+ markVendorReturned: async function (input) {
506
+ if (!input || typeof input !== "object") {
507
+ throw new TypeError("dropship-forwarding.markVendorReturned: input object required");
508
+ }
509
+ _uuid(input.forwarding_id, "forwarding_id");
510
+ var returnedAt = _optTimestamp(input.returned_at, "returned_at");
511
+ if (returnedAt == null) returnedAt = _now();
512
+ return await _transitionForwarding(input.forwarding_id, "return", {
513
+ returned_at: returnedAt,
514
+ });
515
+ },
516
+
517
+ getForwarding: async function (id) {
518
+ _uuid(id, "forwarding_id");
519
+ return await _getForwardingRow(id);
520
+ },
521
+
522
+ // All forwardings for one order, ordered by occurred_at ASC
523
+ // (the order the per-vendor rows were written). Operators
524
+ // rendering an order detail page get the vendor-by-vendor
525
+ // breakdown in the order the forward fired.
526
+ forwardingsForOrder: async function (orderId) {
527
+ _uuid(orderId, "order_id");
528
+ var rows = (await query(
529
+ "SELECT * FROM dropship_forwardings WHERE order_id = ?1 " +
530
+ "ORDER BY occurred_at ASC, id ASC",
531
+ [orderId],
532
+ )).rows;
533
+ for (var i = 0; i < rows.length; i += 1) {
534
+ rows[i].shipping_address = rows[i].shipping_address_json
535
+ ? JSON.parse(rows[i].shipping_address_json) : {};
536
+ }
537
+ return rows;
538
+ },
539
+
540
+ // Worker queue drain: queued + accepted rows in FIFO order so
541
+ // the longest-waiting forwarding goes first. Optional
542
+ // vendor_slug filter narrows to one vendor's worker. `accepted`
543
+ // is included because an operator's tracking-polling worker
544
+ // wants to revisit rows that the vendor acknowledged but
545
+ // hasn't yet shipped — once a row hits shipped it falls off
546
+ // the pending queue (the carrier-side tracking takes over).
547
+ pendingForwardings: async function (listOpts) {
548
+ listOpts = listOpts || {};
549
+ var vendorSlug = null;
550
+ if (listOpts.vendor_slug != null) {
551
+ _slug(listOpts.vendor_slug, "vendor_slug");
552
+ vendorSlug = listOpts.vendor_slug;
553
+ }
554
+ var limit = listOpts.limit == null ? 50 : listOpts.limit;
555
+ _limit(limit);
556
+
557
+ var sql, params;
558
+ if (vendorSlug) {
559
+ sql = "SELECT * FROM dropship_forwardings " +
560
+ "WHERE status IN ('queued', 'accepted') AND vendor_slug = ?1 " +
561
+ "ORDER BY occurred_at ASC, id ASC LIMIT ?2";
562
+ params = [vendorSlug, limit];
563
+ } else {
564
+ sql = "SELECT * FROM dropship_forwardings " +
565
+ "WHERE status IN ('queued', 'accepted') " +
566
+ "ORDER BY occurred_at ASC, id ASC LIMIT ?1";
567
+ params = [limit];
568
+ }
569
+ var rows = (await query(sql, params)).rows;
570
+ for (var i = 0; i < rows.length; i += 1) {
571
+ rows[i].shipping_address = rows[i].shipping_address_json
572
+ ? JSON.parse(rows[i].shipping_address_json) : {};
573
+ }
574
+ return rows;
575
+ },
576
+
577
+ // Per-vendor performance metrics across a closed [from, to)
578
+ // window. Returns counts by terminal status + the average
579
+ // fulfillment latency (queued occurred_at -> shipped_at) for
580
+ // every shipped forwarding in the window. Operators read this
581
+ // to drive vendor scorecards: "this vendor shipped 87% of
582
+ // orders in the window with a median of 2.1 days from queue
583
+ // to shipment."
584
+ //
585
+ // `from` and `to` are epoch-ms ints; the window is [from, to)
586
+ // against `occurred_at` (the queued-at stamp). A forwarding
587
+ // that's still queued at `to` counts toward `queued`; one that
588
+ // shipped before `to` counts toward `shipped` regardless of
589
+ // when shipped_at fired (the audit grain is the queued event).
590
+ metricsForVendor: async function (input) {
591
+ if (!input || typeof input !== "object") {
592
+ throw new TypeError("dropship-forwarding.metricsForVendor: input object required");
593
+ }
594
+ _slug(input.slug, "slug");
595
+ var from = _windowTs(input.from, "from");
596
+ var to = _windowTs(input.to, "to");
597
+ if (to <= from) {
598
+ throw new TypeError("dropship-forwarding.metricsForVendor: to must be > from");
599
+ }
600
+ var rows = (await query(
601
+ "SELECT status, occurred_at, shipped_at FROM dropship_forwardings " +
602
+ "WHERE vendor_slug = ?1 AND occurred_at >= ?2 AND occurred_at < ?3",
603
+ [input.slug, from, to],
604
+ )).rows;
605
+
606
+ var counts = {
607
+ queued: 0, accepted: 0, shipped: 0,
608
+ delivered: 0, failed: 0, returned: 0,
609
+ };
610
+ var shippedCount = 0;
611
+ var totalLatencyMs = 0;
612
+ for (var i = 0; i < rows.length; i += 1) {
613
+ var r = rows[i];
614
+ if (Object.prototype.hasOwnProperty.call(counts, r.status)) {
615
+ counts[r.status] += 1;
616
+ }
617
+ // Shipped-or-better rows that carry a shipped_at stamp
618
+ // contribute to the latency average. The audit grain is
619
+ // the queued -> shipped delta — once the carrier has the
620
+ // package the vendor's responsibility is discharged.
621
+ if (r.shipped_at != null && r.shipped_at >= r.occurred_at) {
622
+ shippedCount += 1;
623
+ totalLatencyMs += (r.shipped_at - r.occurred_at);
624
+ }
625
+ }
626
+ return {
627
+ slug: input.slug,
628
+ from: from,
629
+ to: to,
630
+ total: rows.length,
631
+ counts: counts,
632
+ avg_ship_latency_ms: shippedCount > 0 ? Math.floor(totalLatencyMs / shippedCount) : null,
633
+ shipped_for_latency: shippedCount,
634
+ };
635
+ },
636
+ };
637
+ }
638
+
639
+ module.exports = {
640
+ create: create,
641
+ FORWARDING_STATUSES: FORWARDING_STATUSES,
642
+ // Exposed so the test suite can assert the FSM shape without
643
+ // re-deriving the definition from the transitions table.
644
+ _getDropshipFsm: _getDropshipFsm,
645
+ };