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

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 +71 -13
  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 +69 -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 +94 -15
  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 +150 -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 +929 -0
  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,91 @@ 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 forceHeldPostConsultControls =
263
+ !hideExitConferenceWhileConsultPending &&
264
+ (postDeclineHeldMainLeg || postConsultCompletedHeldMainLeg);
265
+ const selfOnConsultLeg =
266
+ selfParticipant?.consultState === 'consulting' ||
267
+ selfParticipant?.currentState === 'consulting';
268
+ const showMainLegConferenceControlsDuringConsult =
269
+ currentLeg === 'main' && inConference && consultInProgress && !selfOnConsultLeg;
270
+ const allowHeldMainLegControlsForNonInitiator =
271
+ showMainLegConferenceControlsDuringConsult && !isHydratedConferenceConsultPending;
182
272
 
183
273
  return {
184
274
  // Accept/Decline: Voice tasks in offered state
@@ -198,6 +288,7 @@ function computeVoiceInteractionUIControls(
198
288
  // Hold: visible in connected/held/conference, disabled in conference/consulting
199
289
  hold: (() => {
200
290
  if (!hasFullControls) return DISABLED;
291
+ if (forceHeldPostConsultControls) return VISIBLE_ENABLED;
201
292
  if (consultOwnedBySelf && (isConsulting || hasParallelConsultLeg || consultCallHeld)) {
202
293
  return DISABLED;
203
294
  }
@@ -216,6 +307,7 @@ function computeVoiceInteractionUIControls(
216
307
  mute: (() => {
217
308
  if (!isWebrtc) return DISABLED;
218
309
  if (isWrappingUp) return DISABLED;
310
+ if (currentLeg === 'consult' && !selfInConsultCall) return DISABLED;
219
311
  if (isConsulting) return VISIBLE_ENABLED;
220
312
 
221
313
  if (isConnected || isHeld || isConferencing) {
@@ -229,6 +321,9 @@ function computeVoiceInteractionUIControls(
229
321
 
230
322
  // End: varies by state; during consulting only on main leg (consult held)
231
323
  end: (() => {
324
+ if (allowHeldMainLegControlsForNonInitiator) return VISIBLE_ENABLED;
325
+ if (showMainLegConferenceControlsDuringConsult) return VISIBLE_DISABLED;
326
+ if (isHydratedConferenceConsultPending && currentLeg === 'main') return VISIBLE_DISABLED;
232
327
  if (!config.isEndTaskEnabled) return DISABLED;
233
328
  if (hasParallelConsultLeg) {
234
329
  return isConnected && isEpDnConsult ? VISIBLE_ENABLED : VISIBLE_DISABLED;
@@ -242,6 +337,7 @@ function computeVoiceInteractionUIControls(
242
337
 
243
338
  if (inConference) {
244
339
  if (isConsulted) return DISABLED;
340
+ if (forceHeldPostConsultControls) return VISIBLE_DISABLED;
245
341
 
246
342
  if (consultInProgress) return VISIBLE_DISABLED;
247
343
 
@@ -255,6 +351,11 @@ function computeVoiceInteractionUIControls(
255
351
 
256
352
  // Transfer: connected/held/conference
257
353
  transfer: (() => {
354
+ if (isHydratedConferenceConsultPending) return VISIBLE_DISABLED;
355
+ if (isConferenceConsultTransferContext && currentLeg === 'main' && isCurrentLegActive) {
356
+ return DISABLED;
357
+ }
358
+ if (inConference && isConsulting && consultInitiator) return DISABLED;
258
359
  if (hasParallelConsultLeg) {
259
360
  if (!customerPresent) return DISABLED;
260
361
  if (state === TaskState.CONNECTED) return VISIBLE_ENABLED;
@@ -282,6 +383,7 @@ function computeVoiceInteractionUIControls(
282
383
  consult: (() => {
283
384
  const isConnectedOrHeld = state === TaskState.CONNECTED || state === TaskState.HELD;
284
385
 
386
+ if (inConference && nonOwnerPostConsultCompletedHeldMainLeg) return VISIBLE_DISABLED;
285
387
  if (hasParallelConsultLeg) return DISABLED;
286
388
  if (!hasFullControls || !(isConnectedOrHeld || inConference)) {
287
389
  return DISABLED;
@@ -293,7 +395,11 @@ function computeVoiceInteractionUIControls(
293
395
  if (participantCount <= 1) return VISIBLE_DISABLED;
294
396
  // Real conference: consult enabled if conditions met
295
397
  const canFromConference =
296
- !maxParticipants && customerPresent && !consultInProgress && !isConsulting;
398
+ !maxParticipants &&
399
+ customerPresent &&
400
+ !consultInProgress &&
401
+ !otherAgentConsultInProgress &&
402
+ !isConsulting;
297
403
 
298
404
  return {isVisible: true, isEnabled: canFromConference};
299
405
  }
@@ -339,6 +445,7 @@ function computeVoiceInteractionUIControls(
339
445
  // Conference: during consulting, enabled on both legs when agent joined
340
446
  // Label changes based on leg: "Conference" on main leg, "Merge" on consult leg
341
447
  conference: (() => {
448
+ if (isHydratedConferenceConsultPending && currentLeg === 'main') return VISIBLE_DISABLED;
342
449
  if (hasParallelConsultLeg) {
343
450
  if (!customerPresent) return DISABLED;
344
451
  if (state === TaskState.CONNECTED) {
@@ -361,9 +468,15 @@ function computeVoiceInteractionUIControls(
361
468
 
362
469
  // ExitConference: in conference with multiple agents in main call
363
470
  exitConference: (() => {
471
+ if (hideExitConferenceWhileConsultPending) return DISABLED;
472
+ if (allowHeldMainLegControlsForNonInitiator) return VISIBLE_ENABLED;
473
+ if (showMainLegConferenceControlsDuringConsult) return VISIBLE_DISABLED;
474
+ if (hideExitConferenceDuringActiveConsultFromConference) return DISABLED;
475
+ if (forceHeldPostConsultControls) return VISIBLE_DISABLED;
364
476
  if (isConsulted && !isConferencing) return DISABLED;
365
477
  if (!inConference) return DISABLED;
366
478
  if (participantCount <= 1) return DISABLED;
479
+ if (consultInProgress) return VISIBLE_DISABLED;
367
480
  const consultingFromConference = consultInitiator && isConsulting && conferenceFromBackend;
368
481
 
369
482
  return consultingFromConference ? VISIBLE_DISABLED : VISIBLE_ENABLED;
@@ -371,8 +484,19 @@ function computeVoiceInteractionUIControls(
371
484
 
372
485
  // TransferConference: in conference with active consult, owner consulting from conference
373
486
  transferConference: (() => {
374
- if (hasParallelConsultLeg || consultLegOnHold) return DISABLED;
375
- if (!inConference || !isConsulting) return DISABLED;
487
+ if (isConferenceConsultTransferContext && !isCurrentLegActive) return VISIBLE_DISABLED;
488
+ const consultLegTransferAvailable =
489
+ currentLeg === 'consult' && inConference && consultInitiator && hasConsultMedia;
490
+ const selfConsultingOnParticipantState = selfParticipant?.consultState === 'consulting';
491
+ const conferenceTransferAvailable =
492
+ consultLegTransferAvailable ||
493
+ (inConference &&
494
+ consultInitiator &&
495
+ hasConsultMedia &&
496
+ (isConsulting || consultInProgress || selfConsultingOnParticipantState));
497
+ if (consultLegOnHold) return DISABLED;
498
+ if (hasParallelConsultLeg && !conferenceTransferAvailable) return DISABLED;
499
+ if (!conferenceTransferAvailable && (!inConference || !isConsulting)) return DISABLED;
376
500
  if (!consultInitiator || isConsulted) return DISABLED;
377
501
 
378
502
  return isConsultDestinationReady ? VISIBLE_ENABLED : VISIBLE_DISABLED;
@@ -380,6 +504,7 @@ function computeVoiceInteractionUIControls(
380
504
 
381
505
  // MergeToConference: mirrors conference control, enabled on both legs
382
506
  mergeToConference: (() => {
507
+ if (isHydratedConferenceConsultPending && currentLeg === 'consult') return VISIBLE_DISABLED;
383
508
  if (!isConsulting || !consultInitiator) return DISABLED;
384
509
  if (!customerPresent) return VISIBLE_DISABLED;
385
510
  if (consultLegOnHold) return VISIBLE_DISABLED;
@@ -389,6 +514,7 @@ function computeVoiceInteractionUIControls(
389
514
 
390
515
  // Switch: visible only on the currently active leg
391
516
  switch: (() => {
517
+ if (isHydratedConferenceConsultPending && currentLeg === 'consult') return VISIBLE_DISABLED;
392
518
  if (!customerPresent && hasParallelConsultLeg) return DISABLED;
393
519
  if (currentLeg === 'consult') {
394
520
  if (!isConsulting || !consultInitiator || consultCallHeld) return DISABLED;
@@ -472,10 +598,18 @@ function getVoiceLegState(
472
598
  taskData?.consultMediaResourceId ||
473
599
  Object.values(interaction?.media ?? {}).some((media: any) => media?.mType === 'consult')
474
600
  );
601
+ const selfParticipant = selfAgentId ? interaction?.participants?.[selfAgentId] : null;
602
+ const selfConsultPendingOnConsultMedia =
603
+ selfParticipant?.consultState === 'consultInitiated' &&
604
+ !taskData?.isConsulted &&
605
+ hasConsultMedia;
606
+ const selfConsultingOnConsultMedia =
607
+ selfParticipant?.consultState === 'consulting' && hasConsultMedia;
475
608
  const hasConsultLeg = Boolean(
476
- consultOwnedBySelf &&
477
- !taskData?.isConsulted &&
478
- !interaction?.isTerminated &&
609
+ !interaction?.isTerminated &&
610
+ ((consultOwnedBySelf && !taskData?.isConsulted) ||
611
+ selfConsultingOnConsultMedia ||
612
+ selfConsultPendingOnConsultMedia) &&
479
613
  (consultInProgress || isConsultingState || context.consultCallHeld || hasConsultMedia)
480
614
  );
481
615
 
@@ -488,10 +622,17 @@ function getVoiceLegState(
488
622
  };
489
623
  }
490
624
 
625
+ let mainState = TaskState.HELD;
626
+ if (currentState === TaskState.CONFERENCING) {
627
+ mainState = TaskState.CONFERENCING;
628
+ } else if (context.consultCallHeld) {
629
+ mainState = TaskState.CONNECTED;
630
+ }
631
+
491
632
  return {
492
633
  hasConsultLeg: true,
493
634
  activeLeg: context.consultCallHeld ? 'main' : 'consult',
494
- mainState: context.consultCallHeld ? TaskState.CONNECTED : TaskState.HELD,
635
+ mainState,
495
636
  consultState: isConsultingState ? currentState : TaskState.CONSULTING,
496
637
  };
497
638
  }
@@ -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'};