@blamejs/blamejs-shop 0.0.72 → 0.0.75
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 +6 -0
- package/lib/announcement-bar.js +753 -0
- package/lib/banner-ab-tests.js +806 -0
- package/lib/bin-locations.js +791 -0
- package/lib/blog-articles.js +1173 -0
- package/lib/carrier-accounts.js +805 -0
- package/lib/cart-recovery.js +1133 -0
- package/lib/category-navigation.js +934 -0
- package/lib/consent-ledger.js +539 -0
- package/lib/customer-impersonation.js +743 -0
- package/lib/customer-merge.js +879 -0
- package/lib/demand-forecast.js +1121 -0
- package/lib/dispute-resolution.js +886 -0
- package/lib/email-ab-tests.js +918 -0
- package/lib/email-engagement-score.js +649 -0
- package/lib/event-log.js +713 -0
- package/lib/fulfillment-sla.js +791 -0
- package/lib/index.js +41 -0
- package/lib/inventory-audits.js +852 -0
- package/lib/line-gift-wrap.js +430 -0
- package/lib/marketing-budget.js +792 -0
- package/lib/operator-activity-feed.js +977 -0
- package/lib/operator-approvals.js +942 -0
- package/lib/operator-help-center.js +1020 -0
- package/lib/operator-inbox.js +889 -0
- package/lib/operator-sessions.js +701 -0
- package/lib/order-exchanges.js +602 -0
- package/lib/product-compare.js +804 -0
- package/lib/pwa-manifest.js +1005 -0
- package/lib/referral-leaderboard.js +612 -0
- package/lib/sales-tax-filings.js +807 -0
- package/lib/search-ranking.js +859 -0
- package/lib/shipping-insurance.js +757 -0
- package/lib/shrinkage-report.js +1182 -0
- package/lib/sidebar-widgets.js +952 -0
- package/lib/smart-restocking.js +1048 -0
- package/lib/stock-receipts.js +834 -0
- package/lib/subscription-analytics.js +1032 -0
- package/lib/suggestion-box.js +921 -0
- package/lib/tax-remittance.js +625 -0
- package/lib/vendor-invoices.js +1021 -0
- package/lib/winback-campaigns.js +1350 -0
- package/lib/wishlist-digest.js +1133 -0
- package/package.json +1 -1
|
@@ -0,0 +1,934 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.categoryNavigation
|
|
4
|
+
* @title Category navigation — storefront category tree + mega-menu
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Hierarchical (parent / child) categories that drive two surfaces:
|
|
8
|
+
*
|
|
9
|
+
* 1. Breadcrumbs. Product + collection pages render the ancestor
|
|
10
|
+
* chain from the root down to the current category. The chain
|
|
11
|
+
* is the operator-facing identity of where the customer is in
|
|
12
|
+
* the catalog ("Home > Outdoors > Tents > Family Tents").
|
|
13
|
+
* 2. Mega-menu rendering data. The storefront header consumes
|
|
14
|
+
* `tree({ depth: 2 })` to render the dropdown panels — every
|
|
15
|
+
* top-level category becomes a column, its children become
|
|
16
|
+
* the column's link list, and `hero_image_url` lands as the
|
|
17
|
+
* column's featured artwork.
|
|
18
|
+
*
|
|
19
|
+
* Cycle defense:
|
|
20
|
+
*
|
|
21
|
+
* The schema's `CHECK(slug != parent_slug)` catches the trivial
|
|
22
|
+
* self-parent case, but A -> B -> A and deeper cycles require an
|
|
23
|
+
* ancestor walk because SQLite has no recursive-CTE-as-CHECK
|
|
24
|
+
* shape. `defineCategory` and `move` both walk the proposed
|
|
25
|
+
* parent's ancestor chain before persisting; if the moving slug
|
|
26
|
+
* appears anywhere in that chain the call is refused with
|
|
27
|
+
* `CATEGORY_CYCLE`. The walk is bounded by `MAX_TREE_DEPTH` so
|
|
28
|
+
* a malformed graph (e.g. an operator-edited database) can't
|
|
29
|
+
* infinite-loop the primitive.
|
|
30
|
+
*
|
|
31
|
+
* Archive semantics:
|
|
32
|
+
*
|
|
33
|
+
* `archive({ slug })` soft-deletes the row (`archived_at` set,
|
|
34
|
+
* `active` cleared). `cascade: true` recursively archives every
|
|
35
|
+
* descendant — the storefront shouldn't render a category whose
|
|
36
|
+
* parent the operator just removed. Without `cascade`, the call
|
|
37
|
+
* refuses when descendants exist so the operator doesn't orphan
|
|
38
|
+
* a sub-tree by accident. Re-defining an archived slug (via
|
|
39
|
+
* `defineCategory` with the same slug) revives it: `archived_at`
|
|
40
|
+
* clears and `active` flips back to true.
|
|
41
|
+
*
|
|
42
|
+
* Position ordering:
|
|
43
|
+
*
|
|
44
|
+
* Siblings under the same parent sort by `(position ASC, slug
|
|
45
|
+
* ASC)`. `reorderSiblings` takes a complete list of the parent's
|
|
46
|
+
* direct children in the desired order; partial lists are
|
|
47
|
+
* refused so a missing slug can't silently drift to the end.
|
|
48
|
+
* New rows land at the tail (max existing position + 1) unless
|
|
49
|
+
* the operator supplies an explicit `position`.
|
|
50
|
+
*
|
|
51
|
+
* Hero image gate:
|
|
52
|
+
*
|
|
53
|
+
* `hero_image_url` passes through `b.safeUrl.parse` (https-only)
|
|
54
|
+
* OR accepts a /-rooted absolute path. javascript: / data: /
|
|
55
|
+
* protocol-relative `//host/...` URLs are refused before the
|
|
56
|
+
* row lands on disk — the same gate the announcementBar /
|
|
57
|
+
* knowledgeBase primitives apply.
|
|
58
|
+
*
|
|
59
|
+
* Optional catalog handle:
|
|
60
|
+
*
|
|
61
|
+
* `opts.catalog` is an optional read-only marker today — the
|
|
62
|
+
* primitive doesn't reach into it. A future verb that wants to
|
|
63
|
+
* refuse archive-with-products or surface a richer
|
|
64
|
+
* `productCount` can consume the handle without changing the
|
|
65
|
+
* factory shape. `productCount({ slug, include_descendants? })`
|
|
66
|
+
* today returns 0 when the catalog handle is absent and delegates
|
|
67
|
+
* to `catalog.products.countByCategory(slug)` when it exposes
|
|
68
|
+
* that method.
|
|
69
|
+
*
|
|
70
|
+
* Composes:
|
|
71
|
+
* - `b.safeUrl.parse` — hero_image_url protocol gate
|
|
72
|
+
*
|
|
73
|
+
* Monotonic per-process clock: two writes in the same millisecond
|
|
74
|
+
* would tie on `updated_at` and make a sort-by-timestamp read
|
|
75
|
+
* ambiguous. `_now` bumps to `prior + 1` on collision so the
|
|
76
|
+
* ordering carries a strict per-process timeline (matches the
|
|
77
|
+
* knowledgeBase / announcementBar discipline).
|
|
78
|
+
*
|
|
79
|
+
* Surface:
|
|
80
|
+
* - defineCategory({ slug, parent_slug?, title, description?,
|
|
81
|
+
* hero_image_url?, position?, active? })
|
|
82
|
+
* - getCategory(slug)
|
|
83
|
+
* - categoriesByParent({ parent_slug? })
|
|
84
|
+
* - tree({ root_slug?, depth? })
|
|
85
|
+
* - breadcrumbsFor({ slug })
|
|
86
|
+
* - descendantsOf({ slug, depth? })
|
|
87
|
+
* - move({ slug, new_parent_slug, new_position? })
|
|
88
|
+
* - archive({ slug, cascade? })
|
|
89
|
+
* - update({ slug, patch })
|
|
90
|
+
* - reorderSiblings({ parent_slug, ordered_slugs })
|
|
91
|
+
* - productCount({ slug, include_descendants? })
|
|
92
|
+
* - popularCategories({ from, to, limit })
|
|
93
|
+
*
|
|
94
|
+
* Storage:
|
|
95
|
+
* - categories (migration `0201_category_navigation.sql`).
|
|
96
|
+
*
|
|
97
|
+
* @primitive categoryNavigation
|
|
98
|
+
* @related b.safeUrl, shop.catalog, shop.storefront
|
|
99
|
+
*/
|
|
100
|
+
|
|
101
|
+
var MAX_SLUG_LEN = 120;
|
|
102
|
+
var MAX_TITLE_LEN = 200;
|
|
103
|
+
var MAX_DESCRIPTION_LEN = 2000;
|
|
104
|
+
var MAX_HERO_URL_LEN = 2048;
|
|
105
|
+
var MAX_POSITION = 1000000;
|
|
106
|
+
var MAX_TREE_DEPTH = 16;
|
|
107
|
+
var DEFAULT_TREE_DEPTH = 16;
|
|
108
|
+
var MAX_LIST_LIMIT = 200;
|
|
109
|
+
var DEFAULT_POPULAR_LIMIT = 10;
|
|
110
|
+
var MAX_POPULAR_LIMIT = 100;
|
|
111
|
+
var MAX_REORDER_BATCH = 500;
|
|
112
|
+
|
|
113
|
+
var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
114
|
+
var CONTROL_BYTE_STRICT_RE = /[\x00-\x1f\x7f]/;
|
|
115
|
+
var CONTROL_BYTE_BODY_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
116
|
+
var ZERO_WIDTH_RE = new RegExp(
|
|
117
|
+
"[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
var bShop;
|
|
121
|
+
function _b() {
|
|
122
|
+
if (!bShop) bShop = require("./index");
|
|
123
|
+
return bShop.framework;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---- monotonic clock ---------------------------------------------------
|
|
127
|
+
//
|
|
128
|
+
// Operator-driven writes can land in the same millisecond on fast
|
|
129
|
+
// machines. Bumping by 1ms on a tie keeps the timeline strictly
|
|
130
|
+
// increasing so a sort-by-timestamp read returns events in the order
|
|
131
|
+
// they were issued.
|
|
132
|
+
|
|
133
|
+
var _lastTs = 0;
|
|
134
|
+
function _now() {
|
|
135
|
+
var t = Date.now();
|
|
136
|
+
if (t <= _lastTs) t = _lastTs + 1;
|
|
137
|
+
_lastTs = t;
|
|
138
|
+
return t;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---- validators --------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
function _slug(s, label) {
|
|
144
|
+
label = label || "slug";
|
|
145
|
+
if (typeof s !== "string" || !s.length) {
|
|
146
|
+
throw new TypeError("categoryNavigation: " + label + " must be a non-empty string");
|
|
147
|
+
}
|
|
148
|
+
if (s.length > MAX_SLUG_LEN) {
|
|
149
|
+
throw new TypeError("categoryNavigation: " + label + " must be <= " + MAX_SLUG_LEN + " characters");
|
|
150
|
+
}
|
|
151
|
+
if (!SLUG_RE.test(s)) {
|
|
152
|
+
throw new TypeError("categoryNavigation: " + label + " must match /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/");
|
|
153
|
+
}
|
|
154
|
+
return s;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function _title(s) {
|
|
158
|
+
if (typeof s !== "string") {
|
|
159
|
+
throw new TypeError("categoryNavigation: title must be a string");
|
|
160
|
+
}
|
|
161
|
+
var trimmed = s.trim();
|
|
162
|
+
if (!trimmed.length) {
|
|
163
|
+
throw new TypeError("categoryNavigation: title must be non-empty after trim");
|
|
164
|
+
}
|
|
165
|
+
if (s.length > MAX_TITLE_LEN) {
|
|
166
|
+
throw new TypeError("categoryNavigation: title must be <= " + MAX_TITLE_LEN + " characters");
|
|
167
|
+
}
|
|
168
|
+
if (CONTROL_BYTE_STRICT_RE.test(s)) {
|
|
169
|
+
throw new TypeError("categoryNavigation: title contains control bytes");
|
|
170
|
+
}
|
|
171
|
+
if (ZERO_WIDTH_RE.test(s)) {
|
|
172
|
+
throw new TypeError("categoryNavigation: title contains zero-width / direction-override characters");
|
|
173
|
+
}
|
|
174
|
+
return s;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function _description(s) {
|
|
178
|
+
if (s == null) return null;
|
|
179
|
+
if (typeof s !== "string") {
|
|
180
|
+
throw new TypeError("categoryNavigation: description must be a string or null");
|
|
181
|
+
}
|
|
182
|
+
if (s.length > MAX_DESCRIPTION_LEN) {
|
|
183
|
+
throw new TypeError("categoryNavigation: description must be <= " + MAX_DESCRIPTION_LEN + " characters");
|
|
184
|
+
}
|
|
185
|
+
if (CONTROL_BYTE_BODY_RE.test(s)) {
|
|
186
|
+
throw new TypeError("categoryNavigation: description contains control bytes");
|
|
187
|
+
}
|
|
188
|
+
if (ZERO_WIDTH_RE.test(s)) {
|
|
189
|
+
throw new TypeError("categoryNavigation: description contains zero-width / direction-override characters");
|
|
190
|
+
}
|
|
191
|
+
return s;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Validate a hero-image URL through `b.safeUrl.parse` (https-only) OR
|
|
195
|
+
// accept a /-rooted absolute path. The same boundary defense the
|
|
196
|
+
// announcementBar / knowledgeBase primitives apply — javascript: /
|
|
197
|
+
// data: / protocol-relative `//host/...` are refused before
|
|
198
|
+
// persistence.
|
|
199
|
+
function _heroImageUrl(s) {
|
|
200
|
+
if (s == null) return null;
|
|
201
|
+
if (typeof s !== "string" || !s.length) {
|
|
202
|
+
throw new TypeError("categoryNavigation: hero_image_url must be a non-empty string or null");
|
|
203
|
+
}
|
|
204
|
+
if (s.length > MAX_HERO_URL_LEN) {
|
|
205
|
+
throw new TypeError("categoryNavigation: hero_image_url must be <= " + MAX_HERO_URL_LEN + " characters");
|
|
206
|
+
}
|
|
207
|
+
if (CONTROL_BYTE_BODY_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
|
|
208
|
+
throw new TypeError("categoryNavigation: hero_image_url contains control / zero-width bytes");
|
|
209
|
+
}
|
|
210
|
+
if (s.charCodeAt(0) === 47 /* "/" */) {
|
|
211
|
+
if (s.length > 1 && s.charCodeAt(1) === 47) {
|
|
212
|
+
throw new TypeError("categoryNavigation: hero_image_url protocol-relative `//host/...` refused — use absolute https://");
|
|
213
|
+
}
|
|
214
|
+
if (s.indexOf("..") !== -1) {
|
|
215
|
+
throw new TypeError("categoryNavigation: hero_image_url path must not contain '..'");
|
|
216
|
+
}
|
|
217
|
+
return s;
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
_b().safeUrl.parse(s, { allowedProtocols: ["https:"] });
|
|
221
|
+
} catch (e) {
|
|
222
|
+
throw new TypeError("categoryNavigation: hero_image_url — " + (e && e.message || "must be https:// or a /-rooted absolute path"));
|
|
223
|
+
}
|
|
224
|
+
return s;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function _position(n, label) {
|
|
228
|
+
label = label || "position";
|
|
229
|
+
if (n == null) return null;
|
|
230
|
+
if (!Number.isInteger(n) || n < 0 || n > MAX_POSITION) {
|
|
231
|
+
throw new TypeError("categoryNavigation: " + label + " must be an integer 0..." + MAX_POSITION);
|
|
232
|
+
}
|
|
233
|
+
return n;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function _depth(n, label) {
|
|
237
|
+
label = label || "depth";
|
|
238
|
+
if (n == null) return DEFAULT_TREE_DEPTH;
|
|
239
|
+
if (!Number.isInteger(n) || n < 1 || n > MAX_TREE_DEPTH) {
|
|
240
|
+
throw new TypeError("categoryNavigation: " + label + " must be an integer 1..." + MAX_TREE_DEPTH);
|
|
241
|
+
}
|
|
242
|
+
return n;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function _bool(v, label) {
|
|
246
|
+
if (typeof v !== "boolean") {
|
|
247
|
+
throw new TypeError("categoryNavigation: " + label + " must be a boolean");
|
|
248
|
+
}
|
|
249
|
+
return v;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function _limit(n, max, def, label) {
|
|
253
|
+
if (n == null) return def;
|
|
254
|
+
if (!Number.isInteger(n) || n <= 0 || n > max) {
|
|
255
|
+
throw new TypeError("categoryNavigation: " + label + " must be an integer 1..." + max);
|
|
256
|
+
}
|
|
257
|
+
return n;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function _timestampRange(from, to, label) {
|
|
261
|
+
if (!Number.isInteger(from) || from < 0) {
|
|
262
|
+
throw new TypeError("categoryNavigation." + label + ": from must be a non-negative integer (ms epoch)");
|
|
263
|
+
}
|
|
264
|
+
if (!Number.isInteger(to) || to < 0) {
|
|
265
|
+
throw new TypeError("categoryNavigation." + label + ": to must be a non-negative integer (ms epoch)");
|
|
266
|
+
}
|
|
267
|
+
if (from > to) {
|
|
268
|
+
throw new TypeError("categoryNavigation." + label + ": from must be <= to");
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---- hydration ---------------------------------------------------------
|
|
273
|
+
|
|
274
|
+
function _hydrateRow(row) {
|
|
275
|
+
if (!row) return null;
|
|
276
|
+
return {
|
|
277
|
+
slug: row.slug,
|
|
278
|
+
parent_slug: row.parent_slug == null ? null : row.parent_slug,
|
|
279
|
+
title: row.title,
|
|
280
|
+
description: row.description == null ? null : row.description,
|
|
281
|
+
hero_image_url: row.hero_image_url == null ? null : row.hero_image_url,
|
|
282
|
+
position: Number(row.position) || 0,
|
|
283
|
+
active: row.active === 1 || row.active === true,
|
|
284
|
+
archived_at: row.archived_at == null ? null : Number(row.archived_at),
|
|
285
|
+
created_at: Number(row.created_at),
|
|
286
|
+
updated_at: Number(row.updated_at),
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ---- factory -----------------------------------------------------------
|
|
291
|
+
|
|
292
|
+
function create(opts) {
|
|
293
|
+
opts = opts || {};
|
|
294
|
+
var query = opts.query;
|
|
295
|
+
if (!query) {
|
|
296
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// `catalog` is held as an optional read-only marker today — the
|
|
300
|
+
// primitive doesn't read from it on the core verbs. `productCount`
|
|
301
|
+
// delegates to `catalog.products.countByCategory(slug)` when the
|
|
302
|
+
// handle is wired; without it, the count is 0. A future verb that
|
|
303
|
+
// refuses archive-with-products can consume the handle without
|
|
304
|
+
// changing the factory shape.
|
|
305
|
+
var catalog = opts.catalog || null;
|
|
306
|
+
if (catalog !== null && typeof catalog !== "object") {
|
|
307
|
+
throw new TypeError("categoryNavigation.create: opts.catalog must be an object or null");
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
async function _readBySlug(slug) {
|
|
311
|
+
var r = await query("SELECT * FROM categories WHERE slug = ?1", [slug]);
|
|
312
|
+
return r.rows[0] || null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
async function _readChildren(parentSlug) {
|
|
316
|
+
if (parentSlug == null) {
|
|
317
|
+
var r1 = await query(
|
|
318
|
+
"SELECT * FROM categories WHERE parent_slug IS NULL AND archived_at IS NULL " +
|
|
319
|
+
"ORDER BY position ASC, slug ASC",
|
|
320
|
+
[]
|
|
321
|
+
);
|
|
322
|
+
return r1.rows;
|
|
323
|
+
}
|
|
324
|
+
var r2 = await query(
|
|
325
|
+
"SELECT * FROM categories WHERE parent_slug = ?1 AND archived_at IS NULL " +
|
|
326
|
+
"ORDER BY position ASC, slug ASC",
|
|
327
|
+
[parentSlug]
|
|
328
|
+
);
|
|
329
|
+
return r2.rows;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Walk ancestors from a starting slug upward. Returns an array of
|
|
333
|
+
// raw rows ordered child -> root. Bounded by MAX_TREE_DEPTH so a
|
|
334
|
+
// malformed graph (operator-edited DB) can't infinite-loop.
|
|
335
|
+
async function _walkAncestors(slug) {
|
|
336
|
+
var chain = [];
|
|
337
|
+
var cur = slug;
|
|
338
|
+
var seen = {};
|
|
339
|
+
for (var i = 0; i < MAX_TREE_DEPTH + 1; i += 1) {
|
|
340
|
+
if (cur == null) break;
|
|
341
|
+
if (seen[cur]) {
|
|
342
|
+
var loopErr = new Error("categoryNavigation: malformed graph — cycle detected at '" + cur + "'");
|
|
343
|
+
loopErr.code = "CATEGORY_GRAPH_MALFORMED";
|
|
344
|
+
throw loopErr;
|
|
345
|
+
}
|
|
346
|
+
seen[cur] = 1;
|
|
347
|
+
var row = await _readBySlug(cur);
|
|
348
|
+
if (!row) break;
|
|
349
|
+
chain.push(row);
|
|
350
|
+
cur = row.parent_slug;
|
|
351
|
+
}
|
|
352
|
+
if (i > MAX_TREE_DEPTH) {
|
|
353
|
+
var depthErr = new Error("categoryNavigation: ancestor chain exceeds MAX_TREE_DEPTH (" + MAX_TREE_DEPTH + ")");
|
|
354
|
+
depthErr.code = "CATEGORY_GRAPH_MALFORMED";
|
|
355
|
+
throw depthErr;
|
|
356
|
+
}
|
|
357
|
+
return chain;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// Returns true when `candidateAncestor` appears anywhere in the
|
|
361
|
+
// ancestor chain of `descendantSlug` (inclusive). Used by `move`
|
|
362
|
+
// and `defineCategory` to refuse cycle-introducing edges before
|
|
363
|
+
// the row lands on disk.
|
|
364
|
+
async function _isAncestorOrSelf(candidateAncestor, descendantSlug) {
|
|
365
|
+
var cur = descendantSlug;
|
|
366
|
+
var seen = {};
|
|
367
|
+
for (var i = 0; i < MAX_TREE_DEPTH + 1; i += 1) {
|
|
368
|
+
if (cur == null) return false;
|
|
369
|
+
if (cur === candidateAncestor) return true;
|
|
370
|
+
if (seen[cur]) return false;
|
|
371
|
+
seen[cur] = 1;
|
|
372
|
+
var row = await _readBySlug(cur);
|
|
373
|
+
if (!row) return false;
|
|
374
|
+
cur = row.parent_slug;
|
|
375
|
+
}
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function _nextPositionUnder(parentSlug) {
|
|
380
|
+
var sql, params;
|
|
381
|
+
if (parentSlug == null) {
|
|
382
|
+
sql = "SELECT MAX(position) AS max_pos FROM categories WHERE parent_slug IS NULL";
|
|
383
|
+
params = [];
|
|
384
|
+
} else {
|
|
385
|
+
sql = "SELECT MAX(position) AS max_pos FROM categories WHERE parent_slug = ?1";
|
|
386
|
+
params = [parentSlug];
|
|
387
|
+
}
|
|
388
|
+
var r = await query(sql, params);
|
|
389
|
+
var max = r.rows[0] && r.rows[0].max_pos;
|
|
390
|
+
if (max == null) return 0;
|
|
391
|
+
return Number(max) + 1;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Recursive descendant fetch. Returns rows ordered breadth-first
|
|
395
|
+
// (depth-1 children first, then depth-2, etc.). Each row carries
|
|
396
|
+
// an injected `_depth` field so callers can render the tree
|
|
397
|
+
// without re-walking. Bounded by MAX_TREE_DEPTH; archived rows
|
|
398
|
+
// excluded.
|
|
399
|
+
async function _readDescendants(rootSlug, maxDepth) {
|
|
400
|
+
var out = [];
|
|
401
|
+
var queue = [{ slug: rootSlug, depth: 0 }];
|
|
402
|
+
while (queue.length) {
|
|
403
|
+
var head = queue.shift();
|
|
404
|
+
if (head.depth >= maxDepth) continue;
|
|
405
|
+
var kids = await _readChildren(head.slug);
|
|
406
|
+
for (var i = 0; i < kids.length; i += 1) {
|
|
407
|
+
var kid = kids[i];
|
|
408
|
+
kid._depth = head.depth + 1;
|
|
409
|
+
out.push(kid);
|
|
410
|
+
queue.push({ slug: kid.slug, depth: head.depth + 1 });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
MAX_SLUG_LEN: MAX_SLUG_LEN,
|
|
418
|
+
MAX_TITLE_LEN: MAX_TITLE_LEN,
|
|
419
|
+
MAX_DESCRIPTION_LEN: MAX_DESCRIPTION_LEN,
|
|
420
|
+
MAX_HERO_URL_LEN: MAX_HERO_URL_LEN,
|
|
421
|
+
MAX_POSITION: MAX_POSITION,
|
|
422
|
+
MAX_TREE_DEPTH: MAX_TREE_DEPTH,
|
|
423
|
+
MAX_LIST_LIMIT: MAX_LIST_LIMIT,
|
|
424
|
+
MAX_POPULAR_LIMIT: MAX_POPULAR_LIMIT,
|
|
425
|
+
MAX_REORDER_BATCH: MAX_REORDER_BATCH,
|
|
426
|
+
|
|
427
|
+
// Idempotent insert / update of a single category row. The first
|
|
428
|
+
// call for a slug inserts; subsequent calls patch the title /
|
|
429
|
+
// description / hero / position / active fields in place. A
|
|
430
|
+
// re-define on an archived slug revives the row (`archived_at`
|
|
431
|
+
// clears, `active` flips back to true). `parent_slug` is
|
|
432
|
+
// validated to exist + the proposed edge is checked against
|
|
433
|
+
// cycles before persistence.
|
|
434
|
+
defineCategory: async function (input) {
|
|
435
|
+
if (!input || typeof input !== "object") {
|
|
436
|
+
throw new TypeError("categoryNavigation.defineCategory: input object required");
|
|
437
|
+
}
|
|
438
|
+
var slug = _slug(input.slug, "slug");
|
|
439
|
+
var title = _title(input.title);
|
|
440
|
+
var description = _description(input.description);
|
|
441
|
+
var heroImage = _heroImageUrl(input.hero_image_url);
|
|
442
|
+
var explicitPosition = _position(input.position, "position");
|
|
443
|
+
var active = input.active == null ? true : _bool(input.active, "active");
|
|
444
|
+
|
|
445
|
+
var parentSlug = null;
|
|
446
|
+
if (input.parent_slug != null) {
|
|
447
|
+
parentSlug = _slug(input.parent_slug, "parent_slug");
|
|
448
|
+
if (parentSlug === slug) {
|
|
449
|
+
throw new TypeError("categoryNavigation.defineCategory: parent_slug must differ from slug");
|
|
450
|
+
}
|
|
451
|
+
var parentRow = await _readBySlug(parentSlug);
|
|
452
|
+
if (!parentRow) {
|
|
453
|
+
var pErr = new Error("categoryNavigation.defineCategory: parent_slug '" + parentSlug + "' not found");
|
|
454
|
+
pErr.code = "CATEGORY_PARENT_NOT_FOUND";
|
|
455
|
+
throw pErr;
|
|
456
|
+
}
|
|
457
|
+
if (parentRow.archived_at != null) {
|
|
458
|
+
var aErr = new Error("categoryNavigation.defineCategory: parent_slug '" + parentSlug + "' is archived");
|
|
459
|
+
aErr.code = "CATEGORY_PARENT_ARCHIVED";
|
|
460
|
+
throw aErr;
|
|
461
|
+
}
|
|
462
|
+
// Cycle check: would the proposed parent end up downstream
|
|
463
|
+
// of `slug`? Only relevant on an update (the row already
|
|
464
|
+
// exists). For a fresh insert no descendants exist yet, so
|
|
465
|
+
// the candidate parent's chain can be trusted.
|
|
466
|
+
var existing = await _readBySlug(slug);
|
|
467
|
+
if (existing) {
|
|
468
|
+
var wouldCycle = await _isAncestorOrSelf(slug, parentSlug);
|
|
469
|
+
if (wouldCycle) {
|
|
470
|
+
var cErr = new Error("categoryNavigation.defineCategory: setting parent_slug '" + parentSlug + "' would create a cycle through '" + slug + "'");
|
|
471
|
+
cErr.code = "CATEGORY_CYCLE";
|
|
472
|
+
throw cErr;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
var ts = _now();
|
|
478
|
+
var existingRow = await _readBySlug(slug);
|
|
479
|
+
var position;
|
|
480
|
+
if (existingRow) {
|
|
481
|
+
position = explicitPosition == null ? Number(existingRow.position) : explicitPosition;
|
|
482
|
+
await query(
|
|
483
|
+
"UPDATE categories " +
|
|
484
|
+
"SET parent_slug = ?1, title = ?2, description = ?3, hero_image_url = ?4, " +
|
|
485
|
+
" position = ?5, active = ?6, archived_at = NULL, updated_at = ?7 " +
|
|
486
|
+
"WHERE slug = ?8",
|
|
487
|
+
[parentSlug, title, description, heroImage, position, active ? 1 : 0, ts, slug]
|
|
488
|
+
);
|
|
489
|
+
} else {
|
|
490
|
+
position = explicitPosition == null
|
|
491
|
+
? await _nextPositionUnder(parentSlug)
|
|
492
|
+
: explicitPosition;
|
|
493
|
+
await query(
|
|
494
|
+
"INSERT INTO categories " +
|
|
495
|
+
"(slug, parent_slug, title, description, hero_image_url, position, active, " +
|
|
496
|
+
" archived_at, created_at, updated_at) " +
|
|
497
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8, ?8)",
|
|
498
|
+
[slug, parentSlug, title, description, heroImage, position, active ? 1 : 0, ts]
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
var fresh = await _readBySlug(slug);
|
|
502
|
+
return _hydrateRow(fresh);
|
|
503
|
+
},
|
|
504
|
+
|
|
505
|
+
getCategory: async function (slug) {
|
|
506
|
+
slug = _slug(slug, "slug");
|
|
507
|
+
var row = await _readBySlug(slug);
|
|
508
|
+
if (!row || row.archived_at != null) return null;
|
|
509
|
+
return _hydrateRow(row);
|
|
510
|
+
},
|
|
511
|
+
|
|
512
|
+
// Direct children of a parent. `parent_slug` omitted / null
|
|
513
|
+
// returns the top-level (root) categories. Archived rows are
|
|
514
|
+
// excluded from every read.
|
|
515
|
+
categoriesByParent: async function (input) {
|
|
516
|
+
input = input || {};
|
|
517
|
+
var parentSlug = null;
|
|
518
|
+
if (input.parent_slug != null) {
|
|
519
|
+
parentSlug = _slug(input.parent_slug, "parent_slug");
|
|
520
|
+
var parentRow = await _readBySlug(parentSlug);
|
|
521
|
+
if (!parentRow || parentRow.archived_at != null) {
|
|
522
|
+
var err = new Error("categoryNavigation.categoriesByParent: parent_slug '" + parentSlug + "' not found");
|
|
523
|
+
err.code = "CATEGORY_NOT_FOUND";
|
|
524
|
+
throw err;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
var rows = await _readChildren(parentSlug);
|
|
528
|
+
return rows.map(_hydrateRow);
|
|
529
|
+
},
|
|
530
|
+
|
|
531
|
+
// Nested tree. `root_slug` omitted / null roots at the top-level
|
|
532
|
+
// (returns every top-level category with its descendants nested).
|
|
533
|
+
// `depth` caps the recursion; default 16, max 16. Each returned
|
|
534
|
+
// node has a `children` array (empty when no children at that
|
|
535
|
+
// depth, or when `depth` cuts off further recursion).
|
|
536
|
+
tree: async function (input) {
|
|
537
|
+
input = input || {};
|
|
538
|
+
var depth = _depth(input.depth, "depth");
|
|
539
|
+
var rootSlug = null;
|
|
540
|
+
var rootRow = null;
|
|
541
|
+
if (input.root_slug != null) {
|
|
542
|
+
rootSlug = _slug(input.root_slug, "root_slug");
|
|
543
|
+
rootRow = await _readBySlug(rootSlug);
|
|
544
|
+
if (!rootRow || rootRow.archived_at != null) {
|
|
545
|
+
var err = new Error("categoryNavigation.tree: root_slug '" + rootSlug + "' not found");
|
|
546
|
+
err.code = "CATEGORY_NOT_FOUND";
|
|
547
|
+
throw err;
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async function _build(parentSlug, remaining) {
|
|
552
|
+
if (remaining <= 0) return [];
|
|
553
|
+
var kids = await _readChildren(parentSlug);
|
|
554
|
+
var out = [];
|
|
555
|
+
for (var i = 0; i < kids.length; i += 1) {
|
|
556
|
+
var node = _hydrateRow(kids[i]);
|
|
557
|
+
node.children = await _build(kids[i].slug, remaining - 1);
|
|
558
|
+
out.push(node);
|
|
559
|
+
}
|
|
560
|
+
return out;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (rootRow) {
|
|
564
|
+
var rootNode = _hydrateRow(rootRow);
|
|
565
|
+
rootNode.children = await _build(rootSlug, depth);
|
|
566
|
+
return rootNode;
|
|
567
|
+
}
|
|
568
|
+
return await _build(null, depth);
|
|
569
|
+
},
|
|
570
|
+
|
|
571
|
+
// Returns the ancestor chain from root -> slug (inclusive of
|
|
572
|
+
// `slug` itself), one hydrated row per level. Useful for
|
|
573
|
+
// breadcrumb rendering. The current page (slug) is the last
|
|
574
|
+
// entry; the storefront typically renders it as plain text
|
|
575
|
+
// rather than a link.
|
|
576
|
+
breadcrumbsFor: async function (input) {
|
|
577
|
+
if (!input || typeof input !== "object") {
|
|
578
|
+
throw new TypeError("categoryNavigation.breadcrumbsFor: input object required");
|
|
579
|
+
}
|
|
580
|
+
var slug = _slug(input.slug, "slug");
|
|
581
|
+
var row = await _readBySlug(slug);
|
|
582
|
+
if (!row || row.archived_at != null) {
|
|
583
|
+
var err = new Error("categoryNavigation.breadcrumbsFor: slug '" + slug + "' not found");
|
|
584
|
+
err.code = "CATEGORY_NOT_FOUND";
|
|
585
|
+
throw err;
|
|
586
|
+
}
|
|
587
|
+
var chain = await _walkAncestors(slug);
|
|
588
|
+
// _walkAncestors returns child -> root; flip for breadcrumb
|
|
589
|
+
// rendering (root -> child).
|
|
590
|
+
var ordered = chain.slice().reverse();
|
|
591
|
+
return ordered.map(_hydrateRow);
|
|
592
|
+
},
|
|
593
|
+
|
|
594
|
+
// Flat descendant list under a slug. `depth` caps the recursion
|
|
595
|
+
// (default 16). Returned rows carry a `depth` field (1 = direct
|
|
596
|
+
// child of `slug`, 2 = grandchild, etc.) — the caller can
|
|
597
|
+
// reconstruct a nested shape if needed, or just iterate the
|
|
598
|
+
// flat list (e.g. to count descendants).
|
|
599
|
+
descendantsOf: async function (input) {
|
|
600
|
+
if (!input || typeof input !== "object") {
|
|
601
|
+
throw new TypeError("categoryNavigation.descendantsOf: input object required");
|
|
602
|
+
}
|
|
603
|
+
var slug = _slug(input.slug, "slug");
|
|
604
|
+
var depth = _depth(input.depth, "depth");
|
|
605
|
+
var row = await _readBySlug(slug);
|
|
606
|
+
if (!row || row.archived_at != null) {
|
|
607
|
+
var err = new Error("categoryNavigation.descendantsOf: slug '" + slug + "' not found");
|
|
608
|
+
err.code = "CATEGORY_NOT_FOUND";
|
|
609
|
+
throw err;
|
|
610
|
+
}
|
|
611
|
+
var rows = await _readDescendants(slug, depth);
|
|
612
|
+
var out = [];
|
|
613
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
614
|
+
var hydrated = _hydrateRow(rows[i]);
|
|
615
|
+
hydrated.depth = rows[i]._depth;
|
|
616
|
+
out.push(hydrated);
|
|
617
|
+
}
|
|
618
|
+
return out;
|
|
619
|
+
},
|
|
620
|
+
|
|
621
|
+
// Re-parent + optionally re-position a category. The cycle
|
|
622
|
+
// guard is the load-bearing check: moving `outdoors` under its
|
|
623
|
+
// own descendant `tents` would create a cycle, and a JSON
|
|
624
|
+
// round-trip can't validate that without an ancestor walk.
|
|
625
|
+
// `new_parent_slug: null` promotes the category to top-level.
|
|
626
|
+
move: async function (input) {
|
|
627
|
+
if (!input || typeof input !== "object") {
|
|
628
|
+
throw new TypeError("categoryNavigation.move: input object required");
|
|
629
|
+
}
|
|
630
|
+
var slug = _slug(input.slug, "slug");
|
|
631
|
+
var newParentSlug = null;
|
|
632
|
+
if (input.new_parent_slug != null) {
|
|
633
|
+
newParentSlug = _slug(input.new_parent_slug, "new_parent_slug");
|
|
634
|
+
}
|
|
635
|
+
var newPosition = _position(input.new_position, "new_position");
|
|
636
|
+
|
|
637
|
+
var row = await _readBySlug(slug);
|
|
638
|
+
if (!row || row.archived_at != null) {
|
|
639
|
+
var err = new Error("categoryNavigation.move: slug '" + slug + "' not found");
|
|
640
|
+
err.code = "CATEGORY_NOT_FOUND";
|
|
641
|
+
throw err;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
if (newParentSlug !== null) {
|
|
645
|
+
if (newParentSlug === slug) {
|
|
646
|
+
throw new TypeError("categoryNavigation.move: new_parent_slug must differ from slug");
|
|
647
|
+
}
|
|
648
|
+
var parentRow = await _readBySlug(newParentSlug);
|
|
649
|
+
if (!parentRow || parentRow.archived_at != null) {
|
|
650
|
+
var pErr = new Error("categoryNavigation.move: new_parent_slug '" + newParentSlug + "' not found");
|
|
651
|
+
pErr.code = "CATEGORY_PARENT_NOT_FOUND";
|
|
652
|
+
throw pErr;
|
|
653
|
+
}
|
|
654
|
+
// Cycle check — would the proposed parent be downstream of
|
|
655
|
+
// the moving slug?
|
|
656
|
+
var wouldCycle = await _isAncestorOrSelf(slug, newParentSlug);
|
|
657
|
+
if (wouldCycle) {
|
|
658
|
+
var cErr = new Error("categoryNavigation.move: moving '" + slug + "' under '" + newParentSlug + "' would create a cycle");
|
|
659
|
+
cErr.code = "CATEGORY_CYCLE";
|
|
660
|
+
throw cErr;
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
var ts = _now();
|
|
665
|
+
var position = newPosition == null
|
|
666
|
+
? await _nextPositionUnder(newParentSlug)
|
|
667
|
+
: newPosition;
|
|
668
|
+
await query(
|
|
669
|
+
"UPDATE categories SET parent_slug = ?1, position = ?2, updated_at = ?3 WHERE slug = ?4",
|
|
670
|
+
[newParentSlug, position, ts, slug]
|
|
671
|
+
);
|
|
672
|
+
var fresh = await _readBySlug(slug);
|
|
673
|
+
return _hydrateRow(fresh);
|
|
674
|
+
},
|
|
675
|
+
|
|
676
|
+
// Soft-delete. `cascade: true` recursively archives every
|
|
677
|
+
// descendant; without it, the call refuses when descendants
|
|
678
|
+
// exist so an operator doesn't orphan a sub-tree. Archived
|
|
679
|
+
// rows are hidden from every read surface.
|
|
680
|
+
archive: async function (input) {
|
|
681
|
+
if (!input || typeof input !== "object") {
|
|
682
|
+
throw new TypeError("categoryNavigation.archive: input object required");
|
|
683
|
+
}
|
|
684
|
+
var slug = _slug(input.slug, "slug");
|
|
685
|
+
var cascade = input.cascade === true;
|
|
686
|
+
var row = await _readBySlug(slug);
|
|
687
|
+
if (!row) {
|
|
688
|
+
var err = new Error("categoryNavigation.archive: slug '" + slug + "' not found");
|
|
689
|
+
err.code = "CATEGORY_NOT_FOUND";
|
|
690
|
+
throw err;
|
|
691
|
+
}
|
|
692
|
+
if (row.archived_at != null) {
|
|
693
|
+
return _hydrateRow(row);
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
var descendants = await _readDescendants(slug, MAX_TREE_DEPTH);
|
|
697
|
+
if (descendants.length && !cascade) {
|
|
698
|
+
var dErr = new Error("categoryNavigation.archive: slug '" + slug + "' has " + descendants.length + " descendant(s); pass cascade=true to archive the sub-tree");
|
|
699
|
+
dErr.code = "CATEGORY_HAS_DESCENDANTS";
|
|
700
|
+
throw dErr;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
var ts = _now();
|
|
704
|
+
await query(
|
|
705
|
+
"UPDATE categories SET archived_at = ?1, active = 0, updated_at = ?1 WHERE slug = ?2",
|
|
706
|
+
[ts, slug]
|
|
707
|
+
);
|
|
708
|
+
if (cascade) {
|
|
709
|
+
for (var i = 0; i < descendants.length; i += 1) {
|
|
710
|
+
await query(
|
|
711
|
+
"UPDATE categories SET archived_at = ?1, active = 0, updated_at = ?1 WHERE slug = ?2",
|
|
712
|
+
[ts, descendants[i].slug]
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
var fresh = await _readBySlug(slug);
|
|
717
|
+
return _hydrateRow(fresh);
|
|
718
|
+
},
|
|
719
|
+
|
|
720
|
+
// Patch an existing category. The patch envelope can contain any
|
|
721
|
+
// subset of { title, description, hero_image_url, active,
|
|
722
|
+
// position }. Parent re-parenting goes through `move` so the
|
|
723
|
+
// cycle guard fires; trying to patch `parent_slug` here is
|
|
724
|
+
// refused.
|
|
725
|
+
update: async function (input) {
|
|
726
|
+
if (!input || typeof input !== "object") {
|
|
727
|
+
throw new TypeError("categoryNavigation.update: input object required");
|
|
728
|
+
}
|
|
729
|
+
var slug = _slug(input.slug, "slug");
|
|
730
|
+
var patch = input.patch;
|
|
731
|
+
if (!patch || typeof patch !== "object") {
|
|
732
|
+
throw new TypeError("categoryNavigation.update: patch object required");
|
|
733
|
+
}
|
|
734
|
+
if (Object.prototype.hasOwnProperty.call(patch, "parent_slug")) {
|
|
735
|
+
throw new TypeError("categoryNavigation.update: re-parenting goes through move(), not update()");
|
|
736
|
+
}
|
|
737
|
+
if (Object.prototype.hasOwnProperty.call(patch, "slug")) {
|
|
738
|
+
throw new TypeError("categoryNavigation.update: slug is immutable");
|
|
739
|
+
}
|
|
740
|
+
var row = await _readBySlug(slug);
|
|
741
|
+
if (!row) {
|
|
742
|
+
var err = new Error("categoryNavigation.update: slug '" + slug + "' not found");
|
|
743
|
+
err.code = "CATEGORY_NOT_FOUND";
|
|
744
|
+
throw err;
|
|
745
|
+
}
|
|
746
|
+
if (row.archived_at != null) {
|
|
747
|
+
var aErr = new Error("categoryNavigation.update: slug '" + slug + "' is archived");
|
|
748
|
+
aErr.code = "CATEGORY_ARCHIVED";
|
|
749
|
+
throw aErr;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
var title = Object.prototype.hasOwnProperty.call(patch, "title")
|
|
753
|
+
? _title(patch.title)
|
|
754
|
+
: row.title;
|
|
755
|
+
var description = Object.prototype.hasOwnProperty.call(patch, "description")
|
|
756
|
+
? _description(patch.description)
|
|
757
|
+
: row.description;
|
|
758
|
+
var heroImage = Object.prototype.hasOwnProperty.call(patch, "hero_image_url")
|
|
759
|
+
? _heroImageUrl(patch.hero_image_url)
|
|
760
|
+
: row.hero_image_url;
|
|
761
|
+
var active = Object.prototype.hasOwnProperty.call(patch, "active")
|
|
762
|
+
? _bool(patch.active, "active")
|
|
763
|
+
: (row.active === 1 || row.active === true);
|
|
764
|
+
var position = Object.prototype.hasOwnProperty.call(patch, "position")
|
|
765
|
+
? _position(patch.position, "position")
|
|
766
|
+
: Number(row.position);
|
|
767
|
+
if (position == null) position = Number(row.position);
|
|
768
|
+
|
|
769
|
+
var ts = _now();
|
|
770
|
+
await query(
|
|
771
|
+
"UPDATE categories " +
|
|
772
|
+
"SET title = ?1, description = ?2, hero_image_url = ?3, active = ?4, " +
|
|
773
|
+
" position = ?5, updated_at = ?6 " +
|
|
774
|
+
"WHERE slug = ?7",
|
|
775
|
+
[title, description, heroImage, active ? 1 : 0, position, ts, slug]
|
|
776
|
+
);
|
|
777
|
+
var fresh = await _readBySlug(slug);
|
|
778
|
+
return _hydrateRow(fresh);
|
|
779
|
+
},
|
|
780
|
+
|
|
781
|
+
// Re-assign sibling positions in bulk. `ordered_slugs` must be a
|
|
782
|
+
// COMPLETE list of the parent's direct children — a missing slug
|
|
783
|
+
// is refused so it can't silently drift to the end of the order.
|
|
784
|
+
// Positions are re-stamped 0..N-1 in the supplied order.
|
|
785
|
+
reorderSiblings: async function (input) {
|
|
786
|
+
if (!input || typeof input !== "object") {
|
|
787
|
+
throw new TypeError("categoryNavigation.reorderSiblings: input object required");
|
|
788
|
+
}
|
|
789
|
+
var parentSlug = null;
|
|
790
|
+
if (input.parent_slug != null) {
|
|
791
|
+
parentSlug = _slug(input.parent_slug, "parent_slug");
|
|
792
|
+
var parentRow = await _readBySlug(parentSlug);
|
|
793
|
+
if (!parentRow || parentRow.archived_at != null) {
|
|
794
|
+
var pErr = new Error("categoryNavigation.reorderSiblings: parent_slug '" + parentSlug + "' not found");
|
|
795
|
+
pErr.code = "CATEGORY_NOT_FOUND";
|
|
796
|
+
throw pErr;
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
var orderedSlugs = input.ordered_slugs;
|
|
800
|
+
if (!Array.isArray(orderedSlugs)) {
|
|
801
|
+
throw new TypeError("categoryNavigation.reorderSiblings: ordered_slugs must be an array");
|
|
802
|
+
}
|
|
803
|
+
if (orderedSlugs.length > MAX_REORDER_BATCH) {
|
|
804
|
+
throw new TypeError("categoryNavigation.reorderSiblings: ordered_slugs must contain <= " + MAX_REORDER_BATCH + " entries");
|
|
805
|
+
}
|
|
806
|
+
var seen = {};
|
|
807
|
+
for (var i = 0; i < orderedSlugs.length; i += 1) {
|
|
808
|
+
var s = _slug(orderedSlugs[i], "ordered_slugs[" + i + "]");
|
|
809
|
+
if (seen[s]) {
|
|
810
|
+
throw new TypeError("categoryNavigation.reorderSiblings: ordered_slugs contains duplicate '" + s + "'");
|
|
811
|
+
}
|
|
812
|
+
seen[s] = 1;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
var children = await _readChildren(parentSlug);
|
|
816
|
+
if (children.length !== orderedSlugs.length) {
|
|
817
|
+
throw new TypeError(
|
|
818
|
+
"categoryNavigation.reorderSiblings: ordered_slugs has " + orderedSlugs.length +
|
|
819
|
+
" entries but parent has " + children.length + " direct children — supply a complete list"
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
var childSet = {};
|
|
823
|
+
for (var c = 0; c < children.length; c += 1) childSet[children[c].slug] = 1;
|
|
824
|
+
for (var k = 0; k < orderedSlugs.length; k += 1) {
|
|
825
|
+
if (!childSet[orderedSlugs[k]]) {
|
|
826
|
+
throw new TypeError(
|
|
827
|
+
"categoryNavigation.reorderSiblings: '" + orderedSlugs[k] +
|
|
828
|
+
"' is not a direct child of parent_slug " + JSON.stringify(parentSlug)
|
|
829
|
+
);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
var ts = _now();
|
|
834
|
+
for (var p = 0; p < orderedSlugs.length; p += 1) {
|
|
835
|
+
await query(
|
|
836
|
+
"UPDATE categories SET position = ?1, updated_at = ?2 WHERE slug = ?3",
|
|
837
|
+
[p, ts, orderedSlugs[p]]
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
var fresh = await _readChildren(parentSlug);
|
|
841
|
+
return fresh.map(_hydrateRow);
|
|
842
|
+
},
|
|
843
|
+
|
|
844
|
+
// Product-count adjacency. When `opts.catalog` exposes a
|
|
845
|
+
// `products.countByCategory(slug)` method the primitive delegates
|
|
846
|
+
// to it; without the handle the count is 0. `include_descendants`
|
|
847
|
+
// sums every descendant's count under the slug (one DB walk +
|
|
848
|
+
// one count call per category).
|
|
849
|
+
productCount: async function (input) {
|
|
850
|
+
if (!input || typeof input !== "object") {
|
|
851
|
+
throw new TypeError("categoryNavigation.productCount: input object required");
|
|
852
|
+
}
|
|
853
|
+
var slug = _slug(input.slug, "slug");
|
|
854
|
+
var includeDescendants = input.include_descendants === true;
|
|
855
|
+
var row = await _readBySlug(slug);
|
|
856
|
+
if (!row || row.archived_at != null) {
|
|
857
|
+
var err = new Error("categoryNavigation.productCount: slug '" + slug + "' not found");
|
|
858
|
+
err.code = "CATEGORY_NOT_FOUND";
|
|
859
|
+
throw err;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
function _countFor(s) {
|
|
863
|
+
if (!catalog || !catalog.products || typeof catalog.products.countByCategory !== "function") {
|
|
864
|
+
return Promise.resolve(0);
|
|
865
|
+
}
|
|
866
|
+
return Promise.resolve(catalog.products.countByCategory(s));
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
var ownCount = Number(await _countFor(slug)) || 0;
|
|
870
|
+
if (!includeDescendants) {
|
|
871
|
+
return { slug: slug, count: ownCount, includes_descendants: false };
|
|
872
|
+
}
|
|
873
|
+
var descendants = await _readDescendants(slug, MAX_TREE_DEPTH);
|
|
874
|
+
var total = ownCount;
|
|
875
|
+
for (var i = 0; i < descendants.length; i += 1) {
|
|
876
|
+
total += Number(await _countFor(descendants[i].slug)) || 0;
|
|
877
|
+
}
|
|
878
|
+
return { slug: slug, count: total, includes_descendants: true };
|
|
879
|
+
},
|
|
880
|
+
|
|
881
|
+
// Top-N categories by combined product count over the active +
|
|
882
|
+
// non-archived set. The "popular" signal is the product-count
|
|
883
|
+
// delegation above (no separate view log — categories are
|
|
884
|
+
// operator-curated, not customer-visited like KB articles), so
|
|
885
|
+
// the window is informational: the returned snapshot reflects
|
|
886
|
+
// the catalog state at call time, restricted to categories
|
|
887
|
+
// created within [from, to]. Operators who want a true traffic-
|
|
888
|
+
// weighted popularity ranking compose with the clickstream
|
|
889
|
+
// primitive.
|
|
890
|
+
popularCategories: async function (input) {
|
|
891
|
+
if (!input || typeof input !== "object") {
|
|
892
|
+
throw new TypeError("categoryNavigation.popularCategories: input object required");
|
|
893
|
+
}
|
|
894
|
+
_timestampRange(input.from, input.to, "popularCategories");
|
|
895
|
+
var limit = _limit(input.limit, MAX_POPULAR_LIMIT, DEFAULT_POPULAR_LIMIT, "limit");
|
|
896
|
+
|
|
897
|
+
var r = await query(
|
|
898
|
+
"SELECT slug FROM categories " +
|
|
899
|
+
"WHERE archived_at IS NULL AND active = 1 " +
|
|
900
|
+
" AND created_at >= ?1 AND created_at <= ?2",
|
|
901
|
+
[input.from, input.to]
|
|
902
|
+
);
|
|
903
|
+
var scored = [];
|
|
904
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
905
|
+
var s = r.rows[i].slug;
|
|
906
|
+
var count = 0;
|
|
907
|
+
if (catalog && catalog.products && typeof catalog.products.countByCategory === "function") {
|
|
908
|
+
count = Number(await catalog.products.countByCategory(s)) || 0;
|
|
909
|
+
}
|
|
910
|
+
scored.push({ slug: s, count: count });
|
|
911
|
+
}
|
|
912
|
+
scored.sort(function (a, b) {
|
|
913
|
+
if (b.count !== a.count) return b.count - a.count;
|
|
914
|
+
if (a.slug < b.slug) return -1;
|
|
915
|
+
if (a.slug > b.slug) return 1;
|
|
916
|
+
return 0;
|
|
917
|
+
});
|
|
918
|
+
return scored.slice(0, limit);
|
|
919
|
+
},
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
module.exports = {
|
|
924
|
+
create: create,
|
|
925
|
+
MAX_SLUG_LEN: MAX_SLUG_LEN,
|
|
926
|
+
MAX_TITLE_LEN: MAX_TITLE_LEN,
|
|
927
|
+
MAX_DESCRIPTION_LEN: MAX_DESCRIPTION_LEN,
|
|
928
|
+
MAX_HERO_URL_LEN: MAX_HERO_URL_LEN,
|
|
929
|
+
MAX_POSITION: MAX_POSITION,
|
|
930
|
+
MAX_TREE_DEPTH: MAX_TREE_DEPTH,
|
|
931
|
+
MAX_LIST_LIMIT: MAX_LIST_LIMIT,
|
|
932
|
+
MAX_POPULAR_LIMIT: MAX_POPULAR_LIMIT,
|
|
933
|
+
MAX_REORDER_BATCH: MAX_REORDER_BATCH,
|
|
934
|
+
};
|