mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 10:23:27 +00:00
CC Agent: - Call transfer (CONFERENCE + KICK_CALL) with inline transfer dialog - Recording pause/resume during active calls - Missed calls API (Ozonetel abandonCalls) - Call history API (Ozonetel fetchCDRDetails) Live Call Assist: - Deepgram Nova STT via raw WebSocket - OpenAI suggestions every 10s with lead context - LiveTranscript component in sidebar during calls - Browser audio capture from remote WebRTC stream Worklist: - Redesigned table: clickable phones, context menu (Call/SMS/WhatsApp) - Last interaction sub-line, source column, improved SLA - Filtered out rows without phone numbers - New missed call notifications Brand: - Logo on login page - Blue scale rebuilt from logo blue rgb(32, 96, 160) - FontAwesome duotone CSS variables set globally - Profile menu icons switched to duotone Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
481 lines
16 KiB
Markdown
481 lines
16 KiB
Markdown
# CC Agent Features — Phase 1 Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Add call transfer, recording pause, and missed call queue to the CC agent's call desk — the three most impactful features for daily workflow.
|
|
|
|
**Architecture:** Three new service methods in the NestJS sidecar (callControl, pauseRecording, getAbandonCalls), exposed via REST endpoints. Frontend adds Transfer and Pause Recording buttons to the active call card, and a missed call queue that pulls from Ozonetel instead of our webhook-created records.
|
|
|
|
**Tech Stack:** NestJS sidecar (Ozonetel Token Auth APIs), React 19 + Jotai + Untitled UI
|
|
|
|
**Ozonetel API endpoints used:**
|
|
- Call Control: `POST /ca_apis/CallControl_V4` — Token auth — CONFERENCE, HOLD, UNHOLD, MUTE, UNMUTE, KICK_CALL
|
|
- Recording: `GET /CAServices/Call/Record.php` — apiKey in query string — pause/unPause
|
|
- Abandon Calls: `GET /ca_apis/abandonCalls` — Token auth — missed calls list
|
|
|
|
---
|
|
|
|
## File Map
|
|
|
|
### Sidecar (helix-engage-server)
|
|
| File | Action |
|
|
|------|--------|
|
|
| `src/ozonetel/ozonetel-agent.service.ts` | Modify: add `callControl()`, `pauseRecording()`, `getAbandonCalls()` |
|
|
| `src/ozonetel/ozonetel-agent.controller.ts` | Modify: add `POST /api/ozonetel/call-control`, `POST /api/ozonetel/recording`, `GET /api/ozonetel/missed-calls` |
|
|
|
|
### Frontend (helix-engage)
|
|
| File | Action |
|
|
|------|--------|
|
|
| `src/components/call-desk/active-call-card.tsx` | Modify: add Transfer button + transfer input, Pause Recording button |
|
|
| `src/components/call-desk/transfer-dialog.tsx` | Create: inline transfer form (enter number, confirm) |
|
|
| `src/hooks/use-worklist.ts` | Modify: fetch missed calls from Ozonetel API instead of platform |
|
|
|
|
---
|
|
|
|
## Task 1: Add call control service methods
|
|
|
|
Three new methods in the Ozonetel service: `callControl()` (generic), `pauseRecording()`, and `getAbandonCalls()`.
|
|
|
|
**Files:**
|
|
- Modify: `helix-engage-server/src/ozonetel/ozonetel-agent.service.ts`
|
|
|
|
- [ ] **Step 1: Add `callControl()` method**
|
|
|
|
```typescript
|
|
async callControl(params: {
|
|
action: 'CONFERENCE' | 'HOLD' | 'UNHOLD' | 'MUTE' | 'UNMUTE' | 'KICK_CALL';
|
|
ucid: string;
|
|
conferenceNumber?: string;
|
|
}): Promise<{ status: string; message: string; ucid?: string }> {
|
|
const url = `https://${this.apiDomain}/ca_apis/CallControl_V4`;
|
|
const did = process.env.OZONETEL_DID ?? '918041763265';
|
|
const agentPhoneName = process.env.OZONETEL_SIP_ID ?? '523590';
|
|
|
|
this.logger.log(`Call control: action=${params.action} ucid=${params.ucid} conference=${params.conferenceNumber ?? 'none'}`);
|
|
|
|
try {
|
|
const token = await this.getToken();
|
|
const body: Record<string, string> = {
|
|
userName: this.accountId,
|
|
action: params.action,
|
|
ucid: params.ucid,
|
|
did,
|
|
agentPhoneName,
|
|
};
|
|
if (params.conferenceNumber) {
|
|
body.conferenceNumber = params.conferenceNumber;
|
|
}
|
|
|
|
const response = await axios.post(url, body, {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
this.logger.log(`Call control response: ${JSON.stringify(response.data)}`);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
|
this.logger.error(`Call control failed: ${error.message} ${responseData}`);
|
|
throw error;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add `pauseRecording()` method**
|
|
|
|
This uses apiKey in query params, not token auth:
|
|
|
|
```typescript
|
|
async pauseRecording(params: {
|
|
ucid: string;
|
|
action: 'pause' | 'unPause';
|
|
}): Promise<{ status: string; message: string }> {
|
|
const url = `https://${this.apiDomain}/CAServices/Call/Record.php`;
|
|
|
|
this.logger.log(`Recording ${params.action}: ucid=${params.ucid}`);
|
|
|
|
try {
|
|
const response = await axios.get(url, {
|
|
params: {
|
|
userName: this.accountId,
|
|
apiKey: this.apiKey,
|
|
action: params.action,
|
|
ucid: params.ucid,
|
|
},
|
|
});
|
|
|
|
this.logger.log(`Recording control response: ${JSON.stringify(response.data)}`);
|
|
return response.data;
|
|
} catch (error: any) {
|
|
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
|
this.logger.error(`Recording control failed: ${error.message} ${responseData}`);
|
|
throw error;
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Add `getAbandonCalls()` method**
|
|
|
|
```typescript
|
|
async getAbandonCalls(params?: {
|
|
fromTime?: string;
|
|
toTime?: string;
|
|
campaignName?: string;
|
|
}): Promise<Array<{
|
|
monitorUCID: string;
|
|
type: string;
|
|
status: string;
|
|
campaign: string;
|
|
callerID: string;
|
|
did: string;
|
|
agentID: string;
|
|
agent: string;
|
|
hangupBy: string;
|
|
callTime: string;
|
|
}>> {
|
|
const url = `https://${this.apiDomain}/ca_apis/abandonCalls`;
|
|
|
|
this.logger.log('Fetching abandon calls');
|
|
|
|
try {
|
|
const token = await this.getToken();
|
|
const body: Record<string, string> = { userName: this.accountId };
|
|
if (params?.fromTime) body.fromTime = params.fromTime;
|
|
if (params?.toTime) body.toTime = params.toTime;
|
|
if (params?.campaignName) body.campaignName = params.campaignName;
|
|
|
|
const response = await axios.get(url, {
|
|
headers: {
|
|
Authorization: `Bearer ${token}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
data: body,
|
|
});
|
|
|
|
const data = response.data;
|
|
if (data.status === 'success' && Array.isArray(data.message)) {
|
|
return data.message;
|
|
}
|
|
return [];
|
|
} catch (error: any) {
|
|
this.logger.error(`Abandon calls failed: ${error.message}`);
|
|
return [];
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Type check and commit**
|
|
|
|
```bash
|
|
cd helix-engage-server && npx tsc --noEmit
|
|
```
|
|
|
|
```
|
|
feat: add call control, recording pause, and abandon calls to Ozonetel service
|
|
```
|
|
|
|
---
|
|
|
|
## Task 2: Add sidecar REST endpoints
|
|
|
|
**Files:**
|
|
- Modify: `helix-engage-server/src/ozonetel/ozonetel-agent.controller.ts`
|
|
|
|
- [ ] **Step 1: Add `POST /api/ozonetel/call-control`**
|
|
|
|
```typescript
|
|
@Post('call-control')
|
|
async callControl(
|
|
@Body() body: {
|
|
action: 'CONFERENCE' | 'HOLD' | 'UNHOLD' | 'MUTE' | 'UNMUTE' | 'KICK_CALL';
|
|
ucid: string;
|
|
conferenceNumber?: string;
|
|
},
|
|
) {
|
|
if (!body.action || !body.ucid) {
|
|
throw new HttpException('action and ucid required', 400);
|
|
}
|
|
if (body.action === 'CONFERENCE' && !body.conferenceNumber) {
|
|
throw new HttpException('conferenceNumber required for CONFERENCE action', 400);
|
|
}
|
|
|
|
this.logger.log(`Call control: ${body.action} ucid=${body.ucid}`);
|
|
|
|
try {
|
|
const result = await this.ozonetelAgent.callControl(body);
|
|
return result;
|
|
} catch (error: any) {
|
|
const message = error.response?.data?.message ?? error.message ?? 'Call control failed';
|
|
throw new HttpException(message, error.response?.status ?? 502);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Add `POST /api/ozonetel/recording`**
|
|
|
|
```typescript
|
|
@Post('recording')
|
|
async recording(
|
|
@Body() body: { ucid: string; action: 'pause' | 'unPause' },
|
|
) {
|
|
if (!body.ucid || !body.action) {
|
|
throw new HttpException('ucid and action required', 400);
|
|
}
|
|
|
|
try {
|
|
const result = await this.ozonetelAgent.pauseRecording(body);
|
|
return result;
|
|
} catch (error: any) {
|
|
const message = error.response?.data?.message ?? error.message ?? 'Recording control failed';
|
|
throw new HttpException(message, error.response?.status ?? 502);
|
|
}
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 3: Add `GET /api/ozonetel/missed-calls`**
|
|
|
|
Import `Get` from `@nestjs/common`:
|
|
|
|
```typescript
|
|
@Get('missed-calls')
|
|
async missedCalls() {
|
|
const result = await this.ozonetelAgent.getAbandonCalls();
|
|
return result;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 4: Type check and commit**
|
|
|
|
```bash
|
|
cd helix-engage-server && npx tsc --noEmit
|
|
```
|
|
|
|
```
|
|
feat: add call control, recording, and missed calls REST endpoints
|
|
```
|
|
|
|
---
|
|
|
|
## Task 3: Add Transfer and Pause Recording to active call UI
|
|
|
|
During an active call, the agent gets two new buttons:
|
|
- **Transfer** — opens an inline input for the transfer number, then does CONFERENCE + KICK_CALL
|
|
- **Pause Rec** — toggles recording pause
|
|
|
|
**Files:**
|
|
- Create: `helix-engage/src/components/call-desk/transfer-dialog.tsx`
|
|
- Modify: `helix-engage/src/components/call-desk/active-call-card.tsx`
|
|
|
|
- [ ] **Step 1: Create transfer-dialog.tsx**
|
|
|
|
A simple inline form: text input for phone number + "Transfer" button. On submit, calls the sidecar's call-control endpoint twice: CONFERENCE (dial the target), then after confirming, KICK_CALL (drop the agent).
|
|
|
|
```typescript
|
|
import { useState } from 'react';
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
import { faPhoneArrowRight, faXmark } from '@fortawesome/pro-duotone-svg-icons';
|
|
import { Input } from '@/components/base/input/input';
|
|
import { Button } from '@/components/base/buttons/button';
|
|
import { apiClient } from '@/lib/api-client';
|
|
import { notify } from '@/lib/toast';
|
|
|
|
type TransferDialogProps = {
|
|
ucid: string;
|
|
onClose: () => void;
|
|
onTransferred: () => void;
|
|
};
|
|
|
|
export const TransferDialog = ({ ucid, onClose, onTransferred }: TransferDialogProps) => {
|
|
const [number, setNumber] = useState('');
|
|
const [transferring, setTransferring] = useState(false);
|
|
const [stage, setStage] = useState<'input' | 'connected'>('input');
|
|
|
|
const handleConference = async () => {
|
|
if (!number.trim()) return;
|
|
setTransferring(true);
|
|
try {
|
|
// Add the target to the conference
|
|
await apiClient.post('/api/ozonetel/call-control', {
|
|
action: 'CONFERENCE',
|
|
ucid,
|
|
conferenceNumber: `0${number.replace(/\D/g, '')}`,
|
|
});
|
|
notify.success('Connected', 'Third party connected. Click Complete to transfer.');
|
|
setStage('connected');
|
|
} catch {
|
|
notify.error('Transfer Failed', 'Could not connect to the target number');
|
|
} finally {
|
|
setTransferring(false);
|
|
}
|
|
};
|
|
|
|
const handleComplete = async () => {
|
|
setTransferring(true);
|
|
try {
|
|
// Drop the agent from the call — customer stays with the target
|
|
await apiClient.post('/api/ozonetel/call-control', {
|
|
action: 'KICK_CALL',
|
|
ucid,
|
|
conferenceNumber: `0${number.replace(/\D/g, '')}`,
|
|
});
|
|
notify.success('Transferred', 'Call transferred successfully');
|
|
onTransferred();
|
|
} catch {
|
|
notify.error('Transfer Failed', 'Could not complete transfer');
|
|
} finally {
|
|
setTransferring(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="mt-3 rounded-lg border border-secondary bg-secondary p-3">
|
|
<div className="flex items-center justify-between mb-2">
|
|
<span className="text-xs font-semibold text-secondary">Transfer Call</span>
|
|
<button onClick={onClose} className="text-fg-quaternary hover:text-fg-secondary">
|
|
<FontAwesomeIcon icon={faXmark} className="size-3" />
|
|
</button>
|
|
</div>
|
|
{stage === 'input' ? (
|
|
<div className="flex gap-2">
|
|
<Input
|
|
size="sm"
|
|
placeholder="Enter phone number"
|
|
value={number}
|
|
onChange={setNumber}
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
color="primary"
|
|
isLoading={transferring}
|
|
onClick={handleConference}
|
|
isDisabled={!number.trim()}
|
|
>
|
|
Connect
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs text-tertiary">Connected to {number}</span>
|
|
<Button size="sm" color="primary" isLoading={transferring} onClick={handleComplete}>
|
|
Complete Transfer
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
```
|
|
|
|
- [ ] **Step 2: Add Transfer and Pause Recording buttons to active call card**
|
|
|
|
In `active-call-card.tsx`, add imports:
|
|
```typescript
|
|
import { faPhoneArrowRight, faRecordVinyl } from '@fortawesome/pro-duotone-svg-icons';
|
|
import { TransferDialog } from './transfer-dialog';
|
|
```
|
|
|
|
Add state:
|
|
```typescript
|
|
const [transferOpen, setTransferOpen] = useState(false);
|
|
const [recordingPaused, setRecordingPaused] = useState(false);
|
|
```
|
|
|
|
In the active call button row (around line 241), add two new buttons before the End button:
|
|
|
|
```typescript
|
|
<Button size="sm" color="secondary"
|
|
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faPhoneArrowRight} className={className} />}
|
|
onClick={() => setTransferOpen(!transferOpen)}>Transfer</Button>
|
|
<Button size="sm" color={recordingPaused ? 'primary-destructive' : 'secondary'}
|
|
iconLeading={({ className }: { className?: string }) => <FontAwesomeIcon icon={faRecordVinyl} className={className} />}
|
|
onClick={async () => {
|
|
const action = recordingPaused ? 'unPause' : 'pause';
|
|
if (callUcid) {
|
|
apiClient.post('/api/ozonetel/recording', { ucid: callUcid, action }).catch(() => {});
|
|
}
|
|
setRecordingPaused(!recordingPaused);
|
|
}}>{recordingPaused ? 'Resume Rec' : 'Pause Rec'}</Button>
|
|
```
|
|
|
|
After the button row, before the AppointmentForm, add the transfer dialog:
|
|
```typescript
|
|
{transferOpen && callUcid && (
|
|
<TransferDialog
|
|
ucid={callUcid}
|
|
onClose={() => setTransferOpen(false)}
|
|
onTransferred={() => {
|
|
setTransferOpen(false);
|
|
hangup();
|
|
setPostCallStage('disposition');
|
|
}}
|
|
/>
|
|
)}
|
|
```
|
|
|
|
- [ ] **Step 3: Type check and commit**
|
|
|
|
```bash
|
|
cd helix-engage && npx tsc --noEmit
|
|
```
|
|
|
|
```
|
|
feat: add call transfer and recording pause to active call UI
|
|
```
|
|
|
|
---
|
|
|
|
## Task 4: Deploy and verify
|
|
|
|
- [ ] **Step 1: Build and deploy sidecar**
|
|
|
|
```bash
|
|
cd helix-engage-server && npm run build
|
|
# tar + scp + docker cp + restart
|
|
```
|
|
|
|
- [ ] **Step 2: Build and deploy frontend**
|
|
|
|
```bash
|
|
cd helix-engage
|
|
VITE_API_URL=https://engage-api.srv1477139.hstgr.cloud \
|
|
VITE_SIP_URI=sip:523590@blr-pub-rtc4.ozonetel.com \
|
|
VITE_SIP_PASSWORD=523590 \
|
|
VITE_SIP_WS_SERVER=wss://blr-pub-rtc4.ozonetel.com:444 \
|
|
npm run build
|
|
```
|
|
|
|
- [ ] **Step 3: Test call transfer**
|
|
|
|
1. Place an outbound call
|
|
2. Click "Transfer" → enter a phone number → "Connect"
|
|
3. Third party should ring and join the call
|
|
4. Click "Complete Transfer" → agent drops, customer stays with target
|
|
5. Disposition form shows
|
|
|
|
- [ ] **Step 4: Test recording pause**
|
|
|
|
1. During an active call, click "Pause Rec"
|
|
2. Button changes to "Resume Rec" (destructive color)
|
|
3. Check Ozonetel reports — recording should have a gap
|
|
4. Click "Resume Rec" — recording resumes
|
|
|
|
- [ ] **Step 5: Test missed calls endpoint**
|
|
|
|
```bash
|
|
curl -s https://engage-api.srv1477139.hstgr.cloud/api/ozonetel/missed-calls | python3 -m json.tool
|
|
```
|
|
|
|
Verify it returns abandon call data from Ozonetel.
|
|
|
|
---
|
|
|
|
## Notes
|
|
|
|
- **Call Transfer is two-step**: CONFERENCE adds the target, KICK_CALL drops the agent. This is a "warm transfer" — all three parties are briefly connected before the agent drops. For "cold transfer" (blind), we'd CONFERENCE + immediately KICK_CALL without waiting.
|
|
- **Recording pause uses apiKey in query params** — different auth pattern from other `/ca_apis/` endpoints. This is the `/CAServices/` path.
|
|
- **KICK_CALL note from docs**: "Always pass the agent phone number in the conferenceNumber parameter to use KICK_CALL action." This means to drop the agent, pass the agent's phone number as conferenceNumber. To drop the transferred party, pass their number.
|
|
- **Missed calls API** — the `getAbandonCalls` returns today's data by default. For historical data, pass fromTime/toTime.
|
|
- **The active call button row is getting crowded** (Mute, Hold, Book Appt, Transfer, Pause Rec, End — 6 buttons). If this is too many, we can group Transfer + Pause Rec under a "More" dropdown.
|