@webex/contact-center 3.12.0-task-refactor.5 → 3.12.0-task-refactor.7

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.
Files changed (30) hide show
  1. package/dist/services/task/TaskUtils.js +8 -6
  2. package/dist/services/task/TaskUtils.js.map +1 -1
  3. package/dist/services/task/state-machine/TaskStateMachine.js +72 -14
  4. package/dist/services/task/state-machine/TaskStateMachine.js.map +1 -1
  5. package/dist/services/task/state-machine/actions.js +85 -13
  6. package/dist/services/task/state-machine/actions.js.map +1 -1
  7. package/dist/services/task/state-machine/guards.js +35 -0
  8. package/dist/services/task/state-machine/guards.js.map +1 -1
  9. package/dist/services/task/state-machine/uiControlsComputer.js +72 -7
  10. package/dist/services/task/state-machine/uiControlsComputer.js.map +1 -1
  11. package/dist/services/task/voice/Voice.js +1 -1
  12. package/dist/services/task/voice/Voice.js.map +1 -1
  13. package/dist/types/services/task/state-machine/TaskStateMachine.d.ts +60 -8
  14. package/dist/types/services/task/state-machine/guards.d.ts +5 -0
  15. package/dist/webex.js +1 -1
  16. package/package.json +1 -1
  17. package/src/services/task/TaskUtils.ts +8 -6
  18. package/src/services/task/state-machine/TaskStateMachine.ts +95 -16
  19. package/src/services/task/state-machine/actions.ts +148 -24
  20. package/src/services/task/state-machine/guards.ts +46 -0
  21. package/src/services/task/state-machine/uiControlsComputer.ts +163 -9
  22. package/src/services/task/voice/Voice.ts +4 -1
  23. package/test/unit/spec/services/WebCallingService.ts +7 -1
  24. package/test/unit/spec/services/task/TaskUtils.ts +16 -0
  25. package/test/unit/spec/services/task/state-machine/TaskStateMachine.ts +512 -0
  26. package/test/unit/spec/services/task/state-machine/guards.ts +88 -0
  27. package/test/unit/spec/services/task/state-machine/uiControlsComputer.ts +1000 -7
  28. package/test/unit/spec/services/task/voice/Voice.ts +20 -0
  29. package/umd/contact-center.min.js +2 -2
  30. package/umd/contact-center.min.js.map +1 -1
