@astralibx/call-log-engine 0.2.0
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/README.md +157 -0
- package/dist/index.cjs +2500 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.mts +445 -0
- package/dist/index.d.ts +445 -0
- package/dist/index.mjs +2454 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +62 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,2500 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var zod = require('zod');
|
|
4
|
+
var core = require('@astralibx/core');
|
|
5
|
+
var callLogTypes = require('@astralibx/call-log-types');
|
|
6
|
+
var crypto5 = require('crypto');
|
|
7
|
+
var mongoose = require('mongoose');
|
|
8
|
+
var express = require('express');
|
|
9
|
+
|
|
10
|
+
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
11
|
+
|
|
12
|
+
var crypto5__default = /*#__PURE__*/_interopDefault(crypto5);
|
|
13
|
+
|
|
14
|
+
// src/index.ts
|
|
15
|
+
var PipelineStageSchema = new mongoose.Schema(
|
|
16
|
+
{
|
|
17
|
+
stageId: { type: String, required: true, default: () => crypto5__default.default.randomUUID() },
|
|
18
|
+
name: { type: String, required: true },
|
|
19
|
+
color: { type: String, required: true },
|
|
20
|
+
order: { type: Number, required: true },
|
|
21
|
+
isTerminal: { type: Boolean, default: false },
|
|
22
|
+
isDefault: { type: Boolean, default: false }
|
|
23
|
+
},
|
|
24
|
+
{ _id: false }
|
|
25
|
+
);
|
|
26
|
+
var PipelineSchema = new mongoose.Schema(
|
|
27
|
+
{
|
|
28
|
+
pipelineId: { type: String, required: true, unique: true, default: () => crypto5__default.default.randomUUID() },
|
|
29
|
+
name: { type: String, required: true },
|
|
30
|
+
description: { type: String },
|
|
31
|
+
stages: { type: [PipelineStageSchema], required: true },
|
|
32
|
+
isActive: { type: Boolean, default: true, index: true },
|
|
33
|
+
isDeleted: { type: Boolean, default: false },
|
|
34
|
+
isDefault: { type: Boolean, default: false },
|
|
35
|
+
createdBy: { type: String, required: true },
|
|
36
|
+
tenantId: { type: String, sparse: true },
|
|
37
|
+
metadata: { type: mongoose.Schema.Types.Mixed }
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
timestamps: true
|
|
41
|
+
}
|
|
42
|
+
);
|
|
43
|
+
function createPipelineModel(connection, prefix) {
|
|
44
|
+
const collectionName = prefix ? `${prefix}_pipelines` : "pipelines";
|
|
45
|
+
return connection.model("Pipeline", PipelineSchema, collectionName);
|
|
46
|
+
}
|
|
47
|
+
var ContactRefSchema = new mongoose.Schema(
|
|
48
|
+
{
|
|
49
|
+
externalId: { type: String, required: true },
|
|
50
|
+
displayName: { type: String, required: true },
|
|
51
|
+
phone: { type: String },
|
|
52
|
+
email: { type: String }
|
|
53
|
+
},
|
|
54
|
+
{ _id: false }
|
|
55
|
+
);
|
|
56
|
+
var TimelineEntrySchema = new mongoose.Schema(
|
|
57
|
+
{
|
|
58
|
+
entryId: { type: String, required: true, default: () => crypto5__default.default.randomUUID() },
|
|
59
|
+
type: {
|
|
60
|
+
type: String,
|
|
61
|
+
required: true,
|
|
62
|
+
enum: Object.values(callLogTypes.TimelineEntryType)
|
|
63
|
+
},
|
|
64
|
+
content: { type: String },
|
|
65
|
+
authorId: { type: String },
|
|
66
|
+
authorName: { type: String },
|
|
67
|
+
fromStageId: { type: String },
|
|
68
|
+
fromStageName: { type: String },
|
|
69
|
+
toStageId: { type: String },
|
|
70
|
+
toStageName: { type: String },
|
|
71
|
+
fromAgentId: { type: String },
|
|
72
|
+
fromAgentName: { type: String },
|
|
73
|
+
toAgentId: { type: String },
|
|
74
|
+
toAgentName: { type: String },
|
|
75
|
+
createdAt: { type: Date, default: Date.now }
|
|
76
|
+
},
|
|
77
|
+
{ _id: false }
|
|
78
|
+
);
|
|
79
|
+
var StageChangeSchema = new mongoose.Schema(
|
|
80
|
+
{
|
|
81
|
+
fromStageId: { type: String, required: true },
|
|
82
|
+
toStageId: { type: String, required: true },
|
|
83
|
+
fromStageName: { type: String, required: true },
|
|
84
|
+
toStageName: { type: String, required: true },
|
|
85
|
+
changedBy: { type: String, required: true },
|
|
86
|
+
changedAt: { type: Date, required: true },
|
|
87
|
+
timeInStageMs: { type: Number, required: true }
|
|
88
|
+
},
|
|
89
|
+
{ _id: false }
|
|
90
|
+
);
|
|
91
|
+
var CallLogSchema = new mongoose.Schema(
|
|
92
|
+
{
|
|
93
|
+
callLogId: { type: String, required: true, unique: true, default: () => crypto5__default.default.randomUUID() },
|
|
94
|
+
pipelineId: { type: String, required: true, index: true },
|
|
95
|
+
currentStageId: { type: String, required: true, index: true },
|
|
96
|
+
contactRef: { type: ContactRefSchema, required: true },
|
|
97
|
+
direction: {
|
|
98
|
+
type: String,
|
|
99
|
+
required: true,
|
|
100
|
+
enum: Object.values(callLogTypes.CallDirection)
|
|
101
|
+
},
|
|
102
|
+
callDate: { type: Date, required: true, index: true },
|
|
103
|
+
nextFollowUpDate: { type: Date, index: true },
|
|
104
|
+
followUpNotifiedAt: { type: Date },
|
|
105
|
+
priority: {
|
|
106
|
+
type: String,
|
|
107
|
+
required: true,
|
|
108
|
+
enum: Object.values(callLogTypes.CallPriority),
|
|
109
|
+
default: callLogTypes.CallPriority.Medium
|
|
110
|
+
},
|
|
111
|
+
agentId: { type: mongoose.Schema.Types.ObjectId, required: true, index: true },
|
|
112
|
+
assignedBy: { type: String },
|
|
113
|
+
tags: { type: [String], default: [], index: true },
|
|
114
|
+
category: { type: String },
|
|
115
|
+
timeline: { type: [TimelineEntrySchema], default: [] },
|
|
116
|
+
stageHistory: { type: [StageChangeSchema], default: [] },
|
|
117
|
+
durationMinutes: { type: Number, min: 0 },
|
|
118
|
+
isClosed: { type: Boolean, default: false, index: true },
|
|
119
|
+
closedAt: { type: Date },
|
|
120
|
+
tenantId: { type: String, sparse: true },
|
|
121
|
+
metadata: { type: mongoose.Schema.Types.Mixed }
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
timestamps: true
|
|
125
|
+
}
|
|
126
|
+
);
|
|
127
|
+
CallLogSchema.index({ "contactRef.externalId": 1 });
|
|
128
|
+
CallLogSchema.index({ pipelineId: 1, currentStageId: 1 });
|
|
129
|
+
CallLogSchema.index({ agentId: 1, isClosed: 1 });
|
|
130
|
+
function createCallLogModel(connection, prefix) {
|
|
131
|
+
const collectionName = prefix ? `${prefix}_call_logs` : "call_logs";
|
|
132
|
+
return connection.model("CallLog", CallLogSchema, collectionName);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// src/constants/index.ts
|
|
136
|
+
var ERROR_CODE = {
|
|
137
|
+
PipelineNotFound: "CALL_PIPELINE_NOT_FOUND",
|
|
138
|
+
InvalidPipeline: "CALL_INVALID_PIPELINE",
|
|
139
|
+
StageNotFound: "CALL_STAGE_NOT_FOUND",
|
|
140
|
+
StageInUse: "CALL_STAGE_IN_USE",
|
|
141
|
+
CallLogNotFound: "CALL_LOG_NOT_FOUND",
|
|
142
|
+
CallLogClosed: "CALL_LOG_CLOSED",
|
|
143
|
+
ContactNotFound: "CALL_CONTACT_NOT_FOUND",
|
|
144
|
+
AgentCapacityFull: "CALL_AGENT_CAPACITY_FULL",
|
|
145
|
+
InvalidConfig: "CALL_INVALID_CONFIG",
|
|
146
|
+
AuthFailed: "CALL_AUTH_FAILED"
|
|
147
|
+
};
|
|
148
|
+
var ERROR_MESSAGE = {
|
|
149
|
+
PipelineNotFound: "Pipeline not found",
|
|
150
|
+
PipelineNoDefaultStage: "Pipeline must have exactly one default stage",
|
|
151
|
+
PipelineNoTerminalStage: "Pipeline must have at least one terminal stage",
|
|
152
|
+
PipelineDuplicateStageNames: "Stage names must be unique within a pipeline",
|
|
153
|
+
StageNotFound: "Stage not found in pipeline",
|
|
154
|
+
StageInUse: "Cannot remove stage that has active calls",
|
|
155
|
+
CallLogNotFound: "Call log not found",
|
|
156
|
+
CallLogClosed: "Cannot modify a closed call log",
|
|
157
|
+
ContactNotFound: "Contact not found",
|
|
158
|
+
AgentCapacityFull: "Agent has reached maximum concurrent calls",
|
|
159
|
+
AuthFailed: "Authentication failed"
|
|
160
|
+
};
|
|
161
|
+
var PIPELINE_DEFAULTS = {
|
|
162
|
+
MaxStages: 20
|
|
163
|
+
};
|
|
164
|
+
var CALL_LOG_DEFAULTS = {
|
|
165
|
+
MaxTimelineEntries: 200,
|
|
166
|
+
DefaultFollowUpDays: 3,
|
|
167
|
+
TimelinePageSize: 20
|
|
168
|
+
};
|
|
169
|
+
var AGENT_CALL_DEFAULTS = {
|
|
170
|
+
MaxConcurrentCalls: 10
|
|
171
|
+
};
|
|
172
|
+
var SYSTEM_TIMELINE = {
|
|
173
|
+
CallCreated: "Call log created",
|
|
174
|
+
CallClosed: "Call closed",
|
|
175
|
+
CallReopened: "Call reopened",
|
|
176
|
+
FollowUpCompleted: "Follow-up completed"
|
|
177
|
+
};
|
|
178
|
+
var SYSTEM_TIMELINE_FN = {
|
|
179
|
+
stageChanged: (from, to) => `Stage changed from "${from}" to "${to}"`,
|
|
180
|
+
callAssigned: (agentName) => `Call assigned to ${agentName}`,
|
|
181
|
+
callReassigned: (from, to) => `Call reassigned from ${from} to ${to}`,
|
|
182
|
+
followUpSet: (date) => `Follow-up scheduled for ${date}`
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
// src/schemas/call-log-settings.schema.ts
|
|
186
|
+
var DEFAULT_PRIORITY_LEVELS = [
|
|
187
|
+
{ value: callLogTypes.CallPriority.Low, label: "Low", color: "#6b7280", order: 1 },
|
|
188
|
+
{ value: callLogTypes.CallPriority.Medium, label: "Medium", color: "#3b82f6", order: 2 },
|
|
189
|
+
{ value: callLogTypes.CallPriority.High, label: "High", color: "#f59e0b", order: 3 },
|
|
190
|
+
{ value: callLogTypes.CallPriority.Urgent, label: "Urgent", color: "#ef4444", order: 4 }
|
|
191
|
+
];
|
|
192
|
+
var PriorityConfigSchema = new mongoose.Schema(
|
|
193
|
+
{
|
|
194
|
+
value: { type: String, required: true },
|
|
195
|
+
label: { type: String, required: true },
|
|
196
|
+
color: { type: String, required: true },
|
|
197
|
+
order: { type: Number, required: true }
|
|
198
|
+
},
|
|
199
|
+
{ _id: false }
|
|
200
|
+
);
|
|
201
|
+
var CallLogSettingsSchema = new mongoose.Schema(
|
|
202
|
+
{
|
|
203
|
+
key: { type: String, required: true, default: "global" },
|
|
204
|
+
availableTags: { type: [String], default: [] },
|
|
205
|
+
availableCategories: { type: [String], default: [] },
|
|
206
|
+
priorityLevels: {
|
|
207
|
+
type: [PriorityConfigSchema],
|
|
208
|
+
default: () => DEFAULT_PRIORITY_LEVELS.map((p) => ({ ...p }))
|
|
209
|
+
},
|
|
210
|
+
defaultFollowUpDays: {
|
|
211
|
+
type: Number,
|
|
212
|
+
default: CALL_LOG_DEFAULTS.DefaultFollowUpDays
|
|
213
|
+
},
|
|
214
|
+
followUpReminderEnabled: { type: Boolean, default: true },
|
|
215
|
+
defaultPipelineId: { type: String },
|
|
216
|
+
timelinePageSize: {
|
|
217
|
+
type: Number,
|
|
218
|
+
default: CALL_LOG_DEFAULTS.TimelinePageSize
|
|
219
|
+
},
|
|
220
|
+
maxConcurrentCalls: {
|
|
221
|
+
type: Number,
|
|
222
|
+
default: AGENT_CALL_DEFAULTS.MaxConcurrentCalls
|
|
223
|
+
},
|
|
224
|
+
tenantId: { type: String, sparse: true },
|
|
225
|
+
metadata: { type: mongoose.Schema.Types.Mixed }
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
timestamps: true
|
|
229
|
+
}
|
|
230
|
+
);
|
|
231
|
+
CallLogSettingsSchema.index({ key: 1, tenantId: 1 }, { unique: true });
|
|
232
|
+
function createCallLogSettingsModel(connection, prefix) {
|
|
233
|
+
const collectionName = prefix ? `${prefix}_call_log_settings` : "call_log_settings";
|
|
234
|
+
return connection.model(
|
|
235
|
+
"CallLogSettings",
|
|
236
|
+
CallLogSettingsSchema,
|
|
237
|
+
collectionName
|
|
238
|
+
);
|
|
239
|
+
}
|
|
240
|
+
var AlxCallLogError = class extends core.AlxError {
|
|
241
|
+
constructor(message, code, context) {
|
|
242
|
+
super(message, code);
|
|
243
|
+
this.context = context;
|
|
244
|
+
this.name = "AlxCallLogError";
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
var PipelineNotFoundError = class extends AlxCallLogError {
|
|
248
|
+
constructor(pipelineId) {
|
|
249
|
+
super(`Pipeline not found: ${pipelineId}`, ERROR_CODE.PipelineNotFound, { pipelineId });
|
|
250
|
+
this.pipelineId = pipelineId;
|
|
251
|
+
this.name = "PipelineNotFoundError";
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
var InvalidPipelineError = class extends AlxCallLogError {
|
|
255
|
+
constructor(reason, pipelineId) {
|
|
256
|
+
super(
|
|
257
|
+
pipelineId ? `Invalid pipeline "${pipelineId}": ${reason}` : `Invalid pipeline: ${reason}`,
|
|
258
|
+
ERROR_CODE.InvalidPipeline,
|
|
259
|
+
{ pipelineId, reason }
|
|
260
|
+
);
|
|
261
|
+
this.reason = reason;
|
|
262
|
+
this.pipelineId = pipelineId;
|
|
263
|
+
this.name = "InvalidPipelineError";
|
|
264
|
+
}
|
|
265
|
+
};
|
|
266
|
+
var StageNotFoundError = class extends AlxCallLogError {
|
|
267
|
+
constructor(pipelineId, stageId) {
|
|
268
|
+
super(
|
|
269
|
+
`Stage "${stageId}" not found in pipeline "${pipelineId}"`,
|
|
270
|
+
ERROR_CODE.StageNotFound,
|
|
271
|
+
{ pipelineId, stageId }
|
|
272
|
+
);
|
|
273
|
+
this.pipelineId = pipelineId;
|
|
274
|
+
this.stageId = stageId;
|
|
275
|
+
this.name = "StageNotFoundError";
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
var StageInUseError = class extends AlxCallLogError {
|
|
279
|
+
constructor(pipelineId, stageId, activeCallCount) {
|
|
280
|
+
super(
|
|
281
|
+
`Cannot remove stage "${stageId}" in pipeline "${pipelineId}": ${activeCallCount} active call(s)`,
|
|
282
|
+
ERROR_CODE.StageInUse,
|
|
283
|
+
{ pipelineId, stageId, activeCallCount }
|
|
284
|
+
);
|
|
285
|
+
this.pipelineId = pipelineId;
|
|
286
|
+
this.stageId = stageId;
|
|
287
|
+
this.activeCallCount = activeCallCount;
|
|
288
|
+
this.name = "StageInUseError";
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
var CallLogNotFoundError = class extends AlxCallLogError {
|
|
292
|
+
constructor(callLogId) {
|
|
293
|
+
super(`Call log not found: ${callLogId}`, ERROR_CODE.CallLogNotFound, { callLogId });
|
|
294
|
+
this.callLogId = callLogId;
|
|
295
|
+
this.name = "CallLogNotFoundError";
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
var CallLogClosedError = class extends AlxCallLogError {
|
|
299
|
+
constructor(callLogId, attemptedAction) {
|
|
300
|
+
super(
|
|
301
|
+
`Cannot ${attemptedAction} on closed call log "${callLogId}"`,
|
|
302
|
+
ERROR_CODE.CallLogClosed,
|
|
303
|
+
{ callLogId, attemptedAction }
|
|
304
|
+
);
|
|
305
|
+
this.callLogId = callLogId;
|
|
306
|
+
this.attemptedAction = attemptedAction;
|
|
307
|
+
this.name = "CallLogClosedError";
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
var ContactNotFoundError = class extends AlxCallLogError {
|
|
311
|
+
constructor(contactQuery) {
|
|
312
|
+
super(
|
|
313
|
+
`Contact not found: ${JSON.stringify(contactQuery)}`,
|
|
314
|
+
ERROR_CODE.ContactNotFound,
|
|
315
|
+
{ contactQuery }
|
|
316
|
+
);
|
|
317
|
+
this.contactQuery = contactQuery;
|
|
318
|
+
this.name = "ContactNotFoundError";
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
var AgentCapacityError = class extends AlxCallLogError {
|
|
322
|
+
constructor(agentId, currentCalls, maxCalls) {
|
|
323
|
+
super(
|
|
324
|
+
`Agent ${agentId} at capacity (${currentCalls}/${maxCalls})`,
|
|
325
|
+
ERROR_CODE.AgentCapacityFull,
|
|
326
|
+
{ agentId, currentCalls, maxCalls }
|
|
327
|
+
);
|
|
328
|
+
this.agentId = agentId;
|
|
329
|
+
this.currentCalls = currentCalls;
|
|
330
|
+
this.maxCalls = maxCalls;
|
|
331
|
+
this.name = "AgentCapacityError";
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
var InvalidConfigError = class extends AlxCallLogError {
|
|
335
|
+
constructor(field, reason) {
|
|
336
|
+
super(`Invalid config for "${field}": ${reason}`, ERROR_CODE.InvalidConfig, { field, reason });
|
|
337
|
+
this.field = field;
|
|
338
|
+
this.reason = reason;
|
|
339
|
+
this.name = "InvalidConfigError";
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
var AuthFailedError = class extends AlxCallLogError {
|
|
343
|
+
constructor(reason) {
|
|
344
|
+
super(
|
|
345
|
+
reason ? `Authentication failed: ${reason}` : "Authentication failed",
|
|
346
|
+
ERROR_CODE.AuthFailed,
|
|
347
|
+
{ reason }
|
|
348
|
+
);
|
|
349
|
+
this.reason = reason;
|
|
350
|
+
this.name = "AuthFailedError";
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
// src/services/settings.service.ts
|
|
355
|
+
function validateIntegerRange(value, field, min, max) {
|
|
356
|
+
if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) {
|
|
357
|
+
throw new InvalidConfigError(field, `Must be an integer between ${min} and ${max}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
var SettingsService = class {
|
|
361
|
+
constructor(CallLogSettings, logger, tenantId) {
|
|
362
|
+
this.CallLogSettings = CallLogSettings;
|
|
363
|
+
this.logger = logger;
|
|
364
|
+
this.tenantId = tenantId;
|
|
365
|
+
}
|
|
366
|
+
get settingsFilter() {
|
|
367
|
+
const filter = { key: "global" };
|
|
368
|
+
if (this.tenantId) filter.tenantId = this.tenantId;
|
|
369
|
+
return filter;
|
|
370
|
+
}
|
|
371
|
+
buildDefaults() {
|
|
372
|
+
const defaults = {
|
|
373
|
+
key: "global",
|
|
374
|
+
availableTags: [],
|
|
375
|
+
availableCategories: [],
|
|
376
|
+
priorityLevels: [],
|
|
377
|
+
defaultFollowUpDays: CALL_LOG_DEFAULTS.DefaultFollowUpDays,
|
|
378
|
+
followUpReminderEnabled: true,
|
|
379
|
+
timelinePageSize: CALL_LOG_DEFAULTS.TimelinePageSize,
|
|
380
|
+
maxConcurrentCalls: AGENT_CALL_DEFAULTS.MaxConcurrentCalls
|
|
381
|
+
};
|
|
382
|
+
if (this.tenantId) defaults.tenantId = this.tenantId;
|
|
383
|
+
return defaults;
|
|
384
|
+
}
|
|
385
|
+
async get() {
|
|
386
|
+
return this.CallLogSettings.findOneAndUpdate(
|
|
387
|
+
this.settingsFilter,
|
|
388
|
+
{ $setOnInsert: this.buildDefaults() },
|
|
389
|
+
{ upsert: true, new: true }
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
async update(data) {
|
|
393
|
+
if (data.defaultFollowUpDays != null) {
|
|
394
|
+
validateIntegerRange(data.defaultFollowUpDays, "defaultFollowUpDays", 1, 30);
|
|
395
|
+
}
|
|
396
|
+
if (data.timelinePageSize != null) {
|
|
397
|
+
validateIntegerRange(data.timelinePageSize, "timelinePageSize", 5, 100);
|
|
398
|
+
}
|
|
399
|
+
if (data.maxConcurrentCalls != null) {
|
|
400
|
+
validateIntegerRange(data.maxConcurrentCalls, "maxConcurrentCalls", 1, 50);
|
|
401
|
+
}
|
|
402
|
+
const settings = await this.CallLogSettings.findOneAndUpdate(
|
|
403
|
+
this.settingsFilter,
|
|
404
|
+
{ $set: data },
|
|
405
|
+
{ upsert: true, new: true }
|
|
406
|
+
);
|
|
407
|
+
this.logger.info("Call log settings updated", { fields: Object.keys(data) });
|
|
408
|
+
return settings;
|
|
409
|
+
}
|
|
410
|
+
};
|
|
411
|
+
|
|
412
|
+
// src/validation/pipeline.validator.ts
|
|
413
|
+
function validatePipelineStages(stages, pipelineId) {
|
|
414
|
+
if (stages.length === 0) {
|
|
415
|
+
throw new InvalidPipelineError("Pipeline must have at least one stage", pipelineId);
|
|
416
|
+
}
|
|
417
|
+
const defaultStages = stages.filter((s) => s.isDefault);
|
|
418
|
+
if (defaultStages.length === 0) {
|
|
419
|
+
throw new InvalidPipelineError(
|
|
420
|
+
"Pipeline must have exactly one default stage",
|
|
421
|
+
pipelineId
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
if (defaultStages.length > 1) {
|
|
425
|
+
throw new InvalidPipelineError(
|
|
426
|
+
"Pipeline must have exactly one default stage",
|
|
427
|
+
pipelineId
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
const terminalStages = stages.filter((s) => s.isTerminal);
|
|
431
|
+
if (terminalStages.length === 0) {
|
|
432
|
+
throw new InvalidPipelineError(
|
|
433
|
+
"Pipeline must have at least one terminal stage",
|
|
434
|
+
pipelineId
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
const names = stages.map((s) => s.name);
|
|
438
|
+
const uniqueNames = new Set(names);
|
|
439
|
+
if (uniqueNames.size !== names.length) {
|
|
440
|
+
throw new InvalidPipelineError(
|
|
441
|
+
"Stage names must be unique within a pipeline",
|
|
442
|
+
pipelineId
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
if (stages.length > PIPELINE_DEFAULTS.MaxStages) {
|
|
446
|
+
throw new InvalidPipelineError(
|
|
447
|
+
`Pipeline cannot have more than ${PIPELINE_DEFAULTS.MaxStages} stages`,
|
|
448
|
+
pipelineId
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// src/services/pipeline.service.ts
|
|
454
|
+
var PipelineService = class {
|
|
455
|
+
constructor(Pipeline, CallLog, logger, tenantId) {
|
|
456
|
+
this.Pipeline = Pipeline;
|
|
457
|
+
this.CallLog = CallLog;
|
|
458
|
+
this.logger = logger;
|
|
459
|
+
this.tenantId = tenantId;
|
|
460
|
+
}
|
|
461
|
+
get tenantFilter() {
|
|
462
|
+
if (this.tenantId) return { tenantId: this.tenantId };
|
|
463
|
+
return {};
|
|
464
|
+
}
|
|
465
|
+
async create(data) {
|
|
466
|
+
const stages = data.stages.map((s) => ({
|
|
467
|
+
...s,
|
|
468
|
+
stageId: crypto5__default.default.randomUUID()
|
|
469
|
+
}));
|
|
470
|
+
validatePipelineStages(stages);
|
|
471
|
+
const pipelineId = crypto5__default.default.randomUUID();
|
|
472
|
+
if (data.isDefault) {
|
|
473
|
+
await this.Pipeline.updateMany(
|
|
474
|
+
{ ...this.tenantFilter, isDefault: true },
|
|
475
|
+
{ $set: { isDefault: false } }
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
const pipeline = await this.Pipeline.create({
|
|
479
|
+
pipelineId,
|
|
480
|
+
name: data.name,
|
|
481
|
+
description: data.description,
|
|
482
|
+
stages,
|
|
483
|
+
isDefault: data.isDefault ?? false,
|
|
484
|
+
isActive: true,
|
|
485
|
+
isDeleted: false,
|
|
486
|
+
createdBy: data.createdBy,
|
|
487
|
+
...data.tenantId ? { tenantId: data.tenantId } : this.tenantId ? { tenantId: this.tenantId } : {},
|
|
488
|
+
metadata: data.metadata
|
|
489
|
+
});
|
|
490
|
+
this.logger.info("Pipeline created", { pipelineId, name: data.name });
|
|
491
|
+
return pipeline;
|
|
492
|
+
}
|
|
493
|
+
async update(pipelineId, data) {
|
|
494
|
+
const pipeline = await this.Pipeline.findOne({ pipelineId, isDeleted: false, ...this.tenantFilter });
|
|
495
|
+
if (!pipeline) throw new PipelineNotFoundError(pipelineId);
|
|
496
|
+
if (data.isDefault) {
|
|
497
|
+
await this.Pipeline.updateMany(
|
|
498
|
+
{ ...this.tenantFilter, isDefault: true, pipelineId: { $ne: pipelineId } },
|
|
499
|
+
{ $set: { isDefault: false } }
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
const updated = await this.Pipeline.findOneAndUpdate(
|
|
503
|
+
{ pipelineId, isDeleted: false },
|
|
504
|
+
{ $set: data },
|
|
505
|
+
{ new: true }
|
|
506
|
+
);
|
|
507
|
+
if (!updated) throw new PipelineNotFoundError(pipelineId);
|
|
508
|
+
this.logger.info("Pipeline updated", { pipelineId, fields: Object.keys(data) });
|
|
509
|
+
return updated;
|
|
510
|
+
}
|
|
511
|
+
async delete(pipelineId) {
|
|
512
|
+
const pipeline = await this.Pipeline.findOne({ pipelineId, isDeleted: false, ...this.tenantFilter });
|
|
513
|
+
if (!pipeline) throw new PipelineNotFoundError(pipelineId);
|
|
514
|
+
const activeCallCount = await this.CallLog.countDocuments({ pipelineId, isClosed: false });
|
|
515
|
+
if (activeCallCount > 0) {
|
|
516
|
+
throw new InvalidPipelineError(`Cannot delete pipeline with ${activeCallCount} active (non-closed) calls`, pipelineId);
|
|
517
|
+
}
|
|
518
|
+
await this.Pipeline.findOneAndUpdate(
|
|
519
|
+
{ pipelineId, ...this.tenantFilter },
|
|
520
|
+
{ $set: { isDeleted: true, isActive: false } }
|
|
521
|
+
);
|
|
522
|
+
this.logger.info("Pipeline deleted", { pipelineId });
|
|
523
|
+
}
|
|
524
|
+
async list(filter) {
|
|
525
|
+
const query = { isDeleted: false, ...this.tenantFilter };
|
|
526
|
+
if (filter?.isActive !== void 0) {
|
|
527
|
+
query.isActive = filter.isActive;
|
|
528
|
+
}
|
|
529
|
+
return this.Pipeline.find(query).sort({ createdAt: 1 });
|
|
530
|
+
}
|
|
531
|
+
async get(pipelineId) {
|
|
532
|
+
const pipeline = await this.Pipeline.findOne({ pipelineId, isDeleted: false, ...this.tenantFilter });
|
|
533
|
+
if (!pipeline) throw new PipelineNotFoundError(pipelineId);
|
|
534
|
+
return pipeline;
|
|
535
|
+
}
|
|
536
|
+
async addStage(pipelineId, stage) {
|
|
537
|
+
const pipeline = await this.get(pipelineId);
|
|
538
|
+
const newStage = {
|
|
539
|
+
...stage,
|
|
540
|
+
stageId: crypto5__default.default.randomUUID()
|
|
541
|
+
};
|
|
542
|
+
const updatedStages = [...pipeline.stages, newStage];
|
|
543
|
+
validatePipelineStages(updatedStages, pipelineId);
|
|
544
|
+
const updated = await this.Pipeline.findOneAndUpdate(
|
|
545
|
+
{ pipelineId },
|
|
546
|
+
{ $push: { stages: newStage } },
|
|
547
|
+
{ new: true }
|
|
548
|
+
);
|
|
549
|
+
if (!updated) throw new PipelineNotFoundError(pipelineId);
|
|
550
|
+
this.logger.info("Stage added to pipeline", { pipelineId, stageId: newStage.stageId });
|
|
551
|
+
return updated;
|
|
552
|
+
}
|
|
553
|
+
async removeStage(pipelineId, stageId) {
|
|
554
|
+
const pipeline = await this.get(pipelineId);
|
|
555
|
+
const stageExists = pipeline.stages.some((s) => s.stageId === stageId);
|
|
556
|
+
if (!stageExists) throw new StageNotFoundError(pipelineId, stageId);
|
|
557
|
+
const activeCallCount = await this.CallLog.countDocuments({ pipelineId, currentStageId: stageId, isClosed: false });
|
|
558
|
+
if (activeCallCount > 0) {
|
|
559
|
+
throw new StageInUseError(pipelineId, stageId, activeCallCount);
|
|
560
|
+
}
|
|
561
|
+
const updatedStages = pipeline.stages.filter((s) => s.stageId !== stageId);
|
|
562
|
+
validatePipelineStages(updatedStages, pipelineId);
|
|
563
|
+
const updated = await this.Pipeline.findOneAndUpdate(
|
|
564
|
+
{ pipelineId },
|
|
565
|
+
{ $pull: { stages: { stageId } } },
|
|
566
|
+
{ new: true }
|
|
567
|
+
);
|
|
568
|
+
if (!updated) throw new PipelineNotFoundError(pipelineId);
|
|
569
|
+
this.logger.info("Stage removed from pipeline", { pipelineId, stageId });
|
|
570
|
+
return updated;
|
|
571
|
+
}
|
|
572
|
+
async updateStage(pipelineId, stageId, data) {
|
|
573
|
+
const pipeline = await this.get(pipelineId);
|
|
574
|
+
const stageIndex = pipeline.stages.findIndex((s) => s.stageId === stageId);
|
|
575
|
+
if (stageIndex === -1) throw new StageNotFoundError(pipelineId, stageId);
|
|
576
|
+
const updatedStages = pipeline.stages.map((s) => {
|
|
577
|
+
const plain = s.toObject?.() ?? s;
|
|
578
|
+
if (s.stageId === stageId) {
|
|
579
|
+
return { ...plain, ...data, stageId };
|
|
580
|
+
}
|
|
581
|
+
return plain;
|
|
582
|
+
});
|
|
583
|
+
validatePipelineStages(updatedStages, pipelineId);
|
|
584
|
+
const setFields = {};
|
|
585
|
+
for (const [key, value] of Object.entries(data)) {
|
|
586
|
+
if (key !== "stageId") {
|
|
587
|
+
setFields[`stages.${stageIndex}.${key}`] = value;
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
const updated = await this.Pipeline.findOneAndUpdate(
|
|
591
|
+
{ pipelineId },
|
|
592
|
+
{ $set: setFields },
|
|
593
|
+
{ new: true }
|
|
594
|
+
);
|
|
595
|
+
if (!updated) throw new PipelineNotFoundError(pipelineId);
|
|
596
|
+
this.logger.info("Stage updated in pipeline", { pipelineId, stageId, fields: Object.keys(data) });
|
|
597
|
+
return updated;
|
|
598
|
+
}
|
|
599
|
+
async reorderStages(pipelineId, stageIds) {
|
|
600
|
+
const pipeline = await this.get(pipelineId);
|
|
601
|
+
const existingIds = new Set(pipeline.stages.map((s) => s.stageId));
|
|
602
|
+
for (const id of stageIds) {
|
|
603
|
+
if (!existingIds.has(id)) throw new StageNotFoundError(pipelineId, id);
|
|
604
|
+
}
|
|
605
|
+
if (stageIds.length !== pipeline.stages.length) {
|
|
606
|
+
throw new InvalidPipelineError(
|
|
607
|
+
`reorderStages must include all ${pipeline.stages.length} stage IDs`,
|
|
608
|
+
pipelineId
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
const stageMap = new Map(pipeline.stages.map((s) => [s.stageId, s]));
|
|
612
|
+
const reorderedStages = stageIds.map((id, index) => {
|
|
613
|
+
const s = stageMap.get(id);
|
|
614
|
+
const plain = s.toObject?.() ?? s;
|
|
615
|
+
return { ...plain, order: index + 1 };
|
|
616
|
+
});
|
|
617
|
+
validatePipelineStages(reorderedStages, pipelineId);
|
|
618
|
+
const updated = await this.Pipeline.findOneAndUpdate(
|
|
619
|
+
{ pipelineId },
|
|
620
|
+
{ $set: { stages: reorderedStages } },
|
|
621
|
+
{ new: true }
|
|
622
|
+
);
|
|
623
|
+
if (!updated) throw new PipelineNotFoundError(pipelineId);
|
|
624
|
+
this.logger.info("Stages reordered in pipeline", { pipelineId });
|
|
625
|
+
return updated;
|
|
626
|
+
}
|
|
627
|
+
};
|
|
628
|
+
var TimelineService = class {
|
|
629
|
+
constructor(CallLog, logger, options) {
|
|
630
|
+
this.CallLog = CallLog;
|
|
631
|
+
this.logger = logger;
|
|
632
|
+
this.options = options;
|
|
633
|
+
}
|
|
634
|
+
async addNote(callLogId, content, authorId, authorName) {
|
|
635
|
+
const callLog = await this.CallLog.findOne({ callLogId });
|
|
636
|
+
if (!callLog) throw new CallLogNotFoundError(callLogId);
|
|
637
|
+
if (callLog.isClosed) throw new CallLogClosedError(callLogId, "add note");
|
|
638
|
+
const entry = {
|
|
639
|
+
entryId: crypto5__default.default.randomUUID(),
|
|
640
|
+
type: callLogTypes.TimelineEntryType.Note,
|
|
641
|
+
content,
|
|
642
|
+
authorId,
|
|
643
|
+
authorName,
|
|
644
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
645
|
+
};
|
|
646
|
+
await this.CallLog.updateOne(
|
|
647
|
+
{ callLogId },
|
|
648
|
+
{
|
|
649
|
+
$push: {
|
|
650
|
+
timeline: {
|
|
651
|
+
$each: [entry],
|
|
652
|
+
$slice: -this.options.maxTimelineEntries
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
);
|
|
657
|
+
this.logger.info("Note added to call log", { callLogId, entryId: entry.entryId });
|
|
658
|
+
return entry;
|
|
659
|
+
}
|
|
660
|
+
async addSystemEntry(callLogId, content) {
|
|
661
|
+
const callLog = await this.CallLog.findOne({ callLogId });
|
|
662
|
+
if (!callLog) throw new CallLogNotFoundError(callLogId);
|
|
663
|
+
const entry = {
|
|
664
|
+
entryId: crypto5__default.default.randomUUID(),
|
|
665
|
+
type: callLogTypes.TimelineEntryType.System,
|
|
666
|
+
content,
|
|
667
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
668
|
+
};
|
|
669
|
+
await this.CallLog.updateOne(
|
|
670
|
+
{ callLogId },
|
|
671
|
+
{
|
|
672
|
+
$push: {
|
|
673
|
+
timeline: {
|
|
674
|
+
$each: [entry],
|
|
675
|
+
$slice: -this.options.maxTimelineEntries
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
);
|
|
680
|
+
this.logger.info("System entry added to call log", { callLogId, entryId: entry.entryId });
|
|
681
|
+
return entry;
|
|
682
|
+
}
|
|
683
|
+
async getTimeline(callLogId, pagination) {
|
|
684
|
+
const callLog = await this.CallLog.findOne({ callLogId });
|
|
685
|
+
if (!callLog) throw new CallLogNotFoundError(callLogId);
|
|
686
|
+
const total = callLog.timeline.length;
|
|
687
|
+
const { page, limit } = pagination;
|
|
688
|
+
const skip = (page - 1) * limit;
|
|
689
|
+
const reversed = [...callLog.timeline].reverse();
|
|
690
|
+
const entries = reversed.slice(skip, skip + limit);
|
|
691
|
+
return { entries, total };
|
|
692
|
+
}
|
|
693
|
+
async getContactTimeline(externalId, pagination) {
|
|
694
|
+
const { page, limit } = pagination;
|
|
695
|
+
const skip = (page - 1) * limit;
|
|
696
|
+
const countResult = await this.CallLog.aggregate([
|
|
697
|
+
{ $match: { "contactRef.externalId": externalId } },
|
|
698
|
+
{ $unwind: "$timeline" },
|
|
699
|
+
{ $count: "total" }
|
|
700
|
+
]);
|
|
701
|
+
const total = countResult[0]?.total ?? 0;
|
|
702
|
+
const results = await this.CallLog.aggregate([
|
|
703
|
+
{ $match: { "contactRef.externalId": externalId } },
|
|
704
|
+
{ $unwind: "$timeline" },
|
|
705
|
+
{ $sort: { "timeline.createdAt": -1 } },
|
|
706
|
+
{ $skip: skip },
|
|
707
|
+
{ $limit: limit },
|
|
708
|
+
{
|
|
709
|
+
$addFields: {
|
|
710
|
+
"timeline.callLogId": "$callLogId"
|
|
711
|
+
}
|
|
712
|
+
},
|
|
713
|
+
{ $replaceRoot: { newRoot: "$timeline" } }
|
|
714
|
+
]);
|
|
715
|
+
this.logger.info("Contact timeline fetched", { externalId, total, page, limit });
|
|
716
|
+
return { entries: results, total };
|
|
717
|
+
}
|
|
718
|
+
};
|
|
719
|
+
var CallLogService = class {
|
|
720
|
+
constructor(CallLog, Pipeline, timeline, logger, hooks, options) {
|
|
721
|
+
this.CallLog = CallLog;
|
|
722
|
+
this.Pipeline = Pipeline;
|
|
723
|
+
this.timeline = timeline;
|
|
724
|
+
this.logger = logger;
|
|
725
|
+
this.hooks = hooks;
|
|
726
|
+
this.options = options;
|
|
727
|
+
}
|
|
728
|
+
async create(data) {
|
|
729
|
+
const pipeline = await this.Pipeline.findOne({ pipelineId: data.pipelineId, isDeleted: false });
|
|
730
|
+
if (!pipeline) throw new PipelineNotFoundError(data.pipelineId);
|
|
731
|
+
const defaultStage = pipeline.stages.find((s) => s.isDefault);
|
|
732
|
+
if (!defaultStage) throw new PipelineNotFoundError(data.pipelineId);
|
|
733
|
+
const callLogId = crypto5__default.default.randomUUID();
|
|
734
|
+
const initialEntry = {
|
|
735
|
+
entryId: crypto5__default.default.randomUUID(),
|
|
736
|
+
type: callLogTypes.TimelineEntryType.System,
|
|
737
|
+
content: SYSTEM_TIMELINE.CallCreated,
|
|
738
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
739
|
+
};
|
|
740
|
+
const callLog = await this.CallLog.create({
|
|
741
|
+
callLogId,
|
|
742
|
+
pipelineId: data.pipelineId,
|
|
743
|
+
currentStageId: defaultStage.stageId,
|
|
744
|
+
contactRef: data.contactRef,
|
|
745
|
+
direction: data.direction,
|
|
746
|
+
callDate: data.callDate ? new Date(data.callDate) : /* @__PURE__ */ new Date(),
|
|
747
|
+
agentId: data.agentId,
|
|
748
|
+
priority: data.priority ?? callLogTypes.CallPriority.Medium,
|
|
749
|
+
tags: data.tags ?? [],
|
|
750
|
+
category: data.category,
|
|
751
|
+
nextFollowUpDate: data.nextFollowUpDate,
|
|
752
|
+
durationMinutes: data.durationMinutes,
|
|
753
|
+
timeline: [initialEntry],
|
|
754
|
+
stageHistory: [],
|
|
755
|
+
isClosed: false,
|
|
756
|
+
...data.tenantId ? { tenantId: data.tenantId } : {},
|
|
757
|
+
metadata: data.metadata
|
|
758
|
+
});
|
|
759
|
+
this.logger.info("Call log created", { callLogId, pipelineId: data.pipelineId });
|
|
760
|
+
if (this.hooks.onCallCreated) {
|
|
761
|
+
await this.hooks.onCallCreated(callLog);
|
|
762
|
+
}
|
|
763
|
+
return callLog;
|
|
764
|
+
}
|
|
765
|
+
async update(callLogId, data) {
|
|
766
|
+
const callLog = await this.CallLog.findOne({ callLogId });
|
|
767
|
+
if (!callLog) throw new CallLogNotFoundError(callLogId);
|
|
768
|
+
if (callLog.isClosed) throw new CallLogClosedError(callLogId, "update");
|
|
769
|
+
const setFields = {};
|
|
770
|
+
const pushEntries = [];
|
|
771
|
+
if (data.priority !== void 0) setFields.priority = data.priority;
|
|
772
|
+
if (data.tags !== void 0) setFields.tags = data.tags;
|
|
773
|
+
if (data.category !== void 0) setFields.category = data.category;
|
|
774
|
+
if (data.durationMinutes !== void 0) setFields.durationMinutes = data.durationMinutes;
|
|
775
|
+
if (data.nextFollowUpDate !== void 0) {
|
|
776
|
+
const prevDate = callLog.nextFollowUpDate?.toISOString();
|
|
777
|
+
const newDate = data.nextFollowUpDate?.toISOString();
|
|
778
|
+
if (prevDate !== newDate) {
|
|
779
|
+
setFields.nextFollowUpDate = data.nextFollowUpDate;
|
|
780
|
+
if (data.nextFollowUpDate) {
|
|
781
|
+
const followUpEntry = {
|
|
782
|
+
entryId: crypto5__default.default.randomUUID(),
|
|
783
|
+
type: callLogTypes.TimelineEntryType.FollowUpSet,
|
|
784
|
+
content: SYSTEM_TIMELINE_FN.followUpSet(new Date(data.nextFollowUpDate).toISOString()),
|
|
785
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
786
|
+
};
|
|
787
|
+
pushEntries.push(followUpEntry);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
const updateOp = { $set: setFields };
|
|
792
|
+
if (pushEntries.length > 0) {
|
|
793
|
+
updateOp.$push = {
|
|
794
|
+
timeline: {
|
|
795
|
+
$each: pushEntries,
|
|
796
|
+
$slice: -this.options.maxTimelineEntries
|
|
797
|
+
}
|
|
798
|
+
};
|
|
799
|
+
}
|
|
800
|
+
const updated = await this.CallLog.findOneAndUpdate(
|
|
801
|
+
{ callLogId },
|
|
802
|
+
updateOp,
|
|
803
|
+
{ new: true }
|
|
804
|
+
);
|
|
805
|
+
if (!updated) throw new CallLogNotFoundError(callLogId);
|
|
806
|
+
this.logger.info("Call log updated", { callLogId, fields: Object.keys(data) });
|
|
807
|
+
return updated;
|
|
808
|
+
}
|
|
809
|
+
async changeStage(callLogId, newStageId, agentId) {
|
|
810
|
+
const callLog = await this.CallLog.findOne({ callLogId });
|
|
811
|
+
if (!callLog) throw new CallLogNotFoundError(callLogId);
|
|
812
|
+
if (callLog.isClosed) throw new CallLogClosedError(callLogId, "change stage");
|
|
813
|
+
const currentStageId = callLog.currentStageId;
|
|
814
|
+
const pipeline = await this.Pipeline.findOne({ pipelineId: callLog.pipelineId, isDeleted: false });
|
|
815
|
+
if (!pipeline) throw new PipelineNotFoundError(callLog.pipelineId);
|
|
816
|
+
const currentStage = pipeline.stages.find((s) => s.stageId === currentStageId);
|
|
817
|
+
if (!currentStage) throw new StageNotFoundError(callLog.pipelineId, currentStageId);
|
|
818
|
+
const newStage = pipeline.stages.find((s) => s.stageId === newStageId);
|
|
819
|
+
if (!newStage) throw new StageNotFoundError(callLog.pipelineId, newStageId);
|
|
820
|
+
const now = /* @__PURE__ */ new Date();
|
|
821
|
+
const lastHistory = callLog.stageHistory[callLog.stageHistory.length - 1];
|
|
822
|
+
const stageStartTime = lastHistory?.changedAt?.getTime() ?? callLog.createdAt?.getTime() ?? now.getTime();
|
|
823
|
+
const timeInStageMs = now.getTime() - stageStartTime;
|
|
824
|
+
const stageChangeEntry = {
|
|
825
|
+
entryId: crypto5__default.default.randomUUID(),
|
|
826
|
+
type: callLogTypes.TimelineEntryType.StageChange,
|
|
827
|
+
content: SYSTEM_TIMELINE_FN.stageChanged(currentStage.name, newStage.name),
|
|
828
|
+
fromStageId: currentStageId,
|
|
829
|
+
fromStageName: currentStage.name,
|
|
830
|
+
toStageId: newStageId,
|
|
831
|
+
toStageName: newStage.name,
|
|
832
|
+
authorId: agentId,
|
|
833
|
+
createdAt: now
|
|
834
|
+
};
|
|
835
|
+
const stageHistoryEntry = {
|
|
836
|
+
fromStageId: currentStageId,
|
|
837
|
+
toStageId: newStageId,
|
|
838
|
+
fromStageName: currentStage.name,
|
|
839
|
+
toStageName: newStage.name,
|
|
840
|
+
changedBy: agentId,
|
|
841
|
+
changedAt: now,
|
|
842
|
+
timeInStageMs
|
|
843
|
+
};
|
|
844
|
+
const isTerminal = newStage.isTerminal;
|
|
845
|
+
const closeFields = isTerminal ? { currentStageId: newStageId, isClosed: true, closedAt: now } : { currentStageId: newStageId };
|
|
846
|
+
const result = await this.CallLog.findOneAndUpdate(
|
|
847
|
+
{ callLogId, currentStageId },
|
|
848
|
+
{
|
|
849
|
+
$set: closeFields,
|
|
850
|
+
$push: {
|
|
851
|
+
stageHistory: stageHistoryEntry,
|
|
852
|
+
timeline: stageChangeEntry
|
|
853
|
+
}
|
|
854
|
+
},
|
|
855
|
+
{ new: true }
|
|
856
|
+
);
|
|
857
|
+
if (!result) {
|
|
858
|
+
throw new CallLogClosedError(callLogId, "change stage (concurrent modification detected)");
|
|
859
|
+
}
|
|
860
|
+
this.logger.info("Call log stage changed", { callLogId, from: currentStageId, to: newStageId });
|
|
861
|
+
if (isTerminal && this.hooks.onCallClosed) {
|
|
862
|
+
await this.hooks.onCallClosed(result);
|
|
863
|
+
}
|
|
864
|
+
if (this.hooks.onStageChanged) {
|
|
865
|
+
await this.hooks.onStageChanged(result, currentStage.name, newStage.name);
|
|
866
|
+
}
|
|
867
|
+
return result;
|
|
868
|
+
}
|
|
869
|
+
async assign(callLogId, agentId, assignedBy) {
|
|
870
|
+
const callLog = await this.CallLog.findOne({ callLogId });
|
|
871
|
+
if (!callLog) throw new CallLogNotFoundError(callLogId);
|
|
872
|
+
const previousAgentId = callLog.agentId?.toString();
|
|
873
|
+
const assignmentEntry = {
|
|
874
|
+
entryId: crypto5__default.default.randomUUID(),
|
|
875
|
+
type: callLogTypes.TimelineEntryType.Assignment,
|
|
876
|
+
content: previousAgentId ? SYSTEM_TIMELINE_FN.callReassigned(previousAgentId, agentId) : SYSTEM_TIMELINE_FN.callAssigned(agentId),
|
|
877
|
+
toAgentId: agentId,
|
|
878
|
+
fromAgentId: previousAgentId,
|
|
879
|
+
authorId: assignedBy,
|
|
880
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
881
|
+
};
|
|
882
|
+
const updated = await this.CallLog.findOneAndUpdate(
|
|
883
|
+
{ callLogId },
|
|
884
|
+
{
|
|
885
|
+
$set: { agentId, assignedBy },
|
|
886
|
+
$push: {
|
|
887
|
+
timeline: {
|
|
888
|
+
$each: [assignmentEntry],
|
|
889
|
+
$slice: -this.options.maxTimelineEntries
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
},
|
|
893
|
+
{ new: true }
|
|
894
|
+
);
|
|
895
|
+
if (!updated) throw new CallLogNotFoundError(callLogId);
|
|
896
|
+
this.logger.info("Call log assigned", { callLogId, agentId, assignedBy });
|
|
897
|
+
if (this.hooks.onCallAssigned) {
|
|
898
|
+
await this.hooks.onCallAssigned(updated, previousAgentId);
|
|
899
|
+
}
|
|
900
|
+
return updated;
|
|
901
|
+
}
|
|
902
|
+
async list(filter = {}) {
|
|
903
|
+
const query = {};
|
|
904
|
+
if (filter.pipelineId) query.pipelineId = filter.pipelineId;
|
|
905
|
+
if (filter.currentStageId) query.currentStageId = filter.currentStageId;
|
|
906
|
+
if (filter.agentId) query.agentId = filter.agentId;
|
|
907
|
+
if (filter.tags && filter.tags.length > 0) query.tags = { $in: filter.tags };
|
|
908
|
+
if (filter.category) query.category = filter.category;
|
|
909
|
+
if (filter.isClosed !== void 0) query.isClosed = filter.isClosed;
|
|
910
|
+
if (filter.contactExternalId) query["contactRef.externalId"] = filter.contactExternalId;
|
|
911
|
+
if (filter.contactName) query["contactRef.displayName"] = { $regex: filter.contactName, $options: "i" };
|
|
912
|
+
if (filter.contactPhone) query["contactRef.phone"] = { $regex: `^${filter.contactPhone}` };
|
|
913
|
+
if (filter.contactEmail) query["contactRef.email"] = filter.contactEmail;
|
|
914
|
+
if (filter.priority) query.priority = filter.priority;
|
|
915
|
+
if (filter.direction) query.direction = filter.direction;
|
|
916
|
+
if (filter.dateRange?.from || filter.dateRange?.to) {
|
|
917
|
+
const dateFilter = {};
|
|
918
|
+
if (filter.dateRange.from) dateFilter.$gte = new Date(filter.dateRange.from);
|
|
919
|
+
if (filter.dateRange.to) dateFilter.$lte = new Date(filter.dateRange.to);
|
|
920
|
+
query.callDate = dateFilter;
|
|
921
|
+
}
|
|
922
|
+
const page = filter.page ?? 1;
|
|
923
|
+
const limit = filter.limit ?? 20;
|
|
924
|
+
const skip = (page - 1) * limit;
|
|
925
|
+
const sort = filter.sort ?? { callDate: -1 };
|
|
926
|
+
const [callLogs, total] = await Promise.all([
|
|
927
|
+
this.CallLog.find(query).sort(sort).skip(skip).limit(limit),
|
|
928
|
+
this.CallLog.countDocuments(query)
|
|
929
|
+
]);
|
|
930
|
+
return { callLogs, total, page, limit };
|
|
931
|
+
}
|
|
932
|
+
async get(callLogId) {
|
|
933
|
+
const callLog = await this.CallLog.findOne({ callLogId });
|
|
934
|
+
if (!callLog) throw new CallLogNotFoundError(callLogId);
|
|
935
|
+
return callLog;
|
|
936
|
+
}
|
|
937
|
+
async getByContact(externalId) {
|
|
938
|
+
return this.CallLog.find({ "contactRef.externalId": externalId }).sort({ callDate: -1 });
|
|
939
|
+
}
|
|
940
|
+
async getFollowUpsDue(agentId, dateRange) {
|
|
941
|
+
const now = /* @__PURE__ */ new Date();
|
|
942
|
+
const query = {
|
|
943
|
+
nextFollowUpDate: { $lte: now },
|
|
944
|
+
isClosed: false,
|
|
945
|
+
followUpNotifiedAt: null
|
|
946
|
+
};
|
|
947
|
+
if (agentId) query.agentId = agentId;
|
|
948
|
+
if (dateRange?.from || dateRange?.to) {
|
|
949
|
+
const dateFilter = {};
|
|
950
|
+
if (dateRange.from) dateFilter.$gte = new Date(dateRange.from);
|
|
951
|
+
if (dateRange.to) dateFilter.$lte = new Date(dateRange.to);
|
|
952
|
+
query.nextFollowUpDate = { ...query.nextFollowUpDate, ...dateFilter };
|
|
953
|
+
}
|
|
954
|
+
return this.CallLog.find(query).sort({ nextFollowUpDate: 1 });
|
|
955
|
+
}
|
|
956
|
+
async bulkChangeStage(callLogIds, newStageId, agentId) {
|
|
957
|
+
const succeeded = [];
|
|
958
|
+
const failed = [];
|
|
959
|
+
for (const callLogId of callLogIds) {
|
|
960
|
+
try {
|
|
961
|
+
await this.changeStage(callLogId, newStageId, agentId);
|
|
962
|
+
succeeded.push(callLogId);
|
|
963
|
+
} catch (err) {
|
|
964
|
+
failed.push({
|
|
965
|
+
callLogId,
|
|
966
|
+
error: err instanceof Error ? err.message : String(err)
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
this.logger.info("Bulk stage change completed", {
|
|
971
|
+
total: callLogIds.length,
|
|
972
|
+
succeeded: succeeded.length,
|
|
973
|
+
failed: failed.length
|
|
974
|
+
});
|
|
975
|
+
return { succeeded, failed, total: callLogIds.length };
|
|
976
|
+
}
|
|
977
|
+
async close(callLogId, agentId) {
|
|
978
|
+
const callLog = await this.CallLog.findOne({ callLogId });
|
|
979
|
+
if (!callLog) throw new CallLogNotFoundError(callLogId);
|
|
980
|
+
if (callLog.isClosed) throw new CallLogClosedError(callLogId, "close");
|
|
981
|
+
const now = /* @__PURE__ */ new Date();
|
|
982
|
+
const closeEntry = {
|
|
983
|
+
entryId: crypto5__default.default.randomUUID(),
|
|
984
|
+
type: callLogTypes.TimelineEntryType.System,
|
|
985
|
+
content: SYSTEM_TIMELINE.CallClosed,
|
|
986
|
+
authorId: agentId,
|
|
987
|
+
createdAt: now
|
|
988
|
+
};
|
|
989
|
+
const updated = await this.CallLog.findOneAndUpdate(
|
|
990
|
+
{ callLogId },
|
|
991
|
+
{
|
|
992
|
+
$set: { isClosed: true, closedAt: now },
|
|
993
|
+
$push: {
|
|
994
|
+
timeline: {
|
|
995
|
+
$each: [closeEntry],
|
|
996
|
+
$slice: -this.options.maxTimelineEntries
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
},
|
|
1000
|
+
{ new: true }
|
|
1001
|
+
);
|
|
1002
|
+
if (!updated) throw new CallLogNotFoundError(callLogId);
|
|
1003
|
+
this.logger.info("Call log closed", { callLogId, agentId });
|
|
1004
|
+
if (this.hooks.onCallClosed) {
|
|
1005
|
+
await this.hooks.onCallClosed(updated);
|
|
1006
|
+
}
|
|
1007
|
+
return updated;
|
|
1008
|
+
}
|
|
1009
|
+
async reopen(callLogId, agentId) {
|
|
1010
|
+
const callLog = await this.CallLog.findOne({ callLogId });
|
|
1011
|
+
if (!callLog) throw new CallLogNotFoundError(callLogId);
|
|
1012
|
+
if (!callLog.isClosed) throw new CallLogClosedError(callLogId, "reopen");
|
|
1013
|
+
const now = /* @__PURE__ */ new Date();
|
|
1014
|
+
const reopenEntry = {
|
|
1015
|
+
entryId: crypto5__default.default.randomUUID(),
|
|
1016
|
+
type: callLogTypes.TimelineEntryType.System,
|
|
1017
|
+
content: SYSTEM_TIMELINE.CallReopened,
|
|
1018
|
+
authorId: agentId,
|
|
1019
|
+
createdAt: now
|
|
1020
|
+
};
|
|
1021
|
+
const updated = await this.CallLog.findOneAndUpdate(
|
|
1022
|
+
{ callLogId },
|
|
1023
|
+
{
|
|
1024
|
+
$set: { isClosed: false, closedAt: void 0 },
|
|
1025
|
+
$push: {
|
|
1026
|
+
timeline: {
|
|
1027
|
+
$each: [reopenEntry],
|
|
1028
|
+
$slice: -this.options.maxTimelineEntries
|
|
1029
|
+
}
|
|
1030
|
+
}
|
|
1031
|
+
},
|
|
1032
|
+
{ new: true }
|
|
1033
|
+
);
|
|
1034
|
+
if (!updated) throw new CallLogNotFoundError(callLogId);
|
|
1035
|
+
this.logger.info("Call log reopened", { callLogId, agentId });
|
|
1036
|
+
return updated;
|
|
1037
|
+
}
|
|
1038
|
+
};
|
|
1039
|
+
|
|
1040
|
+
// src/services/analytics.service.ts
|
|
1041
|
+
var AnalyticsService = class {
|
|
1042
|
+
constructor(CallLog, Pipeline, logger, resolveAgent) {
|
|
1043
|
+
this.CallLog = CallLog;
|
|
1044
|
+
this.Pipeline = Pipeline;
|
|
1045
|
+
this.logger = logger;
|
|
1046
|
+
this.resolveAgent = resolveAgent;
|
|
1047
|
+
}
|
|
1048
|
+
async getAgentName(agentId) {
|
|
1049
|
+
if (!this.resolveAgent) return String(agentId);
|
|
1050
|
+
try {
|
|
1051
|
+
const agent = await this.resolveAgent(String(agentId));
|
|
1052
|
+
return agent?.displayName ?? String(agentId);
|
|
1053
|
+
} catch {
|
|
1054
|
+
return String(agentId);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
buildDateMatch(dateRange, field = "callDate") {
|
|
1058
|
+
if (!dateRange.from && !dateRange.to) return {};
|
|
1059
|
+
const dateFilter = {};
|
|
1060
|
+
if (dateRange.from) dateFilter.$gte = new Date(dateRange.from);
|
|
1061
|
+
if (dateRange.to) dateFilter.$lte = new Date(dateRange.to);
|
|
1062
|
+
return { [field]: dateFilter };
|
|
1063
|
+
}
|
|
1064
|
+
async getAgentStats(agentId, dateRange) {
|
|
1065
|
+
const now = /* @__PURE__ */ new Date();
|
|
1066
|
+
const matchStage = {
|
|
1067
|
+
agentId,
|
|
1068
|
+
...this.buildDateMatch(dateRange)
|
|
1069
|
+
};
|
|
1070
|
+
const pipeline = [
|
|
1071
|
+
{ $match: matchStage },
|
|
1072
|
+
{
|
|
1073
|
+
$group: {
|
|
1074
|
+
_id: "$agentId",
|
|
1075
|
+
totalCalls: { $sum: 1 },
|
|
1076
|
+
callsClosed: {
|
|
1077
|
+
$sum: { $cond: [{ $eq: ["$isClosed", true] }, 1, 0] }
|
|
1078
|
+
},
|
|
1079
|
+
followUpsCompleted: {
|
|
1080
|
+
$sum: {
|
|
1081
|
+
$cond: [
|
|
1082
|
+
{
|
|
1083
|
+
$and: [
|
|
1084
|
+
{ $ne: ["$nextFollowUpDate", null] },
|
|
1085
|
+
{ $eq: ["$isClosed", true] }
|
|
1086
|
+
]
|
|
1087
|
+
},
|
|
1088
|
+
1,
|
|
1089
|
+
0
|
|
1090
|
+
]
|
|
1091
|
+
}
|
|
1092
|
+
},
|
|
1093
|
+
overdueFollowUps: {
|
|
1094
|
+
$sum: {
|
|
1095
|
+
$cond: [
|
|
1096
|
+
{
|
|
1097
|
+
$and: [
|
|
1098
|
+
{ $ne: ["$nextFollowUpDate", null] },
|
|
1099
|
+
{ $lt: ["$nextFollowUpDate", now] },
|
|
1100
|
+
{ $eq: ["$isClosed", false] }
|
|
1101
|
+
]
|
|
1102
|
+
},
|
|
1103
|
+
1,
|
|
1104
|
+
0
|
|
1105
|
+
]
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
];
|
|
1111
|
+
const [results, pipelineCounts] = await Promise.all([
|
|
1112
|
+
this.CallLog.aggregate(pipeline),
|
|
1113
|
+
this.CallLog.aggregate([
|
|
1114
|
+
{ $match: matchStage },
|
|
1115
|
+
{ $group: { _id: "$pipelineId", count: { $sum: 1 } } }
|
|
1116
|
+
])
|
|
1117
|
+
]);
|
|
1118
|
+
const stat = results[0];
|
|
1119
|
+
const agentName = await this.getAgentName(agentId);
|
|
1120
|
+
if (!stat) {
|
|
1121
|
+
return {
|
|
1122
|
+
agentId,
|
|
1123
|
+
agentName,
|
|
1124
|
+
totalCalls: 0,
|
|
1125
|
+
avgCallsPerDay: 0,
|
|
1126
|
+
followUpsCompleted: 0,
|
|
1127
|
+
overdueFollowUps: 0,
|
|
1128
|
+
callsByPipeline: [],
|
|
1129
|
+
callsClosed: 0,
|
|
1130
|
+
closeRate: 0
|
|
1131
|
+
};
|
|
1132
|
+
}
|
|
1133
|
+
let avgCallsPerDay = 0;
|
|
1134
|
+
if (dateRange.from && dateRange.to) {
|
|
1135
|
+
const days = Math.max(
|
|
1136
|
+
1,
|
|
1137
|
+
Math.ceil((new Date(dateRange.to).getTime() - new Date(dateRange.from).getTime()) / 864e5)
|
|
1138
|
+
);
|
|
1139
|
+
avgCallsPerDay = Math.round(stat.totalCalls / days * 100) / 100;
|
|
1140
|
+
}
|
|
1141
|
+
const callsByPipeline = pipelineCounts.map((p) => ({
|
|
1142
|
+
pipelineId: p._id,
|
|
1143
|
+
pipelineName: p._id,
|
|
1144
|
+
count: p.count
|
|
1145
|
+
}));
|
|
1146
|
+
return {
|
|
1147
|
+
agentId,
|
|
1148
|
+
agentName,
|
|
1149
|
+
totalCalls: stat.totalCalls,
|
|
1150
|
+
avgCallsPerDay,
|
|
1151
|
+
followUpsCompleted: stat.followUpsCompleted,
|
|
1152
|
+
overdueFollowUps: stat.overdueFollowUps,
|
|
1153
|
+
callsByPipeline,
|
|
1154
|
+
callsClosed: stat.callsClosed,
|
|
1155
|
+
closeRate: stat.totalCalls > 0 ? Math.round(stat.callsClosed / stat.totalCalls * 1e4) / 100 : 0
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
async getAgentLeaderboard(dateRange) {
|
|
1159
|
+
const now = /* @__PURE__ */ new Date();
|
|
1160
|
+
const matchStage = this.buildDateMatch(dateRange);
|
|
1161
|
+
const pipeline = [
|
|
1162
|
+
{ $match: matchStage },
|
|
1163
|
+
{
|
|
1164
|
+
$group: {
|
|
1165
|
+
_id: "$agentId",
|
|
1166
|
+
totalCalls: { $sum: 1 },
|
|
1167
|
+
callsClosed: {
|
|
1168
|
+
$sum: { $cond: [{ $eq: ["$isClosed", true] }, 1, 0] }
|
|
1169
|
+
},
|
|
1170
|
+
followUpsCompleted: {
|
|
1171
|
+
$sum: {
|
|
1172
|
+
$cond: [
|
|
1173
|
+
{
|
|
1174
|
+
$and: [
|
|
1175
|
+
{ $ne: ["$nextFollowUpDate", null] },
|
|
1176
|
+
{ $eq: ["$isClosed", true] }
|
|
1177
|
+
]
|
|
1178
|
+
},
|
|
1179
|
+
1,
|
|
1180
|
+
0
|
|
1181
|
+
]
|
|
1182
|
+
}
|
|
1183
|
+
},
|
|
1184
|
+
overdueFollowUps: {
|
|
1185
|
+
$sum: {
|
|
1186
|
+
$cond: [
|
|
1187
|
+
{
|
|
1188
|
+
$and: [
|
|
1189
|
+
{ $ne: ["$nextFollowUpDate", null] },
|
|
1190
|
+
{ $lt: ["$nextFollowUpDate", now] },
|
|
1191
|
+
{ $eq: ["$isClosed", false] }
|
|
1192
|
+
]
|
|
1193
|
+
},
|
|
1194
|
+
1,
|
|
1195
|
+
0
|
|
1196
|
+
]
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
},
|
|
1201
|
+
{ $sort: { totalCalls: -1 } }
|
|
1202
|
+
];
|
|
1203
|
+
const results = await this.CallLog.aggregate(pipeline);
|
|
1204
|
+
return Promise.all(results.map(async (stat) => ({
|
|
1205
|
+
agentId: String(stat._id),
|
|
1206
|
+
agentName: await this.getAgentName(String(stat._id)),
|
|
1207
|
+
totalCalls: stat.totalCalls,
|
|
1208
|
+
avgCallsPerDay: 0,
|
|
1209
|
+
followUpsCompleted: stat.followUpsCompleted,
|
|
1210
|
+
overdueFollowUps: stat.overdueFollowUps,
|
|
1211
|
+
callsByPipeline: [],
|
|
1212
|
+
callsClosed: stat.callsClosed,
|
|
1213
|
+
closeRate: stat.totalCalls > 0 ? Math.round(stat.callsClosed / stat.totalCalls * 1e4) / 100 : 0
|
|
1214
|
+
})));
|
|
1215
|
+
}
|
|
1216
|
+
async getPipelineStats(pipelineId, dateRange) {
|
|
1217
|
+
const pipeline = await this.Pipeline.findOne({ pipelineId, isDeleted: false });
|
|
1218
|
+
const pipelineName = pipeline?.name ?? pipelineId;
|
|
1219
|
+
const stages = pipeline?.stages ?? [];
|
|
1220
|
+
const matchStage = {
|
|
1221
|
+
pipelineId,
|
|
1222
|
+
...this.buildDateMatch(dateRange)
|
|
1223
|
+
};
|
|
1224
|
+
const [totalResult, stageAgg] = await Promise.all([
|
|
1225
|
+
this.CallLog.countDocuments(matchStage),
|
|
1226
|
+
this.CallLog.aggregate([
|
|
1227
|
+
{ $match: matchStage },
|
|
1228
|
+
{ $unwind: { path: "$stageHistory", preserveNullAndEmptyArrays: false } },
|
|
1229
|
+
{
|
|
1230
|
+
$group: {
|
|
1231
|
+
_id: "$stageHistory.toStageId",
|
|
1232
|
+
count: { $sum: 1 },
|
|
1233
|
+
totalTimeMs: { $sum: "$stageHistory.timeInStageMs" }
|
|
1234
|
+
}
|
|
1235
|
+
},
|
|
1236
|
+
{
|
|
1237
|
+
$project: {
|
|
1238
|
+
stageId: "$_id",
|
|
1239
|
+
count: 1,
|
|
1240
|
+
totalTimeMs: 1
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
])
|
|
1244
|
+
]);
|
|
1245
|
+
const totalCalls = totalResult;
|
|
1246
|
+
const stageMap = new Map(stageAgg.map((s) => [s.stageId, s]));
|
|
1247
|
+
let bottleneckStage = null;
|
|
1248
|
+
let maxAvgTime = 0;
|
|
1249
|
+
const stageStats = stages.map((s) => {
|
|
1250
|
+
const agg = stageMap.get(s.stageId);
|
|
1251
|
+
const count = agg?.count ?? 0;
|
|
1252
|
+
const avgTimeMs = count > 0 ? Math.round((agg?.totalTimeMs ?? 0) / count) : 0;
|
|
1253
|
+
const conversionRate = totalCalls > 0 ? Math.round(count / totalCalls * 1e4) / 100 : 0;
|
|
1254
|
+
if (avgTimeMs > maxAvgTime) {
|
|
1255
|
+
maxAvgTime = avgTimeMs;
|
|
1256
|
+
bottleneckStage = s.stageId;
|
|
1257
|
+
}
|
|
1258
|
+
return {
|
|
1259
|
+
stageId: s.stageId,
|
|
1260
|
+
stageName: s.name,
|
|
1261
|
+
count,
|
|
1262
|
+
avgTimeMs,
|
|
1263
|
+
conversionRate
|
|
1264
|
+
};
|
|
1265
|
+
});
|
|
1266
|
+
this.logger.info("Pipeline stats computed", { pipelineId, totalCalls });
|
|
1267
|
+
return {
|
|
1268
|
+
pipelineId,
|
|
1269
|
+
pipelineName,
|
|
1270
|
+
totalCalls,
|
|
1271
|
+
stages: stageStats,
|
|
1272
|
+
bottleneckStage
|
|
1273
|
+
};
|
|
1274
|
+
}
|
|
1275
|
+
async getPipelineFunnel(pipelineId, dateRange) {
|
|
1276
|
+
const pipeline = await this.Pipeline.findOne({ pipelineId, isDeleted: false });
|
|
1277
|
+
const pipelineName = pipeline?.name ?? pipelineId;
|
|
1278
|
+
const stages = pipeline?.stages ?? [];
|
|
1279
|
+
const matchStage = {
|
|
1280
|
+
pipelineId,
|
|
1281
|
+
...this.buildDateMatch(dateRange)
|
|
1282
|
+
};
|
|
1283
|
+
const [enteredAgg, exitedAgg] = await Promise.all([
|
|
1284
|
+
this.CallLog.aggregate([
|
|
1285
|
+
{ $match: matchStage },
|
|
1286
|
+
{ $unwind: "$stageHistory" },
|
|
1287
|
+
{ $group: { _id: "$stageHistory.toStageId", count: { $sum: 1 } } }
|
|
1288
|
+
]),
|
|
1289
|
+
this.CallLog.aggregate([
|
|
1290
|
+
{ $match: matchStage },
|
|
1291
|
+
{ $unwind: "$stageHistory" },
|
|
1292
|
+
{ $group: { _id: "$stageHistory.fromStageId", count: { $sum: 1 } } }
|
|
1293
|
+
])
|
|
1294
|
+
]);
|
|
1295
|
+
const enteredMap = new Map(enteredAgg.map((r) => [String(r._id), r.count]));
|
|
1296
|
+
const exitedMap = new Map(exitedAgg.map((r) => [String(r._id), r.count]));
|
|
1297
|
+
const funnelStages = stages.map((s) => {
|
|
1298
|
+
const entered = enteredMap.get(s.stageId) ?? 0;
|
|
1299
|
+
const exited = exitedMap.get(s.stageId) ?? 0;
|
|
1300
|
+
return {
|
|
1301
|
+
stageId: s.stageId,
|
|
1302
|
+
stageName: s.name,
|
|
1303
|
+
entered,
|
|
1304
|
+
exited,
|
|
1305
|
+
dropOff: Math.max(0, entered - exited)
|
|
1306
|
+
};
|
|
1307
|
+
});
|
|
1308
|
+
return { pipelineId, pipelineName, stages: funnelStages };
|
|
1309
|
+
}
|
|
1310
|
+
async getTeamStats(teamId, dateRange = {}) {
|
|
1311
|
+
const now = /* @__PURE__ */ new Date();
|
|
1312
|
+
const matchStage = this.buildDateMatch(dateRange);
|
|
1313
|
+
const pipeline = [
|
|
1314
|
+
{ $match: matchStage },
|
|
1315
|
+
{
|
|
1316
|
+
$group: {
|
|
1317
|
+
_id: "$agentId",
|
|
1318
|
+
totalCalls: { $sum: 1 },
|
|
1319
|
+
callsClosed: {
|
|
1320
|
+
$sum: { $cond: [{ $eq: ["$isClosed", true] }, 1, 0] }
|
|
1321
|
+
},
|
|
1322
|
+
followUpsCompleted: {
|
|
1323
|
+
$sum: {
|
|
1324
|
+
$cond: [
|
|
1325
|
+
{
|
|
1326
|
+
$and: [
|
|
1327
|
+
{ $ne: ["$nextFollowUpDate", null] },
|
|
1328
|
+
{ $eq: ["$isClosed", true] }
|
|
1329
|
+
]
|
|
1330
|
+
},
|
|
1331
|
+
1,
|
|
1332
|
+
0
|
|
1333
|
+
]
|
|
1334
|
+
}
|
|
1335
|
+
},
|
|
1336
|
+
overdueFollowUps: {
|
|
1337
|
+
$sum: {
|
|
1338
|
+
$cond: [
|
|
1339
|
+
{
|
|
1340
|
+
$and: [
|
|
1341
|
+
{ $ne: ["$nextFollowUpDate", null] },
|
|
1342
|
+
{ $lt: ["$nextFollowUpDate", now] },
|
|
1343
|
+
{ $eq: ["$isClosed", false] }
|
|
1344
|
+
]
|
|
1345
|
+
},
|
|
1346
|
+
1,
|
|
1347
|
+
0
|
|
1348
|
+
]
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
];
|
|
1354
|
+
const results = await this.CallLog.aggregate(pipeline);
|
|
1355
|
+
const agentStats = await Promise.all(results.map(async (stat) => ({
|
|
1356
|
+
agentId: String(stat._id),
|
|
1357
|
+
agentName: await this.getAgentName(String(stat._id)),
|
|
1358
|
+
totalCalls: stat.totalCalls,
|
|
1359
|
+
avgCallsPerDay: 0,
|
|
1360
|
+
followUpsCompleted: stat.followUpsCompleted,
|
|
1361
|
+
overdueFollowUps: stat.overdueFollowUps,
|
|
1362
|
+
callsByPipeline: [],
|
|
1363
|
+
callsClosed: stat.callsClosed,
|
|
1364
|
+
closeRate: stat.totalCalls > 0 ? Math.round(stat.callsClosed / stat.totalCalls * 1e4) / 100 : 0
|
|
1365
|
+
})));
|
|
1366
|
+
const totalCalls = agentStats.reduce((sum, a) => sum + a.totalCalls, 0);
|
|
1367
|
+
return { teamId: teamId ?? null, agentStats, totalCalls };
|
|
1368
|
+
}
|
|
1369
|
+
async getDailyReport(dateRange) {
|
|
1370
|
+
const matchStage = this.buildDateMatch(dateRange);
|
|
1371
|
+
const [dailyAgg, directionAgg, pipelineAgg, agentAgg] = await Promise.all([
|
|
1372
|
+
this.CallLog.aggregate([
|
|
1373
|
+
{ $match: matchStage },
|
|
1374
|
+
{
|
|
1375
|
+
$group: {
|
|
1376
|
+
_id: { $dateToString: { format: "%Y-%m-%d", date: "$callDate" } },
|
|
1377
|
+
count: { $sum: 1 }
|
|
1378
|
+
}
|
|
1379
|
+
},
|
|
1380
|
+
{ $sort: { _id: 1 } }
|
|
1381
|
+
]),
|
|
1382
|
+
this.CallLog.aggregate([
|
|
1383
|
+
{ $match: matchStage },
|
|
1384
|
+
{
|
|
1385
|
+
$group: {
|
|
1386
|
+
_id: {
|
|
1387
|
+
date: { $dateToString: { format: "%Y-%m-%d", date: "$callDate" } },
|
|
1388
|
+
direction: "$direction"
|
|
1389
|
+
},
|
|
1390
|
+
count: { $sum: 1 }
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
]),
|
|
1394
|
+
this.CallLog.aggregate([
|
|
1395
|
+
{ $match: matchStage },
|
|
1396
|
+
{
|
|
1397
|
+
$group: {
|
|
1398
|
+
_id: {
|
|
1399
|
+
date: { $dateToString: { format: "%Y-%m-%d", date: "$callDate" } },
|
|
1400
|
+
pipelineId: "$pipelineId"
|
|
1401
|
+
},
|
|
1402
|
+
count: { $sum: 1 }
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
]),
|
|
1406
|
+
this.CallLog.aggregate([
|
|
1407
|
+
{ $match: matchStage },
|
|
1408
|
+
{
|
|
1409
|
+
$group: {
|
|
1410
|
+
_id: {
|
|
1411
|
+
date: { $dateToString: { format: "%Y-%m-%d", date: "$callDate" } },
|
|
1412
|
+
agentId: { $toString: "$agentId" }
|
|
1413
|
+
},
|
|
1414
|
+
count: { $sum: 1 }
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
])
|
|
1418
|
+
]);
|
|
1419
|
+
const directionByDate = /* @__PURE__ */ new Map();
|
|
1420
|
+
for (const r of directionAgg) {
|
|
1421
|
+
const date = r._id.date;
|
|
1422
|
+
if (!directionByDate.has(date)) directionByDate.set(date, []);
|
|
1423
|
+
directionByDate.get(date).push({ direction: r._id.direction, count: r.count });
|
|
1424
|
+
}
|
|
1425
|
+
const pipelineByDate = /* @__PURE__ */ new Map();
|
|
1426
|
+
for (const r of pipelineAgg) {
|
|
1427
|
+
const date = r._id.date;
|
|
1428
|
+
if (!pipelineByDate.has(date)) pipelineByDate.set(date, []);
|
|
1429
|
+
pipelineByDate.get(date).push({ pipelineId: r._id.pipelineId, pipelineName: r._id.pipelineId, count: r.count });
|
|
1430
|
+
}
|
|
1431
|
+
const agentByDate = /* @__PURE__ */ new Map();
|
|
1432
|
+
await Promise.all(agentAgg.map(async (r) => {
|
|
1433
|
+
const date = r._id.date;
|
|
1434
|
+
if (!agentByDate.has(date)) agentByDate.set(date, []);
|
|
1435
|
+
const agentName = await this.getAgentName(r._id.agentId);
|
|
1436
|
+
agentByDate.get(date).push({ agentId: r._id.agentId, agentName, count: r.count });
|
|
1437
|
+
}));
|
|
1438
|
+
return dailyAgg.map((d) => ({
|
|
1439
|
+
date: d._id,
|
|
1440
|
+
total: d.count,
|
|
1441
|
+
byDirection: directionByDate.get(d._id) ?? [],
|
|
1442
|
+
byPipeline: pipelineByDate.get(d._id) ?? [],
|
|
1443
|
+
byAgent: agentByDate.get(d._id) ?? []
|
|
1444
|
+
}));
|
|
1445
|
+
}
|
|
1446
|
+
async getDashboardStats() {
|
|
1447
|
+
const now = /* @__PURE__ */ new Date();
|
|
1448
|
+
const midnight = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
1449
|
+
const [openCalls, closedToday, overdueFollowUps, callsToday] = await Promise.all([
|
|
1450
|
+
this.CallLog.countDocuments({ isClosed: false }),
|
|
1451
|
+
this.CallLog.countDocuments({ closedAt: { $gte: midnight } }),
|
|
1452
|
+
this.CallLog.countDocuments({
|
|
1453
|
+
nextFollowUpDate: { $lt: now },
|
|
1454
|
+
isClosed: false
|
|
1455
|
+
}),
|
|
1456
|
+
this.CallLog.countDocuments({ callDate: { $gte: midnight } })
|
|
1457
|
+
]);
|
|
1458
|
+
return {
|
|
1459
|
+
openCalls,
|
|
1460
|
+
closedToday,
|
|
1461
|
+
overdueFollowUps,
|
|
1462
|
+
totalAgents: 0,
|
|
1463
|
+
callsToday
|
|
1464
|
+
};
|
|
1465
|
+
}
|
|
1466
|
+
async getWeeklyTrends(weeks) {
|
|
1467
|
+
const now = /* @__PURE__ */ new Date();
|
|
1468
|
+
const from = new Date(now.getTime() - weeks * 7 * 24 * 60 * 60 * 1e3);
|
|
1469
|
+
const results = await this.CallLog.aggregate([
|
|
1470
|
+
{ $match: { callDate: { $gte: from, $lte: now } } },
|
|
1471
|
+
{
|
|
1472
|
+
$group: {
|
|
1473
|
+
_id: {
|
|
1474
|
+
week: { $isoWeek: "$callDate" },
|
|
1475
|
+
year: { $isoWeekYear: "$callDate" }
|
|
1476
|
+
},
|
|
1477
|
+
totalCalls: { $sum: 1 },
|
|
1478
|
+
closedCalls: {
|
|
1479
|
+
$sum: { $cond: [{ $eq: ["$isClosed", true] }, 1, 0] }
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
},
|
|
1483
|
+
{ $sort: { "_id.year": 1, "_id.week": 1 } },
|
|
1484
|
+
{
|
|
1485
|
+
$project: {
|
|
1486
|
+
_id: 0,
|
|
1487
|
+
week: { $toString: "$_id.week" },
|
|
1488
|
+
year: "$_id.year",
|
|
1489
|
+
totalCalls: 1,
|
|
1490
|
+
closedCalls: 1
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
]);
|
|
1494
|
+
return results;
|
|
1495
|
+
}
|
|
1496
|
+
async getOverallReport(dateRange) {
|
|
1497
|
+
const matchStage = this.buildDateMatch(dateRange);
|
|
1498
|
+
const [summaryAgg, tagAgg, categoryAgg, peakHoursAgg, followUpAgg] = await Promise.all([
|
|
1499
|
+
this.CallLog.aggregate([
|
|
1500
|
+
{ $match: matchStage },
|
|
1501
|
+
{
|
|
1502
|
+
$group: {
|
|
1503
|
+
_id: null,
|
|
1504
|
+
totalCalls: { $sum: 1 },
|
|
1505
|
+
closedCalls: {
|
|
1506
|
+
$sum: { $cond: [{ $eq: ["$isClosed", true] }, 1, 0] }
|
|
1507
|
+
},
|
|
1508
|
+
avgTimeToCloseMs: {
|
|
1509
|
+
$avg: {
|
|
1510
|
+
$cond: [
|
|
1511
|
+
{ $and: [{ $eq: ["$isClosed", true] }, { $ne: ["$closedAt", null] }] },
|
|
1512
|
+
{ $subtract: ["$closedAt", "$createdAt"] },
|
|
1513
|
+
null
|
|
1514
|
+
]
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
]),
|
|
1520
|
+
this.CallLog.aggregate([
|
|
1521
|
+
{ $match: { ...matchStage, tags: { $exists: true, $ne: [] } } },
|
|
1522
|
+
{ $unwind: "$tags" },
|
|
1523
|
+
{ $group: { _id: "$tags", count: { $sum: 1 } } },
|
|
1524
|
+
{ $sort: { count: -1 } }
|
|
1525
|
+
]),
|
|
1526
|
+
this.CallLog.aggregate([
|
|
1527
|
+
{ $match: { ...matchStage, category: { $exists: true, $ne: null } } },
|
|
1528
|
+
{ $group: { _id: "$category", count: { $sum: 1 } } },
|
|
1529
|
+
{ $sort: { count: -1 } }
|
|
1530
|
+
]),
|
|
1531
|
+
this.CallLog.aggregate([
|
|
1532
|
+
{ $match: matchStage },
|
|
1533
|
+
{ $group: { _id: { $hour: "$callDate" }, count: { $sum: 1 } } },
|
|
1534
|
+
{ $sort: { _id: 1 } }
|
|
1535
|
+
]),
|
|
1536
|
+
this.CallLog.aggregate([
|
|
1537
|
+
{ $match: matchStage },
|
|
1538
|
+
{
|
|
1539
|
+
$group: {
|
|
1540
|
+
_id: null,
|
|
1541
|
+
total: { $sum: 1 },
|
|
1542
|
+
withFollowUp: {
|
|
1543
|
+
$sum: { $cond: [{ $ne: ["$nextFollowUpDate", null] }, 1, 0] }
|
|
1544
|
+
},
|
|
1545
|
+
completed: {
|
|
1546
|
+
$sum: {
|
|
1547
|
+
$cond: [
|
|
1548
|
+
{
|
|
1549
|
+
$and: [
|
|
1550
|
+
{ $ne: ["$nextFollowUpDate", null] },
|
|
1551
|
+
{ $eq: ["$isClosed", true] }
|
|
1552
|
+
]
|
|
1553
|
+
},
|
|
1554
|
+
1,
|
|
1555
|
+
0
|
|
1556
|
+
]
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
}
|
|
1560
|
+
}
|
|
1561
|
+
])
|
|
1562
|
+
]);
|
|
1563
|
+
const summary = summaryAgg[0] ?? { totalCalls: 0, closedCalls: 0, avgTimeToCloseMs: 0 };
|
|
1564
|
+
const followUp = followUpAgg[0] ?? { withFollowUp: 0, completed: 0 };
|
|
1565
|
+
const followUpComplianceRate = followUp.withFollowUp > 0 ? Math.round(followUp.completed / followUp.withFollowUp * 1e4) / 100 : 0;
|
|
1566
|
+
return {
|
|
1567
|
+
totalCalls: summary.totalCalls,
|
|
1568
|
+
closedCalls: summary.closedCalls,
|
|
1569
|
+
avgTimeToCloseMs: Math.round(summary.avgTimeToCloseMs ?? 0),
|
|
1570
|
+
followUpComplianceRate,
|
|
1571
|
+
tagDistribution: tagAgg.map((r) => ({ tag: String(r._id), count: r.count })),
|
|
1572
|
+
categoryDistribution: categoryAgg.map((r) => ({ category: String(r._id), count: r.count })),
|
|
1573
|
+
peakCallHours: peakHoursAgg.map((r) => ({ hour: Number(r._id), count: r.count }))
|
|
1574
|
+
};
|
|
1575
|
+
}
|
|
1576
|
+
};
|
|
1577
|
+
|
|
1578
|
+
// src/services/export.service.ts
|
|
1579
|
+
var CSV_HEADER = "callLogId,contactName,contactPhone,contactEmail,direction,pipelineId,currentStageId,priority,agentId,callDate,isClosed,tags";
|
|
1580
|
+
var ExportService = class {
|
|
1581
|
+
constructor(CallLog, analytics, logger) {
|
|
1582
|
+
this.CallLog = CallLog;
|
|
1583
|
+
this.analytics = analytics;
|
|
1584
|
+
this.logger = logger;
|
|
1585
|
+
}
|
|
1586
|
+
async exportCallLog(callLogId, format) {
|
|
1587
|
+
const callLog = await this.CallLog.findOne({ callLogId });
|
|
1588
|
+
if (!callLog) throw new CallLogNotFoundError(callLogId);
|
|
1589
|
+
if (format === "csv") {
|
|
1590
|
+
return this.toCSV([callLog]);
|
|
1591
|
+
}
|
|
1592
|
+
return JSON.stringify(
|
|
1593
|
+
callLog.toObject ? callLog.toObject() : callLog,
|
|
1594
|
+
null,
|
|
1595
|
+
2
|
|
1596
|
+
);
|
|
1597
|
+
}
|
|
1598
|
+
async exportCallLogs(filter, format) {
|
|
1599
|
+
const query = {};
|
|
1600
|
+
if (filter.pipelineId) query.pipelineId = filter.pipelineId;
|
|
1601
|
+
if (filter.stageId) query.currentStageId = filter.stageId;
|
|
1602
|
+
if (filter.agentId) query.agentId = filter.agentId;
|
|
1603
|
+
if (filter.tags && filter.tags.length > 0) query.tags = { $in: filter.tags };
|
|
1604
|
+
if (filter.category) query.category = filter.category;
|
|
1605
|
+
if (filter.isClosed !== void 0) query.isClosed = filter.isClosed;
|
|
1606
|
+
if (filter.dateFrom || filter.dateTo) {
|
|
1607
|
+
const dateFilter = {};
|
|
1608
|
+
if (filter.dateFrom) dateFilter.$gte = new Date(filter.dateFrom);
|
|
1609
|
+
if (filter.dateTo) dateFilter.$lte = new Date(filter.dateTo);
|
|
1610
|
+
query.callDate = dateFilter;
|
|
1611
|
+
}
|
|
1612
|
+
const callLogs = await this.CallLog.find(query).sort({ callDate: -1 });
|
|
1613
|
+
if (format === "csv") {
|
|
1614
|
+
return this.toCSV(callLogs);
|
|
1615
|
+
}
|
|
1616
|
+
return JSON.stringify(
|
|
1617
|
+
callLogs.map(
|
|
1618
|
+
(c) => c.toObject ? c.toObject() : c
|
|
1619
|
+
),
|
|
1620
|
+
null,
|
|
1621
|
+
2
|
|
1622
|
+
);
|
|
1623
|
+
}
|
|
1624
|
+
async exportPipelineReport(pipelineId, dateRange, format) {
|
|
1625
|
+
const report = await this.analytics.getPipelineStats(pipelineId, dateRange);
|
|
1626
|
+
if (format === "csv") {
|
|
1627
|
+
const header = "pipelineId,pipelineName,totalCalls,stageId,stageName,count,avgTimeMs,conversionRate,bottleneckStage";
|
|
1628
|
+
const rows = [header];
|
|
1629
|
+
for (const stage of report.stages) {
|
|
1630
|
+
rows.push(
|
|
1631
|
+
[
|
|
1632
|
+
this.escapeCSV(report.pipelineId),
|
|
1633
|
+
this.escapeCSV(report.pipelineName),
|
|
1634
|
+
String(report.totalCalls),
|
|
1635
|
+
this.escapeCSV(stage.stageId),
|
|
1636
|
+
this.escapeCSV(stage.stageName),
|
|
1637
|
+
String(stage.count),
|
|
1638
|
+
String(stage.avgTimeMs),
|
|
1639
|
+
String(stage.conversionRate),
|
|
1640
|
+
this.escapeCSV(report.bottleneckStage ?? "")
|
|
1641
|
+
].join(",")
|
|
1642
|
+
);
|
|
1643
|
+
}
|
|
1644
|
+
this.logger.info("Pipeline report exported as CSV", { pipelineId });
|
|
1645
|
+
return rows.join("\n");
|
|
1646
|
+
}
|
|
1647
|
+
this.logger.info("Pipeline report exported as JSON", { pipelineId });
|
|
1648
|
+
return JSON.stringify(report, null, 2);
|
|
1649
|
+
}
|
|
1650
|
+
toCSV(callLogs) {
|
|
1651
|
+
const rows = [CSV_HEADER];
|
|
1652
|
+
for (const c of callLogs) {
|
|
1653
|
+
const row = [
|
|
1654
|
+
this.escapeCSV(c.callLogId),
|
|
1655
|
+
this.escapeCSV(c.contactRef?.displayName ?? ""),
|
|
1656
|
+
this.escapeCSV(c.contactRef?.phone ?? ""),
|
|
1657
|
+
this.escapeCSV(c.contactRef?.email ?? ""),
|
|
1658
|
+
this.escapeCSV(c.direction ?? ""),
|
|
1659
|
+
this.escapeCSV(c.pipelineId ?? ""),
|
|
1660
|
+
this.escapeCSV(c.currentStageId ?? ""),
|
|
1661
|
+
this.escapeCSV(c.priority ?? ""),
|
|
1662
|
+
this.escapeCSV(c.agentId?.toString() ?? ""),
|
|
1663
|
+
c.callDate ? new Date(c.callDate).toISOString() : "",
|
|
1664
|
+
String(c.isClosed ?? false),
|
|
1665
|
+
this.escapeCSV((c.tags ?? []).join(";"))
|
|
1666
|
+
].join(",");
|
|
1667
|
+
rows.push(row);
|
|
1668
|
+
}
|
|
1669
|
+
return rows.join("\n");
|
|
1670
|
+
}
|
|
1671
|
+
escapeCSV(value) {
|
|
1672
|
+
if (value.includes(",") || value.includes('"') || value.includes("\n")) {
|
|
1673
|
+
return `"${value.replace(/"/g, '""')}"`;
|
|
1674
|
+
}
|
|
1675
|
+
return value;
|
|
1676
|
+
}
|
|
1677
|
+
};
|
|
1678
|
+
function createPipelineRoutes(pipeline, logger) {
|
|
1679
|
+
const router = express.Router();
|
|
1680
|
+
router.get("/", async (req, res) => {
|
|
1681
|
+
try {
|
|
1682
|
+
const isActive = req.query["isActive"] !== void 0 ? req.query["isActive"] === "true" : void 0;
|
|
1683
|
+
const result = await pipeline.list({ isActive });
|
|
1684
|
+
core.sendSuccess(res, result);
|
|
1685
|
+
} catch (error) {
|
|
1686
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1687
|
+
logger.error("Failed to list pipelines", { error: message });
|
|
1688
|
+
core.sendError(res, message, 500);
|
|
1689
|
+
}
|
|
1690
|
+
});
|
|
1691
|
+
router.post("/", async (req, res) => {
|
|
1692
|
+
try {
|
|
1693
|
+
const result = await pipeline.create(req.body);
|
|
1694
|
+
core.sendSuccess(res, result, 201);
|
|
1695
|
+
} catch (error) {
|
|
1696
|
+
if (error instanceof AlxCallLogError) {
|
|
1697
|
+
core.sendError(res, error.message, 400);
|
|
1698
|
+
return;
|
|
1699
|
+
}
|
|
1700
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1701
|
+
logger.error("Failed to create pipeline", { error: message });
|
|
1702
|
+
core.sendError(res, message, 500);
|
|
1703
|
+
}
|
|
1704
|
+
});
|
|
1705
|
+
router.get("/:id", async (req, res) => {
|
|
1706
|
+
try {
|
|
1707
|
+
const result = await pipeline.get(req.params["id"]);
|
|
1708
|
+
core.sendSuccess(res, result);
|
|
1709
|
+
} catch (error) {
|
|
1710
|
+
if (error instanceof AlxCallLogError) {
|
|
1711
|
+
core.sendError(res, error.message, 404);
|
|
1712
|
+
return;
|
|
1713
|
+
}
|
|
1714
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1715
|
+
logger.error("Failed to get pipeline", { id: req.params["id"], error: message });
|
|
1716
|
+
core.sendError(res, message, 500);
|
|
1717
|
+
}
|
|
1718
|
+
});
|
|
1719
|
+
router.put("/:id", async (req, res) => {
|
|
1720
|
+
try {
|
|
1721
|
+
const result = await pipeline.update(req.params["id"], req.body);
|
|
1722
|
+
core.sendSuccess(res, result);
|
|
1723
|
+
} catch (error) {
|
|
1724
|
+
if (error instanceof AlxCallLogError) {
|
|
1725
|
+
core.sendError(res, error.message, 400);
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1729
|
+
logger.error("Failed to update pipeline", { id: req.params["id"], error: message });
|
|
1730
|
+
core.sendError(res, message, 500);
|
|
1731
|
+
}
|
|
1732
|
+
});
|
|
1733
|
+
router.delete("/:id", async (req, res) => {
|
|
1734
|
+
try {
|
|
1735
|
+
await pipeline.delete(req.params["id"]);
|
|
1736
|
+
core.sendSuccess(res, void 0);
|
|
1737
|
+
} catch (error) {
|
|
1738
|
+
if (error instanceof AlxCallLogError) {
|
|
1739
|
+
core.sendError(res, error.message, 400);
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1743
|
+
logger.error("Failed to delete pipeline", { id: req.params["id"], error: message });
|
|
1744
|
+
core.sendError(res, message, 500);
|
|
1745
|
+
}
|
|
1746
|
+
});
|
|
1747
|
+
router.post("/:id/stages", async (req, res) => {
|
|
1748
|
+
try {
|
|
1749
|
+
const result = await pipeline.addStage(req.params["id"], req.body);
|
|
1750
|
+
core.sendSuccess(res, result, 201);
|
|
1751
|
+
} catch (error) {
|
|
1752
|
+
if (error instanceof AlxCallLogError) {
|
|
1753
|
+
core.sendError(res, error.message, 400);
|
|
1754
|
+
return;
|
|
1755
|
+
}
|
|
1756
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1757
|
+
logger.error("Failed to add stage", { id: req.params["id"], error: message });
|
|
1758
|
+
core.sendError(res, message, 500);
|
|
1759
|
+
}
|
|
1760
|
+
});
|
|
1761
|
+
router.put("/:id/stages/reorder", async (req, res) => {
|
|
1762
|
+
try {
|
|
1763
|
+
const { stageIds } = req.body;
|
|
1764
|
+
const result = await pipeline.reorderStages(req.params["id"], stageIds);
|
|
1765
|
+
core.sendSuccess(res, result);
|
|
1766
|
+
} catch (error) {
|
|
1767
|
+
if (error instanceof AlxCallLogError) {
|
|
1768
|
+
core.sendError(res, error.message, 400);
|
|
1769
|
+
return;
|
|
1770
|
+
}
|
|
1771
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1772
|
+
logger.error("Failed to reorder stages", { id: req.params["id"], error: message });
|
|
1773
|
+
core.sendError(res, message, 500);
|
|
1774
|
+
}
|
|
1775
|
+
});
|
|
1776
|
+
router.put("/:id/stages/:stageId", async (req, res) => {
|
|
1777
|
+
try {
|
|
1778
|
+
const result = await pipeline.updateStage(req.params["id"], req.params["stageId"], req.body);
|
|
1779
|
+
core.sendSuccess(res, result);
|
|
1780
|
+
} catch (error) {
|
|
1781
|
+
if (error instanceof AlxCallLogError) {
|
|
1782
|
+
core.sendError(res, error.message, 400);
|
|
1783
|
+
return;
|
|
1784
|
+
}
|
|
1785
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1786
|
+
logger.error("Failed to update stage", { id: req.params["id"], stageId: req.params["stageId"], error: message });
|
|
1787
|
+
core.sendError(res, message, 500);
|
|
1788
|
+
}
|
|
1789
|
+
});
|
|
1790
|
+
router.delete("/:id/stages/:stageId", async (req, res) => {
|
|
1791
|
+
try {
|
|
1792
|
+
await pipeline.removeStage(req.params["id"], req.params["stageId"]);
|
|
1793
|
+
core.sendSuccess(res, void 0);
|
|
1794
|
+
} catch (error) {
|
|
1795
|
+
if (error instanceof AlxCallLogError) {
|
|
1796
|
+
core.sendError(res, error.message, 400);
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1800
|
+
logger.error("Failed to remove stage", { id: req.params["id"], stageId: req.params["stageId"], error: message });
|
|
1801
|
+
core.sendError(res, message, 500);
|
|
1802
|
+
}
|
|
1803
|
+
});
|
|
1804
|
+
return router;
|
|
1805
|
+
}
|
|
1806
|
+
function createCallLogRoutes(services, logger) {
|
|
1807
|
+
const router = express.Router();
|
|
1808
|
+
const { callLogs, timeline } = services;
|
|
1809
|
+
router.get("/follow-ups", async (req, res) => {
|
|
1810
|
+
try {
|
|
1811
|
+
const { agentId } = req.query;
|
|
1812
|
+
const dateRange = {
|
|
1813
|
+
from: req.query["from"],
|
|
1814
|
+
to: req.query["to"]
|
|
1815
|
+
};
|
|
1816
|
+
const result = await callLogs.getFollowUpsDue(agentId, dateRange);
|
|
1817
|
+
core.sendSuccess(res, result);
|
|
1818
|
+
} catch (error) {
|
|
1819
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1820
|
+
logger.error("Failed to get follow-ups", { error: message });
|
|
1821
|
+
core.sendError(res, message, 500);
|
|
1822
|
+
}
|
|
1823
|
+
});
|
|
1824
|
+
router.put("/-/bulk/stage", async (req, res) => {
|
|
1825
|
+
try {
|
|
1826
|
+
const { callLogIds, newStageId, agentId } = req.body;
|
|
1827
|
+
const result = await callLogs.bulkChangeStage(callLogIds, newStageId, agentId);
|
|
1828
|
+
core.sendSuccess(res, result);
|
|
1829
|
+
} catch (error) {
|
|
1830
|
+
if (error instanceof AlxCallLogError) {
|
|
1831
|
+
core.sendError(res, error.message, 400);
|
|
1832
|
+
return;
|
|
1833
|
+
}
|
|
1834
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1835
|
+
logger.error("Failed to bulk change stage", { error: message });
|
|
1836
|
+
core.sendError(res, message, 500);
|
|
1837
|
+
}
|
|
1838
|
+
});
|
|
1839
|
+
router.get("/", async (req, res) => {
|
|
1840
|
+
try {
|
|
1841
|
+
const query = req.query;
|
|
1842
|
+
const filter = {};
|
|
1843
|
+
if (query["pipelineId"]) filter["pipelineId"] = query["pipelineId"];
|
|
1844
|
+
if (query["currentStageId"]) filter["currentStageId"] = query["currentStageId"];
|
|
1845
|
+
if (query["agentId"]) filter["agentId"] = query["agentId"];
|
|
1846
|
+
if (query["category"]) filter["category"] = query["category"];
|
|
1847
|
+
if (query["isClosed"] !== void 0) filter["isClosed"] = query["isClosed"] === "true";
|
|
1848
|
+
if (query["contactExternalId"]) filter["contactExternalId"] = query["contactExternalId"];
|
|
1849
|
+
if (query["contactName"]) filter["contactName"] = query["contactName"];
|
|
1850
|
+
if (query["contactPhone"]) filter["contactPhone"] = query["contactPhone"];
|
|
1851
|
+
if (query["contactEmail"]) filter["contactEmail"] = query["contactEmail"];
|
|
1852
|
+
if (query["priority"]) filter["priority"] = query["priority"];
|
|
1853
|
+
if (query["direction"]) filter["direction"] = query["direction"];
|
|
1854
|
+
if (query["page"]) filter["page"] = parseInt(query["page"], 10);
|
|
1855
|
+
if (query["limit"]) filter["limit"] = parseInt(query["limit"], 10);
|
|
1856
|
+
if (query["from"] || query["to"]) {
|
|
1857
|
+
filter["dateRange"] = { from: query["from"], to: query["to"] };
|
|
1858
|
+
}
|
|
1859
|
+
const result = await callLogs.list(filter);
|
|
1860
|
+
core.sendSuccess(res, result);
|
|
1861
|
+
} catch (error) {
|
|
1862
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1863
|
+
logger.error("Failed to list call logs", { error: message });
|
|
1864
|
+
core.sendError(res, message, 500);
|
|
1865
|
+
}
|
|
1866
|
+
});
|
|
1867
|
+
router.post("/", async (req, res) => {
|
|
1868
|
+
try {
|
|
1869
|
+
const result = await callLogs.create(req.body);
|
|
1870
|
+
core.sendSuccess(res, result, 201);
|
|
1871
|
+
} catch (error) {
|
|
1872
|
+
if (error instanceof AlxCallLogError) {
|
|
1873
|
+
core.sendError(res, error.message, 400);
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1877
|
+
logger.error("Failed to create call log", { error: message });
|
|
1878
|
+
core.sendError(res, message, 500);
|
|
1879
|
+
}
|
|
1880
|
+
});
|
|
1881
|
+
router.get("/:id", async (req, res) => {
|
|
1882
|
+
try {
|
|
1883
|
+
const result = await callLogs.get(req.params["id"]);
|
|
1884
|
+
core.sendSuccess(res, result);
|
|
1885
|
+
} catch (error) {
|
|
1886
|
+
if (error instanceof AlxCallLogError) {
|
|
1887
|
+
core.sendError(res, error.message, 404);
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1891
|
+
logger.error("Failed to get call log", { id: req.params["id"], error: message });
|
|
1892
|
+
core.sendError(res, message, 500);
|
|
1893
|
+
}
|
|
1894
|
+
});
|
|
1895
|
+
router.put("/:id", async (req, res) => {
|
|
1896
|
+
try {
|
|
1897
|
+
const result = await callLogs.update(req.params["id"], req.body);
|
|
1898
|
+
core.sendSuccess(res, result);
|
|
1899
|
+
} catch (error) {
|
|
1900
|
+
if (error instanceof AlxCallLogError) {
|
|
1901
|
+
core.sendError(res, error.message, 400);
|
|
1902
|
+
return;
|
|
1903
|
+
}
|
|
1904
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1905
|
+
logger.error("Failed to update call log", { id: req.params["id"], error: message });
|
|
1906
|
+
core.sendError(res, message, 500);
|
|
1907
|
+
}
|
|
1908
|
+
});
|
|
1909
|
+
router.put("/:id/stage", async (req, res) => {
|
|
1910
|
+
try {
|
|
1911
|
+
const { newStageId, agentId } = req.body;
|
|
1912
|
+
const result = await callLogs.changeStage(req.params["id"], newStageId, agentId);
|
|
1913
|
+
core.sendSuccess(res, result);
|
|
1914
|
+
} catch (error) {
|
|
1915
|
+
if (error instanceof AlxCallLogError) {
|
|
1916
|
+
core.sendError(res, error.message, 400);
|
|
1917
|
+
return;
|
|
1918
|
+
}
|
|
1919
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1920
|
+
logger.error("Failed to change stage", { id: req.params["id"], error: message });
|
|
1921
|
+
core.sendError(res, message, 500);
|
|
1922
|
+
}
|
|
1923
|
+
});
|
|
1924
|
+
router.put("/:id/assign", async (req, res) => {
|
|
1925
|
+
try {
|
|
1926
|
+
const { agentId, assignedBy } = req.body;
|
|
1927
|
+
const result = await callLogs.assign(req.params["id"], agentId, assignedBy);
|
|
1928
|
+
core.sendSuccess(res, result);
|
|
1929
|
+
} catch (error) {
|
|
1930
|
+
if (error instanceof AlxCallLogError) {
|
|
1931
|
+
core.sendError(res, error.message, 400);
|
|
1932
|
+
return;
|
|
1933
|
+
}
|
|
1934
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1935
|
+
logger.error("Failed to assign call log", { id: req.params["id"], error: message });
|
|
1936
|
+
core.sendError(res, message, 500);
|
|
1937
|
+
}
|
|
1938
|
+
});
|
|
1939
|
+
router.put("/:id/close", async (req, res) => {
|
|
1940
|
+
try {
|
|
1941
|
+
const { agentId } = req.body;
|
|
1942
|
+
const result = await callLogs.close(req.params["id"], agentId);
|
|
1943
|
+
core.sendSuccess(res, result);
|
|
1944
|
+
} catch (error) {
|
|
1945
|
+
if (error instanceof AlxCallLogError) {
|
|
1946
|
+
core.sendError(res, error.message, 400);
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1950
|
+
logger.error("Failed to close call log", { id: req.params["id"], error: message });
|
|
1951
|
+
core.sendError(res, message, 500);
|
|
1952
|
+
}
|
|
1953
|
+
});
|
|
1954
|
+
router.put("/:id/reopen", async (req, res) => {
|
|
1955
|
+
try {
|
|
1956
|
+
const { agentId } = req.body;
|
|
1957
|
+
const result = await callLogs.reopen(req.params["id"], agentId);
|
|
1958
|
+
core.sendSuccess(res, result);
|
|
1959
|
+
} catch (error) {
|
|
1960
|
+
if (error instanceof AlxCallLogError) {
|
|
1961
|
+
core.sendError(res, error.message, 400);
|
|
1962
|
+
return;
|
|
1963
|
+
}
|
|
1964
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1965
|
+
logger.error("Failed to reopen call log", { id: req.params["id"], error: message });
|
|
1966
|
+
core.sendError(res, message, 500);
|
|
1967
|
+
}
|
|
1968
|
+
});
|
|
1969
|
+
router.post("/:id/notes", async (req, res) => {
|
|
1970
|
+
try {
|
|
1971
|
+
const { content, authorId, authorName } = req.body;
|
|
1972
|
+
const result = await timeline.addNote(req.params["id"], content, authorId, authorName);
|
|
1973
|
+
core.sendSuccess(res, result, 201);
|
|
1974
|
+
} catch (error) {
|
|
1975
|
+
if (error instanceof AlxCallLogError) {
|
|
1976
|
+
core.sendError(res, error.message, 400);
|
|
1977
|
+
return;
|
|
1978
|
+
}
|
|
1979
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1980
|
+
logger.error("Failed to add timeline note", { id: req.params["id"], error: message });
|
|
1981
|
+
core.sendError(res, message, 500);
|
|
1982
|
+
}
|
|
1983
|
+
});
|
|
1984
|
+
router.get("/:id/timeline", async (req, res) => {
|
|
1985
|
+
try {
|
|
1986
|
+
const page = parseInt(req.query["page"] || "1", 10);
|
|
1987
|
+
const limit = parseInt(req.query["limit"] || "50", 10);
|
|
1988
|
+
const result = await timeline.getTimeline(req.params["id"], { page, limit });
|
|
1989
|
+
core.sendSuccess(res, result);
|
|
1990
|
+
} catch (error) {
|
|
1991
|
+
if (error instanceof AlxCallLogError) {
|
|
1992
|
+
core.sendError(res, error.message, 404);
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
1996
|
+
logger.error("Failed to get timeline", { id: req.params["id"], error: message });
|
|
1997
|
+
core.sendError(res, message, 500);
|
|
1998
|
+
}
|
|
1999
|
+
});
|
|
2000
|
+
return router;
|
|
2001
|
+
}
|
|
2002
|
+
function createContactRoutes(services, logger) {
|
|
2003
|
+
const router = express.Router();
|
|
2004
|
+
const { callLogs, timeline } = services;
|
|
2005
|
+
router.get("/:externalId/calls", async (req, res) => {
|
|
2006
|
+
try {
|
|
2007
|
+
const result = await callLogs.getByContact(req.params["externalId"]);
|
|
2008
|
+
core.sendSuccess(res, result);
|
|
2009
|
+
} catch (error) {
|
|
2010
|
+
if (error instanceof AlxCallLogError) {
|
|
2011
|
+
core.sendError(res, error.message, 404);
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2015
|
+
logger.error("Failed to get calls for contact", { externalId: req.params["externalId"], error: message });
|
|
2016
|
+
core.sendError(res, message, 500);
|
|
2017
|
+
}
|
|
2018
|
+
});
|
|
2019
|
+
router.get("/:externalId/timeline", async (req, res) => {
|
|
2020
|
+
try {
|
|
2021
|
+
const page = parseInt(req.query["page"] || "1", 10);
|
|
2022
|
+
const limit = parseInt(req.query["limit"] || "20", 10);
|
|
2023
|
+
const result = await timeline.getContactTimeline(req.params["externalId"], { page, limit });
|
|
2024
|
+
core.sendSuccess(res, result);
|
|
2025
|
+
} catch (error) {
|
|
2026
|
+
if (error instanceof AlxCallLogError) {
|
|
2027
|
+
core.sendError(res, error.message, 404);
|
|
2028
|
+
return;
|
|
2029
|
+
}
|
|
2030
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2031
|
+
logger.error("Failed to get contact timeline", { externalId: req.params["externalId"], error: message });
|
|
2032
|
+
core.sendError(res, message, 500);
|
|
2033
|
+
}
|
|
2034
|
+
});
|
|
2035
|
+
return router;
|
|
2036
|
+
}
|
|
2037
|
+
function createAnalyticsRoutes(analytics, logger) {
|
|
2038
|
+
const router = express.Router();
|
|
2039
|
+
function parseDateRange(query) {
|
|
2040
|
+
return {
|
|
2041
|
+
from: query["from"],
|
|
2042
|
+
to: query["to"]
|
|
2043
|
+
};
|
|
2044
|
+
}
|
|
2045
|
+
router.get("/stats", async (req, res) => {
|
|
2046
|
+
try {
|
|
2047
|
+
const result = await analytics.getDashboardStats();
|
|
2048
|
+
core.sendSuccess(res, result);
|
|
2049
|
+
} catch (error) {
|
|
2050
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2051
|
+
logger.error("Failed to get dashboard stats", { error: message });
|
|
2052
|
+
core.sendError(res, message, 500);
|
|
2053
|
+
}
|
|
2054
|
+
});
|
|
2055
|
+
router.get("/agent/:agentId", async (req, res) => {
|
|
2056
|
+
try {
|
|
2057
|
+
const dateRange = parseDateRange(req.query);
|
|
2058
|
+
const result = await analytics.getAgentStats(req.params["agentId"], dateRange);
|
|
2059
|
+
core.sendSuccess(res, result);
|
|
2060
|
+
} catch (error) {
|
|
2061
|
+
if (error instanceof AlxCallLogError) {
|
|
2062
|
+
core.sendError(res, error.message, 400);
|
|
2063
|
+
return;
|
|
2064
|
+
}
|
|
2065
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2066
|
+
logger.error("Failed to get agent stats", { agentId: req.params["agentId"], error: message });
|
|
2067
|
+
core.sendError(res, message, 500);
|
|
2068
|
+
}
|
|
2069
|
+
});
|
|
2070
|
+
router.get("/agent-leaderboard", async (req, res) => {
|
|
2071
|
+
try {
|
|
2072
|
+
const dateRange = parseDateRange(req.query);
|
|
2073
|
+
const result = await analytics.getAgentLeaderboard(dateRange);
|
|
2074
|
+
core.sendSuccess(res, result);
|
|
2075
|
+
} catch (error) {
|
|
2076
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2077
|
+
logger.error("Failed to get agent leaderboard", { error: message });
|
|
2078
|
+
core.sendError(res, message, 500);
|
|
2079
|
+
}
|
|
2080
|
+
});
|
|
2081
|
+
router.get("/pipeline/:id", async (req, res) => {
|
|
2082
|
+
try {
|
|
2083
|
+
const dateRange = parseDateRange(req.query);
|
|
2084
|
+
const result = await analytics.getPipelineStats(req.params["id"], dateRange);
|
|
2085
|
+
core.sendSuccess(res, result);
|
|
2086
|
+
} catch (error) {
|
|
2087
|
+
if (error instanceof AlxCallLogError) {
|
|
2088
|
+
core.sendError(res, error.message, 400);
|
|
2089
|
+
return;
|
|
2090
|
+
}
|
|
2091
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2092
|
+
logger.error("Failed to get pipeline stats", { id: req.params["id"], error: message });
|
|
2093
|
+
core.sendError(res, message, 500);
|
|
2094
|
+
}
|
|
2095
|
+
});
|
|
2096
|
+
router.get("/pipeline/:id/funnel", async (req, res) => {
|
|
2097
|
+
try {
|
|
2098
|
+
const dateRange = parseDateRange(req.query);
|
|
2099
|
+
const result = await analytics.getPipelineFunnel(req.params["id"], dateRange);
|
|
2100
|
+
core.sendSuccess(res, result);
|
|
2101
|
+
} catch (error) {
|
|
2102
|
+
if (error instanceof AlxCallLogError) {
|
|
2103
|
+
core.sendError(res, error.message, 400);
|
|
2104
|
+
return;
|
|
2105
|
+
}
|
|
2106
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2107
|
+
logger.error("Failed to get pipeline funnel", { id: req.params["id"], error: message });
|
|
2108
|
+
core.sendError(res, message, 500);
|
|
2109
|
+
}
|
|
2110
|
+
});
|
|
2111
|
+
router.get("/team", async (req, res) => {
|
|
2112
|
+
try {
|
|
2113
|
+
const { teamId } = req.query;
|
|
2114
|
+
const dateRange = parseDateRange(req.query);
|
|
2115
|
+
const result = await analytics.getTeamStats(teamId, dateRange);
|
|
2116
|
+
core.sendSuccess(res, result);
|
|
2117
|
+
} catch (error) {
|
|
2118
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2119
|
+
logger.error("Failed to get team stats", { error: message });
|
|
2120
|
+
core.sendError(res, message, 500);
|
|
2121
|
+
}
|
|
2122
|
+
});
|
|
2123
|
+
router.get("/daily", async (req, res) => {
|
|
2124
|
+
try {
|
|
2125
|
+
const dateRange = parseDateRange(req.query);
|
|
2126
|
+
const result = await analytics.getDailyReport(dateRange);
|
|
2127
|
+
core.sendSuccess(res, result);
|
|
2128
|
+
} catch (error) {
|
|
2129
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2130
|
+
logger.error("Failed to get daily report", { error: message });
|
|
2131
|
+
core.sendError(res, message, 500);
|
|
2132
|
+
}
|
|
2133
|
+
});
|
|
2134
|
+
router.get("/weekly-trends", async (req, res) => {
|
|
2135
|
+
try {
|
|
2136
|
+
const weeks = parseInt(req.query["weeks"] || "4", 10);
|
|
2137
|
+
const result = await analytics.getWeeklyTrends(weeks);
|
|
2138
|
+
core.sendSuccess(res, result);
|
|
2139
|
+
} catch (error) {
|
|
2140
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2141
|
+
logger.error("Failed to get weekly trends", { error: message });
|
|
2142
|
+
core.sendError(res, message, 500);
|
|
2143
|
+
}
|
|
2144
|
+
});
|
|
2145
|
+
router.get("/overall", async (req, res) => {
|
|
2146
|
+
try {
|
|
2147
|
+
const dateRange = parseDateRange(req.query);
|
|
2148
|
+
const result = await analytics.getOverallReport(dateRange);
|
|
2149
|
+
core.sendSuccess(res, result);
|
|
2150
|
+
} catch (error) {
|
|
2151
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2152
|
+
logger.error("Failed to get overall report", { error: message });
|
|
2153
|
+
core.sendError(res, message, 500);
|
|
2154
|
+
}
|
|
2155
|
+
});
|
|
2156
|
+
return router;
|
|
2157
|
+
}
|
|
2158
|
+
function createSettingsRoutes(settings, exportSvc, logger) {
|
|
2159
|
+
const router = express.Router();
|
|
2160
|
+
router.get("/settings", async (_req, res) => {
|
|
2161
|
+
try {
|
|
2162
|
+
const result = await settings.get();
|
|
2163
|
+
core.sendSuccess(res, result);
|
|
2164
|
+
} catch (error) {
|
|
2165
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2166
|
+
logger.error("Failed to get settings", { error: message });
|
|
2167
|
+
core.sendError(res, message, 500);
|
|
2168
|
+
}
|
|
2169
|
+
});
|
|
2170
|
+
router.put("/settings", async (req, res) => {
|
|
2171
|
+
try {
|
|
2172
|
+
const result = await settings.update(req.body);
|
|
2173
|
+
core.sendSuccess(res, result);
|
|
2174
|
+
} catch (error) {
|
|
2175
|
+
if (error instanceof AlxCallLogError) {
|
|
2176
|
+
core.sendError(res, error.message, 400);
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
2179
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2180
|
+
logger.error("Failed to update settings", { error: message });
|
|
2181
|
+
core.sendError(res, message, 500);
|
|
2182
|
+
}
|
|
2183
|
+
});
|
|
2184
|
+
router.get("/export/calls", async (req, res) => {
|
|
2185
|
+
try {
|
|
2186
|
+
const { format = "json", ...filterParams } = req.query;
|
|
2187
|
+
const filter = {};
|
|
2188
|
+
if (filterParams["pipelineId"]) filter["pipelineId"] = filterParams["pipelineId"];
|
|
2189
|
+
if (filterParams["agentId"]) filter["agentId"] = filterParams["agentId"];
|
|
2190
|
+
if (filterParams["isClosed"] !== void 0) filter["isClosed"] = filterParams["isClosed"] === "true";
|
|
2191
|
+
if (filterParams["from"] || filterParams["to"]) {
|
|
2192
|
+
filter["dateRange"] = { from: filterParams["from"], to: filterParams["to"] };
|
|
2193
|
+
}
|
|
2194
|
+
const result = await exportSvc.exportCallLogs(
|
|
2195
|
+
filter,
|
|
2196
|
+
format
|
|
2197
|
+
);
|
|
2198
|
+
const contentType = format === "csv" ? "text/csv" : "application/json";
|
|
2199
|
+
res.setHeader("Content-Type", contentType);
|
|
2200
|
+
res.send(result);
|
|
2201
|
+
} catch (error) {
|
|
2202
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2203
|
+
logger.error("Failed to export call logs", { error: message });
|
|
2204
|
+
core.sendError(res, message, 500);
|
|
2205
|
+
}
|
|
2206
|
+
});
|
|
2207
|
+
router.get("/export/calls/:id", async (req, res) => {
|
|
2208
|
+
try {
|
|
2209
|
+
const format = req.query["format"] || "json";
|
|
2210
|
+
const result = await exportSvc.exportCallLog(req.params["id"], format);
|
|
2211
|
+
const contentType = format === "csv" ? "text/csv" : "application/json";
|
|
2212
|
+
res.setHeader("Content-Type", contentType);
|
|
2213
|
+
res.send(result);
|
|
2214
|
+
} catch (error) {
|
|
2215
|
+
if (error instanceof AlxCallLogError) {
|
|
2216
|
+
core.sendError(res, error.message, 404);
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2219
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2220
|
+
logger.error("Failed to export call log", { id: req.params["id"], error: message });
|
|
2221
|
+
core.sendError(res, message, 500);
|
|
2222
|
+
}
|
|
2223
|
+
});
|
|
2224
|
+
router.get("/export/pipeline/:id", async (req, res) => {
|
|
2225
|
+
try {
|
|
2226
|
+
const format = req.query["format"] || "json";
|
|
2227
|
+
const dateRange = {
|
|
2228
|
+
from: req.query["from"],
|
|
2229
|
+
to: req.query["to"]
|
|
2230
|
+
};
|
|
2231
|
+
const result = await exportSvc.exportPipelineReport(req.params["id"], dateRange, format);
|
|
2232
|
+
const contentType = format === "csv" ? "text/csv" : "application/json";
|
|
2233
|
+
res.setHeader("Content-Type", contentType);
|
|
2234
|
+
res.send(result);
|
|
2235
|
+
} catch (error) {
|
|
2236
|
+
if (error instanceof AlxCallLogError) {
|
|
2237
|
+
core.sendError(res, error.message, 400);
|
|
2238
|
+
return;
|
|
2239
|
+
}
|
|
2240
|
+
const message = error instanceof Error ? error.message : "Unknown error";
|
|
2241
|
+
logger.error("Failed to export pipeline report", { id: req.params["id"], error: message });
|
|
2242
|
+
core.sendError(res, message, 500);
|
|
2243
|
+
}
|
|
2244
|
+
});
|
|
2245
|
+
return router;
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
// src/routes/index.ts
|
|
2249
|
+
function createRoutes(services, options) {
|
|
2250
|
+
const router = express.Router();
|
|
2251
|
+
const { logger, authenticateRequest } = options;
|
|
2252
|
+
let authMiddleware;
|
|
2253
|
+
if (authenticateRequest) {
|
|
2254
|
+
authMiddleware = async (req, res, next) => {
|
|
2255
|
+
try {
|
|
2256
|
+
const result = await authenticateRequest(req);
|
|
2257
|
+
if (!result) {
|
|
2258
|
+
core.sendError(res, "Unauthorized", 401);
|
|
2259
|
+
return;
|
|
2260
|
+
}
|
|
2261
|
+
req.user = result;
|
|
2262
|
+
next();
|
|
2263
|
+
} catch (error) {
|
|
2264
|
+
const message = error instanceof Error ? error.message : "Authentication failed";
|
|
2265
|
+
core.sendError(res, message, 401);
|
|
2266
|
+
}
|
|
2267
|
+
};
|
|
2268
|
+
}
|
|
2269
|
+
const protectedRouter = express.Router();
|
|
2270
|
+
protectedRouter.use("/pipelines", createPipelineRoutes(services.pipelines, logger));
|
|
2271
|
+
protectedRouter.use("/calls", createCallLogRoutes({ callLogs: services.callLogs, timeline: services.timeline }, logger));
|
|
2272
|
+
protectedRouter.use("/contacts", createContactRoutes({ callLogs: services.callLogs, timeline: services.timeline }, logger));
|
|
2273
|
+
protectedRouter.use("/analytics", createAnalyticsRoutes(services.analytics, logger));
|
|
2274
|
+
protectedRouter.use("/", createSettingsRoutes(services.settings, services.export, logger));
|
|
2275
|
+
if (authMiddleware) {
|
|
2276
|
+
router.use(authMiddleware, protectedRouter);
|
|
2277
|
+
} else {
|
|
2278
|
+
router.use(protectedRouter);
|
|
2279
|
+
}
|
|
2280
|
+
return router;
|
|
2281
|
+
}
|
|
2282
|
+
var FollowUpWorker = class {
|
|
2283
|
+
constructor(deps) {
|
|
2284
|
+
this.deps = deps;
|
|
2285
|
+
}
|
|
2286
|
+
intervalId = null;
|
|
2287
|
+
running = false;
|
|
2288
|
+
start() {
|
|
2289
|
+
if (this.intervalId) return;
|
|
2290
|
+
this.intervalId = setInterval(() => {
|
|
2291
|
+
this.tick().catch((err) => {
|
|
2292
|
+
this.deps.logger.error("Follow-up worker tick failed", {
|
|
2293
|
+
error: err instanceof Error ? err.message : "Unknown error"
|
|
2294
|
+
});
|
|
2295
|
+
});
|
|
2296
|
+
}, this.deps.options.followUpCheckIntervalMs);
|
|
2297
|
+
this.deps.logger.info("Follow-up worker started", {
|
|
2298
|
+
intervalMs: this.deps.options.followUpCheckIntervalMs
|
|
2299
|
+
});
|
|
2300
|
+
}
|
|
2301
|
+
stop() {
|
|
2302
|
+
if (this.intervalId) {
|
|
2303
|
+
clearInterval(this.intervalId);
|
|
2304
|
+
this.intervalId = null;
|
|
2305
|
+
}
|
|
2306
|
+
this.deps.logger.info("Follow-up worker stopped");
|
|
2307
|
+
}
|
|
2308
|
+
async tick() {
|
|
2309
|
+
if (this.running) return;
|
|
2310
|
+
this.running = true;
|
|
2311
|
+
try {
|
|
2312
|
+
const dueCallLogs = await this.deps.CallLog.find({
|
|
2313
|
+
nextFollowUpDate: { $lte: /* @__PURE__ */ new Date() },
|
|
2314
|
+
isClosed: false,
|
|
2315
|
+
followUpNotifiedAt: null
|
|
2316
|
+
});
|
|
2317
|
+
for (const callLog of dueCallLogs) {
|
|
2318
|
+
try {
|
|
2319
|
+
if (this.deps.hooks.onFollowUpDue) {
|
|
2320
|
+
await this.deps.hooks.onFollowUpDue(callLog);
|
|
2321
|
+
}
|
|
2322
|
+
await this.deps.CallLog.findOneAndUpdate(
|
|
2323
|
+
{ _id: callLog._id },
|
|
2324
|
+
{
|
|
2325
|
+
$set: { followUpNotifiedAt: /* @__PURE__ */ new Date() },
|
|
2326
|
+
$push: {
|
|
2327
|
+
timeline: {
|
|
2328
|
+
entryId: crypto5__default.default.randomUUID(),
|
|
2329
|
+
type: callLogTypes.TimelineEntryType.FollowUpCompleted,
|
|
2330
|
+
content: SYSTEM_TIMELINE.FollowUpCompleted,
|
|
2331
|
+
createdAt: /* @__PURE__ */ new Date()
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
);
|
|
2336
|
+
this.deps.logger.info("Follow-up notification fired", {
|
|
2337
|
+
callLogId: callLog.callLogId
|
|
2338
|
+
});
|
|
2339
|
+
} catch (err) {
|
|
2340
|
+
this.deps.logger.error("Failed to process follow-up for call log", {
|
|
2341
|
+
callLogId: callLog.callLogId,
|
|
2342
|
+
error: err instanceof Error ? err.message : "Unknown error"
|
|
2343
|
+
});
|
|
2344
|
+
}
|
|
2345
|
+
}
|
|
2346
|
+
} finally {
|
|
2347
|
+
this.running = false;
|
|
2348
|
+
}
|
|
2349
|
+
}
|
|
2350
|
+
};
|
|
2351
|
+
var CallLogEngineConfigSchema = zod.z.object({
|
|
2352
|
+
db: zod.z.object({
|
|
2353
|
+
connection: zod.z.unknown(),
|
|
2354
|
+
collectionPrefix: zod.z.string().optional()
|
|
2355
|
+
}),
|
|
2356
|
+
logger: zod.z.object({
|
|
2357
|
+
info: zod.z.function(),
|
|
2358
|
+
warn: zod.z.function(),
|
|
2359
|
+
error: zod.z.function(),
|
|
2360
|
+
debug: zod.z.function()
|
|
2361
|
+
}).optional(),
|
|
2362
|
+
agents: zod.z.object({
|
|
2363
|
+
collectionName: zod.z.string().optional(),
|
|
2364
|
+
resolveAgent: zod.z.function().optional()
|
|
2365
|
+
}).optional().default({}),
|
|
2366
|
+
adapters: zod.z.object({
|
|
2367
|
+
lookupContact: zod.z.function(),
|
|
2368
|
+
addContact: zod.z.function().optional(),
|
|
2369
|
+
authenticateAgent: zod.z.function()
|
|
2370
|
+
}),
|
|
2371
|
+
hooks: zod.z.object({
|
|
2372
|
+
onCallCreated: zod.z.function().optional(),
|
|
2373
|
+
onStageChanged: zod.z.function().optional(),
|
|
2374
|
+
onCallClosed: zod.z.function().optional(),
|
|
2375
|
+
onCallAssigned: zod.z.function().optional(),
|
|
2376
|
+
onFollowUpDue: zod.z.function().optional(),
|
|
2377
|
+
onMetric: zod.z.function().optional()
|
|
2378
|
+
}).optional(),
|
|
2379
|
+
options: zod.z.object({
|
|
2380
|
+
maxTimelineEntries: zod.z.number().int().positive().optional(),
|
|
2381
|
+
followUpCheckIntervalMs: zod.z.number().int().positive().optional()
|
|
2382
|
+
}).optional()
|
|
2383
|
+
});
|
|
2384
|
+
function createCallLogEngine(config) {
|
|
2385
|
+
const parseResult = CallLogEngineConfigSchema.safeParse(config);
|
|
2386
|
+
if (!parseResult.success) {
|
|
2387
|
+
const issues = parseResult.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join(", ");
|
|
2388
|
+
throw new Error(`Invalid CallLogEngineConfig: ${issues}`);
|
|
2389
|
+
}
|
|
2390
|
+
const resolvedOptions = {
|
|
2391
|
+
...callLogTypes.DEFAULT_OPTIONS,
|
|
2392
|
+
...config.options
|
|
2393
|
+
};
|
|
2394
|
+
const logger = config.logger ?? core.noopLogger;
|
|
2395
|
+
const conn = config.db.connection;
|
|
2396
|
+
const prefix = config.db.collectionPrefix;
|
|
2397
|
+
const Pipeline = createPipelineModel(conn, prefix);
|
|
2398
|
+
const CallLog = createCallLogModel(conn, prefix);
|
|
2399
|
+
const CallLogSettings = createCallLogSettingsModel(conn, prefix);
|
|
2400
|
+
const settingsService = new SettingsService(CallLogSettings, logger);
|
|
2401
|
+
const pipelineService = new PipelineService(Pipeline, CallLog, logger);
|
|
2402
|
+
const timelineService = new TimelineService(CallLog, logger, resolvedOptions);
|
|
2403
|
+
const callLogService = new CallLogService(
|
|
2404
|
+
CallLog,
|
|
2405
|
+
Pipeline,
|
|
2406
|
+
timelineService,
|
|
2407
|
+
logger,
|
|
2408
|
+
config.hooks ?? {},
|
|
2409
|
+
resolvedOptions
|
|
2410
|
+
);
|
|
2411
|
+
const analyticsService = new AnalyticsService(CallLog, Pipeline, logger, config.agents?.resolveAgent);
|
|
2412
|
+
const exportService = new ExportService(CallLog, analyticsService, logger);
|
|
2413
|
+
const routes = createRoutes(
|
|
2414
|
+
{
|
|
2415
|
+
pipelines: pipelineService,
|
|
2416
|
+
callLogs: callLogService,
|
|
2417
|
+
timeline: timelineService,
|
|
2418
|
+
analytics: analyticsService,
|
|
2419
|
+
settings: settingsService,
|
|
2420
|
+
export: exportService
|
|
2421
|
+
},
|
|
2422
|
+
{
|
|
2423
|
+
authenticateRequest: config.adapters.authenticateAgent ? async (req) => {
|
|
2424
|
+
const expressReq = req;
|
|
2425
|
+
const authHeader = expressReq.headers?.["authorization"];
|
|
2426
|
+
const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : authHeader;
|
|
2427
|
+
if (!token) return null;
|
|
2428
|
+
return config.adapters.authenticateAgent(token);
|
|
2429
|
+
} : void 0,
|
|
2430
|
+
logger
|
|
2431
|
+
}
|
|
2432
|
+
);
|
|
2433
|
+
const followUpWorker = new FollowUpWorker({
|
|
2434
|
+
CallLog,
|
|
2435
|
+
hooks: { onFollowUpDue: config.hooks?.onFollowUpDue },
|
|
2436
|
+
logger,
|
|
2437
|
+
options: resolvedOptions
|
|
2438
|
+
});
|
|
2439
|
+
followUpWorker.start();
|
|
2440
|
+
async function destroy() {
|
|
2441
|
+
followUpWorker.stop();
|
|
2442
|
+
logger.info("CallLogEngine destroyed");
|
|
2443
|
+
}
|
|
2444
|
+
return {
|
|
2445
|
+
pipelines: pipelineService,
|
|
2446
|
+
callLogs: callLogService,
|
|
2447
|
+
timeline: timelineService,
|
|
2448
|
+
analytics: analyticsService,
|
|
2449
|
+
settings: settingsService,
|
|
2450
|
+
export: exportService,
|
|
2451
|
+
routes,
|
|
2452
|
+
models: { Pipeline, CallLog, CallLogSettings },
|
|
2453
|
+
destroy
|
|
2454
|
+
};
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
Object.defineProperty(exports, "DEFAULT_OPTIONS", {
|
|
2458
|
+
enumerable: true,
|
|
2459
|
+
get: function () { return callLogTypes.DEFAULT_OPTIONS; }
|
|
2460
|
+
});
|
|
2461
|
+
exports.AGENT_CALL_DEFAULTS = AGENT_CALL_DEFAULTS;
|
|
2462
|
+
exports.AgentCapacityError = AgentCapacityError;
|
|
2463
|
+
exports.AlxCallLogError = AlxCallLogError;
|
|
2464
|
+
exports.AnalyticsService = AnalyticsService;
|
|
2465
|
+
exports.AuthFailedError = AuthFailedError;
|
|
2466
|
+
exports.CALL_LOG_DEFAULTS = CALL_LOG_DEFAULTS;
|
|
2467
|
+
exports.CallLogClosedError = CallLogClosedError;
|
|
2468
|
+
exports.CallLogNotFoundError = CallLogNotFoundError;
|
|
2469
|
+
exports.CallLogSchema = CallLogSchema;
|
|
2470
|
+
exports.CallLogService = CallLogService;
|
|
2471
|
+
exports.CallLogSettingsSchema = CallLogSettingsSchema;
|
|
2472
|
+
exports.ContactNotFoundError = ContactNotFoundError;
|
|
2473
|
+
exports.ContactRefSchema = ContactRefSchema;
|
|
2474
|
+
exports.ERROR_CODE = ERROR_CODE;
|
|
2475
|
+
exports.ERROR_MESSAGE = ERROR_MESSAGE;
|
|
2476
|
+
exports.ExportService = ExportService;
|
|
2477
|
+
exports.FollowUpWorker = FollowUpWorker;
|
|
2478
|
+
exports.InvalidConfigError = InvalidConfigError;
|
|
2479
|
+
exports.InvalidPipelineError = InvalidPipelineError;
|
|
2480
|
+
exports.PIPELINE_DEFAULTS = PIPELINE_DEFAULTS;
|
|
2481
|
+
exports.PipelineNotFoundError = PipelineNotFoundError;
|
|
2482
|
+
exports.PipelineSchema = PipelineSchema;
|
|
2483
|
+
exports.PipelineService = PipelineService;
|
|
2484
|
+
exports.PipelineStageSchema = PipelineStageSchema;
|
|
2485
|
+
exports.SYSTEM_TIMELINE = SYSTEM_TIMELINE;
|
|
2486
|
+
exports.SYSTEM_TIMELINE_FN = SYSTEM_TIMELINE_FN;
|
|
2487
|
+
exports.SettingsService = SettingsService;
|
|
2488
|
+
exports.StageChangeSchema = StageChangeSchema;
|
|
2489
|
+
exports.StageInUseError = StageInUseError;
|
|
2490
|
+
exports.StageNotFoundError = StageNotFoundError;
|
|
2491
|
+
exports.TimelineEntrySchema = TimelineEntrySchema;
|
|
2492
|
+
exports.TimelineService = TimelineService;
|
|
2493
|
+
exports.createCallLogEngine = createCallLogEngine;
|
|
2494
|
+
exports.createCallLogModel = createCallLogModel;
|
|
2495
|
+
exports.createCallLogSettingsModel = createCallLogSettingsModel;
|
|
2496
|
+
exports.createPipelineModel = createPipelineModel;
|
|
2497
|
+
exports.createRoutes = createRoutes;
|
|
2498
|
+
exports.validatePipelineStages = validatePipelineStages;
|
|
2499
|
+
//# sourceMappingURL=index.cjs.map
|
|
2500
|
+
//# sourceMappingURL=index.cjs.map
|