@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,712 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.customerNotes
|
|
4
|
+
* @title Customer notes — operator-side CRM annotations on a customer
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Durable, customer-level annotations a support agent or account
|
|
8
|
+
* manager wants surfaced every time that customer is on the line.
|
|
9
|
+
* Distinct from `orderNotes` (which carries per-order conversation
|
|
10
|
+
* threads): a `customer_notes` row is attached to the customer
|
|
11
|
+
* record itself and persists across that customer's entire order
|
|
12
|
+
* history.
|
|
13
|
+
*
|
|
14
|
+
* The shape covers the canonical operator vocabulary:
|
|
15
|
+
*
|
|
16
|
+
* "VIP — comp shipping where possible"
|
|
17
|
+
* "Always wants gift wrap on orders > $100"
|
|
18
|
+
* "Do not call after 6pm"
|
|
19
|
+
* "Allergic to peanut packaging — flag before fulfilment"
|
|
20
|
+
*
|
|
21
|
+
* Every note is operator-only. There is no customer-facing channel
|
|
22
|
+
* here — customer-visible content belongs in the customer-portal
|
|
23
|
+
* surface, never in this table.
|
|
24
|
+
*
|
|
25
|
+
* Surface:
|
|
26
|
+
* addNote({ customer_id, author, author_id?, body, kind?, tags? })
|
|
27
|
+
* getNote(note_id)
|
|
28
|
+
* notesForCustomer({ customer_id, kind?, tags?, cursor?, limit?,
|
|
29
|
+
* include_archived? })
|
|
30
|
+
* updateNote(note_id, patch)
|
|
31
|
+
* archiveNote(note_id) / unarchiveNote(note_id)
|
|
32
|
+
* pinNote(note_id) / unpinNote(note_id)
|
|
33
|
+
* searchByTag({ tag, limit, cursor })
|
|
34
|
+
* popularTags({ limit })
|
|
35
|
+
*
|
|
36
|
+
* Composes:
|
|
37
|
+
* - `b.guardUuid` — UUID-shape validation for note ids
|
|
38
|
+
* - `b.uuid.v7` — row primary keys (monotonic lex-sortable)
|
|
39
|
+
* - `b.pagination` — HMAC-tagged tuple cursors for the listings
|
|
40
|
+
*
|
|
41
|
+
* Storage: `migrations-d1/0134_customer_notes.sql` —
|
|
42
|
+
* `customer_notes` (single table).
|
|
43
|
+
*
|
|
44
|
+
* @primitive customerNotes
|
|
45
|
+
* @related b.guardUuid, b.uuid, b.pagination
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
var bShop;
|
|
49
|
+
function _b() {
|
|
50
|
+
if (!bShop) bShop = require("./index");
|
|
51
|
+
return bShop.framework;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---- constants ----------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
var ALLOWED_AUTHORS = Object.freeze(["operator", "system"]);
|
|
57
|
+
var ALLOWED_KINDS = Object.freeze(["general", "preference", "escalation", "warning", "billing"]);
|
|
58
|
+
|
|
59
|
+
var MAX_BODY_LEN = 8000;
|
|
60
|
+
var MAX_TAG_COUNT = 16;
|
|
61
|
+
var MAX_TAG_LEN = 64;
|
|
62
|
+
var MAX_CUSTOMER_ID = 200;
|
|
63
|
+
var MAX_AUTHOR_ID = 200;
|
|
64
|
+
|
|
65
|
+
var DEFAULT_LIMIT = 50;
|
|
66
|
+
var MAX_LIMIT = 200;
|
|
67
|
+
var DEFAULT_TAG_LIMIT = 20;
|
|
68
|
+
var MAX_TAG_FETCH = 100;
|
|
69
|
+
|
|
70
|
+
// Refuse C0 control bytes + DEL across the body, tags, and ids. CR/LF
|
|
71
|
+
// + tab survive in `body` so operators can wrap a multi-line note;
|
|
72
|
+
// tags + ids are single-line and refuse CR/LF too via the strict re.
|
|
73
|
+
var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
74
|
+
var CONTROL_BYTE_STRICT_RE = /[\x00-\x1f\x7f]/;
|
|
75
|
+
// Zero-width / direction-override / BOM family. Spelled with
|
|
76
|
+
// \u-escapes so the source file stays free of irregular-whitespace
|
|
77
|
+
// ESLint hits.
|
|
78
|
+
var ZERO_WIDTH_RE = new RegExp(
|
|
79
|
+
"[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
|
|
80
|
+
);
|
|
81
|
+
|
|
82
|
+
// Tag shape — lowercase alnum + dash + underscore. The operator UI
|
|
83
|
+
// canonicalises on input (lower-case) so the popularTags aggregate
|
|
84
|
+
// doesn't fragment between "VIP" and "vip".
|
|
85
|
+
var TAG_RE = /^[a-z0-9](?:[a-z0-9_-]{0,62}[a-z0-9])?$/;
|
|
86
|
+
|
|
87
|
+
// (pinned DESC, created_at DESC, id DESC) — pinned notes float to
|
|
88
|
+
// the top; the id tie-break keeps cursor pagination deterministic
|
|
89
|
+
// when two notes land in the same millisecond.
|
|
90
|
+
var LIST_ORDER_KEY = ["pinned:desc", "created_at:desc", "id:desc"];
|
|
91
|
+
// (created_at DESC, id DESC) for searchByTag — pin order doesn't
|
|
92
|
+
// apply to a single-tag scan across customers.
|
|
93
|
+
var TAG_LIST_ORDER_KEY = ["created_at:desc", "id:desc"];
|
|
94
|
+
|
|
95
|
+
// ---- monotonic clock ----------------------------------------------------
|
|
96
|
+
//
|
|
97
|
+
// Notes are listed by `created_at DESC`. Operators routinely add two
|
|
98
|
+
// notes in the same millisecond (paste-from-clipboard, bulk-import
|
|
99
|
+
// during onboarding) — the strict-monotonic clock guarantees distinct
|
|
100
|
+
// timestamps so the (created_at, id) tie-break never collapses to a
|
|
101
|
+
// single equivalence class. Tests that add notes in tight loops rely
|
|
102
|
+
// on this for ordering assertions.
|
|
103
|
+
var _lastTs = 0;
|
|
104
|
+
function _now() {
|
|
105
|
+
var t = Date.now();
|
|
106
|
+
if (t <= _lastTs) { t = _lastTs + 1; }
|
|
107
|
+
_lastTs = t;
|
|
108
|
+
return t;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- validators ---------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
function _customerId(s) {
|
|
114
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_CUSTOMER_ID) {
|
|
115
|
+
throw new TypeError("customerNotes: customer_id must be a non-empty string (<= " + MAX_CUSTOMER_ID + " chars)");
|
|
116
|
+
}
|
|
117
|
+
if (CONTROL_BYTE_STRICT_RE.test(s)) {
|
|
118
|
+
throw new TypeError("customerNotes: customer_id must not contain control bytes");
|
|
119
|
+
}
|
|
120
|
+
return s;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function _author(s) {
|
|
124
|
+
if (typeof s !== "string" || ALLOWED_AUTHORS.indexOf(s) === -1) {
|
|
125
|
+
throw new TypeError("customerNotes: author must be one of " + ALLOWED_AUTHORS.join(", "));
|
|
126
|
+
}
|
|
127
|
+
return s;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function _authorId(s) {
|
|
131
|
+
if (s == null) return null;
|
|
132
|
+
if (typeof s !== "string" || !s.length || s.length > MAX_AUTHOR_ID) {
|
|
133
|
+
throw new TypeError("customerNotes: author_id must be a non-empty string (<= " + MAX_AUTHOR_ID + " chars) when provided");
|
|
134
|
+
}
|
|
135
|
+
if (CONTROL_BYTE_STRICT_RE.test(s)) {
|
|
136
|
+
throw new TypeError("customerNotes: author_id must not contain control bytes");
|
|
137
|
+
}
|
|
138
|
+
return s;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function _kind(s) {
|
|
142
|
+
if (s == null) return "general";
|
|
143
|
+
if (typeof s !== "string" || ALLOWED_KINDS.indexOf(s) === -1) {
|
|
144
|
+
throw new TypeError("customerNotes: kind must be one of " + ALLOWED_KINDS.join(", "));
|
|
145
|
+
}
|
|
146
|
+
return s;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function _body(s) {
|
|
150
|
+
if (typeof s !== "string") {
|
|
151
|
+
throw new TypeError("customerNotes: body must be a string");
|
|
152
|
+
}
|
|
153
|
+
var trimmed = s.trim();
|
|
154
|
+
if (!trimmed.length) {
|
|
155
|
+
throw new TypeError("customerNotes: body must be non-empty after trim");
|
|
156
|
+
}
|
|
157
|
+
if (s.length > MAX_BODY_LEN) {
|
|
158
|
+
throw new TypeError("customerNotes: body must be <= " + MAX_BODY_LEN + " characters");
|
|
159
|
+
}
|
|
160
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
161
|
+
throw new TypeError("customerNotes: body must not contain control bytes");
|
|
162
|
+
}
|
|
163
|
+
if (ZERO_WIDTH_RE.test(s)) {
|
|
164
|
+
throw new TypeError("customerNotes: body must not contain zero-width / direction-override characters");
|
|
165
|
+
}
|
|
166
|
+
return s;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function _tag(t, label) {
|
|
170
|
+
if (typeof t !== "string" || !TAG_RE.test(t)) {
|
|
171
|
+
throw new TypeError("customerNotes: " + label + " must be lowercase alnum / underscore / dash, 1.." +
|
|
172
|
+
MAX_TAG_LEN + " chars (no leading/trailing dash)");
|
|
173
|
+
}
|
|
174
|
+
if (t.length > MAX_TAG_LEN) {
|
|
175
|
+
throw new TypeError("customerNotes: " + label + " must be <= " + MAX_TAG_LEN + " characters");
|
|
176
|
+
}
|
|
177
|
+
return t;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function _tags(input) {
|
|
181
|
+
if (input == null) return [];
|
|
182
|
+
if (!Array.isArray(input)) {
|
|
183
|
+
throw new TypeError("customerNotes: tags must be an array of strings");
|
|
184
|
+
}
|
|
185
|
+
if (input.length > MAX_TAG_COUNT) {
|
|
186
|
+
throw new TypeError("customerNotes: tags array must contain <= " + MAX_TAG_COUNT + " entries");
|
|
187
|
+
}
|
|
188
|
+
var seen = Object.create(null);
|
|
189
|
+
var out = [];
|
|
190
|
+
for (var i = 0; i < input.length; i += 1) {
|
|
191
|
+
var t = _tag(input[i], "tags[" + i + "]");
|
|
192
|
+
if (seen[t]) {
|
|
193
|
+
throw new TypeError("customerNotes: tags[" + i + "] duplicates a previous entry");
|
|
194
|
+
}
|
|
195
|
+
seen[t] = true;
|
|
196
|
+
out.push(t);
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function _filterTags(input) {
|
|
202
|
+
if (input == null) return null;
|
|
203
|
+
if (!Array.isArray(input)) {
|
|
204
|
+
throw new TypeError("customerNotes: tags filter must be an array of strings");
|
|
205
|
+
}
|
|
206
|
+
if (input.length === 0) return null;
|
|
207
|
+
if (input.length > MAX_TAG_COUNT) {
|
|
208
|
+
throw new TypeError("customerNotes: tags filter must contain <= " + MAX_TAG_COUNT + " entries");
|
|
209
|
+
}
|
|
210
|
+
var out = [];
|
|
211
|
+
for (var i = 0; i < input.length; i += 1) {
|
|
212
|
+
out.push(_tag(input[i], "tags filter[" + i + "]"));
|
|
213
|
+
}
|
|
214
|
+
return out;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function _limit(n, max) {
|
|
218
|
+
if (n == null) return DEFAULT_LIMIT;
|
|
219
|
+
if (!Number.isInteger(n) || n <= 0 || n > (max || MAX_LIMIT)) {
|
|
220
|
+
throw new TypeError("customerNotes: limit must be an integer 1..." + (max || MAX_LIMIT));
|
|
221
|
+
}
|
|
222
|
+
return n;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function _uuid(s, label) {
|
|
226
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
227
|
+
catch (e) { throw new TypeError("customerNotes: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---- row hydration ------------------------------------------------------
|
|
231
|
+
|
|
232
|
+
function _hydrate(row) {
|
|
233
|
+
if (!row) return row;
|
|
234
|
+
var tags;
|
|
235
|
+
try { tags = JSON.parse(row.tags_json || "[]"); }
|
|
236
|
+
catch (_e) { tags = []; }
|
|
237
|
+
return {
|
|
238
|
+
id: row.id,
|
|
239
|
+
customer_id: row.customer_id,
|
|
240
|
+
author: row.author,
|
|
241
|
+
author_id: row.author_id != null ? row.author_id : null,
|
|
242
|
+
body: row.body,
|
|
243
|
+
kind: row.kind,
|
|
244
|
+
tags: Array.isArray(tags) ? tags : [],
|
|
245
|
+
pinned: Number(row.pinned) === 1 ? 1 : 0,
|
|
246
|
+
archived_at: row.archived_at != null ? Number(row.archived_at) : null,
|
|
247
|
+
created_at: Number(row.created_at),
|
|
248
|
+
updated_at: Number(row.updated_at),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ---- factory ------------------------------------------------------------
|
|
253
|
+
|
|
254
|
+
function create(opts) {
|
|
255
|
+
opts = opts || {};
|
|
256
|
+
var query = opts.query;
|
|
257
|
+
if (!query) {
|
|
258
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Pagination cursors are HMAC-tagged via b.pagination so an
|
|
262
|
+
// operator can't hand-craft one to skip past a hidden note or
|
|
263
|
+
// replay across deployments. Production deployments must supply a
|
|
264
|
+
// derived secret (typically
|
|
265
|
+
// `b.crypto.namespaceHash("customer-notes-cursor", D1_BRIDGE_SECRET)`);
|
|
266
|
+
// dev / test gets a placeholder that boots without env wiring.
|
|
267
|
+
if (typeof opts.cursorSecret !== "string" || !opts.cursorSecret.length) {
|
|
268
|
+
if (process.env.NODE_ENV === "production") {
|
|
269
|
+
throw new Error("customerNotes.create: opts.cursorSecret is required in production");
|
|
270
|
+
}
|
|
271
|
+
opts.cursorSecret = "customer-notes-cursor-secret-dev-only";
|
|
272
|
+
}
|
|
273
|
+
var cursorSecret = opts.cursorSecret;
|
|
274
|
+
|
|
275
|
+
function _decodeCursor(cursor, label, orderKey) {
|
|
276
|
+
if (cursor == null) return null;
|
|
277
|
+
if (typeof cursor !== "string") {
|
|
278
|
+
throw new TypeError("customerNotes." + label + ": cursor must be an opaque string or null");
|
|
279
|
+
}
|
|
280
|
+
try {
|
|
281
|
+
var state = _b().pagination.decodeCursor(cursor, cursorSecret);
|
|
282
|
+
if (JSON.stringify(state.orderKey) !== JSON.stringify(orderKey)) {
|
|
283
|
+
throw new TypeError("customerNotes." + label + ": cursor orderKey mismatch");
|
|
284
|
+
}
|
|
285
|
+
return state.vals;
|
|
286
|
+
} catch (e) {
|
|
287
|
+
if (e instanceof TypeError) throw e;
|
|
288
|
+
throw new TypeError("customerNotes." + label + ": cursor — " + (e && e.message || "malformed"));
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function _encodeNextNotes(rows, limit) {
|
|
293
|
+
var last = rows[rows.length - 1];
|
|
294
|
+
if (!last || rows.length < limit) return null;
|
|
295
|
+
return _b().pagination.encodeCursor({
|
|
296
|
+
orderKey: LIST_ORDER_KEY,
|
|
297
|
+
vals: [last.pinned, last.created_at, last.id],
|
|
298
|
+
forward: true,
|
|
299
|
+
}, cursorSecret);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function _encodeNextTag(rows, limit) {
|
|
303
|
+
var last = rows[rows.length - 1];
|
|
304
|
+
if (!last || rows.length < limit) return null;
|
|
305
|
+
return _b().pagination.encodeCursor({
|
|
306
|
+
orderKey: TAG_LIST_ORDER_KEY,
|
|
307
|
+
vals: [last.created_at, last.id],
|
|
308
|
+
forward: true,
|
|
309
|
+
}, cursorSecret);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
async function _getRaw(id) {
|
|
313
|
+
var r = await query("SELECT * FROM customer_notes WHERE id = ?1", [id]);
|
|
314
|
+
return r.rows[0] || null;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ---- addNote ---------------------------------------------------------
|
|
318
|
+
|
|
319
|
+
async function addNote(input) {
|
|
320
|
+
if (!input || typeof input !== "object") {
|
|
321
|
+
throw new TypeError("customerNotes.addNote: input object required");
|
|
322
|
+
}
|
|
323
|
+
var customerId = _customerId(input.customer_id);
|
|
324
|
+
var author = _author(input.author);
|
|
325
|
+
var authorId = _authorId(input.author_id);
|
|
326
|
+
var body = _body(input.body);
|
|
327
|
+
var kind = _kind(input.kind);
|
|
328
|
+
var tags = _tags(input.tags);
|
|
329
|
+
|
|
330
|
+
var id = _b().uuid.v7();
|
|
331
|
+
var ts = _now();
|
|
332
|
+
await query(
|
|
333
|
+
"INSERT INTO customer_notes " +
|
|
334
|
+
"(id, customer_id, author, author_id, body, kind, tags_json, pinned, archived_at, created_at, updated_at) " +
|
|
335
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 0, NULL, ?8, ?8)",
|
|
336
|
+
[id, customerId, author, authorId, body, kind, JSON.stringify(tags), ts],
|
|
337
|
+
);
|
|
338
|
+
return _hydrate(await _getRaw(id));
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// ---- getNote ---------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
async function getNote(id) {
|
|
344
|
+
_uuid(id, "note_id");
|
|
345
|
+
return _hydrate(await _getRaw(id));
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ---- notesForCustomer ------------------------------------------------
|
|
349
|
+
|
|
350
|
+
async function notesForCustomer(listOpts) {
|
|
351
|
+
if (!listOpts || typeof listOpts !== "object") {
|
|
352
|
+
throw new TypeError("customerNotes.notesForCustomer: input object required");
|
|
353
|
+
}
|
|
354
|
+
var customerId = _customerId(listOpts.customer_id);
|
|
355
|
+
var kind = listOpts.kind == null ? null : _kind(listOpts.kind);
|
|
356
|
+
var tagFilter = _filterTags(listOpts.tags);
|
|
357
|
+
var includeArchived = listOpts.include_archived === true;
|
|
358
|
+
if (listOpts.include_archived != null && typeof listOpts.include_archived !== "boolean") {
|
|
359
|
+
throw new TypeError("customerNotes.notesForCustomer: include_archived must be a boolean when provided");
|
|
360
|
+
}
|
|
361
|
+
var limit = _limit(listOpts.limit, MAX_LIMIT);
|
|
362
|
+
var cursorVals = _decodeCursor(listOpts.cursor, "notesForCustomer", LIST_ORDER_KEY);
|
|
363
|
+
|
|
364
|
+
var where = ["customer_id = ?1"];
|
|
365
|
+
var params = [customerId];
|
|
366
|
+
var idx = 2;
|
|
367
|
+
if (!includeArchived) {
|
|
368
|
+
where.push("archived_at IS NULL");
|
|
369
|
+
}
|
|
370
|
+
if (kind != null) {
|
|
371
|
+
where.push("kind = ?" + idx);
|
|
372
|
+
params.push(kind);
|
|
373
|
+
idx += 1;
|
|
374
|
+
}
|
|
375
|
+
if (cursorVals) {
|
|
376
|
+
var a = idx; // pinned
|
|
377
|
+
var b = idx + 1; // created_at
|
|
378
|
+
var c = idx + 2; // id
|
|
379
|
+
where.push(
|
|
380
|
+
"(pinned < ?" + a + " OR " +
|
|
381
|
+
"(pinned = ?" + a + " AND created_at < ?" + b + ") OR " +
|
|
382
|
+
"(pinned = ?" + a + " AND created_at = ?" + b + " AND id < ?" + c + "))"
|
|
383
|
+
);
|
|
384
|
+
params.push(cursorVals[0], cursorVals[1], cursorVals[2]);
|
|
385
|
+
idx += 3;
|
|
386
|
+
}
|
|
387
|
+
params.push(limit);
|
|
388
|
+
var sql = "SELECT * FROM customer_notes WHERE " + where.join(" AND ") +
|
|
389
|
+
" ORDER BY pinned DESC, created_at DESC, id DESC LIMIT ?" + idx;
|
|
390
|
+
var r = await query(sql, params);
|
|
391
|
+
var rows = r.rows.map(_hydrate);
|
|
392
|
+
|
|
393
|
+
// Capture the SQL-page tail BEFORE the in-memory tag filter — the
|
|
394
|
+
// cursor must advance over every candidate the operator already
|
|
395
|
+
// saw, including the ones the tag filter dropped. Otherwise a
|
|
396
|
+
// tag-filtered listing would re-visit the dropped rows on every
|
|
397
|
+
// subsequent page until the cursor finally crossed them.
|
|
398
|
+
var nextCursor = _encodeNextNotes(rows, limit);
|
|
399
|
+
|
|
400
|
+
// Tag filter applied in-memory (small page sizes; SQLite LIKE-on-
|
|
401
|
+
// JSON would force a full scan and lock the operator into one
|
|
402
|
+
// dialect). The match semantics are ANY: a note matches when at
|
|
403
|
+
// least one filter tag appears on the note. Use a single-tag
|
|
404
|
+
// searchByTag() when the operator wants a cross-customer view.
|
|
405
|
+
if (tagFilter && tagFilter.length) {
|
|
406
|
+
var filtered = [];
|
|
407
|
+
for (var i = 0; i < rows.length; i += 1) {
|
|
408
|
+
var noteTags = rows[i].tags || [];
|
|
409
|
+
var matched = false;
|
|
410
|
+
for (var j = 0; j < tagFilter.length && !matched; j += 1) {
|
|
411
|
+
if (noteTags.indexOf(tagFilter[j]) !== -1) matched = true;
|
|
412
|
+
}
|
|
413
|
+
if (matched) filtered.push(rows[i]);
|
|
414
|
+
}
|
|
415
|
+
rows = filtered;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
return { rows: rows, next_cursor: nextCursor };
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ---- updateNote ------------------------------------------------------
|
|
422
|
+
//
|
|
423
|
+
// Patch the mutable columns of an existing note. The set is small on
|
|
424
|
+
// purpose: customer_id / author / author_id are stamped at creation
|
|
425
|
+
// and don't move (a re-attribution is a delete + re-create); body /
|
|
426
|
+
// kind / tags are the operator-editable surface.
|
|
427
|
+
|
|
428
|
+
async function updateNote(id, patch) {
|
|
429
|
+
_uuid(id, "note_id");
|
|
430
|
+
if (!patch || typeof patch !== "object") {
|
|
431
|
+
throw new TypeError("customerNotes.updateNote: patch object required");
|
|
432
|
+
}
|
|
433
|
+
var keys = Object.keys(patch);
|
|
434
|
+
if (!keys.length) {
|
|
435
|
+
throw new TypeError("customerNotes.updateNote: patch must set at least one column");
|
|
436
|
+
}
|
|
437
|
+
var ALLOWED = { body: true, kind: true, tags: true };
|
|
438
|
+
for (var k = 0; k < keys.length; k += 1) {
|
|
439
|
+
if (!ALLOWED[keys[k]]) {
|
|
440
|
+
throw new TypeError("customerNotes.updateNote: unsupported column " + JSON.stringify(keys[k]));
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
var existing = await _getRaw(id);
|
|
445
|
+
if (!existing) {
|
|
446
|
+
var err = new Error("customerNotes.updateNote: note " + id + " not found");
|
|
447
|
+
err.code = "CUSTOMER_NOTE_NOT_FOUND";
|
|
448
|
+
throw err;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
var sets = [];
|
|
452
|
+
var params = [];
|
|
453
|
+
var idx = 1;
|
|
454
|
+
if (Object.prototype.hasOwnProperty.call(patch, "body")) {
|
|
455
|
+
sets.push("body = ?" + idx);
|
|
456
|
+
params.push(_body(patch.body));
|
|
457
|
+
idx += 1;
|
|
458
|
+
}
|
|
459
|
+
if (Object.prototype.hasOwnProperty.call(patch, "kind")) {
|
|
460
|
+
// Explicit null on update would mean "clear kind", which the
|
|
461
|
+
// schema refuses (NOT NULL CHECK). Validate as a kind value.
|
|
462
|
+
sets.push("kind = ?" + idx);
|
|
463
|
+
params.push(_kind(patch.kind));
|
|
464
|
+
idx += 1;
|
|
465
|
+
}
|
|
466
|
+
if (Object.prototype.hasOwnProperty.call(patch, "tags")) {
|
|
467
|
+
sets.push("tags_json = ?" + idx);
|
|
468
|
+
params.push(JSON.stringify(_tags(patch.tags)));
|
|
469
|
+
idx += 1;
|
|
470
|
+
}
|
|
471
|
+
var ts = _now();
|
|
472
|
+
sets.push("updated_at = ?" + idx);
|
|
473
|
+
params.push(ts);
|
|
474
|
+
idx += 1;
|
|
475
|
+
params.push(id);
|
|
476
|
+
await query(
|
|
477
|
+
"UPDATE customer_notes SET " + sets.join(", ") + " WHERE id = ?" + idx,
|
|
478
|
+
params,
|
|
479
|
+
);
|
|
480
|
+
return _hydrate(await _getRaw(id));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// ---- archive / unarchive --------------------------------------------
|
|
484
|
+
|
|
485
|
+
async function archiveNote(id) {
|
|
486
|
+
_uuid(id, "note_id");
|
|
487
|
+
var existing = await _getRaw(id);
|
|
488
|
+
if (!existing) {
|
|
489
|
+
var err = new Error("customerNotes.archiveNote: note " + id + " not found");
|
|
490
|
+
err.code = "CUSTOMER_NOTE_NOT_FOUND";
|
|
491
|
+
throw err;
|
|
492
|
+
}
|
|
493
|
+
if (existing.archived_at != null) {
|
|
494
|
+
// Idempotent — re-archive returns the unchanged row.
|
|
495
|
+
return _hydrate(existing);
|
|
496
|
+
}
|
|
497
|
+
var ts = _now();
|
|
498
|
+
await query(
|
|
499
|
+
"UPDATE customer_notes SET archived_at = ?1, updated_at = ?1 WHERE id = ?2",
|
|
500
|
+
[ts, id],
|
|
501
|
+
);
|
|
502
|
+
return _hydrate(await _getRaw(id));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function unarchiveNote(id) {
|
|
506
|
+
_uuid(id, "note_id");
|
|
507
|
+
var existing = await _getRaw(id);
|
|
508
|
+
if (!existing) {
|
|
509
|
+
var err = new Error("customerNotes.unarchiveNote: note " + id + " not found");
|
|
510
|
+
err.code = "CUSTOMER_NOTE_NOT_FOUND";
|
|
511
|
+
throw err;
|
|
512
|
+
}
|
|
513
|
+
if (existing.archived_at == null) {
|
|
514
|
+
return _hydrate(existing);
|
|
515
|
+
}
|
|
516
|
+
var ts = _now();
|
|
517
|
+
await query(
|
|
518
|
+
"UPDATE customer_notes SET archived_at = NULL, updated_at = ?1 WHERE id = ?2",
|
|
519
|
+
[ts, id],
|
|
520
|
+
);
|
|
521
|
+
return _hydrate(await _getRaw(id));
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// ---- pin / unpin -----------------------------------------------------
|
|
525
|
+
|
|
526
|
+
async function pinNote(id) {
|
|
527
|
+
_uuid(id, "note_id");
|
|
528
|
+
var ts = _now();
|
|
529
|
+
var r = await query(
|
|
530
|
+
"UPDATE customer_notes SET pinned = 1, updated_at = ?1 WHERE id = ?2",
|
|
531
|
+
[ts, id],
|
|
532
|
+
);
|
|
533
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
534
|
+
var err = new Error("customerNotes.pinNote: note " + id + " not found");
|
|
535
|
+
err.code = "CUSTOMER_NOTE_NOT_FOUND";
|
|
536
|
+
throw err;
|
|
537
|
+
}
|
|
538
|
+
return _hydrate(await _getRaw(id));
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
async function unpinNote(id) {
|
|
542
|
+
_uuid(id, "note_id");
|
|
543
|
+
var ts = _now();
|
|
544
|
+
var r = await query(
|
|
545
|
+
"UPDATE customer_notes SET pinned = 0, updated_at = ?1 WHERE id = ?2",
|
|
546
|
+
[ts, id],
|
|
547
|
+
);
|
|
548
|
+
if (Number(r.rowCount || 0) === 0) {
|
|
549
|
+
var err = new Error("customerNotes.unpinNote: note " + id + " not found");
|
|
550
|
+
err.code = "CUSTOMER_NOTE_NOT_FOUND";
|
|
551
|
+
throw err;
|
|
552
|
+
}
|
|
553
|
+
return _hydrate(await _getRaw(id));
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// ---- searchByTag -----------------------------------------------------
|
|
557
|
+
//
|
|
558
|
+
// Cross-customer scan for every note carrying a single tag. The
|
|
559
|
+
// operator console uses this to find every customer flagged "vip"
|
|
560
|
+
// or "fraud-watch" across the entire customer base.
|
|
561
|
+
|
|
562
|
+
async function searchByTag(input) {
|
|
563
|
+
if (!input || typeof input !== "object") {
|
|
564
|
+
throw new TypeError("customerNotes.searchByTag: input object required");
|
|
565
|
+
}
|
|
566
|
+
var tag = _tag(input.tag, "tag");
|
|
567
|
+
var limit = _limit(input.limit, MAX_LIMIT);
|
|
568
|
+
var cursorVals = _decodeCursor(input.cursor, "searchByTag", TAG_LIST_ORDER_KEY);
|
|
569
|
+
|
|
570
|
+
var where = ["archived_at IS NULL"];
|
|
571
|
+
var params = [];
|
|
572
|
+
var idx = 1;
|
|
573
|
+
if (cursorVals) {
|
|
574
|
+
var a = idx; // created_at
|
|
575
|
+
var b = idx + 1; // id
|
|
576
|
+
where.push(
|
|
577
|
+
"(created_at < ?" + a + " OR (created_at = ?" + a + " AND id < ?" + b + "))"
|
|
578
|
+
);
|
|
579
|
+
params.push(cursorVals[0], cursorVals[1]);
|
|
580
|
+
idx += 2;
|
|
581
|
+
}
|
|
582
|
+
params.push(limit);
|
|
583
|
+
// Fetch a 2× buffer so the in-memory tag filter has headroom to
|
|
584
|
+
// reach the requested page size even when half the candidates
|
|
585
|
+
// miss. Operators with high tag-cardinality customers can call
|
|
586
|
+
// again with the returned cursor to drain the rest.
|
|
587
|
+
var sql = "SELECT * FROM customer_notes WHERE " + where.join(" AND ") +
|
|
588
|
+
" ORDER BY created_at DESC, id DESC LIMIT ?" + idx;
|
|
589
|
+
// Replace the limit slot with a buffered fetch.
|
|
590
|
+
var bufferedLimit = Math.min(limit * 4, MAX_LIMIT * 4);
|
|
591
|
+
params[params.length - 1] = bufferedLimit;
|
|
592
|
+
var r = await query(sql, params);
|
|
593
|
+
|
|
594
|
+
var matches = [];
|
|
595
|
+
var lastScanned = null;
|
|
596
|
+
for (var i = 0; i < r.rows.length && matches.length < limit; i += 1) {
|
|
597
|
+
var hydrated = _hydrate(r.rows[i]);
|
|
598
|
+
lastScanned = hydrated;
|
|
599
|
+
if (hydrated.tags.indexOf(tag) !== -1) matches.push(hydrated);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Cursor advances on the last SCANNED row, not the last match —
|
|
603
|
+
// otherwise a page that exhausts its buffer with mostly-misses
|
|
604
|
+
// would emit a null cursor while subsequent matches still wait
|
|
605
|
+
// behind the buffer tail. The caller pages until the SQL fetch
|
|
606
|
+
// returns fewer rows than the buffered limit (signalling "no
|
|
607
|
+
// more candidates anywhere"), at which point we emit null.
|
|
608
|
+
var nextCursor = null;
|
|
609
|
+
if (lastScanned && r.rows.length >= bufferedLimit) {
|
|
610
|
+
nextCursor = _b().pagination.encodeCursor({
|
|
611
|
+
orderKey: TAG_LIST_ORDER_KEY,
|
|
612
|
+
vals: [lastScanned.created_at, lastScanned.id],
|
|
613
|
+
forward: true,
|
|
614
|
+
}, cursorSecret);
|
|
615
|
+
} else if (matches.length === limit && r.rows.length > matches.length) {
|
|
616
|
+
// We filled the requested page before exhausting the buffer —
|
|
617
|
+
// hand back the last-match cursor so the next call resumes
|
|
618
|
+
// immediately after.
|
|
619
|
+
nextCursor = _b().pagination.encodeCursor({
|
|
620
|
+
orderKey: TAG_LIST_ORDER_KEY,
|
|
621
|
+
vals: [matches[matches.length - 1].created_at, matches[matches.length - 1].id],
|
|
622
|
+
forward: true,
|
|
623
|
+
}, cursorSecret);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
return { rows: matches, next_cursor: nextCursor };
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// ---- popularTags -----------------------------------------------------
|
|
630
|
+
//
|
|
631
|
+
// Aggregate the in-use tag vocabulary, ordered by usage count DESC.
|
|
632
|
+
// The operator console uses this to populate the tag picker so
|
|
633
|
+
// agents tag-on-create with the existing taxonomy instead of
|
|
634
|
+
// inventing fresh near-duplicates ("vip" vs "vip-customer").
|
|
635
|
+
|
|
636
|
+
async function popularTags(input) {
|
|
637
|
+
input = input || {};
|
|
638
|
+
var limit;
|
|
639
|
+
if (input.limit == null) {
|
|
640
|
+
limit = DEFAULT_TAG_LIMIT;
|
|
641
|
+
} else if (!Number.isInteger(input.limit) || input.limit <= 0 || input.limit > MAX_TAG_FETCH) {
|
|
642
|
+
throw new TypeError("customerNotes.popularTags: limit must be an integer 1..." + MAX_TAG_FETCH);
|
|
643
|
+
} else {
|
|
644
|
+
limit = input.limit;
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// Single full scan of non-archived notes. The table fits this
|
|
648
|
+
// shape because cardinality is bounded (an operator typically
|
|
649
|
+
// carries hundreds-to-thousands of customer notes, not millions);
|
|
650
|
+
// when the table outgrows the assumption the aggregation moves to
|
|
651
|
+
// a materialised view in a follow-up primitive.
|
|
652
|
+
var r = await query(
|
|
653
|
+
"SELECT tags_json FROM customer_notes WHERE archived_at IS NULL",
|
|
654
|
+
[],
|
|
655
|
+
);
|
|
656
|
+
var counts = Object.create(null);
|
|
657
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
658
|
+
var tags;
|
|
659
|
+
try { tags = JSON.parse(r.rows[i].tags_json || "[]"); }
|
|
660
|
+
catch (_e) { tags = []; }
|
|
661
|
+
if (!Array.isArray(tags)) continue;
|
|
662
|
+
for (var j = 0; j < tags.length; j += 1) {
|
|
663
|
+
var t = tags[j];
|
|
664
|
+
if (typeof t !== "string") continue;
|
|
665
|
+
counts[t] = (counts[t] || 0) + 1;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
var keys = Object.keys(counts);
|
|
669
|
+
keys.sort(function (a, b) {
|
|
670
|
+
var d = counts[b] - counts[a];
|
|
671
|
+
if (d !== 0) return d;
|
|
672
|
+
// Alphabetical tie-break so the operator UI doesn't shuffle the
|
|
673
|
+
// tag picker across reloads when two tags tie on count.
|
|
674
|
+
return a < b ? -1 : (a > b ? 1 : 0);
|
|
675
|
+
});
|
|
676
|
+
var out = [];
|
|
677
|
+
for (var k = 0; k < keys.length && out.length < limit; k += 1) {
|
|
678
|
+
out.push({ tag: keys[k], count: counts[keys[k]] });
|
|
679
|
+
}
|
|
680
|
+
return out;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
return {
|
|
684
|
+
ALLOWED_AUTHORS: ALLOWED_AUTHORS.slice(),
|
|
685
|
+
ALLOWED_KINDS: ALLOWED_KINDS.slice(),
|
|
686
|
+
MAX_BODY_LEN: MAX_BODY_LEN,
|
|
687
|
+
MAX_TAG_COUNT: MAX_TAG_COUNT,
|
|
688
|
+
MAX_TAG_LEN: MAX_TAG_LEN,
|
|
689
|
+
MAX_LIMIT: MAX_LIMIT,
|
|
690
|
+
|
|
691
|
+
addNote: addNote,
|
|
692
|
+
getNote: getNote,
|
|
693
|
+
notesForCustomer: notesForCustomer,
|
|
694
|
+
updateNote: updateNote,
|
|
695
|
+
archiveNote: archiveNote,
|
|
696
|
+
unarchiveNote: unarchiveNote,
|
|
697
|
+
pinNote: pinNote,
|
|
698
|
+
unpinNote: unpinNote,
|
|
699
|
+
searchByTag: searchByTag,
|
|
700
|
+
popularTags: popularTags,
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
module.exports = {
|
|
705
|
+
create: create,
|
|
706
|
+
ALLOWED_AUTHORS: ALLOWED_AUTHORS,
|
|
707
|
+
ALLOWED_KINDS: ALLOWED_KINDS,
|
|
708
|
+
MAX_BODY_LEN: MAX_BODY_LEN,
|
|
709
|
+
MAX_TAG_COUNT: MAX_TAG_COUNT,
|
|
710
|
+
MAX_TAG_LEN: MAX_TAG_LEN,
|
|
711
|
+
MAX_LIMIT: MAX_LIMIT,
|
|
712
|
+
};
|