@tiledesk/tiledesk-server 2.19.7 → 2.19.10

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 CHANGED
@@ -5,7 +5,16 @@
5
5
  🚀 IN PRODUCTION 🚀
6
6
  (https://www.npmjs.com/package/@tiledesk/tiledesk-server/v/2.3.77)
7
7
 
8
- # 2.19.6
8
+ # 2.19.10
9
+ - Fixed bug on add answered question route.
10
+
11
+ # 2.19.9
12
+ - Fixed an issue with the handling of situated_context during namespace import
13
+
14
+ # 2.19.8
15
+ - Fix analytics integration
16
+
17
+ # 2.19.7
9
18
  - Added TTL for events collection
10
19
 
11
20
  # 2.19.6
@@ -2606,4 +2615,4 @@ Added email notification setting for each teammate (also in 2.1.14.3)
2606
2615
  - Websocket performance fix with lean and removing populate
2607
2616
  - Added kubernetes sample config file
2608
2617
  - Added required firstname and lastname to signup endpoint
2609
- - Removed message.request.messages and message.request.department.bot for message.create event
2618
+ - Removed message.request.messages and message.request.department.bot for message.create event
@@ -21,9 +21,9 @@ const client = axios.create({
21
21
  * produce identical envelope shapes, enabling event_id-based deduplication
22
22
  * in the consumer.
23
23
  */
24
- function _buildEnvelope(eventType, idProject, payload) {
24
+ function _buildEnvelope(eventType, idProject, payload, eventId) {
25
25
  return {
26
- event_id: crypto.randomUUID(),
26
+ event_id: eventId || crypto.randomUUID(),
27
27
  event_type: eventType,
28
28
  timestamp: new Date().toISOString(),
29
29
  id_project: idProject,
@@ -39,12 +39,15 @@ function _buildEnvelope(eventType, idProject, payload) {
39
39
  * @param {string} eventType - e.g. 'conversation.created'
40
40
  * @param {string} idProject - Tiledesk project ID
41
41
  * @param {object} payload - event-specific fields
42
+ * @param {string} [eventId] - optional caller-supplied UUID for idempotency
43
+ * (deterministic id so live + backfill events for
44
+ * the same entity dedupe). Random when omitted.
42
45
  */
43
- function track(eventType, idProject, payload) {
46
+ function track(eventType, idProject, payload, eventId) {
44
47
  if (!INGEST_BASE_URL) return;
45
48
  if (!idProject) return;
46
49
 
47
- var body = _buildEnvelope(eventType, idProject, payload);
50
+ var body = _buildEnvelope(eventType, idProject, payload, eventId);
48
51
 
49
52
  client.post("/events", body).catch(function (err) {
50
53
  var status = err.response && err.response.status;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@tiledesk/tiledesk-server",
3
3
  "description": "The Tiledesk server module",
4
- "version": "2.19.7",
4
+ "version": "2.19.10",
5
5
  "scripts": {
6
6
  "start": "node ./bin/www",
7
7
  "pretest": "mongodb-runner start",
@@ -22,6 +22,17 @@ var kbEvent = require("../../event/kbEvent");
22
22
  var departmentEvent = require("../../event/departmentEvent");
23
23
  var botEvent = require("../../event/botEvent");
24
24
  var { track } = require("../../lib/analyticsClient");
25
+ var uuidv5 = require("uuid/v5");
26
+
27
+ // Deterministic event_id namespace — MUST match apps/backfill
28
+ // BACKFILL_UUID_NAMESPACE so a live event and its backfilled twin collapse to a
29
+ // single row (idempotency / no double-count). Applied only to the event types
30
+ // the backfill also produces.
31
+ var ANALYTICS_UUID_NAMESPACE = "6ba7b810-9dad-11d1-80b4-00c04fd430c8";
32
+
33
+ function eventIdFor(key) {
34
+ return uuidv5(String(key), ANALYTICS_UUID_NAMESPACE);
35
+ }
25
36
 
26
37
  // ─── helpers ────────────────────────────────────────────────────────────────
27
38
 
@@ -46,6 +57,7 @@ function availabilityLabel(boolVal) {
46
57
  * to 'user', misclassifying agent messages.
47
58
  */
48
59
  function senderType(sender, request) {
60
+ if (!request) return "user"; // direct/group messages carry no request
49
61
  let bot, agent = undefined;
50
62
  if(request.participantsBots)
51
63
  bot = request.participantsBots.find(participant => participant.includes(sender));
@@ -101,6 +113,18 @@ function toStringId(doc) {
101
113
  return (doc._id || doc.id || doc).toString() || null;
102
114
  }
103
115
 
116
+ /**
117
+ * Extract the tag string from a request.updated TAG_ADD patch.
118
+ * The tag may be a bare string or a { tag: "..." } sub-document — the shape
119
+ * varies by call site (see requestService.addTagByRequestId).
120
+ */
121
+ function tagString(t) {
122
+ if (!t) return null;
123
+ if (typeof t === "string") return t;
124
+ if (typeof t.tag === "string") return t.tag;
125
+ return null;
126
+ }
127
+
104
128
  // ─── listeners ──────────────────────────────────────────────────────────────
105
129
 
106
130
  function listen() {
@@ -120,16 +144,17 @@ function listen() {
120
144
  if (request.preflight === true) return;
121
145
 
122
146
  var dept = request.department;
147
+ var requestId = request.request_id || toStringId(request);
123
148
 
124
149
  track("conversation.created", request.id_project, {
125
- id_request: request.request_id || toStringId(request),
126
- request_id: request.request_id || toStringId(request),
150
+ id_request: requestId,
151
+ request_id: requestId,
127
152
  department: departmentId(dept),
128
153
  channel: (request.channel && request.channel.name) || "web",
129
154
  first_response_time: null,
130
155
  with_bot: request.hasBot || false,
131
156
  visitor_id: firstVisitorId(request.participants),
132
- });
157
+ }, eventIdFor(requestId));
133
158
  }
134
159
 
135
160
  requestEvent.on("request.update.preflight", function (request) {
@@ -149,6 +174,10 @@ function listen() {
149
174
  // duration_seconds number
150
175
  // waiting_time_seconds number|null
151
176
  // satisfaction_rating number 1-5 | null
177
+ // Satisfaction is intentionally NOT included here: ratings are submitted
178
+ // asynchronously (after close) and emitted as a separate conversation.satisfaction
179
+ // event below. The conversation-closed contract has no satisfaction field, so
180
+ // any rating sent here would be stripped by the ingest validator anyway.
152
181
  requestEvent.on("request.close", function (request) {
153
182
  var createdAt = new Date(request.createdAt);
154
183
  var closedAt = request.closed_at ? new Date(request.closed_at) : new Date();
@@ -157,18 +186,10 @@ function listen() {
157
186
  ? Math.round(request.duration / 1000)
158
187
  : Math.round((closedAt - createdAt) / 1000);
159
188
 
160
- // Normalise rating: must be an integer 1–5 or null.
161
- var rawRating = request.rating;
162
- var satisfactionRating = null;
163
- if (rawRating != null) {
164
- var r = parseInt(rawRating, 10);
165
- satisfactionRating = r >= 1 && r <= 5 ? r : null;
166
- }
167
-
168
- // TODO: da splittare rispetto al rating
189
+ var requestId = request.request_id || toStringId(request);
169
190
  track("conversation.closed", request.id_project, {
170
- id_request: request.request_id || toStringId(request),
171
- request_id: request.request_id || toStringId(request),
191
+ id_request: requestId,
192
+ request_id: requestId,
172
193
  closed_by: request.closed_by || "system",
173
194
  close_reason: null,
174
195
  duration_seconds: durationSeconds,
@@ -176,8 +197,52 @@ function listen() {
176
197
  request.waiting_time != null
177
198
  ? Math.round(request.waiting_time / 1000)
178
199
  : null,
179
- satisfaction_rating: satisfactionRating,
180
- });
200
+ }, eventIdFor(requestId + ":closed"));
201
+ });
202
+
203
+ // ── conversation.satisfaction ─────────────────────────────────────────────
204
+ // Emitted by routes/request.js ("request.satisfaction") when a visitor submits
205
+ // a rating — asynchronously, after the conversation is already closed.
206
+ // Contract: packages/contracts/src/payloads/conversation-satisfaction.ts
207
+ // id_request string (required)
208
+ // request_id string (required)
209
+ // rating number (required, integer 1-5)
210
+ // rating_message string|null
211
+ requestEvent.on("request.satisfaction", function (data) {
212
+ var request = (data && data.request) || {};
213
+ if (request.rating == null) return;
214
+
215
+ var r = parseInt(request.rating, 10);
216
+ if (!(r >= 1 && r <= 5)) return; // contract requires an integer 1-5
217
+
218
+ var requestId = request.request_id || toStringId(request);
219
+ track("conversation.satisfaction", request.id_project, {
220
+ id_request: requestId,
221
+ request_id: requestId,
222
+ rating: r,
223
+ rating_message: request.rating_message || null,
224
+ }, eventIdFor(requestId + ":satisfaction"));
225
+ });
226
+
227
+ // ── conversation.tag_added ────────────────────────────────────────────────
228
+ // Tags are added via requestService.addTagByRequestId, which emits the generic
229
+ // "request.updated" event with comment === "TAG_ADD" and the added tag in
230
+ // patch.tags. One analytics event per added tag.
231
+ // Contract: packages/contracts/src/payloads/conversation-tag-added.ts
232
+ // id_request string (required)
233
+ // tag string (required)
234
+ requestEvent.on("request.updated", function (data) {
235
+ if (!data || data.comment !== "TAG_ADD") return;
236
+
237
+ var request = data.request || {};
238
+ var tag = tagString(data.patch && data.patch.tags);
239
+ if (!tag) return; // nothing usable to record
240
+
241
+ var requestId = request.request_id || toStringId(request);
242
+ track("conversation.tag_added", request.id_project, {
243
+ id_request: requestId,
244
+ tag: tag,
245
+ }, eventIdFor(requestId + ":" + tag));
181
246
  });
182
247
 
183
248
  // ── 3. message.sent ────────────────────────────────────────────────────────
@@ -198,64 +263,37 @@ function listen() {
198
263
  var idRequest = messageJson.recipient || null;
199
264
  if (!idRequest) return; // cannot emit without a conversation reference
200
265
 
266
+ // Denormalise department (id) and channel (name) from the parent request so
267
+ // the messages MVs can filter without a query-time join. Mirrors the
268
+ // backfill message mapper (department = id, channel = name). Optional in the
269
+ // contract, so null is fine when the message carries no populated request.
270
+ var msgRequest = messageJson.request || null;
271
+ var msgChannel =
272
+ (msgRequest && msgRequest.channel && msgRequest.channel.name) ||
273
+ (messageJson.channel && messageJson.channel.name) ||
274
+ null;
275
+
276
+ var idMessage = (messageJson._id || messageJson.id || "").toString();
201
277
  track("message.sent", messageJson.id_project, {
202
- id_message: (messageJson._id || messageJson.id || "").toString(),
278
+ id_message: idMessage,
203
279
  id_request: idRequest,
204
280
  sender_id: sender || "unknown", // required non-null — fallback to 'unknown'
205
- sender_type: senderType(sender, messageJson.request),
281
+ sender_type: senderType(sender, msgRequest),
206
282
  message_type: messageJson.type || "text", // required non-null — fallback to 'text'
207
283
  has_attachment: !!(messageJson.metadata && messageJson.metadata.src),
208
284
  language: messageJson.language || null,
209
- });
210
- });
211
-
212
- // ── 5. handover_to_human ──────────────────────────────────────────────────
213
- // Contract: packages/contracts/src/payloads/handover-to-human.ts
214
- // id_request string (required)
215
- // human_id string|null
216
- // reason string|null
217
- // department_id string|null
218
- // waiting_time_seconds number int>=0 | null
219
- // agent_id string|null (optional)
220
- // trigger_intent string|null (optional)
221
- requestEvent.on("request.participants.update", function (data) {
222
- var request = data.request || {};
223
- var removedParticipants = data.removedParticipants || [];
224
- var addedParticipants = data.addedParticipants || [];
225
-
226
- var botRemoved = removedParticipants.some(function (p) {
227
- return p.startsWith("bot_");
228
- });
229
- var humanAdded = addedParticipants.some(function (p) {
230
- return !p.startsWith("bot_");
231
- });
232
- if (!botRemoved || !humanAdded) return;
233
-
234
- var botId =
235
- removedParticipants.find(function (p) {
236
- return p.startsWith("bot_");
237
- }) || null;
238
- var humanId =
239
- addedParticipants.find(function (p) {
240
- return !p.startsWith("bot_");
241
- }) || null;
242
-
243
- var waitingTimeSecs = null;
244
- if (request.waiting_time != null) {
245
- waitingTimeSecs = Math.round(request.waiting_time / 1000);
246
- }
247
-
248
- track("handover_to_human", request.id_project, {
249
- id_request: request.request_id || toStringId(request),
250
- human_id: humanId,
251
- reason: null,
252
- department_id: departmentId(request.department),
253
- waiting_time_seconds: waitingTimeSecs,
254
- agent_id: null,
255
- trigger_intent: null,
256
- });
285
+ department: departmentId(msgRequest && msgRequest.department),
286
+ channel: msgChannel,
287
+ }, eventIdFor(idMessage));
257
288
  });
258
289
 
290
+ // ── handover_to_human: intentionally NOT emitted here ─────────────────────
291
+ // Owned by tiledesk-chatbot (DirMoveToAgent), which fires it reliably at
292
+ // handover time with full context (reason / agent_id / trigger_intent). The
293
+ // participants-diff signal that used to live here was routing-dependent — it
294
+ // required the bot to be removed and a human added in the SAME update, so
295
+ // queue/pool handovers (bot removed first, agent assigned later) were missed —
296
+ // and it double-counted bot escalations the chatbot already records.
259
297
 
260
298
  // ── 5. project_user.activated ─────────────────────────────────────────────
261
299
  // Contract: packages/contracts/src/payloads/project-user-activated.ts
@@ -284,7 +322,7 @@ function listen() {
284
322
  user_email: email,
285
323
  role: pu.role || "agent",
286
324
  invited_by: (event.req && event.req.user && event.req.user.id) || null,
287
- });
325
+ }, eventIdFor(pu.id_project + ":" + toStringId(pu) + ":activated"));
288
326
  });
289
327
 
290
328
  // ── 7. human.status_changed ───────────────────────────────────────────────
@@ -413,22 +451,30 @@ function listen() {
413
451
  // ── 13. agent.metadata_updated ────────────────────────────────────────────
414
452
  // Emitted by routes/faq_kb.js on bot create and update (rename, attribute
415
453
  // changes, language, etc.).
416
- // agent_id = Faq_kb._id.toString()
454
+ // agent_id = Faq_kb.root_id (the canonical/root agent id)
417
455
  // agent_name = Faq_kb.name
418
456
  // The consumer writes these into the agent_dimensions ReplacingMergeTree so
419
457
  // that dashboard queries always resolve the current bot name.
458
+ //
459
+ // Only PUBLISHED chatbots are tracked. Publishing forks the chatbot into a
460
+ // trashed faq_kb that carries root_id (-> the editable draft); the draft/root
461
+ // copy never has root_id. Gating on root_id therefore: (a) excludes drafts
462
+ // that were never published, (b) keys the dimension on the canonical root id
463
+ // — the same id the chatbot stamps on its runtime events — and (c) makes the
464
+ // name reflect the *published* name (a draft rename only lands here on the
465
+ // next publish, when a fresh fork fires faqbot.update).
420
466
  botEvent.on("faqbot.create", function (savedBot) {
421
- if (!savedBot || !savedBot.id_project || !savedBot.name) return;
467
+ if (!savedBot || !savedBot.id_project || !savedBot.name || !savedBot.root_id) return;
422
468
  track("agent.metadata_updated", savedBot.id_project, {
423
- agent_id: savedBot.root_id || savedBot._id.toString(),
469
+ agent_id: savedBot.root_id,
424
470
  agent_name: savedBot.name,
425
471
  });
426
472
  });
427
473
 
428
474
  botEvent.on("faqbot.update", function (updatedBot) {
429
- if (!updatedBot || !updatedBot.id_project || !updatedBot.name) return;
475
+ if (!updatedBot || !updatedBot.id_project || !updatedBot.name || !updatedBot.root_id) return;
430
476
  track("agent.metadata_updated", updatedBot.id_project, {
431
- agent_id: updatedBot.root_id || updatedBot._id.toString(),
477
+ agent_id: updatedBot.root_id,
432
478
  agent_name: updatedBot.name,
433
479
  });
434
480
  });
@@ -0,0 +1,247 @@
1
+ // During the test the env variable is set to test
2
+ process.env.NODE_ENV = 'test';
3
+
4
+ const { expect } = require('chai');
5
+
6
+ // Intercept analyticsClient.track BEFORE requiring the publisher so the
7
+ // reference destructured inside the module body is our spy. No DB or broker
8
+ // is touched: the publisher's listeners are pure (event in -> track() out).
9
+ const analyticsClient = require('../../../lib/analyticsClient');
10
+ const uuidv5 = require('uuid/v5');
11
+
12
+ // Must match apps/backfill BACKFILL_UUID_NAMESPACE.
13
+ const NS = '6ba7b810-9dad-11d1-80b4-00c04fd430c8';
14
+
15
+ const calls = [];
16
+ analyticsClient.track = function (eventType, idProject, payload, eventId) {
17
+ calls.push({ eventType: eventType, idProject: idProject, payload: payload, eventId: eventId });
18
+ };
19
+
20
+ // Fresh require so the module captures the patched track().
21
+ delete require.cache[require.resolve('../index')];
22
+ const publisher = require('../index');
23
+
24
+ const requestEvent = require('../../../event/requestEvent');
25
+ const messageEvent = require('../../../event/messageEvent');
26
+ const authEvent = require('../../../event/authEvent');
27
+
28
+ function callsFor(type) {
29
+ return calls.filter((c) => c.eventType === type);
30
+ }
31
+
32
+ describe('analytics-publisher', function () {
33
+ before(function () {
34
+ publisher.listen();
35
+ });
36
+
37
+ beforeEach(function () {
38
+ calls.length = 0;
39
+ });
40
+
41
+ describe('message.sent', function () {
42
+ it('propagates department (id) and channel (name) from the populated request', function () {
43
+ messageEvent.emit('message.create', {
44
+ _id: 'msg-1',
45
+ id_project: 'proj-1',
46
+ recipient: 'req-1',
47
+ sender: 'agent-1',
48
+ type: 'text',
49
+ request: {
50
+ participantsAgents: ['agent-1'],
51
+ participantsBots: [],
52
+ department: { _id: 'dept-9', name: 'Sales' },
53
+ channel: { name: 'whatsapp' },
54
+ },
55
+ });
56
+
57
+ const sent = callsFor('message.sent');
58
+ expect(sent).to.have.length(1);
59
+ expect(sent[0].payload.department).to.equal('dept-9');
60
+ expect(sent[0].payload.channel).to.equal('whatsapp');
61
+ });
62
+
63
+ it('sets department/channel to null when the message has no request', function () {
64
+ messageEvent.emit('message.create', {
65
+ _id: 'msg-2',
66
+ id_project: 'proj-1',
67
+ recipient: 'req-2',
68
+ sender: 'visitor-1',
69
+ type: 'text',
70
+ // no .request (direct/group message)
71
+ });
72
+
73
+ const sent = callsFor('message.sent');
74
+ expect(sent).to.have.length(1);
75
+ expect(sent[0].payload.department).to.equal(null);
76
+ expect(sent[0].payload.channel).to.equal(null);
77
+ });
78
+ });
79
+
80
+ describe('conversation.satisfaction', function () {
81
+ it('emits on request.satisfaction with a valid 1-5 rating', function () {
82
+ requestEvent.emit('request.satisfaction', {
83
+ request: {
84
+ request_id: 'req-3',
85
+ id_project: 'proj-1',
86
+ rating: 4,
87
+ rating_message: 'good',
88
+ },
89
+ patch: { rating: 4 },
90
+ });
91
+
92
+ const sat = callsFor('conversation.satisfaction');
93
+ expect(sat).to.have.length(1);
94
+ expect(sat[0].idProject).to.equal('proj-1');
95
+ expect(sat[0].payload).to.deep.equal({
96
+ id_request: 'req-3',
97
+ request_id: 'req-3',
98
+ rating: 4,
99
+ rating_message: 'good',
100
+ });
101
+ });
102
+
103
+ it('does not emit for an out-of-range rating', function () {
104
+ requestEvent.emit('request.satisfaction', {
105
+ request: { request_id: 'req-4', id_project: 'proj-1', rating: 9 },
106
+ patch: { rating: 9 },
107
+ });
108
+ expect(callsFor('conversation.satisfaction')).to.have.length(0);
109
+ });
110
+ });
111
+
112
+ describe('conversation.closed', function () {
113
+ it('omits the (stripped, superseded) satisfaction_rating field', function () {
114
+ requestEvent.emit('request.close', {
115
+ request_id: 'req-5',
116
+ id_project: 'proj-1',
117
+ createdAt: new Date(Date.now() - 60000),
118
+ closed_at: new Date(),
119
+ rating: 5,
120
+ });
121
+
122
+ const closed = callsFor('conversation.closed');
123
+ expect(closed).to.have.length(1);
124
+ expect(closed[0].payload).to.not.have.property('satisfaction_rating');
125
+ });
126
+ });
127
+
128
+ describe('conversation.tag_added', function () {
129
+ it('emits on request.updated with comment TAG_ADD (object tag)', function () {
130
+ requestEvent.emit('request.updated', {
131
+ comment: 'TAG_ADD',
132
+ request: { request_id: 'req-6', id_project: 'proj-1' },
133
+ patch: { tags: { tag: 'urgent' } },
134
+ });
135
+
136
+ const tagged = callsFor('conversation.tag_added');
137
+ expect(tagged).to.have.length(1);
138
+ expect(tagged[0].payload).to.deep.equal({ id_request: 'req-6', tag: 'urgent' });
139
+ });
140
+
141
+ it('extracts a plain string tag from the patch', function () {
142
+ requestEvent.emit('request.updated', {
143
+ comment: 'TAG_ADD',
144
+ request: { request_id: 'req-7', id_project: 'proj-1' },
145
+ patch: { tags: 'vip' },
146
+ });
147
+
148
+ const tagged = callsFor('conversation.tag_added');
149
+ expect(tagged).to.have.length(1);
150
+ expect(tagged[0].payload.tag).to.equal('vip');
151
+ });
152
+
153
+ it('does not emit for TAG_REMOVE or other comments', function () {
154
+ requestEvent.emit('request.updated', {
155
+ comment: 'TAG_REMOVE',
156
+ request: { request_id: 'req-8', id_project: 'proj-1' },
157
+ patch: { tags: { tag: 'urgent' } },
158
+ });
159
+ requestEvent.emit('request.updated', {
160
+ comment: 'PATCH',
161
+ request: { request_id: 'req-9', id_project: 'proj-1' },
162
+ patch: { status: 200 },
163
+ });
164
+ expect(callsFor('conversation.tag_added')).to.have.length(0);
165
+ });
166
+ });
167
+
168
+ // handover_to_human is owned by tiledesk-chatbot (DirMoveToAgent), which emits
169
+ // it reliably with full context (reason / agent_id / trigger_intent). The
170
+ // server's participants-diff signal was routing-dependent (missed queue/pool
171
+ // handovers) and double-counted bot escalations, so it is intentionally NOT
172
+ // emitted here.
173
+ describe('handover_to_human (owned by tiledesk-chatbot)', function () {
174
+ it('does not emit handover_to_human on a bot->human participants update', function () {
175
+ requestEvent.emit('request.participants.update', {
176
+ request: { request_id: 'req-h1', id_project: 'proj-1', department: 'dept-1' },
177
+ removedParticipants: ['bot_5e9d'],
178
+ addedParticipants: ['agent_7f2a'],
179
+ });
180
+ expect(callsFor('handover_to_human')).to.have.length(0);
181
+ });
182
+ });
183
+
184
+ // Deterministic event_ids let live + backfill events for the same entity
185
+ // collapse to one row (idempotency). Keys must match the backfill mappers.
186
+ describe('deterministic event_ids', function () {
187
+ it('conversation.created -> uuidv5(request_id)', function () {
188
+ requestEvent.emit('request.create', {
189
+ request_id: 'req-c1', id_project: 'proj-1', participants: [], hasBot: false,
190
+ channel: { name: 'web' },
191
+ });
192
+ const c = callsFor('conversation.created');
193
+ expect(c).to.have.length(1);
194
+ expect(c[0].eventId).to.equal(uuidv5('req-c1', NS));
195
+ });
196
+
197
+ it('conversation.closed -> uuidv5(request_id:closed)', function () {
198
+ requestEvent.emit('request.close', {
199
+ request_id: 'req-c2', id_project: 'proj-1',
200
+ createdAt: new Date(Date.now() - 1000), closed_at: new Date(),
201
+ });
202
+ const c = callsFor('conversation.closed');
203
+ expect(c).to.have.length(1);
204
+ expect(c[0].eventId).to.equal(uuidv5('req-c2:closed', NS));
205
+ });
206
+
207
+ it('conversation.satisfaction -> uuidv5(request_id:satisfaction)', function () {
208
+ requestEvent.emit('request.satisfaction', {
209
+ request: { request_id: 'req-c3', id_project: 'proj-1', rating: 5 },
210
+ });
211
+ const c = callsFor('conversation.satisfaction');
212
+ expect(c).to.have.length(1);
213
+ expect(c[0].eventId).to.equal(uuidv5('req-c3:satisfaction', NS));
214
+ });
215
+
216
+ it('conversation.tag_added -> uuidv5(request_id:tag)', function () {
217
+ requestEvent.emit('request.updated', {
218
+ comment: 'TAG_ADD', request: { request_id: 'req-c4', id_project: 'proj-1' },
219
+ patch: { tags: { tag: 'vip' } },
220
+ });
221
+ const c = callsFor('conversation.tag_added');
222
+ expect(c).to.have.length(1);
223
+ expect(c[0].eventId).to.equal(uuidv5('req-c4:vip', NS));
224
+ });
225
+
226
+ it('message.sent -> uuidv5(id_message)', function () {
227
+ messageEvent.emit('message.create', {
228
+ _id: 'msg-c5', id_project: 'proj-1', recipient: 'req-c5', sender: 'u1', type: 'text',
229
+ });
230
+ const c = callsFor('message.sent');
231
+ expect(c).to.have.length(1);
232
+ expect(c[0].eventId).to.equal(uuidv5('msg-c5', NS));
233
+ });
234
+
235
+ it('project_user.activated -> uuidv5(id_project:pu_id:activated)', function () {
236
+ authEvent.emit('project_user.invite', {
237
+ savedProject_userPopulated: {
238
+ _id: 'pu-c6', id_project: 'proj-1', role: 'agent',
239
+ id_user: { _id: 'user-c6', email: 'a@b.com' },
240
+ },
241
+ });
242
+ const c = callsFor('project_user.activated');
243
+ expect(c).to.have.length(1);
244
+ expect(c[0].eventId).to.equal(uuidv5('proj-1:pu-c6:activated', NS));
245
+ });
246
+ });
247
+ });
@@ -3,6 +3,7 @@ const router = express.Router();
3
3
  const { Namespace, AnsweredQuestion } = require('../models/kb_setting');
4
4
  const winston = require('../config/winston');
5
5
  var fastCsv = require('fast-csv');
6
+ const kbQuestionService = require('../services/kbQuestionService');
6
7
 
7
8
  // Add a new unanswerd question
8
9
  router.post('/', async (req, res) => {
@@ -11,7 +12,7 @@ router.post('/', async (req, res) => {
11
12
  const data = req.body;
12
13
  const id_project = req.projectid;
13
14
 
14
- const savedQuestion = await KbQuestionService.createAnsweredQuestion(
15
+ const savedQuestion = await kbQuestionService.createAnsweredQuestion(
15
16
  id_project,
16
17
  data
17
18
  );
package/routes/kb.js CHANGED
@@ -1267,6 +1267,10 @@ router.post('/namespace/import/:id', upload.single('uploadFile'), async (req, re
1267
1267
  }
1268
1268
  }
1269
1269
 
1270
+ if (e.situated_context === true && (e.type !== "url" || content.scrape_type === 0)) {
1271
+ content.situated_context = true;
1272
+ }
1273
+
1270
1274
  addingContents.push(content);
1271
1275
  })
1272
1276
 
@@ -1291,8 +1295,6 @@ router.post('/namespace/import/:id', upload.single('uploadFile'), async (req, re
1291
1295
  let engine = ns.engine || default_engine;
1292
1296
  let embedding = aiManager.normalizeEmbedding(ns.embedding);
1293
1297
  let hybrid = ns.hybrid;
1294
- const situated_context = normalizeSituatedContext();
1295
-
1296
1298
 
1297
1299
  if (process.env.NODE_ENV !== "test") {
1298
1300
  await aiService.deleteNamespace({
@@ -1327,14 +1329,15 @@ router.post('/namespace/import/:id', upload.single('uploadFile'), async (req, re
1327
1329
  return res.status(500).send({ success: false, error: "Unable to get new content" });
1328
1330
  }
1329
1331
 
1330
- let resources = new_contents.map(({ name, status, __v, createdAt, updatedAt, id_project, ...keepAttrs }) => keepAttrs)
1331
- resources = resources.map(({ _id, scrape_options, ...rest }) => {
1332
+ let resources = new_contents.map(({ name, status, __v, createdAt, updatedAt, ...keepAttrs }) => keepAttrs)
1333
+ resources = resources.map(({ _id, scrape_options, situated_context, ...rest }) => {
1334
+ const situated_context_obj = aiManager.normalizeSituatedContext(situated_context);
1332
1335
  return {
1333
1336
  id: _id,
1334
1337
  parameters_scrape_type_4: scrape_options,
1335
1338
  embedding: embedding,
1336
1339
  engine: engine,
1337
- ...(situated_context && { situated_context: situated_context }),
1340
+ ...(situated_context_obj && { situated_context: situated_context_obj }),
1338
1341
  ...rest
1339
1342
  }
1340
1343
  });
@@ -1731,6 +1734,7 @@ router.post('/', async (req, res) => {
1731
1734
 
1732
1735
  const json = {
1733
1736
  id: saved_kb._id,
1737
+ id_project: saved_kb.id_project,
1734
1738
  type: saved_kb.type,
1735
1739
  source: saved_kb.source,
1736
1740
  content: saved_kb.content || "",
@@ -1909,7 +1913,7 @@ router.post('/csv', upload.single('uploadFile'), async (req, res) => {
1909
1913
  situated_context_obj = normalizeSituatedContext(situated_context);
1910
1914
  }
1911
1915
 
1912
- let resources = result.map(({ name, status, __v, createdAt, updatedAt, id_project, situated_context, ...keepAttrs }) => keepAttrs)
1916
+ let resources = result.map(({ name, status, __v, createdAt, updatedAt, situated_context, ...keepAttrs }) => keepAttrs)
1913
1917
  resources = resources.map(({ _id, ...rest }) => {
1914
1918
  return {
1915
1919
  id: _id,
@@ -2200,6 +2204,7 @@ router.put('/:kb_id', async (req, res) => {
2200
2204
 
2201
2205
  const json = {
2202
2206
  id: updated_content._id,
2207
+ id_project: updated_content.id_project,
2203
2208
  type: updated_content.type,
2204
2209
  source: updated_content.source,
2205
2210
  content: updated_content.content || "",
package/routes/request.js CHANGED
@@ -456,6 +456,12 @@ router.put('/:requestid/replace', async (req, res) => {
456
456
  let id;
457
457
  let name;
458
458
  let slug;
459
+ // Canonical (root) id of the target bot, returned to the caller so analytics
460
+ // (agent.bot_switched.to_agent_id) can attribute the switch to the root agent
461
+ // — matching agent_dimensions and the target bot's own runtime events. A
462
+ // published bot is forked to a trashed copy with its own _id; root_id points
463
+ // back to the editable root, so root_id || _id is the canonical agent id.
464
+ let replacedRootId = null;
459
465
 
460
466
  if (req.body.id) {
461
467
  id = "bot_" + req.body.id;
@@ -478,6 +484,7 @@ router.put('/:requestid/replace', async (req, res) => {
478
484
  }
479
485
 
480
486
  id = "bot_" + chatbot._id;
487
+ replacedRootId = (chatbot.root_id || chatbot._id).toString();
481
488
  winston.verbose("Chatbot found: ", id);
482
489
  }
483
490
 
@@ -492,16 +499,29 @@ router.put('/:requestid/replace', async (req, res) => {
492
499
  }
493
500
 
494
501
  id = "bot_" + chatbot._id;
502
+ replacedRootId = (chatbot.root_id || chatbot._id).toString();
495
503
  winston.verbose("Chatbot found: " + id);
496
504
  }
497
505
 
506
+ // Replacing by raw id: normalize a possibly-published (fork) id to its root.
507
+ if (req.body.id) {
508
+ let chatbot = await faq_kb.findById(req.body.id).catch((err) => {
509
+ winston.error("Error finding bot by id ", err);
510
+ return null;
511
+ });
512
+ replacedRootId = ((chatbot && (chatbot.root_id || chatbot._id)) || req.body.id).toString();
513
+ }
514
+
498
515
  let participants = [];
499
516
  participants.push(id);
500
517
  winston.verbose("participants to be set: ", participants);
501
518
 
502
519
  requestService.setParticipantsByRequestId(req.params.requestid, req.projectid, participants).then((updatedRequest) => {
503
520
  winston.debug("SetParticipant response: ", updatedRequest);
504
- res.status(200).send(updatedRequest);
521
+ // Additive: include the resolved canonical (root) bot id for analytics
522
+ // attribution. Existing consumers ignore the extra field.
523
+ const requestObj = (updatedRequest && updatedRequest.toJSON) ? updatedRequest.toJSON() : updatedRequest;
524
+ res.status(200).send({ ...requestObj, replaced_bot_root_id: replacedRootId });
505
525
  }).catch((err) => {
506
526
  winston.error("Error setting participants ", err);
507
527
  res.status(500).send({ success: false, error: "Error setting participants to request"})
package/routes/webhook.js CHANGED
@@ -166,6 +166,7 @@ router.post('/kb/reindex', async (req, res) => {
166
166
 
167
167
  let json = {
168
168
  id: kb._id,
169
+ id_project: kb.id_project,
169
170
  type: kb.type,
170
171
  source: kb.source,
171
172
  content: "",
@@ -311,7 +312,11 @@ router.all('/:webhook_id', async (req, res) => {
311
312
  //webhookService.run(webhook, payload)
312
313
  // To delete - End
313
314
  webhookService.run(webhook, payload, dev, redis_client).then((response) => {
314
- webhookEvent.emit("webhook.triggered", { webhook: webhook, payload: payload });
315
+ // Only count production runs: a dev/test invocation (?dev=true here, or the
316
+ // dedicated /:webhook_id/dev route below) must not emit analytics.
317
+ if (dev !== true && dev !== 'true') {
318
+ webhookEvent.emit("webhook.triggered", { webhook: webhook, payload: payload });
319
+ }
315
320
  return res.status(200).send(response);
316
321
  }).catch((err) => {
317
322
  if (err.code === errorCodes.WEBHOOK.ERRORS.NO_PRELOADED_DEV_REQUEST) {
@@ -321,7 +326,7 @@ router.all('/:webhook_id', async (req, res) => {
321
326
  return res.status(status).send(err.data);
322
327
  }
323
328
  })
324
-
329
+
325
330
  })
326
331
 
327
332
  router.all('/:webhook_id/dev', async (req, res) => {
@@ -104,7 +104,7 @@ class AiManager {
104
104
 
105
105
  let webhook = apiUrl + '/webhook/kb/status?token=' + KB_WEBHOOK_TOKEN;
106
106
 
107
- let resources = result.map(({ name, status, __v, createdAt, updatedAt, id_project, situated_context, ...keepAttrs }) => keepAttrs)
107
+ let resources = result.map(({ name, status, __v, createdAt, updatedAt, situated_context, ...keepAttrs }) => keepAttrs)
108
108
  resources = resources.map(({ _id, scrape_options, ...rest }) => {
109
109
  return {
110
110
  id: _id,
@@ -139,6 +139,7 @@ class AiManager {
139
139
 
140
140
  let kb = {
141
141
  id: sitemap_content._id,
142
+ id_project: namespace.id_project,
142
143
  source: sitemap_content.source,
143
144
  type: 'sitemap',
144
145
  content: "",
package/test/kbRoute.js CHANGED
@@ -244,7 +244,7 @@ describe('KbRoute', () => {
244
244
  expect(res.body.embedding.name).to.equal('text-embedding-ada-002')
245
245
  expect(res.body.embedding.dimension).to.equal(1536)
246
246
  expect(res.body.embedding.api_key).to.equal('')
247
-
247
+
248
248
  done();
249
249
  })
250
250
  })
@@ -321,7 +321,7 @@ describe('KbRoute', () => {
321
321
  .end((err, res) => {
322
322
  if (err) { console.error("err: ", err); }
323
323
  if (log) { console.log("get all namespaces res.body: ", res.body); }
324
-
324
+
325
325
  res.should.have.status(200);
326
326
  res.body.should.be.a('array');
327
327
  expect(res.body.length).to.equal(1);
@@ -633,6 +633,7 @@ describe('KbRoute', () => {
633
633
 
634
634
  let scheduleJson = res.body.schedule_json;
635
635
  expect(scheduleJson.namespace).to.equal(namespace_id)
636
+ expect(scheduleJson.id_project).to.equal(namespace_id)
636
637
  expect(scheduleJson.type).to.equal("url")
637
638
  expect(scheduleJson.source).to.equal("https://www.exampleurl5.com")
638
639
  expect(scheduleJson.hybrid).to.equal(false);
@@ -1180,6 +1181,7 @@ describe('KbRoute', () => {
1180
1181
  scheduleJson.should.be.a('array');
1181
1182
  expect(scheduleJson.length).to.equal(2);
1182
1183
  expect(scheduleJson[0].namespace).to.equal(namespace_id);
1184
+ expect(scheduleJson[0].id_project).to.equal(namespace_id);
1183
1185
  expect(scheduleJson[0].type).to.equal('faq');
1184
1186
  expect(scheduleJson[0].source).to.equal('Question 1');
1185
1187
  should.exist(scheduleJson[0].engine)
@@ -1344,6 +1346,7 @@ describe('KbRoute', () => {
1344
1346
  let scheduleJson = res.body.schedule_json;
1345
1347
  expect(scheduleJson.length).to.equal(4);
1346
1348
  expect(scheduleJson[0].namespace).to.equal(namespace_id);
1349
+ expect(scheduleJson[0].id_project).to.equal(namespace_id);
1347
1350
  expect(scheduleJson[0].source).to.equal('https://gethelp.tiledesk.com/articles/article1');
1348
1351
  expect(scheduleJson[0].tags.length).to.equal(2);
1349
1352
  expect(scheduleJson[0].tags[0]).to.equal('tag1');
@@ -1546,6 +1549,7 @@ describe('KbRoute', () => {
1546
1549
  scheduleJson.should.be.a('array');
1547
1550
  should.exist(scheduleJson[0].engine)
1548
1551
  should.exist(scheduleJson[0].embedding)
1552
+ expect(scheduleJson[0].id_project).to.equal(namespace_id)
1549
1553
  expect(scheduleJson[0].embedding.api_key).to.equal('fakegptkey');
1550
1554
 
1551
1555
  done();