@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.
- package/CHANGELOG.md +8 -0
- package/lib/announcement-bar.js +753 -0
- package/lib/banner-ab-tests.js +806 -0
- package/lib/bin-locations.js +791 -0
- package/lib/blog-articles.js +1173 -0
- package/lib/carrier-accounts.js +805 -0
- package/lib/cart-recovery.js +1133 -0
- package/lib/category-navigation.js +934 -0
- package/lib/consent-ledger.js +539 -0
- package/lib/customer-impersonation.js +743 -0
- package/lib/customer-merge.js +879 -0
- package/lib/demand-forecast.js +1121 -0
- package/lib/dispute-resolution.js +886 -0
- package/lib/email-ab-tests.js +918 -0
- package/lib/email-engagement-score.js +649 -0
- package/lib/event-log.js +713 -0
- package/lib/fulfillment-sla.js +791 -0
- package/lib/index.js +41 -0
- package/lib/inventory-audits.js +852 -0
- package/lib/line-gift-wrap.js +430 -0
- package/lib/marketing-budget.js +792 -0
- package/lib/operator-activity-feed.js +977 -0
- package/lib/operator-approvals.js +942 -0
- package/lib/operator-help-center.js +1020 -0
- package/lib/operator-inbox.js +889 -0
- package/lib/operator-sessions.js +701 -0
- package/lib/order-exchanges.js +602 -0
- package/lib/product-compare.js +804 -0
- package/lib/pwa-manifest.js +1005 -0
- package/lib/referral-leaderboard.js +612 -0
- package/lib/sales-tax-filings.js +807 -0
- package/lib/search-ranking.js +859 -0
- package/lib/shipping-insurance.js +757 -0
- package/lib/shrinkage-report.js +1182 -0
- package/lib/sidebar-widgets.js +952 -0
- package/lib/smart-restocking.js +1048 -0
- package/lib/stock-receipts.js +834 -0
- package/lib/subscription-analytics.js +1032 -0
- package/lib/suggestion-box.js +921 -0
- package/lib/tax-remittance.js +625 -0
- package/lib/vendor-invoices.js +1021 -0
- package/lib/winback-campaigns.js +1350 -0
- package/lib/wishlist-digest.js +1133 -0
- package/package.json +1 -1
|
@@ -0,0 +1,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
|
+
};
|