@blamejs/blamejs-shop 0.0.66 → 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.
- package/CHANGELOG.md +8 -0
- package/lib/assembly-instructions.js +777 -0
- package/lib/auto-replenish.js +933 -0
- package/lib/click-and-collect.js +711 -0
- package/lib/clickstream.js +713 -0
- package/lib/customer-activity.js +862 -0
- package/lib/customer-notes.js +712 -0
- package/lib/customer-risk-profile.js +593 -0
- package/lib/customer-surveys.js +1012 -0
- package/lib/damage-photos.js +473 -0
- package/lib/dropship-forwarding.js +645 -0
- package/lib/email-templates.js +817 -0
- package/lib/index.js +35 -0
- package/lib/inventory-allocations.js +559 -0
- package/lib/inventory-writeoffs.js +636 -0
- package/lib/knowledge-base.js +1104 -0
- package/lib/locale-router.js +1077 -0
- package/lib/operator-roles.js +768 -0
- package/lib/order-escalation.js +951 -0
- package/lib/order-ratings.js +495 -0
- package/lib/order-tags.js +944 -0
- package/lib/packing-slips.js +810 -0
- package/lib/pixel-events.js +995 -0
- package/lib/print-queue.js +681 -0
- package/lib/product-qa.js +749 -0
- package/lib/promo-bundles.js +835 -0
- package/lib/push-notifications.js +937 -0
- package/lib/refund-automation.js +853 -0
- package/lib/reorder-reminders.js +798 -0
- package/lib/robots-config.js +753 -0
- package/lib/seller-signup.js +1052 -0
- package/lib/sitemap-generator.js +717 -0
- package/lib/subscription-gifts.js +710 -0
- package/lib/tax-cert-renewals.js +632 -0
- package/lib/tier-benefits.js +776 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/metrics.js +68 -4
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
- package/lib/wishlist-alerts.js +842 -0
- package/lib/wishlist-sharing.js +718 -0
- package/package.json +1 -1
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.clickAndCollect
|
|
4
|
+
* @title Click-and-collect — buy online, pick up in store (BOPIS)
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* The pickup workflow has four moving pieces:
|
|
8
|
+
*
|
|
9
|
+
* 1. The operator publishes a set of pickup-eligible stores (each
|
|
10
|
+
* with hours, capacity, and a lead-time minimum the picker needs
|
|
11
|
+
* before a slot opens).
|
|
12
|
+
* 2. At checkout the customer picks a location + a scheduled
|
|
13
|
+
* window. The schedule row lands in `scheduled` and the
|
|
14
|
+
* front-counter dashboard surfaces it.
|
|
15
|
+
* 3. Once the picker has the goods on the hold shelf the operator
|
|
16
|
+
* marks the schedule `ready`. A notification fires (when a
|
|
17
|
+
* dispatcher is wired) telling the customer to come collect.
|
|
18
|
+
* 4. The customer arrives, the operator captures a signature +
|
|
19
|
+
* proof-of-identity kind, the schedule lands in `picked_up`,
|
|
20
|
+
* and (when an `order` handle is wired) the parent order
|
|
21
|
+
* transitions to `delivered`.
|
|
22
|
+
*
|
|
23
|
+
* A no-show path covers the customer-doesn't-arrive case:
|
|
24
|
+
* `markNoShow` records the absence + a reason; rows older than 7
|
|
25
|
+
* days in `no_show` surface through `pickupsForLocation({ status:
|
|
26
|
+
* "no_show" })` as the operator escalation queue. The primitive
|
|
27
|
+
* does NOT unilaterally refund or restock — the operator drives
|
|
28
|
+
* that via the order FSM, since BOPIS no-shows are routinely
|
|
29
|
+
* resolved by phone (the customer just got delayed).
|
|
30
|
+
*
|
|
31
|
+
* FSM:
|
|
32
|
+
*
|
|
33
|
+
* scheduled --markReadyForPickup--> ready --markPickedUp--> picked_up
|
|
34
|
+
* \ \
|
|
35
|
+
* \ --markNoShow--> no_show
|
|
36
|
+
* --markNoShow--> no_show (slot lapsed without ready)
|
|
37
|
+
* --(operator)--> cancelled (handled by reschedule path)
|
|
38
|
+
*
|
|
39
|
+
* `no_show` and `picked_up` and `cancelled` are terminal. `ready`
|
|
40
|
+
* cannot regress to `scheduled` — once the goods are on the hold
|
|
41
|
+
* shelf the operator either completes the pickup or escalates.
|
|
42
|
+
*
|
|
43
|
+
* Signature privacy:
|
|
44
|
+
*
|
|
45
|
+
* The captured pickup signature is hashed at the boundary via
|
|
46
|
+
* `b.crypto.namespaceHash("click-and-collect-signature", raw)` and
|
|
47
|
+
* ONLY the hex digest lands in the database. The raw signature
|
|
48
|
+
* never persists — the operator can later prove a presented
|
|
49
|
+
* signature matches a stored hash without storing PII at rest.
|
|
50
|
+
* `customer_id_proof_kind` is a short label (driver_license,
|
|
51
|
+
* passport, national_id, store_credential, other) — the document
|
|
52
|
+
* number is never captured.
|
|
53
|
+
*
|
|
54
|
+
* Capacity gate:
|
|
55
|
+
*
|
|
56
|
+
* `scheduleAtLocation` refuses when the location's
|
|
57
|
+
* `capacity_per_hour` is already saturated for the proposed
|
|
58
|
+
* one-hour bucket containing `scheduled_window_start`. The bucket
|
|
59
|
+
* is computed as `Math.floor(scheduled_window_start / HOUR_MS)` so
|
|
60
|
+
* two slots that share the same bucket-key share a capacity pool.
|
|
61
|
+
*
|
|
62
|
+
* Lead-time gate:
|
|
63
|
+
*
|
|
64
|
+
* `scheduleAtLocation` refuses when `scheduled_window_start` is
|
|
65
|
+
* less than `lead_time_hours` ahead of the current monotonic
|
|
66
|
+
* clock. Operators that need to override (a same-day rush) flip
|
|
67
|
+
* the location's lead_time_hours to 0.
|
|
68
|
+
*
|
|
69
|
+
* Composes:
|
|
70
|
+
* - `b.uuid.v7` — schedule row ids (lexicographic +
|
|
71
|
+
* monotonic so ties on created_at still sort deterministically).
|
|
72
|
+
* - `b.guardUuid` — strict UUID gate on every order_id /
|
|
73
|
+
* customer_id at the entry point.
|
|
74
|
+
* - `b.crypto.namespaceHash` — pickup signature hashing.
|
|
75
|
+
* - `order` (optional) — when wired, `markPickedUp` drives the
|
|
76
|
+
* parent order to `delivered` via `order.transition(...,
|
|
77
|
+
* "mark_delivered")`; `customerSchedules(customer_id)` reads the
|
|
78
|
+
* customer's order ids through `order.listForCustomer(...)` so
|
|
79
|
+
* this primitive doesn't duplicate the customer→order index.
|
|
80
|
+
* - `inventoryLocations` (optional) — when wired,
|
|
81
|
+
* `availableLocations({ sku })` filters out locations where the
|
|
82
|
+
* sku has zero on-hand stock; absent, every active location is
|
|
83
|
+
* a candidate.
|
|
84
|
+
* - `notifications` (optional) — when wired,
|
|
85
|
+
* `markReadyForPickup` enqueues a customer notification (channel
|
|
86
|
+
* `pickup-ready`, event_type `pickup_ready`) keyed by the order
|
|
87
|
+
* id; absent, the FSM transition still lands but no message is
|
|
88
|
+
* sent.
|
|
89
|
+
*
|
|
90
|
+
* @primitive clickAndCollect
|
|
91
|
+
* @related b.uuid, b.guardUuid, b.crypto.namespaceHash, order,
|
|
92
|
+
* inventoryLocations, notifications
|
|
93
|
+
*/
|
|
94
|
+
|
|
95
|
+
var bShop;
|
|
96
|
+
function _b() {
|
|
97
|
+
if (!bShop) bShop = require("./index");
|
|
98
|
+
return bShop.framework;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---- constants ----------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
var CODE_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
104
|
+
var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
105
|
+
var POSTAL_RE = /^[A-Za-z0-9][A-Za-z0-9 .-]{0,15}$/;
|
|
106
|
+
var MAX_NAME_LEN = 200;
|
|
107
|
+
var MAX_REASON_LEN = 280;
|
|
108
|
+
var MAX_SIGNATURE_LEN = 8192;
|
|
109
|
+
var MAX_LIST_LIMIT = 200;
|
|
110
|
+
var HOUR_MS = 3600 * 1000;
|
|
111
|
+
var NO_SHOW_ESCALATE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
112
|
+
var SIGNATURE_NAMESPACE = "click-and-collect-signature";
|
|
113
|
+
|
|
114
|
+
var PICKUP_STATUSES = Object.freeze([
|
|
115
|
+
"scheduled", "ready", "picked_up", "no_show", "cancelled",
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
var PROOF_KINDS = Object.freeze([
|
|
119
|
+
"driver_license", "passport", "national_id", "store_credential", "other",
|
|
120
|
+
]);
|
|
121
|
+
|
|
122
|
+
// ---- monotonic clock ---------------------------------------------------
|
|
123
|
+
//
|
|
124
|
+
// Operator-driven FSM transitions can land in the same millisecond on
|
|
125
|
+
// fast machines (markShipped immediately followed by markReadyForPickup
|
|
126
|
+
// in a test, for instance). Bumping by 1ms on a tie keeps the timeline
|
|
127
|
+
// strictly increasing so a sort-by-timestamp read returns the events in
|
|
128
|
+
// the order they were issued.
|
|
129
|
+
|
|
130
|
+
var _lastTs = 0;
|
|
131
|
+
function _now() {
|
|
132
|
+
var t = Date.now();
|
|
133
|
+
if (t <= _lastTs) { t = _lastTs + 1; }
|
|
134
|
+
_lastTs = t;
|
|
135
|
+
return t;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ---- validators --------------------------------------------------------
|
|
139
|
+
|
|
140
|
+
function _orderId(s) {
|
|
141
|
+
try {
|
|
142
|
+
return _b().guardUuid.sanitize(s, { profile: "strict" });
|
|
143
|
+
} catch (e) {
|
|
144
|
+
throw new TypeError("click-and-collect: order_id — " + (e && e.message || "invalid UUID"));
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
function _customerId(s) {
|
|
148
|
+
try {
|
|
149
|
+
return _b().guardUuid.sanitize(s, { profile: "strict" });
|
|
150
|
+
} catch (e) {
|
|
151
|
+
throw new TypeError("click-and-collect: customer_id — " + (e && e.message || "invalid UUID"));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function _code(s, label) {
|
|
155
|
+
if (typeof s !== "string" || !CODE_RE.test(s)) {
|
|
156
|
+
throw new TypeError("click-and-collect: " + (label || "code") +
|
|
157
|
+
" must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..64 chars)");
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
function _name(s) {
|
|
161
|
+
if (typeof s !== "string" || s.length === 0 || s.length > MAX_NAME_LEN) {
|
|
162
|
+
throw new TypeError("click-and-collect: name must be a non-empty string ≤ " + MAX_NAME_LEN + " chars");
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
function _positiveInt(n, label) {
|
|
166
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
167
|
+
throw new TypeError("click-and-collect: " + label + " must be a positive integer");
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
function _nonNegInt(n, label) {
|
|
171
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
172
|
+
throw new TypeError("click-and-collect: " + label + " must be a non-negative integer");
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function _ts(n, label) {
|
|
176
|
+
if (!Number.isInteger(n) || n <= 0) {
|
|
177
|
+
throw new TypeError("click-and-collect: " + label + " must be a positive integer (epoch ms)");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
function _bool(v, label) {
|
|
181
|
+
if (typeof v !== "boolean") {
|
|
182
|
+
throw new TypeError("click-and-collect: " + label + " must be a boolean");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
function _addressObj(a) {
|
|
186
|
+
if (!a || typeof a !== "object" || Array.isArray(a)) {
|
|
187
|
+
throw new TypeError("click-and-collect: address must be an object");
|
|
188
|
+
}
|
|
189
|
+
if (typeof a.line1 !== "string" || a.line1.length === 0 || a.line1.length > 200) {
|
|
190
|
+
throw new TypeError("click-and-collect: address.line1 must be a non-empty string ≤ 200 chars");
|
|
191
|
+
}
|
|
192
|
+
if (typeof a.city !== "string" || a.city.length === 0 || a.city.length > 100) {
|
|
193
|
+
throw new TypeError("click-and-collect: address.city must be a non-empty string ≤ 100 chars");
|
|
194
|
+
}
|
|
195
|
+
if (typeof a.country !== "string" || a.country.length !== 2) {
|
|
196
|
+
throw new TypeError("click-and-collect: address.country must be a 2-letter ISO code");
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
function _hoursObj(h) {
|
|
200
|
+
if (!h || typeof h !== "object" || Array.isArray(h)) {
|
|
201
|
+
throw new TypeError("click-and-collect: hours_json must be an object");
|
|
202
|
+
}
|
|
203
|
+
// The shape isn't enforced beyond "object" — operators may add custom
|
|
204
|
+
// notes (holiday overrides, lunch breaks) per their storefront's
|
|
205
|
+
// calendar. The contract is `hours_json` round-trips JSON cleanly.
|
|
206
|
+
try { JSON.parse(JSON.stringify(h)); }
|
|
207
|
+
catch (_e) { throw new TypeError("click-and-collect: hours_json must be JSON-serialisable"); }
|
|
208
|
+
}
|
|
209
|
+
function _sku(s) {
|
|
210
|
+
if (typeof s !== "string" || !SKU_RE.test(s)) {
|
|
211
|
+
throw new TypeError("click-and-collect: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, 1..128 chars)");
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function _postal(s) {
|
|
215
|
+
if (typeof s !== "string" || !POSTAL_RE.test(s)) {
|
|
216
|
+
throw new TypeError("click-and-collect: destination_postal must match /^[A-Za-z0-9][A-Za-z0-9 .-]{0,15}$/");
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
function _reason(s) {
|
|
220
|
+
if (typeof s !== "string" || s.length === 0 || s.length > MAX_REASON_LEN) {
|
|
221
|
+
throw new TypeError("click-and-collect: reason must be a non-empty string ≤ " + MAX_REASON_LEN + " chars");
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
function _proofKind(s) {
|
|
225
|
+
if (PROOF_KINDS.indexOf(s) === -1) {
|
|
226
|
+
throw new TypeError("click-and-collect: customer_id_proof_kind must be one of " +
|
|
227
|
+
PROOF_KINDS.join(", ") + ", got " + JSON.stringify(s));
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
function _signature(s) {
|
|
231
|
+
if (typeof s !== "string" || s.length === 0 || s.length > MAX_SIGNATURE_LEN) {
|
|
232
|
+
throw new TypeError("click-and-collect: signature must be a non-empty string ≤ " +
|
|
233
|
+
MAX_SIGNATURE_LEN + " chars (base64 PNG, SVG path, or operator-defined encoding)");
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
function _limit(n) {
|
|
237
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
|
|
238
|
+
throw new TypeError("click-and-collect: limit must be an integer in 1..." + MAX_LIST_LIMIT);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
function _status(s) {
|
|
242
|
+
if (PICKUP_STATUSES.indexOf(s) === -1) {
|
|
243
|
+
throw new TypeError("click-and-collect: status must be one of " +
|
|
244
|
+
PICKUP_STATUSES.join(", ") + ", got " + JSON.stringify(s));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---- factory -----------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
function create(opts) {
|
|
251
|
+
opts = opts || {};
|
|
252
|
+
var query = opts.query;
|
|
253
|
+
if (!query) {
|
|
254
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// order is optional — when wired, markPickedUp drives the parent
|
|
258
|
+
// order FSM to delivered and customerSchedules looks up the
|
|
259
|
+
// customer's order ids via order.listForCustomer. Absent both verbs
|
|
260
|
+
// when the handle IS provided fails loud at boot.
|
|
261
|
+
var orderPrim = opts.order || null;
|
|
262
|
+
if (orderPrim) {
|
|
263
|
+
if (typeof orderPrim.transition !== "function") {
|
|
264
|
+
throw new TypeError("click-and-collect.create: opts.order must expose a transition(id, event, opts) method");
|
|
265
|
+
}
|
|
266
|
+
if (typeof orderPrim.listForCustomer !== "function") {
|
|
267
|
+
throw new TypeError("click-and-collect.create: opts.order must expose a listForCustomer(customer_id, opts) method");
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// inventoryLocations is optional — when wired, availableLocations({
|
|
272
|
+
// sku }) filters out locations where the sku has zero on-hand stock.
|
|
273
|
+
var invLocations = opts.inventoryLocations || null;
|
|
274
|
+
if (invLocations && typeof invLocations.stockForSku !== "function") {
|
|
275
|
+
throw new TypeError("click-and-collect.create: opts.inventoryLocations must expose a stockForSku(sku) method");
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// notifications is optional — when wired, markReadyForPickup
|
|
279
|
+
// enqueues a customer message. Absent, the FSM transition still
|
|
280
|
+
// lands; bespoke dispatchers can read from the schedules table.
|
|
281
|
+
var notifications = opts.notifications || null;
|
|
282
|
+
if (notifications && typeof notifications.enqueue !== "function") {
|
|
283
|
+
throw new TypeError("click-and-collect.create: opts.notifications must expose an enqueue(input) method");
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function _getLocationRow(code) {
|
|
287
|
+
var r = await query("SELECT * FROM pickup_locations WHERE code = ?1", [code]);
|
|
288
|
+
return r.rows.length ? r.rows[0] : null;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
async function _getScheduleByOrder(orderId) {
|
|
292
|
+
var r = await query("SELECT * FROM pickup_schedules WHERE order_id = ?1", [orderId]);
|
|
293
|
+
return r.rows.length ? r.rows[0] : null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function _capacityBookedForBucket(locationCode, bucketStartMs, bucketEndMs) {
|
|
297
|
+
var r = await query(
|
|
298
|
+
"SELECT COUNT(*) AS n FROM pickup_schedules " +
|
|
299
|
+
"WHERE location_code = ?1 AND status IN ('scheduled', 'ready') " +
|
|
300
|
+
"AND scheduled_window_start >= ?2 AND scheduled_window_start < ?3",
|
|
301
|
+
[locationCode, bucketStartMs, bucketEndMs],
|
|
302
|
+
);
|
|
303
|
+
return Number(r.rows[0].n);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
|
|
308
|
+
PICKUP_STATUSES: PICKUP_STATUSES,
|
|
309
|
+
PROOF_KINDS: PROOF_KINDS,
|
|
310
|
+
|
|
311
|
+
// Register a pickup-eligible location. Upsert semantics on `code` —
|
|
312
|
+
// re-defining the same code updates the row in place (operator
|
|
313
|
+
// changes hours / capacity without invalidating prior schedules).
|
|
314
|
+
definePickupLocation: async function (input) {
|
|
315
|
+
if (!input || typeof input !== "object") {
|
|
316
|
+
throw new TypeError("click-and-collect.definePickupLocation: input object required");
|
|
317
|
+
}
|
|
318
|
+
_code(input.code, "code");
|
|
319
|
+
_name(input.name);
|
|
320
|
+
_addressObj(input.address);
|
|
321
|
+
_hoursObj(input.hours_json);
|
|
322
|
+
_positiveInt(input.capacity_per_hour, "capacity_per_hour");
|
|
323
|
+
_nonNegInt(input.lead_time_hours, "lead_time_hours");
|
|
324
|
+
if (input.active !== undefined) _bool(input.active, "active");
|
|
325
|
+
|
|
326
|
+
var now = _now();
|
|
327
|
+
var active = input.active === false ? 0 : 1;
|
|
328
|
+
var addressJson = JSON.stringify(input.address);
|
|
329
|
+
var hoursJson = JSON.stringify(input.hours_json);
|
|
330
|
+
|
|
331
|
+
var existing = await _getLocationRow(input.code);
|
|
332
|
+
if (existing) {
|
|
333
|
+
await query(
|
|
334
|
+
"UPDATE pickup_locations SET name = ?1, address_json = ?2, hours_json = ?3, " +
|
|
335
|
+
"capacity_per_hour = ?4, lead_time_hours = ?5, active = ?6, archived_at = NULL, " +
|
|
336
|
+
"updated_at = ?7 WHERE code = ?8",
|
|
337
|
+
[input.name, addressJson, hoursJson, input.capacity_per_hour,
|
|
338
|
+
input.lead_time_hours, active, now, input.code],
|
|
339
|
+
);
|
|
340
|
+
} else {
|
|
341
|
+
await query(
|
|
342
|
+
"INSERT INTO pickup_locations (code, name, address_json, hours_json, " +
|
|
343
|
+
"capacity_per_hour, lead_time_hours, active, created_at, updated_at) " +
|
|
344
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
|
345
|
+
[input.code, input.name, addressJson, hoursJson, input.capacity_per_hour,
|
|
346
|
+
input.lead_time_hours, active, now, now],
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
return await this.getLocation(input.code);
|
|
350
|
+
},
|
|
351
|
+
|
|
352
|
+
// Hydrated read of a single location. Returns null on miss so the
|
|
353
|
+
// caller can map cleanly to HTTP 404.
|
|
354
|
+
getLocation: async function (code) {
|
|
355
|
+
_code(code, "code");
|
|
356
|
+
var row = await _getLocationRow(code);
|
|
357
|
+
if (!row) return null;
|
|
358
|
+
row.address = JSON.parse(row.address_json);
|
|
359
|
+
row.hours = JSON.parse(row.hours_json);
|
|
360
|
+
return row;
|
|
361
|
+
},
|
|
362
|
+
|
|
363
|
+
// List active pickup-eligible locations. When `sku` is provided
|
|
364
|
+
// and inventoryLocations is wired, filters to locations where the
|
|
365
|
+
// sku has on-hand stock > 0. `destination_postal` is accepted for
|
|
366
|
+
// shape validation but the primitive doesn't ship a geo-ranking
|
|
367
|
+
// strategy itself — the column lands on the address_json and
|
|
368
|
+
// operators compose a custom ranker on top.
|
|
369
|
+
availableLocations: async function (listOpts) {
|
|
370
|
+
listOpts = listOpts || {};
|
|
371
|
+
if (listOpts.destination_postal != null) _postal(listOpts.destination_postal);
|
|
372
|
+
if (listOpts.sku != null) _sku(listOpts.sku);
|
|
373
|
+
var limit = listOpts.limit == null ? 50 : listOpts.limit;
|
|
374
|
+
_limit(limit);
|
|
375
|
+
|
|
376
|
+
var rows = (await query(
|
|
377
|
+
"SELECT * FROM pickup_locations WHERE active = 1 AND archived_at IS NULL " +
|
|
378
|
+
"ORDER BY code ASC LIMIT ?1",
|
|
379
|
+
[limit],
|
|
380
|
+
)).rows;
|
|
381
|
+
|
|
382
|
+
// Filter by sku-on-hand when both the sku is requested and an
|
|
383
|
+
// inventoryLocations handle is wired. Absent either, every
|
|
384
|
+
// active location is a candidate (the operator's UI can still
|
|
385
|
+
// render an "out of stock here" badge from a separate read).
|
|
386
|
+
if (listOpts.sku && invLocations) {
|
|
387
|
+
var stock = await invLocations.stockForSku(listOpts.sku);
|
|
388
|
+
var stockedCodes = Object.create(null);
|
|
389
|
+
var byLoc = (stock && stock.by_location) || [];
|
|
390
|
+
for (var i = 0; i < byLoc.length; i += 1) {
|
|
391
|
+
if (byLoc[i].quantity > 0) stockedCodes[byLoc[i].code] = true;
|
|
392
|
+
}
|
|
393
|
+
rows = rows.filter(function (r) { return stockedCodes[r.code]; });
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Hydrate the parsed address + hours payloads on each row so
|
|
397
|
+
// the storefront's location-picker doesn't re-parse JSON
|
|
398
|
+
// per-element.
|
|
399
|
+
for (var k = 0; k < rows.length; k += 1) {
|
|
400
|
+
rows[k].address = JSON.parse(rows[k].address_json);
|
|
401
|
+
rows[k].hours = JSON.parse(rows[k].hours_json);
|
|
402
|
+
}
|
|
403
|
+
return rows;
|
|
404
|
+
},
|
|
405
|
+
|
|
406
|
+
// Book a pickup window. Refuses when the location is archived /
|
|
407
|
+
// inactive, when the slot is inside the location's lead_time
|
|
408
|
+
// floor, when the proposed window is degenerate (end <= start),
|
|
409
|
+
// or when the hour-bucket containing `scheduled_window_start` is
|
|
410
|
+
// already at `capacity_per_hour`.
|
|
411
|
+
scheduleAtLocation: async function (input) {
|
|
412
|
+
if (!input || typeof input !== "object") {
|
|
413
|
+
throw new TypeError("click-and-collect.scheduleAtLocation: input object required");
|
|
414
|
+
}
|
|
415
|
+
var orderId = _orderId(input.order_id);
|
|
416
|
+
_code(input.location_code, "location_code");
|
|
417
|
+
_ts(input.scheduled_window_start, "scheduled_window_start");
|
|
418
|
+
_ts(input.scheduled_window_end, "scheduled_window_end");
|
|
419
|
+
if (input.scheduled_window_end <= input.scheduled_window_start) {
|
|
420
|
+
throw new TypeError("click-and-collect.scheduleAtLocation: scheduled_window_end must be > scheduled_window_start");
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
var loc = await _getLocationRow(input.location_code);
|
|
424
|
+
if (!loc) {
|
|
425
|
+
throw new TypeError("click-and-collect.scheduleAtLocation: location_code " +
|
|
426
|
+
JSON.stringify(input.location_code) + " not found");
|
|
427
|
+
}
|
|
428
|
+
if (!Number(loc.active) || loc.archived_at != null) {
|
|
429
|
+
throw new TypeError("click-and-collect.scheduleAtLocation: location_code " +
|
|
430
|
+
JSON.stringify(input.location_code) + " is not active");
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
var now = _now();
|
|
434
|
+
var leadFloor = now + Number(loc.lead_time_hours) * HOUR_MS;
|
|
435
|
+
if (input.scheduled_window_start < leadFloor) {
|
|
436
|
+
throw new TypeError("click-and-collect.scheduleAtLocation: scheduled_window_start violates " +
|
|
437
|
+
loc.lead_time_hours + "h lead time at " + loc.code);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Capacity gate on the one-hour bucket containing
|
|
441
|
+
// scheduled_window_start. Two slots that share the same bucket
|
|
442
|
+
// share a capacity pool — operators who want a finer-grained
|
|
443
|
+
// gate run a higher `capacity_per_hour` and rely on slot
|
|
444
|
+
// discretisation in the storefront's window picker.
|
|
445
|
+
var bucketStart = Math.floor(input.scheduled_window_start / HOUR_MS) * HOUR_MS;
|
|
446
|
+
var bucketEnd = bucketStart + HOUR_MS;
|
|
447
|
+
var booked = await _capacityBookedForBucket(input.location_code, bucketStart, bucketEnd);
|
|
448
|
+
// Re-scheduling the SAME order_id within the same bucket should
|
|
449
|
+
// not consume an additional capacity slot — check whether an
|
|
450
|
+
// existing schedule for this order already lives in the bucket
|
|
451
|
+
// and exclude it from the cap.
|
|
452
|
+
var existing = await _getScheduleByOrder(orderId);
|
|
453
|
+
if (existing && existing.location_code === input.location_code &&
|
|
454
|
+
existing.scheduled_window_start >= bucketStart &&
|
|
455
|
+
existing.scheduled_window_start < bucketEnd &&
|
|
456
|
+
(existing.status === "scheduled" || existing.status === "ready")) {
|
|
457
|
+
booked -= 1;
|
|
458
|
+
}
|
|
459
|
+
if (booked >= Number(loc.capacity_per_hour)) {
|
|
460
|
+
throw new TypeError("click-and-collect.scheduleAtLocation: location_code " +
|
|
461
|
+
JSON.stringify(input.location_code) + " is at capacity for the requested window");
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
if (existing) {
|
|
465
|
+
// Re-schedule in place. Refuses to overwrite a terminal row —
|
|
466
|
+
// a picked_up / no_show / cancelled order books a new pickup
|
|
467
|
+
// by going through the operator's cancel + re-place flow,
|
|
468
|
+
// never by mutating the original schedule.
|
|
469
|
+
if (existing.status !== "scheduled" && existing.status !== "ready") {
|
|
470
|
+
throw new TypeError("click-and-collect.scheduleAtLocation: order " + orderId +
|
|
471
|
+
" has a terminal pickup schedule (status=" + existing.status +
|
|
472
|
+
"); operator must place a new order to reschedule");
|
|
473
|
+
}
|
|
474
|
+
await query(
|
|
475
|
+
"UPDATE pickup_schedules SET location_code = ?1, scheduled_window_start = ?2, " +
|
|
476
|
+
"scheduled_window_end = ?3, status = 'scheduled', ready_at = NULL, updated_at = ?4 " +
|
|
477
|
+
"WHERE order_id = ?5",
|
|
478
|
+
[input.location_code, input.scheduled_window_start, input.scheduled_window_end,
|
|
479
|
+
now, orderId],
|
|
480
|
+
);
|
|
481
|
+
} else {
|
|
482
|
+
await query(
|
|
483
|
+
"INSERT INTO pickup_schedules (id, order_id, location_code, " +
|
|
484
|
+
"scheduled_window_start, scheduled_window_end, status, created_at, updated_at) " +
|
|
485
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, 'scheduled', ?6, ?7)",
|
|
486
|
+
[_b().uuid.v7(), orderId, input.location_code,
|
|
487
|
+
input.scheduled_window_start, input.scheduled_window_end, now, now],
|
|
488
|
+
);
|
|
489
|
+
}
|
|
490
|
+
return await _getScheduleByOrder(orderId);
|
|
491
|
+
},
|
|
492
|
+
|
|
493
|
+
// scheduled -> ready. Stamps ready_at. When notifications is wired,
|
|
494
|
+
// enqueues a `pickup_ready` message keyed by the order_id so the
|
|
495
|
+
// storefront's customer-facing notification surface fires without
|
|
496
|
+
// a separate operator call. Notification failures are drop-silent
|
|
497
|
+
// — a notifications outage must not roll back the FSM transition
|
|
498
|
+
// (the operator's hold-shelf inventory is already committed).
|
|
499
|
+
markReadyForPickup: async function (input) {
|
|
500
|
+
if (!input || typeof input !== "object") {
|
|
501
|
+
throw new TypeError("click-and-collect.markReadyForPickup: input object required");
|
|
502
|
+
}
|
|
503
|
+
var orderId = _orderId(input.order_id);
|
|
504
|
+
var schedule = await _getScheduleByOrder(orderId);
|
|
505
|
+
if (!schedule) {
|
|
506
|
+
throw new TypeError("click-and-collect.markReadyForPickup: no pickup schedule for order " + orderId);
|
|
507
|
+
}
|
|
508
|
+
if (schedule.status !== "scheduled") {
|
|
509
|
+
throw new TypeError("click-and-collect.markReadyForPickup: schedule is " +
|
|
510
|
+
schedule.status + ", only scheduled rows can move to ready");
|
|
511
|
+
}
|
|
512
|
+
var readyAt = input.scheduled_for == null ? _now() : Number(input.scheduled_for);
|
|
513
|
+
if (!Number.isInteger(readyAt) || readyAt <= 0) {
|
|
514
|
+
throw new TypeError("click-and-collect.markReadyForPickup: scheduled_for must be a positive integer (epoch ms)");
|
|
515
|
+
}
|
|
516
|
+
await query(
|
|
517
|
+
"UPDATE pickup_schedules SET status = 'ready', ready_at = ?1, updated_at = ?2 " +
|
|
518
|
+
"WHERE order_id = ?3",
|
|
519
|
+
[readyAt, _now(), orderId],
|
|
520
|
+
);
|
|
521
|
+
if (notifications) {
|
|
522
|
+
try {
|
|
523
|
+
await notifications.enqueue({
|
|
524
|
+
recipient_id: orderId,
|
|
525
|
+
channel: "pickup-ready",
|
|
526
|
+
event_type: "pickup_ready",
|
|
527
|
+
title: "Your order is ready for pickup",
|
|
528
|
+
body: "",
|
|
529
|
+
payload: {
|
|
530
|
+
order_id: orderId,
|
|
531
|
+
location_code: schedule.location_code,
|
|
532
|
+
ready_at: readyAt,
|
|
533
|
+
window_start: schedule.scheduled_window_start,
|
|
534
|
+
window_end: schedule.scheduled_window_end,
|
|
535
|
+
},
|
|
536
|
+
scheduled_at: readyAt,
|
|
537
|
+
});
|
|
538
|
+
} catch (_e) { /* drop-silent — notifications outage must not roll back the FSM transition */ }
|
|
539
|
+
}
|
|
540
|
+
return await _getScheduleByOrder(orderId);
|
|
541
|
+
},
|
|
542
|
+
|
|
543
|
+
// ready -> picked_up. Captures the (namespace-hashed) signature +
|
|
544
|
+
// proof-of-identity kind. When `order` is wired, drives the parent
|
|
545
|
+
// order to `delivered` via `order.transition(..., "mark_delivered")`.
|
|
546
|
+
markPickedUp: async function (input) {
|
|
547
|
+
if (!input || typeof input !== "object") {
|
|
548
|
+
throw new TypeError("click-and-collect.markPickedUp: input object required");
|
|
549
|
+
}
|
|
550
|
+
var orderId = _orderId(input.order_id);
|
|
551
|
+
_ts(input.picked_up_at, "picked_up_at");
|
|
552
|
+
var sigHash = null;
|
|
553
|
+
if (input.signature != null) {
|
|
554
|
+
_signature(input.signature);
|
|
555
|
+
sigHash = _b().crypto.namespaceHash(SIGNATURE_NAMESPACE, input.signature);
|
|
556
|
+
}
|
|
557
|
+
var proofKind = null;
|
|
558
|
+
if (input.customer_id_proof_kind != null) {
|
|
559
|
+
_proofKind(input.customer_id_proof_kind);
|
|
560
|
+
proofKind = input.customer_id_proof_kind;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
var schedule = await _getScheduleByOrder(orderId);
|
|
564
|
+
if (!schedule) {
|
|
565
|
+
throw new TypeError("click-and-collect.markPickedUp: no pickup schedule for order " + orderId);
|
|
566
|
+
}
|
|
567
|
+
if (schedule.status !== "ready") {
|
|
568
|
+
throw new TypeError("click-and-collect.markPickedUp: schedule is " +
|
|
569
|
+
schedule.status + ", only ready rows can move to picked_up");
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
var now = _now();
|
|
573
|
+
await query(
|
|
574
|
+
"UPDATE pickup_schedules SET status = 'picked_up', picked_up_at = ?1, " +
|
|
575
|
+
"signature_hash = ?2, customer_id_proof_kind = ?3, updated_at = ?4 " +
|
|
576
|
+
"WHERE order_id = ?5",
|
|
577
|
+
[input.picked_up_at, sigHash, proofKind, now, orderId],
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
// Drive the parent order FSM to delivered. The transition is
|
|
581
|
+
// guarded — an already-delivered order surfaces as
|
|
582
|
+
// `fsm/illegal-transition` and we swallow it so a double
|
|
583
|
+
// markPickedUp (idempotent at the schedule layer) doesn't
|
|
584
|
+
// spuriously fail at the order layer. Other refusal codes
|
|
585
|
+
// surface to the caller.
|
|
586
|
+
if (orderPrim) {
|
|
587
|
+
try {
|
|
588
|
+
await orderPrim.transition(orderId, "mark_delivered", {
|
|
589
|
+
reason: "click_and_collect_picked_up",
|
|
590
|
+
metadata: {
|
|
591
|
+
location_code: schedule.location_code,
|
|
592
|
+
picked_up_at: input.picked_up_at,
|
|
593
|
+
},
|
|
594
|
+
});
|
|
595
|
+
} catch (e) {
|
|
596
|
+
var code = e && e.code;
|
|
597
|
+
if (code !== "fsm/illegal-transition") throw e;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return await _getScheduleByOrder(orderId);
|
|
601
|
+
},
|
|
602
|
+
|
|
603
|
+
// scheduled|ready -> no_show. Customer didn't arrive. Rows older
|
|
604
|
+
// than 7 days in no_show surface through
|
|
605
|
+
// `pickupsForLocation({ status: "no_show" })` as the operator
|
|
606
|
+
// escalation queue — this primitive does NOT unilaterally refund
|
|
607
|
+
// or restock; the operator drives that via the order FSM.
|
|
608
|
+
markNoShow: async function (input) {
|
|
609
|
+
if (!input || typeof input !== "object") {
|
|
610
|
+
throw new TypeError("click-and-collect.markNoShow: input object required");
|
|
611
|
+
}
|
|
612
|
+
var orderId = _orderId(input.order_id);
|
|
613
|
+
_reason(input.reason);
|
|
614
|
+
|
|
615
|
+
var schedule = await _getScheduleByOrder(orderId);
|
|
616
|
+
if (!schedule) {
|
|
617
|
+
throw new TypeError("click-and-collect.markNoShow: no pickup schedule for order " + orderId);
|
|
618
|
+
}
|
|
619
|
+
if (schedule.status !== "scheduled" && schedule.status !== "ready") {
|
|
620
|
+
throw new TypeError("click-and-collect.markNoShow: schedule is " +
|
|
621
|
+
schedule.status + ", only scheduled or ready rows can move to no_show");
|
|
622
|
+
}
|
|
623
|
+
var now = _now();
|
|
624
|
+
await query(
|
|
625
|
+
"UPDATE pickup_schedules SET status = 'no_show', no_show_at = ?1, updated_at = ?2 " +
|
|
626
|
+
"WHERE order_id = ?3",
|
|
627
|
+
[now, now, orderId],
|
|
628
|
+
);
|
|
629
|
+
var row = await _getScheduleByOrder(orderId);
|
|
630
|
+
// Flag the escalation window inline so the caller can render the
|
|
631
|
+
// operator-warning banner without re-computing the 7-day floor.
|
|
632
|
+
row.escalate_after = now + NO_SHOW_ESCALATE_MS;
|
|
633
|
+
row.no_show_reason = input.reason;
|
|
634
|
+
return row;
|
|
635
|
+
},
|
|
636
|
+
|
|
637
|
+
// Read a hydrated schedule for an order. Returns null on miss.
|
|
638
|
+
getScheduleByOrder: async function (orderId) {
|
|
639
|
+
var id = _orderId(orderId);
|
|
640
|
+
return await _getScheduleByOrder(id);
|
|
641
|
+
},
|
|
642
|
+
|
|
643
|
+
// Window-bounded read of schedules at a location. `from` / `to`
|
|
644
|
+
// bound the scheduled_window_start column inclusively. `status`
|
|
645
|
+
// is optional; absent, every status in the window matches.
|
|
646
|
+
pickupsForLocation: async function (listOpts) {
|
|
647
|
+
if (!listOpts || typeof listOpts !== "object") {
|
|
648
|
+
throw new TypeError("click-and-collect.pickupsForLocation: opts object required");
|
|
649
|
+
}
|
|
650
|
+
_code(listOpts.location_code, "location_code");
|
|
651
|
+
_ts(listOpts.from, "from");
|
|
652
|
+
_ts(listOpts.to, "to");
|
|
653
|
+
if (listOpts.to < listOpts.from) {
|
|
654
|
+
throw new TypeError("click-and-collect.pickupsForLocation: to must be >= from");
|
|
655
|
+
}
|
|
656
|
+
var sql, params;
|
|
657
|
+
if (listOpts.status != null) {
|
|
658
|
+
_status(listOpts.status);
|
|
659
|
+
sql = "SELECT * FROM pickup_schedules WHERE location_code = ?1 AND status = ?2 " +
|
|
660
|
+
"AND scheduled_window_start >= ?3 AND scheduled_window_start <= ?4 " +
|
|
661
|
+
"ORDER BY scheduled_window_start ASC, id ASC";
|
|
662
|
+
params = [listOpts.location_code, listOpts.status, listOpts.from, listOpts.to];
|
|
663
|
+
} else {
|
|
664
|
+
sql = "SELECT * FROM pickup_schedules WHERE location_code = ?1 " +
|
|
665
|
+
"AND scheduled_window_start >= ?2 AND scheduled_window_start <= ?3 " +
|
|
666
|
+
"ORDER BY scheduled_window_start ASC, id ASC";
|
|
667
|
+
params = [listOpts.location_code, listOpts.from, listOpts.to];
|
|
668
|
+
}
|
|
669
|
+
var rows = (await query(sql, params)).rows;
|
|
670
|
+
return rows;
|
|
671
|
+
},
|
|
672
|
+
|
|
673
|
+
// Schedules for a customer. Reads the customer's order ids via
|
|
674
|
+
// the injected `order` primitive's `listForCustomer` — this
|
|
675
|
+
// primitive does NOT duplicate the customer→order index. Refuses
|
|
676
|
+
// when `order` is not wired (the customer→order linkage is the
|
|
677
|
+
// order primitive's responsibility).
|
|
678
|
+
customerSchedules: async function (customerId) {
|
|
679
|
+
var id = _customerId(customerId);
|
|
680
|
+
if (!orderPrim) {
|
|
681
|
+
throw new TypeError("click-and-collect.customerSchedules: opts.order must be wired for " +
|
|
682
|
+
"this read (the customer→order linkage lives on the order primitive)");
|
|
683
|
+
}
|
|
684
|
+
var orderPage = await orderPrim.listForCustomer(id, { limit: MAX_LIST_LIMIT });
|
|
685
|
+
var orders = (orderPage && orderPage.rows) || [];
|
|
686
|
+
if (!orders.length) return [];
|
|
687
|
+
// SQL IN-list construction — every value is a UUID that came
|
|
688
|
+
// out of the order primitive (which itself validates UUIDs at
|
|
689
|
+
// write time), so the placeholder fan-out is safe. We still
|
|
690
|
+
// pass them as bound params rather than interpolating.
|
|
691
|
+
var placeholders = [];
|
|
692
|
+
var params = [];
|
|
693
|
+
for (var i = 0; i < orders.length; i += 1) {
|
|
694
|
+
placeholders.push("?" + (i + 1));
|
|
695
|
+
params.push(orders[i].id);
|
|
696
|
+
}
|
|
697
|
+
var sql = "SELECT * FROM pickup_schedules WHERE order_id IN (" +
|
|
698
|
+
placeholders.join(", ") + ") ORDER BY scheduled_window_start DESC, id DESC";
|
|
699
|
+
var rows = (await query(sql, params)).rows;
|
|
700
|
+
return rows;
|
|
701
|
+
},
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
module.exports = {
|
|
706
|
+
create: create,
|
|
707
|
+
PICKUP_STATUSES: PICKUP_STATUSES,
|
|
708
|
+
PROOF_KINDS: PROOF_KINDS,
|
|
709
|
+
NO_SHOW_ESCALATE_MS: NO_SHOW_ESCALATE_MS,
|
|
710
|
+
SIGNATURE_NAMESPACE: SIGNATURE_NAMESPACE,
|
|
711
|
+
};
|