@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
package/lib/variants.js
ADDED
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Product variants — normalized option-axis layer on top of
|
|
4
|
+
* `catalog.products`. A T-shirt product has axes (color, size) with
|
|
5
|
+
* options (red/blue/green × S/M/L/XL) producing the cartesian
|
|
6
|
+
* product of 12 variant SKUs. The primitive lets operators register
|
|
7
|
+
* the axes + option values, generate the cartesian product for
|
|
8
|
+
* inspection, materialize it into per-SKU rows, and patch each
|
|
9
|
+
* variant's overrides (price, weight, image, inventory).
|
|
10
|
+
*
|
|
11
|
+
* Composes:
|
|
12
|
+
* - `b.guardUuid` — every product / variant / axis / option id
|
|
13
|
+
* passes through the strict UUID profile at the entry point. No
|
|
14
|
+
* raw caller string ever lands in SQL.
|
|
15
|
+
* - `b.uuid.v7` — millisecond-prefixed row id. Lexicographically
|
|
16
|
+
* sortable → B-tree-locality wins on PKs whose insert pattern is
|
|
17
|
+
* "newest at the end."
|
|
18
|
+
* - `b.safeSql` — UPDATE column allowlist routes through
|
|
19
|
+
* `safeSql.assertOneOf` + `safeSql.quoteIdentifier` so a future
|
|
20
|
+
* refactor that introduces a dynamic patch key can't widen the
|
|
21
|
+
* surface to identifier injection.
|
|
22
|
+
*
|
|
23
|
+
* Surface:
|
|
24
|
+
* - `defineAxis({ product_id, axis_name, options: [...] })` —
|
|
25
|
+
* register an axis (e.g. `color`). Position-stable. Refuses if
|
|
26
|
+
* `axis_name` already exists for the product, refuses empty
|
|
27
|
+
* options, refuses duplicate option labels (case-insensitive).
|
|
28
|
+
* Returns `{ id, product_id, axis_name, position, options: [...] }`.
|
|
29
|
+
* - `generateMatrix(product_id)` — cartesian product of all
|
|
30
|
+
* defined axes. Returns the variant rows that WOULD be created
|
|
31
|
+
* without writing them, so the operator can inspect SKU shapes
|
|
32
|
+
* and decide whether to commit.
|
|
33
|
+
* - `materializeMatrix(product_id, { sku_prefix, base_price_minor })`
|
|
34
|
+
* — writes the cartesian product. SKU shape:
|
|
35
|
+
* `<sku_prefix>-<axis1-opt>-<axis2-opt>-...` with each option
|
|
36
|
+
* value lowercase-ASCII-slugged. Existing live variants are
|
|
37
|
+
* re-checked so a re-run after a new axis-option is added only
|
|
38
|
+
* adds the missing rows.
|
|
39
|
+
* - `getVariant(variant_id)` — by id; archived rows still resolve
|
|
40
|
+
* so historic order lines render correctly.
|
|
41
|
+
* - `variantsForProduct(product_id, { include_archived? })` —
|
|
42
|
+
* default lists live only; opt-in includes archived rows.
|
|
43
|
+
* - `findVariant({ product_id, axis_values: { color: 'red', size: 'L' } })`
|
|
44
|
+
* — exact-match lookup against the axis_values map. Returns
|
|
45
|
+
* `null` if no live variant matches. Archived rows are excluded
|
|
46
|
+
* (operators inspect archived state via `getVariant` /
|
|
47
|
+
* `variantsForProduct({ include_archived: true })`).
|
|
48
|
+
* - `updateVariant(variant_id, patch)` — partial patch. Allowlist:
|
|
49
|
+
* `sku`, `price_minor`, `weight_grams`, `image_url`,
|
|
50
|
+
* `inventory_count`, `archived`. Unknown columns throw — closes
|
|
51
|
+
* the column-name-injection hole and surfaces typos at write
|
|
52
|
+
* time.
|
|
53
|
+
* - `archiveVariant(variant_id)` / `unarchiveVariant(variant_id)` —
|
|
54
|
+
* toggle `archived_at`.
|
|
55
|
+
* - `archiveAxisOption({ product_id, axis_name, option_value })` —
|
|
56
|
+
* soft-archive an option value. Cascades: every live variant
|
|
57
|
+
* carrying that (axis, option) pair is archived in the same
|
|
58
|
+
* write. Historic order lines that referenced an archived
|
|
59
|
+
* variant still resolve via `getVariant`.
|
|
60
|
+
*
|
|
61
|
+
* Storage:
|
|
62
|
+
* - `product_variant_axes` (migration `0033_product_variants.sql`)
|
|
63
|
+
* - `product_variant_axis_options` (same migration)
|
|
64
|
+
* - `product_variants` (same migration)
|
|
65
|
+
*
|
|
66
|
+
* @primitive variants
|
|
67
|
+
* @related b.guardUuid, b.safeSql, b.uuid, catalog
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
var MAX_AXIS_NAME_LEN = 64;
|
|
71
|
+
var MAX_OPTION_VALUE_LEN = 64;
|
|
72
|
+
var MAX_OPTIONS_PER_AXIS = 64;
|
|
73
|
+
var MAX_AXES_PER_PRODUCT = 8;
|
|
74
|
+
var MAX_SKU_PREFIX_LEN = 64;
|
|
75
|
+
var MAX_SKU_LEN = 128;
|
|
76
|
+
var MAX_IMAGE_URL_LEN = 2048;
|
|
77
|
+
var AXIS_NAME_RE = /^[a-z][a-z0-9_]{0,63}$/;
|
|
78
|
+
var SKU_PREFIX_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
79
|
+
var SKU_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
80
|
+
|
|
81
|
+
// Columns each updateVariant() call may touch. Locked down via
|
|
82
|
+
// `b.safeSql.assertOneOf` so a future change introducing a dynamic
|
|
83
|
+
// patch key path can't widen the surface to an attacker-controlled
|
|
84
|
+
// column name. `archived` is the caller-facing key that maps to the
|
|
85
|
+
// `archived_at` timestamp column — handled out-of-band so the
|
|
86
|
+
// allowlist matches the storage column name exactly.
|
|
87
|
+
var ALLOWED_VARIANT_COLUMNS = Object.freeze([
|
|
88
|
+
"sku", "price_minor", "weight_grams", "image_url", "inventory_count", "archived_at",
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
var bShop;
|
|
92
|
+
function _b() {
|
|
93
|
+
// Lazy-loaded so unit tests can require the module without first
|
|
94
|
+
// initializing the vendored blamejs tree. Matches the pattern used
|
|
95
|
+
// by every other shop primitive.
|
|
96
|
+
if (!bShop) bShop = require("./index");
|
|
97
|
+
return bShop.framework;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---- validators ----------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
function _id(s, label) {
|
|
103
|
+
try {
|
|
104
|
+
return _b().guardUuid.sanitize(s, { profile: "strict" });
|
|
105
|
+
} catch (e) {
|
|
106
|
+
throw new TypeError("variants: " + label + " — " + (e && e.message || "invalid UUID"));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function _axisName(s) {
|
|
111
|
+
if (typeof s !== "string" || !AXIS_NAME_RE.test(s)) {
|
|
112
|
+
throw new TypeError("variants: axis_name must match /^[a-z][a-z0-9_]*$/ (lowercase, " + MAX_AXIS_NAME_LEN + " chars max)");
|
|
113
|
+
}
|
|
114
|
+
return s;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function _optionValue(s, label) {
|
|
118
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_OPTION_VALUE_LEN) {
|
|
119
|
+
throw new TypeError("variants: " + label + " must be a non-empty string ≤ " + MAX_OPTION_VALUE_LEN + " chars");
|
|
120
|
+
}
|
|
121
|
+
// Refuse control bytes — the option value renders into HTML on
|
|
122
|
+
// the PDP variant selector AND becomes part of the SKU slug. CR /
|
|
123
|
+
// LF / NUL in either context is a smuggling surface.
|
|
124
|
+
if (/[\x00-\x1f\x7f]/.test(s)) {
|
|
125
|
+
throw new TypeError("variants: " + label + " must not contain control bytes");
|
|
126
|
+
}
|
|
127
|
+
return s;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function _skuPrefix(s) {
|
|
131
|
+
if (typeof s !== "string" || !SKU_PREFIX_RE.test(s)) {
|
|
132
|
+
throw new TypeError("variants: sku_prefix must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (≤ " + MAX_SKU_PREFIX_LEN + " chars)");
|
|
133
|
+
}
|
|
134
|
+
return s;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function _sku(s) {
|
|
138
|
+
if (typeof s !== "string" || !SKU_RE.test(s)) {
|
|
139
|
+
throw new TypeError("variants: sku must match /^[A-Za-z0-9][A-Za-z0-9._-]*$/ (≤ " + MAX_SKU_LEN + " chars)");
|
|
140
|
+
}
|
|
141
|
+
return s;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function _nonNegInt(n, label) {
|
|
145
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
146
|
+
throw new TypeError("variants: " + label + " must be a non-negative integer");
|
|
147
|
+
}
|
|
148
|
+
return n;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function _imageUrl(s) {
|
|
152
|
+
if (s == null || s === "") return "";
|
|
153
|
+
if (typeof s !== "string" || s.length > MAX_IMAGE_URL_LEN) {
|
|
154
|
+
throw new TypeError("variants: image_url must be a string ≤ " + MAX_IMAGE_URL_LEN + " chars");
|
|
155
|
+
}
|
|
156
|
+
// Allow https URLs and protocol-relative storefront paths (e.g.
|
|
157
|
+
// `/assets/...`). Anything else is refused — the image_url lands
|
|
158
|
+
// verbatim into an `<img src="...">` on the PDP, so javascript:,
|
|
159
|
+
// data:, and other exotic schemes are a smuggling surface.
|
|
160
|
+
if (s.charCodeAt(0) === 47 /* "/" */) {
|
|
161
|
+
if (/[\x00-\x1f\x7f]/.test(s) || s.indexOf("..") !== -1) {
|
|
162
|
+
throw new TypeError("variants: image_url path must not contain control bytes or '..'");
|
|
163
|
+
}
|
|
164
|
+
return s;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
_b().safeUrl.parse(s, { allowedProtocols: ["https:"] });
|
|
168
|
+
} catch (e) {
|
|
169
|
+
throw new TypeError("variants: image_url — " + (e && e.message || "must be https:// or a /-rooted path"));
|
|
170
|
+
}
|
|
171
|
+
return s;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function _bool(b, label) {
|
|
175
|
+
if (typeof b !== "boolean") {
|
|
176
|
+
throw new TypeError("variants: " + label + " must be a boolean");
|
|
177
|
+
}
|
|
178
|
+
return b;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Slug a single option label into the SKU-safe shape used in the
|
|
182
|
+
// compounded `<sku_prefix>-<axis1-opt>-<axis2-opt>-...` string. Lower-
|
|
183
|
+
// case ASCII alnum; everything else (whitespace, punctuation, multi-
|
|
184
|
+
// byte) collapses to a single `-`. Leading / trailing hyphens are
|
|
185
|
+
// trimmed. The full-axis-value SKU stays human-readable (`tshirt-
|
|
186
|
+
// navy-large`) without bringing identifier-injection surface to the
|
|
187
|
+
// SQL — the SKU column is a parameter, never an identifier.
|
|
188
|
+
function _slugOption(s) {
|
|
189
|
+
var out = "";
|
|
190
|
+
var lastWasHyphen = false;
|
|
191
|
+
for (var i = 0; i < s.length; i += 1) {
|
|
192
|
+
var c = s.charCodeAt(i);
|
|
193
|
+
var ch;
|
|
194
|
+
if (c >= 65 && c <= 90) ch = String.fromCharCode(c + 32); // A-Z -> a-z
|
|
195
|
+
else if (c >= 97 && c <= 122) ch = String.fromCharCode(c); // a-z
|
|
196
|
+
else if (c >= 48 && c <= 57) ch = String.fromCharCode(c); // 0-9
|
|
197
|
+
else ch = "-";
|
|
198
|
+
if (ch === "-") {
|
|
199
|
+
if (!lastWasHyphen && out.length > 0) {
|
|
200
|
+
out += "-";
|
|
201
|
+
lastWasHyphen = true;
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
out += ch;
|
|
205
|
+
lastWasHyphen = false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
// Trim trailing hyphen.
|
|
209
|
+
if (out.length && out.charAt(out.length - 1) === "-") out = out.slice(0, -1);
|
|
210
|
+
if (!out.length) {
|
|
211
|
+
throw new TypeError("variants: option value slugged to empty string (no ASCII alnum characters)");
|
|
212
|
+
}
|
|
213
|
+
return out;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Canonical JSON form for the axis-values map — keys sorted alpha-
|
|
217
|
+
// betically so a re-materialize after a column reorder still hits
|
|
218
|
+
// the same string. Used both for storage and for `findVariant`
|
|
219
|
+
// equality so the comparison is a single string-compare instead of
|
|
220
|
+
// a deep-object walk.
|
|
221
|
+
function _canonicalAxisValuesJson(map) {
|
|
222
|
+
var keys = Object.keys(map).sort();
|
|
223
|
+
var out = {};
|
|
224
|
+
for (var i = 0; i < keys.length; i += 1) out[keys[i]] = map[keys[i]];
|
|
225
|
+
return JSON.stringify(out);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ---- factory -------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
function create(opts) {
|
|
231
|
+
opts = opts || {};
|
|
232
|
+
var query = opts.query;
|
|
233
|
+
if (!query) {
|
|
234
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
235
|
+
}
|
|
236
|
+
// `catalog` is the catalog primitive instance — used to validate
|
|
237
|
+
// that the `product_id` resolves to a real product before any
|
|
238
|
+
// axis / variant write. The caller passes it through so a future
|
|
239
|
+
// multi-tenant deployment (one catalog instance per shop) can wire
|
|
240
|
+
// the variants instance against the matching catalog without
|
|
241
|
+
// resolving via the global require graph.
|
|
242
|
+
var catalog = opts.catalog || null;
|
|
243
|
+
|
|
244
|
+
async function _assertProductExists(productId) {
|
|
245
|
+
if (!catalog) return; // catalog-free test mode — caller takes responsibility
|
|
246
|
+
var p = await catalog.products.get(productId);
|
|
247
|
+
if (!p) throw new TypeError("variants: product_id — no product matches " + productId);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async function _axesForProduct(productId) {
|
|
251
|
+
var r = await query(
|
|
252
|
+
"SELECT * FROM product_variant_axes WHERE product_id = ?1 ORDER BY position ASC, created_at ASC",
|
|
253
|
+
[productId],
|
|
254
|
+
);
|
|
255
|
+
return r.rows;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function _optionsForAxis(axisId, includeArchived) {
|
|
259
|
+
var sql = includeArchived
|
|
260
|
+
? "SELECT * FROM product_variant_axis_options WHERE axis_id = ?1 ORDER BY position ASC"
|
|
261
|
+
: "SELECT * FROM product_variant_axis_options WHERE axis_id = ?1 AND archived_at IS NULL ORDER BY position ASC";
|
|
262
|
+
var r = await query(sql, [axisId]);
|
|
263
|
+
return r.rows;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function _existingVariantSkus(productId) {
|
|
267
|
+
var r = await query(
|
|
268
|
+
"SELECT sku FROM product_variants WHERE product_id = ?1",
|
|
269
|
+
[productId],
|
|
270
|
+
);
|
|
271
|
+
var set = Object.create(null);
|
|
272
|
+
for (var i = 0; i < r.rows.length; i += 1) set[r.rows[i].sku] = true;
|
|
273
|
+
return set;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Internal: build the cartesian product as an array of `{ sku,
|
|
277
|
+
// axis_values }` rows given the already-fetched axes + options.
|
|
278
|
+
// The slug is derived deterministically so the same input always
|
|
279
|
+
// produces the same SKU — re-running materialize is idempotent.
|
|
280
|
+
function _buildMatrix(skuPrefix, axesWithOptions) {
|
|
281
|
+
var rows = [{ axis_values: {}, slugParts: [] }];
|
|
282
|
+
for (var a = 0; a < axesWithOptions.length; a += 1) {
|
|
283
|
+
var axis = axesWithOptions[a].axis;
|
|
284
|
+
var options = axesWithOptions[a].options;
|
|
285
|
+
var next = [];
|
|
286
|
+
for (var r = 0; r < rows.length; r += 1) {
|
|
287
|
+
for (var o = 0; o < options.length; o += 1) {
|
|
288
|
+
var optValue = options[o].option_value;
|
|
289
|
+
var copy = {};
|
|
290
|
+
var k;
|
|
291
|
+
for (k in rows[r].axis_values) {
|
|
292
|
+
if (Object.prototype.hasOwnProperty.call(rows[r].axis_values, k)) {
|
|
293
|
+
copy[k] = rows[r].axis_values[k];
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
copy[axis.axis_name] = optValue;
|
|
297
|
+
next.push({
|
|
298
|
+
axis_values: copy,
|
|
299
|
+
slugParts: rows[r].slugParts.concat(_slugOption(optValue)),
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
rows = next;
|
|
304
|
+
}
|
|
305
|
+
var out = [];
|
|
306
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
307
|
+
var sku = skuPrefix + (rows[i].slugParts.length ? "-" + rows[i].slugParts.join("-") : "");
|
|
308
|
+
_sku(sku); // surfaces an over-long sku_prefix + option combo at generate time
|
|
309
|
+
out.push({
|
|
310
|
+
sku: sku.toLowerCase(),
|
|
311
|
+
axis_values: rows[i].axis_values,
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
return out;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
// ---- defineAxis -----------------------------------------------------
|
|
319
|
+
//
|
|
320
|
+
// Registers an axis + its option values atomically. The unique
|
|
321
|
+
// index on (product_id, axis_name) refuses the second call for
|
|
322
|
+
// the same name; we pre-flight the check so the operator gets a
|
|
323
|
+
// descriptive TypeError instead of a raw constraint-violation
|
|
324
|
+
// string.
|
|
325
|
+
defineAxis: async function (input) {
|
|
326
|
+
if (!input || typeof input !== "object") {
|
|
327
|
+
throw new TypeError("variants.defineAxis: input object required");
|
|
328
|
+
}
|
|
329
|
+
var productId = _id(input.product_id, "product_id");
|
|
330
|
+
var axisName = _axisName(input.axis_name);
|
|
331
|
+
if (!Array.isArray(input.options) || input.options.length === 0) {
|
|
332
|
+
throw new TypeError("variants.defineAxis: options must be a non-empty array");
|
|
333
|
+
}
|
|
334
|
+
if (input.options.length > MAX_OPTIONS_PER_AXIS) {
|
|
335
|
+
throw new TypeError("variants.defineAxis: options must be ≤ " + MAX_OPTIONS_PER_AXIS + " entries");
|
|
336
|
+
}
|
|
337
|
+
// Validate every option value + refuse case-insensitive
|
|
338
|
+
// duplicates so the operator can't accidentally register `Red`
|
|
339
|
+
// alongside `red` (they'd slug to the same SKU, so we treat
|
|
340
|
+
// them as the same option value).
|
|
341
|
+
var seen = Object.create(null);
|
|
342
|
+
var optionValues = [];
|
|
343
|
+
for (var i = 0; i < input.options.length; i += 1) {
|
|
344
|
+
var v = _optionValue(input.options[i], "options[" + i + "]");
|
|
345
|
+
var key = v.toLowerCase();
|
|
346
|
+
if (seen[key]) {
|
|
347
|
+
throw new TypeError("variants.defineAxis: duplicate option label " + JSON.stringify(input.options[i]) + " (case-insensitive)");
|
|
348
|
+
}
|
|
349
|
+
seen[key] = true;
|
|
350
|
+
optionValues.push(v);
|
|
351
|
+
}
|
|
352
|
+
await _assertProductExists(productId);
|
|
353
|
+
|
|
354
|
+
var existingAxes = await _axesForProduct(productId);
|
|
355
|
+
if (existingAxes.length >= MAX_AXES_PER_PRODUCT) {
|
|
356
|
+
throw new TypeError("variants.defineAxis: product already has " + MAX_AXES_PER_PRODUCT + " axes (max)");
|
|
357
|
+
}
|
|
358
|
+
for (var ea = 0; ea < existingAxes.length; ea += 1) {
|
|
359
|
+
if (existingAxes[ea].axis_name === axisName) {
|
|
360
|
+
throw new TypeError("variants.defineAxis: axis_name " + JSON.stringify(axisName) + " already registered for this product");
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
var axisId = _b().uuid.v7();
|
|
365
|
+
var position = existingAxes.length;
|
|
366
|
+
var now = Date.now();
|
|
367
|
+
await query(
|
|
368
|
+
"INSERT INTO product_variant_axes (id, product_id, axis_name, position, created_at) " +
|
|
369
|
+
"VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
370
|
+
[axisId, productId, axisName, position, now],
|
|
371
|
+
);
|
|
372
|
+
var insertedOptions = [];
|
|
373
|
+
for (var oi = 0; oi < optionValues.length; oi += 1) {
|
|
374
|
+
var optId = _b().uuid.v7();
|
|
375
|
+
await query(
|
|
376
|
+
"INSERT INTO product_variant_axis_options (id, axis_id, option_value, position, archived_at) " +
|
|
377
|
+
"VALUES (?1, ?2, ?3, ?4, NULL)",
|
|
378
|
+
[optId, axisId, optionValues[oi], oi],
|
|
379
|
+
);
|
|
380
|
+
insertedOptions.push({
|
|
381
|
+
id: optId,
|
|
382
|
+
axis_id: axisId,
|
|
383
|
+
option_value: optionValues[oi],
|
|
384
|
+
position: oi,
|
|
385
|
+
archived_at: null,
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
return {
|
|
389
|
+
id: axisId,
|
|
390
|
+
product_id: productId,
|
|
391
|
+
axis_name: axisName,
|
|
392
|
+
position: position,
|
|
393
|
+
created_at: now,
|
|
394
|
+
options: insertedOptions,
|
|
395
|
+
};
|
|
396
|
+
},
|
|
397
|
+
|
|
398
|
+
// ---- generateMatrix -------------------------------------------------
|
|
399
|
+
//
|
|
400
|
+
// Pure-read: returns the cartesian product as `[ { sku,
|
|
401
|
+
// axis_values } ]` rows without writing. Operators call this to
|
|
402
|
+
// preview SKUs before committing to `materializeMatrix`.
|
|
403
|
+
generateMatrix: async function (productId) {
|
|
404
|
+
var pid = _id(productId, "product_id");
|
|
405
|
+
var axes = await _axesForProduct(pid);
|
|
406
|
+
if (axes.length === 0) {
|
|
407
|
+
throw new TypeError("variants.generateMatrix: no axes registered for product " + pid);
|
|
408
|
+
}
|
|
409
|
+
var axesWithOptions = [];
|
|
410
|
+
for (var i = 0; i < axes.length; i += 1) {
|
|
411
|
+
var opts = await _optionsForAxis(axes[i].id, false); // live options only
|
|
412
|
+
if (opts.length === 0) {
|
|
413
|
+
throw new TypeError("variants.generateMatrix: axis " + JSON.stringify(axes[i].axis_name) + " has no live options");
|
|
414
|
+
}
|
|
415
|
+
axesWithOptions.push({ axis: axes[i], options: opts });
|
|
416
|
+
}
|
|
417
|
+
// Use a placeholder prefix so the matrix rows can be inspected
|
|
418
|
+
// shape-only — the operator overrides at materialize time.
|
|
419
|
+
return _buildMatrix("preview", axesWithOptions).map(function (row) {
|
|
420
|
+
return {
|
|
421
|
+
sku: row.sku.replace(/^preview-?/, ""), // strip placeholder; caller wires their own prefix
|
|
422
|
+
axis_values: row.axis_values,
|
|
423
|
+
};
|
|
424
|
+
});
|
|
425
|
+
},
|
|
426
|
+
|
|
427
|
+
// ---- materializeMatrix ----------------------------------------------
|
|
428
|
+
//
|
|
429
|
+
// Writes the cartesian-product rows. Idempotent — a re-run with
|
|
430
|
+
// a freshly-added axis option only inserts the missing rows;
|
|
431
|
+
// existing SKUs are left untouched.
|
|
432
|
+
materializeMatrix: async function (productId, input) {
|
|
433
|
+
var pid = _id(productId, "product_id");
|
|
434
|
+
if (!input || typeof input !== "object") {
|
|
435
|
+
throw new TypeError("variants.materializeMatrix: input object required");
|
|
436
|
+
}
|
|
437
|
+
var skuPrefix = _skuPrefix(input.sku_prefix);
|
|
438
|
+
var basePrice = _nonNegInt(input.base_price_minor, "base_price_minor");
|
|
439
|
+
|
|
440
|
+
var axes = await _axesForProduct(pid);
|
|
441
|
+
if (axes.length === 0) {
|
|
442
|
+
throw new TypeError("variants.materializeMatrix: no axes registered for product " + pid);
|
|
443
|
+
}
|
|
444
|
+
var axesWithOptions = [];
|
|
445
|
+
for (var i = 0; i < axes.length; i += 1) {
|
|
446
|
+
var opts = await _optionsForAxis(axes[i].id, false);
|
|
447
|
+
if (opts.length === 0) {
|
|
448
|
+
throw new TypeError("variants.materializeMatrix: axis " + JSON.stringify(axes[i].axis_name) + " has no live options");
|
|
449
|
+
}
|
|
450
|
+
axesWithOptions.push({ axis: axes[i], options: opts });
|
|
451
|
+
}
|
|
452
|
+
var matrix = _buildMatrix(skuPrefix, axesWithOptions);
|
|
453
|
+
var existing = await _existingVariantSkus(pid);
|
|
454
|
+
|
|
455
|
+
var inserted = [];
|
|
456
|
+
var skipped = 0;
|
|
457
|
+
for (var m = 0; m < matrix.length; m += 1) {
|
|
458
|
+
var row = matrix[m];
|
|
459
|
+
if (existing[row.sku]) { skipped += 1; continue; }
|
|
460
|
+
var id = _b().uuid.v7();
|
|
461
|
+
var now = Date.now();
|
|
462
|
+
var json = _canonicalAxisValuesJson(row.axis_values);
|
|
463
|
+
await query(
|
|
464
|
+
"INSERT INTO product_variants (id, product_id, sku, axis_values_json, price_minor, " +
|
|
465
|
+
"weight_grams, image_url, inventory_count, archived_at, created_at, updated_at) " +
|
|
466
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, 0, '', 0, NULL, ?6, ?6)",
|
|
467
|
+
[id, pid, row.sku, json, basePrice, now],
|
|
468
|
+
);
|
|
469
|
+
inserted.push({
|
|
470
|
+
id: id,
|
|
471
|
+
product_id: pid,
|
|
472
|
+
sku: row.sku,
|
|
473
|
+
axis_values: row.axis_values,
|
|
474
|
+
axis_values_json: json,
|
|
475
|
+
price_minor: basePrice,
|
|
476
|
+
weight_grams: 0,
|
|
477
|
+
image_url: "",
|
|
478
|
+
inventory_count: 0,
|
|
479
|
+
archived_at: null,
|
|
480
|
+
created_at: now,
|
|
481
|
+
updated_at: now,
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
return { inserted: inserted, skipped: skipped, total: matrix.length };
|
|
485
|
+
},
|
|
486
|
+
|
|
487
|
+
// ---- getVariant -----------------------------------------------------
|
|
488
|
+
//
|
|
489
|
+
// By id. Archived rows still resolve so an order line created
|
|
490
|
+
// before the archive can still render its variant.
|
|
491
|
+
getVariant: async function (variantId) {
|
|
492
|
+
var vid = _id(variantId, "variant_id");
|
|
493
|
+
var r = await query("SELECT * FROM product_variants WHERE id = ?1", [vid]);
|
|
494
|
+
var row = r.rows[0] || null;
|
|
495
|
+
if (row) row.axis_values = JSON.parse(row.axis_values_json);
|
|
496
|
+
return row;
|
|
497
|
+
},
|
|
498
|
+
|
|
499
|
+
// ---- variantsForProduct ---------------------------------------------
|
|
500
|
+
//
|
|
501
|
+
// Default lists live only. `include_archived: true` returns the
|
|
502
|
+
// full set (admin / inventory views).
|
|
503
|
+
variantsForProduct: async function (productId, listOpts) {
|
|
504
|
+
var pid = _id(productId, "product_id");
|
|
505
|
+
listOpts = listOpts || {};
|
|
506
|
+
var includeArchived = false;
|
|
507
|
+
if (listOpts.include_archived !== undefined) {
|
|
508
|
+
includeArchived = _bool(listOpts.include_archived, "include_archived");
|
|
509
|
+
}
|
|
510
|
+
var sql = includeArchived
|
|
511
|
+
? "SELECT * FROM product_variants WHERE product_id = ?1 ORDER BY created_at ASC, id ASC"
|
|
512
|
+
: "SELECT * FROM product_variants WHERE product_id = ?1 AND archived_at IS NULL ORDER BY created_at ASC, id ASC";
|
|
513
|
+
var r = await query(sql, [pid]);
|
|
514
|
+
return r.rows.map(function (row) {
|
|
515
|
+
row.axis_values = JSON.parse(row.axis_values_json);
|
|
516
|
+
return row;
|
|
517
|
+
});
|
|
518
|
+
},
|
|
519
|
+
|
|
520
|
+
// ---- findVariant ----------------------------------------------------
|
|
521
|
+
//
|
|
522
|
+
// Exact-match by (product_id, axis_values). The axis_values_json
|
|
523
|
+
// column is stored canonical (keys sorted), so the lookup
|
|
524
|
+
// canonicalises the input the same way and matches as a single
|
|
525
|
+
// string compare. Archived variants are excluded — operators
|
|
526
|
+
// looking for an archived row use `getVariant` or
|
|
527
|
+
// `variantsForProduct({ include_archived: true })`.
|
|
528
|
+
findVariant: async function (input) {
|
|
529
|
+
if (!input || typeof input !== "object") {
|
|
530
|
+
throw new TypeError("variants.findVariant: input object required");
|
|
531
|
+
}
|
|
532
|
+
var pid = _id(input.product_id, "product_id");
|
|
533
|
+
if (!input.axis_values || typeof input.axis_values !== "object" || Array.isArray(input.axis_values)) {
|
|
534
|
+
throw new TypeError("variants.findVariant: axis_values must be a plain object (axis_name -> option_value)");
|
|
535
|
+
}
|
|
536
|
+
// Validate every key + value before hashing them into the
|
|
537
|
+
// canonical JSON — refuses control bytes, oversized values,
|
|
538
|
+
// and non-axis-name-shaped keys.
|
|
539
|
+
var keys = Object.keys(input.axis_values);
|
|
540
|
+
if (keys.length === 0) {
|
|
541
|
+
throw new TypeError("variants.findVariant: axis_values must have at least one entry");
|
|
542
|
+
}
|
|
543
|
+
for (var i = 0; i < keys.length; i += 1) {
|
|
544
|
+
_axisName(keys[i]);
|
|
545
|
+
_optionValue(input.axis_values[keys[i]], "axis_values[" + JSON.stringify(keys[i]) + "]");
|
|
546
|
+
}
|
|
547
|
+
var canonical = _canonicalAxisValuesJson(input.axis_values);
|
|
548
|
+
var r = await query(
|
|
549
|
+
"SELECT * FROM product_variants WHERE product_id = ?1 AND axis_values_json = ?2 " +
|
|
550
|
+
"AND archived_at IS NULL LIMIT 1",
|
|
551
|
+
[pid, canonical],
|
|
552
|
+
);
|
|
553
|
+
var row = r.rows[0] || null;
|
|
554
|
+
if (row) row.axis_values = JSON.parse(row.axis_values_json);
|
|
555
|
+
return row;
|
|
556
|
+
},
|
|
557
|
+
|
|
558
|
+
// ---- updateVariant --------------------------------------------------
|
|
559
|
+
//
|
|
560
|
+
// Partial patch. The allowlist routes through `b.safeSql` so an
|
|
561
|
+
// unknown column key throws — the column is never composed into
|
|
562
|
+
// SQL from a caller-supplied identifier.
|
|
563
|
+
updateVariant: async function (variantId, patch) {
|
|
564
|
+
var vid = _id(variantId, "variant_id");
|
|
565
|
+
if (!patch || typeof patch !== "object") {
|
|
566
|
+
throw new TypeError("variants.updateVariant: patch object required");
|
|
567
|
+
}
|
|
568
|
+
// Refuse any key that isn't on the caller-facing allowlist
|
|
569
|
+
// BEFORE composing SQL. The caller-facing key set is
|
|
570
|
+
// {sku, price_minor, weight_grams, image_url, inventory_count,
|
|
571
|
+
// archived} — `archived` maps to `archived_at` (timestamp)
|
|
572
|
+
// out-of-band so the storage column stays the timestamp.
|
|
573
|
+
var ALLOWED_PATCH_KEYS = ["sku", "price_minor", "weight_grams", "image_url", "inventory_count", "archived"];
|
|
574
|
+
var patchKeys = Object.keys(patch);
|
|
575
|
+
for (var pk = 0; pk < patchKeys.length; pk += 1) {
|
|
576
|
+
if (ALLOWED_PATCH_KEYS.indexOf(patchKeys[pk]) === -1) {
|
|
577
|
+
throw new TypeError("variants.updateVariant: unknown patch key " + JSON.stringify(patchKeys[pk]) +
|
|
578
|
+
" (allowed: " + ALLOWED_PATCH_KEYS.join(", ") + ")");
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
var sets = [];
|
|
582
|
+
var params = [];
|
|
583
|
+
var i = 1;
|
|
584
|
+
function _addSet(col, val) {
|
|
585
|
+
_b().safeSql.assertOneOf(col, ALLOWED_VARIANT_COLUMNS);
|
|
586
|
+
sets.push(_b().safeSql.quoteIdentifier(col, "sqlite") + " = ?" + (i++));
|
|
587
|
+
params.push(val);
|
|
588
|
+
}
|
|
589
|
+
if (patch.sku !== undefined) {
|
|
590
|
+
_sku(patch.sku);
|
|
591
|
+
_addSet("sku", patch.sku);
|
|
592
|
+
}
|
|
593
|
+
if (patch.price_minor !== undefined) {
|
|
594
|
+
_nonNegInt(patch.price_minor, "price_minor");
|
|
595
|
+
_addSet("price_minor", patch.price_minor);
|
|
596
|
+
}
|
|
597
|
+
if (patch.weight_grams !== undefined) {
|
|
598
|
+
_nonNegInt(patch.weight_grams, "weight_grams");
|
|
599
|
+
_addSet("weight_grams", patch.weight_grams);
|
|
600
|
+
}
|
|
601
|
+
if (patch.image_url !== undefined) {
|
|
602
|
+
_addSet("image_url", _imageUrl(patch.image_url));
|
|
603
|
+
}
|
|
604
|
+
if (patch.inventory_count !== undefined) {
|
|
605
|
+
_nonNegInt(patch.inventory_count, "inventory_count");
|
|
606
|
+
_addSet("inventory_count", patch.inventory_count);
|
|
607
|
+
}
|
|
608
|
+
if (patch.archived !== undefined) {
|
|
609
|
+
var archived = _bool(patch.archived, "archived");
|
|
610
|
+
_addSet("archived_at", archived ? Date.now() : null);
|
|
611
|
+
}
|
|
612
|
+
if (sets.length === 0) {
|
|
613
|
+
throw new TypeError("variants.updateVariant: patch contained no updatable fields");
|
|
614
|
+
}
|
|
615
|
+
var ts = Date.now();
|
|
616
|
+
sets.push("updated_at = ?" + (i++));
|
|
617
|
+
params.push(ts);
|
|
618
|
+
params.push(vid);
|
|
619
|
+
var r = await query(
|
|
620
|
+
"UPDATE product_variants SET " + sets.join(", ") + " WHERE id = ?" + i,
|
|
621
|
+
params,
|
|
622
|
+
);
|
|
623
|
+
if (r.rowCount === 0) return null;
|
|
624
|
+
return await this.getVariant(vid);
|
|
625
|
+
},
|
|
626
|
+
|
|
627
|
+
// ---- archiveVariant / unarchiveVariant ------------------------------
|
|
628
|
+
archiveVariant: async function (variantId) {
|
|
629
|
+
var vid = _id(variantId, "variant_id");
|
|
630
|
+
var ts = Date.now();
|
|
631
|
+
var r = await query(
|
|
632
|
+
"UPDATE product_variants SET archived_at = ?1, updated_at = ?1 WHERE id = ?2 AND archived_at IS NULL",
|
|
633
|
+
[ts, vid],
|
|
634
|
+
);
|
|
635
|
+
if (r.rowCount === 0) {
|
|
636
|
+
// Could be missing or already archived — disambiguate so the
|
|
637
|
+
// caller gets a meaningful answer.
|
|
638
|
+
var existing = await query("SELECT id FROM product_variants WHERE id = ?1", [vid]);
|
|
639
|
+
if (existing.rows.length === 0) return null;
|
|
640
|
+
}
|
|
641
|
+
return await this.getVariant(vid);
|
|
642
|
+
},
|
|
643
|
+
|
|
644
|
+
unarchiveVariant: async function (variantId) {
|
|
645
|
+
var vid = _id(variantId, "variant_id");
|
|
646
|
+
var ts = Date.now();
|
|
647
|
+
await query(
|
|
648
|
+
"UPDATE product_variants SET archived_at = NULL, updated_at = ?1 WHERE id = ?2",
|
|
649
|
+
[ts, vid],
|
|
650
|
+
);
|
|
651
|
+
return await this.getVariant(vid);
|
|
652
|
+
},
|
|
653
|
+
|
|
654
|
+
// ---- archiveAxisOption ----------------------------------------------
|
|
655
|
+
//
|
|
656
|
+
// Soft-archives the (axis_name, option_value) pair so the
|
|
657
|
+
// storefront PDP can't render it as a selectable option, then
|
|
658
|
+
// cascades the archive to every live variant that carries that
|
|
659
|
+
// pair in its axis_values map.
|
|
660
|
+
archiveAxisOption: async function (input) {
|
|
661
|
+
if (!input || typeof input !== "object") {
|
|
662
|
+
throw new TypeError("variants.archiveAxisOption: input object required");
|
|
663
|
+
}
|
|
664
|
+
var pid = _id(input.product_id, "product_id");
|
|
665
|
+
var axisName = _axisName(input.axis_name);
|
|
666
|
+
var optValue = _optionValue(input.option_value, "option_value");
|
|
667
|
+
|
|
668
|
+
// Resolve the axis + option rows.
|
|
669
|
+
var axisRow = (await query(
|
|
670
|
+
"SELECT id FROM product_variant_axes WHERE product_id = ?1 AND axis_name = ?2",
|
|
671
|
+
[pid, axisName],
|
|
672
|
+
)).rows[0];
|
|
673
|
+
if (!axisRow) {
|
|
674
|
+
throw new TypeError("variants.archiveAxisOption: no axis " + JSON.stringify(axisName) + " for product " + pid);
|
|
675
|
+
}
|
|
676
|
+
var optRow = (await query(
|
|
677
|
+
"SELECT id, archived_at FROM product_variant_axis_options WHERE axis_id = ?1 AND option_value = ?2",
|
|
678
|
+
[axisRow.id, optValue],
|
|
679
|
+
)).rows[0];
|
|
680
|
+
if (!optRow) {
|
|
681
|
+
throw new TypeError("variants.archiveAxisOption: no option " + JSON.stringify(optValue) + " on axis " + JSON.stringify(axisName));
|
|
682
|
+
}
|
|
683
|
+
var ts = Date.now();
|
|
684
|
+
await query(
|
|
685
|
+
"UPDATE product_variant_axis_options SET archived_at = ?1 WHERE id = ?2 AND archived_at IS NULL",
|
|
686
|
+
[ts, optRow.id],
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
// Cascade: archive every live variant whose axis_values_json
|
|
690
|
+
// carries this (axis_name, option_value). We pull the candidate
|
|
691
|
+
// set in a single query, then walk in JS — the table is small
|
|
692
|
+
// per product (max axes × max options = 8 × 64 = 512 rows),
|
|
693
|
+
// and the JSON-match is more reliable than a LIKE pattern
|
|
694
|
+
// against a textual JSON encoding.
|
|
695
|
+
var liveRows = (await query(
|
|
696
|
+
"SELECT id, axis_values_json FROM product_variants WHERE product_id = ?1 AND archived_at IS NULL",
|
|
697
|
+
[pid],
|
|
698
|
+
)).rows;
|
|
699
|
+
var cascadedIds = [];
|
|
700
|
+
for (var i = 0; i < liveRows.length; i += 1) {
|
|
701
|
+
var map;
|
|
702
|
+
try { map = JSON.parse(liveRows[i].axis_values_json); }
|
|
703
|
+
catch (_e) { continue; }
|
|
704
|
+
if (map && map[axisName] === optValue) {
|
|
705
|
+
cascadedIds.push(liveRows[i].id);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
for (var ci = 0; ci < cascadedIds.length; ci += 1) {
|
|
709
|
+
await query(
|
|
710
|
+
"UPDATE product_variants SET archived_at = ?1, updated_at = ?1 WHERE id = ?2",
|
|
711
|
+
[ts, cascadedIds[ci]],
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
return {
|
|
715
|
+
axis_id: axisRow.id,
|
|
716
|
+
option_id: optRow.id,
|
|
717
|
+
archived_at: ts,
|
|
718
|
+
cascaded_variant_ids: cascadedIds,
|
|
719
|
+
};
|
|
720
|
+
},
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
module.exports = {
|
|
725
|
+
create: create,
|
|
726
|
+
};
|