@blamejs/blamejs-shop 0.0.72 → 0.0.76

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 (44) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/lib/announcement-bar.js +753 -0
  3. package/lib/banner-ab-tests.js +806 -0
  4. package/lib/bin-locations.js +791 -0
  5. package/lib/blog-articles.js +1173 -0
  6. package/lib/carrier-accounts.js +805 -0
  7. package/lib/cart-recovery.js +1133 -0
  8. package/lib/category-navigation.js +934 -0
  9. package/lib/consent-ledger.js +539 -0
  10. package/lib/customer-impersonation.js +743 -0
  11. package/lib/customer-merge.js +879 -0
  12. package/lib/demand-forecast.js +1121 -0
  13. package/lib/dispute-resolution.js +886 -0
  14. package/lib/email-ab-tests.js +918 -0
  15. package/lib/email-engagement-score.js +649 -0
  16. package/lib/event-log.js +713 -0
  17. package/lib/fulfillment-sla.js +791 -0
  18. package/lib/index.js +41 -0
  19. package/lib/inventory-audits.js +852 -0
  20. package/lib/line-gift-wrap.js +430 -0
  21. package/lib/marketing-budget.js +792 -0
  22. package/lib/operator-activity-feed.js +977 -0
  23. package/lib/operator-approvals.js +942 -0
  24. package/lib/operator-help-center.js +1020 -0
  25. package/lib/operator-inbox.js +889 -0
  26. package/lib/operator-sessions.js +701 -0
  27. package/lib/order-exchanges.js +602 -0
  28. package/lib/product-compare.js +804 -0
  29. package/lib/pwa-manifest.js +1005 -0
  30. package/lib/referral-leaderboard.js +612 -0
  31. package/lib/sales-tax-filings.js +807 -0
  32. package/lib/search-ranking.js +859 -0
  33. package/lib/shipping-insurance.js +757 -0
  34. package/lib/shrinkage-report.js +1182 -0
  35. package/lib/sidebar-widgets.js +952 -0
  36. package/lib/smart-restocking.js +1048 -0
  37. package/lib/stock-receipts.js +834 -0
  38. package/lib/subscription-analytics.js +1032 -0
  39. package/lib/suggestion-box.js +921 -0
  40. package/lib/tax-remittance.js +625 -0
  41. package/lib/vendor-invoices.js +1021 -0
  42. package/lib/winback-campaigns.js +1350 -0
  43. package/lib/wishlist-digest.js +1133 -0
  44. package/package.json +1 -1
