@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/lib/assembly-instructions.js +777 -0
  3. package/lib/auto-replenish.js +933 -0
  4. package/lib/business-hours.js +980 -0
  5. package/lib/click-and-collect.js +711 -0
  6. package/lib/clickstream.js +713 -0
  7. package/lib/cost-layers.js +774 -0
  8. package/lib/credit-limits.js +752 -0
  9. package/lib/currency-rounding.js +525 -0
  10. package/lib/customer-activity.js +862 -0
  11. package/lib/customer-notes.js +712 -0
  12. package/lib/customer-risk-profile.js +593 -0
  13. package/lib/customer-surveys.js +1012 -0
  14. package/lib/damage-photos.js +473 -0
  15. package/lib/discount-allocation.js +557 -0
  16. package/lib/dropship-forwarding.js +645 -0
  17. package/lib/email-templates.js +817 -0
  18. package/lib/index.js +45 -0
  19. package/lib/inventory-allocations.js +559 -0
  20. package/lib/inventory-writeoffs.js +636 -0
  21. package/lib/knowledge-base.js +1104 -0
  22. package/lib/locale-router.js +1077 -0
  23. package/lib/operator-roles.js +768 -0
  24. package/lib/order-escalation.js +951 -0
  25. package/lib/order-ratings.js +495 -0
  26. package/lib/order-tags.js +944 -0
  27. package/lib/packing-slips.js +810 -0
  28. package/lib/payment-retries.js +816 -0
  29. package/lib/pick-lists.js +639 -0
  30. package/lib/pixel-events.js +995 -0
  31. package/lib/preorder.js +595 -0
  32. package/lib/print-queue.js +681 -0
  33. package/lib/product-qa.js +749 -0
  34. package/lib/promo-bundles.js +835 -0
  35. package/lib/push-notifications.js +937 -0
  36. package/lib/refund-automation.js +853 -0
  37. package/lib/reorder-reminders.js +798 -0
  38. package/lib/robots-config.js +753 -0
  39. package/lib/seller-signup.js +1052 -0
  40. package/lib/site-redirects.js +690 -0
  41. package/lib/sitemap-generator.js +717 -0
  42. package/lib/subscription-gifts.js +710 -0
  43. package/lib/tax-cert-renewals.js +632 -0
  44. package/lib/theme-assets.js +711 -0
  45. package/lib/tier-benefits.js +776 -0
  46. package/lib/vendor/MANIFEST.json +2 -2
  47. package/lib/vendor/blamejs/CHANGELOG.md +2 -0
  48. package/lib/vendor/blamejs/api-snapshot.json +2 -2
  49. package/lib/vendor/blamejs/lib/metrics.js +68 -4
  50. package/lib/vendor/blamejs/package.json +1 -1
  51. package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
  52. package/lib/wishlist-alerts.js +842 -0
  53. package/lib/wishlist-sharing.js +718 -0
  54. 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
+ };