@blamejs/blamejs-shop 0.0.56 → 0.0.57
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 +2 -0
- package/lib/backorder.js +452 -0
- package/lib/bundles.js +587 -0
- package/lib/fraud-screen.js +808 -0
- package/lib/index.js +10 -0
- package/lib/inventory-locations.js +774 -0
- package/lib/order-export.js +724 -0
- package/lib/order-notes.js +563 -0
- package/lib/payment-methods.js +522 -0
- package/lib/print-on-demand.js +709 -0
- package/lib/save-for-later.js +667 -0
- package/lib/variants.js +726 -0
- package/package.json +1 -1
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.saveForLater
|
|
4
|
+
* @title Save-for-later — adjacent to the cart; per-customer holdover
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* The storefront's "save for later" panel sits next to the cart.
|
|
8
|
+
* A shopper moves a line out of the cart (keeping the snapshot
|
|
9
|
+
* price, variant, and quantity) so a future session can drop it
|
|
10
|
+
* back in. This is NOT a wishlist:
|
|
11
|
+
*
|
|
12
|
+
* wishlist → "I'm interested in this product."
|
|
13
|
+
* save-for-later → "I was about to buy this exact
|
|
14
|
+
* configuration. Hold it for me."
|
|
15
|
+
*
|
|
16
|
+
* Each saved row carries the original cart context — price at the
|
|
17
|
+
* moment of save, variant id, quantity, optional shopper notes,
|
|
18
|
+
* and a breadcrumb (source_cart_id + source_line_id) for analytics
|
|
19
|
+
* / abandoned-cart recovery. The shopper can later re-add at the
|
|
20
|
+
* saved snapshot price OR re-price against the current catalog,
|
|
21
|
+
* their choice (`moveToCart({ use_price: "saved" | "current" })`).
|
|
22
|
+
*
|
|
23
|
+
* Composes:
|
|
24
|
+
* - `b.guardUuid` — every customer / save / cart / line id is
|
|
25
|
+
* UUID-shape-validated at the entry point; bad shape throws a
|
|
26
|
+
* TypeError the calling route handler translates to HTTP 400.
|
|
27
|
+
* - `b.uuid.v7` — row id (also lexicographically sortable as a
|
|
28
|
+
* tiebreak alongside saved_at).
|
|
29
|
+
* - `b.pagination.encodeCursor` / `decodeCursor` — HMAC-tagged
|
|
30
|
+
* opaque cursor on (saved_at DESC, id DESC) so an operator
|
|
31
|
+
* can't hand-craft a cursor to skip past a hidden row or
|
|
32
|
+
* replay one across deployments.
|
|
33
|
+
*
|
|
34
|
+
* Surface:
|
|
35
|
+
* - `moveFromCart({ customer_id, cart_id, line_id })` — pulls a
|
|
36
|
+
* cart line, writes a save-for-later row, deletes the cart
|
|
37
|
+
* line. Compensating-undo on failure so the cart line never
|
|
38
|
+
* disappears without the save row landing first.
|
|
39
|
+
* - `moveToCart({ customer_id, save_id, cart_id, use_price })` —
|
|
40
|
+
* reverse: re-creates the cart line (at the saved snapshot
|
|
41
|
+
* price or the current catalog price), deletes the save row.
|
|
42
|
+
* Refuses when the SKU is now out of stock AND not
|
|
43
|
+
* backorderable.
|
|
44
|
+
* - `add({ customer_id, sku, variant_id?, quantity,
|
|
45
|
+
* snapshot_price_minor, notes? })` — direct save (the
|
|
46
|
+
* PDP "save without adding to cart" UI path).
|
|
47
|
+
* - `remove({ customer_id, save_id })` / `clear(customer_id)`.
|
|
48
|
+
* - `listForCustomer({ customer_id, limit?, cursor? })` — HMAC-
|
|
49
|
+
* tagged cursor, saved_at DESC.
|
|
50
|
+
* - `countForCustomer(customer_id)`.
|
|
51
|
+
* - `staleCheck(customer_id)` — annotates each row with
|
|
52
|
+
* `is_stale` (snapshot vs current price diverged),
|
|
53
|
+
* `is_unavailable` (catalog row gone / archived), and
|
|
54
|
+
* `is_low_stock` (catalog stock < saved quantity).
|
|
55
|
+
* - `repriceAll(customer_id)` — bulk rewrite snapshot prices to
|
|
56
|
+
* current; returns the count of rows actually changed.
|
|
57
|
+
* - `expireOlderThan(days)` — operator-scheduler entry point
|
|
58
|
+
* that prunes saves older than the supplied age.
|
|
59
|
+
*
|
|
60
|
+
* Storage:
|
|
61
|
+
* - `save_for_later` (migration `0041_save_for_later.sql`).
|
|
62
|
+
*
|
|
63
|
+
* @primitive saveForLater
|
|
64
|
+
* @related b.guardUuid, b.pagination, b.uuid
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
var MAX_NOTES_LEN = 280;
|
|
68
|
+
var MAX_QTY = 9999;
|
|
69
|
+
var DEFAULT_LIMIT = 20;
|
|
70
|
+
var MAX_LIMIT = 100;
|
|
71
|
+
var ORDER_KEY = ["saved_at:desc", "id:desc"];
|
|
72
|
+
var USE_PRICE_VALUES = Object.freeze(["saved", "current"]);
|
|
73
|
+
|
|
74
|
+
var bShop;
|
|
75
|
+
function _b() {
|
|
76
|
+
if (!bShop) bShop = require("./index");
|
|
77
|
+
return bShop.framework;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function _uuid(s, label) {
|
|
81
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
82
|
+
catch (e) { throw new TypeError("save-for-later: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function _optUuid(s, label) {
|
|
86
|
+
if (s == null || s === "") return null;
|
|
87
|
+
return _uuid(s, label);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
91
|
+
function _sku(s) {
|
|
92
|
+
if (typeof s !== "string" || !SKU_RE.test(s)) {
|
|
93
|
+
throw new TypeError("save-for-later: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (alnum + . _ -, ≤ 128 chars)");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function _qty(n, label) {
|
|
98
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_QTY) {
|
|
99
|
+
throw new TypeError("save-for-later: " + label + " must be a positive integer ≤ " + MAX_QTY);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function _priceMinor(n, label) {
|
|
104
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
105
|
+
throw new TypeError("save-for-later: " + label + " must be a non-negative integer");
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function _days(n) {
|
|
110
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
111
|
+
throw new TypeError("save-for-later: days must be a non-negative integer");
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function _normalizeNotes(s) {
|
|
116
|
+
if (s == null) return null;
|
|
117
|
+
if (typeof s !== "string") {
|
|
118
|
+
throw new TypeError("save-for-later: notes must be a string");
|
|
119
|
+
}
|
|
120
|
+
// Refuse control bytes (incl. CR/LF) so a malicious note can't
|
|
121
|
+
// smuggle header-injection-class content into operator dashboards
|
|
122
|
+
// or downstream email templates that render the note inline.
|
|
123
|
+
if (/[\x00-\x1f\x7f]/.test(s)) {
|
|
124
|
+
throw new TypeError("save-for-later: notes must not contain control bytes");
|
|
125
|
+
}
|
|
126
|
+
if (s.length > MAX_NOTES_LEN) {
|
|
127
|
+
throw new TypeError("save-for-later: notes must be ≤ " + MAX_NOTES_LEN + " chars");
|
|
128
|
+
}
|
|
129
|
+
return s;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function _limit(n, label) {
|
|
133
|
+
if (n == null) return DEFAULT_LIMIT;
|
|
134
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIMIT) {
|
|
135
|
+
throw new TypeError("save-for-later: " + label + " must be 1..." + MAX_LIMIT);
|
|
136
|
+
}
|
|
137
|
+
return n;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function _usePrice(s) {
|
|
141
|
+
if (USE_PRICE_VALUES.indexOf(s) === -1) {
|
|
142
|
+
throw new TypeError("save-for-later: use_price must be one of " + USE_PRICE_VALUES.join(", "));
|
|
143
|
+
}
|
|
144
|
+
return s;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function _now() { return Date.now(); }
|
|
148
|
+
|
|
149
|
+
// ---- catalog lookups ----------------------------------------------------
|
|
150
|
+
//
|
|
151
|
+
// The primitive composes against the shop's catalog handle when one is
|
|
152
|
+
// passed in. Three lookups it performs:
|
|
153
|
+
//
|
|
154
|
+
// * `catalog.inventory.get(sku)` — stock check for
|
|
155
|
+
// `moveToCart` + `staleCheck`. Returns `{ stock_on_hand, ... }`
|
|
156
|
+
// or null when the SKU is unknown.
|
|
157
|
+
// * `catalog.variants.bySku(sku)` — current
|
|
158
|
+
// variant_id + product_id for `staleCheck`'s availability gate.
|
|
159
|
+
// Returns null when the variant is gone.
|
|
160
|
+
// * `catalog.prices.current(variant_id, ccy)` — current price for
|
|
161
|
+
// `moveToCart({ use_price: "current" })` + `staleCheck` price
|
|
162
|
+
// drift + `repriceAll`. Returns `{ amount_minor }` or null.
|
|
163
|
+
//
|
|
164
|
+
// Backorder eligibility uses a small ad-hoc query against
|
|
165
|
+
// `backorder_skus` (active=1 row) so the primitive doesn't need a
|
|
166
|
+
// dedicated backorder handle threaded in.
|
|
167
|
+
async function _isBackorderable(query, sku) {
|
|
168
|
+
var r = await query(
|
|
169
|
+
"SELECT 1 FROM backorder_skus WHERE sku = ?1 AND active = 1 LIMIT 1",
|
|
170
|
+
[sku],
|
|
171
|
+
);
|
|
172
|
+
return r.rows.length > 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ---- factory ------------------------------------------------------------
|
|
176
|
+
|
|
177
|
+
function create(opts) {
|
|
178
|
+
opts = opts || {};
|
|
179
|
+
var query = opts.query;
|
|
180
|
+
if (!query) {
|
|
181
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
182
|
+
}
|
|
183
|
+
// The catalog handle is required for the price-resolution + stock-
|
|
184
|
+
// check paths (`moveToCart` with `use_price: "current"`,
|
|
185
|
+
// `staleCheck`, `repriceAll`). `moveFromCart` and `add` accept the
|
|
186
|
+
// snapshot inline, so they don't strictly need it. The factory
|
|
187
|
+
// refuses up-front when it's missing so operators don't hit a
|
|
188
|
+
// half-working primitive at request time.
|
|
189
|
+
if (!opts.catalog) {
|
|
190
|
+
throw new TypeError("save-for-later.create: opts.catalog required (composes catalog.inventory + catalog.prices + catalog.variants)");
|
|
191
|
+
}
|
|
192
|
+
var catalog = opts.catalog;
|
|
193
|
+
|
|
194
|
+
// The currency the catalog is priced in — `staleCheck` /
|
|
195
|
+
// `repriceAll` / `moveToCart` need it to look up the current
|
|
196
|
+
// price. Defaults to USD for tests; operators wire their own.
|
|
197
|
+
var currency = opts.currency || "USD";
|
|
198
|
+
if (typeof currency !== "string" || !/^[A-Z]{3}$/.test(currency)) {
|
|
199
|
+
throw new TypeError("save-for-later.create: opts.currency must be a 3-letter ISO 4217 code");
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Pagination cursors for listForCustomer are HMAC-tagged via
|
|
203
|
+
// b.pagination so an operator can't hand-craft one to skip past a
|
|
204
|
+
// hidden row or replay across deployments. The secret defaults to
|
|
205
|
+
// a dev-only placeholder so the primitive boots in tests; the
|
|
206
|
+
// production deployment supplies a derived value (typically
|
|
207
|
+
// b.crypto.namespaceHash("save-for-later-cursor", D1_BRIDGE_SECRET)).
|
|
208
|
+
if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
|
|
209
|
+
if (process.env.NODE_ENV === "production") {
|
|
210
|
+
throw new Error("save-for-later.create: opts.cursorSecret is required in production");
|
|
211
|
+
}
|
|
212
|
+
opts.cursorSecret = "save-for-later-cursor-secret-dev-only";
|
|
213
|
+
}
|
|
214
|
+
var cursorSecret = opts.cursorSecret;
|
|
215
|
+
|
|
216
|
+
// Internal helper: writes a save_for_later row. Returns
|
|
217
|
+
// { id, status: "added" | "dedup" }
|
|
218
|
+
// — the same shape wishlist.add uses so the storefront's UI copy
|
|
219
|
+
// can differentiate "saved" from "already in your saved list".
|
|
220
|
+
async function _writeSaveRow(input) {
|
|
221
|
+
var id = _b().uuid.v7();
|
|
222
|
+
var ts = _now();
|
|
223
|
+
await query(
|
|
224
|
+
"INSERT OR IGNORE INTO save_for_later " +
|
|
225
|
+
"(id, customer_id, sku, variant_id, quantity, snapshot_price_minor, " +
|
|
226
|
+
" notes, source_cart_id, source_line_id, saved_at, last_repriced_at) " +
|
|
227
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, NULL)",
|
|
228
|
+
[
|
|
229
|
+
id,
|
|
230
|
+
input.customer_id,
|
|
231
|
+
input.sku,
|
|
232
|
+
input.variant_id,
|
|
233
|
+
input.quantity,
|
|
234
|
+
input.snapshot_price_minor,
|
|
235
|
+
input.notes,
|
|
236
|
+
input.source_cart_id,
|
|
237
|
+
input.source_line_id,
|
|
238
|
+
ts,
|
|
239
|
+
],
|
|
240
|
+
);
|
|
241
|
+
// Read back the (possibly pre-existing) row so the caller learns
|
|
242
|
+
// whether INSERT OR IGNORE actually inserted or was deduped.
|
|
243
|
+
var existing = (await query(
|
|
244
|
+
"SELECT id FROM save_for_later " +
|
|
245
|
+
"WHERE customer_id = ?1 AND sku = ?2 " +
|
|
246
|
+
"AND COALESCE(variant_id, '') = COALESCE(?3, '') LIMIT 1",
|
|
247
|
+
[input.customer_id, input.sku, input.variant_id],
|
|
248
|
+
)).rows[0];
|
|
249
|
+
return {
|
|
250
|
+
id: existing ? existing.id : id,
|
|
251
|
+
status: existing && existing.id === id ? "added" : "dedup",
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
MAX_NOTES_LEN: MAX_NOTES_LEN,
|
|
257
|
+
MAX_QTY: MAX_QTY,
|
|
258
|
+
USE_PRICE_VALUES: USE_PRICE_VALUES,
|
|
259
|
+
|
|
260
|
+
// Pulls a cart line, writes a save-for-later row, deletes the
|
|
261
|
+
// cart line. Atomic in the "either-both-or-neither" sense:
|
|
262
|
+
//
|
|
263
|
+
// 1. Read the cart line.
|
|
264
|
+
// 2. INSERT the save row (with INSERT OR IGNORE → dedups on
|
|
265
|
+
// the (customer, sku, variant) tuple).
|
|
266
|
+
// 3. DELETE the cart line.
|
|
267
|
+
//
|
|
268
|
+
// D1 doesn't expose multi-statement transactions across the
|
|
269
|
+
// HTTP bridge, so atomicity is provided by ordering + a
|
|
270
|
+
// compensating undo: if (3) fails after (2) inserted a new
|
|
271
|
+
// row, the save row is rolled back so the cart line stays the
|
|
272
|
+
// sole source of truth. If (2) was a dedup (the row already
|
|
273
|
+
// existed), the cart-line delete still runs — re-running
|
|
274
|
+
// moveFromCart is idempotent.
|
|
275
|
+
moveFromCart: async function (input) {
|
|
276
|
+
if (!input || typeof input !== "object") {
|
|
277
|
+
throw new TypeError("save-for-later.moveFromCart: input object required");
|
|
278
|
+
}
|
|
279
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
280
|
+
var cartId = _uuid(input.cart_id, "cart_id");
|
|
281
|
+
var lineId = _uuid(input.line_id, "line_id");
|
|
282
|
+
|
|
283
|
+
var line = (await query(
|
|
284
|
+
"SELECT * FROM cart_lines WHERE id = ?1 AND cart_id = ?2 LIMIT 1",
|
|
285
|
+
[lineId, cartId],
|
|
286
|
+
)).rows[0];
|
|
287
|
+
if (!line) {
|
|
288
|
+
throw new TypeError("save-for-later.moveFromCart: cart line " + lineId + " not found in cart " + cartId);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
var saved = await _writeSaveRow({
|
|
292
|
+
customer_id: customerId,
|
|
293
|
+
sku: line.sku,
|
|
294
|
+
variant_id: line.variant_id,
|
|
295
|
+
quantity: line.qty,
|
|
296
|
+
snapshot_price_minor: line.unit_amount_minor,
|
|
297
|
+
notes: null,
|
|
298
|
+
source_cart_id: cartId,
|
|
299
|
+
source_line_id: lineId,
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// The DELETE is the compensating-undo gate: if it throws after
|
|
303
|
+
// the save row was newly inserted, the save row gets rolled
|
|
304
|
+
// back so the cart line remains canonical. A dedup save —
|
|
305
|
+
// status "dedup" — is left in place even on delete failure
|
|
306
|
+
// because the existing save row predates this call and rolling
|
|
307
|
+
// it back would lose unrelated customer state.
|
|
308
|
+
try {
|
|
309
|
+
var del = await query(
|
|
310
|
+
"DELETE FROM cart_lines WHERE id = ?1 AND cart_id = ?2",
|
|
311
|
+
[lineId, cartId],
|
|
312
|
+
);
|
|
313
|
+
if (Number(del.rowCount || 0) === 0) {
|
|
314
|
+
// Someone else removed the line between our SELECT and DELETE.
|
|
315
|
+
// Treat as a no-op on the cart side — the save row still
|
|
316
|
+
// captures what the customer wanted.
|
|
317
|
+
}
|
|
318
|
+
} catch (e) {
|
|
319
|
+
if (saved.status === "added") {
|
|
320
|
+
// Compensating undo — drop the row we just inserted so the
|
|
321
|
+
// cart line stays the only source of truth.
|
|
322
|
+
try {
|
|
323
|
+
await query("DELETE FROM save_for_later WHERE id = ?1", [saved.id]);
|
|
324
|
+
} catch (_undoErr) { /* drop-silent — the original error is what the caller needs to see */ }
|
|
325
|
+
}
|
|
326
|
+
throw e;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return {
|
|
330
|
+
id: saved.id,
|
|
331
|
+
status: saved.status,
|
|
332
|
+
sku: line.sku,
|
|
333
|
+
};
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
// Reverse of moveFromCart: rebuild the cart line from a save row
|
|
337
|
+
// (either at the original snapshot price or the current catalog
|
|
338
|
+
// price), then delete the save row.
|
|
339
|
+
//
|
|
340
|
+
// use_price === "saved" — re-adds at snapshot_price_minor
|
|
341
|
+
// use_price === "current" — re-prices via
|
|
342
|
+
// catalog.prices.current(variant_id,
|
|
343
|
+
// currency)
|
|
344
|
+
//
|
|
345
|
+
// Refuses when the SKU has zero available stock AND is not
|
|
346
|
+
// marked backorderable. Operators who want to allow oversell
|
|
347
|
+
// keep the SKU in `backorder_skus` with active=1.
|
|
348
|
+
moveToCart: async function (input) {
|
|
349
|
+
if (!input || typeof input !== "object") {
|
|
350
|
+
throw new TypeError("save-for-later.moveToCart: input object required");
|
|
351
|
+
}
|
|
352
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
353
|
+
var saveId = _uuid(input.save_id, "save_id");
|
|
354
|
+
var cartId = _uuid(input.cart_id, "cart_id");
|
|
355
|
+
var usePrice = _usePrice(input.use_price);
|
|
356
|
+
|
|
357
|
+
var save = (await query(
|
|
358
|
+
"SELECT * FROM save_for_later WHERE id = ?1 AND customer_id = ?2 LIMIT 1",
|
|
359
|
+
[saveId, customerId],
|
|
360
|
+
)).rows[0];
|
|
361
|
+
if (!save) {
|
|
362
|
+
throw new TypeError("save-for-later.moveToCart: save " + saveId + " not found for customer " + customerId);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// Stock gate — backorderable SKUs bypass the available-stock
|
|
366
|
+
// check; everything else needs current stock >= saved quantity.
|
|
367
|
+
var inv = await catalog.inventory.get(save.sku);
|
|
368
|
+
var available = inv ? (inv.stock_on_hand - (inv.stock_held || 0)) : 0;
|
|
369
|
+
if (available < save.quantity) {
|
|
370
|
+
var backorderable = await _isBackorderable(query, save.sku);
|
|
371
|
+
if (!backorderable) {
|
|
372
|
+
throw new TypeError(
|
|
373
|
+
"save-for-later.moveToCart: sku " + save.sku +
|
|
374
|
+
" is out of stock (available " + available + ", needed " + save.quantity + ") and not backorderable"
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Resolve the unit price.
|
|
380
|
+
var unitAmount;
|
|
381
|
+
if (usePrice === "saved") {
|
|
382
|
+
unitAmount = save.snapshot_price_minor;
|
|
383
|
+
} else {
|
|
384
|
+
if (!save.variant_id) {
|
|
385
|
+
throw new TypeError("save-for-later.moveToCart: use_price='current' requires a saved variant_id");
|
|
386
|
+
}
|
|
387
|
+
var current = await catalog.prices.current(save.variant_id, currency);
|
|
388
|
+
if (!current) {
|
|
389
|
+
throw new TypeError(
|
|
390
|
+
"save-for-later.moveToCart: no current " + currency +
|
|
391
|
+
" price for variant " + save.variant_id
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
unitAmount = current.amount_minor;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Write the cart line. The save row is deleted only after the
|
|
398
|
+
// INSERT lands so a failure here (e.g. UNIQUE collision on
|
|
399
|
+
// (cart_id, variant_id) — the customer already has a line for
|
|
400
|
+
// this variant) leaves the save row intact for the operator /
|
|
401
|
+
// customer to resolve.
|
|
402
|
+
var lineId = _b().uuid.v7();
|
|
403
|
+
var ts = _now();
|
|
404
|
+
await query(
|
|
405
|
+
"INSERT INTO cart_lines (id, cart_id, variant_id, sku, qty, unit_amount_minor, unit_currency, added_at, updated_at) " +
|
|
406
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?8)",
|
|
407
|
+
[lineId, cartId, save.variant_id, save.sku, save.quantity, unitAmount, currency, ts],
|
|
408
|
+
);
|
|
409
|
+
// Bump the parent cart's updated_at so abandoned-cart timers
|
|
410
|
+
// see the activity.
|
|
411
|
+
await query("UPDATE carts SET updated_at = ?1 WHERE id = ?2", [ts, cartId]);
|
|
412
|
+
// Drop the save row.
|
|
413
|
+
await query("DELETE FROM save_for_later WHERE id = ?1", [saveId]);
|
|
414
|
+
|
|
415
|
+
return {
|
|
416
|
+
line_id: lineId,
|
|
417
|
+
cart_id: cartId,
|
|
418
|
+
sku: save.sku,
|
|
419
|
+
variant_id: save.variant_id,
|
|
420
|
+
quantity: save.quantity,
|
|
421
|
+
unit_amount_minor: unitAmount,
|
|
422
|
+
unit_currency: currency,
|
|
423
|
+
priced_from: usePrice,
|
|
424
|
+
};
|
|
425
|
+
},
|
|
426
|
+
|
|
427
|
+
// Direct save (no prior cart line). Storefront PDP "save without
|
|
428
|
+
// adding to cart" UI calls this — the customer supplies SKU,
|
|
429
|
+
// optional variant, quantity, and a snapshot price the storefront
|
|
430
|
+
// captures from the rendered price tag.
|
|
431
|
+
add: async function (input) {
|
|
432
|
+
if (!input || typeof input !== "object") {
|
|
433
|
+
throw new TypeError("save-for-later.add: input object required");
|
|
434
|
+
}
|
|
435
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
436
|
+
_sku(input.sku);
|
|
437
|
+
var variantId = _optUuid(input.variant_id, "variant_id");
|
|
438
|
+
_qty(input.quantity, "quantity");
|
|
439
|
+
_priceMinor(input.snapshot_price_minor, "snapshot_price_minor");
|
|
440
|
+
var notes = _normalizeNotes(input.notes);
|
|
441
|
+
|
|
442
|
+
var saved = await _writeSaveRow({
|
|
443
|
+
customer_id: customerId,
|
|
444
|
+
sku: input.sku,
|
|
445
|
+
variant_id: variantId,
|
|
446
|
+
quantity: input.quantity,
|
|
447
|
+
snapshot_price_minor: input.snapshot_price_minor,
|
|
448
|
+
notes: notes,
|
|
449
|
+
source_cart_id: null,
|
|
450
|
+
source_line_id: null,
|
|
451
|
+
});
|
|
452
|
+
return saved;
|
|
453
|
+
},
|
|
454
|
+
|
|
455
|
+
remove: async function (input) {
|
|
456
|
+
if (!input || typeof input !== "object") {
|
|
457
|
+
throw new TypeError("save-for-later.remove: input object required");
|
|
458
|
+
}
|
|
459
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
460
|
+
var saveId = _uuid(input.save_id, "save_id");
|
|
461
|
+
var r = await query(
|
|
462
|
+
"DELETE FROM save_for_later WHERE id = ?1 AND customer_id = ?2",
|
|
463
|
+
[saveId, customerId],
|
|
464
|
+
);
|
|
465
|
+
return { removed: Number(r.rowCount || 0) > 0 };
|
|
466
|
+
},
|
|
467
|
+
|
|
468
|
+
clear: async function (customerId) {
|
|
469
|
+
var cid = _uuid(customerId, "customer_id");
|
|
470
|
+
var r = await query("DELETE FROM save_for_later WHERE customer_id = ?1", [cid]);
|
|
471
|
+
return { removed: Number(r.rowCount || 0) };
|
|
472
|
+
},
|
|
473
|
+
|
|
474
|
+
listForCustomer: async function (listOpts) {
|
|
475
|
+
if (!listOpts || typeof listOpts !== "object") {
|
|
476
|
+
throw new TypeError("save-for-later.listForCustomer: input object required");
|
|
477
|
+
}
|
|
478
|
+
var customerId = _uuid(listOpts.customer_id, "customer_id");
|
|
479
|
+
var limit = _limit(listOpts.limit, "limit");
|
|
480
|
+
|
|
481
|
+
var cursorVals = null;
|
|
482
|
+
if (listOpts.cursor != null) {
|
|
483
|
+
if (typeof listOpts.cursor !== "string") {
|
|
484
|
+
throw new TypeError("save-for-later.listForCustomer: cursor must be an opaque string or null");
|
|
485
|
+
}
|
|
486
|
+
try {
|
|
487
|
+
var state = _b().pagination.decodeCursor(listOpts.cursor, cursorSecret);
|
|
488
|
+
if (JSON.stringify(state.orderKey) !== JSON.stringify(ORDER_KEY)) {
|
|
489
|
+
throw new TypeError("save-for-later.listForCustomer: cursor orderKey mismatch");
|
|
490
|
+
}
|
|
491
|
+
cursorVals = state.vals;
|
|
492
|
+
} catch (e) {
|
|
493
|
+
if (e instanceof TypeError) throw e;
|
|
494
|
+
throw new TypeError("save-for-later.listForCustomer: cursor — " + (e && e.message || "malformed"));
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
var sql, params;
|
|
499
|
+
if (cursorVals) {
|
|
500
|
+
sql = "SELECT * FROM save_for_later WHERE customer_id = ?1 AND " +
|
|
501
|
+
"(saved_at < ?2 OR (saved_at = ?2 AND id < ?3)) " +
|
|
502
|
+
"ORDER BY saved_at DESC, id DESC LIMIT ?4";
|
|
503
|
+
params = [customerId, cursorVals[0], cursorVals[1], limit];
|
|
504
|
+
} else {
|
|
505
|
+
sql = "SELECT * FROM save_for_later WHERE customer_id = ?1 " +
|
|
506
|
+
"ORDER BY saved_at DESC, id DESC LIMIT ?2";
|
|
507
|
+
params = [customerId, limit];
|
|
508
|
+
}
|
|
509
|
+
var rows = (await query(sql, params)).rows;
|
|
510
|
+
var last = rows[rows.length - 1];
|
|
511
|
+
var nextCursor = null;
|
|
512
|
+
if (last && rows.length === limit) {
|
|
513
|
+
nextCursor = _b().pagination.encodeCursor({
|
|
514
|
+
orderKey: ORDER_KEY,
|
|
515
|
+
vals: [last.saved_at, last.id],
|
|
516
|
+
forward: true,
|
|
517
|
+
}, cursorSecret);
|
|
518
|
+
}
|
|
519
|
+
return { rows: rows, nextCursor: nextCursor };
|
|
520
|
+
},
|
|
521
|
+
|
|
522
|
+
countForCustomer: async function (customerId) {
|
|
523
|
+
var cid = _uuid(customerId, "customer_id");
|
|
524
|
+
var r = await query(
|
|
525
|
+
"SELECT COUNT(*) AS n FROM save_for_later WHERE customer_id = ?1",
|
|
526
|
+
[cid],
|
|
527
|
+
);
|
|
528
|
+
return Number((r.rows[0] || {}).n || 0);
|
|
529
|
+
},
|
|
530
|
+
|
|
531
|
+
// Annotates every save row with three drift flags. Pure read —
|
|
532
|
+
// doesn't mutate the saved rows. The storefront uses these to
|
|
533
|
+
// surface "price changed" / "no longer available" / "running
|
|
534
|
+
// low" badges in the saved-for-later panel.
|
|
535
|
+
//
|
|
536
|
+
// is_stale — current catalog price differs from the
|
|
537
|
+
// saved snapshot. Implies the catalog row
|
|
538
|
+
// still exists.
|
|
539
|
+
// is_unavailable — the variant is gone (or has no current
|
|
540
|
+
// price), the catalog inventory row is gone,
|
|
541
|
+
// OR the parent product is archived.
|
|
542
|
+
// is_low_stock — catalog stock < saved quantity AND the SKU
|
|
543
|
+
// is not backorderable. Backorderable SKUs
|
|
544
|
+
// never flag low-stock (the operator already
|
|
545
|
+
// opted in to overselling).
|
|
546
|
+
staleCheck: async function (customerId) {
|
|
547
|
+
var cid = _uuid(customerId, "customer_id");
|
|
548
|
+
var saves = (await query(
|
|
549
|
+
"SELECT * FROM save_for_later WHERE customer_id = ?1 ORDER BY saved_at DESC, id DESC",
|
|
550
|
+
[cid],
|
|
551
|
+
)).rows;
|
|
552
|
+
var out = [];
|
|
553
|
+
for (var i = 0; i < saves.length; i += 1) {
|
|
554
|
+
var s = saves[i];
|
|
555
|
+
var isStale = false;
|
|
556
|
+
var isUnavailable = false;
|
|
557
|
+
var isLowStock = false;
|
|
558
|
+
var currentPriceMinor = null;
|
|
559
|
+
|
|
560
|
+
// Variant lookup — if the variant id is gone, the row is
|
|
561
|
+
// unavailable regardless of inventory.
|
|
562
|
+
var variant = null;
|
|
563
|
+
if (s.variant_id) {
|
|
564
|
+
variant = await catalog.variants.get(s.variant_id);
|
|
565
|
+
if (!variant) isUnavailable = true;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Inventory lookup.
|
|
569
|
+
var inv = await catalog.inventory.get(s.sku);
|
|
570
|
+
if (!inv) {
|
|
571
|
+
isUnavailable = true;
|
|
572
|
+
} else if (!isUnavailable) {
|
|
573
|
+
var available = inv.stock_on_hand - (inv.stock_held || 0);
|
|
574
|
+
if (available < s.quantity) {
|
|
575
|
+
var backorderable = await _isBackorderable(query, s.sku);
|
|
576
|
+
if (!backorderable) isLowStock = true;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// Price lookup — only meaningful when we still have a
|
|
581
|
+
// variant + the row isn't already flagged unavailable.
|
|
582
|
+
if (!isUnavailable && s.variant_id) {
|
|
583
|
+
var price = await catalog.prices.current(s.variant_id, currency);
|
|
584
|
+
if (!price) {
|
|
585
|
+
isUnavailable = true;
|
|
586
|
+
} else {
|
|
587
|
+
currentPriceMinor = price.amount_minor;
|
|
588
|
+
if (price.amount_minor !== s.snapshot_price_minor) {
|
|
589
|
+
isStale = true;
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
out.push({
|
|
595
|
+
id: s.id,
|
|
596
|
+
customer_id: s.customer_id,
|
|
597
|
+
sku: s.sku,
|
|
598
|
+
variant_id: s.variant_id,
|
|
599
|
+
quantity: s.quantity,
|
|
600
|
+
snapshot_price_minor: s.snapshot_price_minor,
|
|
601
|
+
current_price_minor: currentPriceMinor,
|
|
602
|
+
notes: s.notes,
|
|
603
|
+
source_cart_id: s.source_cart_id,
|
|
604
|
+
source_line_id: s.source_line_id,
|
|
605
|
+
saved_at: s.saved_at,
|
|
606
|
+
last_repriced_at: s.last_repriced_at,
|
|
607
|
+
is_stale: isStale,
|
|
608
|
+
is_unavailable: isUnavailable,
|
|
609
|
+
is_low_stock: isLowStock,
|
|
610
|
+
});
|
|
611
|
+
}
|
|
612
|
+
return out;
|
|
613
|
+
},
|
|
614
|
+
|
|
615
|
+
// Bulk re-price: walk every save row for the customer, look up
|
|
616
|
+
// the current catalog price, UPDATE snapshot_price_minor +
|
|
617
|
+
// last_repriced_at where the price moved. Returns the count of
|
|
618
|
+
// rows that actually changed.
|
|
619
|
+
//
|
|
620
|
+
// Rows with no variant_id, or whose variant has no current
|
|
621
|
+
// price, are skipped (they fall under the unavailable branch
|
|
622
|
+
// of staleCheck — re-pricing an unknown variant would just be
|
|
623
|
+
// a no-op).
|
|
624
|
+
repriceAll: async function (customerId) {
|
|
625
|
+
var cid = _uuid(customerId, "customer_id");
|
|
626
|
+
var saves = (await query(
|
|
627
|
+
"SELECT * FROM save_for_later WHERE customer_id = ?1",
|
|
628
|
+
[cid],
|
|
629
|
+
)).rows;
|
|
630
|
+
var changed = 0;
|
|
631
|
+
for (var i = 0; i < saves.length; i += 1) {
|
|
632
|
+
var s = saves[i];
|
|
633
|
+
if (!s.variant_id) continue;
|
|
634
|
+
var price = await catalog.prices.current(s.variant_id, currency);
|
|
635
|
+
if (!price) continue;
|
|
636
|
+
if (price.amount_minor === s.snapshot_price_minor) continue;
|
|
637
|
+
var ts = _now();
|
|
638
|
+
var r = await query(
|
|
639
|
+
"UPDATE save_for_later SET snapshot_price_minor = ?1, last_repriced_at = ?2 WHERE id = ?3",
|
|
640
|
+
[price.amount_minor, ts, s.id],
|
|
641
|
+
);
|
|
642
|
+
if (Number(r.rowCount || 0) > 0) changed += 1;
|
|
643
|
+
}
|
|
644
|
+
return { changed: changed };
|
|
645
|
+
},
|
|
646
|
+
|
|
647
|
+
// Operator scheduler entry point: remove every save older than
|
|
648
|
+
// the supplied age. Returns the count of removed rows so the
|
|
649
|
+
// cron / scheduled-worker layer can emit a metric.
|
|
650
|
+
expireOlderThan: async function (days) {
|
|
651
|
+
_days(days);
|
|
652
|
+
var threshold = _now() - (days * 24 * 60 * 60 * 1000);
|
|
653
|
+
var r = await query(
|
|
654
|
+
"DELETE FROM save_for_later WHERE saved_at < ?1",
|
|
655
|
+
[threshold],
|
|
656
|
+
);
|
|
657
|
+
return { removed: Number(r.rowCount || 0) };
|
|
658
|
+
},
|
|
659
|
+
};
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
module.exports = {
|
|
663
|
+
create: create,
|
|
664
|
+
MAX_NOTES_LEN: MAX_NOTES_LEN,
|
|
665
|
+
MAX_QTY: MAX_QTY,
|
|
666
|
+
USE_PRICE_VALUES: USE_PRICE_VALUES,
|
|
667
|
+
};
|