@blamejs/blamejs-shop 0.0.65 → 0.0.70
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/lib/assembly-instructions.js +777 -0
- package/lib/auto-replenish.js +933 -0
- package/lib/business-hours.js +980 -0
- package/lib/click-and-collect.js +711 -0
- package/lib/clickstream.js +713 -0
- package/lib/cost-layers.js +774 -0
- package/lib/credit-limits.js +752 -0
- package/lib/currency-rounding.js +525 -0
- package/lib/customer-activity.js +862 -0
- package/lib/customer-notes.js +712 -0
- package/lib/customer-risk-profile.js +593 -0
- package/lib/customer-surveys.js +1012 -0
- package/lib/damage-photos.js +473 -0
- package/lib/discount-allocation.js +557 -0
- package/lib/dropship-forwarding.js +645 -0
- package/lib/email-templates.js +817 -0
- package/lib/index.js +45 -0
- package/lib/inventory-allocations.js +559 -0
- package/lib/inventory-writeoffs.js +636 -0
- package/lib/knowledge-base.js +1104 -0
- package/lib/locale-router.js +1077 -0
- package/lib/operator-roles.js +768 -0
- package/lib/order-escalation.js +951 -0
- package/lib/order-ratings.js +495 -0
- package/lib/order-tags.js +944 -0
- package/lib/packing-slips.js +810 -0
- package/lib/payment-retries.js +816 -0
- package/lib/pick-lists.js +639 -0
- package/lib/pixel-events.js +995 -0
- package/lib/preorder.js +595 -0
- package/lib/print-queue.js +681 -0
- package/lib/product-qa.js +749 -0
- package/lib/promo-bundles.js +835 -0
- package/lib/push-notifications.js +937 -0
- package/lib/refund-automation.js +853 -0
- package/lib/reorder-reminders.js +798 -0
- package/lib/robots-config.js +753 -0
- package/lib/seller-signup.js +1052 -0
- package/lib/site-redirects.js +690 -0
- package/lib/sitemap-generator.js +717 -0
- package/lib/subscription-gifts.js +710 -0
- package/lib/tax-cert-renewals.js +632 -0
- package/lib/theme-assets.js +711 -0
- package/lib/tier-benefits.js +776 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/metrics.js +68 -4
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
- package/lib/wishlist-alerts.js +842 -0
- package/lib/wishlist-sharing.js +718 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1104 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.knowledgeBase
|
|
4
|
+
* @title Knowledge base — self-serve customer help center
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* FAQ articles grouped by category, with locale fallback, search-
|
|
8
|
+
* suggest ranking, view tracking, and helpfulness voting. Composed
|
|
9
|
+
* by the supportTickets primitive's intake flow: the storefront
|
|
10
|
+
* calls `searchSuggest({ query })` against the customer's draft
|
|
11
|
+
* subject + body and surfaces ranked articles before the ticket
|
|
12
|
+
* form actually submits. A customer who finds their answer in the
|
|
13
|
+
* knowledge base closes the loop without ever opening a ticket.
|
|
14
|
+
*
|
|
15
|
+
* Body is authored in the in-process Markdown subset shared with
|
|
16
|
+
* cmsBlocks / storefrontPages. Every text run is HTML-escaped via
|
|
17
|
+
* `b.template.escapeHtml`; every link URL passes through
|
|
18
|
+
* `b.safeUrl.parse` (https-only) OR an allow-list for `/`-rooted
|
|
19
|
+
* absolute paths. Raw HTML in the body never reaches the rendered
|
|
20
|
+
* output — any `<` lands as `<`. The raw body lives in storage;
|
|
21
|
+
* the rendered HTML is computed on demand at read time.
|
|
22
|
+
*
|
|
23
|
+
* Locale fallback walks the requested BCP-47 tag right-to-left,
|
|
24
|
+
* dropping trailing subtags, then falls back to the operator's
|
|
25
|
+
* configured default locale (`opts.defaultLocale`, default `en`).
|
|
26
|
+
* "fr-ca" -> ["fr-ca", "fr", "en"]. First (slug, locale) match
|
|
27
|
+
* wins.
|
|
28
|
+
*
|
|
29
|
+
* Helpfulness voting is deduped at the (slug, session_id_hash)
|
|
30
|
+
* UNIQUE — a repeat vote from the same session collapses to a
|
|
31
|
+
* no-op so the aggregate counters reflect distinct sessions
|
|
32
|
+
* rather than refresh-loop noise. session_id is hashed via
|
|
33
|
+
* `b.crypto.namespaceHash("kb-vote-session", id)` (and the parallel
|
|
34
|
+
* "kb-view-session" namespace on view rows) so the raw session id
|
|
35
|
+
* never lands on disk.
|
|
36
|
+
*
|
|
37
|
+
* `searchSuggest` ranks candidates with three weighted signals:
|
|
38
|
+
* title-token match (weight 3), tag-token match (weight 2), and
|
|
39
|
+
* body-token match (weight 1). Token equality is case-insensitive
|
|
40
|
+
* ASCII; tokens come from a /\s+/ split of the query after lower-
|
|
41
|
+
* casing. Archived + unpublished articles never appear in the
|
|
42
|
+
* ranked output.
|
|
43
|
+
*
|
|
44
|
+
* Composes:
|
|
45
|
+
* - `b.template.escapeHtml` — render-time text escape
|
|
46
|
+
* - `b.safeUrl.parse` — render-time link gate (https://)
|
|
47
|
+
* - `b.crypto.namespaceHash` — session-id hashing
|
|
48
|
+
* - `b.uuid.v7` — row ids on votes + views
|
|
49
|
+
* - `b.pagination` — HMAC-tagged tuple cursor for
|
|
50
|
+
* listArticles
|
|
51
|
+
*
|
|
52
|
+
* Monotonic per-process clock: two writes in the same millisecond
|
|
53
|
+
* would tie on `updated_at` / `occurred_at` and make a sort-by-
|
|
54
|
+
* timestamp read ambiguous. `_now` bumps to `prior + 1` on
|
|
55
|
+
* collision so the (updated_at DESC, slug DESC) listArticles
|
|
56
|
+
* cursor + popularArticles aggregation carry a strict per-process
|
|
57
|
+
* ordering.
|
|
58
|
+
*
|
|
59
|
+
* Surface:
|
|
60
|
+
* - defineArticle({ slug, title, body, category, tags?, locale,
|
|
61
|
+
* published? })
|
|
62
|
+
* - getArticle({ slug, locale? })
|
|
63
|
+
* - listArticles({ category?, search?, published_only?, cursor?,
|
|
64
|
+
* limit? })
|
|
65
|
+
* - updateArticle(slug, patch)
|
|
66
|
+
* - publishArticle(slug)
|
|
67
|
+
* - unpublishArticle(slug)
|
|
68
|
+
* - archiveArticle(slug)
|
|
69
|
+
* - recordView({ slug, session_id?, customer_id? })
|
|
70
|
+
* - recordVote({ slug, session_id, vote })
|
|
71
|
+
* - voteAggregateForArticle(slug)
|
|
72
|
+
* - popularArticles({ from, to, limit })
|
|
73
|
+
* - searchSuggest({ query, limit, locale?, category? })
|
|
74
|
+
*
|
|
75
|
+
* Storage:
|
|
76
|
+
* - kb_articles, kb_views, kb_votes (migration
|
|
77
|
+
* `0162_knowledge_base.sql`).
|
|
78
|
+
*
|
|
79
|
+
* @primitive knowledgeBase
|
|
80
|
+
* @related b.template.escapeHtml, b.safeUrl, b.crypto.namespaceHash,
|
|
81
|
+
* b.uuid.v7, shop.supportTickets, shop.cmsBlocks
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
var MAX_SLUG_LEN = 120;
|
|
85
|
+
var MAX_TITLE_LEN = 200;
|
|
86
|
+
var MAX_BODY_LEN = 32000;
|
|
87
|
+
var MAX_CATEGORY_LEN = 80;
|
|
88
|
+
var MAX_TAG_LEN = 40;
|
|
89
|
+
var MAX_TAG_COUNT = 24;
|
|
90
|
+
var MAX_LOCALE_LEN = 35;
|
|
91
|
+
var MAX_QUERY_LEN = 400;
|
|
92
|
+
var MAX_LIST_LIMIT = 100;
|
|
93
|
+
var DEFAULT_LIST_LIMIT = 25;
|
|
94
|
+
var MAX_POPULAR_LIMIT = 100;
|
|
95
|
+
var DEFAULT_POPULAR_LIMIT = 10;
|
|
96
|
+
var MAX_SUGGEST_LIMIT = 25;
|
|
97
|
+
var DEFAULT_SUGGEST_LIMIT = 5;
|
|
98
|
+
|
|
99
|
+
var VIEW_NAMESPACE = "kb-view-session";
|
|
100
|
+
var VOTE_NAMESPACE = "kb-vote-session";
|
|
101
|
+
|
|
102
|
+
var ALLOWED_VOTES = ["helpful", "not_helpful"];
|
|
103
|
+
|
|
104
|
+
var LIST_ORDER_KEY = ["updated_at:desc", "slug:desc"];
|
|
105
|
+
|
|
106
|
+
var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
107
|
+
var LOCALE_RE = /^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/;
|
|
108
|
+
var CATEGORY_RE = /^[a-z][a-z0-9_-]*$/;
|
|
109
|
+
var TAG_RE = /^[a-z][a-z0-9_-]*$/;
|
|
110
|
+
|
|
111
|
+
var CONTROL_BYTE_BODY_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
112
|
+
var CONTROL_BYTE_STRICT_RE = /[\x00-\x1f\x7f]/;
|
|
113
|
+
var ZERO_WIDTH_RE = new RegExp(
|
|
114
|
+
"[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
var SEARCH_WEIGHTS = Object.freeze({
|
|
118
|
+
title: 3,
|
|
119
|
+
tag: 2,
|
|
120
|
+
body: 1,
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
var bShop;
|
|
124
|
+
function _b() {
|
|
125
|
+
if (!bShop) bShop = require("./index");
|
|
126
|
+
return bShop.framework;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ---- monotonic clock ---------------------------------------------------
|
|
130
|
+
//
|
|
131
|
+
// Operator-driven writes can land in the same millisecond on fast
|
|
132
|
+
// machines. Bumping by 1ms on a tie keeps the timeline strictly
|
|
133
|
+
// increasing so a sort-by-timestamp read returns events in the
|
|
134
|
+
// order they were issued.
|
|
135
|
+
|
|
136
|
+
var _lastTs = 0;
|
|
137
|
+
function _now() {
|
|
138
|
+
var t = Date.now();
|
|
139
|
+
if (t <= _lastTs) t = _lastTs + 1;
|
|
140
|
+
_lastTs = t;
|
|
141
|
+
return t;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---- validators --------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
function _slug(s) {
|
|
147
|
+
if (typeof s !== "string" || !s.length) {
|
|
148
|
+
throw new TypeError("knowledgeBase: slug must be a non-empty string");
|
|
149
|
+
}
|
|
150
|
+
if (s.length > MAX_SLUG_LEN) {
|
|
151
|
+
throw new TypeError("knowledgeBase: slug must be <= " + MAX_SLUG_LEN + " characters");
|
|
152
|
+
}
|
|
153
|
+
if (!SLUG_RE.test(s)) {
|
|
154
|
+
throw new TypeError("knowledgeBase: slug must match /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/");
|
|
155
|
+
}
|
|
156
|
+
return s;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function _title(s) {
|
|
160
|
+
if (typeof s !== "string") {
|
|
161
|
+
throw new TypeError("knowledgeBase: title must be a string");
|
|
162
|
+
}
|
|
163
|
+
var trimmed = s.trim();
|
|
164
|
+
if (!trimmed.length) {
|
|
165
|
+
throw new TypeError("knowledgeBase: title must be non-empty after trim");
|
|
166
|
+
}
|
|
167
|
+
if (s.length > MAX_TITLE_LEN) {
|
|
168
|
+
throw new TypeError("knowledgeBase: title must be <= " + MAX_TITLE_LEN + " characters");
|
|
169
|
+
}
|
|
170
|
+
if (CONTROL_BYTE_STRICT_RE.test(s)) {
|
|
171
|
+
throw new TypeError("knowledgeBase: title contains control bytes");
|
|
172
|
+
}
|
|
173
|
+
if (ZERO_WIDTH_RE.test(s)) {
|
|
174
|
+
throw new TypeError("knowledgeBase: title contains zero-width / direction-override characters");
|
|
175
|
+
}
|
|
176
|
+
return s;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function _body(s) {
|
|
180
|
+
if (typeof s !== "string") {
|
|
181
|
+
throw new TypeError("knowledgeBase: body must be a string");
|
|
182
|
+
}
|
|
183
|
+
var trimmed = s.trim();
|
|
184
|
+
if (!trimmed.length) {
|
|
185
|
+
throw new TypeError("knowledgeBase: body must be non-empty after trim");
|
|
186
|
+
}
|
|
187
|
+
if (s.length > MAX_BODY_LEN) {
|
|
188
|
+
throw new TypeError("knowledgeBase: body must be <= " + MAX_BODY_LEN + " characters");
|
|
189
|
+
}
|
|
190
|
+
if (CONTROL_BYTE_BODY_RE.test(s)) {
|
|
191
|
+
throw new TypeError("knowledgeBase: body contains control bytes");
|
|
192
|
+
}
|
|
193
|
+
if (ZERO_WIDTH_RE.test(s)) {
|
|
194
|
+
throw new TypeError("knowledgeBase: body contains zero-width / direction-override characters");
|
|
195
|
+
}
|
|
196
|
+
return s;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function _category(s) {
|
|
200
|
+
if (typeof s !== "string" || !s.length) {
|
|
201
|
+
throw new TypeError("knowledgeBase: category must be a non-empty string");
|
|
202
|
+
}
|
|
203
|
+
if (s.length > MAX_CATEGORY_LEN) {
|
|
204
|
+
throw new TypeError("knowledgeBase: category must be <= " + MAX_CATEGORY_LEN + " characters");
|
|
205
|
+
}
|
|
206
|
+
if (!CATEGORY_RE.test(s)) {
|
|
207
|
+
throw new TypeError("knowledgeBase: category must match /^[a-z][a-z0-9_-]*$/");
|
|
208
|
+
}
|
|
209
|
+
return s;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function _locale(s, label) {
|
|
213
|
+
label = label || "locale";
|
|
214
|
+
if (typeof s !== "string" || !s.length) {
|
|
215
|
+
throw new TypeError("knowledgeBase: " + label + " must be a non-empty string");
|
|
216
|
+
}
|
|
217
|
+
if (s.length > MAX_LOCALE_LEN) {
|
|
218
|
+
throw new TypeError("knowledgeBase: " + label + " must be <= " + MAX_LOCALE_LEN + " characters");
|
|
219
|
+
}
|
|
220
|
+
var lower = s.toLowerCase();
|
|
221
|
+
if (!LOCALE_RE.test(lower)) {
|
|
222
|
+
throw new TypeError("knowledgeBase: " + label + " must be a BCP-47 tag (e.g. 'en', 'fr-ca')");
|
|
223
|
+
}
|
|
224
|
+
return lower;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function _tags(input) {
|
|
228
|
+
if (input == null) return [];
|
|
229
|
+
if (!Array.isArray(input)) {
|
|
230
|
+
throw new TypeError("knowledgeBase: tags must be an array of strings");
|
|
231
|
+
}
|
|
232
|
+
if (input.length > MAX_TAG_COUNT) {
|
|
233
|
+
throw new TypeError("knowledgeBase: tags must contain <= " + MAX_TAG_COUNT + " entries");
|
|
234
|
+
}
|
|
235
|
+
var seen = {};
|
|
236
|
+
var out = [];
|
|
237
|
+
for (var i = 0; i < input.length; i += 1) {
|
|
238
|
+
var t = input[i];
|
|
239
|
+
if (typeof t !== "string" || !t.length) {
|
|
240
|
+
throw new TypeError("knowledgeBase: tags[" + i + "] must be a non-empty string");
|
|
241
|
+
}
|
|
242
|
+
if (t.length > MAX_TAG_LEN) {
|
|
243
|
+
throw new TypeError("knowledgeBase: tags[" + i + "] must be <= " + MAX_TAG_LEN + " characters");
|
|
244
|
+
}
|
|
245
|
+
if (!TAG_RE.test(t)) {
|
|
246
|
+
throw new TypeError("knowledgeBase: tags[" + i + "] must match /^[a-z][a-z0-9_-]*$/");
|
|
247
|
+
}
|
|
248
|
+
if (seen[t]) continue;
|
|
249
|
+
seen[t] = 1;
|
|
250
|
+
out.push(t);
|
|
251
|
+
}
|
|
252
|
+
return out;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function _vote(s) {
|
|
256
|
+
if (typeof s !== "string" || ALLOWED_VOTES.indexOf(s) === -1) {
|
|
257
|
+
throw new TypeError("knowledgeBase: vote must be one of " + ALLOWED_VOTES.join(", "));
|
|
258
|
+
}
|
|
259
|
+
return s;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function _limit(n, max, def, label) {
|
|
263
|
+
if (n == null) return def;
|
|
264
|
+
if (!Number.isInteger(n) || n <= 0 || n > max) {
|
|
265
|
+
throw new TypeError("knowledgeBase: " + label + " must be an integer 1..." + max);
|
|
266
|
+
}
|
|
267
|
+
return n;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function _timestampRange(from, to, label) {
|
|
271
|
+
if (!Number.isInteger(from) || from < 0) {
|
|
272
|
+
throw new TypeError("knowledgeBase." + label + ": from must be a non-negative integer (ms epoch)");
|
|
273
|
+
}
|
|
274
|
+
if (!Number.isInteger(to) || to < 0) {
|
|
275
|
+
throw new TypeError("knowledgeBase." + label + ": to must be a non-negative integer (ms epoch)");
|
|
276
|
+
}
|
|
277
|
+
if (from > to) {
|
|
278
|
+
throw new TypeError("knowledgeBase." + label + ": from must be <= to");
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function _queryStr(s) {
|
|
283
|
+
if (typeof s !== "string") {
|
|
284
|
+
throw new TypeError("knowledgeBase: query must be a string");
|
|
285
|
+
}
|
|
286
|
+
if (s.length > MAX_QUERY_LEN) {
|
|
287
|
+
throw new TypeError("knowledgeBase: query must be <= " + MAX_QUERY_LEN + " characters");
|
|
288
|
+
}
|
|
289
|
+
if (CONTROL_BYTE_BODY_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
|
|
290
|
+
throw new TypeError("knowledgeBase: query contains control / zero-width bytes");
|
|
291
|
+
}
|
|
292
|
+
return s;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function _customerId(s) {
|
|
296
|
+
try {
|
|
297
|
+
return _b().guardUuid.sanitize(s, { profile: "strict" });
|
|
298
|
+
} catch (e) {
|
|
299
|
+
throw new TypeError("knowledgeBase: customer_id — " + (e && e.message || "invalid UUID"));
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function _sessionIdRaw(s) {
|
|
304
|
+
if (typeof s !== "string" || !s.length) {
|
|
305
|
+
throw new TypeError("knowledgeBase: session_id must be a non-empty string");
|
|
306
|
+
}
|
|
307
|
+
if (s.length > 256) {
|
|
308
|
+
throw new TypeError("knowledgeBase: session_id must be <= 256 characters");
|
|
309
|
+
}
|
|
310
|
+
if (CONTROL_BYTE_STRICT_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
|
|
311
|
+
throw new TypeError("knowledgeBase: session_id contains control / zero-width bytes");
|
|
312
|
+
}
|
|
313
|
+
return s;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// ---- locale fallback ---------------------------------------------------
|
|
317
|
+
//
|
|
318
|
+
// Walk a canonical BCP-47 locale right-to-left, dropping trailing
|
|
319
|
+
// subtags, then append the configured default locale (when distinct).
|
|
320
|
+
// "fr-ca" + default "en" -> ["fr-ca", "fr", "en"].
|
|
321
|
+
function _fallbackChain(locale, defaultLocale) {
|
|
322
|
+
var chain = [];
|
|
323
|
+
var cur = locale;
|
|
324
|
+
while (cur && cur.length) {
|
|
325
|
+
chain.push(cur);
|
|
326
|
+
var idx = cur.lastIndexOf("-");
|
|
327
|
+
if (idx === -1) break;
|
|
328
|
+
cur = cur.slice(0, idx);
|
|
329
|
+
}
|
|
330
|
+
if (defaultLocale && chain.indexOf(defaultLocale) === -1) {
|
|
331
|
+
chain.push(defaultLocale);
|
|
332
|
+
}
|
|
333
|
+
return chain;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ---- Markdown to HTML --------------------------------------------------
|
|
337
|
+
//
|
|
338
|
+
// Minimal in-process Markdown subset, hand-written to keep the
|
|
339
|
+
// primitive in the zero-runtime-deps envelope. Mirrors cmsBlocks /
|
|
340
|
+
// storefrontPages: paragraphs, headings, lists, links, inline code,
|
|
341
|
+
// emphasis, blockquotes, horizontal rules. Every text run is HTML-
|
|
342
|
+
// escaped via `b.template.escapeHtml`. Every link URL passes through
|
|
343
|
+
// `b.safeUrl.parse` (https-only) OR an allow-list for `/`-rooted
|
|
344
|
+
// absolute paths. Any URL that fails the gate is dropped from the
|
|
345
|
+
// rendered HTML; the anchor text falls back to inert escaped text.
|
|
346
|
+
// Raw HTML in the body is never passed through.
|
|
347
|
+
|
|
348
|
+
function _esc(s) {
|
|
349
|
+
return _b().template.escapeHtml(s);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function _safeLinkUrl(url) {
|
|
353
|
+
if (typeof url !== "string" || !url.length || url.length > 2048) return null;
|
|
354
|
+
if (CONTROL_BYTE_BODY_RE.test(url) || ZERO_WIDTH_RE.test(url)) return null;
|
|
355
|
+
if (url.charCodeAt(0) === 47 /* "/" */) {
|
|
356
|
+
if (url.length > 1 && url.charCodeAt(1) === 47) return null;
|
|
357
|
+
if (url.indexOf("..") !== -1) return null;
|
|
358
|
+
return url;
|
|
359
|
+
}
|
|
360
|
+
try {
|
|
361
|
+
_b().safeUrl.parse(url, { allowedProtocols: ["https:"] });
|
|
362
|
+
} catch (_e) {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
return url;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function _renderInline(line) {
|
|
369
|
+
var out = "";
|
|
370
|
+
var i = 0;
|
|
371
|
+
while (i < line.length) {
|
|
372
|
+
var ch = line.charAt(i);
|
|
373
|
+
if (ch === "`") {
|
|
374
|
+
var end = line.indexOf("`", i + 1);
|
|
375
|
+
if (end !== -1) {
|
|
376
|
+
out += "<code>" + _esc(line.slice(i + 1, end)) + "</code>";
|
|
377
|
+
i = end + 1;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
if (ch === "[") {
|
|
382
|
+
var closeBracket = line.indexOf("]", i + 1);
|
|
383
|
+
if (closeBracket !== -1 && line.charAt(closeBracket + 1) === "(") {
|
|
384
|
+
var closeParen = line.indexOf(")", closeBracket + 2);
|
|
385
|
+
if (closeParen !== -1) {
|
|
386
|
+
var text = line.slice(i + 1, closeBracket);
|
|
387
|
+
var url = line.slice(closeBracket + 2, closeParen);
|
|
388
|
+
var safe = _safeLinkUrl(url);
|
|
389
|
+
if (safe) {
|
|
390
|
+
out += '<a href="' + _esc(safe) + '">' + _renderInline(text) + "</a>";
|
|
391
|
+
} else {
|
|
392
|
+
out += _renderInline(text);
|
|
393
|
+
}
|
|
394
|
+
i = closeParen + 1;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
if (ch === "*" && line.charAt(i + 1) === "*") {
|
|
400
|
+
var endBold = line.indexOf("**", i + 2);
|
|
401
|
+
if (endBold !== -1) {
|
|
402
|
+
out += "<strong>" + _renderInline(line.slice(i + 2, endBold)) + "</strong>";
|
|
403
|
+
i = endBold + 2;
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (ch === "*" || ch === "_") {
|
|
408
|
+
var endItalic = line.indexOf(ch, i + 1);
|
|
409
|
+
if (endItalic !== -1 && endItalic !== i + 1) {
|
|
410
|
+
out += "<em>" + _renderInline(line.slice(i + 1, endItalic)) + "</em>";
|
|
411
|
+
i = endItalic + 1;
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
out += _esc(ch);
|
|
416
|
+
i += 1;
|
|
417
|
+
}
|
|
418
|
+
return out;
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function _renderMarkdown(body) {
|
|
422
|
+
var normalized = String(body).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
423
|
+
var lines = normalized.split("\n");
|
|
424
|
+
var out = [];
|
|
425
|
+
var i = 0;
|
|
426
|
+
while (i < lines.length) {
|
|
427
|
+
var line = lines[i];
|
|
428
|
+
if (line.trim() === "") { i += 1; continue; }
|
|
429
|
+
if (/^-{3,}\s*$/.test(line)) {
|
|
430
|
+
out.push("<hr />");
|
|
431
|
+
i += 1;
|
|
432
|
+
continue;
|
|
433
|
+
}
|
|
434
|
+
var hMatch = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
435
|
+
if (hMatch) {
|
|
436
|
+
var level = hMatch[1].length;
|
|
437
|
+
out.push("<h" + level + ">" + _renderInline(hMatch[2].trim()) + "</h" + level + ">");
|
|
438
|
+
i += 1;
|
|
439
|
+
continue;
|
|
440
|
+
}
|
|
441
|
+
if (/^>\s?/.test(line)) {
|
|
442
|
+
var quoteLines = [];
|
|
443
|
+
while (i < lines.length && /^>\s?/.test(lines[i])) {
|
|
444
|
+
quoteLines.push(lines[i].replace(/^>\s?/, ""));
|
|
445
|
+
i += 1;
|
|
446
|
+
}
|
|
447
|
+
out.push("<blockquote><p>" + _renderInline(quoteLines.join(" ")) + "</p></blockquote>");
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
if (/^[-*]\s+/.test(line)) {
|
|
451
|
+
var ulItems = [];
|
|
452
|
+
while (i < lines.length && /^[-*]\s+/.test(lines[i])) {
|
|
453
|
+
ulItems.push(lines[i].replace(/^[-*]\s+/, ""));
|
|
454
|
+
i += 1;
|
|
455
|
+
}
|
|
456
|
+
var ulHtml = ulItems.map(function (item) {
|
|
457
|
+
return "<li>" + _renderInline(item) + "</li>";
|
|
458
|
+
}).join("");
|
|
459
|
+
out.push("<ul>" + ulHtml + "</ul>");
|
|
460
|
+
continue;
|
|
461
|
+
}
|
|
462
|
+
if (/^\d+\.\s+/.test(line)) {
|
|
463
|
+
var olItems = [];
|
|
464
|
+
while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
|
|
465
|
+
olItems.push(lines[i].replace(/^\d+\.\s+/, ""));
|
|
466
|
+
i += 1;
|
|
467
|
+
}
|
|
468
|
+
var olHtml = olItems.map(function (item) {
|
|
469
|
+
return "<li>" + _renderInline(item) + "</li>";
|
|
470
|
+
}).join("");
|
|
471
|
+
out.push("<ol>" + olHtml + "</ol>");
|
|
472
|
+
continue;
|
|
473
|
+
}
|
|
474
|
+
var paraLines = [line];
|
|
475
|
+
i += 1;
|
|
476
|
+
while (
|
|
477
|
+
i < lines.length &&
|
|
478
|
+
lines[i].trim() !== "" &&
|
|
479
|
+
!/^#{1,6}\s+/.test(lines[i]) &&
|
|
480
|
+
!/^[-*]\s+/.test(lines[i]) &&
|
|
481
|
+
!/^\d+\.\s+/.test(lines[i]) &&
|
|
482
|
+
!/^>\s?/.test(lines[i]) &&
|
|
483
|
+
!/^-{3,}\s*$/.test(lines[i])
|
|
484
|
+
) {
|
|
485
|
+
paraLines.push(lines[i]);
|
|
486
|
+
i += 1;
|
|
487
|
+
}
|
|
488
|
+
out.push("<p>" + _renderInline(paraLines.join(" ")) + "</p>");
|
|
489
|
+
}
|
|
490
|
+
return out.join("\n");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// ---- hydration ---------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
function _hydrateRow(row) {
|
|
496
|
+
if (!row) return null;
|
|
497
|
+
var tags;
|
|
498
|
+
try { tags = JSON.parse(row.tags_json || "[]"); }
|
|
499
|
+
catch (_e) { tags = []; }
|
|
500
|
+
return {
|
|
501
|
+
slug: row.slug,
|
|
502
|
+
locale: row.locale,
|
|
503
|
+
title: row.title,
|
|
504
|
+
body: row.body,
|
|
505
|
+
category: row.category,
|
|
506
|
+
tags: tags,
|
|
507
|
+
published: row.published === 1 || row.published === true,
|
|
508
|
+
archived_at: row.archived_at == null ? null : Number(row.archived_at),
|
|
509
|
+
view_count: Number(row.view_count) || 0,
|
|
510
|
+
helpful_count: Number(row.helpful_count) || 0,
|
|
511
|
+
not_helpful_count: Number(row.not_helpful_count) || 0,
|
|
512
|
+
created_at: Number(row.created_at),
|
|
513
|
+
updated_at: Number(row.updated_at),
|
|
514
|
+
body_html: _renderMarkdown(row.body),
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ---- search tokenizer --------------------------------------------------
|
|
519
|
+
//
|
|
520
|
+
// Lowercase + non-alphanum split + drop tokens shorter than 2
|
|
521
|
+
// chars. Keeps the ranker simple + locale-agnostic enough for the
|
|
522
|
+
// in-process search. Operator-supplied query strings are already
|
|
523
|
+
// gated by `_queryStr()` so no control bytes reach this far.
|
|
524
|
+
|
|
525
|
+
function _tokenize(s) {
|
|
526
|
+
var lower = String(s).toLowerCase();
|
|
527
|
+
var raw = lower.split(/[^a-z0-9]+/);
|
|
528
|
+
var out = [];
|
|
529
|
+
for (var i = 0; i < raw.length; i += 1) {
|
|
530
|
+
if (raw[i].length >= 2) out.push(raw[i]);
|
|
531
|
+
}
|
|
532
|
+
return out;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ---- factory -----------------------------------------------------------
|
|
536
|
+
|
|
537
|
+
function create(opts) {
|
|
538
|
+
opts = opts || {};
|
|
539
|
+
var query = opts.query;
|
|
540
|
+
if (!query) {
|
|
541
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
542
|
+
}
|
|
543
|
+
var defaultLocale = opts.defaultLocale == null
|
|
544
|
+
? "en"
|
|
545
|
+
: _locale(opts.defaultLocale, "defaultLocale");
|
|
546
|
+
|
|
547
|
+
if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
|
|
548
|
+
if (process.env.NODE_ENV === "production") {
|
|
549
|
+
throw new Error("knowledgeBase.create: opts.cursorSecret is required in production");
|
|
550
|
+
}
|
|
551
|
+
opts.cursorSecret = "knowledge-base-cursor-secret-dev-only";
|
|
552
|
+
}
|
|
553
|
+
var cursorSecret = opts.cursorSecret;
|
|
554
|
+
|
|
555
|
+
function _decodeCursor(cursor, label) {
|
|
556
|
+
if (cursor == null) return null;
|
|
557
|
+
if (typeof cursor !== "string") {
|
|
558
|
+
throw new TypeError("knowledgeBase." + label + ": cursor must be an opaque string or null");
|
|
559
|
+
}
|
|
560
|
+
try {
|
|
561
|
+
var state = _b().pagination.decodeCursor(cursor, cursorSecret);
|
|
562
|
+
if (JSON.stringify(state.orderKey) !== JSON.stringify(LIST_ORDER_KEY)) {
|
|
563
|
+
throw new TypeError("knowledgeBase." + label + ": cursor orderKey mismatch");
|
|
564
|
+
}
|
|
565
|
+
return state.vals;
|
|
566
|
+
} catch (e) {
|
|
567
|
+
if (e instanceof TypeError) throw e;
|
|
568
|
+
throw new TypeError("knowledgeBase." + label + ": cursor — " + (e && e.message || "malformed"));
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function _encodeNext(rows, limit) {
|
|
573
|
+
var last = rows[rows.length - 1];
|
|
574
|
+
if (!last || rows.length < limit) return null;
|
|
575
|
+
return _b().pagination.encodeCursor({
|
|
576
|
+
orderKey: LIST_ORDER_KEY,
|
|
577
|
+
vals: [last.updated_at, last.slug],
|
|
578
|
+
forward: true,
|
|
579
|
+
}, cursorSecret);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function _hashSession(namespace, sessionId) {
|
|
583
|
+
return _b().crypto.namespaceHash(namespace, sessionId);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function _readExact(slug, locale) {
|
|
587
|
+
var r = await query(
|
|
588
|
+
"SELECT * FROM kb_articles WHERE slug = ?1 AND locale = ?2",
|
|
589
|
+
[slug, locale],
|
|
590
|
+
);
|
|
591
|
+
return r.rows[0] || null;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
async function _readAllLocales(slug) {
|
|
595
|
+
var r = await query(
|
|
596
|
+
"SELECT * FROM kb_articles WHERE slug = ?1",
|
|
597
|
+
[slug],
|
|
598
|
+
);
|
|
599
|
+
return r.rows;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Locale-aware single-article read. Walks the fallback chain;
|
|
603
|
+
// returns the first non-archived row. Archived rows are filtered
|
|
604
|
+
// even at the exact-locale match — once a slug is archived, no
|
|
605
|
+
// locale survives. `published_only=true` additionally filters
|
|
606
|
+
// unpublished rows.
|
|
607
|
+
async function _readWithFallback(slug, locale, publishedOnly) {
|
|
608
|
+
var chain = _fallbackChain(locale, defaultLocale);
|
|
609
|
+
for (var i = 0; i < chain.length; i += 1) {
|
|
610
|
+
var row = await _readExact(slug, chain[i]);
|
|
611
|
+
if (!row) continue;
|
|
612
|
+
if (row.archived_at != null) continue;
|
|
613
|
+
if (publishedOnly && row.published !== 1) continue;
|
|
614
|
+
return row;
|
|
615
|
+
}
|
|
616
|
+
return null;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return {
|
|
620
|
+
MAX_SLUG_LEN: MAX_SLUG_LEN,
|
|
621
|
+
MAX_TITLE_LEN: MAX_TITLE_LEN,
|
|
622
|
+
MAX_BODY_LEN: MAX_BODY_LEN,
|
|
623
|
+
MAX_CATEGORY_LEN: MAX_CATEGORY_LEN,
|
|
624
|
+
MAX_TAG_LEN: MAX_TAG_LEN,
|
|
625
|
+
MAX_TAG_COUNT: MAX_TAG_COUNT,
|
|
626
|
+
MAX_LOCALE_LEN: MAX_LOCALE_LEN,
|
|
627
|
+
MAX_QUERY_LEN: MAX_QUERY_LEN,
|
|
628
|
+
MAX_LIST_LIMIT: MAX_LIST_LIMIT,
|
|
629
|
+
MAX_POPULAR_LIMIT: MAX_POPULAR_LIMIT,
|
|
630
|
+
MAX_SUGGEST_LIMIT: MAX_SUGGEST_LIMIT,
|
|
631
|
+
ALLOWED_VOTES: ALLOWED_VOTES.slice(),
|
|
632
|
+
SEARCH_WEIGHTS: Object.assign({}, SEARCH_WEIGHTS),
|
|
633
|
+
defaultLocale: defaultLocale,
|
|
634
|
+
|
|
635
|
+
// Idempotent insert / update of a single (slug, locale) row.
|
|
636
|
+
// The first defineArticle for a slug establishes the canonical
|
|
637
|
+
// category + tags; subsequent locale rows inherit those slug-
|
|
638
|
+
// wide attributes — operator-supplied category/tags on a
|
|
639
|
+
// localized re-author must match the existing slug, otherwise
|
|
640
|
+
// the call is refused (mismatched metadata across locales is
|
|
641
|
+
// an authoring bug). `published` defaults to false on first
|
|
642
|
+
// insert; existing rows keep their current published flag
|
|
643
|
+
// (use publishArticle / unpublishArticle to flip).
|
|
644
|
+
defineArticle: async function (input) {
|
|
645
|
+
if (!input || typeof input !== "object") {
|
|
646
|
+
throw new TypeError("knowledgeBase.defineArticle: input object required");
|
|
647
|
+
}
|
|
648
|
+
var slug = _slug(input.slug);
|
|
649
|
+
var title = _title(input.title);
|
|
650
|
+
var body = _body(input.body);
|
|
651
|
+
var category = _category(input.category);
|
|
652
|
+
var tags = _tags(input.tags);
|
|
653
|
+
var locale = _locale(input.locale);
|
|
654
|
+
var ts = _now();
|
|
655
|
+
|
|
656
|
+
var existingAny = await _readAllLocales(slug);
|
|
657
|
+
if (existingAny.length) {
|
|
658
|
+
var canonical = existingAny[0];
|
|
659
|
+
if (canonical.category !== category) {
|
|
660
|
+
throw new TypeError(
|
|
661
|
+
"knowledgeBase.defineArticle: category mismatch for slug '" + slug +
|
|
662
|
+
"' (existing='" + canonical.category + "', supplied='" + category + "')"
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
var canonTags;
|
|
666
|
+
try { canonTags = JSON.parse(canonical.tags_json || "[]"); }
|
|
667
|
+
catch (_e) { canonTags = []; }
|
|
668
|
+
if (JSON.stringify(canonTags) !== JSON.stringify(tags)) {
|
|
669
|
+
throw new TypeError(
|
|
670
|
+
"knowledgeBase.defineArticle: tags mismatch for slug '" + slug +
|
|
671
|
+
"' (slug-wide tags are set on first defineArticle and must match on every locale)"
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
if (canonical.archived_at != null) {
|
|
675
|
+
throw new TypeError(
|
|
676
|
+
"knowledgeBase.defineArticle: slug '" + slug +
|
|
677
|
+
"' is archived; call publishArticle to revive before defining additional locales"
|
|
678
|
+
);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
var existingRow = await _readExact(slug, locale);
|
|
683
|
+
if (existingRow) {
|
|
684
|
+
await query(
|
|
685
|
+
"UPDATE kb_articles " +
|
|
686
|
+
"SET title = ?1, body = ?2, category = ?3, tags_json = ?4, updated_at = ?5 " +
|
|
687
|
+
"WHERE slug = ?6 AND locale = ?7",
|
|
688
|
+
[title, body, category, JSON.stringify(tags), ts, slug, locale],
|
|
689
|
+
);
|
|
690
|
+
} else {
|
|
691
|
+
var published = input.published === true ? 1 : 0;
|
|
692
|
+
await query(
|
|
693
|
+
"INSERT INTO kb_articles " +
|
|
694
|
+
"(slug, locale, title, body, category, tags_json, published, archived_at, " +
|
|
695
|
+
" view_count, helpful_count, not_helpful_count, created_at, updated_at) " +
|
|
696
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, 0, 0, 0, ?8, ?8)",
|
|
697
|
+
[slug, locale, title, body, category, JSON.stringify(tags), published, ts],
|
|
698
|
+
);
|
|
699
|
+
}
|
|
700
|
+
var fresh = await _readExact(slug, locale);
|
|
701
|
+
return _hydrateRow(fresh);
|
|
702
|
+
},
|
|
703
|
+
|
|
704
|
+
getArticle: async function (input) {
|
|
705
|
+
if (!input || typeof input !== "object") {
|
|
706
|
+
throw new TypeError("knowledgeBase.getArticle: input object required");
|
|
707
|
+
}
|
|
708
|
+
var slug = _slug(input.slug);
|
|
709
|
+
var locale = input.locale == null ? defaultLocale : _locale(input.locale);
|
|
710
|
+
var row = await _readWithFallback(slug, locale, false);
|
|
711
|
+
return _hydrateRow(row);
|
|
712
|
+
},
|
|
713
|
+
|
|
714
|
+
// Operator + storefront list view. Returns one row per slug
|
|
715
|
+
// (the default-locale row when present). Filters by category +
|
|
716
|
+
// search + published_only. Cursor-paginated by (updated_at DESC,
|
|
717
|
+
// slug DESC) so a tampered cursor can't skip past rows the
|
|
718
|
+
// caller isn't supposed to see. `search` does a case-insensitive
|
|
719
|
+
// LIKE against title + body — for ranked suggestion use
|
|
720
|
+
// `searchSuggest`.
|
|
721
|
+
listArticles: async function (listOpts) {
|
|
722
|
+
listOpts = listOpts || {};
|
|
723
|
+
var limit = _limit(listOpts.limit, MAX_LIST_LIMIT, DEFAULT_LIST_LIMIT, "limit");
|
|
724
|
+
var cursorVals = _decodeCursor(listOpts.cursor, "listArticles");
|
|
725
|
+
var publishedOnly = listOpts.published_only === true;
|
|
726
|
+
|
|
727
|
+
var where = ["locale = ?1", "archived_at IS NULL"];
|
|
728
|
+
var params = [defaultLocale];
|
|
729
|
+
var idx = 2;
|
|
730
|
+
|
|
731
|
+
if (listOpts.category != null) {
|
|
732
|
+
var category = _category(listOpts.category);
|
|
733
|
+
where.push("category = ?" + idx);
|
|
734
|
+
params.push(category);
|
|
735
|
+
idx += 1;
|
|
736
|
+
}
|
|
737
|
+
if (publishedOnly) {
|
|
738
|
+
where.push("published = 1");
|
|
739
|
+
}
|
|
740
|
+
if (listOpts.search != null) {
|
|
741
|
+
var search = _queryStr(listOpts.search);
|
|
742
|
+
var like = "%" + search.toLowerCase().replace(/[%_\\]/g, function (m) { return "\\" + m; }) + "%";
|
|
743
|
+
where.push("(LOWER(title) LIKE ?" + idx + " ESCAPE '\\' OR LOWER(body) LIKE ?" + idx + " ESCAPE '\\')");
|
|
744
|
+
params.push(like);
|
|
745
|
+
idx += 1;
|
|
746
|
+
}
|
|
747
|
+
if (cursorVals) {
|
|
748
|
+
var a = idx;
|
|
749
|
+
var b = idx + 1;
|
|
750
|
+
where.push(
|
|
751
|
+
"(updated_at < ?" + a + " OR " +
|
|
752
|
+
"(updated_at = ?" + a + " AND slug < ?" + b + "))"
|
|
753
|
+
);
|
|
754
|
+
params.push(cursorVals[0], cursorVals[1]);
|
|
755
|
+
idx += 2;
|
|
756
|
+
}
|
|
757
|
+
params.push(limit);
|
|
758
|
+
var sql = "SELECT * FROM kb_articles WHERE " + where.join(" AND ") +
|
|
759
|
+
" ORDER BY updated_at DESC, slug DESC LIMIT ?" + idx;
|
|
760
|
+
var r = await query(sql, params);
|
|
761
|
+
var rows = r.rows.map(_hydrateRow);
|
|
762
|
+
return { rows: rows, next_cursor: _encodeNext(r.rows, limit) };
|
|
763
|
+
},
|
|
764
|
+
|
|
765
|
+
// Patch an existing article. The patch envelope can contain any
|
|
766
|
+
// subset of { title, body, locale, category, tags }. When
|
|
767
|
+
// `locale` is supplied, only that locale row is patched;
|
|
768
|
+
// otherwise the default-locale row is the target. category +
|
|
769
|
+
// tags are slug-wide — patching them rewrites every locale row
|
|
770
|
+
// for the slug.
|
|
771
|
+
updateArticle: async function (slug, patch) {
|
|
772
|
+
slug = _slug(slug);
|
|
773
|
+
if (!patch || typeof patch !== "object") {
|
|
774
|
+
throw new TypeError("knowledgeBase.updateArticle: patch object required");
|
|
775
|
+
}
|
|
776
|
+
var locale = patch.locale == null ? defaultLocale : _locale(patch.locale);
|
|
777
|
+
var existing = await _readExact(slug, locale);
|
|
778
|
+
if (!existing) {
|
|
779
|
+
var err = new Error("knowledgeBase.updateArticle: (slug='" + slug + "', locale='" + locale + "') not found");
|
|
780
|
+
err.code = "KB_ARTICLE_NOT_FOUND";
|
|
781
|
+
throw err;
|
|
782
|
+
}
|
|
783
|
+
if (existing.archived_at != null) {
|
|
784
|
+
var aErr = new Error("knowledgeBase.updateArticle: slug '" + slug + "' is archived");
|
|
785
|
+
aErr.code = "KB_ARTICLE_ARCHIVED";
|
|
786
|
+
throw aErr;
|
|
787
|
+
}
|
|
788
|
+
var ts = _now();
|
|
789
|
+
var title = patch.title == null ? existing.title : _title(patch.title);
|
|
790
|
+
var body = patch.body == null ? existing.body : _body(patch.body);
|
|
791
|
+
|
|
792
|
+
var rewriteSlugWide = patch.category != null || patch.tags !== undefined;
|
|
793
|
+
var category = patch.category == null
|
|
794
|
+
? existing.category
|
|
795
|
+
: _category(patch.category);
|
|
796
|
+
var tagsJson;
|
|
797
|
+
if (patch.tags === undefined) {
|
|
798
|
+
tagsJson = existing.tags_json;
|
|
799
|
+
} else {
|
|
800
|
+
tagsJson = JSON.stringify(_tags(patch.tags));
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
await query(
|
|
804
|
+
"UPDATE kb_articles SET title = ?1, body = ?2, updated_at = ?3 " +
|
|
805
|
+
"WHERE slug = ?4 AND locale = ?5",
|
|
806
|
+
[title, body, ts, slug, locale],
|
|
807
|
+
);
|
|
808
|
+
if (rewriteSlugWide) {
|
|
809
|
+
await query(
|
|
810
|
+
"UPDATE kb_articles SET category = ?1, tags_json = ?2, updated_at = ?3 " +
|
|
811
|
+
"WHERE slug = ?4",
|
|
812
|
+
[category, tagsJson, ts, slug],
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
var fresh = await _readExact(slug, locale);
|
|
816
|
+
return _hydrateRow(fresh);
|
|
817
|
+
},
|
|
818
|
+
|
|
819
|
+
publishArticle: async function (slug) {
|
|
820
|
+
slug = _slug(slug);
|
|
821
|
+
var rows = await _readAllLocales(slug);
|
|
822
|
+
if (!rows.length) {
|
|
823
|
+
var err = new Error("knowledgeBase.publishArticle: slug '" + slug + "' not found");
|
|
824
|
+
err.code = "KB_ARTICLE_NOT_FOUND";
|
|
825
|
+
throw err;
|
|
826
|
+
}
|
|
827
|
+
var ts = _now();
|
|
828
|
+
await query(
|
|
829
|
+
"UPDATE kb_articles SET published = 1, archived_at = NULL, updated_at = ?1 WHERE slug = ?2",
|
|
830
|
+
[ts, slug],
|
|
831
|
+
);
|
|
832
|
+
var fresh = await _readExact(slug, defaultLocale);
|
|
833
|
+
if (!fresh) fresh = (await _readAllLocales(slug))[0];
|
|
834
|
+
return _hydrateRow(fresh);
|
|
835
|
+
},
|
|
836
|
+
|
|
837
|
+
unpublishArticle: async function (slug) {
|
|
838
|
+
slug = _slug(slug);
|
|
839
|
+
var rows = await _readAllLocales(slug);
|
|
840
|
+
if (!rows.length) {
|
|
841
|
+
var err = new Error("knowledgeBase.unpublishArticle: slug '" + slug + "' not found");
|
|
842
|
+
err.code = "KB_ARTICLE_NOT_FOUND";
|
|
843
|
+
throw err;
|
|
844
|
+
}
|
|
845
|
+
var ts = _now();
|
|
846
|
+
await query(
|
|
847
|
+
"UPDATE kb_articles SET published = 0, updated_at = ?1 WHERE slug = ?2",
|
|
848
|
+
[ts, slug],
|
|
849
|
+
);
|
|
850
|
+
var fresh = await _readExact(slug, defaultLocale);
|
|
851
|
+
if (!fresh) fresh = (await _readAllLocales(slug))[0];
|
|
852
|
+
return _hydrateRow(fresh);
|
|
853
|
+
},
|
|
854
|
+
|
|
855
|
+
archiveArticle: async function (slug) {
|
|
856
|
+
slug = _slug(slug);
|
|
857
|
+
var rows = await _readAllLocales(slug);
|
|
858
|
+
if (!rows.length) {
|
|
859
|
+
var err = new Error("knowledgeBase.archiveArticle: slug '" + slug + "' not found");
|
|
860
|
+
err.code = "KB_ARTICLE_NOT_FOUND";
|
|
861
|
+
throw err;
|
|
862
|
+
}
|
|
863
|
+
var ts = _now();
|
|
864
|
+
await query(
|
|
865
|
+
"UPDATE kb_articles SET archived_at = ?1, published = 0, updated_at = ?1 WHERE slug = ?2",
|
|
866
|
+
[ts, slug],
|
|
867
|
+
);
|
|
868
|
+
var fresh = await _readExact(slug, defaultLocale);
|
|
869
|
+
if (!fresh) fresh = (await _readAllLocales(slug))[0];
|
|
870
|
+
return _hydrateRow(fresh);
|
|
871
|
+
},
|
|
872
|
+
|
|
873
|
+
// Append-only view log. session_id is hashed at the door; the
|
|
874
|
+
// raw value never reaches storage. Bumps the slug-wide
|
|
875
|
+
// view_count on every locale row so popularArticles sorts
|
|
876
|
+
// by combined-locale traffic.
|
|
877
|
+
recordView: async function (input) {
|
|
878
|
+
if (!input || typeof input !== "object") {
|
|
879
|
+
throw new TypeError("knowledgeBase.recordView: input object required");
|
|
880
|
+
}
|
|
881
|
+
var slug = _slug(input.slug);
|
|
882
|
+
var rows = await _readAllLocales(slug);
|
|
883
|
+
if (!rows.length) {
|
|
884
|
+
var err = new Error("knowledgeBase.recordView: slug '" + slug + "' not found");
|
|
885
|
+
err.code = "KB_ARTICLE_NOT_FOUND";
|
|
886
|
+
throw err;
|
|
887
|
+
}
|
|
888
|
+
var sessionHash = null;
|
|
889
|
+
if (input.session_id != null) {
|
|
890
|
+
sessionHash = _hashSession(VIEW_NAMESPACE, _sessionIdRaw(input.session_id));
|
|
891
|
+
}
|
|
892
|
+
var customerId = null;
|
|
893
|
+
if (input.customer_id != null) {
|
|
894
|
+
customerId = _customerId(input.customer_id);
|
|
895
|
+
}
|
|
896
|
+
var ts = _now();
|
|
897
|
+
await query(
|
|
898
|
+
"INSERT INTO kb_views (id, slug, session_id_hash, customer_id, occurred_at) " +
|
|
899
|
+
"VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
900
|
+
[_b().uuid.v7(), slug, sessionHash, customerId, ts],
|
|
901
|
+
);
|
|
902
|
+
await query(
|
|
903
|
+
"UPDATE kb_articles SET view_count = view_count + 1 WHERE slug = ?1",
|
|
904
|
+
[slug],
|
|
905
|
+
);
|
|
906
|
+
return { slug: slug, occurred_at: ts };
|
|
907
|
+
},
|
|
908
|
+
|
|
909
|
+
// Vote: helpful / not_helpful. Deduped at (slug, session_id_hash)
|
|
910
|
+
// via the UNIQUE constraint — a repeat vote from the same session
|
|
911
|
+
// collapses to a no-op. session_id is required (votes without a
|
|
912
|
+
// session are anonymous spam in disguise — the FAQ reading flow
|
|
913
|
+
// already has a session).
|
|
914
|
+
recordVote: async function (input) {
|
|
915
|
+
if (!input || typeof input !== "object") {
|
|
916
|
+
throw new TypeError("knowledgeBase.recordVote: input object required");
|
|
917
|
+
}
|
|
918
|
+
var slug = _slug(input.slug);
|
|
919
|
+
var session = _sessionIdRaw(input.session_id);
|
|
920
|
+
var vote = _vote(input.vote);
|
|
921
|
+
var rows = await _readAllLocales(slug);
|
|
922
|
+
if (!rows.length) {
|
|
923
|
+
var err = new Error("knowledgeBase.recordVote: slug '" + slug + "' not found");
|
|
924
|
+
err.code = "KB_ARTICLE_NOT_FOUND";
|
|
925
|
+
throw err;
|
|
926
|
+
}
|
|
927
|
+
var sessionHash = _hashSession(VOTE_NAMESPACE, session);
|
|
928
|
+
var ts = _now();
|
|
929
|
+
|
|
930
|
+
var inserted = await query(
|
|
931
|
+
"INSERT OR IGNORE INTO kb_votes (id, slug, session_id_hash, vote, occurred_at) " +
|
|
932
|
+
"VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
933
|
+
[_b().uuid.v7(), slug, sessionHash, vote, ts],
|
|
934
|
+
);
|
|
935
|
+
var changed = inserted && (inserted.rowCount === 1 || inserted.changes === 1);
|
|
936
|
+
if (changed) {
|
|
937
|
+
var col = vote === "helpful" ? "helpful_count" : "not_helpful_count";
|
|
938
|
+
await query(
|
|
939
|
+
"UPDATE kb_articles SET " + col + " = " + col + " + 1 WHERE slug = ?1",
|
|
940
|
+
[slug],
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
return { slug: slug, vote: vote, recorded: !!changed };
|
|
944
|
+
},
|
|
945
|
+
|
|
946
|
+
voteAggregateForArticle: async function (slug) {
|
|
947
|
+
slug = _slug(slug);
|
|
948
|
+
var rows = await _readAllLocales(slug);
|
|
949
|
+
if (!rows.length) {
|
|
950
|
+
var err = new Error("knowledgeBase.voteAggregateForArticle: slug '" + slug + "' not found");
|
|
951
|
+
err.code = "KB_ARTICLE_NOT_FOUND";
|
|
952
|
+
throw err;
|
|
953
|
+
}
|
|
954
|
+
var r = rows[0];
|
|
955
|
+
var helpful = Number(r.helpful_count) || 0;
|
|
956
|
+
var notHelpful = Number(r.not_helpful_count) || 0;
|
|
957
|
+
var total = helpful + notHelpful;
|
|
958
|
+
return {
|
|
959
|
+
slug: slug,
|
|
960
|
+
helpful_count: helpful,
|
|
961
|
+
not_helpful_count: notHelpful,
|
|
962
|
+
total_votes: total,
|
|
963
|
+
helpfulness_ratio: total > 0 ? helpful / total : null,
|
|
964
|
+
};
|
|
965
|
+
},
|
|
966
|
+
|
|
967
|
+
// Top-N most-viewed articles over a closed time window. Views
|
|
968
|
+
// are counted from kb_views.occurred_at against [from, to].
|
|
969
|
+
// Archived + unpublished articles are filtered. Sorted by view
|
|
970
|
+
// count DESC, slug DESC.
|
|
971
|
+
popularArticles: async function (input) {
|
|
972
|
+
if (!input || typeof input !== "object") {
|
|
973
|
+
throw new TypeError("knowledgeBase.popularArticles: input object required");
|
|
974
|
+
}
|
|
975
|
+
var from = input.from;
|
|
976
|
+
var to = input.to;
|
|
977
|
+
_timestampRange(from, to, "popularArticles");
|
|
978
|
+
var limit = _limit(input.limit, MAX_POPULAR_LIMIT, DEFAULT_POPULAR_LIMIT, "limit");
|
|
979
|
+
|
|
980
|
+
var sql =
|
|
981
|
+
"SELECT v.slug AS slug, COUNT(*) AS views " +
|
|
982
|
+
"FROM kb_views v " +
|
|
983
|
+
"JOIN kb_articles a ON a.slug = v.slug AND a.locale = ?1 " +
|
|
984
|
+
"WHERE v.occurred_at >= ?2 AND v.occurred_at <= ?3 " +
|
|
985
|
+
" AND a.archived_at IS NULL AND a.published = 1 " +
|
|
986
|
+
"GROUP BY v.slug " +
|
|
987
|
+
"ORDER BY views DESC, v.slug DESC " +
|
|
988
|
+
"LIMIT ?4";
|
|
989
|
+
var r = await query(sql, [defaultLocale, from, to, limit]);
|
|
990
|
+
var out = [];
|
|
991
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
992
|
+
var row = r.rows[i];
|
|
993
|
+
out.push({ slug: row.slug, views: Number(row.views) });
|
|
994
|
+
}
|
|
995
|
+
return out;
|
|
996
|
+
},
|
|
997
|
+
|
|
998
|
+
// Ranked search-suggest. Tokenizes the query, then scans every
|
|
999
|
+
// published + non-archived article in the requested locale
|
|
1000
|
+
// (with default-locale fallback) and computes a weighted score:
|
|
1001
|
+
// title-token match (weight 3) + tag-token match (weight 2) +
|
|
1002
|
+
// body-token match (weight 1). Articles with score 0 are
|
|
1003
|
+
// excluded; ties break on slug DESC (deterministic).
|
|
1004
|
+
//
|
|
1005
|
+
// The implementation reads every candidate row in JS rather
|
|
1006
|
+
// than pushing the ranker into SQL — the corpus is FAQ-sized
|
|
1007
|
+
// (dozens to hundreds of articles) and the JS path keeps the
|
|
1008
|
+
// primitive in the zero-runtime-deps envelope. Operators with
|
|
1009
|
+
// very large knowledge bases can pre-filter via `category` to
|
|
1010
|
+
// bound the candidate set.
|
|
1011
|
+
searchSuggest: async function (input) {
|
|
1012
|
+
if (!input || typeof input !== "object") {
|
|
1013
|
+
throw new TypeError("knowledgeBase.searchSuggest: input object required");
|
|
1014
|
+
}
|
|
1015
|
+
var queryString = _queryStr(input.query);
|
|
1016
|
+
var tokens = _tokenize(queryString);
|
|
1017
|
+
var limit = _limit(input.limit, MAX_SUGGEST_LIMIT, DEFAULT_SUGGEST_LIMIT, "limit");
|
|
1018
|
+
var locale = input.locale == null ? defaultLocale : _locale(input.locale);
|
|
1019
|
+
var category;
|
|
1020
|
+
if (input.category != null) {
|
|
1021
|
+
category = _category(input.category);
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
if (!tokens.length) return [];
|
|
1025
|
+
|
|
1026
|
+
var sql, params;
|
|
1027
|
+
if (category) {
|
|
1028
|
+
sql = "SELECT * FROM kb_articles WHERE archived_at IS NULL AND published = 1 AND category = ?1";
|
|
1029
|
+
params = [category];
|
|
1030
|
+
} else {
|
|
1031
|
+
sql = "SELECT * FROM kb_articles WHERE archived_at IS NULL AND published = 1";
|
|
1032
|
+
params = [];
|
|
1033
|
+
}
|
|
1034
|
+
var r = await query(sql, params);
|
|
1035
|
+
|
|
1036
|
+
var bySlug = {};
|
|
1037
|
+
var chain = _fallbackChain(locale, defaultLocale);
|
|
1038
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
1039
|
+
var row = r.rows[i];
|
|
1040
|
+
var pri = chain.indexOf(row.locale);
|
|
1041
|
+
if (pri === -1) continue;
|
|
1042
|
+
var current = bySlug[row.slug];
|
|
1043
|
+
if (!current || current._chainIdx > pri) {
|
|
1044
|
+
row._chainIdx = pri;
|
|
1045
|
+
bySlug[row.slug] = row;
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
var ranked = [];
|
|
1050
|
+
var slugs = Object.keys(bySlug);
|
|
1051
|
+
for (var s = 0; s < slugs.length; s += 1) {
|
|
1052
|
+
var rowS = bySlug[slugs[s]];
|
|
1053
|
+
var titleTokens = _tokenize(rowS.title);
|
|
1054
|
+
var bodyTokens = _tokenize(rowS.body);
|
|
1055
|
+
var tagList;
|
|
1056
|
+
try { tagList = JSON.parse(rowS.tags_json || "[]"); }
|
|
1057
|
+
catch (_e) { tagList = []; }
|
|
1058
|
+
|
|
1059
|
+
var score = 0;
|
|
1060
|
+
for (var t = 0; t < tokens.length; t += 1) {
|
|
1061
|
+
var token = tokens[t];
|
|
1062
|
+
if (titleTokens.indexOf(token) !== -1) score += SEARCH_WEIGHTS.title;
|
|
1063
|
+
if (tagList.indexOf(token) !== -1) score += SEARCH_WEIGHTS.tag;
|
|
1064
|
+
if (bodyTokens.indexOf(token) !== -1) score += SEARCH_WEIGHTS.body;
|
|
1065
|
+
}
|
|
1066
|
+
if (score > 0) {
|
|
1067
|
+
ranked.push({
|
|
1068
|
+
slug: rowS.slug,
|
|
1069
|
+
locale: rowS.locale,
|
|
1070
|
+
title: rowS.title,
|
|
1071
|
+
category: rowS.category,
|
|
1072
|
+
score: score,
|
|
1073
|
+
});
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
ranked.sort(function (a, b) {
|
|
1078
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
1079
|
+
if (a.slug < b.slug) return 1;
|
|
1080
|
+
if (a.slug > b.slug) return -1;
|
|
1081
|
+
return 0;
|
|
1082
|
+
});
|
|
1083
|
+
|
|
1084
|
+
return ranked.slice(0, limit);
|
|
1085
|
+
},
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
module.exports = {
|
|
1090
|
+
create: create,
|
|
1091
|
+
MAX_SLUG_LEN: MAX_SLUG_LEN,
|
|
1092
|
+
MAX_TITLE_LEN: MAX_TITLE_LEN,
|
|
1093
|
+
MAX_BODY_LEN: MAX_BODY_LEN,
|
|
1094
|
+
MAX_CATEGORY_LEN: MAX_CATEGORY_LEN,
|
|
1095
|
+
MAX_TAG_LEN: MAX_TAG_LEN,
|
|
1096
|
+
MAX_TAG_COUNT: MAX_TAG_COUNT,
|
|
1097
|
+
MAX_LOCALE_LEN: MAX_LOCALE_LEN,
|
|
1098
|
+
MAX_QUERY_LEN: MAX_QUERY_LEN,
|
|
1099
|
+
MAX_LIST_LIMIT: MAX_LIST_LIMIT,
|
|
1100
|
+
MAX_POPULAR_LIMIT: MAX_POPULAR_LIMIT,
|
|
1101
|
+
MAX_SUGGEST_LIMIT: MAX_SUGGEST_LIMIT,
|
|
1102
|
+
ALLOWED_VOTES: ALLOWED_VOTES.slice(),
|
|
1103
|
+
SEARCH_WEIGHTS: Object.assign({}, SEARCH_WEIGHTS),
|
|
1104
|
+
};
|