@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,495 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.orderRatings
|
|
4
|
+
* @title Per-order rating primitive — shipping / packaging /
|
|
5
|
+
* recommend-to-friend feedback against a single order.
|
|
6
|
+
*
|
|
7
|
+
* @intro
|
|
8
|
+
* A focused feedback row tied to one order. Distinct from `reviews`
|
|
9
|
+
* (per-product star ratings) and from `customerSurveys` (NPS / CSAT
|
|
10
|
+
* / CES survey instruments with multi-question shapes). The
|
|
11
|
+
* primitive answers one question per order:
|
|
12
|
+
*
|
|
13
|
+
* "How did the customer rate the shipping, packaging, and
|
|
14
|
+
* likelihood to recommend us — and did they leave a comment that
|
|
15
|
+
* needs moderation or a public operator reply?"
|
|
16
|
+
*
|
|
17
|
+
* Surface:
|
|
18
|
+
* - `submitRating({ order_id, customer_id, shipping_rating,
|
|
19
|
+
* packaging_rating, recommend_rating, comment? })`
|
|
20
|
+
* Creates the rating row. Refuses ratings outside [1, 5],
|
|
21
|
+
* refuses control bytes in the comment, refuses a duplicate
|
|
22
|
+
* submission for the same order_id (one rating per order;
|
|
23
|
+
* enforced both by the UNIQUE constraint and by a primitive-
|
|
24
|
+
* layer pre-insert check so the error surfaces with a stable
|
|
25
|
+
* shape).
|
|
26
|
+
* - `getRating({ order_id })`
|
|
27
|
+
* Returns the rating row keyed by order_id, or null when the
|
|
28
|
+
* order has no rating yet. Renders the comment HTML-escaped
|
|
29
|
+
* via `b.template.escapeHtml` so callers that splice the
|
|
30
|
+
* rendered field into a template can't introduce script
|
|
31
|
+
* content from the customer-supplied string.
|
|
32
|
+
* - `ratingsForCustomer({ customer_id })`
|
|
33
|
+
* Lists every rating a customer has left, newest first.
|
|
34
|
+
* - `aggregateForPeriod({ from, to })`
|
|
35
|
+
* Returns the per-rating-axis mean + count + distribution
|
|
36
|
+
* buckets across the [from, to] window. Empty window returns
|
|
37
|
+
* zeroed buckets so the operator can distinguish "no
|
|
38
|
+
* ratings" from "low ratings."
|
|
39
|
+
* - `flagComment({ rating_id, reason, flagged_by })`
|
|
40
|
+
* Operator-side moderation: marks a comment as flagged so
|
|
41
|
+
* storefront renderers can suppress it. Refused when the
|
|
42
|
+
* comment is already flagged (operators clear-and-re-flag
|
|
43
|
+
* when the reason changes; the primitive doesn't silently
|
|
44
|
+
* overwrite). Refused when the rating row has no comment in
|
|
45
|
+
* the first place — flagging an empty string is a smell.
|
|
46
|
+
* - `responseToCustomer({ rating_id, response, responded_by })`
|
|
47
|
+
* Operator's public reply to the rating. One reply per
|
|
48
|
+
* rating; refused on second call. The reply renders HTML-
|
|
49
|
+
* escaped alongside the comment.
|
|
50
|
+
* - `topPositiveRatings({ from, to, limit })`
|
|
51
|
+
* Highest-scoring ratings (sum of the three axes) in the
|
|
52
|
+
* window, ordered (score DESC, occurred_at DESC, id DESC).
|
|
53
|
+
* - `topNegativeRatings({ from, to, limit })`
|
|
54
|
+
* Inverse of topPositive — lowest-scoring ratings first.
|
|
55
|
+
*
|
|
56
|
+
* Comment rendering: `getRating` exposes both the raw `comment`
|
|
57
|
+
* field and `comment_html` (the HTML-escaped form). Templates
|
|
58
|
+
* splicing the rendered field don't have to remember to escape;
|
|
59
|
+
* templates that need the raw text (export, analytics) read
|
|
60
|
+
* `comment`. Same shape for `response_text` / `response_html`.
|
|
61
|
+
*
|
|
62
|
+
* Composes:
|
|
63
|
+
* - `b.guardUuid` — UUID-shape validation for
|
|
64
|
+
* order_id / customer_id / actor /
|
|
65
|
+
* rating_id.
|
|
66
|
+
* - `b.uuid.v7` — row id (lexicographic monotonic so
|
|
67
|
+
* audit reads sort cleanly).
|
|
68
|
+
* - `b.template.escapeHtml` — comment + response render layer.
|
|
69
|
+
*
|
|
70
|
+
* Storage: `migrations-d1/0151_order_ratings.sql`.
|
|
71
|
+
*
|
|
72
|
+
* @primitive orderRatings
|
|
73
|
+
* @related reviews, customerSurveys, b.template.escapeHtml
|
|
74
|
+
*/
|
|
75
|
+
|
|
76
|
+
var bShop;
|
|
77
|
+
function _b() {
|
|
78
|
+
if (!bShop) bShop = require("./index");
|
|
79
|
+
return bShop.framework;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---- constants ----------------------------------------------------------
|
|
83
|
+
|
|
84
|
+
var MIN_RATING = 1;
|
|
85
|
+
var MAX_RATING = 5;
|
|
86
|
+
var RATING_AXES = Object.freeze(["shipping", "packaging", "recommend"]);
|
|
87
|
+
var MAX_COMMENT_LEN = 2000;
|
|
88
|
+
var MAX_FLAG_REASON_LEN = 500;
|
|
89
|
+
var MAX_RESPONSE_LEN = 2000;
|
|
90
|
+
var DEFAULT_LIST_LIMIT = 50;
|
|
91
|
+
var MAX_LIST_LIMIT = 500;
|
|
92
|
+
var DEFAULT_TOP_LIMIT = 10;
|
|
93
|
+
var MAX_TOP_LIMIT = 100;
|
|
94
|
+
|
|
95
|
+
var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
96
|
+
|
|
97
|
+
// ---- monotonic clock ----------------------------------------------------
|
|
98
|
+
//
|
|
99
|
+
// Submissions persist epoch-ms timestamps. Two same-millisecond
|
|
100
|
+
// `_now()` calls produce distinct integers so the row-ordering on
|
|
101
|
+
// `occurred_at` is deterministic without an extra tiebreaker column.
|
|
102
|
+
// Tests that submit ratings in tight loops rely on this for ordering
|
|
103
|
+
// assertions (topPositive / topNegative tiebreakers fall back to
|
|
104
|
+
// occurred_at DESC, then id DESC).
|
|
105
|
+
var _lastTs = 0;
|
|
106
|
+
function _now() {
|
|
107
|
+
var t = Date.now();
|
|
108
|
+
if (t <= _lastTs) { t = _lastTs + 1; }
|
|
109
|
+
_lastTs = t;
|
|
110
|
+
return t;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---- validators ---------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
function _uuid(s, label) {
|
|
116
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
117
|
+
catch (e) { throw new TypeError("orderRatings: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function _rating(n, label) {
|
|
121
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < MIN_RATING || n > MAX_RATING) {
|
|
122
|
+
throw new TypeError("orderRatings: " + label +
|
|
123
|
+
" must be an integer in [" + MIN_RATING + ", " + MAX_RATING + "]");
|
|
124
|
+
}
|
|
125
|
+
return n;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function _comment(s) {
|
|
129
|
+
if (s == null) return null;
|
|
130
|
+
if (typeof s !== "string") {
|
|
131
|
+
throw new TypeError("orderRatings: comment must be a string when provided");
|
|
132
|
+
}
|
|
133
|
+
if (!s.length) return null;
|
|
134
|
+
if (s.length > MAX_COMMENT_LEN) {
|
|
135
|
+
throw new TypeError("orderRatings: comment must be <= " + MAX_COMMENT_LEN + " chars");
|
|
136
|
+
}
|
|
137
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
138
|
+
throw new TypeError("orderRatings: comment must not contain control bytes");
|
|
139
|
+
}
|
|
140
|
+
return s;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function _flagReason(s) {
|
|
144
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_FLAG_REASON_LEN) {
|
|
145
|
+
throw new TypeError("orderRatings: reason must be a non-empty string <= " + MAX_FLAG_REASON_LEN + " chars");
|
|
146
|
+
}
|
|
147
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
148
|
+
throw new TypeError("orderRatings: reason must not contain control bytes");
|
|
149
|
+
}
|
|
150
|
+
return s;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function _responseText(s) {
|
|
154
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_RESPONSE_LEN) {
|
|
155
|
+
throw new TypeError("orderRatings: response must be a non-empty string <= " + MAX_RESPONSE_LEN + " chars");
|
|
156
|
+
}
|
|
157
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
158
|
+
throw new TypeError("orderRatings: response must not contain control bytes");
|
|
159
|
+
}
|
|
160
|
+
return s;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function _epoch(n, label) {
|
|
164
|
+
if (typeof n !== "number" || !Number.isInteger(n) || n < 0) {
|
|
165
|
+
throw new TypeError("orderRatings: " + label + " must be a non-negative integer (ms epoch)");
|
|
166
|
+
}
|
|
167
|
+
return n;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function _limit(n, defaultN, maxN, label) {
|
|
171
|
+
if (n == null) return defaultN;
|
|
172
|
+
if (!Number.isInteger(n) || n <= 0 || n > maxN) {
|
|
173
|
+
throw new TypeError("orderRatings: " + label + " must be an integer in [1, " + maxN + "]");
|
|
174
|
+
}
|
|
175
|
+
return n;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---- render helpers -----------------------------------------------------
|
|
179
|
+
|
|
180
|
+
function _esc(s) {
|
|
181
|
+
if (s == null) return null;
|
|
182
|
+
return _b().template.escapeHtml(s);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---- factory ------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
function create(opts) {
|
|
188
|
+
opts = opts || {};
|
|
189
|
+
var query = opts.query;
|
|
190
|
+
if (!query) {
|
|
191
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function _decode(row) {
|
|
195
|
+
if (!row) return null;
|
|
196
|
+
return {
|
|
197
|
+
id: row.id,
|
|
198
|
+
order_id: row.order_id,
|
|
199
|
+
customer_id: row.customer_id,
|
|
200
|
+
shipping_rating: Number(row.shipping_rating),
|
|
201
|
+
packaging_rating: Number(row.packaging_rating),
|
|
202
|
+
recommend_rating: Number(row.recommend_rating),
|
|
203
|
+
comment: row.comment == null ? null : row.comment,
|
|
204
|
+
comment_html: row.comment == null ? null : _esc(row.comment),
|
|
205
|
+
comment_flagged: row.comment_flagged === 1 || row.comment_flagged === true,
|
|
206
|
+
flag_reason: row.flag_reason == null ? null : row.flag_reason,
|
|
207
|
+
flag_actor: row.flag_actor == null ? null : row.flag_actor,
|
|
208
|
+
response_text: row.response_text == null ? null : row.response_text,
|
|
209
|
+
response_html: row.response_text == null ? null : _esc(row.response_text),
|
|
210
|
+
response_actor: row.response_actor == null ? null : row.response_actor,
|
|
211
|
+
response_at: row.response_at == null ? null : Number(row.response_at),
|
|
212
|
+
occurred_at: Number(row.occurred_at),
|
|
213
|
+
};
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
async function _rowById(id) {
|
|
217
|
+
var r = await query("SELECT * FROM order_ratings WHERE id = ?1", [id]);
|
|
218
|
+
return r.rows[0] || null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function _rowByOrder(orderId) {
|
|
222
|
+
var r = await query("SELECT * FROM order_ratings WHERE order_id = ?1", [orderId]);
|
|
223
|
+
return r.rows[0] || null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ---- submitRating ----------------------------------------------------
|
|
227
|
+
|
|
228
|
+
async function submitRating(input) {
|
|
229
|
+
if (!input || typeof input !== "object") {
|
|
230
|
+
throw new TypeError("orderRatings.submitRating: input object required");
|
|
231
|
+
}
|
|
232
|
+
var orderId = _uuid(input.order_id, "order_id");
|
|
233
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
234
|
+
var shipping = _rating(input.shipping_rating, "shipping_rating");
|
|
235
|
+
var packaging = _rating(input.packaging_rating, "packaging_rating");
|
|
236
|
+
var recommend = _rating(input.recommend_rating, "recommend_rating");
|
|
237
|
+
var comment = _comment(input.comment);
|
|
238
|
+
|
|
239
|
+
// Pre-insert duplicate check so a second submission surfaces a
|
|
240
|
+
// typed error with a stable code rather than a UNIQUE-constraint
|
|
241
|
+
// SQLite error message that varies across drivers. The schema
|
|
242
|
+
// UNIQUE is the second line of defense — a race that slips past
|
|
243
|
+
// this check still trips the constraint and the caller gets the
|
|
244
|
+
// driver-level error.
|
|
245
|
+
var existing = await _rowByOrder(orderId);
|
|
246
|
+
if (existing) {
|
|
247
|
+
var dupe = new Error("orderRatings.submitRating: order " + JSON.stringify(orderId) +
|
|
248
|
+
" already has a rating");
|
|
249
|
+
dupe.code = "ORDER_RATING_ALREADY_EXISTS";
|
|
250
|
+
throw dupe;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
var id = _b().uuid.v7();
|
|
254
|
+
var ts = _now();
|
|
255
|
+
await query(
|
|
256
|
+
"INSERT INTO order_ratings " +
|
|
257
|
+
"(id, order_id, customer_id, shipping_rating, packaging_rating, recommend_rating, " +
|
|
258
|
+
" comment, comment_flagged, flag_reason, flag_actor, " +
|
|
259
|
+
" response_text, response_actor, response_at, occurred_at) " +
|
|
260
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 0, NULL, NULL, NULL, NULL, NULL, ?8)",
|
|
261
|
+
[id, orderId, customerId, shipping, packaging, recommend, comment, ts],
|
|
262
|
+
);
|
|
263
|
+
return _decode(await _rowById(id));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ---- getRating -------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
async function getRating(input) {
|
|
269
|
+
if (!input || typeof input !== "object") {
|
|
270
|
+
throw new TypeError("orderRatings.getRating: input object required");
|
|
271
|
+
}
|
|
272
|
+
var orderId = _uuid(input.order_id, "order_id");
|
|
273
|
+
return _decode(await _rowByOrder(orderId));
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---- ratingsForCustomer ----------------------------------------------
|
|
277
|
+
|
|
278
|
+
async function ratingsForCustomer(input) {
|
|
279
|
+
if (!input || typeof input !== "object") {
|
|
280
|
+
throw new TypeError("orderRatings.ratingsForCustomer: input object required");
|
|
281
|
+
}
|
|
282
|
+
var customerId = _uuid(input.customer_id, "customer_id");
|
|
283
|
+
var limit = _limit(input.limit, DEFAULT_LIST_LIMIT, MAX_LIST_LIMIT, "limit");
|
|
284
|
+
var r = await query(
|
|
285
|
+
"SELECT * FROM order_ratings WHERE customer_id = ?1 " +
|
|
286
|
+
"ORDER BY occurred_at DESC, id DESC LIMIT ?2",
|
|
287
|
+
[customerId, limit],
|
|
288
|
+
);
|
|
289
|
+
var out = [];
|
|
290
|
+
for (var i = 0; i < r.rows.length; i += 1) out.push(_decode(r.rows[i]));
|
|
291
|
+
return out;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---- aggregateForPeriod ----------------------------------------------
|
|
295
|
+
|
|
296
|
+
async function aggregateForPeriod(input) {
|
|
297
|
+
if (!input || typeof input !== "object") {
|
|
298
|
+
throw new TypeError("orderRatings.aggregateForPeriod: input object required");
|
|
299
|
+
}
|
|
300
|
+
var from = _epoch(input.from, "from");
|
|
301
|
+
var to = _epoch(input.to, "to");
|
|
302
|
+
if (from > to) {
|
|
303
|
+
throw new TypeError("orderRatings.aggregateForPeriod: from must be <= to");
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
var r = await query(
|
|
307
|
+
"SELECT shipping_rating, packaging_rating, recommend_rating " +
|
|
308
|
+
"FROM order_ratings WHERE occurred_at >= ?1 AND occurred_at <= ?2",
|
|
309
|
+
[from, to],
|
|
310
|
+
);
|
|
311
|
+
|
|
312
|
+
// Seed bucket shapes from RATING_AXES so the rollup always carries
|
|
313
|
+
// every axis even when the window is empty — operators rendering
|
|
314
|
+
// a dashboard get the same column list regardless of traffic.
|
|
315
|
+
var axes = {};
|
|
316
|
+
for (var a = 0; a < RATING_AXES.length; a += 1) {
|
|
317
|
+
var axisLabel = RATING_AXES[a];
|
|
318
|
+
var dist = {};
|
|
319
|
+
for (var v = MIN_RATING; v <= MAX_RATING; v += 1) dist[String(v)] = 0;
|
|
320
|
+
axes[axisLabel] = { count: 0, sum: 0, mean: 0, distribution: dist };
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
324
|
+
var row = r.rows[i];
|
|
325
|
+
_accumulate(axes.shipping, Number(row.shipping_rating));
|
|
326
|
+
_accumulate(axes.packaging, Number(row.packaging_rating));
|
|
327
|
+
_accumulate(axes.recommend, Number(row.recommend_rating));
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
for (var b = 0; b < RATING_AXES.length; b += 1) {
|
|
331
|
+
var bucket = axes[RATING_AXES[b]];
|
|
332
|
+
if (bucket.count > 0) {
|
|
333
|
+
// Mean rounded to 2 decimals — operator-facing display
|
|
334
|
+
// precision. Sum + count are also exposed so a caller that
|
|
335
|
+
// wants more precision can recompute.
|
|
336
|
+
bucket.mean = Math.round((bucket.sum / bucket.count) * 100) / 100;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
return {
|
|
341
|
+
from: from,
|
|
342
|
+
to: to,
|
|
343
|
+
response_count: r.rows.length,
|
|
344
|
+
shipping: axes.shipping,
|
|
345
|
+
packaging: axes.packaging,
|
|
346
|
+
recommend: axes.recommend,
|
|
347
|
+
};
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// ---- flagComment -----------------------------------------------------
|
|
351
|
+
|
|
352
|
+
async function flagComment(input) {
|
|
353
|
+
if (!input || typeof input !== "object") {
|
|
354
|
+
throw new TypeError("orderRatings.flagComment: input object required");
|
|
355
|
+
}
|
|
356
|
+
var ratingId = _uuid(input.rating_id, "rating_id");
|
|
357
|
+
var reason = _flagReason(input.reason);
|
|
358
|
+
var flaggedBy = _uuid(input.flagged_by, "flagged_by");
|
|
359
|
+
|
|
360
|
+
var row = await _rowById(ratingId);
|
|
361
|
+
if (!row) {
|
|
362
|
+
var miss = new Error("orderRatings.flagComment: rating not found");
|
|
363
|
+
miss.code = "ORDER_RATING_NOT_FOUND";
|
|
364
|
+
throw miss;
|
|
365
|
+
}
|
|
366
|
+
if (row.comment == null) {
|
|
367
|
+
// Flagging an empty-comment rating is a smell — operators flag
|
|
368
|
+
// text, not the absence of text. Refuse so a UI bug that calls
|
|
369
|
+
// flagComment without checking the row first surfaces here.
|
|
370
|
+
var noComment = new Error("orderRatings.flagComment: rating has no comment to flag");
|
|
371
|
+
noComment.code = "ORDER_RATING_NO_COMMENT";
|
|
372
|
+
throw noComment;
|
|
373
|
+
}
|
|
374
|
+
if (row.comment_flagged === 1 || row.comment_flagged === true) {
|
|
375
|
+
var already = new Error("orderRatings.flagComment: comment already flagged");
|
|
376
|
+
already.code = "ORDER_RATING_ALREADY_FLAGGED";
|
|
377
|
+
throw already;
|
|
378
|
+
}
|
|
379
|
+
await query(
|
|
380
|
+
"UPDATE order_ratings SET comment_flagged = 1, flag_reason = ?1, flag_actor = ?2 " +
|
|
381
|
+
"WHERE id = ?3",
|
|
382
|
+
[reason, flaggedBy, ratingId],
|
|
383
|
+
);
|
|
384
|
+
return _decode(await _rowById(ratingId));
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ---- responseToCustomer ----------------------------------------------
|
|
388
|
+
|
|
389
|
+
async function responseToCustomer(input) {
|
|
390
|
+
if (!input || typeof input !== "object") {
|
|
391
|
+
throw new TypeError("orderRatings.responseToCustomer: input object required");
|
|
392
|
+
}
|
|
393
|
+
var ratingId = _uuid(input.rating_id, "rating_id");
|
|
394
|
+
var response = _responseText(input.response);
|
|
395
|
+
var respondedBy = _uuid(input.responded_by, "responded_by");
|
|
396
|
+
|
|
397
|
+
var row = await _rowById(ratingId);
|
|
398
|
+
if (!row) {
|
|
399
|
+
var miss = new Error("orderRatings.responseToCustomer: rating not found");
|
|
400
|
+
miss.code = "ORDER_RATING_NOT_FOUND";
|
|
401
|
+
throw miss;
|
|
402
|
+
}
|
|
403
|
+
if (row.response_text != null) {
|
|
404
|
+
// One operator reply per rating. A second call could be an
|
|
405
|
+
// edit; the primitive doesn't expose that path because the
|
|
406
|
+
// public reply is part of the storefront-visible audit trail
|
|
407
|
+
// and silent rewrites would obscure it. An operator who needs
|
|
408
|
+
// to revise composes a clear-and-rewrite path through the
|
|
409
|
+
// admin surface.
|
|
410
|
+
var already = new Error("orderRatings.responseToCustomer: rating already has a response");
|
|
411
|
+
already.code = "ORDER_RATING_ALREADY_RESPONDED";
|
|
412
|
+
throw already;
|
|
413
|
+
}
|
|
414
|
+
var ts = _now();
|
|
415
|
+
await query(
|
|
416
|
+
"UPDATE order_ratings SET response_text = ?1, response_actor = ?2, response_at = ?3 " +
|
|
417
|
+
"WHERE id = ?4",
|
|
418
|
+
[response, respondedBy, ts, ratingId],
|
|
419
|
+
);
|
|
420
|
+
return _decode(await _rowById(ratingId));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// ---- topPositive / topNegative ---------------------------------------
|
|
424
|
+
|
|
425
|
+
async function _topRatings(input, label, direction) {
|
|
426
|
+
if (!input || typeof input !== "object") {
|
|
427
|
+
throw new TypeError("orderRatings." + label + ": input object required");
|
|
428
|
+
}
|
|
429
|
+
var from = _epoch(input.from, "from");
|
|
430
|
+
var to = _epoch(input.to, "to");
|
|
431
|
+
if (from > to) {
|
|
432
|
+
throw new TypeError("orderRatings." + label + ": from must be <= to");
|
|
433
|
+
}
|
|
434
|
+
var limit = _limit(input.limit, DEFAULT_TOP_LIMIT, MAX_TOP_LIMIT, "limit");
|
|
435
|
+
|
|
436
|
+
// Sum of the three axes is the score. DESC for top-positive,
|
|
437
|
+
// ASC for top-negative. Secondary order on occurred_at DESC so
|
|
438
|
+
// the most recent rating wins ties; tertiary on id DESC for total
|
|
439
|
+
// ordering across same-millisecond submissions.
|
|
440
|
+
var orderClause = direction === "positive"
|
|
441
|
+
? "ORDER BY (shipping_rating + packaging_rating + recommend_rating) DESC, occurred_at DESC, id DESC"
|
|
442
|
+
: "ORDER BY (shipping_rating + packaging_rating + recommend_rating) ASC, occurred_at DESC, id DESC";
|
|
443
|
+
|
|
444
|
+
var r = await query(
|
|
445
|
+
"SELECT * FROM order_ratings WHERE occurred_at >= ?1 AND occurred_at <= ?2 " +
|
|
446
|
+
orderClause + " LIMIT ?3",
|
|
447
|
+
[from, to, limit],
|
|
448
|
+
);
|
|
449
|
+
var out = [];
|
|
450
|
+
for (var i = 0; i < r.rows.length; i += 1) out.push(_decode(r.rows[i]));
|
|
451
|
+
return out;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
async function topPositiveRatings(input) {
|
|
455
|
+
return _topRatings(input, "topPositiveRatings", "positive");
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function topNegativeRatings(input) {
|
|
459
|
+
return _topRatings(input, "topNegativeRatings", "negative");
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
RATING_AXES: RATING_AXES.slice(),
|
|
464
|
+
MIN_RATING: MIN_RATING,
|
|
465
|
+
MAX_RATING: MAX_RATING,
|
|
466
|
+
MAX_COMMENT_LEN: MAX_COMMENT_LEN,
|
|
467
|
+
MAX_FLAG_REASON_LEN: MAX_FLAG_REASON_LEN,
|
|
468
|
+
MAX_RESPONSE_LEN: MAX_RESPONSE_LEN,
|
|
469
|
+
|
|
470
|
+
submitRating: submitRating,
|
|
471
|
+
getRating: getRating,
|
|
472
|
+
ratingsForCustomer: ratingsForCustomer,
|
|
473
|
+
aggregateForPeriod: aggregateForPeriod,
|
|
474
|
+
flagComment: flagComment,
|
|
475
|
+
responseToCustomer: responseToCustomer,
|
|
476
|
+
topPositiveRatings: topPositiveRatings,
|
|
477
|
+
topNegativeRatings: topNegativeRatings,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function _accumulate(bucket, val) {
|
|
482
|
+
bucket.count += 1;
|
|
483
|
+
bucket.sum += val;
|
|
484
|
+
bucket.distribution[String(val)] = (bucket.distribution[String(val)] || 0) + 1;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
module.exports = {
|
|
488
|
+
create: create,
|
|
489
|
+
RATING_AXES: RATING_AXES,
|
|
490
|
+
MIN_RATING: MIN_RATING,
|
|
491
|
+
MAX_RATING: MAX_RATING,
|
|
492
|
+
MAX_COMMENT_LEN: MAX_COMMENT_LEN,
|
|
493
|
+
MAX_FLAG_REASON_LEN: MAX_FLAG_REASON_LEN,
|
|
494
|
+
MAX_RESPONSE_LEN: MAX_RESPONSE_LEN,
|
|
495
|
+
};
|