@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,749 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.productQA
|
|
4
|
+
* @title Product Q&A — PDP-attached question + answer threads
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Customer-submitted questions and operator/customer answers
|
|
8
|
+
* attached to a product detail page. Distinct from the rating-based
|
|
9
|
+
* reviews primitive (lib/reviews.js): there's no star rating, the
|
|
10
|
+
* payload is free-form text, and answer authorship is a three-way
|
|
11
|
+
* enum (operator / customer / system) so the storefront can render
|
|
12
|
+
* the "Answered by the seller" badge without a second lookup.
|
|
13
|
+
*
|
|
14
|
+
* Author identity:
|
|
15
|
+
* - Authenticated questioners pass `customer_id` (UUID); the raw
|
|
16
|
+
* id persists alongside the hash so an operator can build a
|
|
17
|
+
* "my questions" page without leaking the hash space.
|
|
18
|
+
* - Anonymous questioners pass `customer_email`; the raw address
|
|
19
|
+
* is normalised via `b.guardEmail` (strict profile, lowered),
|
|
20
|
+
* hashed with `b.crypto.namespaceHash` under the
|
|
21
|
+
* `product-qa-customer-email` namespace, and ONLY the hash is
|
|
22
|
+
* persisted.
|
|
23
|
+
* - Authenticated answerers pass `author_id` (UUID) — operator
|
|
24
|
+
* staff ids or customer ids, with `is_operator` set on operator
|
|
25
|
+
* writes so `pinAnswer` can refuse to pin a non-operator
|
|
26
|
+
* answer.
|
|
27
|
+
*
|
|
28
|
+
* Moderation FSM (shared by questions and answers):
|
|
29
|
+
* pending -> approved (approveQuestion / approveAnswer)
|
|
30
|
+
* pending -> rejected (rejectQuestion / rejectAnswer)
|
|
31
|
+
* approved -> rejected (operator unpublish after publication)
|
|
32
|
+
* approved -> approved (no-op — approvers are idempotent)
|
|
33
|
+
*
|
|
34
|
+
* Vote-up signal:
|
|
35
|
+
* `voteUpAnswer({ answer_id, session_id })` records a thumbs-up
|
|
36
|
+
* from the visitor's session. The session id is hashed under the
|
|
37
|
+
* `product-qa-vote-session` namespace; the unique
|
|
38
|
+
* (answer_id, session_id_hash) constraint dedupes repeat taps
|
|
39
|
+
* from the same tab to a single vote. Successful inserts bump
|
|
40
|
+
* the denormalised `vote_count` on the answer row in the same
|
|
41
|
+
* pair of statements.
|
|
42
|
+
*
|
|
43
|
+
* Top-answer pinning:
|
|
44
|
+
* `pinAnswer(answer_id)` floats an operator answer to the top of
|
|
45
|
+
* `topAnswerForQuestion`. The partial unique index in
|
|
46
|
+
* `0133_product_qa.sql` enforces at-most-one pinned answer per
|
|
47
|
+
* question — pinning a second answer auto-unpins the previous
|
|
48
|
+
* one in the same transaction. Customer / system answers can't
|
|
49
|
+
* be pinned (operators own the "definitive answer" surface).
|
|
50
|
+
*
|
|
51
|
+
* Composes:
|
|
52
|
+
* - `b.guardUuid` — UUID-shape validation for ids.
|
|
53
|
+
* - `b.guardEmail` — strict-profile email validation +
|
|
54
|
+
* canonical lower-cased form.
|
|
55
|
+
* - `b.crypto.namespaceHash` — SHA3-512 namespace hash for email
|
|
56
|
+
* + session id.
|
|
57
|
+
* - `b.uuid.v7` — monotonic-lexicographic row ids.
|
|
58
|
+
*
|
|
59
|
+
* Storage: `migrations-d1/0133_product_qa.sql` —
|
|
60
|
+
* `product_qa_questions` + `product_qa_answers` (FK CASCADE) +
|
|
61
|
+
* `product_qa_votes` (FK CASCADE, UNIQUE(answer_id, session_id_hash)).
|
|
62
|
+
*
|
|
63
|
+
* @primitive productQA
|
|
64
|
+
* @related b.crypto, b.uuid, b.guardUuid, b.guardEmail
|
|
65
|
+
*/
|
|
66
|
+
|
|
67
|
+
var EMAIL_NAMESPACE = "product-qa-customer-email";
|
|
68
|
+
var SESSION_NAMESPACE = "product-qa-vote-session";
|
|
69
|
+
|
|
70
|
+
var MAX_BODY_LEN = 4000;
|
|
71
|
+
var MAX_REASON_LEN = 500;
|
|
72
|
+
var MAX_SESSION_ID_LEN = 256;
|
|
73
|
+
|
|
74
|
+
var DEFAULT_LIMIT = 50;
|
|
75
|
+
var MAX_LIST_LIMIT = 200;
|
|
76
|
+
|
|
77
|
+
var DEFAULT_CLEANUP_DAYS = 30;
|
|
78
|
+
var MAX_CLEANUP_DAYS = 365 * 5;
|
|
79
|
+
var MIN_CLEANUP_DAYS = 1;
|
|
80
|
+
|
|
81
|
+
var STATUSES = Object.freeze(["pending", "approved", "rejected"]);
|
|
82
|
+
var AUTHORS = Object.freeze(["operator", "customer", "system"]);
|
|
83
|
+
|
|
84
|
+
var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
85
|
+
|
|
86
|
+
// Lazy framework handle — matches the rest of the shop primitives;
|
|
87
|
+
// avoids the require cycle that would arise from importing `./index`
|
|
88
|
+
// at module-eval time.
|
|
89
|
+
var bShop;
|
|
90
|
+
function _b() {
|
|
91
|
+
if (!bShop) { bShop = require("./index"); }
|
|
92
|
+
return bShop.framework;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---- monotonic clock ---------------------------------------------------
|
|
96
|
+
//
|
|
97
|
+
// Q&A rows persist epoch-ms timestamps and the read paths order by
|
|
98
|
+
// occurred_at. The strict-monotonic clock here guarantees two same-
|
|
99
|
+
// millisecond _now() calls produce distinct integers so the ordering
|
|
100
|
+
// in topAnswerForQuestion + questionsForProduct stays deterministic
|
|
101
|
+
// without an extra tiebreaker column. The pagination test below
|
|
102
|
+
// submits questions in tight loops and relies on this.
|
|
103
|
+
var _lastTs = 0;
|
|
104
|
+
function _now() {
|
|
105
|
+
var t = Date.now();
|
|
106
|
+
if (t <= _lastTs) { t = _lastTs + 1; }
|
|
107
|
+
_lastTs = t;
|
|
108
|
+
return t;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- validators --------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
function _uuid(s, label) {
|
|
114
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
115
|
+
catch (e) { throw new TypeError("productQA: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function _body(s, label) {
|
|
119
|
+
if (typeof s !== "string" || !s.length) {
|
|
120
|
+
throw new TypeError("productQA: " + label + " must be a non-empty string");
|
|
121
|
+
}
|
|
122
|
+
if (s.length > MAX_BODY_LEN) {
|
|
123
|
+
throw new TypeError("productQA: " + label + " must be <= " + MAX_BODY_LEN + " characters");
|
|
124
|
+
}
|
|
125
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
126
|
+
throw new TypeError("productQA: " + label + " must not contain control bytes");
|
|
127
|
+
}
|
|
128
|
+
return s;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function _reason(s) {
|
|
132
|
+
if (s == null) return null;
|
|
133
|
+
if (typeof s !== "string") {
|
|
134
|
+
throw new TypeError("productQA: reason must be a string");
|
|
135
|
+
}
|
|
136
|
+
if (!s.length) return null;
|
|
137
|
+
if (s.length > MAX_REASON_LEN) {
|
|
138
|
+
throw new TypeError("productQA: reason must be <= " + MAX_REASON_LEN + " characters");
|
|
139
|
+
}
|
|
140
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
141
|
+
throw new TypeError("productQA: reason must not contain control bytes");
|
|
142
|
+
}
|
|
143
|
+
return s;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function _author(s) {
|
|
147
|
+
if (typeof s !== "string" || AUTHORS.indexOf(s) < 0) {
|
|
148
|
+
throw new TypeError("productQA: author must be one of " + AUTHORS.join(", "));
|
|
149
|
+
}
|
|
150
|
+
return s;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function _statusFilter(s) {
|
|
154
|
+
if (s == null) return undefined;
|
|
155
|
+
if (typeof s !== "string" || STATUSES.indexOf(s) < 0) {
|
|
156
|
+
throw new TypeError("productQA: status filter must be one of " + STATUSES.join(", "));
|
|
157
|
+
}
|
|
158
|
+
return s;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function _limit(n) {
|
|
162
|
+
if (n == null) return DEFAULT_LIMIT;
|
|
163
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
|
|
164
|
+
throw new TypeError("productQA: limit must be an integer 1..." + MAX_LIST_LIMIT);
|
|
165
|
+
}
|
|
166
|
+
return n;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function _sessionId(s) {
|
|
170
|
+
if (typeof s !== "string" || !s.length) {
|
|
171
|
+
throw new TypeError("productQA: session_id must be a non-empty string");
|
|
172
|
+
}
|
|
173
|
+
if (s.length > MAX_SESSION_ID_LEN) {
|
|
174
|
+
throw new TypeError("productQA: session_id must be <= " + MAX_SESSION_ID_LEN + " characters");
|
|
175
|
+
}
|
|
176
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
177
|
+
throw new TypeError("productQA: session_id must not contain control bytes");
|
|
178
|
+
}
|
|
179
|
+
return s;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function _cleanupDays(n) {
|
|
183
|
+
if (n == null) return DEFAULT_CLEANUP_DAYS;
|
|
184
|
+
if (!Number.isInteger(n) || n < MIN_CLEANUP_DAYS || n > MAX_CLEANUP_DAYS) {
|
|
185
|
+
throw new TypeError("productQA: cleanup days must be an integer in [" +
|
|
186
|
+
MIN_CLEANUP_DAYS + ", " + MAX_CLEANUP_DAYS + "]");
|
|
187
|
+
}
|
|
188
|
+
return n;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function _normalizeEmail(input) {
|
|
192
|
+
if (typeof input !== "string" || !input.length) {
|
|
193
|
+
throw new TypeError("productQA: customer_email must be a non-empty string");
|
|
194
|
+
}
|
|
195
|
+
var guardEmail = _b().guardEmail;
|
|
196
|
+
var report;
|
|
197
|
+
try {
|
|
198
|
+
report = guardEmail.validate(input, { profile: "strict" });
|
|
199
|
+
} catch (e) {
|
|
200
|
+
throw new TypeError("productQA: customer_email — " + (e && e.message || "invalid email"));
|
|
201
|
+
}
|
|
202
|
+
if (!report || report.ok === false) {
|
|
203
|
+
var first = (report && report.issues && report.issues[0]) || {};
|
|
204
|
+
throw new TypeError("productQA: customer_email — " +
|
|
205
|
+
(first.snippet || first.ruleId || "refused at strict profile"));
|
|
206
|
+
}
|
|
207
|
+
var canonical;
|
|
208
|
+
try {
|
|
209
|
+
canonical = guardEmail.sanitize(input, { profile: "strict" });
|
|
210
|
+
} catch (e) {
|
|
211
|
+
throw new TypeError("productQA: customer_email — " + (e && e.message || "refused"));
|
|
212
|
+
}
|
|
213
|
+
return canonical.toLowerCase().trim();
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ---- factory -----------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
function create(opts) {
|
|
219
|
+
opts = opts || {};
|
|
220
|
+
var query = opts.query;
|
|
221
|
+
if (!query) {
|
|
222
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
223
|
+
}
|
|
224
|
+
// `customers` integration is optional — the primitive enforces UUID
|
|
225
|
+
// shape on every customer_id parameter, but if the operator wires
|
|
226
|
+
// the live `customers` primitive in we additionally verify the
|
|
227
|
+
// referenced customer row exists before stamping. Tests can pass a
|
|
228
|
+
// minimal fake or leave it null; production wires the real instance.
|
|
229
|
+
var customers = opts.customers || null;
|
|
230
|
+
|
|
231
|
+
function _hashEmail(canonical) {
|
|
232
|
+
return _b().crypto.namespaceHash(EMAIL_NAMESPACE, canonical);
|
|
233
|
+
}
|
|
234
|
+
function _hashSession(sessionId) {
|
|
235
|
+
return _b().crypto.namespaceHash(SESSION_NAMESPACE, sessionId);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function _maybeRequireCustomer(id) {
|
|
239
|
+
if (!customers || id == null) return;
|
|
240
|
+
var row = await customers.get(id);
|
|
241
|
+
if (!row) {
|
|
242
|
+
throw new TypeError("productQA: customer " + JSON.stringify(id) + " not found");
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function _questionRow(id) {
|
|
247
|
+
var r = await query("SELECT * FROM product_qa_questions WHERE id = ?1", [id]);
|
|
248
|
+
return r.rows[0] || null;
|
|
249
|
+
}
|
|
250
|
+
async function _answerRow(id) {
|
|
251
|
+
var r = await query("SELECT * FROM product_qa_answers WHERE id = ?1", [id]);
|
|
252
|
+
return r.rows[0] || null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// ---- submitQuestion --------------------------------------------------
|
|
256
|
+
|
|
257
|
+
async function submitQuestion(input) {
|
|
258
|
+
if (!input || typeof input !== "object") {
|
|
259
|
+
throw new TypeError("productQA.submitQuestion: input object required");
|
|
260
|
+
}
|
|
261
|
+
var productId = _uuid(input.product_id, "product_id");
|
|
262
|
+
var body = _body(input.body, "body");
|
|
263
|
+
|
|
264
|
+
var hasId = input.customer_id != null && input.customer_id !== "";
|
|
265
|
+
var hasEmail = input.customer_email != null && input.customer_email !== "";
|
|
266
|
+
if (!hasId && !hasEmail) {
|
|
267
|
+
throw new TypeError("productQA.submitQuestion: either customer_id or customer_email is required");
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
var customerId = null;
|
|
271
|
+
var customerEmailHash = null;
|
|
272
|
+
if (hasId) {
|
|
273
|
+
customerId = _uuid(input.customer_id, "customer_id");
|
|
274
|
+
await _maybeRequireCustomer(customerId);
|
|
275
|
+
}
|
|
276
|
+
if (hasEmail) {
|
|
277
|
+
customerEmailHash = _hashEmail(_normalizeEmail(input.customer_email));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
var id = _b().uuid.v7();
|
|
281
|
+
var ts = _now();
|
|
282
|
+
await query(
|
|
283
|
+
"INSERT INTO product_qa_questions " +
|
|
284
|
+
"(id, product_id, customer_id, customer_email_hash, body, status, pinned, vote_count, occurred_at) " +
|
|
285
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, 'pending', 0, 0, ?6)",
|
|
286
|
+
[id, productId, customerId, customerEmailHash, body, ts],
|
|
287
|
+
);
|
|
288
|
+
return await getQuestion(id);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ---- submitAnswer ----------------------------------------------------
|
|
292
|
+
|
|
293
|
+
async function submitAnswer(input) {
|
|
294
|
+
if (!input || typeof input !== "object") {
|
|
295
|
+
throw new TypeError("productQA.submitAnswer: input object required");
|
|
296
|
+
}
|
|
297
|
+
var questionId = _uuid(input.question_id, "question_id");
|
|
298
|
+
var author = _author(input.author);
|
|
299
|
+
var body = _body(input.body, "body");
|
|
300
|
+
|
|
301
|
+
var authorId = null;
|
|
302
|
+
if (input.author_id != null && input.author_id !== "") {
|
|
303
|
+
authorId = _uuid(input.author_id, "author_id");
|
|
304
|
+
if (author === "customer") {
|
|
305
|
+
await _maybeRequireCustomer(authorId);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// `is_operator` defaults to true when author === "operator" so
|
|
310
|
+
// callers can omit the flag for the common case. Explicit values
|
|
311
|
+
// override only when consistent — refusing the inconsistent pair
|
|
312
|
+
// catches a caller that asserts "this is the staff answer" while
|
|
313
|
+
// claiming customer authorship.
|
|
314
|
+
var isOperator;
|
|
315
|
+
if (input.is_operator == null) {
|
|
316
|
+
isOperator = author === "operator" ? 1 : 0;
|
|
317
|
+
} else {
|
|
318
|
+
if (input.is_operator !== true && input.is_operator !== false &&
|
|
319
|
+
input.is_operator !== 0 && input.is_operator !== 1) {
|
|
320
|
+
throw new TypeError("productQA.submitAnswer: is_operator must be a boolean or 0/1");
|
|
321
|
+
}
|
|
322
|
+
isOperator = (input.is_operator === true || input.is_operator === 1) ? 1 : 0;
|
|
323
|
+
if (isOperator === 1 && author !== "operator") {
|
|
324
|
+
throw new TypeError("productQA.submitAnswer: is_operator=true requires author === 'operator'");
|
|
325
|
+
}
|
|
326
|
+
if (isOperator === 0 && author === "operator") {
|
|
327
|
+
throw new TypeError("productQA.submitAnswer: author === 'operator' requires is_operator=true");
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Parent question must exist — FK CASCADE handles the inverse
|
|
332
|
+
// (delete question -> delete answers), but the insert itself
|
|
333
|
+
// benefits from a clean app-layer error rather than a raw
|
|
334
|
+
// foreign-key constraint message.
|
|
335
|
+
var parent = await _questionRow(questionId);
|
|
336
|
+
if (!parent) {
|
|
337
|
+
var err = new Error("productQA.submitAnswer: question " + questionId + " not found");
|
|
338
|
+
err.code = "PRODUCT_QA_QUESTION_NOT_FOUND";
|
|
339
|
+
throw err;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
var id = _b().uuid.v7();
|
|
343
|
+
var ts = _now();
|
|
344
|
+
await query(
|
|
345
|
+
"INSERT INTO product_qa_answers " +
|
|
346
|
+
"(id, question_id, author, author_id, body, is_operator, status, pinned, vote_count, occurred_at) " +
|
|
347
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'pending', 0, 0, ?7)",
|
|
348
|
+
[id, questionId, author, authorId, body, isOperator, ts],
|
|
349
|
+
);
|
|
350
|
+
return _decodeAnswer(await _answerRow(id));
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ---- moderation verbs ------------------------------------------------
|
|
354
|
+
|
|
355
|
+
async function _setQuestionStatus(id, toStatus, reason, verbLabel) {
|
|
356
|
+
_uuid(id, "question_id");
|
|
357
|
+
_reason(reason);
|
|
358
|
+
var existing = await _questionRow(id);
|
|
359
|
+
if (!existing) {
|
|
360
|
+
var err = new Error("productQA." + verbLabel + ": question " + id + " not found");
|
|
361
|
+
err.code = "PRODUCT_QA_QUESTION_NOT_FOUND";
|
|
362
|
+
throw err;
|
|
363
|
+
}
|
|
364
|
+
if (existing.status === toStatus) {
|
|
365
|
+
// Idempotent — re-approving an approved row is a no-op.
|
|
366
|
+
return _decodeQuestion(existing);
|
|
367
|
+
}
|
|
368
|
+
if (existing.status === "rejected" && toStatus === "approved") {
|
|
369
|
+
// Rejected rows are terminal for the approve path so operators
|
|
370
|
+
// can't accidentally un-reject after a takedown audit. Use
|
|
371
|
+
// submitQuestion to re-author if the customer re-asks.
|
|
372
|
+
var bad = new Error("productQA." + verbLabel +
|
|
373
|
+
": cannot approve a rejected question");
|
|
374
|
+
bad.code = "PRODUCT_QA_TRANSITION_REFUSED";
|
|
375
|
+
throw bad;
|
|
376
|
+
}
|
|
377
|
+
await query(
|
|
378
|
+
"UPDATE product_qa_questions SET status = ?1 WHERE id = ?2",
|
|
379
|
+
[toStatus, id],
|
|
380
|
+
);
|
|
381
|
+
return _decodeQuestion(await _questionRow(id));
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
async function _setAnswerStatus(id, toStatus, reason, verbLabel) {
|
|
385
|
+
_uuid(id, "answer_id");
|
|
386
|
+
_reason(reason);
|
|
387
|
+
var existing = await _answerRow(id);
|
|
388
|
+
if (!existing) {
|
|
389
|
+
var err = new Error("productQA." + verbLabel + ": answer " + id + " not found");
|
|
390
|
+
err.code = "PRODUCT_QA_ANSWER_NOT_FOUND";
|
|
391
|
+
throw err;
|
|
392
|
+
}
|
|
393
|
+
if (existing.status === toStatus) {
|
|
394
|
+
return _decodeAnswer(existing);
|
|
395
|
+
}
|
|
396
|
+
if (existing.status === "rejected" && toStatus === "approved") {
|
|
397
|
+
var bad = new Error("productQA." + verbLabel +
|
|
398
|
+
": cannot approve a rejected answer");
|
|
399
|
+
bad.code = "PRODUCT_QA_TRANSITION_REFUSED";
|
|
400
|
+
throw bad;
|
|
401
|
+
}
|
|
402
|
+
// Rejecting an answer also clears its pin — a takedown shouldn't
|
|
403
|
+
// leave the pinned slot occupied by a since-removed answer.
|
|
404
|
+
if (toStatus === "rejected" && Number(existing.pinned) === 1) {
|
|
405
|
+
await query(
|
|
406
|
+
"UPDATE product_qa_answers SET status = ?1, pinned = 0 WHERE id = ?2",
|
|
407
|
+
[toStatus, id],
|
|
408
|
+
);
|
|
409
|
+
} else {
|
|
410
|
+
await query(
|
|
411
|
+
"UPDATE product_qa_answers SET status = ?1 WHERE id = ?2",
|
|
412
|
+
[toStatus, id],
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
return _decodeAnswer(await _answerRow(id));
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function approveQuestion(id) { return _setQuestionStatus(id, "approved", null, "approveQuestion"); }
|
|
419
|
+
function rejectQuestion(id, reason) { return _setQuestionStatus(id, "rejected", reason, "rejectQuestion"); }
|
|
420
|
+
function approveAnswer(id) { return _setAnswerStatus(id, "approved", null, "approveAnswer"); }
|
|
421
|
+
function rejectAnswer(id, reason) { return _setAnswerStatus(id, "rejected", reason, "rejectAnswer"); }
|
|
422
|
+
|
|
423
|
+
// ---- pinAnswer -------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
async function pinAnswer(answerId) {
|
|
426
|
+
_uuid(answerId, "answer_id");
|
|
427
|
+
var existing = await _answerRow(answerId);
|
|
428
|
+
if (!existing) {
|
|
429
|
+
var miss = new Error("productQA.pinAnswer: answer " + answerId + " not found");
|
|
430
|
+
miss.code = "PRODUCT_QA_ANSWER_NOT_FOUND";
|
|
431
|
+
throw miss;
|
|
432
|
+
}
|
|
433
|
+
if (Number(existing.is_operator) !== 1) {
|
|
434
|
+
var nonOp = new Error("productQA.pinAnswer: only operator answers can be pinned");
|
|
435
|
+
nonOp.code = "PRODUCT_QA_PIN_REFUSED";
|
|
436
|
+
throw nonOp;
|
|
437
|
+
}
|
|
438
|
+
if (existing.status !== "approved") {
|
|
439
|
+
var notApproved = new Error("productQA.pinAnswer: answer must be approved before pinning (current: " +
|
|
440
|
+
existing.status + ")");
|
|
441
|
+
notApproved.code = "PRODUCT_QA_PIN_REFUSED";
|
|
442
|
+
throw notApproved;
|
|
443
|
+
}
|
|
444
|
+
if (Number(existing.pinned) === 1) {
|
|
445
|
+
return _decodeAnswer(existing);
|
|
446
|
+
}
|
|
447
|
+
// Clear any existing pin on the same question first — the
|
|
448
|
+
// partial unique index in 0133_product_qa.sql enforces at-most-
|
|
449
|
+
// one pinned answer per question and would otherwise reject the
|
|
450
|
+
// second pin. Two statements is fine for this since both target
|
|
451
|
+
// the same indexed (question_id) range.
|
|
452
|
+
await query(
|
|
453
|
+
"UPDATE product_qa_answers SET pinned = 0 WHERE question_id = ?1 AND pinned = 1",
|
|
454
|
+
[existing.question_id],
|
|
455
|
+
);
|
|
456
|
+
await query(
|
|
457
|
+
"UPDATE product_qa_answers SET pinned = 1 WHERE id = ?1",
|
|
458
|
+
[answerId],
|
|
459
|
+
);
|
|
460
|
+
return _decodeAnswer(await _answerRow(answerId));
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// ---- voteUpAnswer ----------------------------------------------------
|
|
464
|
+
|
|
465
|
+
async function voteUpAnswer(input) {
|
|
466
|
+
if (!input || typeof input !== "object") {
|
|
467
|
+
throw new TypeError("productQA.voteUpAnswer: input object required");
|
|
468
|
+
}
|
|
469
|
+
var answerId = _uuid(input.answer_id, "answer_id");
|
|
470
|
+
var session = _sessionId(input.session_id);
|
|
471
|
+
var sessionHash = _hashSession(session);
|
|
472
|
+
|
|
473
|
+
var existing = await _answerRow(answerId);
|
|
474
|
+
if (!existing) {
|
|
475
|
+
var miss = new Error("productQA.voteUpAnswer: answer " + answerId + " not found");
|
|
476
|
+
miss.code = "PRODUCT_QA_ANSWER_NOT_FOUND";
|
|
477
|
+
throw miss;
|
|
478
|
+
}
|
|
479
|
+
// Pre-check the dedup row so the (insert / bump) pair only fires
|
|
480
|
+
// for genuinely new votes. The UNIQUE constraint on
|
|
481
|
+
// (answer_id, session_id_hash) is the source of truth; this
|
|
482
|
+
// pre-check just keeps the happy-path counter consistent without
|
|
483
|
+
// an INSERT-OR-IGNORE-then-conditional-bump dance.
|
|
484
|
+
var dup = await query(
|
|
485
|
+
"SELECT id FROM product_qa_votes WHERE answer_id = ?1 AND session_id_hash = ?2 LIMIT 1",
|
|
486
|
+
[answerId, sessionHash],
|
|
487
|
+
);
|
|
488
|
+
if (dup.rows.length) {
|
|
489
|
+
return {
|
|
490
|
+
answer_id: answerId,
|
|
491
|
+
vote_count: Number(existing.vote_count),
|
|
492
|
+
new_vote: false,
|
|
493
|
+
};
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
var ts = _now();
|
|
497
|
+
var voteId = _b().uuid.v7();
|
|
498
|
+
await query(
|
|
499
|
+
"INSERT INTO product_qa_votes (id, answer_id, session_id_hash, occurred_at) " +
|
|
500
|
+
"VALUES (?1, ?2, ?3, ?4)",
|
|
501
|
+
[voteId, answerId, sessionHash, ts],
|
|
502
|
+
);
|
|
503
|
+
await query(
|
|
504
|
+
"UPDATE product_qa_answers SET vote_count = vote_count + 1 WHERE id = ?1",
|
|
505
|
+
[answerId],
|
|
506
|
+
);
|
|
507
|
+
var fresh = await _answerRow(answerId);
|
|
508
|
+
return {
|
|
509
|
+
answer_id: answerId,
|
|
510
|
+
vote_count: Number(fresh.vote_count),
|
|
511
|
+
new_vote: true,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// ---- read paths ------------------------------------------------------
|
|
516
|
+
|
|
517
|
+
function _decodeQuestion(row) {
|
|
518
|
+
if (!row) return null;
|
|
519
|
+
return {
|
|
520
|
+
id: row.id,
|
|
521
|
+
product_id: row.product_id,
|
|
522
|
+
customer_id: row.customer_id,
|
|
523
|
+
customer_email_hash: row.customer_email_hash,
|
|
524
|
+
body: row.body,
|
|
525
|
+
status: row.status,
|
|
526
|
+
pinned: Number(row.pinned),
|
|
527
|
+
vote_count: Number(row.vote_count),
|
|
528
|
+
occurred_at: Number(row.occurred_at),
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function _decodeAnswer(row) {
|
|
533
|
+
if (!row) return null;
|
|
534
|
+
return {
|
|
535
|
+
id: row.id,
|
|
536
|
+
question_id: row.question_id,
|
|
537
|
+
author: row.author,
|
|
538
|
+
author_id: row.author_id,
|
|
539
|
+
body: row.body,
|
|
540
|
+
is_operator: Number(row.is_operator),
|
|
541
|
+
status: row.status,
|
|
542
|
+
pinned: Number(row.pinned),
|
|
543
|
+
vote_count: Number(row.vote_count),
|
|
544
|
+
occurred_at: Number(row.occurred_at),
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
async function getQuestion(id) {
|
|
549
|
+
_uuid(id, "question_id");
|
|
550
|
+
return _decodeQuestion(await _questionRow(id));
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
async function questionsForProduct(input) {
|
|
554
|
+
if (!input || typeof input !== "object") {
|
|
555
|
+
throw new TypeError("productQA.questionsForProduct: input object required");
|
|
556
|
+
}
|
|
557
|
+
var productId = _uuid(input.product_id, "product_id");
|
|
558
|
+
// Default to approved-only so a caller that forgets the status
|
|
559
|
+
// filter doesn't accidentally leak pending / rejected rows onto
|
|
560
|
+
// the storefront.
|
|
561
|
+
var status = input.status === undefined ? "approved" : _statusFilter(input.status);
|
|
562
|
+
var limit = _limit(input.limit);
|
|
563
|
+
|
|
564
|
+
// Opaque cursor stamps the last-seen (occurred_at, id) tuple as
|
|
565
|
+
// `<occurred_at>:<id>`. The primitive's strict-monotonic _now()
|
|
566
|
+
// guarantees occurred_at is unique per row so the tuple predicate
|
|
567
|
+
// collapses to a deterministic total order even when v7 ids
|
|
568
|
+
// generated in the same millisecond sort the wrong way among
|
|
569
|
+
// themselves (RFC 9562 only orders v7 ids across millisecond
|
|
570
|
+
// boundaries).
|
|
571
|
+
var cursor = input.cursor;
|
|
572
|
+
var cursorTs;
|
|
573
|
+
var cursorId;
|
|
574
|
+
if (cursor != null) {
|
|
575
|
+
if (typeof cursor !== "string" || !cursor.length) {
|
|
576
|
+
throw new TypeError("productQA.questionsForProduct: cursor must be a non-empty string when provided");
|
|
577
|
+
}
|
|
578
|
+
var sep = cursor.indexOf(":");
|
|
579
|
+
if (sep < 0) {
|
|
580
|
+
throw new TypeError("productQA.questionsForProduct: cursor — malformed");
|
|
581
|
+
}
|
|
582
|
+
cursorTs = parseInt(cursor.slice(0, sep), 10);
|
|
583
|
+
cursorId = cursor.slice(sep + 1);
|
|
584
|
+
if (!Number.isInteger(cursorTs) || cursorTs < 0 || !cursorId.length) {
|
|
585
|
+
throw new TypeError("productQA.questionsForProduct: cursor — malformed");
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
var sql, params;
|
|
590
|
+
if (status !== undefined && cursor) {
|
|
591
|
+
sql = "SELECT * FROM product_qa_questions WHERE product_id = ?1 AND status = ?2 " +
|
|
592
|
+
"AND (occurred_at < ?3 OR (occurred_at = ?3 AND id < ?4)) " +
|
|
593
|
+
"ORDER BY occurred_at DESC, id DESC LIMIT ?5";
|
|
594
|
+
params = [productId, status, cursorTs, cursorId, limit];
|
|
595
|
+
} else if (status !== undefined) {
|
|
596
|
+
sql = "SELECT * FROM product_qa_questions WHERE product_id = ?1 AND status = ?2 " +
|
|
597
|
+
"ORDER BY occurred_at DESC, id DESC LIMIT ?3";
|
|
598
|
+
params = [productId, status, limit];
|
|
599
|
+
} else if (cursor) {
|
|
600
|
+
sql = "SELECT * FROM product_qa_questions WHERE product_id = ?1 " +
|
|
601
|
+
"AND (occurred_at < ?2 OR (occurred_at = ?2 AND id < ?3)) " +
|
|
602
|
+
"ORDER BY occurred_at DESC, id DESC LIMIT ?4";
|
|
603
|
+
params = [productId, cursorTs, cursorId, limit];
|
|
604
|
+
} else {
|
|
605
|
+
sql = "SELECT * FROM product_qa_questions WHERE product_id = ?1 " +
|
|
606
|
+
"ORDER BY occurred_at DESC, id DESC LIMIT ?2";
|
|
607
|
+
params = [productId, limit];
|
|
608
|
+
}
|
|
609
|
+
var r = await query(sql, params);
|
|
610
|
+
var rows = [];
|
|
611
|
+
for (var i = 0; i < r.rows.length; i += 1) rows.push(_decodeQuestion(r.rows[i]));
|
|
612
|
+
var nextCursor = null;
|
|
613
|
+
if (rows.length === limit) {
|
|
614
|
+
var last = rows[rows.length - 1];
|
|
615
|
+
nextCursor = String(last.occurred_at) + ":" + last.id;
|
|
616
|
+
}
|
|
617
|
+
return { rows: rows, next_cursor: nextCursor };
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// The top answer for a question is, in priority order:
|
|
621
|
+
// 1. The pinned answer (at most one, guaranteed by the partial
|
|
622
|
+
// unique index).
|
|
623
|
+
// 2. The approved answer with the highest vote_count.
|
|
624
|
+
// 3. Tie-break by occurred_at ASC (older answers win ties so a
|
|
625
|
+
// late-coming "+1" doesn't displace an earlier well-rated
|
|
626
|
+
// answer).
|
|
627
|
+
// Pending / rejected answers never surface.
|
|
628
|
+
async function topAnswerForQuestion(questionId) {
|
|
629
|
+
_uuid(questionId, "question_id");
|
|
630
|
+
var r = await query(
|
|
631
|
+
"SELECT * FROM product_qa_answers " +
|
|
632
|
+
"WHERE question_id = ?1 AND status = 'approved' " +
|
|
633
|
+
"ORDER BY pinned DESC, vote_count DESC, occurred_at ASC, id ASC " +
|
|
634
|
+
"LIMIT 1",
|
|
635
|
+
[questionId],
|
|
636
|
+
);
|
|
637
|
+
return r.rows.length ? _decodeAnswer(r.rows[0]) : null;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Per-product rollup used by the PDP header ("12 questions, 8
|
|
641
|
+
// answered, average 3 upvotes per answer"). Counts approved rows
|
|
642
|
+
// only — pending / rejected don't surface to storefront copy.
|
|
643
|
+
async function metricsForProduct(productId) {
|
|
644
|
+
productId = _uuid(productId, "product_id");
|
|
645
|
+
var qRes = await query(
|
|
646
|
+
"SELECT status, COUNT(*) AS n FROM product_qa_questions " +
|
|
647
|
+
"WHERE product_id = ?1 GROUP BY status",
|
|
648
|
+
[productId],
|
|
649
|
+
);
|
|
650
|
+
var qCounts = { pending: 0, approved: 0, rejected: 0 };
|
|
651
|
+
for (var i = 0; i < qRes.rows.length; i += 1) {
|
|
652
|
+
qCounts[qRes.rows[i].status] = Number(qRes.rows[i].n);
|
|
653
|
+
}
|
|
654
|
+
// Answered = count of approved questions that have at least one
|
|
655
|
+
// approved answer. Use a single grouped aggregate so we get the
|
|
656
|
+
// total upvote tally + answered count in one round-trip.
|
|
657
|
+
var aRes = await query(
|
|
658
|
+
"SELECT a.question_id, COUNT(*) AS answer_count, COALESCE(SUM(a.vote_count), 0) AS upvotes " +
|
|
659
|
+
"FROM product_qa_answers a " +
|
|
660
|
+
"JOIN product_qa_questions q ON q.id = a.question_id " +
|
|
661
|
+
"WHERE q.product_id = ?1 AND q.status = 'approved' AND a.status = 'approved' " +
|
|
662
|
+
"GROUP BY a.question_id",
|
|
663
|
+
[productId],
|
|
664
|
+
);
|
|
665
|
+
var answeredQuestions = aRes.rows.length;
|
|
666
|
+
var totalAnswers = 0;
|
|
667
|
+
var totalUpvotes = 0;
|
|
668
|
+
for (var j = 0; j < aRes.rows.length; j += 1) {
|
|
669
|
+
totalAnswers += Number(aRes.rows[j].answer_count);
|
|
670
|
+
totalUpvotes += Number(aRes.rows[j].upvotes);
|
|
671
|
+
}
|
|
672
|
+
return {
|
|
673
|
+
product_id: productId,
|
|
674
|
+
pending_questions: qCounts.pending,
|
|
675
|
+
approved_questions: qCounts.approved,
|
|
676
|
+
rejected_questions: qCounts.rejected,
|
|
677
|
+
answered_questions: answeredQuestions,
|
|
678
|
+
total_answers: totalAnswers,
|
|
679
|
+
total_upvotes: totalUpvotes,
|
|
680
|
+
};
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
// ---- cleanupRejected -------------------------------------------------
|
|
684
|
+
|
|
685
|
+
// Reclaim rejected rows after a retention window so the moderation
|
|
686
|
+
// table doesn't grow without bound. Operates on both questions and
|
|
687
|
+
// answers; deleting a question cascades into its answers + votes
|
|
688
|
+
// via the FK so the answer pass only catches orphaned answers
|
|
689
|
+
// attached to non-rejected questions.
|
|
690
|
+
async function cleanupRejected(days) {
|
|
691
|
+
var d = _cleanupDays(days);
|
|
692
|
+
var cutoff = _now() - (d * 24 * 60 * 60 * 1000);
|
|
693
|
+
|
|
694
|
+
var qDel = await query(
|
|
695
|
+
"DELETE FROM product_qa_questions WHERE status = 'rejected' AND occurred_at < ?1",
|
|
696
|
+
[cutoff],
|
|
697
|
+
);
|
|
698
|
+
var aDel = await query(
|
|
699
|
+
"DELETE FROM product_qa_answers WHERE status = 'rejected' AND occurred_at < ?1",
|
|
700
|
+
[cutoff],
|
|
701
|
+
);
|
|
702
|
+
return {
|
|
703
|
+
questions_deleted: Number(qDel.rowCount || 0),
|
|
704
|
+
answers_deleted: Number(aDel.rowCount || 0),
|
|
705
|
+
cutoff: cutoff,
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
return {
|
|
710
|
+
EMAIL_NAMESPACE: EMAIL_NAMESPACE,
|
|
711
|
+
SESSION_NAMESPACE: SESSION_NAMESPACE,
|
|
712
|
+
MAX_BODY_LEN: MAX_BODY_LEN,
|
|
713
|
+
MAX_REASON_LEN: MAX_REASON_LEN,
|
|
714
|
+
MAX_SESSION_ID_LEN: MAX_SESSION_ID_LEN,
|
|
715
|
+
MAX_LIST_LIMIT: MAX_LIST_LIMIT,
|
|
716
|
+
DEFAULT_LIMIT: DEFAULT_LIMIT,
|
|
717
|
+
DEFAULT_CLEANUP_DAYS: DEFAULT_CLEANUP_DAYS,
|
|
718
|
+
STATUSES: STATUSES.slice(),
|
|
719
|
+
AUTHORS: AUTHORS.slice(),
|
|
720
|
+
|
|
721
|
+
submitQuestion: submitQuestion,
|
|
722
|
+
submitAnswer: submitAnswer,
|
|
723
|
+
approveQuestion: approveQuestion,
|
|
724
|
+
rejectQuestion: rejectQuestion,
|
|
725
|
+
approveAnswer: approveAnswer,
|
|
726
|
+
rejectAnswer: rejectAnswer,
|
|
727
|
+
pinAnswer: pinAnswer,
|
|
728
|
+
voteUpAnswer: voteUpAnswer,
|
|
729
|
+
questionsForProduct: questionsForProduct,
|
|
730
|
+
getQuestion: getQuestion,
|
|
731
|
+
topAnswerForQuestion: topAnswerForQuestion,
|
|
732
|
+
metricsForProduct: metricsForProduct,
|
|
733
|
+
cleanupRejected: cleanupRejected,
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
module.exports = {
|
|
738
|
+
create: create,
|
|
739
|
+
EMAIL_NAMESPACE: EMAIL_NAMESPACE,
|
|
740
|
+
SESSION_NAMESPACE: SESSION_NAMESPACE,
|
|
741
|
+
MAX_BODY_LEN: MAX_BODY_LEN,
|
|
742
|
+
MAX_REASON_LEN: MAX_REASON_LEN,
|
|
743
|
+
MAX_SESSION_ID_LEN: MAX_SESSION_ID_LEN,
|
|
744
|
+
MAX_LIST_LIMIT: MAX_LIST_LIMIT,
|
|
745
|
+
DEFAULT_LIMIT: DEFAULT_LIMIT,
|
|
746
|
+
DEFAULT_CLEANUP_DAYS: DEFAULT_CLEANUP_DAYS,
|
|
747
|
+
STATUSES: STATUSES,
|
|
748
|
+
AUTHORS: AUTHORS,
|
|
749
|
+
};
|