@@ -144,16 +144,27 @@ function computeVoiceInteractionUIControls(
144
144
  Boolean(selfAgentId) &&
145
145
  Boolean(mainCallId) &&
146
146
  Boolean(interaction?.media?.[mainCallId]?.participants?.includes(selfAgentId as string));
147
+ const consultMedia = Object.values(interaction?.media ?? {}).find(
148
+ (media: any) =>
149
+ media?.mediaResourceId === taskData?.consultMediaResourceId || media?.mType === 'consult'
150
+ ) as {participants?: string[]} | undefined;
151
+ const selfParticipant = selfAgentId ? interaction?.participants?.[selfAgentId] : null;
152
+ const selfInConsultCall =
153
+ Boolean(selfAgentId) && Boolean(consultMedia?.participants?.includes(selfAgentId as string));
147
154
  const conferenceActive = isConferencing || conferenceFromBackend || consultFromConference;
148
155
  // Treat consult initiator as "in conference" even if mainCall participant list lags while consulting.
149
156
  const inConference = conferenceActive && (isConferencing || selfInMainCall || consultInitiator);
150
157
 
151
158
  // Check if this is a consulted agent (must be after isConsulting is computed).
152
- const isSoleAgentOnCall = participantCount <= 1 && !isConsulting && !inConference;
159
+ const isSoleAgentOnCall =
160
+ participantCount <= 1 && selfInMainCall && !isConsulting && !inConference;
153
161
  const isConsulted =
154
162
  inConference || isSoleAgentOnCall
155
163
  ? false
156
- : getIsConsultedAgentForControls(taskData, context, isConsulting);
164
+ : getIsConsultedAgentForControls(taskData, context, isConsulting) ||
165
+ (!consultInitiator &&
166
+ (selfParticipant?.isConsulted === true ||
167
+ selfParticipant?.consultState === 'consulting'));
157
168
 
158
169
  // Active call = can perform call operations
159
170
  const isActive =
@@ -173,12 +184,102 @@ function computeVoiceInteractionUIControls(
173
184
  taskData?.consultMediaResourceId ||
174
185
  Object.values(interaction?.media ?? {}).some((media: any) => media?.mType === 'consult')
175
186
  );
187
+ const selfConsultPendingOnConsultMedia =
188
+ selfParticipant?.consultState === 'consultInitiated' &&
189
+ !taskData?.isConsulted &&
190
+ hasConsultMedia;
191
+ const ownerParticipant = interaction?.owner
192
+ ? interaction.participants?.[interaction.owner]
193
+ : undefined;
194
+ const otherAgentConsultInProgress = Boolean(
195
+ interaction?.participants &&
196
+ Object.values(interaction.participants).some((participant: any) => {
197
+ if (!participant || participant.hasLeft) return false;
198
+ if (participant.id === selfAgentId) return false;
199
+ if (participant.pType !== 'AGENT') return false;
200
+
201
+ return (
202
+ participant.consultState === 'consultInitiated' ||
203
+ participant.consultState === 'consultReserved' ||
204
+ participant.consultState === 'consulting' ||
205
+ participant.currentState === 'consulting'
206
+ );
207
+ })
208
+ );
209
+ const isHydratedConferenceConsultPending =
210
+ inConference && selfConsultPendingOnConsultMedia && !consultDestinationAgentJoined;
176
211
  const hasParallelConsultLeg =
177
212
  consultOwnedBySelf &&
178
213
  !isConsulting &&
179
214
  !isConsulted &&
180
215
  (consultInProgress || consultCallHeld || hasConsultMedia);
216
+ const activeLegForConferenceConsult = consultCallHeld ? 'main' : 'consult';
217
+ const isCurrentLegActive = currentLeg === activeLegForConferenceConsult;
218
+ const isConferenceConsultTransferContext =
219
+ inConference && consultInitiator && hasConsultMedia && isConsultDestinationReady;
181
220
  const consultLegOnHold = isConsulting && consultCallHeld;
221
+ const callProcessingDetails = interaction?.callProcessingDetails as
222
+ | {conferenceHoldParticipant?: boolean | string}
223
+ | undefined;
224
+ const conferenceHoldParticipant =
225
+ callProcessingDetails?.conferenceHoldParticipant === true ||
226
+ callProcessingDetails?.conferenceHoldParticipant === 'true';
227
+ const postDeclineHeldMainLeg =
228
+ consultInitiator &&
229
+ !consultDestinationAgentJoined &&
230
+ isHeld &&
231
+ inConference &&
232
+ conferenceHoldParticipant;
233
+ const postConsultCompletedHeldMainLeg =
234
+ selfParticipant?.consultState === 'consultCompleted' && isHeld && inConference && !isConsulting;
235
+ const nonOwnerPostConsultCompletedHeldMainLeg =
236
+ isHeld &&
237
+ inConference &&
238
+ !isConsulting &&
239
+ !consultInitiator &&
240
+ Boolean(selfAgentId) &&
241
+ Boolean(interaction?.owner) &&
242
+ selfAgentId !== interaction?.owner &&
243
+ ownerParticipant?.consultState === 'consultCompleted';
244
+ const isConsultPendingBeforeJoin =
245
+ selfParticipant?.consultState === 'consultInitiated' && !consultDestinationAgentJoined;
246
+ const hideExitConferenceWhileConsultPending =
247
+ currentLeg === 'main' &&
248
+ inConference &&
249
+ isConsultPendingBeforeJoin &&
250
+ (consultFromConference ||
251
+ consultInitiator ||
252
+ taskData?.type === 'AgentConsultCreated' ||
253
+ consultInProgress ||
254
+ isConsulting);
255
+ const hideExitConferenceDuringActiveConsultFromConference =
256
+ inConference &&
257
+ consultInitiator &&
258
+ consultDestinationAgentJoined &&
259
+ (isConsulting ||
260
+ taskData?.type === 'AgentConsulting' ||
261
+ selfParticipant?.consultState === 'consulting');
262
+ const hideExitConferenceOnMainLegForEpDnConsultFromConference =
263
+ currentLeg === 'main' &&
264
+ inConference &&
265
+ consultFromConference &&
266
+ consultInitiator &&
267
+ (isConsulting ||
268
+ consultInProgress ||
269
+ taskData?.type === 'AgentConsultCreated' ||
270
+ taskData?.type === 'AgentConsulting' ||
271
+ selfParticipant?.consultState === 'consultInitiated' ||
272
+ selfParticipant?.consultState === 'consulting');
273
+ const forceHeldPostConsultControls =
274
+ !hideExitConferenceWhileConsultPending &&
275
+ (postDeclineHeldMainLeg || postConsultCompletedHeldMainLeg);
276
+ const selfOnConsultLeg =
277
+ selfParticipant?.consultState === 'consulting' ||
278
+ selfParticipant?.currentState === 'consulting';
279
+ const showMainLegConferenceControlsDuringConsult =
280
+ currentLeg === 'main' && inConference && consultInProgress && !selfOnConsultLeg;
281
+ const allowHeldMainLegControlsForNonInitiator =
282
+ showMainLegConferenceControlsDuringConsult && !isHydratedConferenceConsultPending;
182
283
 
183
284
  return {
184
285
  // Accept/Decline: Voice tasks in offered state
@@ -198,6 +299,7 @@ function computeVoiceInteractionUIControls(
198
299
  // Hold: visible in connected/held/conference, disabled in conference/consulting
199
300
  hold: (() => {
200
301
  if (!hasFullControls) return DISABLED;
302
+ if (forceHeldPostConsultControls) return VISIBLE_ENABLED;
201
303
  if (consultOwnedBySelf && (isConsulting || hasParallelConsultLeg || consultCallHeld)) {
202
304
  return DISABLED;
203
305
  }
@@ -216,6 +318,8 @@ function computeVoiceInteractionUIControls(
216
318
  mute: (() => {
217
319
  if (!isWebrtc) return DISABLED;
218
320
  if (isWrappingUp) return DISABLED;
321
+ if (currentLeg === 'consult' && !selfInConsultCall) return DISABLED;
322
+ if ((isConsulting || hasParallelConsultLeg) && !isCurrentLegActive) return VISIBLE_DISABLED;
219
323
  if (isConsulting) return VISIBLE_ENABLED;
220
324
 
221
325
  if (isConnected || isHeld || isConferencing) {
@@ -229,6 +333,9 @@ function computeVoiceInteractionUIControls(
229
333
 
230
334
  // End: varies by state; during consulting only on main leg (consult held)
231
335
  end: (() => {
336
+ if (allowHeldMainLegControlsForNonInitiator) return VISIBLE_ENABLED;
337
+ if (showMainLegConferenceControlsDuringConsult) return VISIBLE_DISABLED;
338
+ if (isHydratedConferenceConsultPending && currentLeg === 'main') return VISIBLE_DISABLED;
232
339
  if (!config.isEndTaskEnabled) return DISABLED;
233
340
  if (hasParallelConsultLeg) {
234
341
  return isConnected && isEpDnConsult ? VISIBLE_ENABLED : VISIBLE_DISABLED;
@@ -242,6 +349,7 @@ function computeVoiceInteractionUIControls(
242
349
 
243
350
  if (inConference) {
244
351
  if (isConsulted) return DISABLED;
352
+ if (forceHeldPostConsultControls) return VISIBLE_DISABLED;
245
353
 
246
354
  if (consultInProgress) return VISIBLE_DISABLED;
247
355
 
@@ -255,6 +363,11 @@ function computeVoiceInteractionUIControls(
255
363
 
256
364
  // Transfer: connected/held/conference
257
365
  transfer: (() => {
366
+ if (isHydratedConferenceConsultPending) return VISIBLE_DISABLED;
367
+ if (isConferenceConsultTransferContext && currentLeg === 'main' && isCurrentLegActive) {
368
+ return DISABLED;
369
+ }
370
+ if (inConference && isConsulting && consultInitiator) return DISABLED;
258
371
  if (hasParallelConsultLeg) {
259
372
  if (!customerPresent) return DISABLED;
260
373
  if (state === TaskState.CONNECTED) return VISIBLE_ENABLED;
@@ -282,6 +395,7 @@ function computeVoiceInteractionUIControls(
282
395
  consult: (() => {
283
396
  const isConnectedOrHeld = state === TaskState.CONNECTED || state === TaskState.HELD;
284
397
 
398
+ if (inConference && nonOwnerPostConsultCompletedHeldMainLeg) return VISIBLE_DISABLED;
285
399
  if (hasParallelConsultLeg) return DISABLED;
286
400
  if (!hasFullControls || !(isConnectedOrHeld || inConference)) {
287
401
  return DISABLED;
@@ -293,7 +407,11 @@ function computeVoiceInteractionUIControls(
293
407
  if (participantCount <= 1) return VISIBLE_DISABLED;
294
408
  // Real conference: consult enabled if conditions met
295
409
  const canFromConference =
296
- !maxParticipants && customerPresent && !consultInProgress && !isConsulting;
410
+ !maxParticipants &&
411
+ customerPresent &&
412
+ !consultInProgress &&
413
+ !otherAgentConsultInProgress &&
414
+ !isConsulting;
297
415
 
298
416
  return {isVisible: true, isEnabled: canFromConference};
299
417
  }
@@ -339,6 +457,7 @@ function computeVoiceInteractionUIControls(
339
457
  // Conference: during consulting, enabled on both legs when agent joined
340
458
  // Label changes based on leg: "Conference" on main leg, "Merge" on consult leg
341
459
  conference: (() => {
460
+ if (isHydratedConferenceConsultPending && currentLeg === 'main') return VISIBLE_DISABLED;
342
461
  if (hasParallelConsultLeg) {
343
462
  if (!customerPresent) return DISABLED;
344
463
  if (state === TaskState.CONNECTED) {
@@ -361,9 +480,16 @@ function computeVoiceInteractionUIControls(
361
480
 
362
481
  // ExitConference: in conference with multiple agents in main call
363
482
  exitConference: (() => {
483
+ if (hideExitConferenceWhileConsultPending) return DISABLED;
484
+ if (hideExitConferenceOnMainLegForEpDnConsultFromConference) return DISABLED;
485
+ if (allowHeldMainLegControlsForNonInitiator) return VISIBLE_ENABLED;
486
+ if (showMainLegConferenceControlsDuringConsult) return VISIBLE_DISABLED;
487
+ if (hideExitConferenceDuringActiveConsultFromConference) return DISABLED;
488
+ if (forceHeldPostConsultControls) return VISIBLE_DISABLED;
364
489
  if (isConsulted && !isConferencing) return DISABLED;
365
490
  if (!inConference) return DISABLED;
366
491
  if (participantCount <= 1) return DISABLED;
492
+ if (consultInProgress) return VISIBLE_DISABLED;
367
493
  const consultingFromConference = consultInitiator && isConsulting && conferenceFromBackend;
368
494
 
369
495
  return consultingFromConference ? VISIBLE_DISABLED : VISIBLE_ENABLED;
@@ -371,8 +497,19 @@ function computeVoiceInteractionUIControls(
371
497
 
372
498
  // TransferConference: in conference with active consult, owner consulting from conference
373
499
  transferConference: (() => {
374
- if (hasParallelConsultLeg || consultLegOnHold) return DISABLED;
375
- if (!inConference || !isConsulting) return DISABLED;
500
+ if (isConferenceConsultTransferContext && !isCurrentLegActive) return VISIBLE_DISABLED;
501
+ const consultLegTransferAvailable =
502
+ currentLeg === 'consult' && inConference && consultInitiator && hasConsultMedia;
503
+ const selfConsultingOnParticipantState = selfParticipant?.consultState === 'consulting';
504
+ const conferenceTransferAvailable =
505
+ consultLegTransferAvailable ||
506
+ (inConference &&
507
+ consultInitiator &&
508
+ hasConsultMedia &&
509
+ (isConsulting || consultInProgress || selfConsultingOnParticipantState));
510
+ if (consultLegOnHold) return DISABLED;
511
+ if (hasParallelConsultLeg && !conferenceTransferAvailable) return DISABLED;
512
+ if (!conferenceTransferAvailable && (!inConference || !isConsulting)) return DISABLED;
376
513
  if (!consultInitiator || isConsulted) return DISABLED;
377
514
 
378
515
  return isConsultDestinationReady ? VISIBLE_ENABLED : VISIBLE_DISABLED;
@@ -380,6 +517,7 @@ function computeVoiceInteractionUIControls(
380
517
 
381
518
  // MergeToConference: mirrors conference control, enabled on both legs
382
519
  mergeToConference: (() => {
520
+ if (isHydratedConferenceConsultPending && currentLeg === 'consult') return VISIBLE_DISABLED;
383
521
  if (!isConsulting || !consultInitiator) return DISABLED;
384
522
  if (!customerPresent) return VISIBLE_DISABLED;
385
523
  if (consultLegOnHold) return VISIBLE_DISABLED;
@@ -389,6 +527,7 @@ function computeVoiceInteractionUIControls(
389
527
 
390
528
  // Switch: visible only on the currently active leg
391
529
  switch: (() => {
530
+ if (isHydratedConferenceConsultPending && currentLeg === 'consult') return VISIBLE_DISABLED;
392
531
  if (!customerPresent && hasParallelConsultLeg) return DISABLED;
393
532
  if (currentLeg === 'consult') {
394
533
  if (!isConsulting || !consultInitiator || consultCallHeld) return DISABLED;
@@ -472,10 +611,18 @@ function getVoiceLegState(
472
611
  taskData?.consultMediaResourceId ||
473
612
  Object.values(interaction?.media ?? {}).some((media: any) => media?.mType === 'consult')
474
613
  );
614
+ const selfParticipant = selfAgentId ? interaction?.participants?.[selfAgentId] : null;
615
+ const selfConsultPendingOnConsultMedia =
616
+ selfParticipant?.consultState === 'consultInitiated' &&
617
+ !taskData?.isConsulted &&
618
+ hasConsultMedia;
619
+ const selfConsultingOnConsultMedia =
620
+ selfParticipant?.consultState === 'consulting' && hasConsultMedia;
475
621
  const hasConsultLeg = Boolean(
476
- consultOwnedBySelf &&
477
- !taskData?.isConsulted &&
478
- !interaction?.isTerminated &&
622
+ !interaction?.isTerminated &&
623
+ ((consultOwnedBySelf && !taskData?.isConsulted) ||
624
+ selfConsultingOnConsultMedia ||
625
+ selfConsultPendingOnConsultMedia) &&
479
626
  (consultInProgress || isConsultingState || context.consultCallHeld || hasConsultMedia)
480
627
  );
481
628
 
@@ -488,10 +635,17 @@ function getVoiceLegState(
488
635
  };
489
636
  }
490
637
 
638
+ let mainState = TaskState.HELD;
639
+ if (currentState === TaskState.CONFERENCING) {
640
+ mainState = TaskState.CONFERENCING;
641
+ } else if (context.consultCallHeld) {
642
+ mainState = TaskState.CONNECTED;
643
+ }
644
+
491
645
  return {
492
646
  hasConsultLeg: true,
493
647
  activeLeg: context.consultCallHeld ? 'main' : 'consult',
494
- mainState: context.consultCallHeld ? TaskState.CONNECTED : TaskState.HELD,
648
+ mainState,
495
649
  consultState: isConsultingState ? currentState : TaskState.CONSULTING,
496
650
  };
497
651
  }
@@ -150,7 +150,10 @@ export default class Voice extends Task implements IVoice {
150
150
  });
151
151
  throw error;
152
152
  }
153
- } else if (!state.matches(TaskState.HELD)) {
153
+ } else if (
154
+ !state.matches(TaskState.HELD) &&
155
+ !(state.matches(TaskState.CONFERENCING) && mediaHoldState === true)
156
+ ) {
154
157
  const error = new Error(`Cannot resume call in current state: ${currentState}`);
155
158
  LoggerProxy.error('Resume operation not allowed', {
156
159
  module: CC_FILE,
@@ -200,11 +200,17 @@ describe('WebCallingService', () => {
200
200
 
201
201
  it('should reject if registration times out', async () => {
202
202
  line = callingClient.getLines().line1 as ILine;
203
+ jest.spyOn(global, 'setTimeout').mockImplementation(((handler: TimerHandler) => {
204
+ if (typeof handler === 'function') {
205
+ handler();
206
+ }
207
+ return 0 as unknown as NodeJS.Timeout;
208
+ }) as typeof setTimeout);
203
209
 
204
210
  const promise = webRTCCalling.registerWebCallingLine();
205
211
 
206
212
  await expect(promise).rejects.toThrow('WebCallingService Registration timed out');
207
- }, 20003); // Increased timeout to 20 seconds
213
+ });
208
214
 
209
215
  it('should handle incoming calls', async () => {
210
216
  line = callingClient.getLines().line1 as ILine;
@@ -450,6 +450,14 @@ describe('TaskUtils', () => {
450
450
  expect(getIsCustomerInCall(interaction, interactionId)).toBe(true);
451
451
  });
452
452
 
453
+ it('getIsCustomerInCall returns false when participants map is missing', () => {
454
+ const interaction = createInteraction(
455
+ {[interactionId]: {mType: 'mainCall', participants: ['c1']}},
456
+ undefined
457
+ );
458
+ expect(getIsCustomerInCall(interaction, interactionId)).toBe(false);
459
+ });
460
+
453
461
  it('getConferenceParticipantsCount counts active agents only', () => {
454
462
  const interaction = createInteraction(
455
463
  {[interactionId]: {mType: 'mainCall', participants: ['a1', 'a2', 'c1']}},
@@ -458,6 +466,14 @@ describe('TaskUtils', () => {
458
466
  expect(getConferenceParticipantsCount(interaction, interactionId)).toBe(2);
459
467
  });
460
468
 
469
+ it('getConferenceParticipantsCount returns 0 when participants map is missing', () => {
470
+ const interaction = createInteraction(
471
+ {[interactionId]: {mType: 'mainCall', participants: ['a1', 'a2', 'c1']}},
472
+ undefined
473
+ );
474
+ expect(getConferenceParticipantsCount(interaction, interactionId)).toBe(0);
475
+ });
476
+
461
477
  it('isSecondaryAgent returns true for consult with parentInteractionId', () => {
462
478
  const interaction = createInteraction();
463
479
  interaction.callProcessingDetails = {relationshipType: 'consult', parentInteractionId: 'parent-456'};