@blamejs/blamejs-shop 0.0.66 → 0.0.72
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 +12 -0
- package/lib/assembly-instructions.js +777 -0
- package/lib/auto-replenish.js +933 -0
- package/lib/click-and-collect.js +711 -0
- package/lib/clickstream.js +713 -0
- package/lib/customer-activity.js +862 -0
- package/lib/customer-notes.js +712 -0
- package/lib/customer-risk-profile.js +593 -0
- package/lib/customer-surveys.js +1012 -0
- package/lib/damage-photos.js +473 -0
- package/lib/dropship-forwarding.js +645 -0
- package/lib/email-templates.js +817 -0
- package/lib/index.js +36 -0
- package/lib/inventory-allocations.js +559 -0
- package/lib/inventory-writeoffs.js +636 -0
- package/lib/knowledge-base.js +1104 -0
- package/lib/locale-router.js +1077 -0
- package/lib/loyalty-earn-rules.js +786 -0
- package/lib/operator-roles.js +768 -0
- package/lib/order-escalation.js +951 -0
- package/lib/order-ratings.js +495 -0
- package/lib/order-tags.js +944 -0
- package/lib/packing-slips.js +810 -0
- package/lib/pixel-events.js +995 -0
- package/lib/print-queue.js +681 -0
- package/lib/product-qa.js +749 -0
- package/lib/promo-bundles.js +835 -0
- package/lib/push-notifications.js +937 -0
- package/lib/refund-automation.js +853 -0
- package/lib/reorder-reminders.js +798 -0
- package/lib/robots-config.js +753 -0
- package/lib/seller-signup.js +1052 -0
- package/lib/sitemap-generator.js +717 -0
- package/lib/split-shipments.js +7 -1
- package/lib/subscription-gifts.js +710 -0
- package/lib/tax-cert-renewals.js +632 -0
- package/lib/tier-benefits.js +776 -0
- package/lib/vendor/MANIFEST.json +2 -2
- package/lib/vendor/blamejs/CHANGELOG.md +2 -0
- package/lib/vendor/blamejs/api-snapshot.json +2 -2
- package/lib/vendor/blamejs/lib/metrics.js +68 -4
- package/lib/vendor/blamejs/package.json +1 -1
- package/lib/vendor/blamejs/release-notes/v0.12.5.json +40 -0
- package/lib/wishlist-alerts.js +842 -0
- package/lib/wishlist-sharing.js +718 -0
- package/package.json +1 -1
|
@@ -0,0 +1,1012 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.customerSurveys
|
|
4
|
+
* @title Customer satisfaction surveys — NPS / CSAT / CES / custom
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Post-purchase feedback loop. An operator defines a survey via
|
|
8
|
+
* `defineSurvey({ slug, title, kind, trigger, questions })` once,
|
|
9
|
+
* then the application issues per-customer invitations against it
|
|
10
|
+
* in response to lifecycle events (delivery confirmed, support
|
|
11
|
+
* ticket closed, refund processed) or via a manual operator action.
|
|
12
|
+
* The invitation carries a single-use plaintext token shown to the
|
|
13
|
+
* customer exactly once; the storage column carries only the
|
|
14
|
+
* SHA3-512 namespace-hash of that token. The customer follows the
|
|
15
|
+
* link, submits answers, the primitive validates them against the
|
|
16
|
+
* survey's question shapes and records the response. Operators
|
|
17
|
+
* pull aggregates via `rollup({ slug })` which computes the
|
|
18
|
+
* kind-specific score (NPS = %promoters - %detractors; CSAT =
|
|
19
|
+
* %positive; CES = mean rating; custom = per-question aggregates).
|
|
20
|
+
*
|
|
21
|
+
* Question shapes (each carries a stable `id` so a survey can
|
|
22
|
+
* evolve without breaking historical responses):
|
|
23
|
+
* - { id, kind: "rating", label, required, max? }
|
|
24
|
+
* answer: integer in [0, max] (or [0, 10] for NPS-shaped)
|
|
25
|
+
* - { id, kind: "select", label, required, options: [...] }
|
|
26
|
+
* answer: a string from the options[] set
|
|
27
|
+
* - { id, kind: "free_text", label, required, max? }
|
|
28
|
+
* answer: string <= max chars (default 2000)
|
|
29
|
+
*
|
|
30
|
+
* Survey kinds:
|
|
31
|
+
* - "nps" — primary question MUST be a `rating` with max 10;
|
|
32
|
+
* rollup returns score in [-100, 100], promoter % +
|
|
33
|
+
* passive % + detractor %.
|
|
34
|
+
* - "csat" — primary question MUST be a `rating` with max 5;
|
|
35
|
+
* rollup returns positive % (4 + 5) + mean rating.
|
|
36
|
+
* - "ces" — primary question MUST be a `rating` with max 7;
|
|
37
|
+
* rollup returns mean + per-bucket distribution.
|
|
38
|
+
* - "custom" — no shape constraint; rollup returns per-question
|
|
39
|
+
* aggregates (mean for rating, top-k for select,
|
|
40
|
+
* count for free_text).
|
|
41
|
+
*
|
|
42
|
+
* Lifecycle (per invitation):
|
|
43
|
+
* issued -> responded (submitResponse, FSM-guarded one-shot)
|
|
44
|
+
* issued -> expired (expires_at < now; cleanupExpired sweeps)
|
|
45
|
+
* issued -> closed (closeInvitation, operator-initiated
|
|
46
|
+
* revoke before response)
|
|
47
|
+
*
|
|
48
|
+
* Composes:
|
|
49
|
+
* - `b.crypto.generateBytes` — 32-byte uniform draw for
|
|
50
|
+
* invitation plaintext.
|
|
51
|
+
* - `b.crypto.namespaceHash` — SHA3-512 hash under the
|
|
52
|
+
* `customer-survey-invitation-token`
|
|
53
|
+
* namespace; the only thing stored.
|
|
54
|
+
* - `b.crypto.timingSafeEqual` — constant-time hex compare on
|
|
55
|
+
* submitResponse.
|
|
56
|
+
* - `b.guardUuid` — UUID-shape validation for ids.
|
|
57
|
+
* - `b.uuid.v7` — invitation + response row PKs
|
|
58
|
+
* (monotonic lexicographic so
|
|
59
|
+
* audit reads sort cleanly).
|
|
60
|
+
* - `b.safeSql` — column allow-list for any
|
|
61
|
+
* operator-controlled SQL identifier.
|
|
62
|
+
*
|
|
63
|
+
* Storage: `migrations-d1/0128_customer_surveys.sql` —
|
|
64
|
+
* `customer_surveys` + `survey_invitations` (FK CASCADE) +
|
|
65
|
+
* `survey_responses` (FK CASCADE).
|
|
66
|
+
*
|
|
67
|
+
* @primitive customerSurveys
|
|
68
|
+
* @related b.crypto, b.uuid, b.guardUuid
|
|
69
|
+
*/
|
|
70
|
+
|
|
71
|
+
var bShop;
|
|
72
|
+
function _b() {
|
|
73
|
+
if (!bShop) bShop = require("./index");
|
|
74
|
+
return bShop.framework;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---- constants ----------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
var TOKEN_NAMESPACE = "customer-survey-invitation-token";
|
|
80
|
+
var TOKEN_BYTE_LEN = 32;
|
|
81
|
+
var TOKEN_PLAINTEXT_LEN = 43;
|
|
82
|
+
var TOKEN_PLAINTEXT_RE = /^[A-Za-z0-9_-]{43}$/;
|
|
83
|
+
|
|
84
|
+
var SLUG_RE = /^[a-z0-9](?:[a-z0-9-]{0,98}[a-z0-9])?$/;
|
|
85
|
+
var QUESTION_ID_RE = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/;
|
|
86
|
+
|
|
87
|
+
var KINDS = Object.freeze(["nps", "csat", "ces", "custom"]);
|
|
88
|
+
var TRIGGERS = Object.freeze([
|
|
89
|
+
"after_delivery", "after_support_close", "after_refund", "manual",
|
|
90
|
+
]);
|
|
91
|
+
var QUESTION_KINDS = Object.freeze(["rating", "select", "free_text"]);
|
|
92
|
+
var INVITATION_STATUSES = Object.freeze(["issued", "responded", "expired", "closed"]);
|
|
93
|
+
|
|
94
|
+
var MAX_TITLE_LEN = 200;
|
|
95
|
+
var MAX_LABEL_LEN = 500;
|
|
96
|
+
var MAX_OPTION_LEN = 200;
|
|
97
|
+
var MAX_OPTIONS = 32;
|
|
98
|
+
var MAX_QUESTIONS = 32;
|
|
99
|
+
var MAX_FREE_TEXT_LEN = 2000;
|
|
100
|
+
var MAX_CLOSE_REASON_LEN = 500;
|
|
101
|
+
var MAX_SOURCE_EVENT_LEN = 200;
|
|
102
|
+
|
|
103
|
+
var DEFAULT_EXPIRES_HOURS = 24 * 30; // 30 days
|
|
104
|
+
var MIN_EXPIRES_HOURS = 1;
|
|
105
|
+
var MAX_EXPIRES_HOURS = 24 * 365; // 1 year
|
|
106
|
+
|
|
107
|
+
var DEFAULT_LIMIT = 50;
|
|
108
|
+
var MAX_LIMIT = 500;
|
|
109
|
+
|
|
110
|
+
var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
111
|
+
var CONTROL_BYTE_STRICT_RE = /[\x00-\x1f\x7f]/;
|
|
112
|
+
|
|
113
|
+
// NPS / CSAT / CES enforce a primary-question shape so the rollup
|
|
114
|
+
// math has something well-defined to bucket against. Custom surveys
|
|
115
|
+
// have no primary-question constraint.
|
|
116
|
+
var KIND_RATING_MAX = Object.freeze({ nps: 10, csat: 5, ces: 7 });
|
|
117
|
+
|
|
118
|
+
// ---- monotonic clock ----------------------------------------------------
|
|
119
|
+
//
|
|
120
|
+
// Survey invitations + responses persist epoch-ms timestamps. Operators
|
|
121
|
+
// occasionally backfill responses (importing from a third-party tool,
|
|
122
|
+
// re-issuing a manual invitation). The strict-monotonic clock here
|
|
123
|
+
// guarantees that two same-millisecond `_now()` calls produce distinct
|
|
124
|
+
// integers so the row-ordering on `issued_at` / `occurred_at` is
|
|
125
|
+
// deterministic without an extra tiebreaker column. Tests that issue
|
|
126
|
+
// + submit in tight loops rely on this for ordering assertions.
|
|
127
|
+
var _lastTs = 0;
|
|
128
|
+
function _now() {
|
|
129
|
+
var t = Date.now();
|
|
130
|
+
if (t <= _lastTs) { t = _lastTs + 1; }
|
|
131
|
+
_lastTs = t;
|
|
132
|
+
return t;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---- validators ---------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
function _slug(s, label) {
|
|
138
|
+
if (typeof s !== "string" || !SLUG_RE.test(s)) {
|
|
139
|
+
throw new TypeError("customerSurveys: " + (label || "slug") +
|
|
140
|
+
" must be lowercase alnum + dash, no leading/trailing dash, 1..100 chars");
|
|
141
|
+
}
|
|
142
|
+
return s;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function _title(s) {
|
|
146
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_TITLE_LEN) {
|
|
147
|
+
throw new TypeError("customerSurveys: title must be a non-empty string <= " + MAX_TITLE_LEN + " chars");
|
|
148
|
+
}
|
|
149
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
150
|
+
throw new TypeError("customerSurveys: title must not contain control bytes");
|
|
151
|
+
}
|
|
152
|
+
return s;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function _kind(s) {
|
|
156
|
+
if (typeof s !== "string" || KINDS.indexOf(s) < 0) {
|
|
157
|
+
throw new TypeError("customerSurveys: kind must be one of " + KINDS.join(", "));
|
|
158
|
+
}
|
|
159
|
+
return s;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function _trigger(s) {
|
|
163
|
+
if (typeof s !== "string" || TRIGGERS.indexOf(s) < 0) {
|
|
164
|
+
throw new TypeError("customerSurveys: trigger must be one of " + TRIGGERS.join(", "));
|
|
165
|
+
}
|
|
166
|
+
return s;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function _uuid(s, label) {
|
|
170
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
171
|
+
catch (e) { throw new TypeError("customerSurveys: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function _sourceEventId(s) {
|
|
175
|
+
if (s == null || s === "") return null;
|
|
176
|
+
if (typeof s !== "string" || s.length > MAX_SOURCE_EVENT_LEN) {
|
|
177
|
+
throw new TypeError("customerSurveys: source_event_id must be a string <= " + MAX_SOURCE_EVENT_LEN + " chars");
|
|
178
|
+
}
|
|
179
|
+
if (CONTROL_BYTE_STRICT_RE.test(s)) {
|
|
180
|
+
throw new TypeError("customerSurveys: source_event_id must not contain control bytes");
|
|
181
|
+
}
|
|
182
|
+
return s;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function _expiresInHours(n) {
|
|
186
|
+
if (n == null) return DEFAULT_EXPIRES_HOURS;
|
|
187
|
+
if (!Number.isInteger(n) || n < MIN_EXPIRES_HOURS || n > MAX_EXPIRES_HOURS) {
|
|
188
|
+
throw new TypeError("customerSurveys: expires_in_hours must be an integer in [" +
|
|
189
|
+
MIN_EXPIRES_HOURS + ", " + MAX_EXPIRES_HOURS + "]");
|
|
190
|
+
}
|
|
191
|
+
return n;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function _closeReason(s) {
|
|
195
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_CLOSE_REASON_LEN) {
|
|
196
|
+
throw new TypeError("customerSurveys: reason must be a non-empty string <= " + MAX_CLOSE_REASON_LEN + " chars");
|
|
197
|
+
}
|
|
198
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
199
|
+
throw new TypeError("customerSurveys: reason must not contain control bytes");
|
|
200
|
+
}
|
|
201
|
+
return s;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function _limit(n) {
|
|
205
|
+
if (n == null) return DEFAULT_LIMIT;
|
|
206
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIMIT) {
|
|
207
|
+
throw new TypeError("customerSurveys: limit must be an integer in [1, " + MAX_LIMIT + "]");
|
|
208
|
+
}
|
|
209
|
+
return n;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function _epochOpt(n, label) {
|
|
213
|
+
if (n == null) return null;
|
|
214
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
215
|
+
throw new TypeError("customerSurveys: " + label + " must be a non-negative integer (ms epoch) or null");
|
|
216
|
+
}
|
|
217
|
+
return n;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Validate the operator-authored questions[] array. Returns a
|
|
221
|
+
// canonical (deep-cloned) array so the stored JSON is independent
|
|
222
|
+
// of caller mutation.
|
|
223
|
+
function _questions(arr, kind) {
|
|
224
|
+
if (!Array.isArray(arr) || arr.length === 0) {
|
|
225
|
+
throw new TypeError("customerSurveys: questions must be a non-empty array");
|
|
226
|
+
}
|
|
227
|
+
if (arr.length > MAX_QUESTIONS) {
|
|
228
|
+
throw new TypeError("customerSurveys: questions must contain <= " + MAX_QUESTIONS + " entries");
|
|
229
|
+
}
|
|
230
|
+
var seenIds = Object.create(null);
|
|
231
|
+
var out = [];
|
|
232
|
+
for (var i = 0; i < arr.length; i += 1) {
|
|
233
|
+
var q = arr[i];
|
|
234
|
+
if (!q || typeof q !== "object") {
|
|
235
|
+
throw new TypeError("customerSurveys: questions[" + i + "] must be an object");
|
|
236
|
+
}
|
|
237
|
+
if (typeof q.id !== "string" || !QUESTION_ID_RE.test(q.id)) {
|
|
238
|
+
throw new TypeError("customerSurveys: questions[" + i + "].id must be lowercase alnum / underscore / dash, 1..64 chars");
|
|
239
|
+
}
|
|
240
|
+
if (seenIds[q.id]) {
|
|
241
|
+
throw new TypeError("customerSurveys: questions[" + i + "].id duplicates a previous entry");
|
|
242
|
+
}
|
|
243
|
+
seenIds[q.id] = true;
|
|
244
|
+
|
|
245
|
+
if (typeof q.kind !== "string" || QUESTION_KINDS.indexOf(q.kind) < 0) {
|
|
246
|
+
throw new TypeError("customerSurveys: questions[" + i + "].kind must be one of " + QUESTION_KINDS.join(", "));
|
|
247
|
+
}
|
|
248
|
+
if (typeof q.label !== "string" || !q.label.length || q.label.length > MAX_LABEL_LEN) {
|
|
249
|
+
throw new TypeError("customerSurveys: questions[" + i + "].label must be a non-empty string <= " + MAX_LABEL_LEN + " chars");
|
|
250
|
+
}
|
|
251
|
+
if (CONTROL_BYTE_RE.test(q.label)) {
|
|
252
|
+
throw new TypeError("customerSurveys: questions[" + i + "].label must not contain control bytes");
|
|
253
|
+
}
|
|
254
|
+
var required = q.required == null ? true : q.required;
|
|
255
|
+
if (typeof required !== "boolean") {
|
|
256
|
+
throw new TypeError("customerSurveys: questions[" + i + "].required must be a boolean");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
var canonical = { id: q.id, kind: q.kind, label: q.label, required: required };
|
|
260
|
+
|
|
261
|
+
if (q.kind === "rating") {
|
|
262
|
+
var maxRating;
|
|
263
|
+
if (q.max == null) {
|
|
264
|
+
// Default rating max depends on the survey kind so an
|
|
265
|
+
// operator who picked "nps" but forgot to set max on the
|
|
266
|
+
// primary rating still gets the right scale.
|
|
267
|
+
maxRating = KIND_RATING_MAX[kind] || 5;
|
|
268
|
+
} else {
|
|
269
|
+
if (!Number.isInteger(q.max) || q.max < 1 || q.max > 10) {
|
|
270
|
+
throw new TypeError("customerSurveys: questions[" + i + "].max must be an integer in [1, 10]");
|
|
271
|
+
}
|
|
272
|
+
maxRating = q.max;
|
|
273
|
+
}
|
|
274
|
+
canonical.max = maxRating;
|
|
275
|
+
} else if (q.kind === "select") {
|
|
276
|
+
if (!Array.isArray(q.options) || q.options.length === 0) {
|
|
277
|
+
throw new TypeError("customerSurveys: questions[" + i + "].options must be a non-empty array");
|
|
278
|
+
}
|
|
279
|
+
if (q.options.length > MAX_OPTIONS) {
|
|
280
|
+
throw new TypeError("customerSurveys: questions[" + i + "].options must contain <= " + MAX_OPTIONS + " entries");
|
|
281
|
+
}
|
|
282
|
+
var seenOpts = Object.create(null);
|
|
283
|
+
var opts = [];
|
|
284
|
+
for (var k = 0; k < q.options.length; k += 1) {
|
|
285
|
+
var opt = q.options[k];
|
|
286
|
+
if (typeof opt !== "string" || !opt.length || opt.length > MAX_OPTION_LEN) {
|
|
287
|
+
throw new TypeError("customerSurveys: questions[" + i + "].options[" + k +
|
|
288
|
+
"] must be a non-empty string <= " + MAX_OPTION_LEN + " chars");
|
|
289
|
+
}
|
|
290
|
+
if (CONTROL_BYTE_RE.test(opt)) {
|
|
291
|
+
throw new TypeError("customerSurveys: questions[" + i + "].options[" + k +
|
|
292
|
+
"] must not contain control bytes");
|
|
293
|
+
}
|
|
294
|
+
if (seenOpts[opt]) {
|
|
295
|
+
throw new TypeError("customerSurveys: questions[" + i + "].options[" + k +
|
|
296
|
+
"] duplicates a previous option");
|
|
297
|
+
}
|
|
298
|
+
seenOpts[opt] = true;
|
|
299
|
+
opts.push(opt);
|
|
300
|
+
}
|
|
301
|
+
canonical.options = opts;
|
|
302
|
+
} else {
|
|
303
|
+
// free_text
|
|
304
|
+
var maxLen;
|
|
305
|
+
if (q.max == null) {
|
|
306
|
+
maxLen = MAX_FREE_TEXT_LEN;
|
|
307
|
+
} else {
|
|
308
|
+
if (!Number.isInteger(q.max) || q.max < 1 || q.max > MAX_FREE_TEXT_LEN) {
|
|
309
|
+
throw new TypeError("customerSurveys: questions[" + i + "].max must be an integer in [1, " +
|
|
310
|
+
MAX_FREE_TEXT_LEN + "]");
|
|
311
|
+
}
|
|
312
|
+
maxLen = q.max;
|
|
313
|
+
}
|
|
314
|
+
canonical.max = maxLen;
|
|
315
|
+
}
|
|
316
|
+
out.push(canonical);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Kind-specific primary-question gate. The first question is the
|
|
320
|
+
// canonical scoring question for NPS / CSAT / CES; the rollup
|
|
321
|
+
// math depends on its shape.
|
|
322
|
+
if (kind === "nps" || kind === "csat" || kind === "ces") {
|
|
323
|
+
var primary = out[0];
|
|
324
|
+
if (primary.kind !== "rating") {
|
|
325
|
+
throw new TypeError("customerSurveys: kind '" + kind + "' requires questions[0].kind === 'rating'");
|
|
326
|
+
}
|
|
327
|
+
if (primary.max !== KIND_RATING_MAX[kind]) {
|
|
328
|
+
throw new TypeError("customerSurveys: kind '" + kind + "' requires questions[0].max === " +
|
|
329
|
+
KIND_RATING_MAX[kind]);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return out;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Validate the customer-submitted answers map against the survey's
|
|
337
|
+
// question definitions. Returns a canonical (key-ordered) object so
|
|
338
|
+
// the stored JSON is independent of caller insertion order.
|
|
339
|
+
function _validateAnswers(answers, questions) {
|
|
340
|
+
if (!answers || typeof answers !== "object") {
|
|
341
|
+
throw new TypeError("customerSurveys: answers must be an object");
|
|
342
|
+
}
|
|
343
|
+
var byId = Object.create(null);
|
|
344
|
+
for (var i = 0; i < questions.length; i += 1) byId[questions[i].id] = questions[i];
|
|
345
|
+
|
|
346
|
+
// Refuse answers for unknown questions — defends against a stale
|
|
347
|
+
// client that's holding a previous version of the survey, and
|
|
348
|
+
// against a malicious caller trying to smuggle extra fields into
|
|
349
|
+
// the stored JSON.
|
|
350
|
+
var answerKeys = Object.keys(answers);
|
|
351
|
+
for (var j = 0; j < answerKeys.length; j += 1) {
|
|
352
|
+
if (!byId[answerKeys[j]]) {
|
|
353
|
+
throw new TypeError("customerSurveys: answer references unknown question id " +
|
|
354
|
+
JSON.stringify(answerKeys[j]));
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
var out = {};
|
|
359
|
+
for (var k = 0; k < questions.length; k += 1) {
|
|
360
|
+
var q = questions[k];
|
|
361
|
+
var hasAnswer = Object.prototype.hasOwnProperty.call(answers, q.id);
|
|
362
|
+
var a = hasAnswer ? answers[q.id] : undefined;
|
|
363
|
+
|
|
364
|
+
if (!hasAnswer || a == null || a === "") {
|
|
365
|
+
if (q.required) {
|
|
366
|
+
throw new TypeError("customerSurveys: answer for required question " + JSON.stringify(q.id) + " is missing");
|
|
367
|
+
}
|
|
368
|
+
// Skip optional unanswered questions — they don't appear in
|
|
369
|
+
// the stored answers object, so rollup math treats them as
|
|
370
|
+
// an explicit non-response rather than a zero.
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (q.kind === "rating") {
|
|
375
|
+
if (typeof a !== "number" || !Number.isInteger(a) || a < 0 || a > q.max) {
|
|
376
|
+
throw new TypeError("customerSurveys: answer for " + JSON.stringify(q.id) +
|
|
377
|
+
" must be an integer in [0, " + q.max + "]");
|
|
378
|
+
}
|
|
379
|
+
out[q.id] = a;
|
|
380
|
+
} else if (q.kind === "select") {
|
|
381
|
+
if (typeof a !== "string") {
|
|
382
|
+
throw new TypeError("customerSurveys: answer for " + JSON.stringify(q.id) + " must be a string");
|
|
383
|
+
}
|
|
384
|
+
if (q.options.indexOf(a) < 0) {
|
|
385
|
+
throw new TypeError("customerSurveys: answer for " + JSON.stringify(q.id) +
|
|
386
|
+
" must be one of " + q.options.join(", "));
|
|
387
|
+
}
|
|
388
|
+
out[q.id] = a;
|
|
389
|
+
} else {
|
|
390
|
+
// free_text
|
|
391
|
+
if (typeof a !== "string") {
|
|
392
|
+
throw new TypeError("customerSurveys: answer for " + JSON.stringify(q.id) + " must be a string");
|
|
393
|
+
}
|
|
394
|
+
if (a.length > q.max) {
|
|
395
|
+
throw new TypeError("customerSurveys: answer for " + JSON.stringify(q.id) +
|
|
396
|
+
" must be <= " + q.max + " chars");
|
|
397
|
+
}
|
|
398
|
+
if (CONTROL_BYTE_RE.test(a)) {
|
|
399
|
+
throw new TypeError("customerSurveys: answer for " + JSON.stringify(q.id) +
|
|
400
|
+
" must not contain control bytes");
|
|
401
|
+
}
|
|
402
|
+
out[q.id] = a;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
return out;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// ---- token generation + hashing -----------------------------------------
|
|
409
|
+
|
|
410
|
+
// 32 random bytes -> 43-char base64url (no padding). Rendered
|
|
411
|
+
// manually so the primitive doesn't depend on a Buffer-side flag
|
|
412
|
+
// rename across Node minors.
|
|
413
|
+
function _generateToken() {
|
|
414
|
+
var buf = _b().crypto.generateBytes(TOKEN_BYTE_LEN);
|
|
415
|
+
return buf.toString("base64")
|
|
416
|
+
.replace(/\+/g, "-")
|
|
417
|
+
.replace(/\//g, "_")
|
|
418
|
+
.replace(/=+$/, "");
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
function _canonicalToken(input) {
|
|
422
|
+
if (typeof input !== "string" || !input.length) {
|
|
423
|
+
throw new TypeError("customerSurveys: token must be a non-empty string");
|
|
424
|
+
}
|
|
425
|
+
if (!TOKEN_PLAINTEXT_RE.test(input)) {
|
|
426
|
+
throw new TypeError("customerSurveys: token must be 43 base64url characters");
|
|
427
|
+
}
|
|
428
|
+
return input;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
function _hashToken(canonical) {
|
|
432
|
+
return _b().crypto.namespaceHash(TOKEN_NAMESPACE, canonical);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// ---- factory ------------------------------------------------------------
|
|
436
|
+
|
|
437
|
+
function create(opts) {
|
|
438
|
+
opts = opts || {};
|
|
439
|
+
var query = opts.query;
|
|
440
|
+
if (!query) {
|
|
441
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ---- internal helpers -------------------------------------------------
|
|
445
|
+
|
|
446
|
+
async function _surveyRow(slug) {
|
|
447
|
+
var r = await query("SELECT * FROM customer_surveys WHERE slug = ?1", [slug]);
|
|
448
|
+
return r.rows[0] || null;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function _decodeSurvey(row) {
|
|
452
|
+
if (!row) return null;
|
|
453
|
+
var questions;
|
|
454
|
+
try { questions = JSON.parse(row.questions_json); }
|
|
455
|
+
catch (_e) { questions = []; }
|
|
456
|
+
return {
|
|
457
|
+
slug: row.slug,
|
|
458
|
+
title: row.title,
|
|
459
|
+
kind: row.kind,
|
|
460
|
+
trigger: row.trigger_event,
|
|
461
|
+
questions: questions,
|
|
462
|
+
archived_at: row.archived_at != null ? Number(row.archived_at) : null,
|
|
463
|
+
created_at: Number(row.created_at),
|
|
464
|
+
updated_at: Number(row.updated_at),
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function _decodeInvitation(row) {
|
|
469
|
+
if (!row) return null;
|
|
470
|
+
return {
|
|
471
|
+
id: row.id,
|
|
472
|
+
survey_slug: row.survey_slug,
|
|
473
|
+
customer_id: row.customer_id,
|
|
474
|
+
source_event_id: row.source_event_id,
|
|
475
|
+
status: row.status,
|
|
476
|
+
expires_at: Number(row.expires_at),
|
|
477
|
+
issued_at: Number(row.issued_at),
|
|
478
|
+
responded_at: row.responded_at != null ? Number(row.responded_at) : null,
|
|
479
|
+
closed_at: row.closed_at != null ? Number(row.closed_at) : null,
|
|
480
|
+
closed_reason: row.closed_reason,
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function _decodeResponse(row) {
|
|
485
|
+
if (!row) return null;
|
|
486
|
+
var answers;
|
|
487
|
+
try { answers = JSON.parse(row.answers_json); }
|
|
488
|
+
catch (_e) { answers = {}; }
|
|
489
|
+
return {
|
|
490
|
+
id: row.id,
|
|
491
|
+
invitation_id: row.invitation_id,
|
|
492
|
+
answers: answers,
|
|
493
|
+
occurred_at: Number(row.occurred_at),
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ---- defineSurvey -----------------------------------------------------
|
|
498
|
+
|
|
499
|
+
async function defineSurvey(input) {
|
|
500
|
+
if (!input || typeof input !== "object") {
|
|
501
|
+
throw new TypeError("customerSurveys.defineSurvey: input object required");
|
|
502
|
+
}
|
|
503
|
+
var slug = _slug(input.slug, "slug");
|
|
504
|
+
var title = _title(input.title);
|
|
505
|
+
var kind = _kind(input.kind);
|
|
506
|
+
var trigger = _trigger(input.trigger);
|
|
507
|
+
var questions = _questions(input.questions, kind);
|
|
508
|
+
|
|
509
|
+
var existing = await _surveyRow(slug);
|
|
510
|
+
var ts = _now();
|
|
511
|
+
if (existing) {
|
|
512
|
+
// Update path: the slug already exists. Operators evolve a
|
|
513
|
+
// survey by re-issuing defineSurvey with the same slug — the
|
|
514
|
+
// questions JSON is replaced atomically, but existing
|
|
515
|
+
// invitations + responses retain their historical question
|
|
516
|
+
// ids (which is why every question carries a stable id even
|
|
517
|
+
// though defineSurvey allows the operator to reorder / extend
|
|
518
|
+
// the list).
|
|
519
|
+
if (existing.archived_at != null) {
|
|
520
|
+
throw new TypeError("customerSurveys.defineSurvey: survey " + JSON.stringify(slug) + " is archived");
|
|
521
|
+
}
|
|
522
|
+
await query(
|
|
523
|
+
"UPDATE customer_surveys " +
|
|
524
|
+
"SET title = ?1, kind = ?2, trigger_event = ?3, questions_json = ?4, updated_at = ?5 " +
|
|
525
|
+
"WHERE slug = ?6",
|
|
526
|
+
[title, kind, trigger, JSON.stringify(questions), ts, slug],
|
|
527
|
+
);
|
|
528
|
+
} else {
|
|
529
|
+
await query(
|
|
530
|
+
"INSERT INTO customer_surveys " +
|
|
531
|
+
"(slug, title, kind, trigger_event, questions_json, archived_at, created_at, updated_at) " +
|
|
532
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, ?6)",
|
|
533
|
+
[slug, title, kind, trigger, JSON.stringify(questions), ts],
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
return _decodeSurvey(await _surveyRow(slug));
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
async function getSurvey(slug) {
|
|
540
|
+
_slug(slug, "slug");
|
|
541
|
+
return _decodeSurvey(await _surveyRow(slug));
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
async function archiveSurvey(slug) {
|
|
545
|
+
_slug(slug, "slug");
|
|
546
|
+
var ts = _now();
|
|
547
|
+
var r = await query(
|
|
548
|
+
"UPDATE customer_surveys SET archived_at = ?1, updated_at = ?1 " +
|
|
549
|
+
"WHERE slug = ?2 AND archived_at IS NULL",
|
|
550
|
+
[ts, slug],
|
|
551
|
+
);
|
|
552
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
553
|
+
var existing = await _surveyRow(slug);
|
|
554
|
+
if (!existing) return null;
|
|
555
|
+
return _decodeSurvey(existing);
|
|
556
|
+
}
|
|
557
|
+
return _decodeSurvey(await _surveyRow(slug));
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ---- issueInvitation --------------------------------------------------
|
|
561
|
+
|
|
562
|
+
async function issueInvitation(input) {
|
|
563
|
+
if (!input || typeof input !== "object") {
|
|
564
|
+
throw new TypeError("customerSurveys.issueInvitation: input object required");
|
|
565
|
+
}
|
|
566
|
+
var slug = _slug(input.survey_slug, "survey_slug");
|
|
567
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
568
|
+
var sourceEventId = _sourceEventId(input.source_event_id);
|
|
569
|
+
var expiresHours = _expiresInHours(input.expires_in_hours);
|
|
570
|
+
|
|
571
|
+
var survey = await _surveyRow(slug);
|
|
572
|
+
if (!survey) {
|
|
573
|
+
throw new TypeError("customerSurveys.issueInvitation: survey " + JSON.stringify(slug) + " not found");
|
|
574
|
+
}
|
|
575
|
+
if (survey.archived_at != null) {
|
|
576
|
+
throw new TypeError("customerSurveys.issueInvitation: survey " + JSON.stringify(slug) + " is archived");
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
var id = _b().uuid.v7();
|
|
580
|
+
var plaintext = _generateToken();
|
|
581
|
+
var tokenHash = _hashToken(plaintext);
|
|
582
|
+
var issuedAt = _now();
|
|
583
|
+
var expiresAt = issuedAt + (expiresHours * 60 * 60 * 1000);
|
|
584
|
+
|
|
585
|
+
await query(
|
|
586
|
+
"INSERT INTO survey_invitations " +
|
|
587
|
+
"(id, survey_slug, customer_id, token_hash, source_event_id, status, expires_at, " +
|
|
588
|
+
" issued_at, responded_at, closed_at, closed_reason) " +
|
|
589
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, 'issued', ?6, ?7, NULL, NULL, NULL)",
|
|
590
|
+
[id, slug, customerId, tokenHash, sourceEventId, expiresAt, issuedAt],
|
|
591
|
+
);
|
|
592
|
+
|
|
593
|
+
// Plaintext token is returned EXACTLY ONCE here. Subsequent reads
|
|
594
|
+
// of the invitation row never see it again — the storage column
|
|
595
|
+
// carries only the SHA3-512 namespace-hash. Callers (email /
|
|
596
|
+
// SMS dispatch) embed the plaintext in the invitation link and
|
|
597
|
+
// discard it immediately after.
|
|
598
|
+
return {
|
|
599
|
+
invitation_id: id,
|
|
600
|
+
survey_slug: slug,
|
|
601
|
+
customer_id: customerId,
|
|
602
|
+
plaintext_token: plaintext,
|
|
603
|
+
source_event_id: sourceEventId,
|
|
604
|
+
status: "issued",
|
|
605
|
+
expires_at: expiresAt,
|
|
606
|
+
issued_at: issuedAt,
|
|
607
|
+
};
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
async function getInvitation(id) {
|
|
611
|
+
_uuid(id, "id");
|
|
612
|
+
var r = await query("SELECT * FROM survey_invitations WHERE id = ?1", [id]);
|
|
613
|
+
return _decodeInvitation(r.rows[0]);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
async function invitationsForCustomer(customerId, listOpts) {
|
|
617
|
+
var custId = _uuid(customerId, "customer_id");
|
|
618
|
+
listOpts = listOpts || {};
|
|
619
|
+
var limit = _limit(listOpts.limit);
|
|
620
|
+
var r = await query(
|
|
621
|
+
"SELECT * FROM survey_invitations WHERE customer_id = ?1 " +
|
|
622
|
+
"ORDER BY issued_at DESC, id DESC LIMIT ?2",
|
|
623
|
+
[custId, limit],
|
|
624
|
+
);
|
|
625
|
+
var out = [];
|
|
626
|
+
for (var i = 0; i < r.rows.length; i += 1) out.push(_decodeInvitation(r.rows[i]));
|
|
627
|
+
return out;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// ---- submitResponse ---------------------------------------------------
|
|
631
|
+
|
|
632
|
+
async function submitResponse(input) {
|
|
633
|
+
if (!input || typeof input !== "object") {
|
|
634
|
+
throw new TypeError("customerSurveys.submitResponse: input object required");
|
|
635
|
+
}
|
|
636
|
+
var token = _canonicalToken(input.token);
|
|
637
|
+
var occurredOpt = _epochOpt(input.occurred_at, "occurred_at");
|
|
638
|
+
|
|
639
|
+
if (!input.answers || typeof input.answers !== "object") {
|
|
640
|
+
throw new TypeError("customerSurveys.submitResponse: answers must be an object");
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
var hash = _hashToken(token);
|
|
644
|
+
var r = await query(
|
|
645
|
+
"SELECT * FROM survey_invitations WHERE token_hash = ?1",
|
|
646
|
+
[hash],
|
|
647
|
+
);
|
|
648
|
+
if (!r.rows.length) {
|
|
649
|
+
var miss = new Error("customerSurveys.submitResponse: invitation not found");
|
|
650
|
+
miss.code = "SURVEY_INVITATION_NOT_FOUND";
|
|
651
|
+
throw miss;
|
|
652
|
+
}
|
|
653
|
+
var invitation = r.rows[0];
|
|
654
|
+
|
|
655
|
+
// Constant-time hex compare on the matched row's hash — belt-
|
|
656
|
+
// and-braces over the SQL = match so any future schema change
|
|
657
|
+
// that swaps the lookup for a collection scan still leaves no
|
|
658
|
+
// micro-timing oracle.
|
|
659
|
+
if (!_b().crypto.timingSafeEqual(invitation.token_hash, hash)) {
|
|
660
|
+
var mismatch = new Error("customerSurveys.submitResponse: invitation not found");
|
|
661
|
+
mismatch.code = "SURVEY_INVITATION_NOT_FOUND";
|
|
662
|
+
throw mismatch;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
var nowTs = _now();
|
|
666
|
+
var occurredAt = occurredOpt != null ? occurredOpt : nowTs;
|
|
667
|
+
|
|
668
|
+
if (invitation.status === "responded") {
|
|
669
|
+
var dupe = new Error("customerSurveys.submitResponse: invitation already responded");
|
|
670
|
+
dupe.code = "SURVEY_INVITATION_ALREADY_RESPONDED";
|
|
671
|
+
throw dupe;
|
|
672
|
+
}
|
|
673
|
+
if (invitation.status === "closed") {
|
|
674
|
+
var closed = new Error("customerSurveys.submitResponse: invitation has been closed");
|
|
675
|
+
closed.code = "SURVEY_INVITATION_CLOSED";
|
|
676
|
+
throw closed;
|
|
677
|
+
}
|
|
678
|
+
if (invitation.status === "expired" || Number(invitation.expires_at) < nowTs) {
|
|
679
|
+
var expired = new Error("customerSurveys.submitResponse: invitation has expired");
|
|
680
|
+
expired.code = "SURVEY_INVITATION_EXPIRED";
|
|
681
|
+
throw expired;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
var surveyRow = await _surveyRow(invitation.survey_slug);
|
|
685
|
+
if (!surveyRow) {
|
|
686
|
+
// Schema CASCADE on the FK should make this unreachable, but
|
|
687
|
+
// guard so a hand-deleted survey row produces a clean error.
|
|
688
|
+
var noSurvey = new Error("customerSurveys.submitResponse: parent survey is missing");
|
|
689
|
+
noSurvey.code = "SURVEY_NOT_FOUND";
|
|
690
|
+
throw noSurvey;
|
|
691
|
+
}
|
|
692
|
+
var questions = JSON.parse(surveyRow.questions_json);
|
|
693
|
+
|
|
694
|
+
var canonicalAnswers = _validateAnswers(input.answers, questions);
|
|
695
|
+
|
|
696
|
+
var responseId = _b().uuid.v7();
|
|
697
|
+
await query(
|
|
698
|
+
"INSERT INTO survey_responses (id, invitation_id, answers_json, occurred_at) " +
|
|
699
|
+
"VALUES (?1, ?2, ?3, ?4)",
|
|
700
|
+
[responseId, invitation.id, JSON.stringify(canonicalAnswers), occurredAt],
|
|
701
|
+
);
|
|
702
|
+
await query(
|
|
703
|
+
"UPDATE survey_invitations SET status = 'responded', responded_at = ?1 WHERE id = ?2",
|
|
704
|
+
[nowTs, invitation.id],
|
|
705
|
+
);
|
|
706
|
+
|
|
707
|
+
return {
|
|
708
|
+
response_id: responseId,
|
|
709
|
+
invitation_id: invitation.id,
|
|
710
|
+
survey_slug: invitation.survey_slug,
|
|
711
|
+
customer_id: invitation.customer_id,
|
|
712
|
+
answers: canonicalAnswers,
|
|
713
|
+
occurred_at: occurredAt,
|
|
714
|
+
responded_at: nowTs,
|
|
715
|
+
};
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// ---- responsesForSurvey -----------------------------------------------
|
|
719
|
+
|
|
720
|
+
async function responsesForSurvey(input) {
|
|
721
|
+
if (!input || typeof input !== "object") {
|
|
722
|
+
throw new TypeError("customerSurveys.responsesForSurvey: input object required");
|
|
723
|
+
}
|
|
724
|
+
var slug = _slug(input.slug, "slug");
|
|
725
|
+
var from = _epochOpt(input.from, "from");
|
|
726
|
+
var to = _epochOpt(input.to, "to");
|
|
727
|
+
if (from != null && to != null && from > to) {
|
|
728
|
+
throw new TypeError("customerSurveys.responsesForSurvey: from must be <= to");
|
|
729
|
+
}
|
|
730
|
+
var limit = _limit(input.limit);
|
|
731
|
+
var cursor = input.cursor;
|
|
732
|
+
if (cursor != null && (typeof cursor !== "string" || !cursor.length)) {
|
|
733
|
+
throw new TypeError("customerSurveys.responsesForSurvey: cursor must be a non-empty string when provided");
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
var sql = "SELECT r.* FROM survey_responses r " +
|
|
737
|
+
"JOIN survey_invitations i ON i.id = r.invitation_id " +
|
|
738
|
+
"WHERE i.survey_slug = ?1";
|
|
739
|
+
var params = [slug];
|
|
740
|
+
var idx = 2;
|
|
741
|
+
if (from != null) {
|
|
742
|
+
sql += " AND r.occurred_at >= ?" + idx; params.push(from); idx += 1;
|
|
743
|
+
}
|
|
744
|
+
if (to != null) {
|
|
745
|
+
sql += " AND r.occurred_at <= ?" + idx; params.push(to); idx += 1;
|
|
746
|
+
}
|
|
747
|
+
if (cursor != null) {
|
|
748
|
+
// Cursor is the last-seen response id. Sort is (occurred_at DESC,
|
|
749
|
+
// id DESC) so the cursor predicate is (id < cursor) at equal
|
|
750
|
+
// occurred_at — but we use a simple `id < cursor` over the
|
|
751
|
+
// lexicographically-monotonic v7 id, which already encodes
|
|
752
|
+
// occurred_at in its prefix. This collapses the cursor logic
|
|
753
|
+
// to one comparison without losing total ordering.
|
|
754
|
+
sql += " AND r.id < ?" + idx; params.push(cursor); idx += 1;
|
|
755
|
+
}
|
|
756
|
+
sql += " ORDER BY r.id DESC LIMIT ?" + idx;
|
|
757
|
+
params.push(limit);
|
|
758
|
+
|
|
759
|
+
var r = await query(sql, params);
|
|
760
|
+
var out = [];
|
|
761
|
+
for (var i = 0; i < r.rows.length; i += 1) out.push(_decodeResponse(r.rows[i]));
|
|
762
|
+
var nextCursor = out.length === limit ? out[out.length - 1].id : null;
|
|
763
|
+
return { rows: out, next_cursor: nextCursor };
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// ---- rollup -----------------------------------------------------------
|
|
767
|
+
|
|
768
|
+
async function rollup(input) {
|
|
769
|
+
if (!input || typeof input !== "object") {
|
|
770
|
+
throw new TypeError("customerSurveys.rollup: input object required");
|
|
771
|
+
}
|
|
772
|
+
var slug = _slug(input.slug, "slug");
|
|
773
|
+
var from = _epochOpt(input.from, "from");
|
|
774
|
+
var to = _epochOpt(input.to, "to");
|
|
775
|
+
if (from != null && to != null && from > to) {
|
|
776
|
+
throw new TypeError("customerSurveys.rollup: from must be <= to");
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
var surveyRow = await _surveyRow(slug);
|
|
780
|
+
if (!surveyRow) return null;
|
|
781
|
+
var survey = _decodeSurvey(surveyRow);
|
|
782
|
+
|
|
783
|
+
var sql = "SELECT r.answers_json FROM survey_responses r " +
|
|
784
|
+
"JOIN survey_invitations i ON i.id = r.invitation_id " +
|
|
785
|
+
"WHERE i.survey_slug = ?1";
|
|
786
|
+
var params = [slug];
|
|
787
|
+
var idx = 2;
|
|
788
|
+
if (from != null) { sql += " AND r.occurred_at >= ?" + idx; params.push(from); idx += 1; }
|
|
789
|
+
if (to != null) { sql += " AND r.occurred_at <= ?" + idx; params.push(to); idx += 1; }
|
|
790
|
+
var r = await query(sql, params);
|
|
791
|
+
|
|
792
|
+
var responseCount = r.rows.length;
|
|
793
|
+
|
|
794
|
+
// Per-question aggregate buckets seeded from the survey shape so
|
|
795
|
+
// a question with zero answers still appears in the rollup with
|
|
796
|
+
// a count of 0.
|
|
797
|
+
var perQuestion = Object.create(null);
|
|
798
|
+
for (var i = 0; i < survey.questions.length; i += 1) {
|
|
799
|
+
var q = survey.questions[i];
|
|
800
|
+
var seed;
|
|
801
|
+
if (q.kind === "rating") {
|
|
802
|
+
seed = { kind: "rating", id: q.id, max: q.max, count: 0, sum: 0, mean: 0, buckets: {} };
|
|
803
|
+
} else if (q.kind === "select") {
|
|
804
|
+
seed = { kind: "select", id: q.id, options: q.options.slice(), count: 0, buckets: {} };
|
|
805
|
+
for (var j = 0; j < q.options.length; j += 1) seed.buckets[q.options[j]] = 0;
|
|
806
|
+
} else {
|
|
807
|
+
seed = { kind: "free_text", id: q.id, count: 0 };
|
|
808
|
+
}
|
|
809
|
+
perQuestion[q.id] = seed;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
for (var k = 0; k < r.rows.length; k += 1) {
|
|
813
|
+
var answers;
|
|
814
|
+
try { answers = JSON.parse(r.rows[k].answers_json); }
|
|
815
|
+
catch (_e) { answers = {}; }
|
|
816
|
+
var keys = Object.keys(answers);
|
|
817
|
+
for (var m = 0; m < keys.length; m += 1) {
|
|
818
|
+
var ansKey = keys[m];
|
|
819
|
+
var bucket = perQuestion[ansKey];
|
|
820
|
+
if (!bucket) continue; // historical answer for since-removed question
|
|
821
|
+
var val = answers[ansKey];
|
|
822
|
+
if (bucket.kind === "rating") {
|
|
823
|
+
if (typeof val !== "number") continue;
|
|
824
|
+
bucket.count += 1;
|
|
825
|
+
bucket.sum += val;
|
|
826
|
+
bucket.buckets[String(val)] = (bucket.buckets[String(val)] || 0) + 1;
|
|
827
|
+
} else if (bucket.kind === "select") {
|
|
828
|
+
if (typeof val !== "string") continue;
|
|
829
|
+
bucket.count += 1;
|
|
830
|
+
bucket.buckets[val] = (bucket.buckets[val] || 0) + 1;
|
|
831
|
+
} else {
|
|
832
|
+
if (typeof val !== "string") continue;
|
|
833
|
+
bucket.count += 1;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Finalize: compute mean for rating buckets.
|
|
839
|
+
var perQuestionList = [];
|
|
840
|
+
for (var pq = 0; pq < survey.questions.length; pq += 1) {
|
|
841
|
+
var qid = survey.questions[pq].id;
|
|
842
|
+
var entry = perQuestion[qid];
|
|
843
|
+
if (entry.kind === "rating" && entry.count > 0) {
|
|
844
|
+
entry.mean = entry.sum / entry.count;
|
|
845
|
+
}
|
|
846
|
+
perQuestionList.push(entry);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
var out = {
|
|
850
|
+
slug: slug,
|
|
851
|
+
kind: survey.kind,
|
|
852
|
+
from: from,
|
|
853
|
+
to: to,
|
|
854
|
+
response_count: responseCount,
|
|
855
|
+
per_question: perQuestionList,
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
// Kind-specific score on top of the primary (questions[0]) rating
|
|
859
|
+
// bucket. Empty-response case: every score is 0 (or null where
|
|
860
|
+
// mean would divide by zero) so the operator can distinguish "no
|
|
861
|
+
// responses yet" from "responses with poor scores".
|
|
862
|
+
var primary = perQuestionList[0];
|
|
863
|
+
if (survey.kind === "nps" && primary && primary.kind === "rating") {
|
|
864
|
+
// Promoters = 9-10, passives = 7-8, detractors = 0-6 (the
|
|
865
|
+
// canonical NPS definition).
|
|
866
|
+
var promoters = 0; var passives = 0; var detractors = 0;
|
|
867
|
+
var ratingKeys = Object.keys(primary.buckets);
|
|
868
|
+
for (var rk = 0; rk < ratingKeys.length; rk += 1) {
|
|
869
|
+
var ratingVal = parseInt(ratingKeys[rk], 10);
|
|
870
|
+
var ratingCnt = primary.buckets[ratingKeys[rk]];
|
|
871
|
+
if (ratingVal >= 9) promoters += ratingCnt;
|
|
872
|
+
else if (ratingVal >= 7) passives += ratingCnt;
|
|
873
|
+
else detractors += ratingCnt;
|
|
874
|
+
}
|
|
875
|
+
var total = promoters + passives + detractors;
|
|
876
|
+
if (total === 0) {
|
|
877
|
+
out.nps = { score: 0, promoter_pct: 0, passive_pct: 0, detractor_pct: 0,
|
|
878
|
+
promoters: 0, passives: 0, detractors: 0 };
|
|
879
|
+
} else {
|
|
880
|
+
var promoPct = (promoters / total) * 100;
|
|
881
|
+
var passPct = (passives / total) * 100;
|
|
882
|
+
var detPct = (detractors / total) * 100;
|
|
883
|
+
out.nps = {
|
|
884
|
+
score: Math.round(promoPct - detPct),
|
|
885
|
+
promoter_pct: Math.round(promoPct * 10) / 10,
|
|
886
|
+
passive_pct: Math.round(passPct * 10) / 10,
|
|
887
|
+
detractor_pct: Math.round(detPct * 10) / 10,
|
|
888
|
+
promoters: promoters,
|
|
889
|
+
passives: passives,
|
|
890
|
+
detractors: detractors,
|
|
891
|
+
};
|
|
892
|
+
}
|
|
893
|
+
} else if (survey.kind === "csat" && primary && primary.kind === "rating") {
|
|
894
|
+
// Positive = 4-5 on a 1-5 scale (CSAT convention). Top-2-box.
|
|
895
|
+
var pos = 0; var neg = 0; var neu = 0;
|
|
896
|
+
var csatKeys = Object.keys(primary.buckets);
|
|
897
|
+
for (var ck = 0; ck < csatKeys.length; ck += 1) {
|
|
898
|
+
var csatVal = parseInt(csatKeys[ck], 10);
|
|
899
|
+
var csatCnt = primary.buckets[csatKeys[ck]];
|
|
900
|
+
if (csatVal >= 4) pos += csatCnt;
|
|
901
|
+
else if (csatVal === 3) neu += csatCnt;
|
|
902
|
+
else neg += csatCnt;
|
|
903
|
+
}
|
|
904
|
+
var csatTotal = pos + neu + neg;
|
|
905
|
+
out.csat = csatTotal === 0
|
|
906
|
+
? { positive_pct: 0, mean: 0, positives: 0, neutrals: 0, negatives: 0 }
|
|
907
|
+
: {
|
|
908
|
+
positive_pct: Math.round((pos / csatTotal) * 1000) / 10,
|
|
909
|
+
mean: Math.round(primary.mean * 100) / 100,
|
|
910
|
+
positives: pos,
|
|
911
|
+
neutrals: neu,
|
|
912
|
+
negatives: neg,
|
|
913
|
+
};
|
|
914
|
+
} else if (survey.kind === "ces" && primary && primary.kind === "rating") {
|
|
915
|
+
// CES uses the mean of the 1-7 effort score directly. Lower
|
|
916
|
+
// = less effort; 5+ counts as "agree the experience was easy."
|
|
917
|
+
var cesAgree = 0;
|
|
918
|
+
var cesKeys = Object.keys(primary.buckets);
|
|
919
|
+
for (var sk = 0; sk < cesKeys.length; sk += 1) {
|
|
920
|
+
var cesVal = parseInt(cesKeys[sk], 10);
|
|
921
|
+
if (cesVal >= 5) cesAgree += primary.buckets[cesKeys[sk]];
|
|
922
|
+
}
|
|
923
|
+
out.ces = primary.count === 0
|
|
924
|
+
? { mean: 0, agree_pct: 0, agree: 0 }
|
|
925
|
+
: {
|
|
926
|
+
mean: Math.round(primary.mean * 100) / 100,
|
|
927
|
+
agree_pct: Math.round((cesAgree / primary.count) * 1000) / 10,
|
|
928
|
+
agree: cesAgree,
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
return out;
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
// ---- closeInvitation --------------------------------------------------
|
|
936
|
+
|
|
937
|
+
async function closeInvitation(input) {
|
|
938
|
+
if (!input || typeof input !== "object") {
|
|
939
|
+
throw new TypeError("customerSurveys.closeInvitation: input object required");
|
|
940
|
+
}
|
|
941
|
+
var id = _uuid(input.invitation_id, "invitation_id");
|
|
942
|
+
var reason = _closeReason(input.reason);
|
|
943
|
+
var ts = _now();
|
|
944
|
+
|
|
945
|
+
var existing = await query("SELECT * FROM survey_invitations WHERE id = ?1", [id]);
|
|
946
|
+
if (!existing.rows.length) return null;
|
|
947
|
+
var row = existing.rows[0];
|
|
948
|
+
if (row.status !== "issued") {
|
|
949
|
+
// Already responded / expired / closed — refuse so a caller
|
|
950
|
+
// can distinguish a successful close from a no-op.
|
|
951
|
+
var bad = new Error("customerSurveys.closeInvitation: invitation status is " + row.status);
|
|
952
|
+
bad.code = "SURVEY_INVITATION_NOT_ISSUED";
|
|
953
|
+
throw bad;
|
|
954
|
+
}
|
|
955
|
+
await query(
|
|
956
|
+
"UPDATE survey_invitations SET status = 'closed', closed_at = ?1, closed_reason = ?2 " +
|
|
957
|
+
"WHERE id = ?3 AND status = 'issued'",
|
|
958
|
+
[ts, reason, id],
|
|
959
|
+
);
|
|
960
|
+
var fresh = await query("SELECT * FROM survey_invitations WHERE id = ?1", [id]);
|
|
961
|
+
return _decodeInvitation(fresh.rows[0]);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// ---- cleanupExpired ---------------------------------------------------
|
|
965
|
+
|
|
966
|
+
async function cleanupExpired(cleanupOpts) {
|
|
967
|
+
cleanupOpts = cleanupOpts || {};
|
|
968
|
+
var nowTs = cleanupOpts.now != null ? _epochOpt(cleanupOpts.now, "now") : _now();
|
|
969
|
+
if (nowTs == null) nowTs = _now();
|
|
970
|
+
var r = await query(
|
|
971
|
+
"UPDATE survey_invitations SET status = 'expired' " +
|
|
972
|
+
"WHERE status = 'issued' AND expires_at < ?1",
|
|
973
|
+
[nowTs],
|
|
974
|
+
);
|
|
975
|
+
return { expired_count: Number(r.rowCount || 0) };
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
return {
|
|
979
|
+
KINDS: KINDS.slice(),
|
|
980
|
+
TRIGGERS: TRIGGERS.slice(),
|
|
981
|
+
QUESTION_KINDS: QUESTION_KINDS.slice(),
|
|
982
|
+
INVITATION_STATUSES: INVITATION_STATUSES.slice(),
|
|
983
|
+
TOKEN_NAMESPACE: TOKEN_NAMESPACE,
|
|
984
|
+
TOKEN_PLAINTEXT_LEN: TOKEN_PLAINTEXT_LEN,
|
|
985
|
+
DEFAULT_EXPIRES_HOURS: DEFAULT_EXPIRES_HOURS,
|
|
986
|
+
MAX_QUESTIONS: MAX_QUESTIONS,
|
|
987
|
+
MAX_FREE_TEXT_LEN: MAX_FREE_TEXT_LEN,
|
|
988
|
+
|
|
989
|
+
defineSurvey: defineSurvey,
|
|
990
|
+
getSurvey: getSurvey,
|
|
991
|
+
archiveSurvey: archiveSurvey,
|
|
992
|
+
issueInvitation: issueInvitation,
|
|
993
|
+
getInvitation: getInvitation,
|
|
994
|
+
invitationsForCustomer: invitationsForCustomer,
|
|
995
|
+
submitResponse: submitResponse,
|
|
996
|
+
responsesForSurvey: responsesForSurvey,
|
|
997
|
+
rollup: rollup,
|
|
998
|
+
closeInvitation: closeInvitation,
|
|
999
|
+
cleanupExpired: cleanupExpired,
|
|
1000
|
+
};
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
module.exports = {
|
|
1004
|
+
create: create,
|
|
1005
|
+
KINDS: KINDS,
|
|
1006
|
+
TRIGGERS: TRIGGERS,
|
|
1007
|
+
QUESTION_KINDS: QUESTION_KINDS,
|
|
1008
|
+
INVITATION_STATUSES: INVITATION_STATUSES,
|
|
1009
|
+
TOKEN_NAMESPACE: TOKEN_NAMESPACE,
|
|
1010
|
+
TOKEN_PLAINTEXT_LEN: TOKEN_PLAINTEXT_LEN,
|
|
1011
|
+
DEFAULT_EXPIRES_HOURS: DEFAULT_EXPIRES_HOURS,
|
|
1012
|
+
};
|