diff --git a/packages/contact-center/ai-docs/migration/store-event-wiring-migration.md b/packages/contact-center/ai-docs/migration/store-event-wiring-migration.md new file mode 100644 index 000000000..08ac77fb1 --- /dev/null +++ b/packages/contact-center/ai-docs/migration/store-event-wiring-migration.md @@ -0,0 +1,446 @@ +# Store Event Wiring Migration + +## Summary + +The store's `storeEventsWrapper.ts` currently registers 27 individual task event handlers via `registerTaskEventListeners()` that manually call `refreshTaskList()` and update observables. A companion method `handleTaskRemove()` unregisters them. With the SDK task-refactor, the SDK handles state transitions internally via a state machine. The core migration is: + +1. **Switch event names** — use SDK `TASK_EVENTS` enum (delete local copy) +2. **Keep `refreshTaskList()`** — the store does not observe `task.data` directly; `refreshTaskList()` is needed so the store re-syncs observables and the UI re-renders +3. **Add `TASK_UI_CONTROLS_UPDATED`** subscription to trigger widget re-renders +4. **Replace `isDeclineButtonEnabled`** store property with `task.uiControls.decline.isEnabled` +5. **Fix `TASK_CONSULT_END` wiring** — wire the existing (dead) `handleConsultEnd` method + +--- + +## Files to Modify + +| File | Action | +|------|--------| +| `store/src/storeEventsWrapper.ts` | Update event names to SDK names. Keep `refreshTaskList()` in existing handlers. Add `TASK_UI_CONTROLS_UPDATED` listener. Wire `handleConsultEnd` to `TASK_CONSULT_END` (fix dead code). Remove `setIsDeclineButtonEnabled` from `handleAutoAnswer`. | +| `store/src/store.ts` | No changes expected (observables stay). Mark `isDeclineButtonEnabled` for removal once widget layer uses `uiControls.decline.isEnabled`. | +| `store/src/store.types.ts` | Delete local `TASK_EVENTS` enum; import from SDK: `import { TASK_EVENTS } from '@webex/contact-center';`. | +| **Task-layer consumers of `TASK_EVENTS`** | **Must be updated in the same step** so that removing the store's local enum does not break the build. `task/src/helper.ts` imports `TASK_EVENTS` from `@webex/cc-store` and uses legacy names: `AGENT_WRAPPEDUP`, `CONTACT_RECORDING_PAUSED`, `CONTACT_RECORDING_RESUMED`. Replace with SDK names: `TASK_WRAPPEDUP`, `TASK_RECORDING_PAUSED`, `TASK_RECORDING_RESUMED`. | +| `store/tests/*` | Update tests for renamed events, new `TASK_UI_CONTROLS_UPDATED` handler, `handleConsultEnd` wiring fix, `handleAutoAnswer` change | + +--- + +## SDK Event Reference + +The SDK exports `TASK_EVENTS` from its package entry point (`packages/@webex/contact-center/src/index.ts`): +```typescript +export {TASK_EVENTS} from './services/task/types'; +export type {TASK_EVENTS as TaskEvents} from './services/task/types'; +``` + +**Action:** Delete the local `TASK_EVENTS` enum from `store/src/store.types.ts` and import from SDK: `import { TASK_EVENTS } from '@webex/contact-center';`. If the widgets repo depends on an older SDK that does not re-export `TASK_EVENTS`, keep the local enum and align event string values until the dependency is updated. + +### Events Emitted by SDK (complete list) + +Sourced from the SDK task-refactor branch. Events are emitted from Task (lifecycle) and Voice (media/telephony) layers. + +**Task lifecycle events (from Task.ts):** + +| SDK Event | String Value | +|-----------|--------------| +| `TASK_INCOMING` | `'task:incoming'` | +| `TASK_HYDRATE` | `'task:hydrate'` | +| `TASK_OFFER_CONTACT` | `'task:offerContact'` | +| `TASK_ASSIGNED` | `'task:assigned'` | +| `TASK_END` | `'task:end'` | +| `TASK_OFFER_CONSULT` | `'task:offerConsult'` | +| `TASK_CONSULT_CREATED` | `'task:consultCreated'` | +| `TASK_CONSULT_ACCEPTED` | `'task:consultAccepted'` | +| `TASK_CONSULT_END` | `'task:consultEnd'` | +| `TASK_CONSULT_QUEUE_CANCELLED` | `'task:consultQueueCancelled'` | +| `TASK_CONSULT_QUEUE_FAILED` | `'task:consultQueueFailed'` | +| `TASK_WRAPPEDUP` | `'task:wrappedup'` | + +**Voice / media events (from Voice.ts):** + +| SDK Event | String Value | +|-----------|--------------| +| `TASK_HOLD` | `'task:hold'` | +| `TASK_RESUME` | `'task:resume'` | +| `TASK_RECORDING_STARTED` | `'task:recordingStarted'` | +| `TASK_RECORDING_PAUSED` | `'task:recordingPaused'` | +| `TASK_RECORDING_PAUSE_FAILED` | `'task:recordingPauseFailed'` | +| `TASK_RECORDING_RESUMED` | `'task:recordingResumed'` | +| `TASK_RECORDING_RESUME_FAILED` | `'task:recordingResumeFailed'` | +| `TASK_PARTICIPANT_JOINED` | `'task:participantJoined'` | +| `TASK_PARTICIPANT_LEFT` | `'task:participantLeft'` | +| `TASK_CONFERENCE_STARTED` | `'task:conferenceStarted'` | +| `TASK_CONFERENCE_ENDED` | `'task:conferenceEnded'` | +| `TASK_CONFERENCE_FAILED` | `'task:conferenceFailed'` | +| `TASK_EXIT_CONFERENCE` | `'task:exitConference'` | +| `TASK_TRANSFER_CONFERENCE` | `'task:transferConference'` | +| `TASK_SWITCH_CALL` | `'task:switchCall'` | +| `TASK_CONFERENCE_TRANSFER_FAILED` | `'task:conferenceTransferFailed'` | +| `TASK_OUTDIAL_FAILED` | `'task:outdialFailed'` | + +**Additional events (in TASK_EVENTS enum, emitted from TaskManager or other layers):** + +| SDK Event | String Value | Note | +|-----------|--------------|------| +| `TASK_MEDIA` | `'task:media'` | Browser WebRTC remote media | +| `TASK_AUTO_ANSWERED` | `'task:autoAnswered'` | Auto-answer notification | +| `TASK_REJECT` | `'task:rejected'` | Task rejected | +| `TASK_MERGED` | `'task:merged'` | Task merged | +| `TASK_POST_CALL_ACTIVITY` | `'task:postCallActivity'` | Post-call activity | +| `TASK_CONFERENCE_ESTABLISHING` | `'task:conferenceEstablishing'` | Conference in progress | +| `TASK_CONFERENCE_END_FAILED` | `'task:conferenceEndFailed'` | Conference end failure | +| `TASK_PARTICIPANT_LEFT_FAILED` | `'task:participantLeftFailed'` | Participant removal failure | +| `TASK_CONFERENCE_TRANSFERRED` | `'task:conferenceTransferred'` | Conference transferred | +| `TASK_UI_CONTROLS_UPDATED` | `'task:ui-controls-updated'` | UI controls recomputed — **must subscribe** | +| `TASK_UNASSIGNED` | `'task:unassigned'` | Evaluate if widget needs to handle | +| `TASK_WRAPUP` | `'task:wrapup'` | Evaluate if widget needs to handle | +| `TASK_CONSULTING` | `'task:consulting'` | Consulting state entered | + +--- + +## Event Names — Widget Local vs SDK + +The widget's local `TASK_EVENTS` enum (in `store/src/store.types.ts`) uses CC-level naming that differs from the SDK's task-level naming. + +### 3 Renamed Events (task-refactor specific) + +| Old (Widget) | Old Value | New (SDK) | New Value | +|---|---|---|---| +| `AGENT_WRAPPEDUP` | `'AgentWrappedUp'` | `TASK_WRAPPEDUP` | `'task:wrappedup'` | +| `AGENT_CONSULT_CREATED` | `'AgentConsultCreated'` | `TASK_CONSULT_CREATED` | `'task:consultCreated'` | +| `AGENT_OFFER_CONTACT` | `'AgentOfferContact'` | `TASK_OFFER_CONTACT` | `'task:offerContact'` | + +### Pre-existing Name Mismatches (not task-refactor — fix when switching to SDK enum) + +These are incorrect names in the widget's local enum that already differed from the SDK. They are **not** renames introduced by the task-refactor; the SDK has always used the `task:*` naming for these. + +| Old (Widget) | Old Value | Correct (SDK) | SDK Value | +|---|---|---|---| +| `CONTACT_RECORDING_PAUSED` | `'ContactRecordingPaused'` | `TASK_RECORDING_PAUSED` | `'task:recordingPaused'` | +| `CONTACT_RECORDING_RESUMED` | `'ContactRecordingResumed'` | `TASK_RECORDING_RESUMED` | `'task:recordingResumed'` | + +### 4 Store-Only Enum Members (delete — no SDK equivalent) + +| Widget Enum Member | Value | Note | +|---|---|---| +| `TASK_UNHOLD` | `'task:unhold'` | SDK uses `TASK_RESUME` (`'task:resume'`) instead | +| `TASK_CONSULT` | `'task:consult'` | No SDK equivalent; consult flow uses multiple events | +| `TASK_PAUSE` | `'task:pause'` | No SDK equivalent; SDK uses `TASK_HOLD` (`'task:hold'`) | +| `AGENT_CONTACT_ASSIGNED` | `'AgentContactAssigned'` | SDK uses `TASK_ASSIGNED` (`'task:assigned'`) | + +**Docs to update when migrating event names:** CallControl widget spec references `TASK_CONSULT` (and related consult flow) in its sequence diagram — see `packages/contact-center/task/ai-docs/widgets/CallControl/ARCHITECTURE.md` (consult sequence around line 169). Update that doc to use SDK event names and remove references to store-only enum members (`TASK_CONSULT`, `TASK_UNHOLD`) once the migration is applied. + +--- + +## Task-Refactor Migration Changes + +### What Changes in SDK +1. SDK state machine handles all transitions internally +2. `task.data` is updated by the state machine's `updateTaskData` action on every event +3. `task.uiControls` is recomputed after every state transition +4. `task:ui-controls-updated` is emitted when controls change + +### Definitive New Event Registration + +`refreshTaskList()` is **kept** in all handlers that already call it. Removing `refreshTaskList()` is a future widget optimization, not part of this task-refactor migration (see [Future Optimization](#future-optimization-refreshtasklist-removal) below). + +#### No change required (22 events) + +These handlers are unchanged by the migration. Event names already match the SDK. + +| # | Event | Handler | Detail | +|---|-------|---------|--------| +| 1 | `TASK_END` | `handleTaskEnd` | Remove from task list, clear current task | +| 2 | `TASK_ASSIGNED` | `handleTaskAssigned` | Update task list, set current task | +| 3 | `TASK_REJECT` | `handleTaskReject` | Remove from task list | +| 4 | `TASK_OUTDIAL_FAILED` | `handleOutdialFailed` | Remove from task list | +| 5 | `TASK_MEDIA` | `handleTaskMedia` | Browser-only WebRTC setup — not task-refactor related | +| 6 | `TASK_CONSULT_QUEUE_CANCELLED` | `handleConsultQueueCancelled` | Consult state reset + `refreshTaskList()` | +| 7 | `TASK_CONSULTING` | `handleConsulting` | `setConsultStartTimeStamp(Date.now())` + `refreshTaskList()` | +| 8 | `TASK_CONSULT_ACCEPTED` | `handleConsultAccepted` | `setConsultStartTimeStamp(Date.now())` + `refreshTaskList()` + `TASK_MEDIA` listener (browser) | +| 9 | `TASK_OFFER_CONSULT` | `handleConsultOffer` | `refreshTaskList()` | +| 10 | `TASK_PARTICIPANT_JOINED` | `handleConferenceStarted` | Consult state reset + `refreshTaskList()` | +| 11 | `TASK_CONFERENCE_STARTED` | `handleConferenceStarted` | Same as #10 | +| 12 | `TASK_CONFERENCE_ENDED` | `handleConferenceEnded` | `refreshTaskList()` | +| 13 | `TASK_PARTICIPANT_LEFT` | `handleConferenceEnded` | Same as #12 | +| 14 | `TASK_HOLD` | `refreshTaskList` | Re-fetch all tasks | +| 15 | `TASK_RESUME` | `refreshTaskList` | Re-fetch all tasks | +| 16 | `TASK_POST_CALL_ACTIVITY` | `refreshTaskList` | Re-fetch all tasks | +| 17 | `TASK_CONFERENCE_ESTABLISHING` | `refreshTaskList` | Re-fetch all tasks | +| 18 | `TASK_CONFERENCE_FAILED` | `refreshTaskList` | Re-fetch all tasks | +| 19 | `TASK_CONFERENCE_END_FAILED` | `refreshTaskList` | Re-fetch all tasks | +| 20 | `TASK_PARTICIPANT_LEFT_FAILED` | `refreshTaskList` | Re-fetch all tasks | +| 21 | `TASK_CONFERENCE_TRANSFERRED` | `refreshTaskList` | Re-fetch all tasks | +| 22 | `TASK_CONFERENCE_TRANSFER_FAILED` | `refreshTaskList` | Re-fetch all tasks | + +#### Changes required (7 events) + +| # | Event | Handler | Category | Detail | +|---|-------|---------|----------|--------| +| 23 | `TASK_WRAPPEDUP` | `refreshTaskList` | Task-refactor | **Rename** from `AGENT_WRAPPEDUP`. Handler unchanged. | +| 24 | `TASK_CONSULT_CREATED` | `handleConsultCreated` | Task-refactor | **Rename** from `AGENT_CONSULT_CREATED`. Handler unchanged — `setConsultStartTimeStamp(Date.now())` + `refreshTaskList()`. | +| 25 | `TASK_OFFER_CONTACT` | `refreshTaskList` | Task-refactor | **Rename** from `AGENT_OFFER_CONTACT`. Handler unchanged. | +| 26 | `TASK_RECORDING_PAUSED` | `refreshTaskList` | Pre-existing fix | **Fix name** from `CONTACT_RECORDING_PAUSED`. Handler unchanged. | +| 27 | `TASK_RECORDING_RESUMED` | `refreshTaskList` | Pre-existing fix | **Fix name** from `CONTACT_RECORDING_RESUMED`. Handler unchanged. | +| 28 | `TASK_CONSULT_END` | `handleConsultEnd` | Pre-existing fix | **Fix wiring** — wire the existing (currently dead) `handleConsultEnd` method instead of bare `refreshTaskList`. | +| 29 | `TASK_AUTO_ANSWERED` | `handleAutoAnswer` | Task-refactor | **Remove `setIsDeclineButtonEnabled(true)`** — replace with `task.uiControls.decline.isEnabled`. Keep `refreshTaskList()`. | + +#### New additions (1 event) + +| # | Event | Handler | Category | Detail | +|---|-------|---------|----------|--------| +| 30 | `TASK_UI_CONTROLS_UPDATED` | New handler | Task-refactor | Fire callbacks to trigger widget re-renders when SDK recomputes `uiControls` | + +### `refreshTaskList()` — Retained + +`refreshTaskList()` is **kept in all existing handlers** for this migration. The store does not directly observe `task.data` via MobX, so `refreshTaskList()` is the mechanism that re-syncs the store's observable `taskList` and triggers MobX-driven re-renders. Removing it is a separate widget-layer optimization — not part of the task-refactor migration. + +### Future Optimization: `refreshTaskList()` Removal + +> **Scope:** Widget improvement — not part of this migration. + +Once the widget layer is updated to derive UI state from `task.data` / `task.uiControls` directly (via callbacks or by making the `cc` object MobX-observable), `refreshTaskList()` calls can be removed from most handlers. Until then, the current approach of calling `refreshTaskList()` on every event is safe and correct. + +**Why it could be removed in the future:** +1. **SDK keeps the same task reference up to date.** The state machine updates `task.data` (and `task.uiControls`) on the **same** `ITask` reference already held in the store's `taskList`. +2. **Widget re-renders could be driven by callbacks.** Widgets registered via `setTaskCallback(event, cb, taskId)` are notified when the store fires callbacks. Those callbacks could cause the widget to re-read the latest `task.data` directly. +3. **Re-fetch would only be needed when the list membership changes** (initial load, full refresh, or `TASK_WRAPPEDUP`). + +### Migration: `isDeclineButtonEnabled` → `task.uiControls.decline.isEnabled` + +The store currently has an `isDeclineButtonEnabled` observable set by `handleAutoAnswer`. With the SDK task-refactor, `task.uiControls.decline.isEnabled` is recomputed by the SDK and should be the source of truth for decline button state. + +**Current consumers:** +- `store/src/store.ts` — observable property +- `storeEventsWrapper.ts` — getter + `setIsDeclineButtonEnabled()` setter +- `task/src/helper.ts` — reads `store.isDeclineButtonEnabled` and passes to widget +- `cc-components/.../TaskList/task-list.utils.ts` — `!store.isDeclineButtonEnabled` for auto-answer disable logic +- `cc-components/.../IncomingTask/incoming-task.utils.tsx` — `isDeclineButtonEnabled` prop + +**Migration steps:** +1. Update `handleAutoAnswer` to remove `setIsDeclineButtonEnabled(true)` — no store mutation needed. +2. Update `task/src/helper.ts` to read `task.uiControls.decline.isEnabled` instead of `store.isDeclineButtonEnabled`. +3. Update IncomingTask and TaskList components to read from `task.uiControls.decline.isEnabled`. +4. Once no consumers remain, delete the store property, getter, and setter. + +### Migration: Store Consult State Observables (future removal) + +The store tracks consult state via three observables (`consultStartTimeStamp`, `isQueueConsultInProgress`, `currentConsultQueueId`) that are mutated by `handleConsultCreated`, `handleConsulting`, `handleConsultAccepted`, `handleConsultEnd`, `handleConsultQueueCancelled`, and `handleConferenceStarted`. + +With the SDK task-refactor, consult state is tracked in `task.data` and `task.uiControls`. These store observables are **retained for now** because widget consult timer logic (`timer-utils.ts`, `CallControl/index.tsx`) reads `consultStartTimeStamp` directly. + +**End state:** Once widgets derive consult timing from `task.data` (or the SDK exposes consult start time), these store properties and their setters in `handleConferenceStarted` and other handlers can be removed. + +--- + +## Pre-existing Issues (tracked separately from task-refactor) + +These are bugs in the current widget code, not introduced by the task-refactor. They should be fixed during migration but are not task-refactor changes. + +### Bug 1: `handleConsultEnd` is dead code + +A `handleConsultEnd` method exists (resets `isQueueConsultInProgress`, `currentConsultQueueId`, `consultStartTimeStamp`) but `TASK_CONSULT_END` is wired to `refreshTaskList()` instead. The method's consult state cleanup never runs. + +**Migration / test plan:** When wiring `TASK_CONSULT_END` to `handleConsultEnd`, update store unit tests to assert the new wiring and consult state reset. Remove or rewrite tests that only covered the old dead path. + +### Bug 2: `handleTaskRemove` listener mismatch + +`registerTaskEventListeners` wires `TASK_CONFERENCE_TRANSFERRED → this.refreshTaskList`. But `handleTaskRemove` calls `taskToRemove.off(TASK_EVENTS.TASK_CONFERENCE_TRANSFERRED, this.handleConferenceEnded)` — wrong handler reference. This listener is **never actually removed**, causing a listener leak. + +### Test gap + +`TASK_CONFERENCE_TRANSFERRED` currently has **no unit test** (registration and cleanup). Tracked by *(Jira: [CAI-7758](https://jira-eng-sjc12.cisco.com/jira/browse/CAI-7758))*. When implementing the migration, add tests for this event. + +--- + +## Old Code Reference + +### Entry Point +**File:** `packages/contact-center/store/src/storeEventsWrapper.ts` +**Register:** `registerTaskEventListeners(task: ITask)` — registers 27 event listeners +**Cleanup:** `handleTaskRemove(taskToRemove: ITask)` — unregisters all listeners + resets state + +### How It Works (Old) +1. On task creation, store registers individual listeners for 27 task events +2. Each handler manually updates store observables (`taskList`, `currentTask`, `consultStartTimeStamp`, `isQueueConsultInProgress`, `currentConsultQueueId`) +3. Many handlers simply call `refreshTaskList()` to re-fetch task state +4. Some handlers have specialized logic (consult, conference lifecycle) +5. Widgets subscribe to store callbacks via `setTaskCallback(event, cb, taskId)` + +### Store Observables Affected by Event Handlers + +| Observable | Type | Mutated By | +|---|---|---| +| `currentTask` | `ITask \| null` | `handleTaskAssigned`, `handleTaskEnd`, `handleTaskRemove` | +| `taskList` | `Record` | `refreshTaskList` | +| `consultStartTimeStamp` | `number \| undefined` | `handleConsultCreated`, `handleConsulting`, `handleConsultAccepted`, `handleConsultEnd`, `handleConsultQueueCancelled`, `handleConferenceStarted` | +| `isQueueConsultInProgress` | `boolean` | `handleConsultEnd`, `handleConsultQueueCancelled`, `handleConferenceStarted` | +| `currentConsultQueueId` | `string` | `handleConsultEnd`, `handleConsultQueueCancelled`, `handleConferenceStarted` | +| `isDeclineButtonEnabled` | `boolean` | `handleAutoAnswer` — **migrate to `task.uiControls.decline.isEnabled`** | + +### Old Event Handlers (27 events) + +| # | Event | Handler | Action | +|---|-------|---------|--------| +| 1 | `TASK_END` | `handleTaskEnd` | Remove task from list, clear current task | +| 2 | `TASK_ASSIGNED` | `handleTaskAssigned` | Update task list, set current task | +| 3 | `AGENT_OFFER_CONTACT` | `refreshTaskList` | Re-fetch all tasks | +| 4 | `AGENT_CONSULT_CREATED` | `handleConsultCreated` | `refreshTaskList()` + `setConsultStartTimeStamp(Date.now())` | +| 5 | `TASK_CONSULT_QUEUE_CANCELLED` | `handleConsultQueueCancelled` | Reset `isQueueConsultInProgress`, `currentConsultQueueId`, `consultStartTimeStamp` + `refreshTaskList()` | +| 6 | `TASK_REJECT` | `handleTaskReject` | Remove task, fire callbacks | +| 7 | `TASK_OUTDIAL_FAILED` | `handleOutdialFailed` | Remove task, fire callbacks | +| 8 | `AGENT_WRAPPEDUP` | `refreshTaskList` | Re-fetch all tasks | +| 9 | `TASK_CONSULTING` | `handleConsulting` | `refreshTaskList()` + `setConsultStartTimeStamp(Date.now())` | +| 10 | `TASK_CONSULT_ACCEPTED` | `handleConsultAccepted` | `refreshTaskList()` + `setConsultStartTimeStamp(Date.now())` + set ENGAGED state + **registers `TASK_MEDIA` listener on consult task (browser only)** | +| 11 | `TASK_OFFER_CONSULT` | `handleConsultOffer` | `refreshTaskList()` | +| 12 | `TASK_AUTO_ANSWERED` | `handleAutoAnswer` | `setIsDeclineButtonEnabled(true)` + `refreshTaskList()` | +| 13 | `TASK_CONSULT_END` | `refreshTaskList` | Re-fetch all tasks (**Note:** `handleConsultEnd` method exists but is NOT wired — see pre-existing bug) | +| 14 | `TASK_HOLD` | `refreshTaskList` | Re-fetch all tasks | +| 15 | `TASK_RESUME` | `refreshTaskList` | Re-fetch all tasks | +| 16 | `TASK_CONFERENCE_ENDED` | `handleConferenceEnded` | `refreshTaskList()` | +| 17 | `TASK_CONFERENCE_END_FAILED` | `refreshTaskList` | Re-fetch all tasks | +| 18 | `TASK_CONFERENCE_ESTABLISHING` | `refreshTaskList` | Re-fetch all tasks | +| 19 | `TASK_CONFERENCE_FAILED` | `refreshTaskList` | Re-fetch all tasks | +| 20 | `TASK_PARTICIPANT_JOINED` | `handleConferenceStarted` | Reset `isQueueConsultInProgress`, `currentConsultQueueId`, `consultStartTimeStamp` + `refreshTaskList()` | +| 21 | `TASK_PARTICIPANT_LEFT` | `handleConferenceEnded` | `refreshTaskList()` | +| 22 | `TASK_PARTICIPANT_LEFT_FAILED` | `refreshTaskList` | Re-fetch all tasks | +| 23 | `TASK_CONFERENCE_STARTED` | `handleConferenceStarted` | (same as #20) | +| 24 | `TASK_CONFERENCE_TRANSFERRED` | `refreshTaskList` | Re-fetch all tasks | +| 25 | `TASK_CONFERENCE_TRANSFER_FAILED` | `refreshTaskList` | Re-fetch all tasks | +| 26 | `TASK_POST_CALL_ACTIVITY` | `refreshTaskList` | Re-fetch all tasks | +| 27 | `TASK_MEDIA` | `handleTaskMedia` | Browser-only: `setCallControlAudio(new MediaStream([track]))` | + +--- + +## Implementation Reference + +This section provides code-level guidance for the implementing developer. The core migration: switch to SDK event names, keep `refreshTaskList()`, add `TASK_UI_CONTROLS_UPDATED`, fix `TASK_CONSULT_END` wiring, and replace `isDeclineButtonEnabled`. + +### Before/After: `registerTaskEventListeners()` + +#### Before (old event names) +```typescript +registerTaskEventListeners(task: ITask) { + const interactionId = task.data.interactionId; + task.on(TASK_EVENTS.TASK_END, this.handleTaskEnd); + task.on(TASK_EVENTS.TASK_ASSIGNED, this.handleTaskAssigned); + task.on(TASK_EVENTS.AGENT_OFFER_CONTACT, this.refreshTaskList); // old name + task.on(TASK_EVENTS.AGENT_CONSULT_CREATED, this.handleConsultCreated); // old name + task.on(TASK_EVENTS.AGENT_WRAPPEDUP, this.refreshTaskList); // old name + task.on(TASK_EVENTS.TASK_CONSULT_END, this.refreshTaskList); // BUG: handleConsultEnd exists but not wired + task.on(TASK_EVENTS.CONTACT_RECORDING_PAUSED, this.refreshTaskList); // wrong name + task.on(TASK_EVENTS.CONTACT_RECORDING_RESUMED, this.refreshTaskList);// wrong name + // ... remaining events unchanged +} +``` + +#### After (SDK event names, refreshTaskList kept, fixes applied) +```typescript +registerTaskEventListeners(task: ITask) { + const interactionId = task.data.interactionId; + + // Lifecycle (unchanged handlers) + task.on(TASK_EVENTS.TASK_END, this.handleTaskEnd); + task.on(TASK_EVENTS.TASK_ASSIGNED, this.handleTaskAssigned); + task.on(TASK_EVENTS.TASK_REJECT, this.handleTaskReject); + task.on(TASK_EVENTS.TASK_OUTDIAL_FAILED, this.handleOutdialFailed); + + // NEW: SDK-computed UI control updates + task.on(TASK_EVENTS.TASK_UI_CONTROLS_UPDATED, /* fire callbacks for this task */); + + // RENAMED events (handler unchanged, refreshTaskList kept) + task.on(TASK_EVENTS.TASK_WRAPPEDUP, this.refreshTaskList); // was AGENT_WRAPPEDUP + task.on(TASK_EVENTS.TASK_CONSULT_CREATED, this.handleConsultCreated); // was AGENT_CONSULT_CREATED + task.on(TASK_EVENTS.TASK_OFFER_CONTACT, this.refreshTaskList); // was AGENT_OFFER_CONTACT + + // FIX: Wire handleConsultEnd (was dead code — wired to refreshTaskList before) + task.on(TASK_EVENTS.TASK_CONSULT_END, this.handleConsultEnd); + + // FIX: Correct event names (handler unchanged) + task.on(TASK_EVENTS.TASK_RECORDING_PAUSED, this.refreshTaskList); // was CONTACT_RECORDING_PAUSED + task.on(TASK_EVENTS.TASK_RECORDING_RESUMED, this.refreshTaskList); // was CONTACT_RECORDING_RESUMED + + // Auto-answer — remove setIsDeclineButtonEnabled, keep refreshTaskList + task.on(TASK_EVENTS.TASK_AUTO_ANSWERED, this.handleAutoAnswer); + + // Consult/conference handlers (unchanged — keep refreshTaskList + state mutations) + task.on(TASK_EVENTS.TASK_CONSULTING, this.handleConsulting); + task.on(TASK_EVENTS.TASK_CONSULT_ACCEPTED, this.handleConsultAccepted); + task.on(TASK_EVENTS.TASK_CONSULT_QUEUE_CANCELLED, this.handleConsultQueueCancelled); + task.on(TASK_EVENTS.TASK_PARTICIPANT_JOINED, this.handleConferenceStarted); + task.on(TASK_EVENTS.TASK_CONFERENCE_STARTED, this.handleConferenceStarted); + task.on(TASK_EVENTS.TASK_CONFERENCE_ENDED, this.handleConferenceEnded); + task.on(TASK_EVENTS.TASK_PARTICIPANT_LEFT, this.handleConferenceEnded); + task.on(TASK_EVENTS.TASK_OFFER_CONSULT, this.handleConsultOffer); + + // Events wired to refreshTaskList (unchanged) + task.on(TASK_EVENTS.TASK_HOLD, this.refreshTaskList); + task.on(TASK_EVENTS.TASK_RESUME, this.refreshTaskList); + task.on(TASK_EVENTS.TASK_POST_CALL_ACTIVITY, this.refreshTaskList); + task.on(TASK_EVENTS.TASK_CONFERENCE_ESTABLISHING, this.refreshTaskList); + task.on(TASK_EVENTS.TASK_CONFERENCE_FAILED, this.refreshTaskList); + task.on(TASK_EVENTS.TASK_CONFERENCE_END_FAILED, this.refreshTaskList); + task.on(TASK_EVENTS.TASK_PARTICIPANT_LEFT_FAILED, this.refreshTaskList); + task.on(TASK_EVENTS.TASK_CONFERENCE_TRANSFERRED, this.refreshTaskList); + task.on(TASK_EVENTS.TASK_CONFERENCE_TRANSFER_FAILED, this.refreshTaskList); + + // Browser-only: WebRTC media setup (unchanged) + if (this.deviceType === DEVICE_TYPE_BROWSER) { + task.on(TASK_EVENTS.TASK_MEDIA, this.handleTaskMedia); + } +} +``` + +> **Note on handler references and `.off()` cleanup:** The implementing developer must ensure that handler references registered via `task.on()` match those used in `handleTaskRemove` for `.off()`. This is a standard listener pattern — see the old code's existing approach for reference. + +### `handleConferenceStarted` — No Change + +`handleConferenceStarted` is **unchanged** by this migration. Consult state resets and `refreshTaskList()` are both retained. + +```typescript +handleConferenceStarted = () => { + runInAction(() => { + this.setIsQueueConsultInProgress(false); + this.setCurrentConsultQueueId(null); + this.setConsultStartTimeStamp(null); + }); + this.refreshTaskList(); +}; +``` + +### Before/After: `handleAutoAnswer` + +#### Before +```typescript +handleAutoAnswer = () => { + this.setIsDeclineButtonEnabled(true); + this.refreshTaskList(); +}; +``` + +#### After +```typescript +handleAutoAnswer = () => { + // setIsDeclineButtonEnabled removed — use task.uiControls.decline.isEnabled instead. + this.refreshTaskList(); +}; +``` + +--- + +## Validation Criteria + +- [ ] All event names switched to SDK `TASK_EVENTS` enum (`TASK_WRAPPEDUP`, `TASK_CONSULT_CREATED`, `TASK_OFFER_CONTACT`, `TASK_RECORDING_PAUSED`, `TASK_RECORDING_RESUMED`) +- [ ] Local `TASK_EVENTS` enum deleted from `store.types.ts`; imported from `@webex/contact-center` +- [ ] `refreshTaskList()` still called in all existing handlers (no removal in this migration) +- [ ] `TASK_UI_CONTROLS_UPDATED` handler added; triggers widget re-renders +- [ ] `handleConsultEnd` is properly wired to `TASK_CONSULT_END` and resets consult state +- [ ] `handleAutoAnswer` no longer calls `setIsDeclineButtonEnabled` — widget layer uses `task.uiControls.decline.isEnabled` +- [ ] `handleConsultAccepted` still registers `TASK_MEDIA` listener on consult task (browser) +- [ ] Task list stays in sync on all lifecycle events (incoming, assigned, end, reject, wrapup) +- [ ] No regression in consult/conference/hold flows +- [ ] `handleTaskRemove` unregisters all listeners correctly (no listener leaks) +- [ ] Task-layer consumers (`task/src/helper.ts`) updated to use SDK event names + +--- + +_Parent: [migration-overview.md](./migration-overview.md) — overview doc is added in PR 1/4; link resolves once that PR is merged._ diff --git a/packages/contact-center/ai-docs/migration/store-task-utils-migration.md b/packages/contact-center/ai-docs/migration/store-task-utils-migration.md new file mode 100644 index 000000000..0d88889d8 --- /dev/null +++ b/packages/contact-center/ai-docs/migration/store-task-utils-migration.md @@ -0,0 +1,389 @@ +# Store Task Utils Migration + +## Summary + +The store's `task-utils.ts` contains 16 exported utility functions that inspect raw task data to derive state flags (consult status, hold status, conference state, participant info). Many of these become redundant when `task.uiControls` is the source of truth. This document maps which utils to keep, simplify, or remove. + +**Barrel export:** `store/src/index.ts` has `export * from './task-utils'` — all 16 functions are publicly exported via `@webex/cc-store`. Removing functions will cause compile errors in any downstream consumer still importing them. + +**Pre-migration: confirm downstream usage.** Exported does not mean used. The only known downstream consumer today is Epic. Before removing store task-utils or changing exports, confirm in the space (or with Epic) that these utils are unused. If they are not used, removal is safe and external compile impact can be ignored; if they are used, coordinate the migration or provide an alternative. + +**Migration end state:** The task object (and `task.data` / `task.uiControls`) is the source of truth. `task.uiControls` is recomputed by the SDK whenever `task.data` changes and should be considered the source for anything and everything in the UI. The goal is to remove these legacy constants and derived-state helpers once the migration is complete. This document defines the **safe ordering** to remove them (e.g. rewrite `findHoldStatus` before deleting consult-state constants). Helpers `findHoldStatus` and `findHoldTimestamp` are kept for now only because per-leg hold state and hold timestamps must be derived from `task.data.interaction.media`; they can be removed when the SDK or task layer exposes equivalent per-leg hold state. + +--- + +## Constants and Types to Delete + +| Delete | File | Reason | +|--------|------|--------| +| Local `TASK_EVENTS` enum | `store/src/store.types.ts` | SDK exports this — delete local copy (covered in detail in [store-event-wiring-migration.md](./store-event-wiring-migration.md)) | +| `ConsultStatus` enum | `store/src/store.types.ts` | All consumers (`getConsultStatus`, `getControlsVisibility`) are being removed | +| `TASK_STATE_CONSULT` | `store/src/constants.ts` | **Dual use:** (1) Task state → SDK `TaskState.CONSULT_INITIATING`. (2) **Media-type sentinel** in `findHoldStatus(task, mType, agentId)` — line 328 uses `mType === TASK_STATE_CONSULT` to identify the consult *leg*, not task state. **Delete ONLY AFTER rewriting `findHoldStatus`**; when rewriting, preserve a media-type constant (or use media-type enums) for the consult leg — do **not** replace that branch with `TaskState`. See ordering note below. | +| `TASK_STATE_CONSULTING` | `store/src/constants.ts` | SDK `TaskState.CONSULTING` — **same ordering constraint** (used only as task state, not as mType) | +| `TASK_STATE_CONSULT_COMPLETED` | `store/src/constants.ts` | SDK handles via context — **same ordering constraint** | +| `INTERACTION_STATE_WRAPUP` | `store/src/constants.ts` | SDK `TaskState.WRAPPING_UP` — **delete ONLY AFTER rewriting `getTaskStatus`** (see ordering note below) | +| `INTERACTION_STATE_POST_CALL` | `store/src/constants.ts` | SDK `TaskState.POST_CALL` — **same ordering constraint** | +| `INTERACTION_STATE_CONNECTED` | `store/src/constants.ts` | SDK `TaskState.CONNECTED` — **same ordering constraint** | +| `INTERACTION_STATE_CONFERENCE` | `store/src/constants.ts` | SDK `TaskState.CONFERENCING` — **same ordering constraint** | +| `CONSULT_STATE_INITIATED` | `store/src/constants.ts` | SDK handles via context — **delete ONLY AFTER rewriting `getConsultMPCState`** (see ordering note below) | +| `CONSULT_STATE_COMPLETED` | `store/src/constants.ts` | SDK handles via context — **same ordering constraint** | +| `CONSULT_STATE_CONFERENCING` | `store/src/constants.ts` | SDK handles via context — **same ordering constraint** | + +## Constants to Keep + +**All entries in this table are kept** (no deletions). Use them until the corresponding helpers are rewritten or removed per the ordering constraints below. + +| Keep | File | Reason | +|------|------|--------| +| `RELATIONSHIP_TYPE_CONSULT` | `store/src/constants.ts` | Used by `findMediaResourceId` | +| `MEDIA_TYPE_CONSULT` | `store/src/constants.ts` | Used by `findMediaResourceId` | +| `AGENT` | `store/src/constants.ts` | Used by `getConferenceParticipants` for participant filtering | +| `CUSTOMER` | `store/src/constants.ts` | Used by `EXCLUDED_PARTICIPANT_TYPES` | +| `SUPERVISOR` | `store/src/constants.ts` | Used by `EXCLUDED_PARTICIPANT_TYPES` | +| `VVA` | `store/src/constants.ts` | Used by `EXCLUDED_PARTICIPANT_TYPES` | +| `EXCLUDED_PARTICIPANT_TYPES` | `store/src/constants.ts` | Used by `getConferenceParticipants` for participant filtering | + +**Consult string alias:** `TASK_STATE_CONSULT`, `RELATIONSHIP_TYPE_CONSULT`, and `MEDIA_TYPE_CONSULT` all resolve to the same string `'consult'`. When rewriting `findHoldStatus` or consolidating constants, consider using a single constant (e.g. one media-type constant for the consult leg) or document the alias explicitly to avoid drift. Do not rely on three separate names for the same value long term. + +## Ordering Constraint: Consult State Constants + +`findHoldStatus` (KEEP) depends on `TASK_STATE_CONSULT`, `TASK_STATE_CONSULTING`, and `TASK_STATE_CONSULT_COMPLETED` via: +- **Direct usage:** Line 328 — `mType === TASK_STATE_CONSULT` +- **Via `isConsultOnHoldMPC`:** Line 303 — `[TASK_STATE_CONSULT, TASK_STATE_CONSULTING].includes(getConsultMPCState(...))` +- **Via `getConsultMPCState`:** Line 321 — `[TASK_STATE_CONSULT_COMPLETED].includes(getConsultMPCState(...))` + +**Do NOT delete these 3 constants until `findHoldStatus` and `isConsultOnHoldMPC` are rewritten** to use SDK `TaskState` equivalents. Deleting them first will break compilation. When rewriting `findHoldStatus`, note that `TASK_STATE_CONSULT` is used there as a **media-type** sentinel (`mType === TASK_STATE_CONSULT`); preserve a media-type constant or media-type enum for the consult leg — do not replace that comparison with `TaskState`. + +## Ordering Constraint: Interaction State Constants + +`getTaskStatus` (KEEP) depends on `INTERACTION_STATE_WRAPUP`, `INTERACTION_STATE_POST_CALL`, `INTERACTION_STATE_CONNECTED`, and `INTERACTION_STATE_CONFERENCE` extensively: +- **`isIncomingTask`:** Line 46 — `task.data.interaction.state !== INTERACTION_STATE_WRAPUP` +- **`getTaskStatus`:** Lines 56–60 — returns `INTERACTION_STATE_CONNECTED` or `INTERACTION_STATE_CONFERENCE` +- **`getTaskStatus`:** Lines 99–100 — conference state check +- **`getTaskStatus`:** Lines 105–106 — wrapup/post-call consult-completed check +- **`getConsultMPCState`:** Lines 137–139 — connected/conference branching + +**Do NOT delete these 4 constants until `getTaskStatus` and `getConsultMPCState` are rewritten** to use SDK `TaskState` equivalents. Deleting them first will break compilation. + +## Ordering Constraint: Consult State Constants (`CONSULT_STATE_*`) + +`getConsultMPCState` (used by `getTaskStatus` and `findHoldStatus`) depends on `CONSULT_STATE_INITIATED`, `CONSULT_STATE_COMPLETED`, and `CONSULT_STATE_CONFERENCING`: +- **Line 53:** `case CONSULT_STATE_INITIATED:` — returns `TASK_STATE_CONSULT` +- **Line 55:** `case CONSULT_STATE_COMPLETED:` — returns connected or consult-completed +- **Line 59:** `case CONSULT_STATE_CONFERENCING:` — returns `INTERACTION_STATE_CONFERENCE` +- **Line 107:** `consultState === CONSULT_STATE_COMPLETED` — wrapup path in `getTaskStatus` + +**Do NOT delete these 3 constants until `getConsultMPCState` is rewritten** to use SDK equivalents. Deleting them first will break compilation. + +## Gotcha: `TaskState.CONSULT_INITIATING` vs `CONSULTING` + +The SDK has `CONSULT_INITIATING` (consult requested, async in-progress) and `CONSULTING` (consult accepted, actively consulting) as distinct states. The old widget constant `TASK_STATE_CONSULT` ('consult') maps to `CONSULT_INITIATING`, NOT `CONSULTING`. Do not collapse these when updating `getTaskStatus()` or any consult timer logic. + +## Decision: `findHoldStatus` and `findHoldTimestamp` retained for now + +Reviewers may suggest removing these because "task is source of truth." The SDK `task.uiControls` is recomputed every time `task.data` changes and should be considered the source for anything and everything in the UI. These helpers are **kept** in this migration only because timers and UI need **per-leg hold state** and **hold timestamp**, which are derived from `task.data.interaction.media`; the store helpers centralize that derivation. Once the SDK or task layer exposes equivalent per-leg hold state (or widgets derive it in a single place from `task.data` only), these helpers can be removed and the "task as source of truth" end state is fully achieved. + +--- + +## Old Utilities Inventory + +**File:** `packages/contact-center/store/src/task-utils.ts` (16 exported functions) + +| # | Function | Purpose | Actual Consumers (production code) | +|---|----------|---------|-------------------------------------| +| 1 | `isIncomingTask(task, agentId)` | Check if task is incoming | `storeEventsWrapper.ts` | +| 2 | `getConsultMPCState(task, agentId)` | Consult multi-party conference state string | `getTaskStatus()`, `findHoldStatus()` area logic (internal to `task-utils.ts`) | +| 3 | `isSecondaryAgent(task)` | Whether agent is secondary in consult | `task-utils.ts` (internal) | +| 4 | `isSecondaryEpDnAgent(task)` | Whether agent is secondary EP-DN | `getTaskStatus()`, `getConsultStatus()` (internal to `task-utils.ts`) | +| 5 | `getTaskStatus(task, agentId)` | Human-readable task status string | `getConsultStatus()` (internal — no external consumer currently) | +| 6 | `getConsultStatus(task, agentId)` | `ConsultStatus` enum value | `task/src/Utils/task-util.ts` (`getControlsVisibility`) | +| 7 | `getIsConferenceInProgress(task)` | Boolean conference check | Tests only — `task-util.ts` uses `task?.data?.isConferenceInProgress` directly instead | +| 8 | `getConferenceParticipants(task, agentId)` | Filtered participant list | `task/src/helper.ts` (CallControl hook) | +| 9 | `getConferenceParticipantsCount(task)` | Participant count | `task/src/Utils/task-util.ts` (`getControlsVisibility`) | +| 10 | `getIsCustomerInCall(task)` | Whether customer is connected | `task/src/Utils/task-util.ts` (`getControlsVisibility`) | +| 11 | `getIsConsultInProgress(task)` | Whether consult is active | `task/src/Utils/task-util.ts` (`getControlsVisibility`) | +| 12 | `isInteractionOnHold(task)` | Whether any media is held | Timer utils | +| 13 | `setmTypeForEPDN(task, mType)` | Media type for EP-DN agents | CallControl hook | +| 14 | `findMediaResourceId(task, mType)` | Media resource ID lookup | CallControl hook (switch calls) | +| 15 | `findHoldStatus(task, mType, agentId)` | Hold status by media type | `task/src/Utils/task-util.ts` (`getControlsVisibility`), `getTaskStatus()` (via `getConsultMPCState` chain) | +| 16 | `findHoldTimestamp(task, mType)` | Hold timestamp for timers | Timer utils (store version takes `ITask`; see dual-signature note below) | + +### Important: `findHoldTimestamp` Dual Signatures + +Two different `findHoldTimestamp` functions exist with different signatures: +- **`store/src/task-utils.ts`:** `findHoldTimestamp(task: ITask, mType: string)` — takes full task object +- **`task/src/Utils/task-util.ts`:** `findHoldTimestamp(interaction: Interaction, mType: string)` — takes interaction only + +`timer-utils.ts` imports from `@webex/cc-store` (task version). `useHoldTimer.ts` imports from `task-util` (interaction version). Both are kept but the implementing agent must not confuse them. + +--- + +## Migration Decisions + +### Remove — 5 Functions (SDK handles via uiControls) + +| # | Function | Reason | SDK Replacement | Impact on Other Functions | +|---|----------|--------|-----------------|--------------------------| +| 1 | `getConsultStatus(task, agentId)` | Primary consumer `getControlsVisibility` is deleted | `task.uiControls` encodes all consult control states | `getConsultStatus()` **calls** `getTaskStatus()` (not the reverse). When we delete `getConsultStatus`, update `getTaskStatus` to use `task.uiControls` (see After code below). | +| 2 | `getIsConferenceInProgress(task)` | `task-util.ts` already uses `task?.data?.isConferenceInProgress` directly; function only used in tests | **State-based:** `task?.data?.isConferenceInProgress`. Do **not** use `task.uiControls.exitConference.isVisible` — that is control visibility and can be false when `conferenceEnabled` hides the button while the task is still in conference, causing false negatives. | None | +| 3 | `getConferenceParticipantsCount(task)` | Used only in `getControlsVisibility` | SDK computes max participant check internally | None | +| 4 | `getIsCustomerInCall(task)` | Used only in `getControlsVisibility` | SDK computes internally | None | +| 5 | `getIsConsultInProgress(task)` | Used only in `getControlsVisibility` | SDK computes internally | None | + +### Keep — 7 Functions (Widget-layer concerns) + +| # | Function | Reason | +|---|----------|--------| +| 1 | `isIncomingTask(task, agentId)` | Store needs this for routing incoming tasks | +| 2 | `getTaskStatus(task, agentId)` | Returns human-readable status. Currently only consumed internally by `getConsultStatus()`, but after migration will be the primary status provider for TaskList display. Must be updated to use `task.uiControls` instead of `getConsultStatus()`. | +| 3 | `getConferenceParticipants(task, agentId)` | CallControl UI shows participant list (display, not control visibility). Uses `EXCLUDED_PARTICIPANT_TYPES` from constants. | +| 4 | `isInteractionOnHold(task)` | Timer logic needs this | +| 5 | `findMediaResourceId(task, mType)` | Switch-call actions need media resource IDs. Uses `RELATIONSHIP_TYPE_CONSULT`, `MEDIA_TYPE_CONSULT` from constants. | +| 6 | `findHoldTimestamp(task, mType)` | Hold timer needs timestamp. Note: store version takes `ITask`, task-util version takes `Interaction` (see dual-signature note above). | +| 7 | `findHoldStatus(task, mType, agentId)` | Needed for per-leg hold state and hold timers (from `task.data.interaction.media`); `getTaskStatus()` and component layer `isHeld` use this until SDK/task exposes equivalent | + +### Review — 4 Functions (may simplify or remove) + +| # | Function | Consideration | Dependency | +|---|----------|--------------|------------| +| 1 | `isSecondaryAgent(task)` | May be replaceable by SDK context | Used internally by `task-utils.ts` | +| 2 | `isSecondaryEpDnAgent(task)` | May be replaceable by SDK context | Used by `getTaskStatus()` and `getConsultStatus()` | +| 3 | `getConsultMPCState(task, agentId)` | Review if still needed with SDK handling consult state | **Called by `getTaskStatus()` (line 112) as its return value** — if `getTaskStatus` is rewritten to use `task.uiControls`, this may become removable | +| 4 | `setmTypeForEPDN(task, mType)` | Review if SDK simplifies this | Used by CallControl hook | + +--- + +## Before/After: Downstream Impact — `getControlsVisibility` Deletion + +> **Note:** `getControlsVisibility` is NOT in the store. It lives in the **task package** at +> `task/src/Utils/task-util.ts` (hook/widget layer). It is shown here because it is the +> **primary consumer** of the 5 store functions being removed above. When those store +> functions are deleted, this entire function chain becomes deletable — replaced by `task.uiControls`. + +### Before (`task/src/Utils/task-util.ts` — imports 5 store functions) +```typescript +// task/src/Utils/task-util.ts (hook layer, NOT store) +import { getConsultStatus, ConsultStatus, getIsConsultInProgress, getIsCustomerInCall, + getConferenceParticipantsCount, findHoldStatus } from '@webex/cc-store'; + +export function getControlsVisibility(deviceType, featureFlags, task, agentId, conferenceEnabled) { + const taskConsultStatus = getConsultStatus(task, agentId); + const isConsultInitiated = taskConsultStatus === ConsultStatus.CONSULT_INITIATED; + const isConsultAccepted = taskConsultStatus === ConsultStatus.CONSULT_ACCEPTED; + const isBeingConsulted = taskConsultStatus === ConsultStatus.BEING_CONSULTED_ACCEPTED; + const isConsultCompleted = taskConsultStatus === ConsultStatus.CONSULT_COMPLETED; + + const isHeld = findHoldStatus(task, 'mainCall', agentId); + const consultCallHeld = findHoldStatus(task, 'consult', agentId); + + const isConferenceInProgress = task?.data?.isConferenceInProgress ?? false; + const isConsultInProgress = getIsConsultInProgress(task); + const isCustomerInCall = getIsCustomerInCall(task); + const conferenceParticipantsCount = getConferenceParticipantsCount(task); + + // 22 individual get*ButtonVisibility() functions using these derived states... + return { /* 22 controls + 7 state flags */ }; +} +``` + +### After (`task/src/Utils/task-util.ts` — entire `getControlsVisibility` + 22 visibility functions deleted) +```typescript +// task/src/Utils/task-util.ts (hook layer, NOT store) +// DELETE getControlsVisibility and all 22 get*ButtonVisibility functions: +// getAcceptButtonVisibility, getDeclineButtonVisibility, getEndButtonVisibility, +// getMuteUnmuteButtonVisibility, getHoldResumeButtonVisibility, +// getPauseResumeRecordingButtonVisibility, getRecordingIndicatorVisibility, +// getTransferButtonVisibility, getConferenceButtonVisibility, +// getExitConferenceButtonVisibility, getMergeConferenceButtonVisibility, +// getConsultButtonVisibility, getEndConsultButtonVisibility, +// getConsultTransferButtonVisibility, getMergeConferenceConsultButtonVisibility, +// getConsultTransferConsultButtonVisibility, getMuteUnmuteConsultButtonVisibility, +// getSwitchToMainCallButtonVisibility, getSwitchToConsultButtonVisibility, +// getWrapupButtonVisibility, getControlsVisibility +// +// KEEP in task-util.ts: findHoldTimestamp(interaction, mType) — different signature from store version + +// In useCallControl hook — no imports from store task-utils for controls: +const controls = currentTask?.uiControls ?? getDefaultUIControls(); +// All 17 controls come pre-computed from SDK. Zero store util calls needed. +``` + +> **CRITICAL: Migrate derived state consumers before removing getControlsVisibility** +> +> Many consumers still depend on **derived booleans** that are not part of SDK `uiControls` (e.g. `controlVisibility.consultCallHeld`, `isConsultInitiated` in `task/src/Utils/timer-utils.ts`; `isHeld`, `isConferenceInProgress` in `cc-components/.../CallControl*`). Do **not** follow the replacement above literally without migrating those consumers in the same step: either pass derived values from parent (e.g. `findHoldStatus(task, 'mainCall', agentId)` for `isHeld`) or move derivation to the hook/layer that owns the control. Otherwise removing `getControlsVisibility` / `ControlVisibility` will break timers and button behavior. + +> **CRITICAL: Feature-Flag Gating Overlay** +> +> The old `getControlsVisibility` applied integrator-provided widget props (`featureFlags` +> and `conferenceEnabled`) that the SDK has **no knowledge of**. SDK-computed `task.uiControls` +> reflects task-state-only visibility. The widget layer **must** still overlay these gates on +> top of the SDK controls to honour integrator configuration. +> +> | Widget Prop | Controls Affected | Gate Logic | +> |-------------|-------------------|------------| +> | `featureFlags.webRtcEnabled` | accept, decline, muteUnmute, conference, muteUnmuteConsult, **transfer** (browser: `isTransferVisibility`), **consult**, **recording** (pause/resume), + telephony support (holdResume, endConsult). (Old logic: `telephonySupported` from `webRtcEnabled` drives `getConsultButtonVisibility`, `getPauseResumeRecordingButtonVisibility` in task-util.) | Hide control when `webRtcEnabled` is `false` and channel is voice in browser | +> | `featureFlags.isEndCallEnabled` | end | Hide end button when `isEndCallEnabled` is `false` (phone device only) | +> | `featureFlags.isEndConsultEnabled` | endConsult | Hide end-consult when `isEndConsultEnabled` is `false` | +> | `conferenceEnabled` (widget prop) | conference, exitConference, mergeConference, **mergeConferenceConsult** | Hide all conference-related controls when `conferenceEnabled` is `false`. **Do not** gate `consultTransferConsult` on `conferenceEnabled` — current code does not; gating it would regress consult-transfer-consult in tenants with conference disabled. | +> +> **Implementation pattern — apply after reading SDK controls:** +> ```typescript +> const sdkControls = currentTask?.uiControls ?? getDefaultUIControls(); +> +> // Overlay integrator feature-flag gates +> const controls = applyFeatureGates(sdkControls, { +> deviceType, +> featureFlags, // { webRtcEnabled, isEndCallEnabled, isEndConsultEnabled } +> conferenceEnabled, +> channelType, // voice vs digital — needed for webRtc gate +> }); +> ``` +> The `applyFeatureGates` helper is a thin function that sets `isVisible = false` +> on any control whose integrator gate is off. It does **not** re-derive state; it only +> narrows visibility that the SDK already computed. + +### Before/After: `findHoldStatus` — RETAINED (not removed) + +#### Before (used in controls computation and task status) +```typescript +// store/task-utils.ts — exported function +export const findHoldStatus = (task: ITask, mType: string, agentId: string): boolean => { + // Reads from task.data.interaction.participants to determine hold state + // ... +}; + +// task-util.ts — consumed for control visibility (BEING DELETED) +const isHeld = findHoldStatus(task, 'mainCall', agentId); +const consultCallHeld = findHoldStatus(task, 'consult', agentId); +``` + +#### After +```typescript +// KEPT in store/task-utils.ts — still needed for: +// Per-leg hold state and hold timers (from task.data.interaction.media); +// getTaskStatus() and component layer isHeld use this until SDK/task exposes equivalent. +// Implementation unchanged — reads from task.data.interaction.participants +export const findHoldStatus = (task: ITask, mType: string, agentId: string): boolean => { + // ...unchanged... +}; +``` + +### Before/After: `getTaskStatus` (KEPT but rewritten) + +#### Before (actual implementation) +```typescript +// store/task-utils.ts — the real code (NOT a simplification) +export function getTaskStatus(task: ITask, agentId: string): string { + const interaction = task.data.interaction; + + // EP-DN secondary agent handling + if (isSecondaryEpDnAgent(task)) { + if (interaction.state === INTERACTION_STATE_CONFERENCE) { + return INTERACTION_STATE_CONFERENCE; + } + return TASK_STATE_CONSULTING; + } + + // Consult-completed wrapup handling + if ( + (interaction.state === INTERACTION_STATE_WRAPUP || interaction.state === INTERACTION_STATE_POST_CALL) && + interaction.participants[agentId]?.consultState === CONSULT_STATE_COMPLETED + ) { + return TASK_STATE_CONSULT_COMPLETED; + } + + // Delegates to getConsultMPCState for all other cases + return getConsultMPCState(task, agentId); +} + +// getConsultStatus() calls getTaskStatus() and maps the string to ConsultStatus enum values +``` + +#### After (rewritten to use SDK controls — preserve return contract) +```typescript +// store/task-utils.ts — rewritten to use task.uiControls +// CONTRACT: getTaskStatus is barrel-exported from @webex/cc-store. Downstream consumers +// may compare return values to INTERACTION_STATE_* / TASK_STATE_* constants. Preserve the +// existing machine-readable return values; do NOT switch to display labels here. +// Display labels ('Wrap Up', 'Conference', etc.) are a UI concern — implement via a +// separate helper or mapping layer if needed. +export function getTaskStatus(task: ITask, agentId: string): string { + const interaction = task.data.interaction; + const controls = task.uiControls; + + // When uiControls is missing (hydration/race), run full legacy path so EP-DN and + // consult-completed derivation are preserved; avoid falling back to raw interaction.state only. + if (!controls) { + if (isSecondaryEpDnAgent(task)) { + if (interaction.state === INTERACTION_STATE_CONFERENCE) return INTERACTION_STATE_CONFERENCE; + return TASK_STATE_CONSULTING; + } + if ( + (interaction.state === INTERACTION_STATE_WRAPUP || interaction.state === INTERACTION_STATE_POST_CALL) && + interaction.participants[agentId]?.consultState === CONSULT_STATE_COMPLETED + ) { + return TASK_STATE_CONSULT_COMPLETED; + } + return getConsultMPCState(task, agentId); + } + + // EP-DN secondary agent (same as Before) + if (isSecondaryEpDnAgent(task)) { + if (interaction.state === INTERACTION_STATE_CONFERENCE) return INTERACTION_STATE_CONFERENCE; + return TASK_STATE_CONSULTING; + } + // Wrapup / post-call with consult completed (same as Before) + if ( + (interaction.state === INTERACTION_STATE_WRAPUP || interaction.state === INTERACTION_STATE_POST_CALL) && + interaction.participants[agentId]?.consultState === CONSULT_STATE_COMPLETED + ) { + return TASK_STATE_CONSULT_COMPLETED; + } + + // Map from uiControls to same constant values as old getConsultMPCState / getTaskStatus + if (controls.wrapup.isVisible) return INTERACTION_STATE_WRAPUP; + // endConsult.isVisible is true for both consult-initiating and consulting; use getConsultMPCState + // so we return TASK_STATE_CONSULT vs TASK_STATE_CONSULTING correctly (see gotcha in this doc). + if (controls.endConsult.isVisible) return getConsultMPCState(task, agentId); + if (controls.exitConference.isVisible) return INTERACTION_STATE_CONFERENCE; + if (findHoldStatus(task, 'mainCall', agentId)) return INTERACTION_STATE_CONNECTED; // held → connected + if (controls.end.isVisible) return INTERACTION_STATE_CONNECTED; + if (controls.accept.isVisible) return interaction?.state ?? 'new'; // offered + return getConsultMPCState(task, agentId); // fallback preserves legacy behaviour +} +``` + +--- + +## Files to Modify + +| File | Action | +|------|--------| +| `store/src/task-utils.ts` | Remove 5 functions, keep 7 (update `getTaskStatus` to use `task.uiControls`), review 4 | +| `store/src/store.types.ts` | Delete `ConsultStatus` enum (all consumers removed) | +| `store/src/constants.ts` | **Phased deletion:** Delete 9 task/interaction/consult state constants **only after** dependent functions (`getTaskStatus`, `findHoldStatus`, `getConsultMPCState`) are rewritten to use SDK `TaskState`. The "After" `getTaskStatus` code still returns these constants as values — they must remain until a follow-up step rewrites return values to SDK `TaskState`. Keep 7 participant/media constants. See ordering constraints above. | +| `task/src/Utils/task-util.ts` | Delete `getControlsVisibility` + all 22 `get*ButtonVisibility` functions; keep `findHoldTimestamp(interaction, mType)` | +| `store/tests/task-utils.ts` | Update tests: remove tests for 5 deleted functions, update `getTaskStatus` tests | +| `task/tests/utils/task-util.ts` | Remove tests for deleted visibility functions | +| All consumers of removed functions | Update imports, switch to `task.uiControls` | + +--- + +## Validation Criteria + +- [ ] 5 removed functions have no remaining consumers (compile check) +- [ ] 7 kept functions still work correctly +- [ ] `getTaskStatus` produces correct status for all task states (connected, held, consulting, conference, wrapup, offered) +- [ ] `getTaskStatus` handles EP-DN secondary agent edge cases correctly +- [ ] Conference participant display unchanged +- [ ] Hold timer unchanged +- [ ] Switch-call media resource IDs work +- [ ] `ConsultStatus` enum removed with no remaining imports +- [ ] 9 deleted constants have no remaining consumers +- [ ] `findHoldTimestamp` dual-signature (task vs interaction) not confused during migration +- [ ] `task-util.ts` `getControlsVisibility` + 22 visibility functions fully deleted +- [ ] Feature-flag overlay (`applyFeatureGates`) preserves `webRtcEnabled`, `isEndCallEnabled`, `isEndConsultEnabled`, and `conferenceEnabled` gating on top of SDK controls + +--- + +_Parent: [migration-overview.md](./migration-overview.md)_