@@ -0,0 +1,921 @@
1
+ "use strict";
2
+ /**
3
+ * @module shop.suggestionBox
4
+ * @title Suggestion box — customer-submitted product / feature ideas
5
+ *
6
+ * @intro
7
+ * The "users tell us what to build" loop. Customers submit a title
8
+ * + body via `submitSuggestion`, browse + up/downvote other
9
+ * submissions via `voteOnSuggestion`, and operators respond +
10
+ * transition the suggestion through the product-roadmap FSM via
11
+ * `respondToSuggestion`. Distinct from `customerSurveys` — that
12
+ * primitive is operator-driven (operator authors a survey, the
13
+ * application issues per-customer invitations); this one is
14
+ * customer-driven (customer authors the suggestion, operators
15
+ * curate the resulting backlog).
16
+ *
17
+ * The submitter's identity is captured optionally — either
18
+ * `customer_id` (an authenticated account), `customer_email` (a
19
+ * storefront visitor with a confirmed email), both, or neither
20
+ * (an anonymous walk-up). The email is never stored raw; the
21
+ * primitive hashes it through `b.crypto.namespaceHash` under the
22
+ * `suggestion-box-email` namespace before the row lands on disk
23
+ * so an operator who later pages through the table sees only the
24
+ * hash. The same treatment applies to vote session-ids under
25
+ * `suggestion-box-vote-session` — the UNIQUE on
26
+ * (suggestion_id, session_id_hash) dedupes a repeat-vote from the
27
+ * same browser session at the storage layer.
28
+ *
29
+ * Categories (operator-roadmap buckets):
30
+ * - product_idea — new product / SKU someone wants stocked
31
+ * - feature_request — new storefront / app capability
32
+ * - improvement — refinement of an existing surface
33
+ * - complaint — pain point the operator should address
34
+ * - general — anything else
35
+ *
36
+ * Status FSM (operator-driven via `respondToSuggestion`):
37
+ * - open — submitted, no response yet (entry)
38
+ * - under_consideration — triaged, operator evaluating
39
+ * - planned — committed to the roadmap
40
+ * - shipped — delivered (terminal)
41
+ * - declined — won't build (terminal)
42
+ * - duplicate — merged into another suggestion via
43
+ * `linkDuplicates`
44
+ *
45
+ * Valid transitions:
46
+ * open -> under_consideration | planned | shipped |
47
+ * declined | duplicate
48
+ * under_consideration -> planned | shipped | declined | duplicate
49
+ * planned -> shipped | declined | duplicate
50
+ * shipped — terminal
51
+ * declined — terminal
52
+ * duplicate — terminal
53
+ *
54
+ * `linkDuplicates({ suggestion_id, canonical_id })` marks the
55
+ * source suggestion as `duplicate`, points its `canonical_id` at
56
+ * the survivor, and migrates the source's net `vote_count` onto
57
+ * the canonical row. Individual vote rows stay on the source
58
+ * suggestion (so re-linking is reversible at the audit layer);
59
+ * the canonical row absorbs only the rolled-up score.
60
+ *
61
+ * `voteOnSuggestion` uses an INSERT-OR-IGNORE on the UNIQUE
62
+ * (suggestion_id, session_id_hash) — the first vote from a
63
+ * session is recorded and the denormalized counter bumps; a
64
+ * repeat vote (same session_id, same direction OR opposite
65
+ * direction) collapses to a no-op so the counter reflects
66
+ * distinct sessions rather than refresh-loop noise. The
67
+ * denormalized `vote_count` is the NET score: (#upvotes -
68
+ * #downvotes); the operator's roadmap ranking surfaces
69
+ * signal-minus-noise rather than gross engagement.
70
+ *
71
+ * `metricsForCategory({ category, from, to })` rolls up a closed
72
+ * time window: total submissions in window, per-status
73
+ * distribution, top-3 most-voted suggestions, mean vote count.
74
+ * Spam-flagged + archived rows are excluded.
75
+ *
76
+ * `flagAsSpam` and `archiveSuggestion` are operator-only
77
+ * tombstones — both hide the row from public-facing lists +
78
+ * metrics. `flagAsSpam` is reversible (un-flag); `archive` is
79
+ * not. Voting + responding against an archived suggestion
80
+ * refuses.
81
+ *
82
+ * Composes:
83
+ * - `b.crypto.namespaceHash` — email + session-id hashing
84
+ * - `b.guardEmail` — strict-profile validate + sanitize
85
+ * - `b.guardUuid` — UUID-shape sanitization
86
+ * - `b.uuid.v7` — row ids (suggestion + vote)
87
+ * - `b.pagination` — HMAC-tagged cursor for listSuggestions
88
+ *
89
+ * Monotonic clock: two writes in the same millisecond would tie
90
+ * on `created_at` / `updated_at` and make a sort-by-timestamp
91
+ * read ambiguous. `_now` bumps to `prior + 1` on collision so
92
+ * the (created_at DESC, id DESC) cursor + per-suggestion event
93
+ * timeline carry a strict per-process ordering.
94
+ *
95
+ * Surface:
96
+ * - submitSuggestion({ customer_id?, customer_email?, title, body, category })
97
+ * - getSuggestion(id)
98
+ * - listSuggestions({ category?, status?, sort?, cursor?, limit? })
99
+ * - voteOnSuggestion({ suggestion_id, session_id, vote })
100
+ * - respondToSuggestion({ suggestion_id, response, status, responder })
101
+ * - linkDuplicates({ suggestion_id, canonical_id })
102
+ * - metricsForCategory({ category, from, to })
103
+ * - archiveSuggestion(id)
104
+ * - flagAsSpam({ suggestion_id, flagged })
105
+ *
106
+ * Storage: `migrations-d1/0181_suggestion_box.sql` —
107
+ * `suggestions` + `suggestion_votes`.
108
+ *
109
+ * @primitive suggestionBox
110
+ * @related b.crypto, b.guardEmail, b.guardUuid, b.uuid, b.pagination,
111
+ * shop.customerSurveys
112
+ */
113
+
114
+ var bShop;
115
+ function _b() {
116
+ if (!bShop) bShop = require("./index");
117
+ return bShop.framework;
118
+ }
119
+
120
+ // ---- constants ----------------------------------------------------------
121
+
122
+ var EMAIL_NAMESPACE = "suggestion-box-email";
123
+ var SESSION_NAMESPACE = "suggestion-box-vote-session";
124
+
125
+ var CATEGORIES = Object.freeze([
126
+ "product_idea", "feature_request", "improvement", "complaint", "general",
127
+ ]);
128
+ var STATUSES = Object.freeze([
129
+ "open", "under_consideration", "planned", "shipped", "declined", "duplicate",
130
+ ]);
131
+ var TERMINAL_STATUSES = Object.freeze(["shipped", "declined", "duplicate"]);
132
+ var VOTES = Object.freeze(["upvote", "downvote"]);
133
+ var SORTS = Object.freeze(["newest", "top_voted", "most_discussed"]);
134
+
135
+ // FSM transitions. Source status -> set of allowed destination
136
+ // statuses. A `respondToSuggestion` whose target isn't in the set
137
+ // refuses with a SUGGESTION_INVALID_TRANSITION error. Note: the FSM
138
+ // allows `respondToSuggestion` to set `duplicate` directly so an
139
+ // operator who knows the canonical id at triage time can both
140
+ // respond + link in two calls; in practice most operators reach
141
+ // duplicate via `linkDuplicates` (which sets the status + canonical
142
+ // id atomically).
143
+ var ALLOWED_TRANSITIONS = Object.freeze({
144
+ open: ["under_consideration", "planned", "shipped", "declined", "duplicate"],
145
+ under_consideration: ["planned", "shipped", "declined", "duplicate"],
146
+ planned: ["shipped", "declined", "duplicate"],
147
+ shipped: [],
148
+ declined: [],
149
+ duplicate: [],
150
+ });
151
+
152
+ var MAX_TITLE_LEN = 200;
153
+ var MAX_BODY_LEN = 5000;
154
+ var MAX_RESPONSE_LEN = 5000;
155
+ var MAX_RESPONDER_LEN = 200;
156
+
157
+ var DEFAULT_LIST_LIMIT = 25;
158
+ var MAX_LIST_LIMIT = 100;
159
+
160
+ var DEFAULT_SORT = "newest";
161
+
162
+ // Cursor order keys per sort mode. Cursor encodes (sort key value,
163
+ // id) so the per-page predicate is total-ordered.
164
+ var ORDER_KEY_NEWEST = ["created_at:desc", "id:desc"];
165
+ var ORDER_KEY_TOP_VOTED = ["vote_count:desc", "id:desc"];
166
+ var ORDER_KEY_MOST_DISCUSSED = ["comment_count:desc", "id:desc"];
167
+
168
+ var CONTROL_BYTE_BODY_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
169
+ var CONTROL_BYTE_STRICT_RE = /[\x00-\x1f\x7f]/;
170
+ var ZERO_WIDTH_RE = new RegExp(
171
+ "[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
172
+ );
173
+
174
+ // ---- monotonic clock ----------------------------------------------------
175
+ //
176
+ // Submissions + votes + status transitions persist epoch-ms
177
+ // timestamps. Two writes in the same millisecond would tie on
178
+ // `created_at` / `updated_at`, making sort-by-timestamp reads
179
+ // ambiguous and corrupting cursor pagination. Bumping by 1ms on
180
+ // collision keeps the per-process timeline strictly increasing so
181
+ // the (created_at DESC, id DESC) cursor predicate stays total-
182
+ // ordered without an extra tiebreaker column.
183
+
184
+ var _lastTs = 0;
185
+ function _now() {
186
+ var t = Date.now();
187
+ if (t <= _lastTs) t = _lastTs + 1;
188
+ _lastTs = t;
189
+ return t;
190
+ }
191
+
192
+ // ---- validators ---------------------------------------------------------
193
+
194
+ function _title(s) {
195
+ if (typeof s !== "string") {
196
+ throw new TypeError("suggestionBox: title must be a string");
197
+ }
198
+ var trimmed = s.trim();
199
+ if (!trimmed.length) {
200
+ throw new TypeError("suggestionBox: title must be non-empty after trim");
201
+ }
202
+ if (s.length > MAX_TITLE_LEN) {
203
+ throw new TypeError("suggestionBox: title must be <= " + MAX_TITLE_LEN + " characters");
204
+ }
205
+ if (CONTROL_BYTE_STRICT_RE.test(s)) {
206
+ throw new TypeError("suggestionBox: title must not contain control bytes");
207
+ }
208
+ if (ZERO_WIDTH_RE.test(s)) {
209
+ throw new TypeError("suggestionBox: title contains zero-width / direction-override characters");
210
+ }
211
+ return s;
212
+ }
213
+
214
+ function _body(s) {
215
+ if (typeof s !== "string") {
216
+ throw new TypeError("suggestionBox: body must be a string");
217
+ }
218
+ var trimmed = s.trim();
219
+ if (!trimmed.length) {
220
+ throw new TypeError("suggestionBox: body must be non-empty after trim");
221
+ }
222
+ if (s.length > MAX_BODY_LEN) {
223
+ throw new TypeError("suggestionBox: body must be <= " + MAX_BODY_LEN + " characters");
224
+ }
225
+ if (CONTROL_BYTE_BODY_RE.test(s)) {
226
+ throw new TypeError("suggestionBox: body must not contain control bytes");
227
+ }
228
+ if (ZERO_WIDTH_RE.test(s)) {
229
+ throw new TypeError("suggestionBox: body contains zero-width / direction-override characters");
230
+ }
231
+ return s;
232
+ }
233
+
234
+ function _category(s) {
235
+ if (typeof s !== "string" || CATEGORIES.indexOf(s) < 0) {
236
+ throw new TypeError("suggestionBox: category must be one of " + CATEGORIES.join(", "));
237
+ }
238
+ return s;
239
+ }
240
+
241
+ function _status(s, label) {
242
+ if (typeof s !== "string" || STATUSES.indexOf(s) < 0) {
243
+ throw new TypeError("suggestionBox: " + (label || "status") +
244
+ " must be one of " + STATUSES.join(", "));
245
+ }
246
+ return s;
247
+ }
248
+
249
+ function _vote(s) {
250
+ if (typeof s !== "string" || VOTES.indexOf(s) < 0) {
251
+ throw new TypeError("suggestionBox: vote must be one of " + VOTES.join(", "));
252
+ }
253
+ return s;
254
+ }
255
+
256
+ function _sort(s) {
257
+ if (s == null) return DEFAULT_SORT;
258
+ if (typeof s !== "string" || SORTS.indexOf(s) < 0) {
259
+ throw new TypeError("suggestionBox: sort must be one of " + SORTS.join(", "));
260
+ }
261
+ return s;
262
+ }
263
+
264
+ function _uuid(s, label) {
265
+ try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
266
+ catch (e) { throw new TypeError("suggestionBox: " + label + " — " + (e && e.message || "invalid UUID")); }
267
+ }
268
+
269
+ function _customerIdOpt(s) {
270
+ if (s == null) return null;
271
+ return _uuid(s, "customer_id");
272
+ }
273
+
274
+ function _emailOpt(input) {
275
+ if (input == null) return null;
276
+ if (typeof input !== "string" || !input.length) {
277
+ throw new TypeError("suggestionBox: customer_email must be a non-empty string when provided");
278
+ }
279
+ var guardEmail = _b().guardEmail;
280
+ var report;
281
+ try {
282
+ report = guardEmail.validate(input, { profile: "strict" });
283
+ } catch (e) {
284
+ throw new TypeError("suggestionBox: customer_email — " + (e && e.message || "invalid email"));
285
+ }
286
+ if (!report || report.ok === false) {
287
+ var first = (report && report.issues && report.issues[0]) || {};
288
+ throw new TypeError("suggestionBox: customer_email — " +
289
+ (first.snippet || first.ruleId || "refused at strict profile"));
290
+ }
291
+ var canonical;
292
+ try {
293
+ canonical = guardEmail.sanitize(input, { profile: "strict" });
294
+ } catch (e2) {
295
+ throw new TypeError("suggestionBox: customer_email — " + (e2 && e2.message || "refused"));
296
+ }
297
+ return canonical.trim().toLowerCase();
298
+ }
299
+
300
+ function _sessionIdRaw(s) {
301
+ if (typeof s !== "string" || !s.length) {
302
+ throw new TypeError("suggestionBox: session_id must be a non-empty string");
303
+ }
304
+ if (s.length > 256) {
305
+ throw new TypeError("suggestionBox: session_id must be <= 256 characters");
306
+ }
307
+ if (CONTROL_BYTE_STRICT_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
308
+ throw new TypeError("suggestionBox: session_id contains control / zero-width bytes");
309
+ }
310
+ return s;
311
+ }
312
+
313
+ function _response(s) {
314
+ if (typeof s !== "string") {
315
+ throw new TypeError("suggestionBox: response must be a string");
316
+ }
317
+ if (s.length > MAX_RESPONSE_LEN) {
318
+ throw new TypeError("suggestionBox: response must be <= " + MAX_RESPONSE_LEN + " characters");
319
+ }
320
+ if (CONTROL_BYTE_BODY_RE.test(s)) {
321
+ throw new TypeError("suggestionBox: response must not contain control bytes");
322
+ }
323
+ if (ZERO_WIDTH_RE.test(s)) {
324
+ throw new TypeError("suggestionBox: response contains zero-width / direction-override characters");
325
+ }
326
+ // A status-only transition is allowed with response === "" (no
327
+ // operator-visible reply, just a state change). Trim-then-check
328
+ // for the operator-supplied case below in respondToSuggestion.
329
+ return s;
330
+ }
331
+
332
+ function _responder(s) {
333
+ if (typeof s !== "string" || !s.length) {
334
+ throw new TypeError("suggestionBox: responder must be a non-empty string");
335
+ }
336
+ if (s.length > MAX_RESPONDER_LEN) {
337
+ throw new TypeError("suggestionBox: responder must be <= " + MAX_RESPONDER_LEN + " characters");
338
+ }
339
+ if (CONTROL_BYTE_STRICT_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
340
+ throw new TypeError("suggestionBox: responder contains control / zero-width characters");
341
+ }
342
+ return s;
343
+ }
344
+
345
+ function _limit(n) {
346
+ if (n == null) return DEFAULT_LIST_LIMIT;
347
+ if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
348
+ throw new TypeError("suggestionBox: limit must be an integer in [1, " + MAX_LIST_LIMIT + "]");
349
+ }
350
+ return n;
351
+ }
352
+
353
+ function _epoch(n, label) {
354
+ if (!Number.isInteger(n) || n < 0) {
355
+ throw new TypeError("suggestionBox: " + label + " must be a non-negative integer (ms epoch)");
356
+ }
357
+ return n;
358
+ }
359
+
360
+ function _timestampRange(from, to, label) {
361
+ _epoch(from, label + ".from");
362
+ _epoch(to, label + ".to");
363
+ if (from > to) {
364
+ throw new TypeError("suggestionBox." + label + ": from must be <= to");
365
+ }
366
+ }
367
+
368
+ function _flag(v, label) {
369
+ if (typeof v !== "boolean") {
370
+ throw new TypeError("suggestionBox: " + label + " must be a boolean");
371
+ }
372
+ return v;
373
+ }
374
+
375
+ // ---- factory ------------------------------------------------------------
376
+
377
+ function create(opts) {
378
+ opts = opts || {};
379
+ var query = opts.query;
380
+ if (!query) {
381
+ query = function (sql, params) { return _b().externalDb.query(sql, params); };
382
+ }
383
+
384
+ // Pagination cursors are HMAC-tagged via b.pagination so a caller
385
+ // can't hand-craft one to skip across suggestions or replay
386
+ // across deployments. The secret defaults to a dev-only
387
+ // placeholder so the primitive boots in tests; production
388
+ // deployments must supply a derived value (typically
389
+ // b.crypto.namespaceHash("suggestion-box-cursor", D1_BRIDGE_SECRET)).
390
+ if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
391
+ if (process.env.NODE_ENV === "production") {
392
+ throw new Error("suggestionBox.create: opts.cursorSecret is required in production");
393
+ }
394
+ opts.cursorSecret = "suggestion-box-cursor-secret-dev-only";
395
+ }
396
+ var cursorSecret = opts.cursorSecret;
397
+
398
+ function _orderKeyFor(sort) {
399
+ if (sort === "top_voted") return ORDER_KEY_TOP_VOTED;
400
+ if (sort === "most_discussed") return ORDER_KEY_MOST_DISCUSSED;
401
+ return ORDER_KEY_NEWEST;
402
+ }
403
+
404
+ function _decodeCursor(cursor, sort, label) {
405
+ if (cursor == null) return null;
406
+ if (typeof cursor !== "string") {
407
+ throw new TypeError("suggestionBox." + label + ": cursor must be an opaque string or null");
408
+ }
409
+ var expected = _orderKeyFor(sort);
410
+ try {
411
+ var state = _b().pagination.decodeCursor(cursor, cursorSecret);
412
+ if (JSON.stringify(state.orderKey) !== JSON.stringify(expected)) {
413
+ throw new TypeError("suggestionBox." + label + ": cursor orderKey mismatch — sort changed since the previous page");
414
+ }
415
+ return state.vals;
416
+ } catch (e) {
417
+ if (e instanceof TypeError) throw e;
418
+ throw new TypeError("suggestionBox." + label + ": cursor — " + (e && e.message || "malformed"));
419
+ }
420
+ }
421
+
422
+ function _encodeNext(rows, limit, sort) {
423
+ var last = rows[rows.length - 1];
424
+ if (!last || rows.length < limit) return null;
425
+ var primary;
426
+ if (sort === "top_voted") primary = Number(last.vote_count);
427
+ else if (sort === "most_discussed") primary = Number(last.comment_count);
428
+ else primary = Number(last.created_at);
429
+ return _b().pagination.encodeCursor({
430
+ orderKey: _orderKeyFor(sort),
431
+ vals: [primary, last.id],
432
+ forward: true,
433
+ }, cursorSecret);
434
+ }
435
+
436
+ function _hashEmail(canonical) {
437
+ return _b().crypto.namespaceHash(EMAIL_NAMESPACE, canonical);
438
+ }
439
+ function _hashSession(raw) {
440
+ return _b().crypto.namespaceHash(SESSION_NAMESPACE, raw);
441
+ }
442
+
443
+ async function _getRaw(id) {
444
+ var r = await query("SELECT * FROM suggestions WHERE id = ?1", [id]);
445
+ return r.rows[0] || null;
446
+ }
447
+
448
+ function _decode(row) {
449
+ if (!row) return null;
450
+ return {
451
+ id: row.id,
452
+ customer_id: row.customer_id,
453
+ customer_email_hash: row.customer_email_hash,
454
+ title: row.title,
455
+ body: row.body,
456
+ category: row.category,
457
+ status: row.status,
458
+ vote_count: Number(row.vote_count) || 0,
459
+ comment_count: Number(row.comment_count) || 0,
460
+ response_text: row.response_text,
461
+ response_by: row.response_by,
462
+ responded_at: row.responded_at != null ? Number(row.responded_at) : null,
463
+ canonical_id: row.canonical_id,
464
+ spam_flagged: Number(row.spam_flagged) === 1,
465
+ archived_at: row.archived_at != null ? Number(row.archived_at) : null,
466
+ created_at: Number(row.created_at),
467
+ updated_at: Number(row.updated_at),
468
+ };
469
+ }
470
+
471
+ // ---- submitSuggestion -------------------------------------------------
472
+
473
+ async function submitSuggestion(input) {
474
+ if (!input || typeof input !== "object") {
475
+ throw new TypeError("suggestionBox.submitSuggestion: input object required");
476
+ }
477
+ var title = _title(input.title);
478
+ var body = _body(input.body);
479
+ var category = _category(input.category);
480
+ var custId = _customerIdOpt(input.customer_id);
481
+ var email = _emailOpt(input.customer_email);
482
+ var emailHash = email != null ? _hashEmail(email) : null;
483
+
484
+ var id = _b().uuid.v7();
485
+ var ts = _now();
486
+
487
+ await query(
488
+ "INSERT INTO suggestions " +
489
+ "(id, customer_id, customer_email_hash, title, body, category, status, " +
490
+ " vote_count, comment_count, response_text, response_by, responded_at, " +
491
+ " canonical_id, spam_flagged, archived_at, created_at, updated_at) " +
492
+ "VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'open', 0, 0, NULL, NULL, NULL, NULL, 0, NULL, ?7, ?7)",
493
+ [id, custId, emailHash, title, body, category, ts],
494
+ );
495
+
496
+ return _decode(await _getRaw(id));
497
+ }
498
+
499
+ // ---- getSuggestion ----------------------------------------------------
500
+
501
+ async function getSuggestion(id) {
502
+ var sid = _uuid(id, "id");
503
+ return _decode(await _getRaw(sid));
504
+ }
505
+
506
+ // ---- listSuggestions --------------------------------------------------
507
+ //
508
+ // Cursor pagination over a single sort key. Spam-flagged and
509
+ // archived rows are excluded from the public list — the
510
+ // operator-only metrics rollup excludes them too.
511
+
512
+ async function listSuggestions(input) {
513
+ input = input || {};
514
+ var category = input.category != null ? _category(input.category) : null;
515
+ var status = input.status != null ? _status(input.status, "status") : null;
516
+ var sort = _sort(input.sort);
517
+ var limit = _limit(input.limit);
518
+ var cursor = _decodeCursor(input.cursor, sort, "listSuggestions");
519
+
520
+ var sql = "SELECT * FROM suggestions WHERE spam_flagged = 0 AND archived_at IS NULL";
521
+ var params = [];
522
+ var idx = 1;
523
+ if (category != null) { sql += " AND category = ?" + idx; params.push(category); idx += 1; }
524
+ if (status != null) { sql += " AND status = ?" + idx; params.push(status); idx += 1; }
525
+
526
+ if (cursor != null) {
527
+ // Cursor is [primarySortValue, id] — predicate is
528
+ // (primary < cursor[0]) OR (primary = cursor[0] AND id < cursor[1])
529
+ // for a DESC, DESC ordering. The vote_count + comment_count
530
+ // case carries the same shape.
531
+ var primaryCol;
532
+ if (sort === "top_voted") primaryCol = "vote_count";
533
+ else if (sort === "most_discussed") primaryCol = "comment_count";
534
+ else primaryCol = "created_at";
535
+ sql += " AND (" + primaryCol + " < ?" + idx +
536
+ " OR (" + primaryCol + " = ?" + idx + " AND id < ?" + (idx + 1) + "))";
537
+ params.push(cursor[0]);
538
+ params.push(cursor[1]);
539
+ idx += 2;
540
+ }
541
+
542
+ var orderCol;
543
+ if (sort === "top_voted") orderCol = "vote_count";
544
+ else if (sort === "most_discussed") orderCol = "comment_count";
545
+ else orderCol = "created_at";
546
+ sql += " ORDER BY " + orderCol + " DESC, id DESC LIMIT ?" + idx;
547
+ params.push(limit);
548
+
549
+ var r = await query(sql, params);
550
+ var out = [];
551
+ for (var i = 0; i < r.rows.length; i += 1) out.push(_decode(r.rows[i]));
552
+ return { rows: out, next_cursor: _encodeNext(r.rows, limit, sort), sort: sort };
553
+ }
554
+
555
+ // ---- voteOnSuggestion -------------------------------------------------
556
+ //
557
+ // Dedupe at (suggestion_id, session_id_hash) via the UNIQUE
558
+ // constraint — a repeat vote from the same session collapses to
559
+ // a no-op. The denormalized `vote_count` on suggestions is the
560
+ // NET score (#upvotes - #downvotes), so an upvote bumps +1, a
561
+ // downvote bumps -1, and a duplicate from the same session is a
562
+ // 0 delta.
563
+
564
+ async function voteOnSuggestion(input) {
565
+ if (!input || typeof input !== "object") {
566
+ throw new TypeError("suggestionBox.voteOnSuggestion: input object required");
567
+ }
568
+ var sid = _uuid(input.suggestion_id, "suggestion_id");
569
+ var session = _sessionIdRaw(input.session_id);
570
+ var vote = _vote(input.vote);
571
+
572
+ var existing = await _getRaw(sid);
573
+ if (!existing) {
574
+ var notFound = new Error("suggestionBox.voteOnSuggestion: suggestion not found");
575
+ notFound.code = "SUGGESTION_NOT_FOUND";
576
+ throw notFound;
577
+ }
578
+ if (existing.archived_at != null) {
579
+ var arch = new Error("suggestionBox.voteOnSuggestion: suggestion is archived");
580
+ arch.code = "SUGGESTION_ARCHIVED";
581
+ throw arch;
582
+ }
583
+ if (TERMINAL_STATUSES.indexOf(existing.status) >= 0) {
584
+ // Terminal statuses freeze the vote count — operators don't
585
+ // want a shipped feature to keep accumulating votes that
586
+ // influence ranking. Surfaces a distinct error so the client
587
+ // can render "voting closed" rather than a generic refusal.
588
+ var term = new Error("suggestionBox.voteOnSuggestion: suggestion is in terminal status " + existing.status);
589
+ term.code = "SUGGESTION_VOTING_CLOSED";
590
+ throw term;
591
+ }
592
+
593
+ var sessionHash = _hashSession(session);
594
+ var ts = _now();
595
+ var voteId = _b().uuid.v7();
596
+
597
+ var ins = await query(
598
+ "INSERT OR IGNORE INTO suggestion_votes (id, suggestion_id, session_id_hash, vote, occurred_at) " +
599
+ "VALUES (?1, ?2, ?3, ?4, ?5)",
600
+ [voteId, sid, sessionHash, vote, ts],
601
+ );
602
+ var changed = ins && (Number(ins.rowCount) === 1 || Number(ins.changes) === 1);
603
+ if (!changed) {
604
+ // Existing vote — surface "already voted" so the client can
605
+ // render a UI confirmation rather than silently no-op.
606
+ return {
607
+ suggestion_id: sid,
608
+ vote: vote,
609
+ recorded: false,
610
+ vote_count: Number(existing.vote_count) || 0,
611
+ };
612
+ }
613
+
614
+ var delta = vote === "upvote" ? 1 : -1;
615
+ await query(
616
+ "UPDATE suggestions SET vote_count = vote_count + ?1, updated_at = ?2 WHERE id = ?3",
617
+ [delta, ts, sid],
618
+ );
619
+ var after = await _getRaw(sid);
620
+ return {
621
+ suggestion_id: sid,
622
+ vote: vote,
623
+ recorded: true,
624
+ vote_count: Number(after.vote_count) || 0,
625
+ };
626
+ }
627
+
628
+ // ---- respondToSuggestion ----------------------------------------------
629
+ //
630
+ // FSM-guarded operator response. Validates the destination status
631
+ // against ALLOWED_TRANSITIONS, refuses on terminal source, refuses
632
+ // on archived suggestion. Sets response_text + response_by +
633
+ // responded_at atomically with the status transition. Setting
634
+ // status = 'duplicate' here is allowed but does NOT set
635
+ // canonical_id — operators wanting both should call
636
+ // linkDuplicates instead, which sets both atomically. A
637
+ // respondToSuggestion that lands status = 'duplicate' refuses
638
+ // because the schema CHECK requires canonical_id IS NOT NULL on
639
+ // duplicate rows.
640
+
641
+ async function respondToSuggestion(input) {
642
+ if (!input || typeof input !== "object") {
643
+ throw new TypeError("suggestionBox.respondToSuggestion: input object required");
644
+ }
645
+ var sid = _uuid(input.suggestion_id, "suggestion_id");
646
+ var response = _response(input.response);
647
+ var nextStat = _status(input.status, "status");
648
+ var responder = _responder(input.responder);
649
+
650
+ if (nextStat === "duplicate") {
651
+ throw new TypeError("suggestionBox.respondToSuggestion: use linkDuplicates to set status='duplicate' (canonical_id required)");
652
+ }
653
+ if (nextStat === "open") {
654
+ throw new TypeError("suggestionBox.respondToSuggestion: status='open' is the entry state and not a valid response transition");
655
+ }
656
+
657
+ var existing = await _getRaw(sid);
658
+ if (!existing) {
659
+ var notFound = new Error("suggestionBox.respondToSuggestion: suggestion not found");
660
+ notFound.code = "SUGGESTION_NOT_FOUND";
661
+ throw notFound;
662
+ }
663
+ if (existing.archived_at != null) {
664
+ var arch = new Error("suggestionBox.respondToSuggestion: suggestion is archived");
665
+ arch.code = "SUGGESTION_ARCHIVED";
666
+ throw arch;
667
+ }
668
+ var allowed = ALLOWED_TRANSITIONS[existing.status] || [];
669
+ if (allowed.indexOf(nextStat) < 0) {
670
+ var bad = new Error("suggestionBox.respondToSuggestion: invalid transition " +
671
+ existing.status + " -> " + nextStat);
672
+ bad.code = "SUGGESTION_INVALID_TRANSITION";
673
+ throw bad;
674
+ }
675
+
676
+ var ts = _now();
677
+ // response_text is stored only when the operator supplied a
678
+ // non-empty value after trim; an empty-string response means
679
+ // "transition the status without leaving a public-visible
680
+ // reply" (the responder + timestamp still land for audit).
681
+ var responseText = response.trim().length > 0 ? response : null;
682
+ var commentDelta = responseText != null ? 1 : 0;
683
+
684
+ await query(
685
+ "UPDATE suggestions SET " +
686
+ " status = ?1, response_text = ?2, response_by = ?3, responded_at = ?4, " +
687
+ " comment_count = comment_count + ?5, updated_at = ?4 " +
688
+ "WHERE id = ?6",
689
+ [nextStat, responseText, responder, ts, commentDelta, sid],
690
+ );
691
+ return _decode(await _getRaw(sid));
692
+ }
693
+
694
+ // ---- linkDuplicates ---------------------------------------------------
695
+ //
696
+ // Mark `suggestion_id` as a duplicate of `canonical_id`. Sets
697
+ // status = 'duplicate' and canonical_id atomically (the schema
698
+ // CHECK enforces the pair). Migrates the source's net vote_count
699
+ // onto the canonical row so the operator's roadmap ranking
700
+ // reflects the combined demand signal. Individual vote rows stay
701
+ // on the source so the merge is reversible at the audit layer.
702
+
703
+ async function linkDuplicates(input) {
704
+ if (!input || typeof input !== "object") {
705
+ throw new TypeError("suggestionBox.linkDuplicates: input object required");
706
+ }
707
+ var sid = _uuid(input.suggestion_id, "suggestion_id");
708
+ var cid = _uuid(input.canonical_id, "canonical_id");
709
+ if (sid === cid) {
710
+ throw new TypeError("suggestionBox.linkDuplicates: suggestion_id and canonical_id must differ");
711
+ }
712
+
713
+ var src = await _getRaw(sid);
714
+ if (!src) {
715
+ var notFoundSrc = new Error("suggestionBox.linkDuplicates: source suggestion not found");
716
+ notFoundSrc.code = "SUGGESTION_NOT_FOUND";
717
+ throw notFoundSrc;
718
+ }
719
+ var canonical = await _getRaw(cid);
720
+ if (!canonical) {
721
+ var notFoundDst = new Error("suggestionBox.linkDuplicates: canonical suggestion not found");
722
+ notFoundDst.code = "SUGGESTION_NOT_FOUND";
723
+ throw notFoundDst;
724
+ }
725
+ if (src.archived_at != null || canonical.archived_at != null) {
726
+ var arch = new Error("suggestionBox.linkDuplicates: cannot link archived suggestions");
727
+ arch.code = "SUGGESTION_ARCHIVED";
728
+ throw arch;
729
+ }
730
+ if (src.status === "duplicate") {
731
+ var alreadyDup = new Error("suggestionBox.linkDuplicates: source already marked duplicate");
732
+ alreadyDup.code = "SUGGESTION_ALREADY_DUPLICATE";
733
+ throw alreadyDup;
734
+ }
735
+ if (canonical.status === "duplicate") {
736
+ // Refuse to link onto a chain — the canonical of a
737
+ // duplicate is itself a duplicate, which would build a
738
+ // pointer chain that any consumer has to walk. Operator
739
+ // should resolve to the deepest canonical first.
740
+ var chain = new Error("suggestionBox.linkDuplicates: canonical is itself marked duplicate — resolve to the deepest canonical");
741
+ chain.code = "SUGGESTION_DUPLICATE_CHAIN";
742
+ throw chain;
743
+ }
744
+ var allowed = ALLOWED_TRANSITIONS[src.status] || [];
745
+ if (allowed.indexOf("duplicate") < 0) {
746
+ var bad = new Error("suggestionBox.linkDuplicates: invalid transition " + src.status + " -> duplicate");
747
+ bad.code = "SUGGESTION_INVALID_TRANSITION";
748
+ throw bad;
749
+ }
750
+
751
+ var ts = _now();
752
+ var srcVotes = Number(src.vote_count) || 0;
753
+
754
+ await query(
755
+ "UPDATE suggestions SET status = 'duplicate', canonical_id = ?1, " +
756
+ "vote_count = 0, updated_at = ?2 WHERE id = ?3",
757
+ [cid, ts, sid],
758
+ );
759
+ if (srcVotes !== 0) {
760
+ await query(
761
+ "UPDATE suggestions SET vote_count = vote_count + ?1, updated_at = ?2 WHERE id = ?3",
762
+ [srcVotes, ts, cid],
763
+ );
764
+ }
765
+ return {
766
+ suggestion_id: sid,
767
+ canonical_id: cid,
768
+ migrated_votes: srcVotes,
769
+ source: _decode(await _getRaw(sid)),
770
+ canonical: _decode(await _getRaw(cid)),
771
+ };
772
+ }
773
+
774
+ // ---- metricsForCategory -----------------------------------------------
775
+ //
776
+ // Closed time window. Submissions created_at in [from, to]
777
+ // bucket by status, top-3 most-voted in window, mean vote count.
778
+ // Spam-flagged + archived rows are excluded so the rollup
779
+ // reflects only the operator's curated backlog.
780
+
781
+ async function metricsForCategory(input) {
782
+ if (!input || typeof input !== "object") {
783
+ throw new TypeError("suggestionBox.metricsForCategory: input object required");
784
+ }
785
+ var category = _category(input.category);
786
+ var from = input.from;
787
+ var to = input.to;
788
+ _timestampRange(from, to, "metricsForCategory");
789
+
790
+ var rowsResult = await query(
791
+ "SELECT id, status, vote_count, title, created_at FROM suggestions " +
792
+ "WHERE category = ?1 AND created_at >= ?2 AND created_at <= ?3 " +
793
+ " AND spam_flagged = 0 AND archived_at IS NULL " +
794
+ "ORDER BY created_at DESC, id DESC",
795
+ [category, from, to],
796
+ );
797
+
798
+ var perStatus = Object.create(null);
799
+ for (var s = 0; s < STATUSES.length; s += 1) perStatus[STATUSES[s]] = 0;
800
+
801
+ var voteSum = 0;
802
+ var total = rowsResult.rows.length;
803
+ for (var i = 0; i < rowsResult.rows.length; i += 1) {
804
+ var r = rowsResult.rows[i];
805
+ var st = r.status;
806
+ perStatus[st] = (perStatus[st] || 0) + 1;
807
+ voteSum += Number(r.vote_count) || 0;
808
+ }
809
+
810
+ // Top-3 by net vote_count (DESC). Ties broken by created_at DESC.
811
+ var sorted = rowsResult.rows.slice().sort(function (a, b) {
812
+ var av = Number(a.vote_count) || 0;
813
+ var bv = Number(b.vote_count) || 0;
814
+ if (av !== bv) return bv - av;
815
+ return Number(b.created_at) - Number(a.created_at);
816
+ });
817
+ var top = [];
818
+ for (var t = 0; t < Math.min(3, sorted.length); t += 1) {
819
+ top.push({
820
+ id: sorted[t].id,
821
+ title: sorted[t].title,
822
+ vote_count: Number(sorted[t].vote_count) || 0,
823
+ });
824
+ }
825
+
826
+ return {
827
+ category: category,
828
+ from: from,
829
+ to: to,
830
+ total: total,
831
+ per_status: perStatus,
832
+ mean_votes: total === 0 ? 0 : Math.round((voteSum / total) * 100) / 100,
833
+ top_voted: top,
834
+ };
835
+ }
836
+
837
+ // ---- archiveSuggestion ------------------------------------------------
838
+ //
839
+ // Soft-delete. Once archived, votes / responses / duplicate-
840
+ // linking against the row refuse. archiveSuggestion on an
841
+ // already-archived row is a no-op (returns the existing row);
842
+ // archiveSuggestion on an unknown id returns null.
843
+
844
+ async function archiveSuggestion(id) {
845
+ var sid = _uuid(id, "id");
846
+ var existing = await _getRaw(sid);
847
+ if (!existing) return null;
848
+ if (existing.archived_at != null) return _decode(existing);
849
+ var ts = _now();
850
+ await query(
851
+ "UPDATE suggestions SET archived_at = ?1, updated_at = ?1 WHERE id = ?2",
852
+ [ts, sid],
853
+ );
854
+ return _decode(await _getRaw(sid));
855
+ }
856
+
857
+ // ---- flagAsSpam -------------------------------------------------------
858
+ //
859
+ // Operator-only. Sets spam_flagged = 1 (or back to 0 if the
860
+ // operator supplies `flagged: false`). Spam-flagged rows are
861
+ // hidden from listSuggestions + metricsForCategory but stay in
862
+ // the table so an un-flag restores them in place.
863
+
864
+ async function flagAsSpam(input) {
865
+ if (!input || typeof input !== "object") {
866
+ throw new TypeError("suggestionBox.flagAsSpam: input object required");
867
+ }
868
+ var sid = _uuid(input.suggestion_id, "suggestion_id");
869
+ var flagged = input.flagged == null ? true : _flag(input.flagged, "flagged");
870
+
871
+ var existing = await _getRaw(sid);
872
+ if (!existing) return null;
873
+ var ts = _now();
874
+ await query(
875
+ "UPDATE suggestions SET spam_flagged = ?1, updated_at = ?2 WHERE id = ?3",
876
+ [flagged ? 1 : 0, ts, sid],
877
+ );
878
+ return _decode(await _getRaw(sid));
879
+ }
880
+
881
+ return {
882
+ CATEGORIES: CATEGORIES.slice(),
883
+ STATUSES: STATUSES.slice(),
884
+ TERMINAL_STATUSES: TERMINAL_STATUSES.slice(),
885
+ VOTES: VOTES.slice(),
886
+ SORTS: SORTS.slice(),
887
+ ALLOWED_TRANSITIONS: JSON.parse(JSON.stringify(ALLOWED_TRANSITIONS)),
888
+ MAX_TITLE_LEN: MAX_TITLE_LEN,
889
+ MAX_BODY_LEN: MAX_BODY_LEN,
890
+ MAX_RESPONSE_LEN: MAX_RESPONSE_LEN,
891
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
892
+ EMAIL_NAMESPACE: EMAIL_NAMESPACE,
893
+ SESSION_NAMESPACE: SESSION_NAMESPACE,
894
+
895
+ submitSuggestion: submitSuggestion,
896
+ getSuggestion: getSuggestion,
897
+ listSuggestions: listSuggestions,
898
+ voteOnSuggestion: voteOnSuggestion,
899
+ respondToSuggestion: respondToSuggestion,
900
+ linkDuplicates: linkDuplicates,
901
+ metricsForCategory: metricsForCategory,
902
+ archiveSuggestion: archiveSuggestion,
903
+ flagAsSpam: flagAsSpam,
904
+ };
905
+ }
906
+
907
+ module.exports = {
908
+ create: create,
909
+ CATEGORIES: CATEGORIES,
910
+ STATUSES: STATUSES,
911
+ TERMINAL_STATUSES: TERMINAL_STATUSES,
912
+ VOTES: VOTES,
913
+ SORTS: SORTS,
914
+ ALLOWED_TRANSITIONS: ALLOWED_TRANSITIONS,
915
+ MAX_TITLE_LEN: MAX_TITLE_LEN,
916
+ MAX_BODY_LEN: MAX_BODY_LEN,
917
+ MAX_RESPONSE_LEN: MAX_RESPONSE_LEN,
918
+ MAX_LIST_LIMIT: MAX_LIST_LIMIT,
919
+ EMAIL_NAMESPACE: EMAIL_NAMESPACE,
920
+ SESSION_NAMESPACE: SESSION_NAMESPACE,
921
+ };