@blamejs/blamejs-shop 0.0.61 → 0.0.64
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 +6 -0
- package/lib/compliance-export.js +614 -0
- package/lib/email-campaigns.js +844 -0
- package/lib/error-log.js +525 -0
- package/lib/geolocation.js +651 -0
- package/lib/gift-registry.js +820 -0
- package/lib/index.js +15 -0
- package/lib/invoice-renderer.js +618 -0
- package/lib/live-chat.js +714 -0
- package/lib/loyalty-redemption.js +673 -0
- package/lib/plan-changes.js +508 -0
- package/lib/refund-policy.js +965 -0
- package/lib/sms-dispatcher.js +7 -1
- package/lib/stock-transfers.js +777 -0
- package/lib/store-credit.js +565 -0
- package/lib/storefront-dashboards.js +863 -0
- package/lib/vendors.js +797 -0
- package/package.json +1 -1
package/lib/live-chat.js
ADDED
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* @module shop.liveChat
|
|
4
|
+
* @title Live chat — real-time customer-service queueing
|
|
5
|
+
*
|
|
6
|
+
* @intro
|
|
7
|
+
* Synchronous storefront customer-service surface. A visitor opens
|
|
8
|
+
* the chat widget; this primitive enqueues the session; an operator
|
|
9
|
+
* picks it up; messages flow back and forth until either party
|
|
10
|
+
* closes the session or the reaper marks it abandoned.
|
|
11
|
+
*
|
|
12
|
+
* Distinct from `supportTickets` — that primitive is for
|
|
13
|
+
* asynchronous correspondence with SLA timers measured in hours or
|
|
14
|
+
* days. This one is for the synchronous chat window: pickup
|
|
15
|
+
* measured in seconds, transcript bounded by one sitting.
|
|
16
|
+
*
|
|
17
|
+
* Six-state FSM (mirrors the CHECK enum in the migration):
|
|
18
|
+
*
|
|
19
|
+
* openSession
|
|
20
|
+
* (no row) -> queued
|
|
21
|
+
* Stamps the visitor session hash, the optional customer_id /
|
|
22
|
+
* email hash, the source page, and `opened_at`. Seeds the
|
|
23
|
+
* transcript with the visitor's initial_message when supplied.
|
|
24
|
+
*
|
|
25
|
+
* enqueueSession(session_id)
|
|
26
|
+
* queued -> queued (no-op when already queued; refuses
|
|
27
|
+
* otherwise). Surfaces a system message recording the re-
|
|
28
|
+
* queue beat so the transcript stays complete when an
|
|
29
|
+
* operator releases the session back to the dispatcher.
|
|
30
|
+
*
|
|
31
|
+
* assignToOperator({ session_id, operator_id })
|
|
32
|
+
* queued -> assigned. Stamps `assigned_operator_id` +
|
|
33
|
+
* `assigned_at`, emits a system "operator joined" message.
|
|
34
|
+
*
|
|
35
|
+
* recordMessage({ session_id, author, body })
|
|
36
|
+
* assigned|active|waiting -> active (customer)
|
|
37
|
+
* | waiting (operator)
|
|
38
|
+
* Appends to the transcript and advances `last_activity_at`.
|
|
39
|
+
* Author='system' is reserved for the primitive's own internal
|
|
40
|
+
* writes (no public emission shape — operators can record
|
|
41
|
+
* customer or operator messages only).
|
|
42
|
+
*
|
|
43
|
+
* closeSession({ session_id, reason })
|
|
44
|
+
* any non-terminal -> closed. Stamps `closed_at` + reason.
|
|
45
|
+
*
|
|
46
|
+
* cleanupAbandoned({ idle_minutes })
|
|
47
|
+
* any non-terminal whose `last_activity_at` is older than
|
|
48
|
+
* idle_minutes -> abandoned. Emits a system close message so
|
|
49
|
+
* the transcript records why the session ended.
|
|
50
|
+
*
|
|
51
|
+
* Visitor session id + email are hashed at the door via
|
|
52
|
+
* `b.crypto.namespaceHash`. The raw values never land. The
|
|
53
|
+
* storefront re-derives the hash from the visitor's live session
|
|
54
|
+
* id when it needs to look up an in-flight chat.
|
|
55
|
+
*
|
|
56
|
+
* Composition:
|
|
57
|
+
* - `b.guardUuid` — UUID-shape validation for ids
|
|
58
|
+
* - `b.guardEmail` — strict-profile validate + sanitize
|
|
59
|
+
* - `b.crypto.namespaceHash` — visitor / email hashing (SHA3-512)
|
|
60
|
+
* - `b.uuid.v7` — row ids (sortable)
|
|
61
|
+
*
|
|
62
|
+
* Storage:
|
|
63
|
+
* - `chat_sessions` (migration `0113_live_chat.sql`)
|
|
64
|
+
* - `chat_messages` (same migration)
|
|
65
|
+
* - `chat_operator_state` (same migration)
|
|
66
|
+
*/
|
|
67
|
+
|
|
68
|
+
var MAX_BODY_LEN = 4000;
|
|
69
|
+
var MAX_REASON_LEN = 280;
|
|
70
|
+
var MAX_SOURCE_PAGE = 1024;
|
|
71
|
+
var MAX_SESSION_ID_LEN = 256;
|
|
72
|
+
var MAX_LIST_LIMIT = 200;
|
|
73
|
+
var DEFAULT_QUEUE_LIMIT = 50;
|
|
74
|
+
var MAX_IDLE_MINUTES = 60 * 24 * 7; // 7 days — generous upper bound
|
|
75
|
+
var MIN_IDLE_MINUTES = 1;
|
|
76
|
+
|
|
77
|
+
var VISITOR_NAMESPACE = "live-chat-visitor";
|
|
78
|
+
var EMAIL_NAMESPACE = "live-chat-email";
|
|
79
|
+
|
|
80
|
+
var ALLOWED_AUTHORS = ["customer", "operator", "system"];
|
|
81
|
+
var PUBLIC_AUTHORS = ["customer", "operator"];
|
|
82
|
+
var ALLOWED_STATUSES = ["queued", "assigned", "active", "waiting", "closed", "abandoned"];
|
|
83
|
+
var NON_TERMINAL_STATUSES = ["queued", "assigned", "active", "waiting"];
|
|
84
|
+
var OPERATOR_STATUSES = ["available", "away", "offline"];
|
|
85
|
+
|
|
86
|
+
// FSM allow-list keyed by from-state -> set of legal to-states.
|
|
87
|
+
// `closeSession` is a global edge: every non-terminal status can move
|
|
88
|
+
// to `closed`. `cleanupAbandoned` is a global edge to `abandoned`.
|
|
89
|
+
// `recordMessage` advances assigned|active|waiting -> active|waiting
|
|
90
|
+
// depending on author, but does NOT pass through this table — the
|
|
91
|
+
// transition is implicit and only the message author drives it.
|
|
92
|
+
var TRANSITIONS = {
|
|
93
|
+
queued: { assigned: 1, closed: 1, abandoned: 1 },
|
|
94
|
+
assigned: { active: 1, waiting: 1, queued: 1, closed: 1, abandoned: 1 },
|
|
95
|
+
active: { waiting: 1, closed: 1, abandoned: 1 },
|
|
96
|
+
waiting: { active: 1, closed: 1, abandoned: 1 },
|
|
97
|
+
closed: {},
|
|
98
|
+
abandoned: {},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
var CONTROL_BYTE_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/;
|
|
102
|
+
var CONTROL_BYTE_STRICT_RE = /[\x00-\x1f\x7f]/;
|
|
103
|
+
// Zero-width + direction-override characters. Spelled via \u escapes
|
|
104
|
+
// so the file stays ESLint no-irregular-whitespace clean.
|
|
105
|
+
var ZERO_WIDTH_RE = new RegExp(
|
|
106
|
+
"[\\u200B-\\u200F\\u202A-\\u202E\\u2060-\\u2064\\u2066-\\u2069\\uFEFF\\u061C]"
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
// Lazy framework handle — matches the rest of the shop primitives;
|
|
110
|
+
// avoids the require cycle that would arise from importing `./index`
|
|
111
|
+
// at module-eval time.
|
|
112
|
+
var bShop;
|
|
113
|
+
function _b() {
|
|
114
|
+
if (!bShop) bShop = require("./index");
|
|
115
|
+
return bShop.framework;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ---- validators ---------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
function _uuid(s, label) {
|
|
121
|
+
try { return _b().guardUuid.sanitize(s, { profile: "strict" }); }
|
|
122
|
+
catch (e) { throw new TypeError("live-chat: " + label + " — " + (e && e.message || "invalid UUID")); }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function _publicAuthor(s) {
|
|
126
|
+
if (typeof s !== "string" || PUBLIC_AUTHORS.indexOf(s) === -1) {
|
|
127
|
+
throw new TypeError("live-chat: author must be one of " + PUBLIC_AUTHORS.join(", "));
|
|
128
|
+
}
|
|
129
|
+
return s;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function _body(s) {
|
|
133
|
+
if (typeof s !== "string") {
|
|
134
|
+
throw new TypeError("live-chat: body must be a string");
|
|
135
|
+
}
|
|
136
|
+
var trimmed = s.trim();
|
|
137
|
+
if (!trimmed.length) {
|
|
138
|
+
throw new TypeError("live-chat: body must be non-empty after trim");
|
|
139
|
+
}
|
|
140
|
+
if (s.length > MAX_BODY_LEN) {
|
|
141
|
+
throw new TypeError("live-chat: body must be <= " + MAX_BODY_LEN + " characters");
|
|
142
|
+
}
|
|
143
|
+
if (CONTROL_BYTE_RE.test(s)) {
|
|
144
|
+
throw new TypeError("live-chat: body contains control bytes");
|
|
145
|
+
}
|
|
146
|
+
if (ZERO_WIDTH_RE.test(s)) {
|
|
147
|
+
throw new TypeError("live-chat: body contains zero-width / direction-override characters");
|
|
148
|
+
}
|
|
149
|
+
return s;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function _reason(r, label) {
|
|
153
|
+
if (r == null) return null;
|
|
154
|
+
if (typeof r !== "string") {
|
|
155
|
+
throw new TypeError("live-chat: " + (label || "reason") + " must be a string or null");
|
|
156
|
+
}
|
|
157
|
+
if (!r.length) return null;
|
|
158
|
+
if (r.length > MAX_REASON_LEN) {
|
|
159
|
+
throw new TypeError("live-chat: " + (label || "reason") + " must be <= " + MAX_REASON_LEN + " characters");
|
|
160
|
+
}
|
|
161
|
+
if (CONTROL_BYTE_STRICT_RE.test(r) || ZERO_WIDTH_RE.test(r)) {
|
|
162
|
+
throw new TypeError("live-chat: " + (label || "reason") + " contains control / zero-width bytes");
|
|
163
|
+
}
|
|
164
|
+
return r;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function _sourcePage(s) {
|
|
168
|
+
if (typeof s !== "string" || !s.length) {
|
|
169
|
+
throw new TypeError("live-chat: source_page must be a non-empty string");
|
|
170
|
+
}
|
|
171
|
+
if (s.length > MAX_SOURCE_PAGE) {
|
|
172
|
+
throw new TypeError("live-chat: source_page must be <= " + MAX_SOURCE_PAGE + " characters");
|
|
173
|
+
}
|
|
174
|
+
if (CONTROL_BYTE_STRICT_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
|
|
175
|
+
throw new TypeError("live-chat: source_page contains control / zero-width bytes");
|
|
176
|
+
}
|
|
177
|
+
return s;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function _visitorSessionId(s) {
|
|
181
|
+
if (typeof s !== "string" || !s.length) {
|
|
182
|
+
throw new TypeError("live-chat: session_id must be a non-empty string");
|
|
183
|
+
}
|
|
184
|
+
if (s.length > MAX_SESSION_ID_LEN) {
|
|
185
|
+
throw new TypeError("live-chat: session_id must be <= " + MAX_SESSION_ID_LEN + " characters");
|
|
186
|
+
}
|
|
187
|
+
if (CONTROL_BYTE_STRICT_RE.test(s) || ZERO_WIDTH_RE.test(s)) {
|
|
188
|
+
throw new TypeError("live-chat: session_id contains control / zero-width bytes");
|
|
189
|
+
}
|
|
190
|
+
return s;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function _normalizeEmail(input) {
|
|
194
|
+
if (typeof input !== "string" || !input.length) {
|
|
195
|
+
throw new TypeError("live-chat: visitor_email must be a non-empty string");
|
|
196
|
+
}
|
|
197
|
+
var guardEmail = _b().guardEmail;
|
|
198
|
+
var report;
|
|
199
|
+
try {
|
|
200
|
+
report = guardEmail.validate(input, { profile: "strict" });
|
|
201
|
+
} catch (e) {
|
|
202
|
+
throw new TypeError("live-chat: visitor_email — " + (e && e.message || "invalid email"));
|
|
203
|
+
}
|
|
204
|
+
if (!report || report.ok === false) {
|
|
205
|
+
var first = (report && report.issues && report.issues[0]) || {};
|
|
206
|
+
throw new TypeError("live-chat: visitor_email — " + (first.snippet || first.ruleId || "refused at strict profile"));
|
|
207
|
+
}
|
|
208
|
+
var canonical;
|
|
209
|
+
try {
|
|
210
|
+
canonical = guardEmail.sanitize(input, { profile: "strict" });
|
|
211
|
+
} catch (e2) {
|
|
212
|
+
throw new TypeError("live-chat: visitor_email — " + (e2 && e2.message || "refused"));
|
|
213
|
+
}
|
|
214
|
+
return canonical.trim().toLowerCase();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function _operatorStatus(s) {
|
|
218
|
+
if (typeof s !== "string" || OPERATOR_STATUSES.indexOf(s) === -1) {
|
|
219
|
+
throw new TypeError("live-chat: operator status must be one of " + OPERATOR_STATUSES.join(", "));
|
|
220
|
+
}
|
|
221
|
+
return s;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function _sessionStatus(s, label) {
|
|
225
|
+
if (typeof s !== "string" || ALLOWED_STATUSES.indexOf(s) === -1) {
|
|
226
|
+
throw new TypeError("live-chat: " + (label || "status") +
|
|
227
|
+
" must be one of " + ALLOWED_STATUSES.join(", "));
|
|
228
|
+
}
|
|
229
|
+
return s;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function _limit(n, defaultN) {
|
|
233
|
+
if (n == null) return defaultN;
|
|
234
|
+
if (!Number.isInteger(n) || n <= 0 || n > MAX_LIST_LIMIT) {
|
|
235
|
+
throw new TypeError("live-chat: limit must be an integer 1..." + MAX_LIST_LIMIT);
|
|
236
|
+
}
|
|
237
|
+
return n;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function _since(n) {
|
|
241
|
+
if (n == null) return null;
|
|
242
|
+
if (!Number.isInteger(n) || n < 0) {
|
|
243
|
+
throw new TypeError("live-chat: since must be a non-negative integer (epoch ms)");
|
|
244
|
+
}
|
|
245
|
+
return n;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function _idleMinutes(n) {
|
|
249
|
+
if (!Number.isInteger(n) || n < MIN_IDLE_MINUTES || n > MAX_IDLE_MINUTES) {
|
|
250
|
+
throw new TypeError("live-chat: idle_minutes must be an integer " +
|
|
251
|
+
MIN_IDLE_MINUTES + "..." + MAX_IDLE_MINUTES);
|
|
252
|
+
}
|
|
253
|
+
return n;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
var _lastTs = 0;
|
|
257
|
+
function _now() {
|
|
258
|
+
var t = Date.now();
|
|
259
|
+
if (t <= _lastTs) { t = _lastTs + 1; }
|
|
260
|
+
_lastTs = t;
|
|
261
|
+
return t;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// ---- factory ------------------------------------------------------------
|
|
265
|
+
|
|
266
|
+
function create(opts) {
|
|
267
|
+
opts = opts || {};
|
|
268
|
+
var query = opts.query;
|
|
269
|
+
if (!query) {
|
|
270
|
+
query = function (sql, params) { return _b().externalDb.query(sql, params); };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function _hashVisitor(sessionId) {
|
|
274
|
+
return _b().crypto.namespaceHash(VISITOR_NAMESPACE, sessionId);
|
|
275
|
+
}
|
|
276
|
+
function _hashEmail(canonical) {
|
|
277
|
+
return _b().crypto.namespaceHash(EMAIL_NAMESPACE, canonical);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async function _getRaw(id) {
|
|
281
|
+
var r = await query("SELECT * FROM chat_sessions WHERE id = ?1", [id]);
|
|
282
|
+
return r.rows[0] || null;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
async function _writeSystemMessage(sessionId, body, ts) {
|
|
286
|
+
await query(
|
|
287
|
+
"INSERT INTO chat_messages (id, session_id, author, body, occurred_at) " +
|
|
288
|
+
"VALUES (?1, ?2, 'system', ?3, ?4)",
|
|
289
|
+
[_b().uuid.v7(), sessionId, body, ts],
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Refuse if the candidate transition isn't legal from the row's
|
|
294
|
+
// current status. Used by every public verb that's gated on
|
|
295
|
+
// current state.
|
|
296
|
+
function _ensureTransition(currentStatus, toStatus, verbLabel) {
|
|
297
|
+
var allowed = TRANSITIONS[currentStatus] || {};
|
|
298
|
+
if (!allowed[toStatus]) {
|
|
299
|
+
var err = new Error("live-chat." + verbLabel +
|
|
300
|
+
": refused — cannot move " + currentStatus + " -> " + toStatus);
|
|
301
|
+
err.code = "LIVE_CHAT_TRANSITION_REFUSED";
|
|
302
|
+
throw err;
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
return {
|
|
307
|
+
MAX_BODY_LEN: MAX_BODY_LEN,
|
|
308
|
+
MAX_REASON_LEN: MAX_REASON_LEN,
|
|
309
|
+
MAX_SOURCE_PAGE: MAX_SOURCE_PAGE,
|
|
310
|
+
MAX_SESSION_ID_LEN: MAX_SESSION_ID_LEN,
|
|
311
|
+
MAX_LIST_LIMIT: MAX_LIST_LIMIT,
|
|
312
|
+
DEFAULT_QUEUE_LIMIT: DEFAULT_QUEUE_LIMIT,
|
|
313
|
+
MAX_IDLE_MINUTES: MAX_IDLE_MINUTES,
|
|
314
|
+
MIN_IDLE_MINUTES: MIN_IDLE_MINUTES,
|
|
315
|
+
ALLOWED_AUTHORS: ALLOWED_AUTHORS.slice(),
|
|
316
|
+
PUBLIC_AUTHORS: PUBLIC_AUTHORS.slice(),
|
|
317
|
+
ALLOWED_STATUSES: ALLOWED_STATUSES.slice(),
|
|
318
|
+
NON_TERMINAL_STATUSES: NON_TERMINAL_STATUSES.slice(),
|
|
319
|
+
OPERATOR_STATUSES: OPERATOR_STATUSES.slice(),
|
|
320
|
+
|
|
321
|
+
// Open a new chat session. Stamps the visitor + optional email
|
|
322
|
+
// hashes, creates the row in `queued` state, and seeds the
|
|
323
|
+
// transcript with the visitor's `initial_message` (when present)
|
|
324
|
+
// authored by `customer`. The opening beat is also recorded as a
|
|
325
|
+
// system message so the rendered transcript shows "session
|
|
326
|
+
// opened from <source_page>" alongside the first visitor line.
|
|
327
|
+
openSession: async function (input) {
|
|
328
|
+
if (!input || typeof input !== "object") {
|
|
329
|
+
throw new TypeError("live-chat.openSession: input object required");
|
|
330
|
+
}
|
|
331
|
+
var rawSessionId = _visitorSessionId(input.session_id);
|
|
332
|
+
var visitorHash = _hashVisitor(rawSessionId);
|
|
333
|
+
var sourcePage = _sourcePage(input.source_page);
|
|
334
|
+
|
|
335
|
+
var customerId = null;
|
|
336
|
+
if (input.customer_id != null) {
|
|
337
|
+
customerId = _uuid(input.customer_id, "customer_id");
|
|
338
|
+
}
|
|
339
|
+
var emailHash = null;
|
|
340
|
+
if (input.visitor_email != null) {
|
|
341
|
+
var canonical = _normalizeEmail(input.visitor_email);
|
|
342
|
+
emailHash = _hashEmail(canonical);
|
|
343
|
+
}
|
|
344
|
+
var initialMessage = null;
|
|
345
|
+
if (input.initial_message != null) {
|
|
346
|
+
initialMessage = _body(input.initial_message);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
var id = _b().uuid.v7();
|
|
350
|
+
var ts = _now();
|
|
351
|
+
await query(
|
|
352
|
+
"INSERT INTO chat_sessions " +
|
|
353
|
+
"(id, customer_id, visitor_session_id_hash, visitor_email_hash, source_page, " +
|
|
354
|
+
" status, assigned_operator_id, opened_at, assigned_at, last_activity_at, " +
|
|
355
|
+
" closed_at, close_reason) " +
|
|
356
|
+
"VALUES (?1, ?2, ?3, ?4, ?5, 'queued', NULL, ?6, NULL, ?6, NULL, NULL)",
|
|
357
|
+
[id, customerId, visitorHash, emailHash, sourcePage, ts],
|
|
358
|
+
);
|
|
359
|
+
await _writeSystemMessage(id, "session opened from " + sourcePage, ts);
|
|
360
|
+
if (initialMessage != null) {
|
|
361
|
+
await query(
|
|
362
|
+
"INSERT INTO chat_messages (id, session_id, author, body, occurred_at) " +
|
|
363
|
+
"VALUES (?1, ?2, 'customer', ?3, ?4)",
|
|
364
|
+
[_b().uuid.v7(), id, initialMessage, ts],
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
return await _getRaw(id);
|
|
368
|
+
},
|
|
369
|
+
|
|
370
|
+
// queued -> queued. No-op when the session is already queued;
|
|
371
|
+
// refuses if the caller is trying to re-queue from an active
|
|
372
|
+
// operator-held state (use closeSession + a fresh openSession
|
|
373
|
+
// for that workflow). Records a system message when the session
|
|
374
|
+
// was previously assigned, so the transcript shows the release.
|
|
375
|
+
enqueueSession: async function (sessionId) {
|
|
376
|
+
sessionId = _uuid(sessionId, "session_id");
|
|
377
|
+
var row = await _getRaw(sessionId);
|
|
378
|
+
if (!row) {
|
|
379
|
+
var err = new Error("live-chat.enqueueSession: session " + sessionId + " not found");
|
|
380
|
+
err.code = "LIVE_CHAT_SESSION_NOT_FOUND";
|
|
381
|
+
throw err;
|
|
382
|
+
}
|
|
383
|
+
if (row.status === "queued") return row;
|
|
384
|
+
// Only `assigned` may release back to queued. assigned|active|
|
|
385
|
+
// waiting all carry an operator, but releasing mid-conversation
|
|
386
|
+
// (active / waiting) is operator-error: the right verb is to
|
|
387
|
+
// closeSession the current chat and let the visitor re-open if
|
|
388
|
+
// they want to talk to someone else. assigned -> queued is the
|
|
389
|
+
// only safe release (the operator picked up but hasn't sent
|
|
390
|
+
// anything yet).
|
|
391
|
+
_ensureTransition(row.status, "queued", "enqueueSession");
|
|
392
|
+
var ts = _now();
|
|
393
|
+
await query(
|
|
394
|
+
"UPDATE chat_sessions SET status = 'queued', assigned_operator_id = NULL, " +
|
|
395
|
+
"last_activity_at = ?1 WHERE id = ?2",
|
|
396
|
+
[ts, sessionId],
|
|
397
|
+
);
|
|
398
|
+
await _writeSystemMessage(sessionId, "session re-queued", ts);
|
|
399
|
+
return await _getRaw(sessionId);
|
|
400
|
+
},
|
|
401
|
+
|
|
402
|
+
// queued -> assigned. Stamps assigned_operator_id + assigned_at,
|
|
403
|
+
// emits a system "operator joined" message. Refuses if the
|
|
404
|
+
// session has already been assigned (concurrent dispatcher pick-
|
|
405
|
+
// up should serialize on this refusal).
|
|
406
|
+
assignToOperator: async function (input) {
|
|
407
|
+
if (!input || typeof input !== "object") {
|
|
408
|
+
throw new TypeError("live-chat.assignToOperator: input object required");
|
|
409
|
+
}
|
|
410
|
+
var sessionId = _uuid(input.session_id, "session_id");
|
|
411
|
+
var operatorId = _uuid(input.operator_id, "operator_id");
|
|
412
|
+
var row = await _getRaw(sessionId);
|
|
413
|
+
if (!row) {
|
|
414
|
+
var err = new Error("live-chat.assignToOperator: session " + sessionId + " not found");
|
|
415
|
+
err.code = "LIVE_CHAT_SESSION_NOT_FOUND";
|
|
416
|
+
throw err;
|
|
417
|
+
}
|
|
418
|
+
_ensureTransition(row.status, "assigned", "assignToOperator");
|
|
419
|
+
var ts = _now();
|
|
420
|
+
await query(
|
|
421
|
+
"UPDATE chat_sessions SET status = 'assigned', assigned_operator_id = ?1, " +
|
|
422
|
+
"assigned_at = ?2, last_activity_at = ?2 WHERE id = ?3",
|
|
423
|
+
[operatorId, ts, sessionId],
|
|
424
|
+
);
|
|
425
|
+
await _writeSystemMessage(sessionId, "operator joined", ts);
|
|
426
|
+
return await _getRaw(sessionId);
|
|
427
|
+
},
|
|
428
|
+
|
|
429
|
+
// Append a message to the transcript. author='customer' moves
|
|
430
|
+
// the session to `active`; author='operator' moves it to
|
|
431
|
+
// `waiting`. Either advances `last_activity_at` so the reaper
|
|
432
|
+
// doesn't sweep the chat while it's still in motion.
|
|
433
|
+
recordMessage: async function (input) {
|
|
434
|
+
if (!input || typeof input !== "object") {
|
|
435
|
+
throw new TypeError("live-chat.recordMessage: input object required");
|
|
436
|
+
}
|
|
437
|
+
var sessionId = _uuid(input.session_id, "session_id");
|
|
438
|
+
var author = _publicAuthor(input.author);
|
|
439
|
+
var body = _body(input.body);
|
|
440
|
+
var row = await _getRaw(sessionId);
|
|
441
|
+
if (!row) {
|
|
442
|
+
var err = new Error("live-chat.recordMessage: session " + sessionId + " not found");
|
|
443
|
+
err.code = "LIVE_CHAT_SESSION_NOT_FOUND";
|
|
444
|
+
throw err;
|
|
445
|
+
}
|
|
446
|
+
if (row.status === "closed" || row.status === "abandoned") {
|
|
447
|
+
var tErr = new Error("live-chat.recordMessage: session " + sessionId +
|
|
448
|
+
" is " + row.status + ", cannot record more messages");
|
|
449
|
+
tErr.code = "LIVE_CHAT_SESSION_TERMINAL";
|
|
450
|
+
throw tErr;
|
|
451
|
+
}
|
|
452
|
+
if (row.status === "queued") {
|
|
453
|
+
// Customer-side typing while still queued is allowed and
|
|
454
|
+
// does NOT move the session out of `queued` — the
|
|
455
|
+
// dispatcher still needs to assign an operator before the
|
|
456
|
+
// chat is "active" in the operator-console sense.
|
|
457
|
+
// Operator-side messages on a queued session are refused —
|
|
458
|
+
// an operator must `assignToOperator` first.
|
|
459
|
+
if (author === "operator") {
|
|
460
|
+
var qErr = new Error("live-chat.recordMessage: session " + sessionId +
|
|
461
|
+
" is queued, operator must assignToOperator before sending messages");
|
|
462
|
+
qErr.code = "LIVE_CHAT_NOT_ASSIGNED";
|
|
463
|
+
throw qErr;
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
var ts = _now();
|
|
467
|
+
await query(
|
|
468
|
+
"INSERT INTO chat_messages (id, session_id, author, body, occurred_at) " +
|
|
469
|
+
"VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
470
|
+
[_b().uuid.v7(), sessionId, author, body, ts],
|
|
471
|
+
);
|
|
472
|
+
// Status advancement:
|
|
473
|
+
// queued + customer -> queued (no movement)
|
|
474
|
+
// assigned + customer -> active
|
|
475
|
+
// assigned + operator -> waiting
|
|
476
|
+
// active|waiting + customer -> active
|
|
477
|
+
// active|waiting + operator -> waiting
|
|
478
|
+
var newStatus = row.status;
|
|
479
|
+
if (row.status !== "queued") {
|
|
480
|
+
newStatus = author === "customer" ? "active" : "waiting";
|
|
481
|
+
}
|
|
482
|
+
await query(
|
|
483
|
+
"UPDATE chat_sessions SET status = ?1, last_activity_at = ?2 WHERE id = ?3",
|
|
484
|
+
[newStatus, ts, sessionId],
|
|
485
|
+
);
|
|
486
|
+
return await _getRaw(sessionId);
|
|
487
|
+
},
|
|
488
|
+
|
|
489
|
+
// any non-terminal -> closed. Records a system close message
|
|
490
|
+
// carrying the reason so the transcript explains the close.
|
|
491
|
+
closeSession: async function (input) {
|
|
492
|
+
if (!input || typeof input !== "object") {
|
|
493
|
+
throw new TypeError("live-chat.closeSession: input object required");
|
|
494
|
+
}
|
|
495
|
+
var sessionId = _uuid(input.session_id, "session_id");
|
|
496
|
+
var reason = _reason(input.reason, "reason");
|
|
497
|
+
var row = await _getRaw(sessionId);
|
|
498
|
+
if (!row) {
|
|
499
|
+
var err = new Error("live-chat.closeSession: session " + sessionId + " not found");
|
|
500
|
+
err.code = "LIVE_CHAT_SESSION_NOT_FOUND";
|
|
501
|
+
throw err;
|
|
502
|
+
}
|
|
503
|
+
_ensureTransition(row.status, "closed", "closeSession");
|
|
504
|
+
var ts = _now();
|
|
505
|
+
await query(
|
|
506
|
+
"UPDATE chat_sessions SET status = 'closed', closed_at = ?1, close_reason = ?2, " +
|
|
507
|
+
"last_activity_at = ?1 WHERE id = ?3",
|
|
508
|
+
[ts, reason, sessionId],
|
|
509
|
+
);
|
|
510
|
+
await _writeSystemMessage(sessionId, "session closed" + (reason ? ": " + reason : ""), ts);
|
|
511
|
+
return await _getRaw(sessionId);
|
|
512
|
+
},
|
|
513
|
+
|
|
514
|
+
// Read a single session (raw row) or null when not found.
|
|
515
|
+
getSession: async function (sessionId) {
|
|
516
|
+
sessionId = _uuid(sessionId, "session_id");
|
|
517
|
+
return await _getRaw(sessionId);
|
|
518
|
+
},
|
|
519
|
+
|
|
520
|
+
// Transcript view. Optional `since` (epoch ms) so the
|
|
521
|
+
// storefront poll-or-tail surface only pulls new lines.
|
|
522
|
+
// Ordered by (occurred_at ASC, id ASC) so consecutive messages
|
|
523
|
+
// with the same timestamp render in insertion order.
|
|
524
|
+
messagesForSession: async function (input) {
|
|
525
|
+
if (!input || typeof input !== "object") {
|
|
526
|
+
throw new TypeError("live-chat.messagesForSession: input object required");
|
|
527
|
+
}
|
|
528
|
+
var sessionId = _uuid(input.session_id, "session_id");
|
|
529
|
+
var since = _since(input.since);
|
|
530
|
+
var sql, params;
|
|
531
|
+
if (since != null) {
|
|
532
|
+
sql = "SELECT * FROM chat_messages WHERE session_id = ?1 AND occurred_at > ?2 " +
|
|
533
|
+
"ORDER BY occurred_at ASC, id ASC";
|
|
534
|
+
params = [sessionId, since];
|
|
535
|
+
} else {
|
|
536
|
+
sql = "SELECT * FROM chat_messages WHERE session_id = ?1 " +
|
|
537
|
+
"ORDER BY occurred_at ASC, id ASC";
|
|
538
|
+
params = [sessionId];
|
|
539
|
+
}
|
|
540
|
+
var r = await query(sql, params);
|
|
541
|
+
return r.rows;
|
|
542
|
+
},
|
|
543
|
+
|
|
544
|
+
// Operator-side queue: sessions assigned to one operator, or to
|
|
545
|
+
// any operator (when operator_id omitted) — both restricted to
|
|
546
|
+
// the supplied status filter (or every non-terminal state when
|
|
547
|
+
// omitted). Ordered by assigned_at ASC so the operator works
|
|
548
|
+
// longest-held first.
|
|
549
|
+
operatorQueue: async function (input) {
|
|
550
|
+
input = input || {};
|
|
551
|
+
var operatorId;
|
|
552
|
+
if (input.operator_id != null) {
|
|
553
|
+
operatorId = _uuid(input.operator_id, "operator_id");
|
|
554
|
+
}
|
|
555
|
+
var statusFilter;
|
|
556
|
+
if (input.status != null) {
|
|
557
|
+
statusFilter = _sessionStatus(input.status, "status");
|
|
558
|
+
if (NON_TERMINAL_STATUSES.indexOf(statusFilter) === -1 && statusFilter !== "closed" && statusFilter !== "abandoned") {
|
|
559
|
+
throw new TypeError("live-chat.operatorQueue: status filter out of range");
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
var where = [];
|
|
563
|
+
var params = [];
|
|
564
|
+
var idx = 1;
|
|
565
|
+
if (operatorId !== undefined) {
|
|
566
|
+
where.push("assigned_operator_id = ?" + idx);
|
|
567
|
+
params.push(operatorId);
|
|
568
|
+
idx += 1;
|
|
569
|
+
} else {
|
|
570
|
+
where.push("assigned_operator_id IS NOT NULL");
|
|
571
|
+
}
|
|
572
|
+
if (statusFilter !== undefined) {
|
|
573
|
+
where.push("status = ?" + idx);
|
|
574
|
+
params.push(statusFilter);
|
|
575
|
+
idx += 1;
|
|
576
|
+
} else {
|
|
577
|
+
where.push("status IN ('assigned','active','waiting')");
|
|
578
|
+
}
|
|
579
|
+
var sql = "SELECT * FROM chat_sessions WHERE " + where.join(" AND ") +
|
|
580
|
+
" ORDER BY assigned_at ASC, opened_at ASC, id ASC";
|
|
581
|
+
var r = await query(sql, params);
|
|
582
|
+
return r.rows;
|
|
583
|
+
},
|
|
584
|
+
|
|
585
|
+
// Dispatcher queue: queued sessions in FIFO order. limit caps
|
|
586
|
+
// the page; default is DEFAULT_QUEUE_LIMIT.
|
|
587
|
+
waitingQueue: async function (input) {
|
|
588
|
+
input = input || {};
|
|
589
|
+
var limit = _limit(input.limit, DEFAULT_QUEUE_LIMIT);
|
|
590
|
+
var r = await query(
|
|
591
|
+
"SELECT * FROM chat_sessions WHERE status = 'queued' " +
|
|
592
|
+
"ORDER BY opened_at ASC, id ASC LIMIT ?1",
|
|
593
|
+
[limit],
|
|
594
|
+
);
|
|
595
|
+
return r.rows;
|
|
596
|
+
},
|
|
597
|
+
|
|
598
|
+
// Count of active concurrent sessions held by one operator —
|
|
599
|
+
// counts the assigned|active|waiting states (every state in
|
|
600
|
+
// which the operator is currently on the hook). Drives the
|
|
601
|
+
// dispatcher's load-balancing heuristic.
|
|
602
|
+
operatorWorkload: async function (operatorId) {
|
|
603
|
+
operatorId = _uuid(operatorId, "operator_id");
|
|
604
|
+
var r = await query(
|
|
605
|
+
"SELECT COUNT(*) AS c FROM chat_sessions " +
|
|
606
|
+
"WHERE assigned_operator_id = ?1 AND status IN ('assigned','active','waiting')",
|
|
607
|
+
[operatorId],
|
|
608
|
+
);
|
|
609
|
+
var row = r.rows[0];
|
|
610
|
+
return row ? Number(row.c) : 0;
|
|
611
|
+
},
|
|
612
|
+
|
|
613
|
+
// Mark an operator available — eligible for dispatcher pickup.
|
|
614
|
+
// Upserts the row.
|
|
615
|
+
markOperatorAvailable: async function (operatorId) {
|
|
616
|
+
operatorId = _uuid(operatorId, "operator_id");
|
|
617
|
+
var ts = _now();
|
|
618
|
+
// Manual upsert because the in-memory test DB doesn't speak
|
|
619
|
+
// sqlite's UPSERT until pragma; same shape used elsewhere.
|
|
620
|
+
var existing = await query(
|
|
621
|
+
"SELECT operator_id FROM chat_operator_state WHERE operator_id = ?1",
|
|
622
|
+
[operatorId],
|
|
623
|
+
);
|
|
624
|
+
if (existing.rows.length) {
|
|
625
|
+
await query(
|
|
626
|
+
"UPDATE chat_operator_state SET status = 'available', updated_at = ?1 " +
|
|
627
|
+
"WHERE operator_id = ?2",
|
|
628
|
+
[ts, operatorId],
|
|
629
|
+
);
|
|
630
|
+
} else {
|
|
631
|
+
await query(
|
|
632
|
+
"INSERT INTO chat_operator_state (operator_id, status, updated_at) " +
|
|
633
|
+
"VALUES (?1, 'available', ?2)",
|
|
634
|
+
[operatorId, ts],
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
return { operator_id: operatorId, status: "available", updated_at: ts };
|
|
638
|
+
},
|
|
639
|
+
|
|
640
|
+
// Mark an operator away — dispatcher should skip them. Existing
|
|
641
|
+
// assignments are NOT auto-released; the operator drains their
|
|
642
|
+
// current queue.
|
|
643
|
+
markOperatorAway: async function (operatorId) {
|
|
644
|
+
operatorId = _uuid(operatorId, "operator_id");
|
|
645
|
+
var ts = _now();
|
|
646
|
+
var existing = await query(
|
|
647
|
+
"SELECT operator_id FROM chat_operator_state WHERE operator_id = ?1",
|
|
648
|
+
[operatorId],
|
|
649
|
+
);
|
|
650
|
+
if (existing.rows.length) {
|
|
651
|
+
await query(
|
|
652
|
+
"UPDATE chat_operator_state SET status = 'away', updated_at = ?1 " +
|
|
653
|
+
"WHERE operator_id = ?2",
|
|
654
|
+
[ts, operatorId],
|
|
655
|
+
);
|
|
656
|
+
} else {
|
|
657
|
+
await query(
|
|
658
|
+
"INSERT INTO chat_operator_state (operator_id, status, updated_at) " +
|
|
659
|
+
"VALUES (?1, 'away', ?2)",
|
|
660
|
+
[operatorId, ts],
|
|
661
|
+
);
|
|
662
|
+
}
|
|
663
|
+
return { operator_id: operatorId, status: "away", updated_at: ts };
|
|
664
|
+
},
|
|
665
|
+
|
|
666
|
+
// Reaper. Closes every non-terminal session whose
|
|
667
|
+
// `last_activity_at` is older than `idle_minutes` minutes ago.
|
|
668
|
+
// Returns the list of session ids that were swept so the
|
|
669
|
+
// scheduler can log / notify.
|
|
670
|
+
cleanupAbandoned: async function (input) {
|
|
671
|
+
if (!input || typeof input !== "object") {
|
|
672
|
+
throw new TypeError("live-chat.cleanupAbandoned: input object required");
|
|
673
|
+
}
|
|
674
|
+
var idleMinutes = _idleMinutes(input.idle_minutes);
|
|
675
|
+
var ts = _now();
|
|
676
|
+
var threshold = ts - idleMinutes * 60 * 1000;
|
|
677
|
+
var r = await query(
|
|
678
|
+
"SELECT id FROM chat_sessions " +
|
|
679
|
+
"WHERE status IN ('queued','assigned','active','waiting') " +
|
|
680
|
+
"AND last_activity_at < ?1",
|
|
681
|
+
[threshold],
|
|
682
|
+
);
|
|
683
|
+
var swept = [];
|
|
684
|
+
for (var i = 0; i < r.rows.length; i += 1) {
|
|
685
|
+
var sid = r.rows[i].id;
|
|
686
|
+
await query(
|
|
687
|
+
"UPDATE chat_sessions SET status = 'abandoned', closed_at = ?1, " +
|
|
688
|
+
"close_reason = 'idle timeout', last_activity_at = ?1 WHERE id = ?2",
|
|
689
|
+
[ts, sid],
|
|
690
|
+
);
|
|
691
|
+
await _writeSystemMessage(sid, "session abandoned: idle timeout", ts);
|
|
692
|
+
swept.push(sid);
|
|
693
|
+
}
|
|
694
|
+
return { swept: swept, count: swept.length, threshold_ms: threshold };
|
|
695
|
+
},
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
module.exports = {
|
|
700
|
+
create: create,
|
|
701
|
+
MAX_BODY_LEN: MAX_BODY_LEN,
|
|
702
|
+
MAX_REASON_LEN: MAX_REASON_LEN,
|
|
703
|
+
MAX_SOURCE_PAGE: MAX_SOURCE_PAGE,
|
|
704
|
+
MAX_SESSION_ID_LEN: MAX_SESSION_ID_LEN,
|
|
705
|
+
MAX_LIST_LIMIT: MAX_LIST_LIMIT,
|
|
706
|
+
DEFAULT_QUEUE_LIMIT: DEFAULT_QUEUE_LIMIT,
|
|
707
|
+
MAX_IDLE_MINUTES: MAX_IDLE_MINUTES,
|
|
708
|
+
MIN_IDLE_MINUTES: MIN_IDLE_MINUTES,
|
|
709
|
+
ALLOWED_AUTHORS: ALLOWED_AUTHORS.slice(),
|
|
710
|
+
PUBLIC_AUTHORS: PUBLIC_AUTHORS.slice(),
|
|
711
|
+
ALLOWED_STATUSES: ALLOWED_STATUSES.slice(),
|
|
712
|
+
NON_TERMINAL_STATUSES: NON_TERMINAL_STATUSES.slice(),
|
|
713
|
+
OPERATOR_STATUSES: OPERATOR_STATUSES.slice(),
|
|
714
|
+
};
|