@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.
@@ -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
+ };