@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,1020 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.operatorHelpCenter
|
|
4
|
+
* @title Operator help center — in-admin help articles indexed by
|
|
5
|
+
* admin-console section
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* `knowledgeBase` is the customer-facing FAQ; this primitive is the
|
|
9
|
+
* operator-facing equivalent. Articles answer "how do I issue a
|
|
10
|
+
* refund?", "where do I edit shipping zones?", "what does the
|
|
11
|
+
* approvals queue do?". Each article is indexed by the admin-console
|
|
12
|
+
* `section` it documents so the help drawer on any screen can
|
|
13
|
+
* surface articles relevant to that screen via
|
|
14
|
+
* `articlesForSection({ section, role })`.
|
|
15
|
+
*
|
|
16
|
+
* Visibility is gated by `audience_roles` — a closed allow-list of
|
|
17
|
+
* `operatorRoles` permission tokens. An article is visible to an
|
|
18
|
+
* operator iff at least one of its `audience_roles` matches a
|
|
19
|
+
* permission token the operator's roles grant (empty `audience_roles`
|
|
20
|
+
* = visible to every operator). The allow-list is closed at THIS
|
|
21
|
+
* primitive layer — `defineArticle` refuses any token outside
|
|
22
|
+
* `operatorRoles.PERMISSIONS` so a typo doesn't silently produce an
|
|
23
|
+
* article that nobody can see.
|
|
24
|
+
*
|
|
25
|
+
* Body is authored in the in-process Markdown subset shared with
|
|
26
|
+
* knowledgeBase / cmsBlocks / storefrontPages. Every text run is
|
|
27
|
+
* HTML-escaped via `b.template.escapeHtml`; every link URL passes
|
|
28
|
+
* through `b.safeUrl.parse` (https-only) OR an allow-list for
|
|
29
|
+
* `/`-rooted absolute paths. Raw HTML in the body never reaches the
|
|
30
|
+
* rendered output. The raw body lives in storage; the rendered HTML
|
|
31
|
+
* is computed on demand at read time.
|
|
32
|
+
*
|
|
33
|
+
* `searchSuggest` ranks candidates with three weighted signals
|
|
34
|
+
* (mirrors knowledgeBase): title-token match (weight 3), section-
|
|
35
|
+
* token match (weight 2), body-token match (weight 1). Token
|
|
36
|
+
* equality is case-insensitive ASCII; tokens come from a non-
|
|
37
|
+
* alphanum split of the query after lower-casing. Archived
|
|
38
|
+
* articles never appear in the ranked output. When `role` is
|
|
39
|
+
* supplied, articles whose `audience_roles` exclude that
|
|
40
|
+
* permission are also filtered.
|
|
41
|
+
*
|
|
42
|
+
* `recordHelpfulVote` is deduped at the (slug, operator_id) UNIQUE
|
|
43
|
+
* — a repeat vote from the same operator collapses to a no-op so
|
|
44
|
+
* the aggregate counters reflect distinct operators rather than
|
|
45
|
+
* refresh-loop noise.
|
|
46
|
+
*
|
|
47
|
+
* Composes:
|
|
48
|
+
* - `b.template.escapeHtml` — render-time text escape
|
|
49
|
+
* - `b.safeUrl.parse` — render-time link gate (https://)
|
|
50
|
+
* - `b.guardUuid` — strict UUID gate on every
|
|
51
|
+
* `operator_id`
|
|
52
|
+
* - `b.uuid.v7` — row ids on votes + views
|
|
53
|
+
* - `b.pagination` — HMAC-tagged tuple cursor for
|
|
54
|
+
* articlesForSection
|
|
55
|
+
* - `shop.operatorRoles` — PERMISSIONS allow-list (closed at
|
|
56
|
+
* this layer)
|
|
57
|
+
*
|
|
58
|
+
* Monotonic per-process clock: two writes in the same millisecond
|
|
59
|
+
* would tie on `updated_at` / `occurred_at` and make a sort-by-
|
|
60
|
+
* timestamp read ambiguous. `_now` bumps to `prior + 1` on
|
|
61
|
+
* collision so the (updated_at DESC, slug DESC) articlesForSection
|
|
62
|
+
* cursor + popularArticles aggregation carry a strict per-process
|
|
63
|
+
* ordering.
|
|
64
|
+
*
|
|
65
|
+
* Surface:
|
|
66
|
+
* - defineArticle({ slug, title, body, section, related_actions?,
|
|
67
|
+
* audience_roles? })
|
|
68
|
+
* - getArticle({ slug })
|
|
69
|
+
* - articlesForSection({ section, role?, cursor?, limit? })
|
|
70
|
+
* - searchSuggest({ query, role?, limit? })
|
|
71
|
+
* - recordView({ slug, operator_id })
|
|
72
|
+
* - recordHelpfulVote({ slug, operator_id, vote })
|
|
73
|
+
* - popularArticles({ from, to, role?, limit? })
|
|
74
|
+
* - archiveArticle(slug)
|
|
75
|
+
* - updateArticle(slug, patch)
|
|
76
|
+
* - listSections()
|
|
77
|
+
*
|
|
78
|
+
* Storage:
|
|
79
|
+
* - operator_help_articles, operator_help_views,
|
|
80
|
+
* operator_help_votes (migration `0200_operator_help_center.sql`).
|
|
81
|
+
*
|
|
82
|
+
* @primitive operatorHelpCenter
|
|
83
|
+
* @related b.template.escapeHtml, b.safeUrl, b.guardUuid, b.uuid.v7,
|
|
84
|
+
* b.pagination, shop.operatorRoles, shop.knowledgeBase
|
|
85
|
+
*/
|
|
86
|
+
|
|
87
|
+
var operatorRoles = require("./operator-roles");
|
|
88
|
+
|
|
89
|
+
var MAX_SLUG_LEN = 120;
|
|
90
|
+
var MAX_TITLE_LEN = 200;
|
|
91
|
+
var MAX_BODY_LEN = 32000;
|
|
92
|
+
var MAX_SECTION_LEN = 80;
|
|
93
|
+
var MAX_ACTION_LEN = 200;
|
|
94
|
+
var MAX_ACTION_COUNT = 24;
|
|
95
|
+
var MAX_AUDIENCE_COUNT = 24;
|
|
96
|
+
var MAX_QUERY_LEN = 400;
|
|
97
|
+
var MAX_LIST_LIMIT = 100;
|
|
98
|
+
var DEFAULT_LIST_LIMIT = 25;
|
|
99
|
+
var MAX_POPULAR_LIMIT = 100;
|
|
100
|
+
var DEFAULT_POPULAR_LIMIT = 10;
|
|
101
|
+
var MAX_SUGGEST_LIMIT = 25;
|
|
102
|
+
var DEFAULT_SUGGEST_LIMIT = 5;
|
|
103
|
+
|
|
104
|
+
var ALLOWED_VOTES = ["helpful", "not_helpful"];
|
|
105
|
+
|
|
106
|
+
var LIST_ORDER_KEY = ["updated_at:desc", "slug:desc"];
|
|
107
|
+
|
|
108
|
+
var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/;
|
|
109
|
+
var SECTION_RE = /^[a-z][a-z0-9_-]*$/;
|
|
110
|
+
var ACTION_RE = /^[a-z0-9](?:[a-z0-9._/:-]*[a-z0-9])?$/;
|
|
111
|
+
|
|
112
|
+
var CONTROL_BYTE_BODY_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
113
|
+
var CONTROL_BYTE_STRICT_RE = /[\x00-\x1f\x7f]/;
|
|
114
|
+
var ZERO_WIDTH_RE = new RegExp(
|
|
115
|
+
"[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
var SEARCH_WEIGHTS = Object.freeze({
|
|
119
|
+
title: 3,
|
|
120
|
+
section: 2,
|
|
121
|
+
body: 1,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
var ALLOWED_PATCH_COLUMNS = Object.freeze([
|
|
125
|
+
"title", "body", "section", "related_actions", "audience_roles",
|
|
126
|
+
]);
|
|
127
|
+
|
|
128
|
+
var bShop;
|
|
129
|
+
function _b() {
|
|
130
|
+
if (!bShop) bShop = require("./index");
|
|
131
|
+
return bShop.framework;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---- monotonic clock ---------------------------------------------------
|
|
135
|
+
//
|
|
136
|
+
// Operator-driven writes can land in the same millisecond on fast
|
|
137
|
+
// machines. Bumping by 1ms on a tie keeps the timeline strictly
|
|
138
|
+
// increasing so a sort-by-timestamp read returns events in the
|
|
139
|
+
// order they were issued.
|
|
140
|
+
|
|
141
|
+
var _lastTs = 0;
|
|
142
|
+
function _now() {
|
|
143
|
+
var t = Date.now();
|
|
144
|
+
if (t <= _lastTs) t = _lastTs + 1;
|
|
145
|
+
_lastTs = t;
|
|
146
|
+
return t;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// ---- validators --------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
function _slug(s) {
|
|
152
|
+
if (typeof s !== "string" || !s.length) {
|
|
153
|
+
throw new TypeError("operatorHelpCenter: slug must be a non-empty string");
|
|
154
|
+
}
|
|
155
|
+
if (s.length > MAX_SLUG_LEN) {
|
|
156
|
+
throw new TypeError("operatorHelpCenter: slug must be <= " + MAX_SLUG_LEN + " characters");
|
|
157
|
+
}
|
|
158
|
+
if (!SLUG_RE.test(s)) {
|
|
159
|
+
throw new TypeError("operatorHelpCenter: slug must match /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/");
|
|
160
|
+
}
|
|
161
|
+
return s;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function _title(s) {
|
|
165
|
+
if (typeof s !== "string") {
|
|
166
|
+
throw new TypeError("operatorHelpCenter: title must be a string");
|
|
167
|
+
}
|
|
168
|
+
var trimmed = s.trim();
|
|
169
|
+
if (!trimmed.length) {
|
|
170
|
+
throw new TypeError("operatorHelpCenter: title must be non-empty after trim");
|
|
171
|
+
}
|
|
172
|
+
if (s.length > MAX_TITLE_LEN) {
|
|
173
|
+
throw new TypeError("operatorHelpCenter: title must be <= " + MAX_TITLE_LEN + " characters");
|
|
174
|
+
}
|
|
175
|
+
if (CONTROL_BYTE_STRICT_RE.test(s)) {
|
|
176
|
+
throw new TypeError("operatorHelpCenter: title contains control bytes");
|
|
177
|
+
}
|
|
178
|
+
if (ZERO_WIDTH_RE.test(s)) {
|
|
179
|
+
throw new TypeError("operatorHelpCenter: title contains zero-width / direction-override characters");
|
|
180
|
+
}
|
|
181
|
+
return s;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function _body(s) {
|
|
185
|
+
if (typeof s !== "string") {
|
|
186
|
+
throw new TypeError("operatorHelpCenter: body must be a string");
|
|
187
|
+
}
|
|
188
|
+
var trimmed = s.trim();
|
|
189
|
+
if (!trimmed.length) {
|
|
190
|
+
throw new TypeError("operatorHelpCenter: body must be non-empty after trim");
|
|
191
|
+
}
|
|
192
|
+
if (s.length > MAX_BODY_LEN) {
|
|
193
|
+
throw new TypeError("operatorHelpCenter: body must be <= " + MAX_BODY_LEN + " characters");
|
|
194
|
+
}
|
|
195
|
+
if (CONTROL_BYTE_BODY_RE.test(s)) {
|
|
196
|
+
throw new TypeError("operatorHelpCenter: body contains control bytes");
|
|
197
|
+
}
|
|
198
|
+
if (ZERO_WIDTH_RE.test(s)) {
|
|
199
|
+
throw new TypeError("operatorHelpCenter: body contains zero-width / direction-override characters");
|
|
200
|
+
}
|
|
201
|
+
return s;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function _section(s) {
|
|
205
|
+
if (typeof s !== "string" || !s.length) {
|
|
206
|
+
throw new TypeError("operatorHelpCenter: section must be a non-empty string");
|
|
207
|
+
}
|
|
208
|
+
if (s.length > MAX_SECTION_LEN) {
|
|
209
|
+
throw new TypeError("operatorHelpCenter: section must be <= " + MAX_SECTION_LEN + " characters");
|
|
210
|
+
}
|
|
211
|
+
if (!SECTION_RE.test(s)) {
|
|
212
|
+
throw new TypeError("operatorHelpCenter: section must match /^[a-z][a-z0-9_-]*$/");
|
|
213
|
+
}
|
|
214
|
+
return s;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function _relatedActions(input) {
|
|
218
|
+
if (input == null) return [];
|
|
219
|
+
if (!Array.isArray(input)) {
|
|
220
|
+
throw new TypeError("operatorHelpCenter: related_actions must be an array of strings");
|
|
221
|
+
}
|
|
222
|
+
if (input.length > MAX_ACTION_COUNT) {
|
|
223
|
+
throw new TypeError("operatorHelpCenter: related_actions must contain <= " +
|
|
224
|
+
MAX_ACTION_COUNT + " entries");
|
|
225
|
+
}
|
|
226
|
+
var seen = {};
|
|
227
|
+
var out = [];
|
|
228
|
+
for (var i = 0; i < input.length; i += 1) {
|
|
229
|
+
var a = input[i];
|
|
230
|
+
if (typeof a !== "string" || !a.length) {
|
|
231
|
+
throw new TypeError("operatorHelpCenter: related_actions[" + i + "] must be a non-empty string");
|
|
232
|
+
}
|
|
233
|
+
if (a.length > MAX_ACTION_LEN) {
|
|
234
|
+
throw new TypeError("operatorHelpCenter: related_actions[" + i + "] must be <= " +
|
|
235
|
+
MAX_ACTION_LEN + " characters");
|
|
236
|
+
}
|
|
237
|
+
if (!ACTION_RE.test(a)) {
|
|
238
|
+
throw new TypeError("operatorHelpCenter: related_actions[" + i +
|
|
239
|
+
"] must match /^[a-z0-9](?:[a-z0-9._/:-]*[a-z0-9])?$/");
|
|
240
|
+
}
|
|
241
|
+
if (seen[a]) continue;
|
|
242
|
+
seen[a] = 1;
|
|
243
|
+
out.push(a);
|
|
244
|
+
}
|
|
245
|
+
return out;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function _audienceRoles(input) {
|
|
249
|
+
if (input == null) return [];
|
|
250
|
+
if (!Array.isArray(input)) {
|
|
251
|
+
throw new TypeError("operatorHelpCenter: audience_roles must be an array of permission tokens");
|
|
252
|
+
}
|
|
253
|
+
if (input.length > MAX_AUDIENCE_COUNT) {
|
|
254
|
+
throw new TypeError("operatorHelpCenter: audience_roles must contain <= " +
|
|
255
|
+
MAX_AUDIENCE_COUNT + " entries");
|
|
256
|
+
}
|
|
257
|
+
var seen = {};
|
|
258
|
+
var out = [];
|
|
259
|
+
for (var i = 0; i < input.length; i += 1) {
|
|
260
|
+
var p = input[i];
|
|
261
|
+
if (typeof p !== "string" || !p.length) {
|
|
262
|
+
throw new TypeError("operatorHelpCenter: audience_roles[" + i + "] must be a non-empty string");
|
|
263
|
+
}
|
|
264
|
+
if (operatorRoles.PERMISSIONS.indexOf(p) === -1) {
|
|
265
|
+
throw new TypeError("operatorHelpCenter: audience_roles[" + i + "] " +
|
|
266
|
+
JSON.stringify(p) + " is not in the operatorRoles permission allow-list");
|
|
267
|
+
}
|
|
268
|
+
if (seen[p]) continue;
|
|
269
|
+
seen[p] = 1;
|
|
270
|
+
out.push(p);
|
|
271
|
+
}
|
|
272
|
+
return out;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function _vote(s) {
|
|
276
|
+
if (typeof s !== "string" || ALLOWED_VOTES.indexOf(s) === -1) {
|
|
277
|
+
throw new TypeError("operatorHelpCenter: vote must be one of " + ALLOWED_VOTES.join(", "));
|
|
278
|
+
}
|
|
279
|
+
return s;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function _limit(n, max, def, label) {
|
|
283
|
+
if (n == null) return def;
|
|
284
|
+
if (!Number.isInteger(n) || n <= 0 || n > max) {
|
|
285
|
+
throw new TypeError("operatorHelpCenter: " + label + " must be an integer 1..." + max);
|
|
286
|
+
}
|
|
287
|
+
return n;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function _timestampRange(from, to, label) {
|
|
291
|
+
if (!Number.isInteger(from) || from < 0) {
|
|
292
|
+
throw new TypeError("operatorHelpCenter." + label +
|
|
293
|
+
": from must be a non-negative integer (ms epoch)");
|
|
294
|
+
}
|
|
295
|
+
if (!Number.isInteger(to) || to < 0) {
|
|
296
|
+
throw new TypeError("operatorHelpCenter." + label +
|
|
297
|
+
": to must be a non-negative integer (ms epoch)");
|
|
298
|
+
}
|
|
299
|
+
if (from > to) {
|
|
300
|
+
throw new TypeError("operatorHelpCenter." + label + ": from must be <= to");
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function _queryStr(s) {
|
|
305
|
+
if (typeof s !== "string") {
|
|
306
|
+
throw new TypeError("operatorHelpCenter: query must be a string");
|
|
307
|
+
}
|
|
308
|
+
if (s.length > MAX_QUERY_LEN) {
|
|
309
|
+
throw new TypeError("operatorHelpCenter: query must be <= " + MAX_QUERY_LEN + " characters");
|
|
310
|
+
}
|
|
311
|
+
if (CONTROL_BYTE_BODY_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
|
|
312
|
+
throw new TypeError("operatorHelpCenter: query contains control / zero-width bytes");
|
|
313
|
+
}
|
|
314
|
+
return s;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function _operatorId(s) {
|
|
318
|
+
try {
|
|
319
|
+
return _b().guardUuid.sanitize(s, { profile: "strict" });
|
|
320
|
+
} catch (e) {
|
|
321
|
+
throw new TypeError("operatorHelpCenter: operator_id — " + (e && e.message || "invalid UUID"));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function _role(s) {
|
|
326
|
+
if (typeof s !== "string" || !s.length) {
|
|
327
|
+
throw new TypeError("operatorHelpCenter: role must be a non-empty permission token");
|
|
328
|
+
}
|
|
329
|
+
if (operatorRoles.PERMISSIONS.indexOf(s) === -1) {
|
|
330
|
+
throw new TypeError("operatorHelpCenter: role " + JSON.stringify(s) +
|
|
331
|
+
" is not in the operatorRoles permission allow-list");
|
|
332
|
+
}
|
|
333
|
+
return s;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ---- Markdown to HTML --------------------------------------------------
|
|
337
|
+
//
|
|
338
|
+
// Minimal in-process Markdown subset mirroring knowledgeBase /
|
|
339
|
+
// cmsBlocks / storefrontPages: paragraphs, headings, lists, links,
|
|
340
|
+
// inline code, emphasis, blockquotes, horizontal rules. Every text
|
|
341
|
+
// run is HTML-escaped via b.template.escapeHtml. Every link URL
|
|
342
|
+
// passes through b.safeUrl.parse (https-only) OR an allow-list for
|
|
343
|
+
// "/"-rooted absolute paths. Any URL that fails the gate is dropped
|
|
344
|
+
// from the rendered HTML; the anchor text falls back to inert
|
|
345
|
+
// escaped text. Raw HTML in the body is never passed through.
|
|
346
|
+
|
|
347
|
+
function _esc(s) {
|
|
348
|
+
return _b().template.escapeHtml(s);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
function _safeLinkUrl(url) {
|
|
352
|
+
if (typeof url !== "string" || !url.length || url.length > 2048) return null;
|
|
353
|
+
if (CONTROL_BYTE_BODY_RE.test(url) || ZERO_WIDTH_RE.test(url)) return null;
|
|
354
|
+
if (url.charCodeAt(0) === 47 /* "/" */) {
|
|
355
|
+
if (url.length > 1 && url.charCodeAt(1) === 47) return null;
|
|
356
|
+
if (url.indexOf("..") !== -1) return null;
|
|
357
|
+
return url;
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
_b().safeUrl.parse(url, { allowedProtocols: ["https:"] });
|
|
361
|
+
} catch (_e) {
|
|
362
|
+
return null;
|
|
363
|
+
}
|
|
364
|
+
return url;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function _renderInline(line) {
|
|
368
|
+
var out = "";
|
|
369
|
+
var i = 0;
|
|
370
|
+
while (i < line.length) {
|
|
371
|
+
var ch = line.charAt(i);
|
|
372
|
+
if (ch === "`") {
|
|
373
|
+
var end = line.indexOf("`", i + 1);
|
|
374
|
+
if (end !== -1) {
|
|
375
|
+
out += "<code>" + _esc(line.slice(i + 1, end)) + "</code>";
|
|
376
|
+
i = end + 1;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
if (ch === "[") {
|
|
381
|
+
var closeBracket = line.indexOf("]", i + 1);
|
|
382
|
+
if (closeBracket !== -1 && line.charAt(closeBracket + 1) === "(") {
|
|
383
|
+
var closeParen = line.indexOf(")", closeBracket + 2);
|
|
384
|
+
if (closeParen !== -1) {
|
|
385
|
+
var text = line.slice(i + 1, closeBracket);
|
|
386
|
+
var url = line.slice(closeBracket + 2, closeParen);
|
|
387
|
+
var safe = _safeLinkUrl(url);
|
|
388
|
+
if (safe) {
|
|
389
|
+
out += '<a href="' + _esc(safe) + '">' + _renderInline(text) + "</a>";
|
|
390
|
+
} else {
|
|
391
|
+
out += _renderInline(text);
|
|
392
|
+
}
|
|
393
|
+
i = closeParen + 1;
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (ch === "*" && line.charAt(i + 1) === "*") {
|
|
399
|
+
var endBold = line.indexOf("**", i + 2);
|
|
400
|
+
if (endBold !== -1) {
|
|
401
|
+
out += "<strong>" + _renderInline(line.slice(i + 2, endBold)) + "</strong>";
|
|
402
|
+
i = endBold + 2;
|
|
403
|
+
continue;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (ch === "*" || ch === "_") {
|
|
407
|
+
var endItalic = line.indexOf(ch, i + 1);
|
|
408
|
+
if (endItalic !== -1 && endItalic !== i + 1) {
|
|
409
|
+
out += "<em>" + _renderInline(line.slice(i + 1, endItalic)) + "</em>";
|
|
410
|
+
i = endItalic + 1;
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
out += _esc(ch);
|
|
415
|
+
i += 1;
|
|
416
|
+
}
|
|
417
|
+
return out;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function _renderMarkdown(body) {
|
|
421
|
+
var normalized = String(body).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
422
|
+
var lines = normalized.split("\n");
|
|
423
|
+
var out = [];
|
|
424
|
+
var i = 0;
|
|
425
|
+
while (i < lines.length) {
|
|
426
|
+
var line = lines[i];
|
|
427
|
+
if (line.trim() === "") { i += 1; continue; }
|
|
428
|
+
if (/^-{3,}\s*$/.test(line)) {
|
|
429
|
+
out.push("<hr />");
|
|
430
|
+
i += 1;
|
|
431
|
+
continue;
|
|
432
|
+
}
|
|
433
|
+
var hMatch = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
434
|
+
if (hMatch) {
|
|
435
|
+
var level = hMatch[1].length;
|
|
436
|
+
out.push("<h" + level + ">" + _renderInline(hMatch[2].trim()) + "</h" + level + ">");
|
|
437
|
+
i += 1;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
if (/^>\s?/.test(line)) {
|
|
441
|
+
var quoteLines = [];
|
|
442
|
+
while (i < lines.length && /^>\s?/.test(lines[i])) {
|
|
443
|
+
quoteLines.push(lines[i].replace(/^>\s?/, ""));
|
|
444
|
+
i += 1;
|
|
445
|
+
}
|
|
446
|
+
out.push("<blockquote><p>" + _renderInline(quoteLines.join(" ")) + "</p></blockquote>");
|
|
447
|
+
continue;
|
|
448
|
+
}
|
|
449
|
+
if (/^[-*]\s+/.test(line)) {
|
|
450
|
+
var ulItems = [];
|
|
451
|
+
while (i < lines.length && /^[-*]\s+/.test(lines[i])) {
|
|
452
|
+
ulItems.push(lines[i].replace(/^[-*]\s+/, ""));
|
|
453
|
+
i += 1;
|
|
454
|
+
}
|
|
455
|
+
var ulHtml = ulItems.map(function (item) {
|
|
456
|
+
return "<li>" + _renderInline(item) + "</li>";
|
|
457
|
+
}).join("");
|
|
458
|
+
out.push("<ul>" + ulHtml + "</ul>");
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
if (/^\d+\.\s+/.test(line)) {
|
|
462
|
+
var olItems = [];
|
|
463
|
+
while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
|
|
464
|
+
olItems.push(lines[i].replace(/^\d+\.\s+/, ""));
|
|
465
|
+
i += 1;
|
|
466
|
+
}
|
|
467
|
+
var olHtml = olItems.map(function (item) {
|
|
468
|
+
return "<li>" + _renderInline(item) + "</li>";
|
|
469
|
+
}).join("");
|
|
470
|
+
out.push("<ol>" + olHtml + "</ol>");
|
|
471
|
+
continue;
|
|
472
|
+
}
|
|
473
|
+
var paraLines = [line];
|
|
474
|
+
i += 1;
|
|
475
|
+
while (
|
|
476
|
+
i < lines.length &&
|
|
477
|
+
lines[i].trim() !== "" &&
|
|
478
|
+
!/^#{1,6}\s+/.test(lines[i]) &&
|
|
479
|
+
!/^[-*]\s+/.test(lines[i]) &&
|
|
480
|
+
!/^\d+\.\s+/.test(lines[i]) &&
|
|
481
|
+
!/^>\s?/.test(lines[i]) &&
|
|
482
|
+
!/^-{3,}\s*$/.test(lines[i])
|
|
483
|
+
) {
|
|
484
|
+
paraLines.push(lines[i]);
|
|
485
|
+
i += 1;
|
|
486
|
+
}
|
|
487
|
+
out.push("<p>" + _renderInline(paraLines.join(" ")) + "</p>");
|
|
488
|
+
}
|
|
489
|
+
return out.join("\n");
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// ---- hydration ---------------------------------------------------------
|
|
493
|
+
|
|
494
|
+
function _safeParseArray(json) {
|
|
495
|
+
try {
|
|
496
|
+
var v = JSON.parse(json || "[]");
|
|
497
|
+
return Array.isArray(v) ? v : [];
|
|
498
|
+
} catch (_e) {
|
|
499
|
+
return [];
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function _hydrateRow(row) {
|
|
504
|
+
if (!row) return null;
|
|
505
|
+
return {
|
|
506
|
+
slug: row.slug,
|
|
507
|
+
title: row.title,
|
|
508
|
+
body: row.body,
|
|
509
|
+
section: row.section,
|
|
510
|
+
related_actions: _safeParseArray(row.related_actions_json),
|
|
511
|
+
audience_roles: _safeParseArray(row.audience_roles_json),
|
|
512
|
+
archived_at: row.archived_at == null ? null : Number(row.archived_at),
|
|
513
|
+
view_count: Number(row.view_count) || 0,
|
|
514
|
+
helpful_count: Number(row.helpful_count) || 0,
|
|
515
|
+
not_helpful_count: Number(row.not_helpful_count) || 0,
|
|
516
|
+
created_at: Number(row.created_at),
|
|
517
|
+
updated_at: Number(row.updated_at),
|
|
518
|
+
body_html: _renderMarkdown(row.body),
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// ---- search tokenizer --------------------------------------------------
|
|
523
|
+
|
|
524
|
+
function _tokenize(s) {
|
|
525
|
+
var lower = String(s).toLowerCase();
|
|
526
|
+
var raw = lower.split(/[^a-z0-9]+/);
|
|
527
|
+
var out = [];
|
|
528
|
+
for (var i = 0; i < raw.length; i += 1) {
|
|
529
|
+
if (raw[i].length >= 2) out.push(raw[i]);
|
|
530
|
+
}
|
|
531
|
+
return out;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Audience-roles match: empty list means visible to every operator;
|
|
535
|
+
// otherwise the operator's role-token must be in the list. The
|
|
536
|
+
// caller resolves the operator's effective permission set; this
|
|
537
|
+
// primitive sees ONE token at a time (the most-privileged one the
|
|
538
|
+
// caller chose to filter by).
|
|
539
|
+
function _audienceVisible(audienceRoles, role) {
|
|
540
|
+
if (!audienceRoles || !audienceRoles.length) return true;
|
|
541
|
+
if (role == null) return true;
|
|
542
|
+
return audienceRoles.indexOf(role) !== -1;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// ---- factory -----------------------------------------------------------
|
|
546
|
+
|
|
547
|
+
function create(opts) {
|
|
548
|
+
opts = opts || {};
|
|
549
|
+
var query = opts.query;
|
|
550
|
+
if (!query) {
|
|
551
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
|
|
555
|
+
if (process.env.NODE_ENV === "production") {
|
|
556
|
+
throw new Error("operatorHelpCenter.create: opts.cursorSecret is required in production");
|
|
557
|
+
}
|
|
558
|
+
opts.cursorSecret = "operator-help-center-cursor-secret-dev-only";
|
|
559
|
+
}
|
|
560
|
+
var cursorSecret = opts.cursorSecret;
|
|
561
|
+
|
|
562
|
+
function _decodeCursor(cursor, label) {
|
|
563
|
+
if (cursor == null) return null;
|
|
564
|
+
if (typeof cursor !== "string") {
|
|
565
|
+
throw new TypeError("operatorHelpCenter." + label +
|
|
566
|
+
": cursor must be an opaque string or null");
|
|
567
|
+
}
|
|
568
|
+
try {
|
|
569
|
+
var state = _b().pagination.decodeCursor(cursor, cursorSecret);
|
|
570
|
+
if (JSON.stringify(state.orderKey) !== JSON.stringify(LIST_ORDER_KEY)) {
|
|
571
|
+
throw new TypeError("operatorHelpCenter." + label + ": cursor orderKey mismatch");
|
|
572
|
+
}
|
|
573
|
+
return state.vals;
|
|
574
|
+
} catch (e) {
|
|
575
|
+
if (e instanceof TypeError) throw e;
|
|
576
|
+
throw new TypeError("operatorHelpCenter." + label + ": cursor — " +
|
|
577
|
+
(e && e.message || "malformed"));
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
async function _readRow(slug) {
|
|
582
|
+
var r = await query(
|
|
583
|
+
"SELECT * FROM operator_help_articles WHERE slug = ?1",
|
|
584
|
+
[slug],
|
|
585
|
+
);
|
|
586
|
+
return r.rows[0] || null;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
return {
|
|
590
|
+
MAX_SLUG_LEN: MAX_SLUG_LEN,
|
|
591
|
+
MAX_TITLE_LEN: MAX_TITLE_LEN,
|
|
592
|
+
MAX_BODY_LEN: MAX_BODY_LEN,
|
|
593
|
+
MAX_SECTION_LEN: MAX_SECTION_LEN,
|
|
594
|
+
MAX_ACTION_LEN: MAX_ACTION_LEN,
|
|
595
|
+
MAX_ACTION_COUNT: MAX_ACTION_COUNT,
|
|
596
|
+
MAX_AUDIENCE_COUNT: MAX_AUDIENCE_COUNT,
|
|
597
|
+
MAX_QUERY_LEN: MAX_QUERY_LEN,
|
|
598
|
+
MAX_LIST_LIMIT: MAX_LIST_LIMIT,
|
|
599
|
+
MAX_POPULAR_LIMIT: MAX_POPULAR_LIMIT,
|
|
600
|
+
MAX_SUGGEST_LIMIT: MAX_SUGGEST_LIMIT,
|
|
601
|
+
ALLOWED_VOTES: ALLOWED_VOTES.slice(),
|
|
602
|
+
SEARCH_WEIGHTS: Object.assign({}, SEARCH_WEIGHTS),
|
|
603
|
+
PERMISSIONS: operatorRoles.PERMISSIONS,
|
|
604
|
+
|
|
605
|
+
// Idempotent insert / update of one article. Subsequent calls
|
|
606
|
+
// for the same slug update title / body / section / related
|
|
607
|
+
// actions / audience roles in place. Archived articles are
|
|
608
|
+
// refused — the operator must author under a fresh slug.
|
|
609
|
+
defineArticle: async function (input) {
|
|
610
|
+
if (!input || typeof input !== "object") {
|
|
611
|
+
throw new TypeError("operatorHelpCenter.defineArticle: input object required");
|
|
612
|
+
}
|
|
613
|
+
var slug = _slug(input.slug);
|
|
614
|
+
var title = _title(input.title);
|
|
615
|
+
var body = _body(input.body);
|
|
616
|
+
var section = _section(input.section);
|
|
617
|
+
var relatedActions = _relatedActions(input.related_actions);
|
|
618
|
+
var audienceRoles = _audienceRoles(input.audience_roles);
|
|
619
|
+
var ts = _now();
|
|
620
|
+
|
|
621
|
+
var existing = await _readRow(slug);
|
|
622
|
+
if (existing && existing.archived_at != null) {
|
|
623
|
+
throw new TypeError(
|
|
624
|
+
"operatorHelpCenter.defineArticle: slug '" + slug + "' is archived"
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
if (existing) {
|
|
629
|
+
await query(
|
|
630
|
+
"UPDATE operator_help_articles " +
|
|
631
|
+
"SET title = ?1, body = ?2, section = ?3, related_actions_json = ?4, " +
|
|
632
|
+
" audience_roles_json = ?5, updated_at = ?6 " +
|
|
633
|
+
"WHERE slug = ?7",
|
|
634
|
+
[title, body, section, JSON.stringify(relatedActions),
|
|
635
|
+
JSON.stringify(audienceRoles), ts, slug],
|
|
636
|
+
);
|
|
637
|
+
} else {
|
|
638
|
+
await query(
|
|
639
|
+
"INSERT INTO operator_help_articles " +
|
|
640
|
+
"(slug, title, body, section, related_actions_json, audience_roles_json, " +
|
|
641
|
+
" archived_at, view_count, helpful_count, not_helpful_count, " +
|
|
642
|
+
" created_at, updated_at) " +
|
|
643
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, NULL, 0, 0, 0, ?7, ?7)",
|
|
644
|
+
[slug, title, body, section, JSON.stringify(relatedActions),
|
|
645
|
+
JSON.stringify(audienceRoles), ts],
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
return _hydrateRow(await _readRow(slug));
|
|
649
|
+
},
|
|
650
|
+
|
|
651
|
+
getArticle: async function (input) {
|
|
652
|
+
if (!input || typeof input !== "object") {
|
|
653
|
+
throw new TypeError("operatorHelpCenter.getArticle: input object required");
|
|
654
|
+
}
|
|
655
|
+
var slug = _slug(input.slug);
|
|
656
|
+
var row = await _readRow(slug);
|
|
657
|
+
if (!row) return null;
|
|
658
|
+
if (row.archived_at != null) return null;
|
|
659
|
+
return _hydrateRow(row);
|
|
660
|
+
},
|
|
661
|
+
|
|
662
|
+
// Section-indexed list. Cursor-paginated by (updated_at DESC,
|
|
663
|
+
// slug DESC) so a tampered cursor can't skip past rows the
|
|
664
|
+
// caller isn't supposed to see. `role` (optional) is a single
|
|
665
|
+
// operatorRoles permission token — articles whose audience_roles
|
|
666
|
+
// is non-empty and excludes that token are filtered.
|
|
667
|
+
articlesForSection: async function (input) {
|
|
668
|
+
if (!input || typeof input !== "object") {
|
|
669
|
+
throw new TypeError("operatorHelpCenter.articlesForSection: input object required");
|
|
670
|
+
}
|
|
671
|
+
var section = _section(input.section);
|
|
672
|
+
var role = input.role == null ? null : _role(input.role);
|
|
673
|
+
var limit = _limit(input.limit, MAX_LIST_LIMIT, DEFAULT_LIST_LIMIT, "limit");
|
|
674
|
+
var cursorVals = _decodeCursor(input.cursor, "articlesForSection");
|
|
675
|
+
|
|
676
|
+
var where = ["section = ?1", "archived_at IS NULL"];
|
|
677
|
+
var params = [section];
|
|
678
|
+
var idx = 2;
|
|
679
|
+
if (cursorVals) {
|
|
680
|
+
var a = idx;
|
|
681
|
+
var b = idx + 1;
|
|
682
|
+
where.push(
|
|
683
|
+
"(updated_at < ?" + a + " OR " +
|
|
684
|
+
"(updated_at = ?" + a + " AND slug < ?" + b + "))"
|
|
685
|
+
);
|
|
686
|
+
params.push(cursorVals[0], cursorVals[1]);
|
|
687
|
+
idx += 2;
|
|
688
|
+
}
|
|
689
|
+
// Over-fetch by one extra qualifying row so we can detect
|
|
690
|
+
// "more rows beyond this page" without short-paging the cursor.
|
|
691
|
+
// Role filter can drop rows mid-scan, so when a role is
|
|
692
|
+
// supplied we scan a wider window and keep going until we
|
|
693
|
+
// either fill (limit + 1) visible rows or exhaust the window.
|
|
694
|
+
// Cap is bounded — in-admin help corpora are dozens to hundreds
|
|
695
|
+
// of articles, not millions.
|
|
696
|
+
var scanLimit = role == null
|
|
697
|
+
? limit + 1
|
|
698
|
+
: Math.min((limit + 1) * 4, MAX_LIST_LIMIT * 4);
|
|
699
|
+
params.push(scanLimit);
|
|
700
|
+
var sql = "SELECT * FROM operator_help_articles WHERE " + where.join(" AND ") +
|
|
701
|
+
" ORDER BY updated_at DESC, slug DESC LIMIT ?" + idx;
|
|
702
|
+
var r = await query(sql, params);
|
|
703
|
+
|
|
704
|
+
var visibleRaw = [];
|
|
705
|
+
var hasMore = false;
|
|
706
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
707
|
+
var row = r.rows[i];
|
|
708
|
+
var audience = _safeParseArray(row.audience_roles_json);
|
|
709
|
+
if (_audienceVisible(audience, role)) {
|
|
710
|
+
if (visibleRaw.length >= limit) { hasMore = true; break; }
|
|
711
|
+
visibleRaw.push(row);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
var hydrated = visibleRaw.map(_hydrateRow);
|
|
715
|
+
var nextCursor = null;
|
|
716
|
+
if (hasMore && visibleRaw.length === limit) {
|
|
717
|
+
var last = visibleRaw[visibleRaw.length - 1];
|
|
718
|
+
nextCursor = _b().pagination.encodeCursor({
|
|
719
|
+
orderKey: LIST_ORDER_KEY,
|
|
720
|
+
vals: [Number(last.updated_at), last.slug],
|
|
721
|
+
forward: true,
|
|
722
|
+
}, cursorSecret);
|
|
723
|
+
}
|
|
724
|
+
return { rows: hydrated, next_cursor: nextCursor };
|
|
725
|
+
},
|
|
726
|
+
|
|
727
|
+
// Ranked search-suggest. Tokenizes the query, then scans every
|
|
728
|
+
// non-archived article and computes a weighted score: title-
|
|
729
|
+
// token match (weight 3) + section-token match (weight 2) +
|
|
730
|
+
// body-token match (weight 1). Articles with score 0 are
|
|
731
|
+
// excluded; ties break on slug DESC. When `role` is supplied,
|
|
732
|
+
// articles whose audience_roles excludes it are filtered before
|
|
733
|
+
// scoring.
|
|
734
|
+
searchSuggest: async function (input) {
|
|
735
|
+
if (!input || typeof input !== "object") {
|
|
736
|
+
throw new TypeError("operatorHelpCenter.searchSuggest: input object required");
|
|
737
|
+
}
|
|
738
|
+
var queryString = _queryStr(input.query);
|
|
739
|
+
var tokens = _tokenize(queryString);
|
|
740
|
+
var limit = _limit(input.limit, MAX_SUGGEST_LIMIT, DEFAULT_SUGGEST_LIMIT, "limit");
|
|
741
|
+
var role = input.role == null ? null : _role(input.role);
|
|
742
|
+
|
|
743
|
+
if (!tokens.length) return [];
|
|
744
|
+
|
|
745
|
+
var r = await query(
|
|
746
|
+
"SELECT * FROM operator_help_articles WHERE archived_at IS NULL",
|
|
747
|
+
[],
|
|
748
|
+
);
|
|
749
|
+
|
|
750
|
+
var ranked = [];
|
|
751
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
752
|
+
var row = r.rows[i];
|
|
753
|
+
var audience = _safeParseArray(row.audience_roles_json);
|
|
754
|
+
if (!_audienceVisible(audience, role)) continue;
|
|
755
|
+
|
|
756
|
+
var titleTokens = _tokenize(row.title);
|
|
757
|
+
var sectionTokens = _tokenize(row.section);
|
|
758
|
+
var bodyTokens = _tokenize(row.body);
|
|
759
|
+
|
|
760
|
+
var score = 0;
|
|
761
|
+
for (var t = 0; t < tokens.length; t += 1) {
|
|
762
|
+
var token = tokens[t];
|
|
763
|
+
if (titleTokens.indexOf(token) !== -1) score += SEARCH_WEIGHTS.title;
|
|
764
|
+
if (sectionTokens.indexOf(token) !== -1) score += SEARCH_WEIGHTS.section;
|
|
765
|
+
if (bodyTokens.indexOf(token) !== -1) score += SEARCH_WEIGHTS.body;
|
|
766
|
+
}
|
|
767
|
+
if (score > 0) {
|
|
768
|
+
ranked.push({
|
|
769
|
+
slug: row.slug,
|
|
770
|
+
title: row.title,
|
|
771
|
+
section: row.section,
|
|
772
|
+
score: score,
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
ranked.sort(function (a, b) {
|
|
778
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
779
|
+
if (a.slug < b.slug) return 1;
|
|
780
|
+
if (a.slug > b.slug) return -1;
|
|
781
|
+
return 0;
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
return ranked.slice(0, limit);
|
|
785
|
+
},
|
|
786
|
+
|
|
787
|
+
// Append-only view log. operator_id is a strict UUID. Bumps the
|
|
788
|
+
// view_count counter on the article row so popularArticles can
|
|
789
|
+
// sort by combined traffic without re-aggregating the views
|
|
790
|
+
// table on every read.
|
|
791
|
+
recordView: async function (input) {
|
|
792
|
+
if (!input || typeof input !== "object") {
|
|
793
|
+
throw new TypeError("operatorHelpCenter.recordView: input object required");
|
|
794
|
+
}
|
|
795
|
+
var slug = _slug(input.slug);
|
|
796
|
+
var operatorId = _operatorId(input.operator_id);
|
|
797
|
+
var row = await _readRow(slug);
|
|
798
|
+
if (!row) {
|
|
799
|
+
var err = new Error("operatorHelpCenter.recordView: slug '" + slug + "' not found");
|
|
800
|
+
err.code = "OPERATOR_HELP_ARTICLE_NOT_FOUND";
|
|
801
|
+
throw err;
|
|
802
|
+
}
|
|
803
|
+
if (row.archived_at != null) {
|
|
804
|
+
var aErr = new Error("operatorHelpCenter.recordView: slug '" + slug + "' is archived");
|
|
805
|
+
aErr.code = "OPERATOR_HELP_ARTICLE_ARCHIVED";
|
|
806
|
+
throw aErr;
|
|
807
|
+
}
|
|
808
|
+
var ts = _now();
|
|
809
|
+
await query(
|
|
810
|
+
"INSERT INTO operator_help_views (id, slug, operator_id, occurred_at) " +
|
|
811
|
+
"VALUES (?1, ?2, ?3, ?4)",
|
|
812
|
+
[_b().uuid.v7(), slug, operatorId, ts],
|
|
813
|
+
);
|
|
814
|
+
await query(
|
|
815
|
+
"UPDATE operator_help_articles SET view_count = view_count + 1 WHERE slug = ?1",
|
|
816
|
+
[slug],
|
|
817
|
+
);
|
|
818
|
+
return { slug: slug, operator_id: operatorId, occurred_at: ts };
|
|
819
|
+
},
|
|
820
|
+
|
|
821
|
+
// Vote: helpful / not_helpful. Deduped at (slug, operator_id)
|
|
822
|
+
// via the UNIQUE constraint — a repeat vote from the same
|
|
823
|
+
// operator collapses to a no-op. The first vote wins; flipping
|
|
824
|
+
// requires the operator to clear (not exposed by design — the
|
|
825
|
+
// helpfulness signal is "did the article work for you the first
|
|
826
|
+
// time", not "what's your current opinion").
|
|
827
|
+
recordHelpfulVote: async function (input) {
|
|
828
|
+
if (!input || typeof input !== "object") {
|
|
829
|
+
throw new TypeError("operatorHelpCenter.recordHelpfulVote: input object required");
|
|
830
|
+
}
|
|
831
|
+
var slug = _slug(input.slug);
|
|
832
|
+
var operatorId = _operatorId(input.operator_id);
|
|
833
|
+
var vote = _vote(input.vote);
|
|
834
|
+
var row = await _readRow(slug);
|
|
835
|
+
if (!row) {
|
|
836
|
+
var err = new Error("operatorHelpCenter.recordHelpfulVote: slug '" + slug + "' not found");
|
|
837
|
+
err.code = "OPERATOR_HELP_ARTICLE_NOT_FOUND";
|
|
838
|
+
throw err;
|
|
839
|
+
}
|
|
840
|
+
if (row.archived_at != null) {
|
|
841
|
+
var aErr = new Error("operatorHelpCenter.recordHelpfulVote: slug '" + slug + "' is archived");
|
|
842
|
+
aErr.code = "OPERATOR_HELP_ARTICLE_ARCHIVED";
|
|
843
|
+
throw aErr;
|
|
844
|
+
}
|
|
845
|
+
var ts = _now();
|
|
846
|
+
var inserted = await query(
|
|
847
|
+
"INSERT OR IGNORE INTO operator_help_votes (id, slug, operator_id, vote, occurred_at) " +
|
|
848
|
+
"VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
849
|
+
[_b().uuid.v7(), slug, operatorId, vote, ts],
|
|
850
|
+
);
|
|
851
|
+
var changed = inserted && (inserted.rowCount === 1 || inserted.changes === 1);
|
|
852
|
+
if (changed) {
|
|
853
|
+
var col = vote === "helpful" ? "helpful_count" : "not_helpful_count";
|
|
854
|
+
await query(
|
|
855
|
+
"UPDATE operator_help_articles SET " + col + " = " + col + " + 1 WHERE slug = ?1",
|
|
856
|
+
[slug],
|
|
857
|
+
);
|
|
858
|
+
}
|
|
859
|
+
return { slug: slug, operator_id: operatorId, vote: vote, recorded: !!changed };
|
|
860
|
+
},
|
|
861
|
+
|
|
862
|
+
// Top-N most-viewed articles over a closed [from, to] window.
|
|
863
|
+
// Views are counted from operator_help_views.occurred_at.
|
|
864
|
+
// Archived articles are filtered. When `role` is supplied,
|
|
865
|
+
// articles whose audience_roles excludes that token are also
|
|
866
|
+
// filtered. Sorted by view count DESC, slug DESC.
|
|
867
|
+
popularArticles: async function (input) {
|
|
868
|
+
if (!input || typeof input !== "object") {
|
|
869
|
+
throw new TypeError("operatorHelpCenter.popularArticles: input object required");
|
|
870
|
+
}
|
|
871
|
+
var from = input.from;
|
|
872
|
+
var to = input.to;
|
|
873
|
+
_timestampRange(from, to, "popularArticles");
|
|
874
|
+
var limit = _limit(input.limit, MAX_POPULAR_LIMIT, DEFAULT_POPULAR_LIMIT, "limit");
|
|
875
|
+
var role = input.role == null ? null : _role(input.role);
|
|
876
|
+
|
|
877
|
+
var sql =
|
|
878
|
+
"SELECT v.slug AS slug, a.audience_roles_json AS audience_roles_json, " +
|
|
879
|
+
" COUNT(*) AS views " +
|
|
880
|
+
"FROM operator_help_views v " +
|
|
881
|
+
"JOIN operator_help_articles a ON a.slug = v.slug " +
|
|
882
|
+
"WHERE v.occurred_at >= ?1 AND v.occurred_at <= ?2 " +
|
|
883
|
+
" AND a.archived_at IS NULL " +
|
|
884
|
+
"GROUP BY v.slug " +
|
|
885
|
+
"ORDER BY views DESC, v.slug DESC";
|
|
886
|
+
var r = await query(sql, [from, to]);
|
|
887
|
+
var out = [];
|
|
888
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
889
|
+
var row = r.rows[i];
|
|
890
|
+
var audience = _safeParseArray(row.audience_roles_json);
|
|
891
|
+
if (!_audienceVisible(audience, role)) continue;
|
|
892
|
+
out.push({ slug: row.slug, views: Number(row.views) });
|
|
893
|
+
if (out.length >= limit) break;
|
|
894
|
+
}
|
|
895
|
+
return out;
|
|
896
|
+
},
|
|
897
|
+
|
|
898
|
+
// Archive — sets archived_at tombstone. Archived articles are
|
|
899
|
+
// hidden from every read surface (getArticle, articlesForSection,
|
|
900
|
+
// searchSuggest, popularArticles, recordView, recordHelpfulVote).
|
|
901
|
+
// Terminal in v1 — no de-archive surface; an operator who
|
|
902
|
+
// archives by mistake re-authors under a fresh slug. Operator
|
|
903
|
+
// demand for un-archive can be added later via a separate
|
|
904
|
+
// surface; the storage column is in place.
|
|
905
|
+
archiveArticle: async function (slug) {
|
|
906
|
+
slug = _slug(slug);
|
|
907
|
+
var existing = await _readRow(slug);
|
|
908
|
+
if (!existing) {
|
|
909
|
+
var err = new Error("operatorHelpCenter.archiveArticle: slug '" + slug + "' not found");
|
|
910
|
+
err.code = "OPERATOR_HELP_ARTICLE_NOT_FOUND";
|
|
911
|
+
throw err;
|
|
912
|
+
}
|
|
913
|
+
var ts = _now();
|
|
914
|
+
await query(
|
|
915
|
+
"UPDATE operator_help_articles SET archived_at = ?1, updated_at = ?1 WHERE slug = ?2",
|
|
916
|
+
[ts, slug],
|
|
917
|
+
);
|
|
918
|
+
return _hydrateRow(await _readRow(slug));
|
|
919
|
+
},
|
|
920
|
+
|
|
921
|
+
// Patch any subset of { title, body, section, related_actions,
|
|
922
|
+
// audience_roles }. Slug is immutable. Refuses on archived rows
|
|
923
|
+
// — operator must re-author under a fresh slug.
|
|
924
|
+
updateArticle: async function (slug, patch) {
|
|
925
|
+
slug = _slug(slug);
|
|
926
|
+
if (!patch || typeof patch !== "object") {
|
|
927
|
+
throw new TypeError("operatorHelpCenter.updateArticle: patch object required");
|
|
928
|
+
}
|
|
929
|
+
var existing = await _readRow(slug);
|
|
930
|
+
if (!existing) {
|
|
931
|
+
var err = new Error("operatorHelpCenter.updateArticle: slug '" + slug + "' not found");
|
|
932
|
+
err.code = "OPERATOR_HELP_ARTICLE_NOT_FOUND";
|
|
933
|
+
throw err;
|
|
934
|
+
}
|
|
935
|
+
if (existing.archived_at != null) {
|
|
936
|
+
var aErr = new Error("operatorHelpCenter.updateArticle: slug '" + slug + "' is archived");
|
|
937
|
+
aErr.code = "OPERATOR_HELP_ARTICLE_ARCHIVED";
|
|
938
|
+
throw aErr;
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// Reject any key outside the allow-list so a typo'd field
|
|
942
|
+
// doesn't silently no-op.
|
|
943
|
+
var patchKeys = Object.keys(patch);
|
|
944
|
+
for (var k = 0; k < patchKeys.length; k += 1) {
|
|
945
|
+
if (ALLOWED_PATCH_COLUMNS.indexOf(patchKeys[k]) === -1) {
|
|
946
|
+
throw new TypeError(
|
|
947
|
+
"operatorHelpCenter.updateArticle: patch key " +
|
|
948
|
+
JSON.stringify(patchKeys[k]) +
|
|
949
|
+
" is not in the allow-list (" + ALLOWED_PATCH_COLUMNS.join(", ") + ")"
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
var sets = [];
|
|
955
|
+
var params = [];
|
|
956
|
+
var idx = 1;
|
|
957
|
+
|
|
958
|
+
if (patch.title !== undefined) {
|
|
959
|
+
sets.push("title = ?" + idx); params.push(_title(patch.title)); idx += 1;
|
|
960
|
+
}
|
|
961
|
+
if (patch.body !== undefined) {
|
|
962
|
+
sets.push("body = ?" + idx); params.push(_body(patch.body)); idx += 1;
|
|
963
|
+
}
|
|
964
|
+
if (patch.section !== undefined) {
|
|
965
|
+
sets.push("section = ?" + idx); params.push(_section(patch.section)); idx += 1;
|
|
966
|
+
}
|
|
967
|
+
if (patch.related_actions !== undefined) {
|
|
968
|
+
sets.push("related_actions_json = ?" + idx);
|
|
969
|
+
params.push(JSON.stringify(_relatedActions(patch.related_actions))); idx += 1;
|
|
970
|
+
}
|
|
971
|
+
if (patch.audience_roles !== undefined) {
|
|
972
|
+
sets.push("audience_roles_json = ?" + idx);
|
|
973
|
+
params.push(JSON.stringify(_audienceRoles(patch.audience_roles))); idx += 1;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
var ts = _now();
|
|
977
|
+
sets.push("updated_at = ?" + idx); params.push(ts); idx += 1;
|
|
978
|
+
params.push(slug);
|
|
979
|
+
|
|
980
|
+
await query(
|
|
981
|
+
"UPDATE operator_help_articles SET " + sets.join(", ") + " WHERE slug = ?" + idx,
|
|
982
|
+
params,
|
|
983
|
+
);
|
|
984
|
+
return _hydrateRow(await _readRow(slug));
|
|
985
|
+
},
|
|
986
|
+
|
|
987
|
+
// Distinct sections currently in use (excluding archived rows).
|
|
988
|
+
// Drives the help-drawer section picker so operators see only
|
|
989
|
+
// sections that actually have articles.
|
|
990
|
+
listSections: async function () {
|
|
991
|
+
var r = await query(
|
|
992
|
+
"SELECT DISTINCT section FROM operator_help_articles " +
|
|
993
|
+
"WHERE archived_at IS NULL ORDER BY section ASC",
|
|
994
|
+
[],
|
|
995
|
+
);
|
|
996
|
+
var out = [];
|
|
997
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
998
|
+
out.push(r.rows[i].section);
|
|
999
|
+
}
|
|
1000
|
+
return out;
|
|
1001
|
+
},
|
|
1002
|
+
};
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
module.exports = {
|
|
1006
|
+
create: create,
|
|
1007
|
+
MAX_SLUG_LEN: MAX_SLUG_LEN,
|
|
1008
|
+
MAX_TITLE_LEN: MAX_TITLE_LEN,
|
|
1009
|
+
MAX_BODY_LEN: MAX_BODY_LEN,
|
|
1010
|
+
MAX_SECTION_LEN: MAX_SECTION_LEN,
|
|
1011
|
+
MAX_ACTION_LEN: MAX_ACTION_LEN,
|
|
1012
|
+
MAX_ACTION_COUNT: MAX_ACTION_COUNT,
|
|
1013
|
+
MAX_AUDIENCE_COUNT: MAX_AUDIENCE_COUNT,
|
|
1014
|
+
MAX_QUERY_LEN: MAX_QUERY_LEN,
|
|
1015
|
+
MAX_LIST_LIMIT: MAX_LIST_LIMIT,
|
|
1016
|
+
MAX_POPULAR_LIMIT: MAX_POPULAR_LIMIT,
|
|
1017
|
+
MAX_SUGGEST_LIMIT: MAX_SUGGEST_LIMIT,
|
|
1018
|
+
ALLOWED_VOTES: ALLOWED_VOTES.slice(),
|
|
1019
|
+
SEARCH_WEIGHTS: Object.assign({}, SEARCH_WEIGHTS),
|
|
1020
|
+
};
|