@cemscale-voip/voip-sdk 2.0.11 → 2.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +443 -46
- package/dist/client.d.ts +3531 -211
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +3456 -136
- package/dist/client.js.map +1 -1
- package/dist/hooks/useVoIP.d.ts.map +1 -1
- package/dist/hooks/useVoIP.js +26 -4
- package/dist/hooks/useVoIP.js.map +1 -1
- package/dist/types.d.ts +1172 -32
- package/dist/types.d.ts.map +1 -1
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -45,7 +45,35 @@ export class VoIPClient {
|
|
|
45
45
|
setApiKey(apiKey) { this.apiKey = apiKey; }
|
|
46
46
|
/** Get the current API key */
|
|
47
47
|
getApiKey() { return this.apiKey; }
|
|
48
|
-
/**
|
|
48
|
+
/**
|
|
49
|
+
* Set JWT token manually (only for browser login flows — most apps should use apiKey).
|
|
50
|
+
*
|
|
51
|
+
* @description
|
|
52
|
+
* Restores a previously-obtained JWT token into the client so all subsequent API calls
|
|
53
|
+
* use Bearer authentication. Use this to rehydrate a session after a page reload:
|
|
54
|
+
* get the token from localStorage/sessionStorage and call `voip.setToken(token)`.
|
|
55
|
+
*
|
|
56
|
+
* @param token — The JWT token string from a prior `login()`, `adminLogin()`, or `superadminLogin()` call.
|
|
57
|
+
*
|
|
58
|
+
* @example
|
|
59
|
+
* ```typescript
|
|
60
|
+
* // Page reload: restore token from localStorage
|
|
61
|
+
* const savedToken = localStorage.getItem('voip-token');
|
|
62
|
+
* if (savedToken) {
|
|
63
|
+
* voip.setToken(savedToken);
|
|
64
|
+
* // Verify it's still valid
|
|
65
|
+
* try {
|
|
66
|
+
* const { user } = await voip.me();
|
|
67
|
+
* console.log('Session restored:', user.displayName);
|
|
68
|
+
* } catch {
|
|
69
|
+
* // Token expired — redirect to login
|
|
70
|
+
* localStorage.removeItem('voip-token');
|
|
71
|
+
* }
|
|
72
|
+
* }
|
|
73
|
+
* ```
|
|
74
|
+
*
|
|
75
|
+
* @see login, adminLogin, superadminLogin
|
|
76
|
+
*/
|
|
49
77
|
setToken(token) { this.token = token; }
|
|
50
78
|
/** Get current JWT token */
|
|
51
79
|
getToken() { return this.token; }
|
|
@@ -133,14 +161,103 @@ export class VoIPClient {
|
|
|
133
161
|
// -------------------------------------------------------------------
|
|
134
162
|
// Auth
|
|
135
163
|
// -------------------------------------------------------------------
|
|
136
|
-
/**
|
|
164
|
+
/**
|
|
165
|
+
* Log in as an extension user with username, password, and tenant ID.
|
|
166
|
+
*
|
|
167
|
+
* @description
|
|
168
|
+
* Authenticates a PBX extension user (e.g. 1001, 1002) and returns a JWT token.
|
|
169
|
+
* The token is automatically stored in the client instance for subsequent requests.
|
|
170
|
+
* This is the login flow for softphone users, desk phone users, and CRM integrations
|
|
171
|
+
* that authenticate as a specific extension rather than as a tenant admin.
|
|
172
|
+
*
|
|
173
|
+
* After successful login, the client:
|
|
174
|
+
* - Stores the JWT token for all future API calls
|
|
175
|
+
* - Sets the tenant ID from the `tenantId` parameter
|
|
176
|
+
*
|
|
177
|
+
* For admin login, use `adminLogin()`. For platform superadmin, use `superadminLogin()`.
|
|
178
|
+
*
|
|
179
|
+
* @param params — Login parameters
|
|
180
|
+
* @param params.username — The extension number (e.g. `'1001'` or `'2002'`)
|
|
181
|
+
* @param params.password — The extension's SIP password
|
|
182
|
+
* @param params.tenantId — The UUID of the tenant this extension belongs to
|
|
183
|
+
*
|
|
184
|
+
* @returns An object containing:
|
|
185
|
+
* - `token: string` — JWT token (also stored internally in the client)
|
|
186
|
+
* - `user?: object` — User info including `id`, `extension`, `displayName`, `tenantId`
|
|
187
|
+
*
|
|
188
|
+
* @throws {HttpError} 401 — Invalid credentials (wrong username, password, or tenant)
|
|
189
|
+
*
|
|
190
|
+
* @example
|
|
191
|
+
* ```typescript
|
|
192
|
+
* const voip = new VoIPClient({ apiUrl: 'https://voip-api.cemscale.com' });
|
|
193
|
+
*
|
|
194
|
+
* const { token, user } = await voip.login({
|
|
195
|
+
* username: '1001',
|
|
196
|
+
* password: 'sip-password-here',
|
|
197
|
+
* tenantId: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
|
|
198
|
+
* });
|
|
199
|
+
*
|
|
200
|
+
* console.log(`Logged in as ${user?.displayName} (${user?.extension})`);
|
|
201
|
+
* ```
|
|
202
|
+
*
|
|
203
|
+
* @see POST /api/auth/login
|
|
204
|
+
*/
|
|
137
205
|
async login(params) {
|
|
138
206
|
const result = await this.request('POST', '/api/auth/login', params);
|
|
139
207
|
this.token = result.token;
|
|
140
208
|
this.tenantId = params.tenantId;
|
|
141
209
|
return result;
|
|
142
210
|
}
|
|
143
|
-
/**
|
|
211
|
+
/**
|
|
212
|
+
* Log in as a tenant administrator with email and password.
|
|
213
|
+
*
|
|
214
|
+
* @description
|
|
215
|
+
* Authenticates a tenant admin user and returns a JWT token. The admin role has
|
|
216
|
+
* full CRUD access to all resources within their tenant: extensions, DIDs, IVR menus,
|
|
217
|
+
* ring groups, queues, webhooks, schedules, blocklist, recordings, voicemails, and reports.
|
|
218
|
+
*
|
|
219
|
+
* After successful login, the client:
|
|
220
|
+
* - Stores the JWT token for all future API calls
|
|
221
|
+
* - Automatically sets the tenant ID from the response (the admin's own tenant)
|
|
222
|
+
*
|
|
223
|
+
* Use this for dashboard/admin panel logins. For extension-level (softphone) login,
|
|
224
|
+
* use `login()`. For cross-tenant superadmin access, use `superadminLogin()`.
|
|
225
|
+
*
|
|
226
|
+
* **CRM UI guidance:** Store the returned token in localStorage/sessionStorage and
|
|
227
|
+
* rehydrate it on page load via `setToken(token)`. Redirect to the dashboard
|
|
228
|
+
* after successful login. Show a "Forgot password?" link and rate-limit the login
|
|
229
|
+
* button to prevent brute-force attempts (the API enforces rate limiting server-side).
|
|
230
|
+
*
|
|
231
|
+
* @param params — Login parameters
|
|
232
|
+
* @param params.email — The admin's email address
|
|
233
|
+
* @param params.password — The admin's password
|
|
234
|
+
*
|
|
235
|
+
* @returns An object containing:
|
|
236
|
+
* - `token: string` — JWT token (also stored internally in the client)
|
|
237
|
+
* - `user?: object` — User info including `id`, `extension`, `displayName`, `tenantId`
|
|
238
|
+
* - `tenant?: object` — Tenant info including `id`, `subdomain`, `companyName`
|
|
239
|
+
*
|
|
240
|
+
* @throws {HttpError} 401 — Invalid credentials (wrong email or password)
|
|
241
|
+
* @throws {HttpError} 403 — Account is disabled or suspended
|
|
242
|
+
*
|
|
243
|
+
* @example
|
|
244
|
+
* ```typescript
|
|
245
|
+
* const voip = new VoIPClient({ apiUrl: 'https://voip-api.cemscale.com' });
|
|
246
|
+
*
|
|
247
|
+
* const { token, user, tenant } = await voip.adminLogin({
|
|
248
|
+
* email: 'admin@acmecorp.com',
|
|
249
|
+
* password: 'secure-password',
|
|
250
|
+
* });
|
|
251
|
+
*
|
|
252
|
+
* console.log(`Logged in to ${tenant?.companyName} as ${user?.displayName}`);
|
|
253
|
+
*
|
|
254
|
+
* // CRM UI: store token for page reloads
|
|
255
|
+
* localStorage.setItem('voip-token', token);
|
|
256
|
+
* localStorage.setItem('voip-tenant', JSON.stringify(tenant));
|
|
257
|
+
* ```
|
|
258
|
+
*
|
|
259
|
+
* @see POST /api/auth/admin-login
|
|
260
|
+
*/
|
|
144
261
|
async adminLogin(params) {
|
|
145
262
|
const result = await this.request('POST', '/api/auth/admin-login', params);
|
|
146
263
|
this.token = result.token;
|
|
@@ -148,7 +265,61 @@ export class VoIPClient {
|
|
|
148
265
|
this.tenantId = result.tenant.id;
|
|
149
266
|
return result;
|
|
150
267
|
}
|
|
151
|
-
/**
|
|
268
|
+
/**
|
|
269
|
+
* Log in as a platform superadmin with email and password.
|
|
270
|
+
*
|
|
271
|
+
* @description
|
|
272
|
+
* Authenticates a platform superadmin — the highest privilege level that has access
|
|
273
|
+
* to ALL tenants and global resources. Superadmins can manage tenants, create global
|
|
274
|
+
* SIP trunks, administer API keys for any tenant, and view cross-tenant reports.
|
|
275
|
+
*
|
|
276
|
+
* Unlike `adminLogin()`, this does NOT auto-set a tenant ID because superadmins
|
|
277
|
+
* operate across all tenants. Use `setTenantId()` or the per-request `options.tenantId`
|
|
278
|
+
* parameter when acting on behalf of a specific tenant.
|
|
279
|
+
*
|
|
280
|
+
* Superadmin can also impersonate any tenant by setting the `X-Tenant-ID` header,
|
|
281
|
+
* which is passed automatically when you use `options.tenantId` on any SDK method.
|
|
282
|
+
*
|
|
283
|
+
* **CRM UI guidance:** After login, show the tenant selector dropdown so the superadmin
|
|
284
|
+
* can pick which tenant to manage. Store the selected tenant ID in state and pass it
|
|
285
|
+
* as `{ tenantId }` on every SDK call. Show "Superadmin" badge in the header.
|
|
286
|
+
*
|
|
287
|
+
* @param params — Login parameters
|
|
288
|
+
* @param params.email — The superadmin's email address
|
|
289
|
+
* @param params.password — The superadmin's password
|
|
290
|
+
*
|
|
291
|
+
* @returns An object containing:
|
|
292
|
+
* - `token: string` — JWT token (also stored internally in the client)
|
|
293
|
+
* - `user?: object` — User info including `id`, `extension`, `displayName`, `tenantId`
|
|
294
|
+
* - `tenant?: object` — Tenant info (typically null for superadmin)
|
|
295
|
+
*
|
|
296
|
+
* @throws {HttpError} 401 — Invalid credentials (wrong email or password)
|
|
297
|
+
* @throws {HttpError} 403 — Account is not a superadmin
|
|
298
|
+
*
|
|
299
|
+
* @example
|
|
300
|
+
* ```typescript
|
|
301
|
+
* const voip = new VoIPClient({ apiUrl: 'https://voip-api.cemscale.com' });
|
|
302
|
+
*
|
|
303
|
+
* const { token, user } = await voip.superadminLogin({
|
|
304
|
+
* email: 'superadmin@cemscale.com',
|
|
305
|
+
* password: 'secure-password',
|
|
306
|
+
* });
|
|
307
|
+
*
|
|
308
|
+
* // Superadmin: list ALL tenants
|
|
309
|
+
* const { tenants } = await voip.listTenants();
|
|
310
|
+
* console.log(`Managing ${tenants.length} tenants`);
|
|
311
|
+
*
|
|
312
|
+
* // Superadmin: view calls for a specific tenant
|
|
313
|
+
* const { calls } = await voip.listCalls({ tenantId: 'a1b2c3d4-...' });
|
|
314
|
+
*
|
|
315
|
+
* // CRM UI: tenant selector
|
|
316
|
+
* tenants.forEach(t => {
|
|
317
|
+
* renderTenantOption({ value: t.id, label: `${t.companyName} (${t.subdomain})` });
|
|
318
|
+
* });
|
|
319
|
+
* ```
|
|
320
|
+
*
|
|
321
|
+
* @see POST /api/auth/superadmin-login
|
|
322
|
+
*/
|
|
152
323
|
async superadminLogin(params) {
|
|
153
324
|
const result = await this.request('POST', '/api/auth/superadmin-login', params);
|
|
154
325
|
this.token = result.token;
|
|
@@ -158,7 +329,55 @@ export class VoIPClient {
|
|
|
158
329
|
async me() {
|
|
159
330
|
return this.request('GET', '/api/auth/me');
|
|
160
331
|
}
|
|
161
|
-
/**
|
|
332
|
+
/**
|
|
333
|
+
* Get TURN/STUN credentials for establishing WebRTC media connections.
|
|
334
|
+
*
|
|
335
|
+
* @description
|
|
336
|
+
* Returns time-limited TURN credentials (username, credential, TURN server URLs) used
|
|
337
|
+
* to establish peer-to-peer WebRTC media connections through the coTURN server.
|
|
338
|
+
* These credentials are auto-generated per tenant and expire after the TTL period.
|
|
339
|
+
*
|
|
340
|
+
* **When to call this:**
|
|
341
|
+
* - Before creating a `WebRTCPhone` instance — pass the credentials to the SIP.js UA config
|
|
342
|
+
* - When TURN credentials expire during a long call — re-fetch and update the ICE transport
|
|
343
|
+
* - On page load in a WebRTC-based softphone app
|
|
344
|
+
*
|
|
345
|
+
* The TTL (time-to-live) indicates how many seconds the credential is valid.
|
|
346
|
+
* Re-fetch credentials before the TTL expires if the user is on a long call.
|
|
347
|
+
*
|
|
348
|
+
* **CRM UI guidance:** Call this once when the WebRTC softphone initializes.
|
|
349
|
+
* Show a "Connecting..." spinner while fetching. If it fails, show
|
|
350
|
+
* "Unable to connect to media server — please check your network" with a Retry button.
|
|
351
|
+
*
|
|
352
|
+
* @returns An object containing:
|
|
353
|
+
* - `urls: string[]` — Array of TURN server URLs (e.g. `['turn:3.20.219.41:3478?transport=udp']`)
|
|
354
|
+
* - `username: string` — Time-limited TURN username
|
|
355
|
+
* - `credential: string` — Time-limited TURN password
|
|
356
|
+
* - `ttl: number` — Time-to-live in seconds before the credential expires
|
|
357
|
+
*
|
|
358
|
+
* @throws {HttpError} 401 — Missing or invalid API key/token
|
|
359
|
+
* @throws {HttpError} 500 — TURN server configuration error
|
|
360
|
+
*
|
|
361
|
+
* @example
|
|
362
|
+
* ```typescript
|
|
363
|
+
* // Fetch credentials for WebRTC phone setup
|
|
364
|
+
* const { urls, username, credential, ttl } = await voip.getTurnCredentials();
|
|
365
|
+
*
|
|
366
|
+
* // Pass to WebRTCPhone or SIP.js UA
|
|
367
|
+
* const phone = new WebRTCPhone({
|
|
368
|
+
* iceServers: [{ urls, username, credential }],
|
|
369
|
+
* // ...
|
|
370
|
+
* });
|
|
371
|
+
*
|
|
372
|
+
* // Refresh before TTL expires on long calls
|
|
373
|
+
* setTimeout(async () => {
|
|
374
|
+
* const newCreds = await voip.getTurnCredentials();
|
|
375
|
+
* phone.updateIceServers([{ urls: newCreds.urls, username: newCreds.username, credential: newCreds.credential }]);
|
|
376
|
+
* }, (ttl - 30) * 1000); // Refresh 30s before expiry
|
|
377
|
+
* ```
|
|
378
|
+
*
|
|
379
|
+
* @see GET /api/auth/turn-credentials
|
|
380
|
+
*/
|
|
162
381
|
async getTurnCredentials() {
|
|
163
382
|
return this.request('GET', '/api/auth/turn-credentials');
|
|
164
383
|
}
|
|
@@ -179,27 +398,77 @@ export class VoIPClient {
|
|
|
179
398
|
// See the CallRecord type JSDoc for the full playback guide.
|
|
180
399
|
// -------------------------------------------------------------------
|
|
181
400
|
/**
|
|
182
|
-
* List call records (CDR) with pagination and
|
|
401
|
+
* List historical call records (CDR — Call Detail Records) with pagination, filtering, and search.
|
|
402
|
+
*
|
|
403
|
+
* @description
|
|
404
|
+
* Retrieves a paginated list of completed/missed/failed call records for the current tenant.
|
|
405
|
+
* Think of this as your "call history" table — every call that went through the phone system
|
|
406
|
+
* appears here, with caller ID, destination, duration, status, and optional recording URLs.
|
|
407
|
+
*
|
|
408
|
+
* **Recording playback:** Each call with a recording includes `cdn_url` (CloudFront, instant
|
|
409
|
+
* playback, no auth required) and `audio_url` (API fallback, requires auth). The `cdn_url`
|
|
410
|
+
* is a permanent URL cached at 400+ global edge locations — you can store it in your database,
|
|
411
|
+
* send it in emails, or embed it in `<audio>` tags directly.
|
|
412
|
+
*
|
|
413
|
+
* **Superadmin mode:** When using a global superadmin API key, calls from ALL tenants are
|
|
414
|
+
* returned. Each call includes `company_name` and `subdomain` fields for tenant identification.
|
|
183
415
|
*
|
|
184
|
-
*
|
|
185
|
-
*
|
|
416
|
+
* @param params — Optional filters and pagination
|
|
417
|
+
* @param params.page — Page number (1-based, default 1)
|
|
418
|
+
* @param params.limit — Records per page (max 500, default 50). Example: `50`
|
|
419
|
+
* @param params.direction — Filter by call direction. `'inbound'` = someone called you,
|
|
420
|
+
* `'outbound'` = you called someone, `'internal'` = extension-to-extension, `'all'` = everything
|
|
421
|
+
* @param params.status — Filter by call outcome. `'completed'` = answered, `'failed'` = error,
|
|
422
|
+
* `'missed'` = no answer, `'voicemail'` = went to voicemail
|
|
423
|
+
* @param params.dateFrom — ISO date string: only calls on or after this date. Example: `'2026-04-01'`
|
|
424
|
+
* @param params.dateTo — ISO date string: only calls on or before this date. Example: `'2026-04-30'`
|
|
425
|
+
* @param params.extension — Filter by extension number (matches caller or destination). Example: `'1001'`
|
|
426
|
+
* @param params.number — Search by phone number or name (partial match, case-insensitive). Example: `'+1555123'`
|
|
427
|
+
* @param params.forwarded — `'true'` = only forwarded calls, `'false'` = only non-forwarded
|
|
428
|
+
* @param params.forwardedTo — Search by the external number a call was forwarded to. Example: `'+1555999'`
|
|
429
|
+
* @param params.tenantId — Filter by tenant (superadmin only, alternative to X-Tenant-ID header)
|
|
430
|
+
*
|
|
431
|
+
* @returns An object containing:
|
|
432
|
+
* - `calls: CallRecord[]` — Array of call records. Each record has `id`, `call_uuid`,
|
|
433
|
+
* `direction`, `caller_id_number`, `caller_id_name`, `destination`, `status`,
|
|
434
|
+
* `duration_seconds`, `billsec` (billed seconds), `forwarded_to`, `was_voicemail`,
|
|
435
|
+
* `cdn_url` (CloudFront recording URL, no auth), `audio_url` (API fallback),
|
|
436
|
+
* `started_at`, `answered_at`, `ended_at`, and optional `company_name`/`subdomain` (superadmin).
|
|
437
|
+
* - `pagination: PaginationInfo` — `{ page, limit, total, pages }`. Use `total` for
|
|
438
|
+
* "Showing 1-50 of 342 calls" and `pages` for page navigation.
|
|
439
|
+
*
|
|
440
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
441
|
+
* @throws {HttpError} 403 — API key doesn't have access to the tenant
|
|
186
442
|
*
|
|
187
443
|
* @example
|
|
188
444
|
* ```typescript
|
|
445
|
+
* // Basic list — show last 50 calls
|
|
446
|
+
* const { calls, pagination } = await voip.listCalls({ limit: 50 });
|
|
447
|
+
*
|
|
448
|
+
* // Filtered list — all inbound answered calls this month
|
|
189
449
|
* const { calls, pagination } = await voip.listCalls({
|
|
190
450
|
* direction: 'inbound',
|
|
191
451
|
* status: 'completed',
|
|
192
|
-
* dateFrom: '2026-
|
|
193
|
-
*
|
|
452
|
+
* dateFrom: '2026-06-01',
|
|
453
|
+
* dateTo: '2026-06-30',
|
|
454
|
+
* limit: 100,
|
|
194
455
|
* });
|
|
195
456
|
*
|
|
457
|
+
* // CRM UI: build a call history table
|
|
458
|
+
* const formatDuration = (s: number | null) => s ? `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}` : '—';
|
|
196
459
|
* calls.forEach(call => {
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
* console.log(` Recording: ${call.cdn_url}`); // instant CDN playback
|
|
200
|
-
* }
|
|
460
|
+
* const recordingSrc = call.cdn_url ?? voip.buildUrl(`/api/recordings/${call.id}/audio`);
|
|
461
|
+
* console.log(`${call.started_at} | ${call.direction} | ${call.caller_id_number} → ${call.destination} | ${formatDuration(call.billsec)} | ${recordingSrc ? '🔊' : '—'}`);
|
|
201
462
|
* });
|
|
463
|
+
*
|
|
464
|
+
* // Pagination — page through all results
|
|
465
|
+
* for (let page = 1; page <= pagination.pages; page++) {
|
|
466
|
+
* const { calls } = await voip.listCalls({ page, limit: 50 });
|
|
467
|
+
* // render each page
|
|
468
|
+
* }
|
|
202
469
|
* ```
|
|
470
|
+
*
|
|
471
|
+
* @see GET /api/calls
|
|
203
472
|
*/
|
|
204
473
|
async listCalls(params) {
|
|
205
474
|
return this.request('GET', '/api/calls', undefined, params);
|
|
@@ -224,45 +493,322 @@ export class VoIPClient {
|
|
|
224
493
|
async getCall(id) {
|
|
225
494
|
return this.request('GET', `/api/calls/${id}`);
|
|
226
495
|
}
|
|
227
|
-
/**
|
|
496
|
+
/**
|
|
497
|
+
* Start a new outbound phone call from an extension to any destination number.
|
|
498
|
+
*
|
|
499
|
+
* @description
|
|
500
|
+
* Tells the phone system to dial an external or internal number FROM a specific extension.
|
|
501
|
+
* The extension's physical phone (desk phone, softphone, or WebRTC phone) will ring first,
|
|
502
|
+
* and when answered, the system connects it to the destination.
|
|
503
|
+
*
|
|
504
|
+
* This is the primary way to make outbound calls programmatically — also known as
|
|
505
|
+
* "click-to-call." The CRM clicks a phone number, the user's phone rings, they pick up,
|
|
506
|
+
* and the system dials the customer.
|
|
507
|
+
*
|
|
508
|
+
* **Superadmin mode:** Pass `{ tenantId: 'uuid-here' }` as the `options` parameter when
|
|
509
|
+
* using a global superadmin API key to originate calls on behalf of a specific tenant.
|
|
510
|
+
*
|
|
511
|
+
* @param params — Call parameters
|
|
512
|
+
* @param params.fromExtension — The extension number making the call. This extension's
|
|
513
|
+
* phone will ring first. Example: `'1001'`
|
|
514
|
+
* @param params.toNumber — The destination phone number in E.164 format (+country code +
|
|
515
|
+
* number) or an internal extension number. Examples: `'+17865551234'` (PSTN), `'1002'` (internal)
|
|
516
|
+
* @param options — Optional request overrides
|
|
517
|
+
* @param options.tenantId — Target tenant UUID (required for superadmin keys only).
|
|
518
|
+
* Example: `'a1b2c3d4-...'`
|
|
519
|
+
*
|
|
520
|
+
* @returns An object containing:
|
|
521
|
+
* - `message: string` — Human-readable confirmation (e.g. "Call originated successfully")
|
|
522
|
+
* - `callUuid: string` — The FreeSWITCH call UUID for tracking. Use this to monitor the
|
|
523
|
+
* call via WebSocket events (`call.answered`, `call.end`) and to call `hangup()`,
|
|
524
|
+
* `transfer()`, `holdCall()`, or `parkCall()`
|
|
525
|
+
*
|
|
526
|
+
* @throws {HttpError} 400 — `fromExtension` doesn't exist, `toNumber` is invalid, or extension is disabled
|
|
527
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
528
|
+
* @throws {HttpError} 500 — FreeSWITCH connection failed or originate error
|
|
529
|
+
*
|
|
530
|
+
* @example
|
|
531
|
+
* ```typescript
|
|
532
|
+
* // Click-to-call from extension 1001 to customer +17865551234
|
|
533
|
+
* const { callUuid, message } = await voip.originate({
|
|
534
|
+
* fromExtension: '1001',
|
|
535
|
+
* toNumber: '+17865551234',
|
|
536
|
+
* });
|
|
537
|
+
* console.log(message); // "Call originated successfully"
|
|
538
|
+
* console.log(`Tracking UUID: ${callUuid}`);
|
|
539
|
+
*
|
|
540
|
+
* // CRM UI: Click a phone number in the contact card
|
|
541
|
+
* async function handleClickToCall(extension: string, customerPhone: string) {
|
|
542
|
+
* try {
|
|
543
|
+
* const { callUuid } = await voip.originate({
|
|
544
|
+
* fromExtension: extension,
|
|
545
|
+
* toNumber: customerPhone,
|
|
546
|
+
* });
|
|
547
|
+
* // Show "Dialing..." state in the UI
|
|
548
|
+
* // Listen for WebSocket 'call_start' event to confirm the call connected
|
|
549
|
+
* showCallBanner(callUuid, customerPhone, 'dialing');
|
|
550
|
+
* } catch (err) {
|
|
551
|
+
* if (err instanceof HttpError && err.status === 400) {
|
|
552
|
+
* showError('Invalid phone number — please check the format');
|
|
553
|
+
* }
|
|
554
|
+
* }
|
|
555
|
+
* }
|
|
556
|
+
*
|
|
557
|
+
* // Superadmin: originate for a specific tenant
|
|
558
|
+
* const { callUuid } = await voip.originate(
|
|
559
|
+
* { fromExtension: '1001', toNumber: '+17865551234' },
|
|
560
|
+
* { tenantId: 'a1b2c3d4-...' },
|
|
561
|
+
* );
|
|
562
|
+
* ```
|
|
563
|
+
*
|
|
564
|
+
* @see POST /api/calls/originate
|
|
565
|
+
*/
|
|
228
566
|
async originate(params, options) {
|
|
229
567
|
return this.request('POST', '/api/calls/originate', params, undefined, options);
|
|
230
568
|
}
|
|
231
|
-
/**
|
|
569
|
+
/**
|
|
570
|
+
* End (hang up) an active call immediately.
|
|
571
|
+
*
|
|
572
|
+
* @description
|
|
573
|
+
* Terminates an in-progress call identified by its FreeSWITCH channel UUID.
|
|
574
|
+
* This is the server-side equivalent of physically hanging up the phone — both parties
|
|
575
|
+
* are disconnected. Works on any active call: WebRTC, desk phone, PSTN, or conference leg.
|
|
576
|
+
*
|
|
577
|
+
* After calling this, expect a `call_end` WebSocket event confirming the call terminated.
|
|
578
|
+
*
|
|
579
|
+
* @param uuid — The FreeSWITCH channel UUID of the active call to hang up.
|
|
580
|
+
* You get this from:
|
|
581
|
+
* - `originate()` → `callUuid`
|
|
582
|
+
* - `getActiveCalls()` → `activeCalls[].uuid`
|
|
583
|
+
* - `getParkedCalls()` → `parkedCalls[].uuid`
|
|
584
|
+
* - WebSocket `call_start` event → `call.uuid`
|
|
585
|
+
* Example: `'a1b2c3d4-e5f6-7890-abcd-ef1234567890'`
|
|
586
|
+
*
|
|
587
|
+
* @returns An object containing:
|
|
588
|
+
* - `message: string` — Confirmation message (e.g. "Call hung up successfully")
|
|
589
|
+
*
|
|
590
|
+
* @throws {HttpError} 400 — The call UUID is not found or the call is already terminated
|
|
591
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
592
|
+
* @throws {HttpError} 500 — FreeSWITCH communication error
|
|
593
|
+
*
|
|
594
|
+
* @example
|
|
595
|
+
* ```typescript
|
|
596
|
+
* // Hang up the call that was just originated
|
|
597
|
+
* const { callUuid } = await voip.originate({
|
|
598
|
+
* fromExtension: '1001',
|
|
599
|
+
* toNumber: '+17865551234',
|
|
600
|
+
* });
|
|
601
|
+
*
|
|
602
|
+
* // ... later, when the user clicks "End Call" in the UI
|
|
603
|
+
* await voip.hangup(callUuid);
|
|
604
|
+
*
|
|
605
|
+
* // CRM UI: End Call button
|
|
606
|
+
* async function handleEndCall(callUuid: string) {
|
|
607
|
+
* await voip.hangup(callUuid);
|
|
608
|
+
* hideCallBanner();
|
|
609
|
+
* showNotification('Call ended');
|
|
610
|
+
* }
|
|
611
|
+
*
|
|
612
|
+
* // Hang up from getActiveCalls
|
|
613
|
+
* const { activeCalls } = await voip.getActiveCalls();
|
|
614
|
+
* const currentCall = activeCalls.find(c => c.caller_id_number === '+17865551234');
|
|
615
|
+
* if (currentCall) {
|
|
616
|
+
* await voip.hangup(currentCall.uuid);
|
|
617
|
+
* }
|
|
618
|
+
* ```
|
|
619
|
+
*
|
|
620
|
+
* @see POST /api/calls/{uuid}/hangup
|
|
621
|
+
*/
|
|
232
622
|
async hangup(uuid) {
|
|
233
623
|
return this.request('POST', `/api/calls/${uuid}/hangup`);
|
|
234
624
|
}
|
|
235
|
-
/**
|
|
625
|
+
/**
|
|
626
|
+
* Transfer an active call to another extension.
|
|
627
|
+
*
|
|
628
|
+
* @description
|
|
629
|
+
* Moves an in-progress call from one extension to another. Two transfer modes:
|
|
630
|
+
*
|
|
631
|
+
* - **Blind transfer** (`type: 'blind'`, default): Immediately transfers the caller
|
|
632
|
+
* to the target extension without consulting the target first. The original extension
|
|
633
|
+
* is disconnected. Use this for "I'll transfer you now" — the caller hears hold music
|
|
634
|
+
* until the target picks up.
|
|
635
|
+
*
|
|
636
|
+
* - **Attended transfer** (`type: 'attended'`): The original extension calls the target
|
|
637
|
+
* first, talks to them ("I have Mr. Smith on the line about his invoice"), and then
|
|
638
|
+
* the call is connected. Requires a two-step UI flow with `addCallParticipant()`
|
|
639
|
+
* and `mergeCalls()` for the full attended transfer sequence.
|
|
640
|
+
*
|
|
641
|
+
* @param uuid — The FreeSWITCH channel UUID of the active call to transfer.
|
|
642
|
+
* Get this from `originate()` → `callUuid` or `getActiveCalls()`.
|
|
643
|
+
* Example: `'a1b2c3d4-e5f6-7890-abcd-ef1234567890'`
|
|
644
|
+
* @param params — Transfer parameters
|
|
645
|
+
* @param params.targetExtension — The extension to transfer the call TO.
|
|
646
|
+
* This is the recipient — the person who will receive the call.
|
|
647
|
+
* Example: `'1002'` (extension), `'+17865551234'` (external number)
|
|
648
|
+
* @param params.type — Transfer type: `'blind'` (default, immediate) or `'attended'`
|
|
649
|
+
* (talk to target first, then connect). Example: `'blind'`
|
|
650
|
+
*
|
|
651
|
+
* @returns An object containing:
|
|
652
|
+
* - `message: string` — Confirmation message (e.g. "Call transferred successfully")
|
|
653
|
+
*
|
|
654
|
+
* @throws {HttpError} 400 — Call UUID not found, target extension doesn't exist, or target is the same as caller
|
|
655
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
656
|
+
* @throws {HttpError} 500 — FreeSWITCH communication error
|
|
657
|
+
*
|
|
658
|
+
* @example
|
|
659
|
+
* ```typescript
|
|
660
|
+
* // Blind transfer — send the caller directly to extension 1002
|
|
661
|
+
* const { callUuid } = await voip.originate({
|
|
662
|
+
* fromExtension: '1001',
|
|
663
|
+
* toNumber: '+17865551234',
|
|
664
|
+
* });
|
|
665
|
+
* await voip.transfer(callUuid, { targetExtension: '1002', type: 'blind' });
|
|
666
|
+
*
|
|
667
|
+
* // CRM UI: Transfer button with extension picker
|
|
668
|
+
* async function handleTransfer(callUuid: string, targetExt: string) {
|
|
669
|
+
* await voip.transfer(callUuid, { targetExtension: targetExt, type: 'blind' });
|
|
670
|
+
* showNotification(`Call transferred to ${targetExt}`);
|
|
671
|
+
* hideCallBanner();
|
|
672
|
+
* }
|
|
673
|
+
*
|
|
674
|
+
* // Blind transfer to an external number
|
|
675
|
+
* await voip.transfer(callUuid, { targetExtension: '+17865559999', type: 'blind' });
|
|
676
|
+
* ```
|
|
677
|
+
*
|
|
678
|
+
* @see POST /api/calls/{uuid}/transfer
|
|
679
|
+
*/
|
|
236
680
|
async transfer(uuid, params) {
|
|
237
681
|
return this.request('POST', `/api/calls/${uuid}/transfer`, params);
|
|
238
682
|
}
|
|
239
|
-
/**
|
|
683
|
+
/**
|
|
684
|
+
* Get all currently active (in-progress) calls in the phone system.
|
|
685
|
+
*
|
|
686
|
+
* @description
|
|
687
|
+
* Queries FreeSWITCH for every live call channel — calls that are ringing, connected,
|
|
688
|
+
* on hold, or in a conference. This is the real-time snapshot of what's happening
|
|
689
|
+
* on your phone system RIGHT NOW. Unlike `listCalls()` which shows historical CDRs,
|
|
690
|
+
* this shows only live calls.
|
|
691
|
+
*
|
|
692
|
+
* **CRM usage:** Build a "Live Calls" dashboard panel or operator console showing
|
|
693
|
+
* all active conversations. Poll this every 3-5 seconds for a near-real-time view,
|
|
694
|
+
* or use WebSocket events for true real-time updates.
|
|
695
|
+
*
|
|
696
|
+
* Each ActiveCall includes:
|
|
697
|
+
* - `uuid` — FreeSWITCH channel UUID. Required for `hangup()`, `transfer()`,
|
|
698
|
+
* `holdCall()`, `parkCall()`, and three-way operations
|
|
699
|
+
* - `call_uuid` — The unique call identifier (shared across both legs of the same call)
|
|
700
|
+
* - `caller_id_number` / `caller_id_name` — Who's calling
|
|
701
|
+
* - `destination` — Who's being called
|
|
702
|
+
* - `direction` — `'inbound'` (someone called you) or `'outbound'` (you called them)
|
|
703
|
+
* - `status` — Channel state: `'active'`, `'ringing'`, `'held'`
|
|
704
|
+
* - `answered` — `true` if the call was picked up, `false` if still ringing
|
|
705
|
+
* - `on_hold` — `true` if the call is currently on hold
|
|
706
|
+
* - `started_at` — When the call started (ISO timestamp)
|
|
707
|
+
*
|
|
708
|
+
* @returns An object containing:
|
|
709
|
+
* - `activeCalls: ActiveCall[]` — Array of live calls. Empty array `[]` if no calls are active.
|
|
710
|
+
*
|
|
711
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
712
|
+
* @throws {HttpError} 500 — FreeSWITCH connection error
|
|
713
|
+
*
|
|
714
|
+
* @example
|
|
715
|
+
* ```typescript
|
|
716
|
+
* // Build a live calls panel
|
|
717
|
+
* const { activeCalls } = await voip.getActiveCalls();
|
|
718
|
+
*
|
|
719
|
+
* // Show in a table
|
|
720
|
+
* activeCalls.forEach(call => {
|
|
721
|
+
* const status = call.answered ? (call.on_hold ? '⏸ On Hold' : '🗣 Active') : '📞 Ringing';
|
|
722
|
+
* console.log(`${status} | ${call.caller_id_number} → ${call.destination} | ${call.uuid}`);
|
|
723
|
+
* });
|
|
724
|
+
*
|
|
725
|
+
* // CRM UI: Live Calls widget — poll every 5 seconds
|
|
726
|
+
* setInterval(async () => {
|
|
727
|
+
* const { activeCalls } = await voip.getActiveCalls();
|
|
728
|
+
* updateLiveCallsTable(activeCalls);
|
|
729
|
+
* }, 5000);
|
|
730
|
+
*
|
|
731
|
+
* // CRM UI: Show/hide logic
|
|
732
|
+
* if (activeCalls.length === 0) {
|
|
733
|
+
* showEmptyState('No active calls right now');
|
|
734
|
+
* } else {
|
|
735
|
+
* const activeCount = activeCalls.filter(c => c.answered && !c.on_hold).length;
|
|
736
|
+
* const ringingCount = activeCalls.filter(c => !c.answered).length;
|
|
737
|
+
* showStats(`${activeCount} active, ${ringingCount} ringing`);
|
|
738
|
+
* }
|
|
739
|
+
*
|
|
740
|
+
* // Find a specific call by phone number
|
|
741
|
+
* const customerCall = activeCalls.find(c => c.caller_id_number === '+17865551234');
|
|
742
|
+
* if (customerCall) {
|
|
743
|
+
* // Show call controls: Hang Up, Transfer, Hold
|
|
744
|
+
* showCallControls(customerCall.uuid);
|
|
745
|
+
* }
|
|
746
|
+
* ```
|
|
747
|
+
*
|
|
748
|
+
* @see GET /api/calls/active
|
|
749
|
+
*/
|
|
240
750
|
async getActiveCalls() {
|
|
241
751
|
return this.request('GET', '/api/calls/active');
|
|
242
752
|
}
|
|
243
753
|
/**
|
|
244
|
-
*
|
|
754
|
+
* Look up the FreeSWITCH channel UUID for a specific call by matching extension + remote number.
|
|
755
|
+
*
|
|
756
|
+
* @description
|
|
757
|
+
* When building three-way calling features, you need FreeSWITCH channel UUIDs — not the
|
|
758
|
+
* local SIP.js session IDs that the SDK's WebRTC phone uses internally. This helper
|
|
759
|
+
* searches the active calls list and returns the matching FS UUID.
|
|
245
760
|
*
|
|
246
|
-
* This is
|
|
247
|
-
*
|
|
248
|
-
*
|
|
761
|
+
* This is a **client-side convenience method** — it calls `getActiveCalls()` under the hood
|
|
762
|
+
* and does a fuzzy match on caller/destination numbers. It's best used in WebRTC-based
|
|
763
|
+
* applications where you have the extension number and the remote party's number but
|
|
764
|
+
* don't have the FreeSWITCH UUID.
|
|
249
765
|
*
|
|
250
|
-
* **
|
|
251
|
-
*
|
|
766
|
+
* **Important:** This method only works for calls that are currently active in FreeSWITCH.
|
|
767
|
+
* It returns `null` if the call isn't found (e.g. the call already ended, or the numbers
|
|
768
|
+
* don't match any active channel).
|
|
252
769
|
*
|
|
253
|
-
* @param extension
|
|
254
|
-
*
|
|
255
|
-
* @param
|
|
256
|
-
*
|
|
770
|
+
* @param extension — The user's extension number. This is the LOCAL party — the one
|
|
771
|
+
* registered in your phone system. Example: `'1001'`
|
|
772
|
+
* @param remoteNumber — The remote party's phone number or extension. This is the OTHER
|
|
773
|
+
* party in the call. Example: `'+17865551234'` (external) or `'1002'` (internal)
|
|
774
|
+
* @param direction — The direction of the call relative to your system:
|
|
775
|
+
* - `'outbound'`: the extension called the remote number (extension is caller_id_number)
|
|
776
|
+
* - `'inbound'`: the remote number called the extension (extension is destination)
|
|
777
|
+
*
|
|
778
|
+
* @returns The FreeSWITCH channel UUID (e.g. `'a1b2c3d4-e5f6-7890-abcd-ef1234567890'`)
|
|
779
|
+
* or `null` if no matching active call was found.
|
|
257
780
|
*
|
|
258
781
|
* @example
|
|
259
782
|
* ```typescript
|
|
260
|
-
* // After a call
|
|
261
|
-
* const
|
|
783
|
+
* // After originating a call, look up its FS UUID for three-way operations
|
|
784
|
+
* const { callUuid } = await voip.originate({
|
|
785
|
+
* fromExtension: '1001',
|
|
786
|
+
* toNumber: '+17865551234',
|
|
787
|
+
* });
|
|
788
|
+
*
|
|
789
|
+
* // Wait for the call to connect, then:
|
|
790
|
+
* const fsUuid = await voip.resolveCallFsUuid('1001', '+17865551234', 'outbound');
|
|
262
791
|
* if (fsUuid) {
|
|
263
|
-
*
|
|
792
|
+
* // Now we can add a third party
|
|
793
|
+
* const { newCallUuid } = await voip.addCallParticipant(fsUuid, { toNumber: '+17865559999' });
|
|
794
|
+
* // ... wait for B to answer, then merge
|
|
795
|
+
* await voip.mergeCalls({ callUuidA: fsUuid, callUuidB: newCallUuid });
|
|
796
|
+
* }
|
|
797
|
+
*
|
|
798
|
+
* // CRM UI: Three-way call button
|
|
799
|
+
* async function startThreeWay(extension: string, customerPhone: string, thirdParty: string) {
|
|
800
|
+
* const fsUuid = await voip.resolveCallFsUuid(extension, customerPhone, 'outbound');
|
|
801
|
+
* if (!fsUuid) {
|
|
802
|
+
* showError('Call not found — it may have ended');
|
|
803
|
+
* return;
|
|
804
|
+
* }
|
|
805
|
+
* const { newCallUuid } = await voip.addCallParticipant(fsUuid, { toNumber: thirdParty });
|
|
806
|
+
* showNotification(`Dialing ${thirdParty}...`);
|
|
807
|
+
* // Listen for WebSocket 'call_start' event on newCallUuid, then auto-merge
|
|
264
808
|
* }
|
|
265
809
|
* ```
|
|
810
|
+
*
|
|
811
|
+
* @see GET /api/calls/active (used internally)
|
|
266
812
|
*/
|
|
267
813
|
async resolveCallFsUuid(extension, remoteNumber, direction) {
|
|
268
814
|
try {
|
|
@@ -291,15 +837,173 @@ export class VoIPClient {
|
|
|
291
837
|
return null;
|
|
292
838
|
}
|
|
293
839
|
}
|
|
294
|
-
/**
|
|
840
|
+
/**
|
|
841
|
+
* Get aggregate call statistics for a time period.
|
|
842
|
+
*
|
|
843
|
+
* @description
|
|
844
|
+
* Returns total call counts broken down by direction (inbound, outbound, internal)
|
|
845
|
+
* for the specified period. Use this for dashboard KPI cards showing "Calls Today",
|
|
846
|
+
* "Calls This Week", "Calls This Month", etc.
|
|
847
|
+
*
|
|
848
|
+
* **CRM UI guidance:** Display the stats as KPI cards at the top of the dashboard:
|
|
849
|
+
* - "Total Calls: 342" (large number, counts don't change often)
|
|
850
|
+
* - "Inbound: 210 | Outbound: 98 | Internal: 34" (breakdown rows)
|
|
851
|
+
* - Show a period selector: Today | 7 Days | 30 Days | 90 Days
|
|
852
|
+
* - Refresh every 60 seconds or on manual "Refresh" button click
|
|
853
|
+
* - Show a loading skeleton while fetching
|
|
854
|
+
*
|
|
855
|
+
* @param period — The time window for statistics:
|
|
856
|
+
* - `'today'` — Since midnight today
|
|
857
|
+
* - `'7d'` — Last 7 days (default if omitted)
|
|
858
|
+
* - `'30d'` — Last 30 days
|
|
859
|
+
* - `'90d'` — Last 90 days
|
|
860
|
+
*
|
|
861
|
+
* @returns An object containing:
|
|
862
|
+
* - `stats: object` — `{ total, inbound, outbound, internal, period }`
|
|
863
|
+
*
|
|
864
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
865
|
+
*
|
|
866
|
+
* @example
|
|
867
|
+
* ```typescript
|
|
868
|
+
* // Get today's stats for the dashboard
|
|
869
|
+
* const { stats } = await voip.getCallStats('today');
|
|
870
|
+
*
|
|
871
|
+
* // Render KPI cards
|
|
872
|
+
* console.log(`${stats.total} calls today`);
|
|
873
|
+
* console.log(`↘ ${stats.inbound} inbound | ↗ ${stats.outbound} outbound | ↔ ${stats.internal} internal`);
|
|
874
|
+
*
|
|
875
|
+
* // CRM UI: period selector
|
|
876
|
+
* const periods = [
|
|
877
|
+
* { value: 'today', label: 'Today' },
|
|
878
|
+
* { value: '7d', label: '7 Days' },
|
|
879
|
+
* { value: '30d', label: '30 Days' },
|
|
880
|
+
* { value: '90d', label: '90 Days' },
|
|
881
|
+
* ];
|
|
882
|
+
* // <select onChange={e => fetchStats(e.target.value)}>
|
|
883
|
+
* ```
|
|
884
|
+
*
|
|
885
|
+
* @see GET /api/calls/stats
|
|
886
|
+
*/
|
|
295
887
|
async getCallStats(period) {
|
|
296
888
|
return this.request('GET', '/api/calls/stats', undefined, period ? { period } : undefined);
|
|
297
889
|
}
|
|
298
|
-
/**
|
|
890
|
+
/**
|
|
891
|
+
* Place an active call on hold or resume it from hold.
|
|
892
|
+
*
|
|
893
|
+
* @description
|
|
894
|
+
* When you put a call on hold, the remote party hears hold music (or silence) and
|
|
895
|
+
* cannot hear anything from your end. This is useful when you need to consult with
|
|
896
|
+
* someone else, look up information, or set up a three-way call.
|
|
897
|
+
*
|
|
898
|
+
* Call `holdCall(uuid, false)` to resume the call — both parties can hear each other again.
|
|
899
|
+
* You can also use this as a toggle: `holdCall(uuid, !isCurrentlyOnHold)`.
|
|
900
|
+
*
|
|
901
|
+
* @param uuid — The FreeSWITCH channel UUID of the active call.
|
|
902
|
+
* Example: `'a1b2c3d4-e5f6-7890-abcd-ef1234567890'`
|
|
903
|
+
* @param hold — `true` to put the call ON hold, `false` to resume (take OFF hold).
|
|
904
|
+
* Default is `true`. Example: `true`
|
|
905
|
+
*
|
|
906
|
+
* @returns An object containing:
|
|
907
|
+
* - `message: string` — Confirmation (e.g. "Call placed on hold" or "Call resumed")
|
|
908
|
+
*
|
|
909
|
+
* @throws {HttpError} 400 — Call UUID not found or call already ended
|
|
910
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
911
|
+
*
|
|
912
|
+
* @example
|
|
913
|
+
* ```typescript
|
|
914
|
+
* // Put a call on hold
|
|
915
|
+
* await voip.holdCall(callUuid, true);
|
|
916
|
+
*
|
|
917
|
+
* // Resume a call
|
|
918
|
+
* await voip.holdCall(callUuid, false);
|
|
919
|
+
*
|
|
920
|
+
* // CRM UI: Hold/Resume toggle button
|
|
921
|
+
* let isOnHold = false;
|
|
922
|
+
*
|
|
923
|
+
* async function toggleHold(callUuid: string) {
|
|
924
|
+
* isOnHold = !isOnHold;
|
|
925
|
+
* await voip.holdCall(callUuid, isOnHold);
|
|
926
|
+
* updateHoldButton(isOnHold ? 'Resume' : 'Hold', isOnHold ? '▶' : '⏸');
|
|
927
|
+
* }
|
|
928
|
+
*
|
|
929
|
+
* // Put on hold before starting a three-way call
|
|
930
|
+
* await voip.holdCall(callUuid, true);
|
|
931
|
+
* const { newCallUuid } = await voip.addCallParticipant(callUuid, { toNumber: '+17865559999' });
|
|
932
|
+
* // ... when third party answers, merge
|
|
933
|
+
* ```
|
|
934
|
+
*
|
|
935
|
+
* @see POST /api/calls/{uuid}/hold
|
|
936
|
+
*/
|
|
299
937
|
async holdCall(uuid, hold = true) {
|
|
300
938
|
return this.request('POST', `/api/calls/${uuid}/hold`, { hold });
|
|
301
939
|
}
|
|
302
|
-
/**
|
|
940
|
+
/**
|
|
941
|
+
* Park an active call in a numbered parking slot so another extension can pick it up.
|
|
942
|
+
*
|
|
943
|
+
* @description
|
|
944
|
+
* Call parking is like putting a call "on hold in the cloud" — the caller hears hold
|
|
945
|
+
* music, and any extension in the tenant can retrieve the call by dialing the parking
|
|
946
|
+
* slot number. This is the telephony equivalent of "please hold while I transfer you"
|
|
947
|
+
* followed by an overhead page: "John, pick up on line 701."
|
|
948
|
+
*
|
|
949
|
+
* **How it works:**
|
|
950
|
+
* 1. Agent receives a call
|
|
951
|
+
* 2. Agent parks the call → system assigns a parking slot (e.g. `701`)
|
|
952
|
+
* 3. System announces "Call parked on 701"
|
|
953
|
+
* 4. Another agent dials `701` to pick up the call
|
|
954
|
+
*
|
|
955
|
+
* If you don't specify a slot number, the system auto-assigns the next available slot.
|
|
956
|
+
* Parked calls can be listed with `getParkedCalls()`.
|
|
957
|
+
*
|
|
958
|
+
* @param uuid — The FreeSWITCH channel UUID of the active call to park.
|
|
959
|
+
* Example: `'a1b2c3d4-e5f6-7890-abcd-ef1234567890'`
|
|
960
|
+
* @param slot — Optional parking slot number. If omitted, the system assigns the next
|
|
961
|
+
* available slot (recommended). If specified, this exact slot number is used.
|
|
962
|
+
* Example: `701`
|
|
963
|
+
*
|
|
964
|
+
* @returns An object containing:
|
|
965
|
+
* - `message: string` — Confirmation message (e.g. "Call parked in slot 701")
|
|
966
|
+
* - `slot: string` — The parking slot number where the call is parked.
|
|
967
|
+
* Display this prominently in the UI so agents know which slot to dial.
|
|
968
|
+
*
|
|
969
|
+
* @throws {HttpError} 400 — Call UUID not found, slot already occupied, or call already ended
|
|
970
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
971
|
+
*
|
|
972
|
+
* @example
|
|
973
|
+
* ```typescript
|
|
974
|
+
* // Park a call — let the system assign a slot
|
|
975
|
+
* const { slot } = await voip.parkCall(callUuid);
|
|
976
|
+
* console.log(`Call parked in slot ${slot} — tell the agent to dial ${slot}`);
|
|
977
|
+
*
|
|
978
|
+
* // Park in a specific slot
|
|
979
|
+
* const { slot } = await voip.parkCall(callUuid, 702);
|
|
980
|
+
*
|
|
981
|
+
* // CRM UI: Park Call button
|
|
982
|
+
* async function handleParkCall(callUuid: string) {
|
|
983
|
+
* const { slot } = await voip.parkCall(callUuid);
|
|
984
|
+
* showNotification(`Call parked on ${slot}`, 'success');
|
|
985
|
+
* showParkedCallBanner(slot, callUuid);
|
|
986
|
+
* }
|
|
987
|
+
*
|
|
988
|
+
* // List all parked calls (e.g. for a "Parked Calls" widget)
|
|
989
|
+
* const { parkedCalls } = await voip.getParkedCalls();
|
|
990
|
+
* parkedCalls.forEach(parked => {
|
|
991
|
+
* console.log(`Slot ${parked.slot}: ${parked.caller_id_number} → ${parked.destination}`);
|
|
992
|
+
* // Show "Pick Up" button next to each parked call
|
|
993
|
+
* });
|
|
994
|
+
*
|
|
995
|
+
* // CRM UI: Show/hide parked calls panel
|
|
996
|
+
* const { parkedCalls } = await voip.getParkedCalls();
|
|
997
|
+
* if (parkedCalls.length === 0) {
|
|
998
|
+
* hideParkedCallsPanel();
|
|
999
|
+
* } else {
|
|
1000
|
+
* showParkedCallsPanel(parkedCalls); // Shows slot numbers and caller info
|
|
1001
|
+
* }
|
|
1002
|
+
* ```
|
|
1003
|
+
*
|
|
1004
|
+
* @see POST /api/calls/{uuid}/park
|
|
1005
|
+
* @see GET /api/calls/parked (getParkedCalls)
|
|
1006
|
+
*/
|
|
303
1007
|
async parkCall(uuid, slot) {
|
|
304
1008
|
return this.request('POST', `/api/calls/${uuid}/park`, slot ? { slot } : undefined);
|
|
305
1009
|
}
|
|
@@ -308,8 +1012,69 @@ export class VoIPClient {
|
|
|
308
1012
|
* an incoming call. When answered, audio from the target call is bridged
|
|
309
1013
|
* to the monitoring extension.
|
|
310
1014
|
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
1015
|
+
* @description
|
|
1016
|
+
* Allows a supervisor or admin to silently monitor, coach, or intervene in an active
|
|
1017
|
+
* call. Three modes are available:
|
|
1018
|
+
*
|
|
1019
|
+
* - **`listen`** (default): The supervisor hears the call audio but neither party
|
|
1020
|
+
* hears the supervisor. Useful for quality monitoring and training.
|
|
1021
|
+
* - **`whisper`**: The supervisor can speak to the agent (extension) privately
|
|
1022
|
+
* without the remote party hearing. Used for real-time coaching.
|
|
1023
|
+
* - **`barge`**: The supervisor enters the call — all three parties can hear each
|
|
1024
|
+
* other. Used for escalations and intervention.
|
|
1025
|
+
*
|
|
1026
|
+
* After the monitoring extension answers the incoming call, DTMF controls are available:
|
|
1027
|
+
* - Press `1` — whisper mode
|
|
1028
|
+
* - Press `2` — barge mode
|
|
1029
|
+
* - Press `3` — mute
|
|
1030
|
+
* - Press `0` — toggle mute
|
|
1031
|
+
*
|
|
1032
|
+
* **CRM UI guidance:** Show an "Eavesdrop" button on active calls (visible to admins
|
|
1033
|
+
* and supervisors only). Display a mode selector (Listen / Whisper / Barge). After
|
|
1034
|
+
* initiating, show "Monitoring [caller]..." with mode indicator and DTMF instructions.
|
|
1035
|
+
*
|
|
1036
|
+
* @param callUuid — The FreeSWITCH channel UUID of the active call to monitor.
|
|
1037
|
+
* Example: `'a1b2c3d4-e5f6-7890-abcd-ef1234567890'`
|
|
1038
|
+
* @param extension — The extension number of the monitoring supervisor.
|
|
1039
|
+
* This extension's phone will ring — when answered, monitoring begins.
|
|
1040
|
+
* Example: `'1001'`
|
|
1041
|
+
* @param mode — Eavesdrop mode: `'listen'` (default, silent), `'whisper'` (coach agent),
|
|
1042
|
+
* or `'barge'` (join call). Example: `'listen'`
|
|
1043
|
+
*
|
|
1044
|
+
* @returns An object containing:
|
|
1045
|
+
* - `message: string` — Confirmation message
|
|
1046
|
+
* - `spyUuid: string` — The monitoring session UUID (used with `switchEavesdropMode()`)
|
|
1047
|
+
*
|
|
1048
|
+
* @throws {HttpError} 400 — Call UUID not found or extension is not registered
|
|
1049
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
1050
|
+
* @throws {HttpError} 403 — Extension doesn't have eavesdrop permission
|
|
1051
|
+
* @throws {HttpError} 500 — FreeSWITCH operation failed
|
|
1052
|
+
*
|
|
1053
|
+
* @example
|
|
1054
|
+
* ```typescript
|
|
1055
|
+
* // Silent monitoring — supervisor hears but is not heard
|
|
1056
|
+
* const { spyUuid } = await voip.eavesdropCall(callUuid, '1001', 'listen');
|
|
1057
|
+
*
|
|
1058
|
+
* // Coach the agent privately (whisper mode)
|
|
1059
|
+
* await voip.eavesdropCall(callUuid, '1001', 'whisper');
|
|
1060
|
+
*
|
|
1061
|
+
* // CRM UI: Supervisor panel
|
|
1062
|
+
* async function startMonitoring(callUuid: string, supervisorExt: string, mode: string) {
|
|
1063
|
+
* showMonitoringBanner(`Connecting supervisor ${supervisorExt}...`);
|
|
1064
|
+
* try {
|
|
1065
|
+
* const { spyUuid } = await voip.eavesdropCall(callUuid, supervisorExt, mode);
|
|
1066
|
+
* showMonitoringActive(spyUuid, mode);
|
|
1067
|
+
* showDtmfInstructions(mode); // "Press 1 for whisper, 2 for barge"
|
|
1068
|
+
* } catch (err) {
|
|
1069
|
+
* if (err instanceof HttpError && err.status === 400) {
|
|
1070
|
+
* showError('Call not found — it may have ended');
|
|
1071
|
+
* }
|
|
1072
|
+
* }
|
|
1073
|
+
* }
|
|
1074
|
+
* ```
|
|
1075
|
+
*
|
|
1076
|
+
* @see POST /api/calls/{uuid}/eavesdrop
|
|
1077
|
+
* @see switchEavesdropMode (change mode during monitoring)
|
|
313
1078
|
*/
|
|
314
1079
|
async eavesdropCall(callUuid, extension, mode = 'listen') {
|
|
315
1080
|
return this.request('POST', `/api/calls/${callUuid}/eavesdrop`, { extension, mode });
|
|
@@ -322,7 +1087,66 @@ export class VoIPClient {
|
|
|
322
1087
|
async getParkedCalls() {
|
|
323
1088
|
return this.request('GET', '/api/calls/parked');
|
|
324
1089
|
}
|
|
325
|
-
/**
|
|
1090
|
+
/**
|
|
1091
|
+
* Export CDR (Call Detail Records) as CSV text.
|
|
1092
|
+
*
|
|
1093
|
+
* @description
|
|
1094
|
+
* Generates a CSV file containing filtered call records for the specified date range.
|
|
1095
|
+
* The response is raw CSV text — you need to handle downloading or displaying it.
|
|
1096
|
+
* This is useful for:
|
|
1097
|
+
* - "Export to CSV" buttons in the call history UI
|
|
1098
|
+
* - Downloading call records for billing/invoicing
|
|
1099
|
+
* - Importing into external analytics tools
|
|
1100
|
+
*
|
|
1101
|
+
* **CRM UI guidance:** Show an "Export CSV" button near the call history table.
|
|
1102
|
+
* When clicked, filter by the current date range and download. Create a Blob from
|
|
1103
|
+
* the response text and trigger a browser download via a temporary anchor element.
|
|
1104
|
+
*
|
|
1105
|
+
* @param params — Optional filters to narrow the export
|
|
1106
|
+
* @param params.dateFrom — ISO date string: only calls on or after this date. Example: `'2026-04-01'`
|
|
1107
|
+
* @param params.dateTo — ISO date string: only calls on or before this date. Example: `'2026-04-30'`
|
|
1108
|
+
* @param params.direction — Filter by direction: `'inbound'`, `'outbound'`, `'internal'`, or `'all'` (default)
|
|
1109
|
+
* @param params.limit — Maximum number of records to export (default: no limit)
|
|
1110
|
+
*
|
|
1111
|
+
* @returns Raw CSV text string with headers and call records
|
|
1112
|
+
*
|
|
1113
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
1114
|
+
*
|
|
1115
|
+
* @example
|
|
1116
|
+
* ```typescript
|
|
1117
|
+
* // Export last month's calls
|
|
1118
|
+
* const csv = await voip.exportCalls({
|
|
1119
|
+
* dateFrom: '2026-05-01',
|
|
1120
|
+
* dateTo: '2026-05-31',
|
|
1121
|
+
* direction: 'outbound',
|
|
1122
|
+
* });
|
|
1123
|
+
*
|
|
1124
|
+
* // Trigger browser download
|
|
1125
|
+
* const blob = new Blob([csv], { type: 'text/csv' });
|
|
1126
|
+
* const url = URL.createObjectURL(blob);
|
|
1127
|
+
* const a = document.createElement('a');
|
|
1128
|
+
* a.href = url;
|
|
1129
|
+
* a.download = 'calls-may-2026.csv';
|
|
1130
|
+
* a.click();
|
|
1131
|
+
* URL.revokeObjectURL(url);
|
|
1132
|
+
*
|
|
1133
|
+
* // CRM UI: Export button in call history
|
|
1134
|
+
* async function handleExport(dateFrom: string, dateTo: string) {
|
|
1135
|
+
* showExportingSpinner();
|
|
1136
|
+
* try {
|
|
1137
|
+
* const csv = await voip.exportCalls({ dateFrom, dateTo });
|
|
1138
|
+
* downloadFile(csv, `calls-${dateFrom}-to-${dateTo}.csv`);
|
|
1139
|
+
* showNotification('Export complete');
|
|
1140
|
+
* } catch {
|
|
1141
|
+
* showError('Export failed — please try again');
|
|
1142
|
+
* } finally {
|
|
1143
|
+
* hideExportingSpinner();
|
|
1144
|
+
* }
|
|
1145
|
+
* }
|
|
1146
|
+
* ```
|
|
1147
|
+
*
|
|
1148
|
+
* @see GET /api/calls/export
|
|
1149
|
+
*/
|
|
326
1150
|
async exportCalls(params) {
|
|
327
1151
|
return this.request('GET', '/api/calls/export', undefined, params);
|
|
328
1152
|
}
|
|
@@ -330,41 +1154,371 @@ export class VoIPClient {
|
|
|
330
1154
|
// Three-Way Calls
|
|
331
1155
|
// -------------------------------------------------------------------
|
|
332
1156
|
/**
|
|
333
|
-
*
|
|
334
|
-
* The caller should watch for a `call.answered` WebSocket event on `newCallUuid`,
|
|
335
|
-
* then call `mergeCalls()` to bring all three into a conference.
|
|
1157
|
+
* Add a third participant to an existing two-party call (Step 1 of three-way calling).
|
|
336
1158
|
*
|
|
337
|
-
* @
|
|
338
|
-
*
|
|
339
|
-
|
|
340
|
-
|
|
1159
|
+
* @description
|
|
1160
|
+
* This is the first step in setting up a three-way conference call. It:
|
|
1161
|
+
* 1. Places the existing call on hold (the remote party hears hold music)
|
|
1162
|
+
* 2. Originates a NEW call from you to a third party (the `toNumber` or `toExtension`)
|
|
1163
|
+
* 3. Returns the new call UUID so you can track when the third party answers
|
|
1164
|
+
*
|
|
1165
|
+
* **Three-way call flow (3 steps):**
|
|
1166
|
+
* 1. `addCallParticipant()` — Hold person A, dial person B
|
|
1167
|
+
* 2. Wait for WebSocket `call_start` / `call_answer` event on `newCallUuid` (person B answers)
|
|
1168
|
+
* 3. `mergeCalls()` — Join A + you + B into a conference room
|
|
1169
|
+
*
|
|
1170
|
+
* **CRM UI guidance:**
|
|
1171
|
+
* - After calling this, show "Dialing [name/number]..." in the three-way panel
|
|
1172
|
+
* - Gray out the Merge button until the WebSocket confirms person B answered
|
|
1173
|
+
* - Show an inline timer ("Dialing... 10s")
|
|
1174
|
+
* - If person B doesn't answer within 30s, show "No answer" and an "End" button
|
|
1175
|
+
*
|
|
1176
|
+
* @param callUuid — The FreeSWITCH channel UUID of YOUR leg in the active two-party call.
|
|
1177
|
+
* This is your own WebRTC/desk phone channel, NOT the remote party's channel.
|
|
1178
|
+
* Get it from `originate()` → `callUuid`, `resolveCallFsUuid()`, or `getActiveCalls()`.
|
|
1179
|
+
* Example: `'a1b2c3d4-e5f6-7890-abcd-ef1234567890'`
|
|
1180
|
+
* @param params — Who to invite into the call
|
|
1181
|
+
* @param params.toNumber — An external PSTN phone number to call.
|
|
1182
|
+
* Example: `'+17865559999'`. Use this OR `toExtension`, not both.
|
|
1183
|
+
* @param params.toExtension — An internal extension to add.
|
|
1184
|
+
* Example: `'1002'`. Use this OR `toNumber`, not both.
|
|
1185
|
+
* @param options — Optional request overrides
|
|
1186
|
+
* @param options.tenantId — Target tenant UUID (required for superadmin keys only)
|
|
1187
|
+
*
|
|
1188
|
+
* @returns An object containing:
|
|
1189
|
+
* - `message: string` — Confirmation message
|
|
1190
|
+
* - `newCallUuid: string` — The UUID of the NEW call leg (person B). Watch for
|
|
1191
|
+
* WebSocket `call_start` and `call_answer` events with this UUID to know when
|
|
1192
|
+
* person B answers. This UUID goes into `mergeCalls({ callUuidB })`.
|
|
1193
|
+
* - `originalBridgedLeg: string` — The UUID of person A's PSTN leg (the original
|
|
1194
|
+
* remote party). Pass this to `mergeCalls({ bridgedLegA })` for optimal routing.
|
|
1195
|
+
*
|
|
1196
|
+
* @throws {HttpError} 400 — `callUuid` not found, both `toNumber` and `toExtension` missing, or extension is disabled
|
|
1197
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
1198
|
+
* @throws {HttpError} 500 — FreeSWITCH originate failed
|
|
1199
|
+
*
|
|
1200
|
+
* @example
|
|
1201
|
+
* ```typescript
|
|
1202
|
+
* // Full three-way call flow
|
|
1203
|
+
* // 1. Start the add
|
|
1204
|
+
* const { newCallUuid, originalBridgedLeg } = await voip.addCallParticipant(
|
|
1205
|
+
* currentCallUuid,
|
|
1206
|
+
* { toNumber: '+17865559999' },
|
|
1207
|
+
* );
|
|
1208
|
+
*
|
|
1209
|
+
* // 2. Listen for the WebSocket event that person B answered
|
|
1210
|
+
* voip.on('call_answer', (data) => {
|
|
1211
|
+
* if (data.call_uuid === newCallUuid) {
|
|
1212
|
+
* // 3. Now merge all three parties
|
|
1213
|
+
* voip.mergeCalls({
|
|
1214
|
+
* callUuidA: currentCallUuid,
|
|
1215
|
+
* callUuidB: newCallUuid,
|
|
1216
|
+
* bridgedLegA: originalBridgedLeg,
|
|
1217
|
+
* });
|
|
1218
|
+
* }
|
|
1219
|
+
* });
|
|
1220
|
+
*
|
|
1221
|
+
* // CRM UI: Three-way call with an internal extension
|
|
1222
|
+
* async function startThreeWayWithColleague(callUuid: string, colleagueExt: string) {
|
|
1223
|
+
* showThreeWayPanel('dialing');
|
|
1224
|
+
* try {
|
|
1225
|
+
* const { newCallUuid, originalBridgedLeg } = await voip.addCallParticipant(
|
|
1226
|
+
* callUuid,
|
|
1227
|
+
* { toExtension: colleagueExt },
|
|
1228
|
+
* );
|
|
1229
|
+
* // Store these for the merge step
|
|
1230
|
+
* pendingThreeWay = { callUuidA: callUuid, callUuidB: newCallUuid, bridgedLegA: originalBridgedLeg };
|
|
1231
|
+
* showNotification(`Dialing ${colleagueExt}...`);
|
|
1232
|
+
* } catch (err) {
|
|
1233
|
+
* showError('Could not add participant — extension may be busy or offline');
|
|
1234
|
+
* hideThreeWayPanel();
|
|
1235
|
+
* }
|
|
1236
|
+
* }
|
|
1237
|
+
* ```
|
|
1238
|
+
*
|
|
1239
|
+
* @see POST /api/calls/{uuid}/add-participant
|
|
1240
|
+
* @see mergeCalls (step 2 — join all three parties)
|
|
1241
|
+
* @see swapCallParticipant (go private with one party)
|
|
1242
|
+
*/
|
|
1243
|
+
async addCallParticipant(callUuid, params, options) {
|
|
341
1244
|
return this.request('POST', `/api/calls/${callUuid}/add-participant`, params, undefined, options);
|
|
342
1245
|
}
|
|
343
1246
|
/**
|
|
344
|
-
*
|
|
345
|
-
*
|
|
1247
|
+
* Merge two active call legs into a three-way conference room (Step 2 of three-way calling).
|
|
1248
|
+
*
|
|
1249
|
+
* @description
|
|
1250
|
+
* After using `addCallParticipant()` to dial a third party and waiting for them to answer,
|
|
1251
|
+
* call this method to join all three parties (you + person A + person B) into a single
|
|
1252
|
+
* conference room. Everyone hears everyone.
|
|
1253
|
+
*
|
|
1254
|
+
* **Behind the scenes:** This creates a FreeSWITCH conference, moves your call leg and
|
|
1255
|
+
* both remote parties into it, and returns the conference name and participant details.
|
|
1256
|
+
* The conference stays alive as long as you (the moderator) are in it.
|
|
1257
|
+
*
|
|
1258
|
+
* **Three-way call flow (3 steps):**
|
|
1259
|
+
* 1. `addCallParticipant()` — Hold A, dial B
|
|
1260
|
+
* 2. Wait for person B to answer
|
|
1261
|
+
* 3. `mergeCalls()` — Join everyone into a conference ← **YOU ARE HERE**
|
|
1262
|
+
*
|
|
1263
|
+
* **After merging, you can:**
|
|
1264
|
+
* - `swapCallParticipant()` — Talk privately with one person while the other is on hold
|
|
1265
|
+
* - `kickFromConference()` — Remove someone from the call
|
|
1266
|
+
* - `muteConferenceMember()` — Mute/unmute a participant
|
|
1267
|
+
*
|
|
1268
|
+
* **CRM UI guidance:**
|
|
1269
|
+
* - After merging, show a "Conference Active" panel with all 3 participant avatars/names
|
|
1270
|
+
* - Show per-participant controls: Mute, Hold (private swap), Kick
|
|
1271
|
+
* - The merge button should be disabled until person B answers
|
|
1272
|
+
* - If merge fails, show "Could not merge calls" and offer "End" or "Retry"
|
|
1273
|
+
*
|
|
1274
|
+
* @param params — Merge parameters
|
|
1275
|
+
* @param params.callUuidA — Your own WebRTC/desk phone UUID (the moderator's channel).
|
|
1276
|
+
* This is the same UUID you passed to `addCallParticipant()` as `callUuid`.
|
|
1277
|
+
* Example: `'a1b2c3d4-e5f6-7890-abcd-ef1234567890'`
|
|
1278
|
+
* @param params.callUuidB — The third party's UUID, returned by `addCallParticipant()`
|
|
1279
|
+
* as `newCallUuid`. Example: `'b2c3d4e5-f6a7-8901-bcde-f12345678901'`
|
|
1280
|
+
* @param params.bridgedLegA — Optional. The original remote party's PSTN leg UUID,
|
|
1281
|
+
* returned by `addCallParticipant()` as `originalBridgedLeg`. Passing this avoids
|
|
1282
|
+
* an extra server-side lookup and ensures the correct leg is used.
|
|
1283
|
+
* Example: `'c3d4e5f6-a7b8-9012-cdef-123456789012'`
|
|
1284
|
+
* @param params.conferenceName — Optional custom name for the conference room.
|
|
1285
|
+
* Auto-generated if omitted (recommended). Example: `'support-three-way'`
|
|
1286
|
+
* @param options — Optional request overrides
|
|
1287
|
+
* @param options.tenantId — Target tenant UUID (required for superadmin keys only)
|
|
1288
|
+
*
|
|
1289
|
+
* @returns An object containing:
|
|
1290
|
+
* - `message: string` — Confirmation message
|
|
1291
|
+
* - `conferenceName: string` — The conference room name. Use this for subsequent
|
|
1292
|
+
* operations (`swapCallParticipant`, `kickFromConference`, `muteConferenceMember`).
|
|
1293
|
+
* - `participants: ThreeWayParticipant[]` — Array of all 3 participants in the conference.
|
|
1294
|
+
* Each has `uuid` (FS channel UUID), `memberId` (needed for kick/mute),
|
|
1295
|
+
* `callerIdNumber`, `callerIdName`, `muted` (boolean), and `status` (`'active'` or `'held'`).
|
|
1296
|
+
*
|
|
1297
|
+
* @throws {HttpError} 400 — One or both call UUIDs not found, or calls already terminated
|
|
1298
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
1299
|
+
* @throws {HttpError} 500 — FreeSWITCH conference creation failed
|
|
1300
|
+
*
|
|
1301
|
+
* @example
|
|
1302
|
+
* ```typescript
|
|
1303
|
+
* // Complete three-way call flow
|
|
1304
|
+
* // Step 1: Add third party
|
|
1305
|
+
* const { newCallUuid, originalBridgedLeg } = await voip.addCallParticipant(
|
|
1306
|
+
* currentCallUuid,
|
|
1307
|
+
* { toNumber: '+17865559999' },
|
|
1308
|
+
* );
|
|
1309
|
+
*
|
|
1310
|
+
* // Step 2: Wait for answer (via WebSocket or polling)
|
|
1311
|
+
* voip.on('call_answer', async (data) => {
|
|
1312
|
+
* if (data.call_uuid === newCallUuid) {
|
|
1313
|
+
* // Step 3: Merge — all three parties now connected
|
|
1314
|
+
* const { conferenceName, participants } = await voip.mergeCalls({
|
|
1315
|
+
* callUuidA: currentCallUuid,
|
|
1316
|
+
* callUuidB: newCallUuid,
|
|
1317
|
+
* bridgedLegA: originalBridgedLeg,
|
|
1318
|
+
* });
|
|
1319
|
+
*
|
|
1320
|
+
* console.log(`Conference "${conferenceName}" is live`);
|
|
1321
|
+
* participants.forEach(p => {
|
|
1322
|
+
* console.log(` ${p.callerIdName} (${p.callerIdNumber}) — ${p.status}`);
|
|
1323
|
+
* });
|
|
1324
|
+
* }
|
|
1325
|
+
* });
|
|
1326
|
+
*
|
|
1327
|
+
* // CRM UI: Build participant list after merge
|
|
1328
|
+
* function renderParticipants(participants: ThreeWayParticipant[]) {
|
|
1329
|
+
* participants.forEach(p => {
|
|
1330
|
+
* // Show avatar, name, number, and controls
|
|
1331
|
+
* renderParticipantCard({
|
|
1332
|
+
* name: p.callerIdName || p.callerIdNumber,
|
|
1333
|
+
* number: p.callerIdNumber,
|
|
1334
|
+
* isMuted: p.muted,
|
|
1335
|
+
* onMute: () => voip.muteConferenceMember(conferenceName, p.memberId, !p.muted),
|
|
1336
|
+
* onKick: () => voip.kickFromConference(conferenceName, p.memberId),
|
|
1337
|
+
* });
|
|
1338
|
+
* });
|
|
1339
|
+
* }
|
|
1340
|
+
* ```
|
|
346
1341
|
*
|
|
347
|
-
* @
|
|
1342
|
+
* @see POST /api/calls/three-way/merge
|
|
1343
|
+
* @see addCallParticipant (step 1)
|
|
1344
|
+
* @see swapCallParticipant (go private with one party)
|
|
1345
|
+
* @see mergeDirectCalls (skip addCallParticipant — merge two existing calls)
|
|
348
1346
|
*/
|
|
349
1347
|
async mergeCalls(params, options) {
|
|
350
1348
|
return this.request('POST', '/api/calls/three-way/merge', params, undefined, options);
|
|
351
1349
|
}
|
|
352
1350
|
/**
|
|
353
|
-
* Swap
|
|
354
|
-
*
|
|
1351
|
+
* Swap which participant is active in a three-way conference — go "private"
|
|
1352
|
+
* with one person while the other is placed on hold.
|
|
1353
|
+
*
|
|
1354
|
+
* @description
|
|
1355
|
+
* In a three-way conference call, you might need to have a private conversation with
|
|
1356
|
+
* just one participant while the other waits on hold. This method removes one
|
|
1357
|
+
* participant from the conference (they hear hold music) and keeps the other
|
|
1358
|
+
* connected. The held participant can be brought back by calling `mergeCalls()` again.
|
|
1359
|
+
*
|
|
1360
|
+
* **Use case:** You're on a call with a customer (A) and your manager (B).
|
|
1361
|
+
* You need to discuss something privately with your manager:
|
|
1362
|
+
* 1. `swapCallParticipant({ keepUuid: managerUuid, holdUuid: customerUuid })`
|
|
1363
|
+
* 2. Now you and your manager can talk privately — the customer hears hold music
|
|
1364
|
+
* 3. `mergeCalls()` — Bring all three back together
|
|
1365
|
+
*
|
|
1366
|
+
* **CRM UI guidance:**
|
|
1367
|
+
* - Show a "Private" or "Talk Privately" button next to each participant
|
|
1368
|
+
* - After swapping, the held participant's card should show "On Hold" with a "Rejoin" button
|
|
1369
|
+
* - The active participant's card should show "Private Conversation"
|
|
1370
|
+
* - Show a "Rejoin All" button that calls `mergeCalls()` to restore the full conference
|
|
1371
|
+
*
|
|
1372
|
+
* @param params — Swap parameters
|
|
1373
|
+
* @param params.conferenceName — The conference room name, returned by `mergeCalls()`
|
|
1374
|
+
* or `mergeDirectCalls()`. Example: `'conf-abc123'`
|
|
1375
|
+
* @param params.keepUuid — The FreeSWITCH channel UUID of the participant to KEEP
|
|
1376
|
+
* active in the conference (talk privately with this person).
|
|
1377
|
+
* Get this from `mergeCalls()` → `participants[].uuid`.
|
|
1378
|
+
* Example: `'b2c3d4e5-f6a7-8901-bcde-f12345678901'`
|
|
1379
|
+
* @param params.holdUuid — The FreeSWITCH channel UUID of the participant to PLACE
|
|
1380
|
+
* ON HOLD (remove from conference — they hear hold music).
|
|
1381
|
+
* Get this from `mergeCalls()` → `participants[].uuid`.
|
|
1382
|
+
* Example: `'a1b2c3d4-e5f6-7890-abcd-ef1234567890'`
|
|
1383
|
+
* @param options — Optional request overrides
|
|
1384
|
+
* @param options.tenantId — Target tenant UUID (required for superadmin keys only)
|
|
1385
|
+
*
|
|
1386
|
+
* @returns An object containing:
|
|
1387
|
+
* - `message: string` — Confirmation message
|
|
1388
|
+
* - `conferenceName: string` — The same conference name (for subsequent operations)
|
|
1389
|
+
* - `activeUuid: string` — The UUID of the participant still in the conference (the one you kept)
|
|
1390
|
+
* - `heldUuid: string` — The UUID of the participant now on hold (the one you held)
|
|
1391
|
+
*
|
|
1392
|
+
* @throws {HttpError} 400 — Conference not found, UUIDs don't match participants, or invalid state
|
|
1393
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
1394
|
+
* @throws {HttpError} 500 — FreeSWITCH operation failed
|
|
1395
|
+
*
|
|
1396
|
+
* @example
|
|
1397
|
+
* ```typescript
|
|
1398
|
+
* // After mergeCalls, talk privately with the manager
|
|
1399
|
+
* const { conferenceName, participants } = await voip.mergeCalls({
|
|
1400
|
+
* callUuidA: selfUuid,
|
|
1401
|
+
* callUuidB: newCallUuid,
|
|
1402
|
+
* });
|
|
1403
|
+
*
|
|
1404
|
+
* const customer = participants.find(p => p.callerIdNumber === '+17865551234');
|
|
1405
|
+
* const manager = participants.find(p => p.callerIdNumber === '1002');
|
|
1406
|
+
*
|
|
1407
|
+
* // Go private with the manager — customer goes on hold
|
|
1408
|
+
* await voip.swapCallParticipant({
|
|
1409
|
+
* conferenceName,
|
|
1410
|
+
* keepUuid: manager.uuid,
|
|
1411
|
+
* holdUuid: customer.uuid,
|
|
1412
|
+
* });
|
|
1413
|
+
*
|
|
1414
|
+
* // CRM UI: Private conversation with manager
|
|
1415
|
+
* showPrivateBanner('Talking privately with Manager');
|
|
1416
|
+
* showOnHoldBanner(customer.callerIdNumber);
|
|
1417
|
+
*
|
|
1418
|
+
* // Bring everyone back
|
|
1419
|
+
* document.getElementById('rejoin-all-btn').onclick = async () => {
|
|
1420
|
+
* await voip.mergeCalls({
|
|
1421
|
+
* callUuidA: selfUuid,
|
|
1422
|
+
* callUuidB: manager.uuid,
|
|
1423
|
+
* });
|
|
1424
|
+
* showConferenceActiveBanner();
|
|
1425
|
+
* };
|
|
1426
|
+
*
|
|
1427
|
+
* // CRM UI: Swap to talk privately with customer while manager waits
|
|
1428
|
+
* await voip.swapCallParticipant({
|
|
1429
|
+
* conferenceName,
|
|
1430
|
+
* keepUuid: customer.uuid,
|
|
1431
|
+
* holdUuid: manager.uuid,
|
|
1432
|
+
* });
|
|
1433
|
+
* ```
|
|
355
1434
|
*
|
|
356
|
-
* @
|
|
1435
|
+
* @see POST /api/calls/three-way/swap
|
|
1436
|
+
* @see mergeCalls (restore full three-way)
|
|
357
1437
|
*/
|
|
358
1438
|
async swapCallParticipant(params, options) {
|
|
359
1439
|
return this.request('POST', '/api/calls/three-way/swap', params, undefined, options);
|
|
360
1440
|
}
|
|
361
1441
|
/**
|
|
362
|
-
* Merge two independently-created calls directly into a conference
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
1442
|
+
* Merge two independently-created calls directly into a conference — no
|
|
1443
|
+
* `addCallParticipant()` step required.
|
|
1444
|
+
*
|
|
1445
|
+
* @description
|
|
1446
|
+
* Unlike `mergeCalls()` which requires the `addCallParticipant()` → merge flow,
|
|
1447
|
+
* this method works with ANY two active call UUIDs from any source. Use this when:
|
|
1448
|
+
*
|
|
1449
|
+
* - A desk phone user puts call A on hold (using the phone's Hold button),
|
|
1450
|
+
* dials call B manually, and then wants to merge them
|
|
1451
|
+
* - Two WebRTC calls were originated separately via `originate()`
|
|
1452
|
+
* - You have two active call UUIDs from `getActiveCalls()` and want to merge them
|
|
1453
|
+
*
|
|
1454
|
+
* This is the lower-level "merge any two calls" endpoint. For the guided three-way
|
|
1455
|
+
* flow with auto-hold and dialing, use `addCallParticipant()` + `mergeCalls()` instead.
|
|
1456
|
+
*
|
|
1457
|
+
* **CRM UI guidance:**
|
|
1458
|
+
* - Use this for a "Merge" button in a multi-call view where the user has two
|
|
1459
|
+
* separate active calls
|
|
1460
|
+
* - Show both calls side by side with a "Merge Calls" button between them
|
|
1461
|
+
* - After merge, show the same conference participant panel as `mergeCalls()`
|
|
1462
|
+
*
|
|
1463
|
+
* @param params — Merge parameters
|
|
1464
|
+
* @param params.callUuidA — UUID of the first active call leg.
|
|
1465
|
+
* Example: `'a1b2c3d4-e5f6-7890-abcd-ef1234567890'`
|
|
1466
|
+
* @param params.callUuidB — UUID of the second active call leg.
|
|
1467
|
+
* Example: `'b2c3d4e5-f6a7-8901-bcde-f12345678901'`
|
|
1468
|
+
* @param params.conferenceName — Optional custom conference name. Auto-generated if omitted.
|
|
1469
|
+
* Example: `'manual-merge'`
|
|
1470
|
+
* @param options — Optional request overrides
|
|
1471
|
+
* @param options.tenantId — Target tenant UUID (required for superadmin keys only)
|
|
1472
|
+
*
|
|
1473
|
+
* @returns An object containing:
|
|
1474
|
+
* - `message: string` — Confirmation message
|
|
1475
|
+
* - `conferenceName: string` — The conference room name for subsequent operations
|
|
1476
|
+
* - `participants: ThreeWayParticipant[]` — All participants now in the conference.
|
|
1477
|
+
* Each has `uuid`, `memberId`, `callerIdNumber`, `callerIdName`, `muted`, and `status`.
|
|
1478
|
+
*
|
|
1479
|
+
* @throws {HttpError} 400 — One or both call UUIDs not found or already terminated
|
|
1480
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
1481
|
+
* @throws {HttpError} 500 — FreeSWITCH conference creation failed
|
|
1482
|
+
*
|
|
1483
|
+
* @example
|
|
1484
|
+
* ```typescript
|
|
1485
|
+
* // Desk phone user: held call A, dialed call B, now wants to merge
|
|
1486
|
+
* const { activeCalls } = await voip.getActiveCalls();
|
|
366
1487
|
*
|
|
367
|
-
*
|
|
1488
|
+
* // Find the two calls (one on hold, one active)
|
|
1489
|
+
* const heldCall = activeCalls.find(c => c.on_hold);
|
|
1490
|
+
* const activeCall = activeCalls.find(c => !c.on_hold && c.uuid !== heldCall?.uuid);
|
|
1491
|
+
*
|
|
1492
|
+
* if (heldCall && activeCall) {
|
|
1493
|
+
* const { conferenceName, participants } = await voip.mergeDirectCalls({
|
|
1494
|
+
* callUuidA: heldCall.uuid,
|
|
1495
|
+
* callUuidB: activeCall.uuid,
|
|
1496
|
+
* });
|
|
1497
|
+
* console.log(`Merged into conference: ${conferenceName}`);
|
|
1498
|
+
* }
|
|
1499
|
+
*
|
|
1500
|
+
* // CRM UI: Merge button for two active calls
|
|
1501
|
+
* async function mergeTwoCalls(uuid1: string, uuid2: string) {
|
|
1502
|
+
* try {
|
|
1503
|
+
* const { conferenceName, participants } = await voip.mergeDirectCalls({
|
|
1504
|
+
* callUuidA: uuid1,
|
|
1505
|
+
* callUuidB: uuid2,
|
|
1506
|
+
* });
|
|
1507
|
+
* showConferencePanel(conferenceName, participants);
|
|
1508
|
+
* hideMergeButton();
|
|
1509
|
+
* } catch (err) {
|
|
1510
|
+
* showError('Could not merge calls — one may have ended');
|
|
1511
|
+
* }
|
|
1512
|
+
* }
|
|
1513
|
+
*
|
|
1514
|
+
* // After merge, use the same conference operations
|
|
1515
|
+
* await voip.muteConferenceMember(conferenceName, participants[1].memberId, true);
|
|
1516
|
+
* await voip.kickFromConference(conferenceName, participants[2].memberId);
|
|
1517
|
+
* ```
|
|
1518
|
+
*
|
|
1519
|
+
* @see POST /api/calls/three-way/merge-direct
|
|
1520
|
+
* @see mergeCalls (guided three-way flow with addCallParticipant)
|
|
1521
|
+
* @see swapCallParticipant (go private with one party)
|
|
368
1522
|
*/
|
|
369
1523
|
async mergeDirectCalls(params, options) {
|
|
370
1524
|
return this.request('POST', '/api/calls/three-way/merge-direct', params, undefined, options);
|
|
@@ -372,23 +1526,298 @@ export class VoIPClient {
|
|
|
372
1526
|
// -------------------------------------------------------------------
|
|
373
1527
|
// Extensions
|
|
374
1528
|
// -------------------------------------------------------------------
|
|
375
|
-
/**
|
|
1529
|
+
/**
|
|
1530
|
+
* List all extensions for the current tenant.
|
|
1531
|
+
*
|
|
1532
|
+
* @description
|
|
1533
|
+
* Retrieves every PBX extension in the tenant, including SIP registration details,
|
|
1534
|
+
* voicemail configuration, call forwarding settings, and real-time presence status.
|
|
1535
|
+
* This is the primary method for populating extension lists in admin dashboards,
|
|
1536
|
+
* user directories, and CRM extension pickers.
|
|
1537
|
+
*
|
|
1538
|
+
* Each extension includes:
|
|
1539
|
+
* - `id` — UUID (used for update/delete/SIP credential lookups)
|
|
1540
|
+
* - `extension` — The extension number (e.g. `'1001'`)
|
|
1541
|
+
* - `display_name` — Human-readable name (e.g. `'John Smith'`)
|
|
1542
|
+
* - `presence` — Real-time status: `'available'`, `'on_call'`, `'offline'`, `'do_not_disturb'`
|
|
1543
|
+
* - `voicemail_enabled`, `recording_enabled`, `do_not_disturb`, `status`
|
|
1544
|
+
* - `crm_user_id` — Linked CRM user ID (for mapping extensions to CRM records)
|
|
1545
|
+
*
|
|
1546
|
+
* **Superadmin mode:** Returns extensions from ALL tenants. Each extension includes
|
|
1547
|
+
* a `tenants` field with `{ id, company_name, subdomain }`.
|
|
1548
|
+
*
|
|
1549
|
+
* **CRM UI guidance:** Show this as a table with columns: Extension, Display Name,
|
|
1550
|
+
* Presence (colored dot), Status (active/disabled), Voicemail (on/off), Actions
|
|
1551
|
+
* (Edit, Delete, SIP Credentials). Add a search bar and presence filter.
|
|
1552
|
+
*
|
|
1553
|
+
* @returns An object containing:
|
|
1554
|
+
* - `extensions: Extension[]` — Array of extension objects
|
|
1555
|
+
*
|
|
1556
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
1557
|
+
*
|
|
1558
|
+
* @example
|
|
1559
|
+
* ```typescript
|
|
1560
|
+
* const { extensions } = await voip.listExtensions();
|
|
1561
|
+
*
|
|
1562
|
+
* // Show extensions table
|
|
1563
|
+
* extensions.forEach(ext => {
|
|
1564
|
+
* const presenceColor = {
|
|
1565
|
+
* available: 'green', on_call: 'orange',
|
|
1566
|
+
* offline: 'gray', do_not_disturb: 'red',
|
|
1567
|
+
* }[ext.presence || 'offline'];
|
|
1568
|
+
* console.log(`${presenceColor} ${ext.extension} | ${ext.display_name} | ${ext.presence}`);
|
|
1569
|
+
* });
|
|
1570
|
+
*
|
|
1571
|
+
* // CRM UI: build a call transfer extension picker
|
|
1572
|
+
* const onlineExtensions = extensions.filter(e =>
|
|
1573
|
+
* e.presence === 'available' && e.status === 'active'
|
|
1574
|
+
* );
|
|
1575
|
+
* // Show in a dropdown for blind transfer
|
|
1576
|
+
* ```
|
|
1577
|
+
*
|
|
1578
|
+
* @see GET /api/extensions
|
|
1579
|
+
*/
|
|
376
1580
|
async listExtensions() {
|
|
377
1581
|
return this.request('GET', '/api/extensions');
|
|
378
1582
|
}
|
|
379
|
-
/**
|
|
1583
|
+
/**
|
|
1584
|
+
* Get a single extension by its UUID.
|
|
1585
|
+
*
|
|
1586
|
+
* @description
|
|
1587
|
+
* Returns full details for one extension including SIP settings, voicemail config,
|
|
1588
|
+
* call forwarding, CRM mapping, and real-time presence status. Use this to populate
|
|
1589
|
+
* an "Extension Detail" panel or edit form in the admin dashboard.
|
|
1590
|
+
*
|
|
1591
|
+
* **CRM UI guidance:** Show all fields in a detail view with an "Edit" button.
|
|
1592
|
+
* Display presence as a colored dot with status text. Show voicemail settings
|
|
1593
|
+
* (enabled/disabled, PIN, greeting type). Show call forwarding destination if active.
|
|
1594
|
+
*
|
|
1595
|
+
* @param id — The UUID of the extension (from `listExtensions()` → `extensions[].id`)
|
|
1596
|
+
*
|
|
1597
|
+
* @returns An object containing:
|
|
1598
|
+
* - `extension: Extension` — The full extension object
|
|
1599
|
+
*
|
|
1600
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
1601
|
+
* @throws {HttpError} 404 — Extension not found
|
|
1602
|
+
*
|
|
1603
|
+
* @example
|
|
1604
|
+
* ```typescript
|
|
1605
|
+
* const { extension } = await voip.getExtension('a1b2c3d4-e5f6-7890-abcd-ef1234567890');
|
|
1606
|
+
*
|
|
1607
|
+
* console.log(`Extension: ${extension.extension}`);
|
|
1608
|
+
* console.log(`Name: ${extension.display_name}`);
|
|
1609
|
+
* console.log(`Presence: ${extension.presence}`);
|
|
1610
|
+
* console.log(`Voicemail: ${extension.voicemail_enabled ? 'On' : 'Off'}`);
|
|
1611
|
+
* console.log(`CRM User: ${extension.crm_user_id || 'Not mapped'}`);
|
|
1612
|
+
* ```
|
|
1613
|
+
*
|
|
1614
|
+
* @see GET /api/extensions/{id}
|
|
1615
|
+
*/
|
|
380
1616
|
async getExtension(id) {
|
|
381
1617
|
return this.request('GET', `/api/extensions/${id}`);
|
|
382
1618
|
}
|
|
383
|
-
/**
|
|
1619
|
+
/**
|
|
1620
|
+
* Create a new extension (also creates a Kamailio SIP subscriber for registration).
|
|
1621
|
+
*
|
|
1622
|
+
* @description
|
|
1623
|
+
* Provisions a new PBX extension for the tenant. This creates:
|
|
1624
|
+
* - An extension record in the PostgreSQL database
|
|
1625
|
+
* - A Kamailio SIP subscriber entry so the extension can register (softphone/desk phone)
|
|
1626
|
+
* - Optional voicemail box with PIN protection
|
|
1627
|
+
*
|
|
1628
|
+
* The returned `sip` object contains the provisioning info needed to configure
|
|
1629
|
+
* a SIP softphone or desk phone: `username`, `domain`, and `server`.
|
|
1630
|
+
*
|
|
1631
|
+
* **Superadmin mode:** Pass `{ tenantId: 'uuid' }` as the second parameter when
|
|
1632
|
+
* using a global superadmin API key to create an extension in a specific tenant.
|
|
1633
|
+
*
|
|
1634
|
+
* **CRM UI guidance:** Show a form with fields: Extension Number (required),
|
|
1635
|
+
* Display Name (required), Password (required), Voicemail Enabled (checkbox),
|
|
1636
|
+
* Voicemail PIN (optional, 4-6 digits), Recording Enabled (checkbox, default on),
|
|
1637
|
+
* Outbound Caller ID (dropdown of tenant DIDs). Validate before submit.
|
|
1638
|
+
*
|
|
1639
|
+
* @param params — Extension creation parameters
|
|
1640
|
+
* @param params.extension — The extension number (e.g. `'2001'`). Must be unique within the tenant
|
|
1641
|
+
* @param params.password — SIP password for registration (min 8 chars recommended)
|
|
1642
|
+
* @param params.displayName — Human-readable display name (e.g. `'Alice Johnson'`)
|
|
1643
|
+
* @param params.voicemailEnabled — Enable voicemail for this extension (default `false`)
|
|
1644
|
+
* @param params.voicemailPin — 4-6 digit PIN for voicemail access via *97 (optional)
|
|
1645
|
+
* @param params.doNotDisturb — Enable Do Not Disturb mode (default `false`)
|
|
1646
|
+
* @param params.recordingEnabled — Enable call recording (default `true`)
|
|
1647
|
+
* @param params.noiseCancellationEnabled — Enable server-side RNNoise noise cancellation (default `true`)
|
|
1648
|
+
* @param params.outboundCallerId — Outbound caller ID DID number (must be a tenant DID).
|
|
1649
|
+
* Set to `null` to fall back to the tenant's first active DID
|
|
1650
|
+
* @param options — Optional request overrides
|
|
1651
|
+
* @param options.tenantId — Target tenant UUID (required for superadmin keys only)
|
|
1652
|
+
*
|
|
1653
|
+
* @returns An object containing:
|
|
1654
|
+
* - `extension: Extension` — The created extension object
|
|
1655
|
+
* - `sip: SipProvisioningInfo` — SIP provisioning info with `username`, `domain`, `server`
|
|
1656
|
+
*
|
|
1657
|
+
* @throws {HttpError} 400 — Extension number already exists, invalid outbound caller ID, or missing required fields
|
|
1658
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
1659
|
+
*
|
|
1660
|
+
* @example
|
|
1661
|
+
* ```typescript
|
|
1662
|
+
* // Create a new extension for the tenant
|
|
1663
|
+
* const { extension, sip } = await voip.createExtension({
|
|
1664
|
+
* extension: '2001',
|
|
1665
|
+
* password: 'secure-sip-password',
|
|
1666
|
+
* displayName: 'Alice Johnson',
|
|
1667
|
+
* voicemailEnabled: true,
|
|
1668
|
+
* voicemailPin: '1234',
|
|
1669
|
+
* recordingEnabled: true,
|
|
1670
|
+
* });
|
|
1671
|
+
*
|
|
1672
|
+
* console.log(`Created extension ${sip.username}@${sip.domain} (server: ${sip.server})`);
|
|
1673
|
+
*
|
|
1674
|
+
* // Superadmin: create extension in a specific tenant
|
|
1675
|
+
* const { extension } = await voip.createExtension(
|
|
1676
|
+
* { extension: '3001', password: 'pass123', displayName: 'Bob' },
|
|
1677
|
+
* { tenantId: 'a1b2c3d4-...' },
|
|
1678
|
+
* );
|
|
1679
|
+
*
|
|
1680
|
+
* // CRM UI: handle form submission
|
|
1681
|
+
* async function handleCreateExtension(formData: CreateExtensionParams) {
|
|
1682
|
+
* try {
|
|
1683
|
+
* const { extension, sip } = await voip.createExtension(formData);
|
|
1684
|
+
* showNotification(`Extension ${extension.extension} created successfully`);
|
|
1685
|
+
* showProvisioningInfo(sip); // Show SIP config for the user
|
|
1686
|
+
* } catch (err) {
|
|
1687
|
+
* if (err instanceof HttpError && err.status === 400) {
|
|
1688
|
+
* showError('Extension number already exists — choose another');
|
|
1689
|
+
* }
|
|
1690
|
+
* }
|
|
1691
|
+
* }
|
|
1692
|
+
* ```
|
|
1693
|
+
*
|
|
1694
|
+
* @see POST /api/extensions
|
|
1695
|
+
*/
|
|
384
1696
|
async createExtension(params, options) {
|
|
385
1697
|
return this.request('POST', '/api/extensions', params, undefined, options);
|
|
386
1698
|
}
|
|
387
|
-
/**
|
|
1699
|
+
/**
|
|
1700
|
+
* Update an existing extension's settings.
|
|
1701
|
+
*
|
|
1702
|
+
* @description
|
|
1703
|
+
* Modifies one or more fields on an extension. All parameters are optional —
|
|
1704
|
+
* only the fields you provide will be updated. Common use cases:
|
|
1705
|
+
* - Change display name when an employee's name changes
|
|
1706
|
+
* - Reset SIP password for a user who forgot it
|
|
1707
|
+
* - Enable/disable voicemail or change the PIN
|
|
1708
|
+
* - Toggle Do Not Disturb for vacation/after-hours
|
|
1709
|
+
* - Enable/disable recording per extension
|
|
1710
|
+
* - Activate or disable an extension
|
|
1711
|
+
*
|
|
1712
|
+
* **CRM UI guidance:** Show an edit form pre-populated with current values from `getExtension()`.
|
|
1713
|
+
* Use a modal or slide-over panel. Show a "Save" button that only lights up when a value
|
|
1714
|
+
* has changed. For password changes, show a "Show/Hide" toggle button.
|
|
1715
|
+
*
|
|
1716
|
+
* @param id — The UUID of the extension to update (from `listExtensions()` → `extensions[].id`)
|
|
1717
|
+
* @param params — Fields to update (all optional, omit unchanged fields)
|
|
1718
|
+
* @param params.displayName — New display name. Example: `'Alicia Johnson'`
|
|
1719
|
+
* @param params.password — New SIP password. Example: `'new-secure-password'`
|
|
1720
|
+
* @param params.voicemailEnabled — Enable or disable voicemail
|
|
1721
|
+
* @param params.voicemailPin — New 4-6 digit voicemail PIN. Set to `null` to remove PIN protection
|
|
1722
|
+
* @param params.doNotDisturb — Enable or disable Do Not Disturb
|
|
1723
|
+
* @param params.status — `'active'` or `'disabled'`. Disabled extensions cannot make/receive calls
|
|
1724
|
+
* @param params.recordingEnabled — Enable or disable call recording
|
|
1725
|
+
* @param params.noiseCancellationEnabled — Enable or disable server-side noise cancellation
|
|
1726
|
+
* @param options — Optional request overrides
|
|
1727
|
+
* @param options.tenantId — Target tenant UUID (required for superadmin keys only)
|
|
1728
|
+
*
|
|
1729
|
+
* @returns An object containing:
|
|
1730
|
+
* - `message: string` — Confirmation message (e.g. `'Extension updated'`)
|
|
1731
|
+
*
|
|
1732
|
+
* @throws {HttpError} 400 — Invalid field value or extension not found
|
|
1733
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
1734
|
+
*
|
|
1735
|
+
* @example
|
|
1736
|
+
* ```typescript
|
|
1737
|
+
* // Update display name and enable voicemail
|
|
1738
|
+
* await voip.updateExtension('ext-uuid-here', {
|
|
1739
|
+
* displayName: 'Alicia Johnson',
|
|
1740
|
+
* voicemailEnabled: true,
|
|
1741
|
+
* voicemailPin: '5678',
|
|
1742
|
+
* });
|
|
1743
|
+
*
|
|
1744
|
+
* // Disable an extension (temporarily)
|
|
1745
|
+
* await voip.updateExtension('ext-uuid-here', { status: 'disabled' });
|
|
1746
|
+
*
|
|
1747
|
+
* // Re-enable
|
|
1748
|
+
* await voip.updateExtension('ext-uuid-here', { status: 'active' });
|
|
1749
|
+
*
|
|
1750
|
+
* // CRM UI: Edit form submit
|
|
1751
|
+
* async function handleUpdateExtension(id: string, changes: UpdateExtensionParams) {
|
|
1752
|
+
* if (Object.keys(changes).length === 0) return; // Nothing changed
|
|
1753
|
+
* try {
|
|
1754
|
+
* await voip.updateExtension(id, changes);
|
|
1755
|
+
* showNotification('Extension updated');
|
|
1756
|
+
* closeEditModal();
|
|
1757
|
+
* refreshExtensionList();
|
|
1758
|
+
* } catch (err) {
|
|
1759
|
+
* showError('Update failed — please try again');
|
|
1760
|
+
* }
|
|
1761
|
+
* }
|
|
1762
|
+
* ```
|
|
1763
|
+
*
|
|
1764
|
+
* @see PUT /api/extensions/{id}
|
|
1765
|
+
*/
|
|
388
1766
|
async updateExtension(id, params, options) {
|
|
389
1767
|
return this.request('PUT', `/api/extensions/${id}`, params, undefined, options);
|
|
390
1768
|
}
|
|
391
|
-
/**
|
|
1769
|
+
/**
|
|
1770
|
+
* Delete an extension permanently.
|
|
1771
|
+
*
|
|
1772
|
+
* @description
|
|
1773
|
+
* Removes the extension from the database and cleans up associated resources:
|
|
1774
|
+
* - Extension record (PostgreSQL)
|
|
1775
|
+
* - Kamailio SIP subscriber (can no longer register)
|
|
1776
|
+
* - Voicemail messages and greeting audio files
|
|
1777
|
+
* - Ring group / queue / paging group memberships
|
|
1778
|
+
* - CRM mapping associations
|
|
1779
|
+
*
|
|
1780
|
+
* This operation is irreversible. Call records (CDRs) belonging to this extension
|
|
1781
|
+
* are preserved for audit purposes.
|
|
1782
|
+
*
|
|
1783
|
+
* **CRM UI guidance:** Show a confirmation dialog before deleting:
|
|
1784
|
+
* "Are you sure you want to delete extension [number] ([name])? This will also
|
|
1785
|
+
* delete all associated voicemail messages, greetings, and group memberships."
|
|
1786
|
+
* Use a red "Delete" button. After deletion, remove the row from the table.
|
|
1787
|
+
*
|
|
1788
|
+
* @param id — The UUID of the extension to delete (from `listExtensions()` → `extensions[].id`)
|
|
1789
|
+
*
|
|
1790
|
+
* @returns An object containing:
|
|
1791
|
+
* - `message: string` — Confirmation message (e.g. `'Extension deleted'`)
|
|
1792
|
+
*
|
|
1793
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
1794
|
+
* @throws {HttpError} 404 — Extension not found
|
|
1795
|
+
*
|
|
1796
|
+
* @example
|
|
1797
|
+
* ```typescript
|
|
1798
|
+
* // Delete extension 2001
|
|
1799
|
+
* await voip.deleteExtension('ext-uuid-here');
|
|
1800
|
+
*
|
|
1801
|
+
* // CRM UI: Delete button with confirmation
|
|
1802
|
+
* async function handleDeleteExtension(ext: Extension) {
|
|
1803
|
+
* const confirmed = confirm(
|
|
1804
|
+
* `Delete extension ${ext.extension} (${ext.display_name})?\n\n` +
|
|
1805
|
+
* 'This will also delete all voicemail messages and greetings.'
|
|
1806
|
+
* );
|
|
1807
|
+
* if (!confirmed) return;
|
|
1808
|
+
*
|
|
1809
|
+
* try {
|
|
1810
|
+
* await voip.deleteExtension(ext.id);
|
|
1811
|
+
* removeExtensionRow(ext.id);
|
|
1812
|
+
* showNotification(`Extension ${ext.extension} deleted`);
|
|
1813
|
+
* } catch (err) {
|
|
1814
|
+
* showError('Delete failed — please try again');
|
|
1815
|
+
* }
|
|
1816
|
+
* }
|
|
1817
|
+
* ```
|
|
1818
|
+
*
|
|
1819
|
+
* @see DELETE /api/extensions/{id}
|
|
1820
|
+
*/
|
|
392
1821
|
async deleteExtension(id) {
|
|
393
1822
|
return this.request('DELETE', `/api/extensions/${id}`);
|
|
394
1823
|
}
|
|
@@ -409,19 +1838,77 @@ export class VoIPClient {
|
|
|
409
1838
|
return this.request('PUT', `/api/extensions/${extensionId}/crm-mapping`, params);
|
|
410
1839
|
}
|
|
411
1840
|
/**
|
|
412
|
-
* Get SIP credentials for connecting a WebRTC softphone.
|
|
413
|
-
*
|
|
414
|
-
*
|
|
1841
|
+
* Get SIP credentials for connecting a WebRTC softphone or SIP device.
|
|
1842
|
+
*
|
|
1843
|
+
* @description
|
|
1844
|
+
* Returns everything needed to register a softphone or desk phone with the SIP server:
|
|
1845
|
+
* extension number, SIP password, SIP domain, WebSocket URI (for WebRTC), and registrar address.
|
|
1846
|
+
* Use this so your app never needs to store SIP passwords — fetch them on demand
|
|
1847
|
+
* when the user opens the softphone page.
|
|
1848
|
+
*
|
|
1849
|
+
* **Two lookup modes:**
|
|
1850
|
+
* - **By UUID** (default): `getSipCredentials('extension-uuid')` — use the `id` from `listExtensions()`
|
|
1851
|
+
* - **By number**: `getSipCredentials('1001', { byNumber: true, tenantId: 'uuid' })` — look up
|
|
1852
|
+
* by extension number string. `tenantId` is required for by-number lookup (the extension
|
|
1853
|
+
* number is only unique within a tenant).
|
|
1854
|
+
*
|
|
1855
|
+
* **CRM UI guidance:** Call this when the user clicks "Connect Softphone" or opens the
|
|
1856
|
+
* WebRTC dialer. Pass the credentials to the `WebRTCPhone` constructor. Never display
|
|
1857
|
+
* the password in the UI — use a "Copy" button with `type="password"` input.
|
|
1858
|
+
*
|
|
1859
|
+
* @param target — Extension UUID (default mode), or extension number string when `byNumber: true`
|
|
1860
|
+
* @param options — Optional overrides. Include `{ byNumber: true, tenantId: 'uuid' }` to look
|
|
1861
|
+
* up by extension number instead of UUID. `tenantId` is required for by-number lookups.
|
|
415
1862
|
*
|
|
416
|
-
* @
|
|
417
|
-
*
|
|
1863
|
+
* @returns An object containing:
|
|
1864
|
+
* - `sipCredentials: SipCredentials` — Object with `extension`, `displayName`, `password`,
|
|
1865
|
+
* `sipDomain`, `wsUri` (WebSocket URI for WebRTC), and `registrar`
|
|
1866
|
+
*
|
|
1867
|
+
* @throws {HttpError} 400 — `tenantId` is required when using `byNumber: true`
|
|
1868
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
1869
|
+
* @throws {HttpError} 404 — Extension not found
|
|
418
1870
|
*
|
|
419
1871
|
* @example
|
|
420
|
-
*
|
|
1872
|
+
* ```typescript
|
|
1873
|
+
* // By UUID (default) — pass id from listExtensions()
|
|
421
1874
|
* const { sipCredentials } = await voip.getSipCredentials('extension-uuid');
|
|
422
1875
|
*
|
|
423
|
-
* // By extension number
|
|
424
|
-
* const { sipCredentials } = await voip.getSipCredentials('1001', {
|
|
1876
|
+
* // By extension number — requires tenantId
|
|
1877
|
+
* const { sipCredentials } = await voip.getSipCredentials('1001', {
|
|
1878
|
+
* byNumber: true,
|
|
1879
|
+
* tenantId: 'a1b2c3d4-...',
|
|
1880
|
+
* });
|
|
1881
|
+
*
|
|
1882
|
+
* // Use credentials with WebRTCPhone
|
|
1883
|
+
* const phone = new WebRTCPhone({
|
|
1884
|
+
* extension: sipCredentials.extension,
|
|
1885
|
+
* password: sipCredentials.password,
|
|
1886
|
+
* sipDomain: sipCredentials.sipDomain,
|
|
1887
|
+
* wsUri: sipCredentials.wsUri,
|
|
1888
|
+
* });
|
|
1889
|
+
*
|
|
1890
|
+
* // CRM UI: "Connect Phone" button
|
|
1891
|
+
* async function connectSoftphone(extensionId: string) {
|
|
1892
|
+
* showConnectingSpinner();
|
|
1893
|
+
* try {
|
|
1894
|
+
* const { sipCredentials } = await voip.getSipCredentials(extensionId);
|
|
1895
|
+
* const phone = new WebRTCPhone({
|
|
1896
|
+
* extension: sipCredentials.extension,
|
|
1897
|
+
* password: sipCredentials.password,
|
|
1898
|
+
* sipDomain: sipCredentials.sipDomain,
|
|
1899
|
+
* wsUri: sipCredentials.wsUri,
|
|
1900
|
+
* audioElement: document.getElementById('remote-audio') as HTMLAudioElement,
|
|
1901
|
+
* });
|
|
1902
|
+
* await phone.connect();
|
|
1903
|
+
* showPhoneReady(sipCredentials.extension);
|
|
1904
|
+
* } catch (err) {
|
|
1905
|
+
* showError('Failed to connect softphone');
|
|
1906
|
+
* }
|
|
1907
|
+
* }
|
|
1908
|
+
* ```
|
|
1909
|
+
*
|
|
1910
|
+
* @see GET /api/extensions/{id}/sip-credentials
|
|
1911
|
+
* @see GET /api/extensions/by-number/{number}/sip-credentials
|
|
425
1912
|
*/
|
|
426
1913
|
async getSipCredentials(target, options) {
|
|
427
1914
|
const byNumber = options && 'byNumber' in options ? options.byNumber : false;
|
|
@@ -433,7 +1920,45 @@ export class VoIPClient {
|
|
|
433
1920
|
// -------------------------------------------------------------------
|
|
434
1921
|
// Tenants (superadmin)
|
|
435
1922
|
// -------------------------------------------------------------------
|
|
436
|
-
/**
|
|
1923
|
+
/**
|
|
1924
|
+
* List all tenants (superadmin only).
|
|
1925
|
+
*
|
|
1926
|
+
* @description
|
|
1927
|
+
* Returns every tenant in the platform. This is a superadmin-only endpoint — tenant
|
|
1928
|
+
* admins cannot see other tenants. Each tenant includes company name, subdomain,
|
|
1929
|
+
* SIP domain, Twilio integration settings, and feature flags.
|
|
1930
|
+
*
|
|
1931
|
+
* **CRM UI guidance (superadmin):** Show as a table with columns: Company Name,
|
|
1932
|
+
* Subdomain, Extensions Count (use `getTenantStats()`), Status (active/disabled),
|
|
1933
|
+
* and an Actions menu (View, Edit, Delete). Add a search bar to filter by company
|
|
1934
|
+
* name. Show a "Create Tenant" button at the top.
|
|
1935
|
+
*
|
|
1936
|
+
* @returns An object containing:
|
|
1937
|
+
* - `tenants: Tenant[]` — Array of tenant objects
|
|
1938
|
+
*
|
|
1939
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
1940
|
+
* @throws {HttpError} 403 — Not a superadmin
|
|
1941
|
+
*
|
|
1942
|
+
* @example
|
|
1943
|
+
* ```typescript
|
|
1944
|
+
* const { tenants } = await voip.listTenants();
|
|
1945
|
+
*
|
|
1946
|
+
* tenants.forEach(t => {
|
|
1947
|
+
* console.log(`${t.companyName} (${t.subdomain}) — ${t.status}`);
|
|
1948
|
+
* });
|
|
1949
|
+
*
|
|
1950
|
+
* // CRM UI: superadmin tenant list
|
|
1951
|
+
* const { tenants } = await voip.listTenants();
|
|
1952
|
+
* renderTenantTable(tenants.map(t => ({
|
|
1953
|
+
* id: t.id,
|
|
1954
|
+
* name: t.companyName,
|
|
1955
|
+
* subdomain: t.subdomain,
|
|
1956
|
+
* status: t.status,
|
|
1957
|
+
* })));
|
|
1958
|
+
* ```
|
|
1959
|
+
*
|
|
1960
|
+
* @see GET /api/tenants
|
|
1961
|
+
*/
|
|
437
1962
|
async listTenants() {
|
|
438
1963
|
return this.request('GET', '/api/tenants');
|
|
439
1964
|
}
|
|
@@ -476,7 +2001,53 @@ export class VoIPClient {
|
|
|
476
2001
|
// -------------------------------------------------------------------
|
|
477
2002
|
// DIDs
|
|
478
2003
|
// -------------------------------------------------------------------
|
|
479
|
-
/**
|
|
2004
|
+
/**
|
|
2005
|
+
* List all DID (Direct Inward Dialing) phone numbers for the current tenant.
|
|
2006
|
+
*
|
|
2007
|
+
* @description
|
|
2008
|
+
* Retrieves every phone number assigned to the tenant. Each DID includes:
|
|
2009
|
+
* - The phone number itself (the DID)
|
|
2010
|
+
* - Inbound routing configuration (IVR, extension, queue, ring group, AI agent, etc.)
|
|
2011
|
+
* - Route target (e.g. IVR menu name, extension number)
|
|
2012
|
+
* - Emergency caller ID flag
|
|
2013
|
+
* - Status (active/inactive)
|
|
2014
|
+
*
|
|
2015
|
+
* DIDs are how external callers reach your phone system — someone dials the DID
|
|
2016
|
+
* number, and the system routes the call according to the inbound route settings.
|
|
2017
|
+
*
|
|
2018
|
+
* **CRM UI guidance:** Show as a table with columns: Phone Number, Route Type
|
|
2019
|
+
* (Extension/IVR/Queue/etc.), Route Target, Status (active/inactive), Actions
|
|
2020
|
+
* (Edit, Delete). Show a "Create DID" button. Use a badge/tag for the route type.
|
|
2021
|
+
*
|
|
2022
|
+
* @returns An object containing:
|
|
2023
|
+
* - `dids: DidNumber[]` — Array of DID number objects
|
|
2024
|
+
*
|
|
2025
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
2026
|
+
*
|
|
2027
|
+
* @example
|
|
2028
|
+
* ```typescript
|
|
2029
|
+
* const { dids } = await voip.listDids();
|
|
2030
|
+
*
|
|
2031
|
+
* dids.forEach(did => {
|
|
2032
|
+
* console.log(`${did.number} → ${did.inbound_route} / ${did.route_target}`);
|
|
2033
|
+
* });
|
|
2034
|
+
*
|
|
2035
|
+
* // CRM UI: DID list with route badges
|
|
2036
|
+
* dids.forEach(did => {
|
|
2037
|
+
* const routeLabel = {
|
|
2038
|
+
* extension: '📞 Extension',
|
|
2039
|
+
* ivr: '🌳 IVR',
|
|
2040
|
+
* queue: '👥 Queue',
|
|
2041
|
+
* ring_group: '🔔 Ring Group',
|
|
2042
|
+
* ai_agent: '🤖 AI Agent',
|
|
2043
|
+
* whatsapp: '💬 WhatsApp',
|
|
2044
|
+
* }[did.inbound_route] || did.inbound_route;
|
|
2045
|
+
* renderDidRow(did.number, routeLabel, did.route_target, did.status);
|
|
2046
|
+
* });
|
|
2047
|
+
* ```
|
|
2048
|
+
*
|
|
2049
|
+
* @see GET /api/dids
|
|
2050
|
+
*/
|
|
480
2051
|
async listDids() {
|
|
481
2052
|
return this.request('GET', '/api/dids');
|
|
482
2053
|
}
|
|
@@ -961,61 +2532,161 @@ export class VoIPClient {
|
|
|
961
2532
|
//
|
|
962
2533
|
// -------------------------------------------------------------------
|
|
963
2534
|
/**
|
|
964
|
-
* List recordings with pagination.
|
|
2535
|
+
* List call recordings with pagination and filtering.
|
|
965
2536
|
*
|
|
966
|
-
*
|
|
967
|
-
*
|
|
2537
|
+
* @description
|
|
2538
|
+
* Retrieves a paginated list of call recordings for the current tenant. Each recording
|
|
2539
|
+
* includes `cdn_url` (CloudFront CDN, instant, no auth) and `audio_url` (API fallback).
|
|
2540
|
+
* Always prefer `cdn_url` when non-null for the fastest playback from the nearest
|
|
2541
|
+
* CloudFront edge location.
|
|
2542
|
+
*
|
|
2543
|
+
* **CRM UI guidance:** Show a "Recordings" table with columns: Date/Time, Caller,
|
|
2544
|
+
* Destination, Duration, and a Play button. The Play button should use `cdn_url` if
|
|
2545
|
+
* available (no API call needed), falling back to `buildUrl(audio_url)`. Show a
|
|
2546
|
+
* loading spinner state. If recordings exist, show pagination controls at the bottom.
|
|
2547
|
+
*
|
|
2548
|
+
* **Playback decision tree:**
|
|
2549
|
+
* ```
|
|
2550
|
+
* if (rec.cdn_url) → use cdn_url directly (<audio src={rec.cdn_url} />)
|
|
2551
|
+
* else if (rec.audio_url) → use voip.buildUrl(`/api/recordings/${rec.id}/audio`)
|
|
2552
|
+
* else → "No recording" state
|
|
2553
|
+
* ```
|
|
2554
|
+
*
|
|
2555
|
+
* @param params — Optional filters and pagination
|
|
2556
|
+
* @param params.page — Page number (1-based, default 1)
|
|
2557
|
+
* @param params.limit — Records per page (default 50)
|
|
2558
|
+
* @param params.direction — Filter by call direction: `'inbound'`, `'outbound'`, `'internal'`, or `'all'`
|
|
2559
|
+
* @param params.dateFrom — ISO date string: recordings on or after this date
|
|
2560
|
+
* @param params.dateTo — ISO date string: recordings on or before this date
|
|
2561
|
+
* @param params.extension — Filter by extension number
|
|
2562
|
+
*
|
|
2563
|
+
* @returns An object containing:
|
|
2564
|
+
* - `recordings: Recording[]` — Array of recording objects with `id`, `cdn_url`,
|
|
2565
|
+
* `audio_url`, `caller_id_number`, `destination`, `duration_seconds`, `started_at`,
|
|
2566
|
+
* `format`, and optional `company_name`/`subdomain` (superadmin)
|
|
2567
|
+
* - `pagination: PaginationInfo` — `{ page, limit, total, pages }`
|
|
2568
|
+
*
|
|
2569
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
968
2570
|
*
|
|
969
2571
|
* @example
|
|
970
2572
|
* ```typescript
|
|
971
2573
|
* const { recordings, pagination } = await voip.listRecordings({
|
|
972
2574
|
* page: 1,
|
|
973
2575
|
* limit: 20,
|
|
2576
|
+
* dateFrom: '2026-06-01',
|
|
2577
|
+
* dateTo: '2026-06-30',
|
|
974
2578
|
* direction: 'inbound',
|
|
975
2579
|
* });
|
|
976
2580
|
*
|
|
2581
|
+
* console.log(`Found ${pagination.total} recordings`);
|
|
2582
|
+
*
|
|
977
2583
|
* recordings.forEach(rec => {
|
|
978
|
-
* // Always prefer CDN URL — instant playback from nearest edge
|
|
979
2584
|
* const src = rec.cdn_url ?? voip.buildUrl(`/api/recordings/${rec.id}/audio`);
|
|
980
|
-
*
|
|
2585
|
+
* // Render <audio src={src} preload="none" controls />
|
|
981
2586
|
* });
|
|
2587
|
+
*
|
|
2588
|
+
* // CRM UI: pagination through all recordings
|
|
2589
|
+
* for (let page = 1; page <= pagination.pages; page++) {
|
|
2590
|
+
* const { recordings } = await voip.listRecordings({ page, limit: 50 });
|
|
2591
|
+
* renderRecordingPage(recordings, page);
|
|
2592
|
+
* }
|
|
982
2593
|
* ```
|
|
2594
|
+
*
|
|
2595
|
+
* @see GET /api/recordings
|
|
983
2596
|
*/
|
|
984
2597
|
async listRecordings(params) {
|
|
985
2598
|
return this.request('GET', '/api/recordings', undefined, params);
|
|
986
2599
|
}
|
|
987
2600
|
/**
|
|
988
|
-
* Get the best available URL to play a recording.
|
|
2601
|
+
* Get the best available URL to play a recording, with source and format metadata.
|
|
989
2602
|
*
|
|
990
|
-
*
|
|
991
|
-
*
|
|
992
|
-
*
|
|
993
|
-
*
|
|
2603
|
+
* @description
|
|
2604
|
+
* Resolves the optimal recording URL by checking S3/CloudFront availability first,
|
|
2605
|
+
* then falling back to local disk streaming. Returns metadata about which source
|
|
2606
|
+
* was selected so you can optimize future calls.
|
|
994
2607
|
*
|
|
995
2608
|
* **For most CRM use cases**, you don't need this method. Just read `cdn_url` from
|
|
996
|
-
* `listCalls()` or `getCall()` directly
|
|
997
|
-
*
|
|
2609
|
+
* `listCalls()` or `getCall()` directly — it's the same CDN URL with zero API calls.
|
|
2610
|
+
* Use this only when you need to programmatically resolve the URL before rendering
|
|
2611
|
+
* and need to know the source and format.
|
|
2612
|
+
*
|
|
2613
|
+
* @param id — The recording ID (from `listCalls()` → `calls[].id` or `listRecordings()` → `recordings[].id`)
|
|
2614
|
+
*
|
|
2615
|
+
* @returns An object containing:
|
|
2616
|
+
* - `url: string` — The full URL to play this recording (CDN or API)
|
|
2617
|
+
* - `source: 'cdn' | 'local'` — `'cdn'` = CloudFront (fast, ~5-20ms) or `'local'` = disk stream (slower)
|
|
2618
|
+
* - `format: 'mp3' | 'wav'` — Audio file format
|
|
2619
|
+
*
|
|
2620
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
2621
|
+
* @throws {HttpError} 404 — Recording not found
|
|
998
2622
|
*
|
|
999
2623
|
* @example
|
|
1000
2624
|
* ```typescript
|
|
1001
2625
|
* const { url, source, format } = await voip.getRecordingUrl(callId);
|
|
1002
|
-
*
|
|
2626
|
+
*
|
|
2627
|
+
* if (source === 'cdn') {
|
|
2628
|
+
* console.log(`Playing from CDN: ${url} (${format})`);
|
|
2629
|
+
* } else {
|
|
2630
|
+
* console.log(`Playing from local disk: ${url} (${format})`);
|
|
2631
|
+
* }
|
|
2632
|
+
*
|
|
2633
|
+
* // Pass to audio element
|
|
1003
2634
|
* audioElement.src = url;
|
|
2635
|
+
* audioElement.load();
|
|
2636
|
+
* audioElement.play();
|
|
1004
2637
|
* ```
|
|
2638
|
+
*
|
|
2639
|
+
* @see GET /api/recordings/{id}/url
|
|
1005
2640
|
*/
|
|
1006
2641
|
async getRecordingUrl(id) {
|
|
1007
2642
|
return this.request('GET', `/api/recordings/${id}/url`);
|
|
1008
2643
|
}
|
|
1009
2644
|
/**
|
|
1010
|
-
* Delete a recording permanently.
|
|
2645
|
+
* Delete a recording permanently from all storage.
|
|
2646
|
+
*
|
|
2647
|
+
* @description
|
|
2648
|
+
* Removes the audio file from S3 cloud storage (if uploaded), local server disk, and
|
|
2649
|
+
* Redis cache. The `recording_url`, `s3_key`, `cdn_url`, and `audio_url` fields for
|
|
2650
|
+
* this call record will become null after deletion. The call record (CDR) itself is
|
|
2651
|
+
* preserved — only the audio file is removed.
|
|
2652
|
+
*
|
|
2653
|
+
* This is irreversible. Use with care, especially when complying with data retention
|
|
2654
|
+
* policies or GDPR deletion requests.
|
|
2655
|
+
*
|
|
2656
|
+
* **CRM UI guidance:** Show a confirmation dialog: "Permanently delete this recording?
|
|
2657
|
+
* The call history entry will remain, but the audio file cannot be recovered."
|
|
2658
|
+
* Use a red "Delete" button with a trash icon. After deletion, update the row to show
|
|
2659
|
+
* "Recording deleted" or remove the Play button.
|
|
2660
|
+
*
|
|
2661
|
+
* @param id — The recording/call ID (from `listCalls()` → `calls[].id` or `listRecordings()` → `recordings[].id`)
|
|
2662
|
+
*
|
|
2663
|
+
* @returns An object containing:
|
|
2664
|
+
* - `message: string` — Confirmation message (e.g. `'Recording deleted'`)
|
|
2665
|
+
*
|
|
2666
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
2667
|
+
* @throws {HttpError} 404 — Recording not found
|
|
1011
2668
|
*
|
|
1012
|
-
*
|
|
1013
|
-
*
|
|
1014
|
-
*
|
|
1015
|
-
* -
|
|
2669
|
+
* @example
|
|
2670
|
+
* ```typescript
|
|
2671
|
+
* // Delete a specific recording
|
|
2672
|
+
* await voip.deleteRecording('recording-id-here');
|
|
2673
|
+
*
|
|
2674
|
+
* // CRM UI: Delete button with confirmation
|
|
2675
|
+
* async function handleDeleteRecording(recordingId: string) {
|
|
2676
|
+
* if (!confirm('Permanently delete this recording? The audio file cannot be recovered.')) {
|
|
2677
|
+
* return;
|
|
2678
|
+
* }
|
|
2679
|
+
* try {
|
|
2680
|
+
* await voip.deleteRecording(recordingId);
|
|
2681
|
+
* showNotification('Recording deleted');
|
|
2682
|
+
* removePlayButton(recordingId); // Remove Play button from the row
|
|
2683
|
+
* } catch (err) {
|
|
2684
|
+
* showError('Failed to delete recording');
|
|
2685
|
+
* }
|
|
2686
|
+
* }
|
|
2687
|
+
* ```
|
|
1016
2688
|
*
|
|
1017
|
-
*
|
|
1018
|
-
* will become null after deletion.
|
|
2689
|
+
* @see DELETE /api/recordings/{id}
|
|
1019
2690
|
*/
|
|
1020
2691
|
async deleteRecording(id) {
|
|
1021
2692
|
return this.request('DELETE', `/api/recordings/${id}`);
|
|
@@ -1023,27 +2694,305 @@ export class VoIPClient {
|
|
|
1023
2694
|
// -------------------------------------------------------------------
|
|
1024
2695
|
// Conferences
|
|
1025
2696
|
// -------------------------------------------------------------------
|
|
1026
|
-
/**
|
|
2697
|
+
/**
|
|
2698
|
+
* List all active conferences in the phone system.
|
|
2699
|
+
*
|
|
2700
|
+
* @description
|
|
2701
|
+
* Returns every live conference room currently running in FreeSWITCH. Each
|
|
2702
|
+
* conference includes:
|
|
2703
|
+
* - `name` — Conference room name (used for join/kick/mute operations)
|
|
2704
|
+
* - `memberCount` — Number of participants
|
|
2705
|
+
* - `locked` — Whether the conference is locked (no new participants)
|
|
2706
|
+
* - `runTime` — How long the conference has been active (HH:MM:SS)
|
|
2707
|
+
* - `members` — Array of conference member objects with caller info
|
|
2708
|
+
*
|
|
2709
|
+
* Conferences are created automatically by three-way calling (`mergeCalls()`,
|
|
2710
|
+
* `mergeDirectCalls()`) or manually via `transferToConference()`.
|
|
2711
|
+
*
|
|
2712
|
+
* **CRM UI guidance:** Show as a "Active Conferences" panel or monitor view.
|
|
2713
|
+
* Each conference row shows: Name, Participant Count, Duration, Lock Status.
|
|
2714
|
+
* Expand a row to show member details with per-participant controls (Mute, Kick).
|
|
2715
|
+
* Poll every 3-5 seconds for a near-real-time view.
|
|
2716
|
+
*
|
|
2717
|
+
* @returns An object containing:
|
|
2718
|
+
* - `conferences: Conference[]` — Array of active conference objects
|
|
2719
|
+
*
|
|
2720
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
2721
|
+
* @throws {HttpError} 500 — FreeSWITCH connection error
|
|
2722
|
+
*
|
|
2723
|
+
* @example
|
|
2724
|
+
* ```typescript
|
|
2725
|
+
* const { conferences } = await voip.listConferences();
|
|
2726
|
+
*
|
|
2727
|
+
* if (conferences.length === 0) {
|
|
2728
|
+
* console.log('No active conferences');
|
|
2729
|
+
* } else {
|
|
2730
|
+
* conferences.forEach(conf => {
|
|
2731
|
+
* console.log(`${conf.name}: ${conf.memberCount} participants, ${conf.runTime}`);
|
|
2732
|
+
* });
|
|
2733
|
+
* }
|
|
2734
|
+
*
|
|
2735
|
+
* // CRM UI: live conference monitor
|
|
2736
|
+
* setInterval(async () => {
|
|
2737
|
+
* const { conferences } = await voip.listConferences();
|
|
2738
|
+
* updateConferenceList(conferences);
|
|
2739
|
+
* }, 5000);
|
|
2740
|
+
* ```
|
|
2741
|
+
*
|
|
2742
|
+
* @see GET /api/conferences
|
|
2743
|
+
*/
|
|
1027
2744
|
async listConferences() {
|
|
1028
2745
|
return this.request('GET', '/api/conferences');
|
|
1029
2746
|
}
|
|
1030
|
-
/**
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
2747
|
+
/**
|
|
2748
|
+
* Get detailed information about a specific conference by name.
|
|
2749
|
+
*
|
|
2750
|
+
* @description
|
|
2751
|
+
* Returns full conference details including the member list with per-participant
|
|
2752
|
+
* caller ID, mute status, and other metadata. Use this to populate a conference
|
|
2753
|
+
* detail panel in an operator console or monitoring dashboard.
|
|
2754
|
+
*
|
|
2755
|
+
* **CRM UI guidance:** Show as a detail view with conference name, runtime, lock
|
|
2756
|
+
* button, and participant list with individual controls (Mute/Unmute toggle,
|
|
2757
|
+
* Kick button). Show a "Record" button if recording is supported. Display each
|
|
2758
|
+
* participant's caller ID and mute status.
|
|
2759
|
+
*
|
|
2760
|
+
* @param name — The conference room name (from `listConferences()` → `conferences[].name`
|
|
2761
|
+
* or returned by `mergeCalls()` → `conferenceName`)
|
|
2762
|
+
*
|
|
2763
|
+
* @returns An object containing:
|
|
2764
|
+
* - `conference: Conference` — Full conference object with `members` array
|
|
2765
|
+
*
|
|
2766
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
2767
|
+
* @throws {HttpError} 404 — Conference not found
|
|
2768
|
+
* @throws {HttpError} 500 — FreeSWITCH communication error
|
|
2769
|
+
*
|
|
2770
|
+
* @example
|
|
2771
|
+
* ```typescript
|
|
2772
|
+
* const { conference } = await voip.getConference('conf-abc123');
|
|
2773
|
+
*
|
|
2774
|
+
* console.log(`Conference: ${conference.name}`);
|
|
2775
|
+
* console.log(`Participants: ${conference.memberCount}`);
|
|
2776
|
+
* console.log(`Locked: ${conference.locked}`);
|
|
2777
|
+
*
|
|
2778
|
+
* conference.members.forEach(member => {
|
|
2779
|
+
* console.log(` ${member.caller_id_number} — ${member.muted ? 'Muted' : 'Unmuted'}`);
|
|
2780
|
+
* });
|
|
2781
|
+
* ```
|
|
2782
|
+
*
|
|
2783
|
+
* @see GET /api/conferences/{name}
|
|
2784
|
+
*/
|
|
2785
|
+
async getConference(name) {
|
|
2786
|
+
return this.request('GET', `/api/conferences/${name}`);
|
|
2787
|
+
}
|
|
2788
|
+
/** Join a call to a conference */
|
|
2789
|
+
async joinConference(conferenceName, callUuid) {
|
|
2790
|
+
return this.request('POST', `/api/conferences/${conferenceName}/join`, { callUuid });
|
|
2791
|
+
}
|
|
2792
|
+
/**
|
|
2793
|
+
* Transfer an active call into a conference room.
|
|
2794
|
+
*
|
|
2795
|
+
* @description
|
|
2796
|
+
* Moves an existing active call into a FreeSWITCH conference room. If a
|
|
2797
|
+
* `conferenceName` is specified, the call is added to that existing conference.
|
|
2798
|
+
* If omitted, a new conference room is created automatically.
|
|
2799
|
+
*
|
|
2800
|
+
* Use this for:
|
|
2801
|
+
* - Adding a caller to a scheduled conference call
|
|
2802
|
+
* - Transferring a support call from an agent to a team conference
|
|
2803
|
+
* - Creating a "join conference" button in the call controls
|
|
2804
|
+
*
|
|
2805
|
+
* **CRM UI guidance:** Show a "Transfer to Conference" button in the call controls.
|
|
2806
|
+
* On click, show a selector: pick from existing conferences (`listConferences()`) or
|
|
2807
|
+
* create a new one. After transfer, show "Call moved to conference [name]".
|
|
2808
|
+
*
|
|
2809
|
+
* @param uuid — The FreeSWITCH channel UUID of the active call to transfer.
|
|
2810
|
+
* Example: `'a1b2c3d4-e5f6-7890-abcd-ef1234567890'`
|
|
2811
|
+
* @param conferenceName — Optional conference room to join. If omitted, a new
|
|
2812
|
+
* conference is created automatically. Example: `'support-team'` or omitted
|
|
2813
|
+
*
|
|
2814
|
+
* @returns An object containing:
|
|
2815
|
+
* - `message: string` — Confirmation message
|
|
2816
|
+
*
|
|
2817
|
+
* @throws {HttpError} 400 — Call UUID not found or already ended
|
|
2818
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
2819
|
+
* @throws {HttpError} 500 — FreeSWITCH operation failed
|
|
2820
|
+
*
|
|
2821
|
+
* @example
|
|
2822
|
+
* ```typescript
|
|
2823
|
+
* // Transfer a call to an existing conference
|
|
2824
|
+
* await voip.transferToConference(callUuid, 'support-team');
|
|
2825
|
+
*
|
|
2826
|
+
* // Transfer to a new auto-created conference
|
|
2827
|
+
* await voip.transferToConference(callUuid);
|
|
2828
|
+
*
|
|
2829
|
+
* // CRM UI: transfer to conference button
|
|
2830
|
+
* async function handleTransferToConference(callUuid: string) {
|
|
2831
|
+
* const { conferences } = await voip.listConferences();
|
|
2832
|
+
* const chosenConf = showConferencePicker(conferences);
|
|
2833
|
+
* if (chosenConf) {
|
|
2834
|
+
* await voip.transferToConference(callUuid, chosenConf);
|
|
2835
|
+
* showNotification(`Call transferred to conference "${chosenConf}"`);
|
|
2836
|
+
* }
|
|
2837
|
+
* }
|
|
2838
|
+
* ```
|
|
2839
|
+
*
|
|
2840
|
+
* @see POST /api/calls/{uuid}/conference
|
|
2841
|
+
*/
|
|
2842
|
+
async transferToConference(uuid, conferenceName) {
|
|
2843
|
+
return this.request('POST', `/api/calls/${uuid}/conference`, conferenceName ? { conferenceName } : undefined);
|
|
2844
|
+
}
|
|
2845
|
+
/**
|
|
2846
|
+
* Remove (kick) a participant from a conference room.
|
|
2847
|
+
*
|
|
2848
|
+
* @description
|
|
2849
|
+
* Forcefully removes a single participant from an active conference. The kicked
|
|
2850
|
+
* participant is disconnected immediately. This is used for moderator control in
|
|
2851
|
+
* three-way calls and multi-party conferences — for example, removing a disruptive
|
|
2852
|
+
* participant or ending a consult call while keeping the main call active.
|
|
2853
|
+
*
|
|
2854
|
+
* **CRM UI guidance:**
|
|
2855
|
+
* - Show a "Remove" or "Kick" button/icon next to each participant
|
|
2856
|
+
* - Show a confirmation dialog: "Remove [name] from the call?"
|
|
2857
|
+
* - After kicking, remove their card from the participant list immediately
|
|
2858
|
+
* - If only 2 participants remain, automatically leave the conference (three-way → two-way)
|
|
2859
|
+
*
|
|
2860
|
+
* @param conferenceName — The conference room name, returned by `mergeCalls()`,
|
|
2861
|
+
* `mergeDirectCalls()`, or `listConferences()`.
|
|
2862
|
+
* Example: `'conf-abc123'`
|
|
2863
|
+
* @param memberId — The conference member ID to kick (NOT the FS channel UUID).
|
|
2864
|
+
* Get this from `mergeCalls()` → `participants[].memberId`.
|
|
2865
|
+
* Example: `'1'` or `'2'`
|
|
2866
|
+
*
|
|
2867
|
+
* @returns An object containing:
|
|
2868
|
+
* - `message: string` — Confirmation message (e.g. "Member kicked from conference")
|
|
2869
|
+
*
|
|
2870
|
+
* @throws {HttpError} 400 — Conference or member not found, or member ID is invalid
|
|
2871
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
2872
|
+
* @throws {HttpError} 500 — FreeSWITCH operation failed
|
|
2873
|
+
*
|
|
2874
|
+
* @example
|
|
2875
|
+
* ```typescript
|
|
2876
|
+
* // After mergeCalls, kick the third party
|
|
2877
|
+
* const { conferenceName, participants } = await voip.mergeCalls({
|
|
2878
|
+
* callUuidA: selfUuid,
|
|
2879
|
+
* callUuidB: newCallUuid,
|
|
2880
|
+
* });
|
|
2881
|
+
*
|
|
2882
|
+
* const thirdParty = participants.find(p => p.callerIdNumber === '+17865559999');
|
|
2883
|
+
* if (thirdParty) {
|
|
2884
|
+
* await voip.kickFromConference(conferenceName, thirdParty.memberId);
|
|
2885
|
+
* }
|
|
2886
|
+
*
|
|
2887
|
+
* // CRM UI: Kick button per participant
|
|
2888
|
+
* function renderParticipantControls(participant: ThreeWayParticipant) {
|
|
2889
|
+
* const kickBtn = document.createElement('button');
|
|
2890
|
+
* kickBtn.textContent = 'Remove';
|
|
2891
|
+
* kickBtn.onclick = async () => {
|
|
2892
|
+
* if (confirm(`Remove ${participant.callerIdName || participant.callerIdNumber} from the call?`)) {
|
|
2893
|
+
* await voip.kickFromConference(conferenceName, participant.memberId);
|
|
2894
|
+
* removeParticipantCard(participant.memberId);
|
|
2895
|
+
* }
|
|
2896
|
+
* };
|
|
2897
|
+
* return kickBtn;
|
|
2898
|
+
* }
|
|
2899
|
+
*
|
|
2900
|
+
* // CRM UI: Show/hide kick button
|
|
2901
|
+
* // Only show kick for non-self participants
|
|
2902
|
+
* participants
|
|
2903
|
+
* .filter(p => p.uuid !== selfUuid)
|
|
2904
|
+
* .forEach(p => showKickButton(p));
|
|
2905
|
+
* ```
|
|
2906
|
+
*
|
|
2907
|
+
* @see POST /api/conferences/{conferenceName}/kick
|
|
2908
|
+
* @see mergeCalls (get conferenceName and memberId)
|
|
2909
|
+
*/
|
|
1043
2910
|
async kickFromConference(conferenceName, memberId) {
|
|
1044
2911
|
return this.request('POST', `/api/conferences/${conferenceName}/kick`, { memberId });
|
|
1045
2912
|
}
|
|
1046
|
-
/**
|
|
2913
|
+
/**
|
|
2914
|
+
* Mute or unmute a participant in a conference room.
|
|
2915
|
+
*
|
|
2916
|
+
* @description
|
|
2917
|
+
* Toggles the microphone for a single conference participant. When muted, the
|
|
2918
|
+
* participant can still hear everyone, but nobody can hear them. This is the
|
|
2919
|
+
* moderator control for managing who can speak in a three-way or multi-party call.
|
|
2920
|
+
*
|
|
2921
|
+
* Common use cases:
|
|
2922
|
+
* - Mute a noisy participant (background noise, typing, echoes)
|
|
2923
|
+
* - Mute yourself temporarily while looking up information
|
|
2924
|
+
* - Mute a third party during a private consultation with the other participant
|
|
2925
|
+
*
|
|
2926
|
+
* **CRM UI guidance:**
|
|
2927
|
+
* - Show a microphone icon next to each participant
|
|
2928
|
+
* - Filled mic = unmuted, crossed-out mic = muted
|
|
2929
|
+
* - Click to toggle: `muteConferenceMember(name, memberId, !currentMutedState)`
|
|
2930
|
+
* - For your own card, label it "Mute Me" instead of just "Mute"
|
|
2931
|
+
* - After muting, show a pulsing "Muted" indicator on their card
|
|
2932
|
+
*
|
|
2933
|
+
* @param conferenceName — The conference room name, returned by `mergeCalls()`,
|
|
2934
|
+
* `mergeDirectCalls()`, or `listConferences()`.
|
|
2935
|
+
* Example: `'conf-abc123'`
|
|
2936
|
+
* @param memberId — The conference member ID to mute/unmute (NOT the FS channel UUID).
|
|
2937
|
+
* Get this from `mergeCalls()` → `participants[].memberId`.
|
|
2938
|
+
* Example: `'1'` or `'2'`
|
|
2939
|
+
* @param mute — `true` to mute (silence the participant's microphone), `false` to unmute
|
|
2940
|
+
* (allow them to speak again). Default is `true`.
|
|
2941
|
+
* Example: `true`
|
|
2942
|
+
*
|
|
2943
|
+
* @returns An object containing:
|
|
2944
|
+
* - `message: string` — Confirmation message (e.g. "Member muted" or "Member unmuted")
|
|
2945
|
+
*
|
|
2946
|
+
* @throws {HttpError} 400 — Conference or member not found, or member ID is invalid
|
|
2947
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
2948
|
+
* @throws {HttpError} 500 — FreeSWITCH operation failed
|
|
2949
|
+
*
|
|
2950
|
+
* @example
|
|
2951
|
+
* ```typescript
|
|
2952
|
+
* // Mute a noisy participant
|
|
2953
|
+
* const { conferenceName, participants } = await voip.mergeCalls({
|
|
2954
|
+
* callUuidA: selfUuid,
|
|
2955
|
+
* callUuidB: newCallUuid,
|
|
2956
|
+
* });
|
|
2957
|
+
*
|
|
2958
|
+
* const noisyParticipant = participants.find(p => p.callerIdNumber === '+17865559999');
|
|
2959
|
+
* if (noisyParticipant) {
|
|
2960
|
+
* await voip.muteConferenceMember(conferenceName, noisyParticipant.memberId, true);
|
|
2961
|
+
* }
|
|
2962
|
+
*
|
|
2963
|
+
* // Unmute them when the noise is gone
|
|
2964
|
+
* await voip.muteConferenceMember(conferenceName, noisyParticipant.memberId, false);
|
|
2965
|
+
*
|
|
2966
|
+
* // CRM UI: Mute/Unmute toggle button per participant
|
|
2967
|
+
* function renderMuteButton(participant: ThreeWayParticipant, conferenceName: string) {
|
|
2968
|
+
* let isMuted = participant.muted;
|
|
2969
|
+
* const btn = document.createElement('button');
|
|
2970
|
+
*
|
|
2971
|
+
* function updateButton() {
|
|
2972
|
+
* btn.textContent = isMuted ? '🔇 Unmute' : '🎤 Mute';
|
|
2973
|
+
* btn.className = isMuted ? 'btn-warning' : 'btn-default';
|
|
2974
|
+
* }
|
|
2975
|
+
*
|
|
2976
|
+
* btn.onclick = async () => {
|
|
2977
|
+
* isMuted = !isMuted;
|
|
2978
|
+
* updateButton();
|
|
2979
|
+
* await voip.muteConferenceMember(conferenceName, participant.memberId, isMuted);
|
|
2980
|
+
* };
|
|
2981
|
+
*
|
|
2982
|
+
* updateButton();
|
|
2983
|
+
* return btn;
|
|
2984
|
+
* }
|
|
2985
|
+
*
|
|
2986
|
+
* // Mute yourself (the local participant)
|
|
2987
|
+
* async function muteMyself(conferenceName: string, myMemberId: string) {
|
|
2988
|
+
* await voip.muteConferenceMember(conferenceName, myMemberId, true);
|
|
2989
|
+
* showSelfMutedIndicator();
|
|
2990
|
+
* }
|
|
2991
|
+
* ```
|
|
2992
|
+
*
|
|
2993
|
+
* @see POST /api/conferences/{conferenceName}/mute
|
|
2994
|
+
* @see mergeCalls (get conferenceName and memberId)
|
|
2995
|
+
*/
|
|
1047
2996
|
async muteConferenceMember(conferenceName, memberId, mute = true) {
|
|
1048
2997
|
return this.request('POST', `/api/conferences/${conferenceName}/mute`, { memberId, mute });
|
|
1049
2998
|
}
|
|
@@ -1062,7 +3011,44 @@ export class VoIPClient {
|
|
|
1062
3011
|
// -------------------------------------------------------------------
|
|
1063
3012
|
// Ring Groups
|
|
1064
3013
|
// -------------------------------------------------------------------
|
|
1065
|
-
/**
|
|
3014
|
+
/**
|
|
3015
|
+
* List all ring groups for the current tenant.
|
|
3016
|
+
*
|
|
3017
|
+
* @description
|
|
3018
|
+
* Returns every ring group configured for the tenant. A ring group rings multiple
|
|
3019
|
+
* extensions simultaneously or sequentially when a DID is dialed. Common strategies:
|
|
3020
|
+
*
|
|
3021
|
+
* - **`simultaneous`** — All extensions ring at once. First to answer gets the call.
|
|
3022
|
+
* - **`sequential`** — Extensions ring one at a time in priority order.
|
|
3023
|
+
* - **`random`** — A random extension is selected to receive the call.
|
|
3024
|
+
*
|
|
3025
|
+
* Each ring group includes:
|
|
3026
|
+
* - Name, strategy, ring timeout, no-answer action
|
|
3027
|
+
* - Member list with extensions, priorities, and delay seconds
|
|
3028
|
+
*
|
|
3029
|
+
* **CRM UI guidance:** Show as a table with columns: Name, Strategy,
|
|
3030
|
+
* Members (count), No Answer Action, Status, and Actions (Edit, Delete).
|
|
3031
|
+
* Each row should expand to show member details.
|
|
3032
|
+
*
|
|
3033
|
+
* @returns An object containing:
|
|
3034
|
+
* - `ringGroups: RingGroup[]` — Array of ring group objects with members
|
|
3035
|
+
*
|
|
3036
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
3037
|
+
*
|
|
3038
|
+
* @example
|
|
3039
|
+
* ```typescript
|
|
3040
|
+
* const { ringGroups } = await voip.listRingGroups();
|
|
3041
|
+
*
|
|
3042
|
+
* ringGroups.forEach(rg => {
|
|
3043
|
+
* console.log(`${rg.name} — ${rg.strategy} — ${rg.members.length} members`);
|
|
3044
|
+
* rg.members.forEach(m => {
|
|
3045
|
+
* console.log(` ${m.extension} (priority: ${m.priority}, delay: ${m.delay_seconds}s)`);
|
|
3046
|
+
* });
|
|
3047
|
+
* });
|
|
3048
|
+
* ```
|
|
3049
|
+
*
|
|
3050
|
+
* @see GET /api/ring-groups
|
|
3051
|
+
*/
|
|
1066
3052
|
async listRingGroups() {
|
|
1067
3053
|
return this.request('GET', '/api/ring-groups');
|
|
1068
3054
|
}
|
|
@@ -1070,7 +3056,84 @@ export class VoIPClient {
|
|
|
1070
3056
|
async getRingGroup(id) {
|
|
1071
3057
|
return this.request('GET', `/api/ring-groups/${id}`);
|
|
1072
3058
|
}
|
|
1073
|
-
/**
|
|
3059
|
+
/**
|
|
3060
|
+
* Create a new ring group.
|
|
3061
|
+
*
|
|
3062
|
+
* @description
|
|
3063
|
+
* Configures a group of extensions that ring together when a DID or internal number
|
|
3064
|
+
* is dialed. This is the primary way to implement "ring all phones in the sales
|
|
3065
|
+
* department" or "try John, then Jane, then voicemail" routing patterns.
|
|
3066
|
+
*
|
|
3067
|
+
* **Strategies:**
|
|
3068
|
+
* - `'simultaneous'` (default) — All extensions ring at the same time
|
|
3069
|
+
* - `'sequential'` — One at a time, in priority order
|
|
3070
|
+
* - `'random'` — Randomly pick one extension to ring
|
|
3071
|
+
*
|
|
3072
|
+
* **No-answer handling:** When no member answers within `ringTimeout` seconds,
|
|
3073
|
+
* the call is routed according to `noAnswerAction` and `noAnswerTarget`.
|
|
3074
|
+
*
|
|
3075
|
+
* **Superadmin mode:** Pass `{ tenantId: 'uuid' }` when using a global superadmin
|
|
3076
|
+
* key to create a ring group for a specific tenant.
|
|
3077
|
+
*
|
|
3078
|
+
* **CRM UI guidance:** Show a form with: Name (required), Strategy (dropdown),
|
|
3079
|
+
* Ring Timeout (seconds slider, 5-60), No Answer Action (dropdown),
|
|
3080
|
+
* No Answer Target (dynamic based on action), and Member picker (multi-select
|
|
3081
|
+
* from extension list with priority and delay settings per member).
|
|
3082
|
+
*
|
|
3083
|
+
* @param params — Ring group creation parameters
|
|
3084
|
+
* @param params.name — Ring group name. Example: `'Sales Team'`
|
|
3085
|
+
* @param params.strategy — Ring strategy: `'simultaneous'` (default), `'sequential'`, or `'random'`
|
|
3086
|
+
* @param params.ringTimeout — Max seconds to ring before no-answer action (default: 30).
|
|
3087
|
+
* Example: `20`
|
|
3088
|
+
* @param params.noAnswerAction — What to do when nobody answers:
|
|
3089
|
+
* `'voicemail'`, `'ivr'`, `'extension'`, `'hangup'`, `'ai_agent'`
|
|
3090
|
+
* @param params.noAnswerTarget — Target for no-answer action
|
|
3091
|
+
* (extension number, IVR name, etc.)
|
|
3092
|
+
* @param params.members — Array of member extensions.
|
|
3093
|
+
* Each has: `extension` (string), `priority` (number, lower = rings first for sequential),
|
|
3094
|
+
* `delaySeconds` (seconds to delay before ringing this member)
|
|
3095
|
+
* @param options — Optional request overrides
|
|
3096
|
+
* @param options.tenantId — Target tenant UUID (required for superadmin keys only)
|
|
3097
|
+
*
|
|
3098
|
+
* @returns An object containing:
|
|
3099
|
+
* - `ringGroup: RingGroup` — The created ring group with members
|
|
3100
|
+
*
|
|
3101
|
+
* @throws {HttpError} 400 — Duplicate name, invalid extension in members, or missing required fields
|
|
3102
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
3103
|
+
*
|
|
3104
|
+
* @example
|
|
3105
|
+
* ```typescript
|
|
3106
|
+
* // Create a sales team ring group — ring all at once
|
|
3107
|
+
* const { ringGroup } = await voip.createRingGroup({
|
|
3108
|
+
* name: 'Sales Team',
|
|
3109
|
+
* strategy: 'simultaneous',
|
|
3110
|
+
* ringTimeout: 20,
|
|
3111
|
+
* noAnswerAction: 'voicemail',
|
|
3112
|
+
* noAnswerTarget: '1001',
|
|
3113
|
+
* members: [
|
|
3114
|
+
* { extension: '1001', priority: 1, delaySeconds: 0 },
|
|
3115
|
+
* { extension: '1002', priority: 1, delaySeconds: 0 },
|
|
3116
|
+
* { extension: '1003', priority: 1, delaySeconds: 0 },
|
|
3117
|
+
* ],
|
|
3118
|
+
* });
|
|
3119
|
+
*
|
|
3120
|
+
* // Sequential ring group — try primary first, then backup
|
|
3121
|
+
* const { ringGroup } = await voip.createRingGroup({
|
|
3122
|
+
* name: 'Support Escalation',
|
|
3123
|
+
* strategy: 'sequential',
|
|
3124
|
+
* ringTimeout: 15,
|
|
3125
|
+
* noAnswerAction: 'extension',
|
|
3126
|
+
* noAnswerTarget: '2000',
|
|
3127
|
+
* members: [
|
|
3128
|
+
* { extension: '1001', priority: 1, delaySeconds: 0 },
|
|
3129
|
+
* { extension: '1002', priority: 2, delaySeconds: 5 },
|
|
3130
|
+
* { extension: '1003', priority: 3, delaySeconds: 10 },
|
|
3131
|
+
* ],
|
|
3132
|
+
* });
|
|
3133
|
+
* ```
|
|
3134
|
+
*
|
|
3135
|
+
* @see POST /api/ring-groups
|
|
3136
|
+
*/
|
|
1074
3137
|
async createRingGroup(params, options) {
|
|
1075
3138
|
return this.request('POST', '/api/ring-groups', params, undefined, options);
|
|
1076
3139
|
}
|
|
@@ -1085,7 +3148,46 @@ export class VoIPClient {
|
|
|
1085
3148
|
// -------------------------------------------------------------------
|
|
1086
3149
|
// Paging Groups
|
|
1087
3150
|
// -------------------------------------------------------------------
|
|
1088
|
-
/**
|
|
3151
|
+
/**
|
|
3152
|
+
* List all paging groups for the current tenant.
|
|
3153
|
+
*
|
|
3154
|
+
* @description
|
|
3155
|
+
* Returns every paging/intercom group configured for the tenant. A paging group
|
|
3156
|
+
* enables one-way audio broadcast (PA system) to multiple extensions simultaneously
|
|
3157
|
+
* with auto-answer. Use this for announcements, emergency alerts, or overhead paging.
|
|
3158
|
+
*
|
|
3159
|
+
* Each paging group includes:
|
|
3160
|
+
* - `name` — Group name (e.g. "All Employees")
|
|
3161
|
+
* - `extension_code` — The dial code that triggers paging (e.g. `*801`)
|
|
3162
|
+
* - `members` — List of member extensions that receive the broadcast
|
|
3163
|
+
*
|
|
3164
|
+
* **CRM UI guidance:** Show as a table with columns: Name, Code, Members (count),
|
|
3165
|
+
* Status, and Actions (Broadcast, Edit, Delete). The Broadcast button opens a
|
|
3166
|
+
* modal to select a caller extension and sends the page. Show "No paging groups
|
|
3167
|
+
* configured" empty state with a Create button if the list is empty.
|
|
3168
|
+
*
|
|
3169
|
+
* @returns An object containing:
|
|
3170
|
+
* - `pagingGroups: PagingGroup[]` — Array of paging group objects with members
|
|
3171
|
+
*
|
|
3172
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
3173
|
+
*
|
|
3174
|
+
* @example
|
|
3175
|
+
* ```typescript
|
|
3176
|
+
* const { pagingGroups } = await voip.listPagingGroups();
|
|
3177
|
+
*
|
|
3178
|
+
* pagingGroups.forEach(pg => {
|
|
3179
|
+
* console.log(`${pg.name} (${pg.extension_code}) — ${pg.members.length} members`);
|
|
3180
|
+
* });
|
|
3181
|
+
*
|
|
3182
|
+
* // CRM UI: show broadcast button
|
|
3183
|
+
* pagingGroups.forEach(pg => {
|
|
3184
|
+
* renderPagingRow(pg.name, pg.extension_code, pg.members.length);
|
|
3185
|
+
* renderBroadcastButton(pg.id, pg.name);
|
|
3186
|
+
* });
|
|
3187
|
+
* ```
|
|
3188
|
+
*
|
|
3189
|
+
* @see GET /api/paging-groups
|
|
3190
|
+
*/
|
|
1089
3191
|
async listPagingGroups() {
|
|
1090
3192
|
return this.request('GET', '/api/paging-groups');
|
|
1091
3193
|
}
|
|
@@ -1093,7 +3195,76 @@ export class VoIPClient {
|
|
|
1093
3195
|
async getPagingGroup(id) {
|
|
1094
3196
|
return this.request('GET', `/api/paging-groups/${id}`);
|
|
1095
3197
|
}
|
|
1096
|
-
/**
|
|
3198
|
+
/**
|
|
3199
|
+
* Create a new paging group for one-way audio broadcast.
|
|
3200
|
+
*
|
|
3201
|
+
* @description
|
|
3202
|
+
* Sets up a group of extensions that receive auto-answer audio broadcasts when
|
|
3203
|
+
* someone dials the `extensionCode` (e.g. `*801`). All online members hear the
|
|
3204
|
+
* caller through their speaker — useful for announcements, emergency alerts,
|
|
3205
|
+
* and overhead paging.
|
|
3206
|
+
*
|
|
3207
|
+
* **How it works:**
|
|
3208
|
+
* 1. Admin creates a paging group with a dial code and member extensions
|
|
3209
|
+
* 2. A user dials the code (e.g. `*801`) from their phone
|
|
3210
|
+
* 3. All online members auto-answer — the caller's audio is broadcast
|
|
3211
|
+
* 4. When the caller hangs up, all member calls end
|
|
3212
|
+
*
|
|
3213
|
+
* **Superadmin mode:** Pass `{ tenantId: 'uuid' }` when using a global superadmin
|
|
3214
|
+
* key to create a paging group for a specific tenant.
|
|
3215
|
+
*
|
|
3216
|
+
* **CRM UI guidance:** Show a form with: Name (required, e.g. "All Employees"),
|
|
3217
|
+
* Extension Code (required, e.g. `*801` — must be unique per tenant),
|
|
3218
|
+
* Description (optional), and Members (multi-select from extension list).
|
|
3219
|
+
* Show a preview of the dial code with "Users will dial *801 to page" hint.
|
|
3220
|
+
*
|
|
3221
|
+
* @param params — Paging group creation parameters
|
|
3222
|
+
* @param params.name — Group name. Must be unique within the tenant.
|
|
3223
|
+
* Example: `'All Employees'`
|
|
3224
|
+
* @param params.description — Optional description. Example: `'Broadcast to all office phones'`
|
|
3225
|
+
* @param params.extensionCode — Dial code that triggers the page. Must start with `*`.
|
|
3226
|
+
* Example: `'*801'`. Must be unique within the tenant
|
|
3227
|
+
* @param params.members — Array of member extensions to page.
|
|
3228
|
+
* Each has: `extension` (string, the extension number).
|
|
3229
|
+
* Example: `[{ extension: '1001' }, { extension: '1002' }]`
|
|
3230
|
+
* @param options — Optional request overrides
|
|
3231
|
+
* @param options.tenantId — Target tenant UUID (required for superadmin keys only)
|
|
3232
|
+
*
|
|
3233
|
+
* @returns An object containing:
|
|
3234
|
+
* - `pagingGroup: PagingGroup` — The created paging group with members
|
|
3235
|
+
*
|
|
3236
|
+
* @throws {HttpError} 400 — Duplicate name or extension code, invalid code format, or extension doesn't exist
|
|
3237
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
3238
|
+
*
|
|
3239
|
+
* @example
|
|
3240
|
+
* ```typescript
|
|
3241
|
+
* // Create an All Employees paging group
|
|
3242
|
+
* const { pagingGroup } = await voip.createPagingGroup({
|
|
3243
|
+
* name: 'All Employees',
|
|
3244
|
+
* description: 'Emergency broadcast to all employees',
|
|
3245
|
+
* extensionCode: '*801',
|
|
3246
|
+
* members: [
|
|
3247
|
+
* { extension: '1001' },
|
|
3248
|
+
* { extension: '1002' },
|
|
3249
|
+
* { extension: '1003' },
|
|
3250
|
+
* ],
|
|
3251
|
+
* });
|
|
3252
|
+
*
|
|
3253
|
+
* console.log(`Paging group created: dial ${pagingGroup.extension_code} to page ${pagingGroup.members.length} members`);
|
|
3254
|
+
*
|
|
3255
|
+
* // Create a department paging group
|
|
3256
|
+
* const { pagingGroup } = await voip.createPagingGroup({
|
|
3257
|
+
* name: 'Support Floor',
|
|
3258
|
+
* extensionCode: '*802',
|
|
3259
|
+
* members: [
|
|
3260
|
+
* { extension: '2001' },
|
|
3261
|
+
* { extension: '2002' },
|
|
3262
|
+
* ],
|
|
3263
|
+
* });
|
|
3264
|
+
* ```
|
|
3265
|
+
*
|
|
3266
|
+
* @see POST /api/paging-groups
|
|
3267
|
+
*/
|
|
1097
3268
|
async createPagingGroup(params, options) {
|
|
1098
3269
|
return this.request('POST', '/api/paging-groups', params, undefined, options);
|
|
1099
3270
|
}
|
|
@@ -1105,14 +3276,131 @@ export class VoIPClient {
|
|
|
1105
3276
|
async deletePagingGroup(id) {
|
|
1106
3277
|
return this.request('DELETE', `/api/paging-groups/${id}`);
|
|
1107
3278
|
}
|
|
1108
|
-
/**
|
|
3279
|
+
/**
|
|
3280
|
+
* Broadcast a paging announcement to all online members of a paging group via the API.
|
|
3281
|
+
*
|
|
3282
|
+
* @description
|
|
3283
|
+
* Triggers a one-way audio broadcast to all currently online members of the specified
|
|
3284
|
+
* paging group. The caller extension initiates the broadcast — all online members
|
|
3285
|
+
* auto-answer and hear the caller through their speaker.
|
|
3286
|
+
*
|
|
3287
|
+
* This is the API-based alternative to dialing the paging code (`*801`) from a phone.
|
|
3288
|
+
* Use this for programmatic paging — e.g. a "Broadcast" button in the admin dashboard,
|
|
3289
|
+
* automated emergency alerts, or scheduled announcements.
|
|
3290
|
+
*
|
|
3291
|
+
* **How it works:**
|
|
3292
|
+
* 1. The API creates a conference room with the caller as moderator
|
|
3293
|
+
* 2. All online members are auto-called into the conference (listen-only, muted)
|
|
3294
|
+
* 3. The caller speaks — all members hear through their speaker
|
|
3295
|
+
* 4. When the caller hangs up, the conference auto-destroys
|
|
3296
|
+
*
|
|
3297
|
+
* **CRM UI guidance:** Show a "Broadcast" button next to each paging group.
|
|
3298
|
+
* On click, open a modal: select the caller extension (dropdown of online extensions),
|
|
3299
|
+
* and a "Start Broadcast" button. During broadcast, show a "Broadcasting..." indicator
|
|
3300
|
+
* with the number of members reached vs offline. After completion, show results
|
|
3301
|
+
* (membersOnline, membersContacted, status per extension).
|
|
3302
|
+
*
|
|
3303
|
+
* @param id — The UUID of the paging group (from `listPagingGroups()` → `pagingGroups[].id`)
|
|
3304
|
+
* @param callerExtension — The extension number initiating the broadcast.
|
|
3305
|
+
* This extension will be the speaker. Example: `'1001'`
|
|
3306
|
+
*
|
|
3307
|
+
* @returns An object containing:
|
|
3308
|
+
* - `jobId: string` — Unique broadcast job ID for tracking
|
|
3309
|
+
* - `groupName: string` — The paging group name
|
|
3310
|
+
* - `conferenceName: string` — The conference room name created for this broadcast
|
|
3311
|
+
* - `membersContacted: number` — Number of members successfully reached
|
|
3312
|
+
* - `membersOnline: number` — Number of members that were online at broadcast time
|
|
3313
|
+
* - `membersTotal: number` — Total number of members in the group
|
|
3314
|
+
* - `results: Array<{ extension: string; status: 'online' | 'offline' }>` — Per-member status
|
|
3315
|
+
*
|
|
3316
|
+
* @throws {HttpError} 400 — Paging group not found, caller extension doesn't exist, or no members online
|
|
3317
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
3318
|
+
* @throws {HttpError} 500 — FreeSWITCH conference creation failed
|
|
3319
|
+
*
|
|
3320
|
+
* @example
|
|
3321
|
+
* ```typescript
|
|
3322
|
+
* // Broadcast an emergency alert to all employees
|
|
3323
|
+
* const result = await voip.broadcastPaging('paging-group-uuid', '1001');
|
|
3324
|
+
*
|
|
3325
|
+
* console.log(`Broadcasting to ${result.groupName}`);
|
|
3326
|
+
* console.log(`Reached ${result.membersContacted}/${result.membersTotal} members`);
|
|
3327
|
+
* console.log(`Online: ${result.membersOnline}, Offline: ${result.membersTotal - result.membersOnline}`);
|
|
3328
|
+
*
|
|
3329
|
+
* result.results.forEach(r => {
|
|
3330
|
+
* console.log(` ${r.extension}: ${r.status}`);
|
|
3331
|
+
* });
|
|
3332
|
+
*
|
|
3333
|
+
* // CRM UI: broadcast button with results modal
|
|
3334
|
+
* async function handleBroadcast(pagingGroupId: string, callerExt: string) {
|
|
3335
|
+
* showBroadcastingModal(callerExt);
|
|
3336
|
+
* try {
|
|
3337
|
+
* const result = await voip.broadcastPaging(pagingGroupId, callerExt);
|
|
3338
|
+
* showBroadcastResultsModal({
|
|
3339
|
+
* reached: result.membersContacted,
|
|
3340
|
+
* online: result.membersOnline,
|
|
3341
|
+
* total: result.membersTotal,
|
|
3342
|
+
* results: result.results,
|
|
3343
|
+
* });
|
|
3344
|
+
* } catch (err) {
|
|
3345
|
+
* if (err instanceof HttpError && err.status === 400) {
|
|
3346
|
+
* showError('No members are online to receive the broadcast');
|
|
3347
|
+
* }
|
|
3348
|
+
* }
|
|
3349
|
+
* }
|
|
3350
|
+
* ```
|
|
3351
|
+
*
|
|
3352
|
+
* @see POST /api/paging-groups/{id}/broadcast
|
|
3353
|
+
*/
|
|
1109
3354
|
async broadcastPaging(id, callerExtension) {
|
|
1110
3355
|
return this.request('POST', `/api/paging-groups/${id}/broadcast`, { callerExtension });
|
|
1111
3356
|
}
|
|
1112
3357
|
// -------------------------------------------------------------------
|
|
1113
3358
|
// Call Queues
|
|
1114
3359
|
// -------------------------------------------------------------------
|
|
1115
|
-
/**
|
|
3360
|
+
/**
|
|
3361
|
+
* List all call queues for the current tenant.
|
|
3362
|
+
*
|
|
3363
|
+
* @description
|
|
3364
|
+
* Retrieves every call queue configured for the tenant. Each queue includes:
|
|
3365
|
+
* - Queue name, strategy (round-robin, ring-all, longest-idle, etc.)
|
|
3366
|
+
* - Music on hold, announcements, timeout settings
|
|
3367
|
+
* - Agent list with priorities and penalties
|
|
3368
|
+
* - Fallback actions when no agents are available or the caller times out
|
|
3369
|
+
*
|
|
3370
|
+
* Call queues are used for call center / ACD (Automatic Call Distribution) scenarios
|
|
3371
|
+
* where multiple agents handle incoming calls.
|
|
3372
|
+
*
|
|
3373
|
+
* **CRM UI guidance:** Show as a table with columns: Queue Name, Strategy,
|
|
3374
|
+
* Agents (count), Active Status, and Actions (Edit, Delete, Stats). Show a
|
|
3375
|
+
* "Create Queue" button. Each row should link to a detail view showing agents,
|
|
3376
|
+
* real-time stats, and queue configuration.
|
|
3377
|
+
*
|
|
3378
|
+
* @returns An object containing:
|
|
3379
|
+
* - `queues: CallQueue[]` — Array of call queue objects with agents
|
|
3380
|
+
*
|
|
3381
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
3382
|
+
*
|
|
3383
|
+
* @example
|
|
3384
|
+
* ```typescript
|
|
3385
|
+
* const { queues } = await voip.listQueues();
|
|
3386
|
+
*
|
|
3387
|
+
* queues.forEach(q => {
|
|
3388
|
+
* console.log(`${q.name} — ${q.strategy} — ${q.agents.length} agents`);
|
|
3389
|
+
* });
|
|
3390
|
+
*
|
|
3391
|
+
* // CRM UI: queue overview with agent counts
|
|
3392
|
+
* queues.forEach(q => {
|
|
3393
|
+
* const agentStatus = {
|
|
3394
|
+
* available: q.agents.filter(a => a.status === 'available').length,
|
|
3395
|
+
* busy: q.agents.filter(a => a.status === 'busy').length,
|
|
3396
|
+
* paused: q.agents.filter(a => a.status === 'paused').length,
|
|
3397
|
+
* };
|
|
3398
|
+
* renderQueueRow(q.name, q.strategy, agentStatus);
|
|
3399
|
+
* });
|
|
3400
|
+
* ```
|
|
3401
|
+
*
|
|
3402
|
+
* @see GET /api/queues
|
|
3403
|
+
*/
|
|
1116
3404
|
async listQueues() {
|
|
1117
3405
|
return this.request('GET', '/api/queues');
|
|
1118
3406
|
}
|
|
@@ -1147,7 +3435,50 @@ export class VoIPClient {
|
|
|
1147
3435
|
// -------------------------------------------------------------------
|
|
1148
3436
|
// Webhooks
|
|
1149
3437
|
// -------------------------------------------------------------------
|
|
1150
|
-
/**
|
|
3438
|
+
/**
|
|
3439
|
+
* List all webhook subscriptions for the current tenant, along with supported event types.
|
|
3440
|
+
*
|
|
3441
|
+
* @description
|
|
3442
|
+
* Returns every webhook endpoint the tenant has configured, plus the list of all
|
|
3443
|
+
* supported event types that can be subscribed to. Each webhook includes:
|
|
3444
|
+
* - Name, URL, subscribed events, active status
|
|
3445
|
+
* - Whether a signing secret is configured (`hasSecret`)
|
|
3446
|
+
* - Custom headers, max retries, retry delay
|
|
3447
|
+
*
|
|
3448
|
+
* Webhooks are HTTP callbacks that fire when specific events occur in the phone system
|
|
3449
|
+
* (call started, call ended, voicemail received, etc.). Use them to integrate the
|
|
3450
|
+
* VoIP platform with external CRMs, ticketing systems, or analytics tools.
|
|
3451
|
+
*
|
|
3452
|
+
* **CRM UI guidance:** Show as a table with columns: Name, URL, Events (badge list),
|
|
3453
|
+
* Status (active/inactive), and Actions (Edit, Delete, Test). Show the supported
|
|
3454
|
+
* events list in a sidebar or tooltip for reference when creating/editing webhooks.
|
|
3455
|
+
*
|
|
3456
|
+
* @returns An object containing:
|
|
3457
|
+
* - `webhooks: Webhook[]` — Array of webhook subscription objects
|
|
3458
|
+
* - `supportedEvents: string[]` — All event types available for subscription.
|
|
3459
|
+
* Examples: `'call.start'`, `'call.end'`, `'call.answer'`, `'voicemail.received'`,
|
|
3460
|
+
* `'recording.ready'`, `'presence.change'`, `'queue.agent.login'`
|
|
3461
|
+
*
|
|
3462
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
3463
|
+
*
|
|
3464
|
+
* @example
|
|
3465
|
+
* ```typescript
|
|
3466
|
+
* const { webhooks, supportedEvents } = await voip.listWebhooks();
|
|
3467
|
+
*
|
|
3468
|
+
* console.log(`Available events: ${supportedEvents.join(', ')}`);
|
|
3469
|
+
*
|
|
3470
|
+
* webhooks.forEach(wh => {
|
|
3471
|
+
* console.log(`${wh.name} → ${wh.url} [${wh.events.join(', ')}] — ${wh.active ? 'Active' : 'Inactive'}`);
|
|
3472
|
+
* });
|
|
3473
|
+
*
|
|
3474
|
+
* // CRM UI: populate event checkboxes when creating a webhook
|
|
3475
|
+
* supportedEvents.forEach(event => {
|
|
3476
|
+
* renderEventCheckbox(event, `${event}.description`);
|
|
3477
|
+
* });
|
|
3478
|
+
* ```
|
|
3479
|
+
*
|
|
3480
|
+
* @see GET /api/webhooks
|
|
3481
|
+
*/
|
|
1151
3482
|
async listWebhooks() {
|
|
1152
3483
|
return this.request('GET', '/api/webhooks');
|
|
1153
3484
|
}
|
|
@@ -1155,7 +3486,76 @@ export class VoIPClient {
|
|
|
1155
3486
|
async getWebhook(id) {
|
|
1156
3487
|
return this.request('GET', `/api/webhooks/${id}`);
|
|
1157
3488
|
}
|
|
1158
|
-
/**
|
|
3489
|
+
/**
|
|
3490
|
+
* Create a new webhook subscription.
|
|
3491
|
+
*
|
|
3492
|
+
* @description
|
|
3493
|
+
* Registers a URL to receive HTTP POST callbacks when specific events occur in the
|
|
3494
|
+
* phone system. The response includes the signing secret (`secret`) — store this
|
|
3495
|
+
* immediately because it is only returned once (during creation).
|
|
3496
|
+
*
|
|
3497
|
+
* **Signing secret:** The secret is used to verify that incoming webhook requests
|
|
3498
|
+
* actually came from the VoIP platform. The platform sends an `X-Webhook-Signature`
|
|
3499
|
+
* header with each delivery — your endpoint should compute the HMAC-SHA256 of the
|
|
3500
|
+
* request body using this secret and compare it to the header value.
|
|
3501
|
+
*
|
|
3502
|
+
* **Superadmin mode:** Pass `{ tenantId: 'uuid' }` when using a global superadmin
|
|
3503
|
+
* key to create a webhook for a specific tenant.
|
|
3504
|
+
*
|
|
3505
|
+
* **CRM UI guidance:** Show a form with fields: Name (required), URL (required),
|
|
3506
|
+
* Events (checkboxes — use `supportedEvents` from `listWebhooks()`), Secret
|
|
3507
|
+
* (optional, auto-generate button), Custom Headers (optional, key-value editor),
|
|
3508
|
+
* Max Retries, Retry Delay. After creation, show the secret prominently:
|
|
3509
|
+
* "Copy this secret now — it will not be shown again" with a Copy button.
|
|
3510
|
+
*
|
|
3511
|
+
* @param params — Webhook creation parameters
|
|
3512
|
+
* @param params.name — Human-readable name for this webhook. Example: `'CRM Call Logger'`
|
|
3513
|
+
* @param params.url — The HTTPS URL to send events to. Example: `'https://my-crm.example.com/api/webhooks/voip'`
|
|
3514
|
+
* @param params.events — Array of event types to subscribe to.
|
|
3515
|
+
* Example: `['call.start', 'call.end', 'voicemail.received', 'recording.ready']`
|
|
3516
|
+
* @param params.secret — Optional signing secret (auto-generated if omitted, recommended)
|
|
3517
|
+
* @param params.headers — Optional custom HTTP headers to include in each delivery.
|
|
3518
|
+
* Example: `{ 'X-Custom-Header': 'value' }`
|
|
3519
|
+
* @param params.maxRetries — Max delivery retry attempts (default: 5)
|
|
3520
|
+
* @param params.retryDelaySeconds — Delay between retries in seconds (default: 60)
|
|
3521
|
+
* @param options — Optional request overrides
|
|
3522
|
+
* @param options.tenantId — Target tenant UUID (required for superadmin keys only)
|
|
3523
|
+
*
|
|
3524
|
+
* @returns An object containing:
|
|
3525
|
+
* - `webhook: Webhook & { secret: string }` — The created webhook object with
|
|
3526
|
+
* the signing secret (only returned once!)
|
|
3527
|
+
*
|
|
3528
|
+
* @throws {HttpError} 400 — Invalid URL, duplicate name, or invalid event types
|
|
3529
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
3530
|
+
*
|
|
3531
|
+
* @example
|
|
3532
|
+
* ```typescript
|
|
3533
|
+
* // Create a webhook for call logging
|
|
3534
|
+
* const { webhook } = await voip.createWebhook({
|
|
3535
|
+
* name: 'CRM Call Logger',
|
|
3536
|
+
* url: 'https://my-crm.example.com/api/webhooks/voip',
|
|
3537
|
+
* events: ['call.start', 'call.end', 'call.answer'],
|
|
3538
|
+
* maxRetries: 3,
|
|
3539
|
+
* });
|
|
3540
|
+
*
|
|
3541
|
+
* console.log(`Webhook secret (save this!): ${webhook.secret}`);
|
|
3542
|
+
*
|
|
3543
|
+
* // CRM UI: handle webhook creation
|
|
3544
|
+
* async function handleCreateWebhook(formData: CreateWebhookParams) {
|
|
3545
|
+
* try {
|
|
3546
|
+
* const { webhook } = await voip.createWebhook(formData);
|
|
3547
|
+
* showSecretModal(webhook.secret); // "Copy and save this secret!"
|
|
3548
|
+
* showNotification(`Webhook "${formData.name}" created`);
|
|
3549
|
+
* } catch (err) {
|
|
3550
|
+
* if (err instanceof HttpError && err.status === 400) {
|
|
3551
|
+
* showError('Invalid URL or duplicate webhook name');
|
|
3552
|
+
* }
|
|
3553
|
+
* }
|
|
3554
|
+
* }
|
|
3555
|
+
* ```
|
|
3556
|
+
*
|
|
3557
|
+
* @see POST /api/webhooks
|
|
3558
|
+
*/
|
|
1159
3559
|
async createWebhook(params, options) {
|
|
1160
3560
|
return this.request('POST', '/api/webhooks', params, undefined, options);
|
|
1161
3561
|
}
|
|
@@ -1178,23 +3578,251 @@ export class VoIPClient {
|
|
|
1178
3578
|
// -------------------------------------------------------------------
|
|
1179
3579
|
// Voicemail
|
|
1180
3580
|
// -------------------------------------------------------------------
|
|
1181
|
-
/**
|
|
3581
|
+
/**
|
|
3582
|
+
* List voicemail messages with pagination and optional filters.
|
|
3583
|
+
*
|
|
3584
|
+
* @description
|
|
3585
|
+
* Retrieves a paginated list of voicemail messages for the current tenant. Each message
|
|
3586
|
+
* includes caller ID, duration, read status, urgency, and an `audio_url` for playback.
|
|
3587
|
+
* Use this to populate a voicemail inbox UI — similar to an email inbox but for voice.
|
|
3588
|
+
*
|
|
3589
|
+
* Each voicemail includes:
|
|
3590
|
+
* - `id` — UUID for read/delete operations
|
|
3591
|
+
* - `caller_id` — Phone number or extension that left the message
|
|
3592
|
+
* - `caller_name` — Name if Caller ID lookup was performed
|
|
3593
|
+
* - `duration_seconds` — Length of the message
|
|
3594
|
+
* - `is_read` — Whether the message has been heard
|
|
3595
|
+
* - `is_urgent` — Whether the caller marked it urgent (pressing * after recording)
|
|
3596
|
+
* - `audio_url` — URL to stream the audio (append `?apiKey=...` for browser playback)
|
|
3597
|
+
* - `created_at` — ISO timestamp when the message was left
|
|
3598
|
+
*
|
|
3599
|
+
* **CRM UI guidance:** Show as an inbox-style list with columns: Status (unread dot),
|
|
3600
|
+
* Caller, Duration, Date, and a Play button. Unread messages should be bold or have
|
|
3601
|
+
* a blue dot indicator. Urgent messages should have a red exclamation mark. Show
|
|
3602
|
+
* "Mark Read" button per message and a "Delete" button with confirmation.
|
|
3603
|
+
* Show pagination at the bottom.
|
|
3604
|
+
*
|
|
3605
|
+
* @param params — Optional filters and pagination
|
|
3606
|
+
* @param params.page — Page number (1-based, default 1)
|
|
3607
|
+
* @param params.limit — Records per page (default 50)
|
|
3608
|
+
* @param params.unreadOnly — Show only unread messages when `true`
|
|
3609
|
+
* @param params.extension — Filter by extension number (e.g. `'1001'`)
|
|
3610
|
+
*
|
|
3611
|
+
* @returns An object containing:
|
|
3612
|
+
* - `messages: VoicemailMessage[]` — Array of voicemail messages
|
|
3613
|
+
* - `unread: number` — Total count of unread messages (for badge)
|
|
3614
|
+
* - `pagination: PaginationInfo` — `{ page, limit, total, pages }`
|
|
3615
|
+
*
|
|
3616
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
3617
|
+
*
|
|
3618
|
+
* @example
|
|
3619
|
+
* ```typescript
|
|
3620
|
+
* // List all voicemails
|
|
3621
|
+
* const { messages, unread, pagination } = await voip.listVoicemails({ limit: 25 });
|
|
3622
|
+
*
|
|
3623
|
+
* console.log(`${unread} unread voicemails (${pagination.total} total)`);
|
|
3624
|
+
*
|
|
3625
|
+
* // Show unread messages first
|
|
3626
|
+
* const { messages } = await voip.listVoicemails({ unreadOnly: true });
|
|
3627
|
+
*
|
|
3628
|
+
* // Play a voicemail in the browser
|
|
3629
|
+
* messages.forEach(msg => {
|
|
3630
|
+
* const audioUrl = voip.buildUrl(msg.audio_url);
|
|
3631
|
+
* // Render: <audio src={audioUrl} preload="none" controls />
|
|
3632
|
+
* });
|
|
3633
|
+
*
|
|
3634
|
+
* // CRM UI: voicemail inbox with unread badge
|
|
3635
|
+
* const { messages, unread } = await voip.listVoicemails({ page: 1, limit: 50 });
|
|
3636
|
+
* updateUnreadBadge(unread); // Show "3" badge on Voicemail tab
|
|
3637
|
+
* renderVoicemailInbox(messages);
|
|
3638
|
+
* ```
|
|
3639
|
+
*
|
|
3640
|
+
* @see GET /api/voicemail
|
|
3641
|
+
*/
|
|
1182
3642
|
async listVoicemails(params) {
|
|
1183
3643
|
return this.request('GET', '/api/voicemail', undefined, params);
|
|
1184
3644
|
}
|
|
1185
|
-
/**
|
|
3645
|
+
/**
|
|
3646
|
+
* Get voicemail count (total and unread) for an extension.
|
|
3647
|
+
*
|
|
3648
|
+
* @description
|
|
3649
|
+
* Returns the total number of voicemail messages and the unread count for the specified
|
|
3650
|
+
* extension. This is ideal for displaying a badge counter on the voicemail tab or
|
|
3651
|
+
* menu item — poll this periodically to keep the badge up to date.
|
|
3652
|
+
*
|
|
3653
|
+
* If no extension is specified, returns counts for the authenticated user's extension
|
|
3654
|
+
* (when using JWT/extension-level auth). When using an API key with admin scope,
|
|
3655
|
+
* you must specify the extension.
|
|
3656
|
+
*
|
|
3657
|
+
* **CRM UI guidance:** Call this every 15-30 seconds via `setInterval` to keep the
|
|
3658
|
+
* unread badge updated. Show the count as a red circle badge: "Voicemail (3⦁)".
|
|
3659
|
+
* When counts are zero, hide the badge or show a gray "(0)" indicator.
|
|
3660
|
+
*
|
|
3661
|
+
* @param extension — Optional extension number (e.g. `'1001'`). If not specified,
|
|
3662
|
+
* uses the authenticated user's extension
|
|
3663
|
+
*
|
|
3664
|
+
* @returns An object containing:
|
|
3665
|
+
* - `total: number` — Total number of voicemail messages
|
|
3666
|
+
* - `unread: number` — Number of unread messages (for badge display)
|
|
3667
|
+
* - `extension: string` — The extension these counts belong to
|
|
3668
|
+
*
|
|
3669
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
3670
|
+
*
|
|
3671
|
+
* @example
|
|
3672
|
+
* ```typescript
|
|
3673
|
+
* // Check unread count for extension 1001
|
|
3674
|
+
* const { total, unread } = await voip.getVoicemailCount('1001');
|
|
3675
|
+
* console.log(`${unread} unread / ${total} total voicemails for 1001`);
|
|
3676
|
+
*
|
|
3677
|
+
* // CRM UI: update badge every 30 seconds
|
|
3678
|
+
* setInterval(async () => {
|
|
3679
|
+
* const { unread } = await voip.getVoicemailCount('1001');
|
|
3680
|
+
* updateVoicemailBadge(unread > 0 ? unread : null);
|
|
3681
|
+
* }, 30000);
|
|
3682
|
+
* ```
|
|
3683
|
+
*
|
|
3684
|
+
* @see GET /api/voicemail/count
|
|
3685
|
+
*/
|
|
1186
3686
|
async getVoicemailCount(extension) {
|
|
1187
3687
|
return this.request('GET', '/api/voicemail/count', undefined, extension ? { extension } : undefined);
|
|
1188
3688
|
}
|
|
1189
|
-
/**
|
|
3689
|
+
/**
|
|
3690
|
+
* Get a single voicemail message by its UUID.
|
|
3691
|
+
*
|
|
3692
|
+
* @description
|
|
3693
|
+
* Returns full details for one voicemail message including caller ID, duration,
|
|
3694
|
+
* read status, urgency, audio URL for playback, and any linked call record.
|
|
3695
|
+
* Use this to show a detailed voicemail view with playback controls and metadata.
|
|
3696
|
+
*
|
|
3697
|
+
* @param id — The UUID of the voicemail message (from `listVoicemails()` → `messages[].id`)
|
|
3698
|
+
*
|
|
3699
|
+
* @returns A `VoicemailMessage` object containing:
|
|
3700
|
+
* - `id` — UUID for read/delete operations
|
|
3701
|
+
* - `caller_id` — Phone number or extension that left the message
|
|
3702
|
+
* - `caller_name` — Name if Caller ID lookup was performed
|
|
3703
|
+
* - `duration_seconds` — Length of the message in seconds
|
|
3704
|
+
* - `is_read` — Whether the message has been heard
|
|
3705
|
+
* - `is_urgent` — Whether the caller marked it urgent
|
|
3706
|
+
* - `audio_url` — URL to stream the audio (use `buildUrl()` for browser playback)
|
|
3707
|
+
* - `call` — Linked call record (null if no matching call found)
|
|
3708
|
+
* - `created_at` — ISO timestamp when the message was left
|
|
3709
|
+
*
|
|
3710
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
3711
|
+
* @throws {HttpError} 404 — Voicemail not found
|
|
3712
|
+
*
|
|
3713
|
+
* @example
|
|
3714
|
+
* ```typescript
|
|
3715
|
+
* const msg = await voip.getVoicemail('voicemail-uuid-here');
|
|
3716
|
+
*
|
|
3717
|
+
* console.log(`From: ${msg.caller_id}`);
|
|
3718
|
+
* console.log(`Name: ${msg.caller_name || 'Unknown'}`);
|
|
3719
|
+
* console.log(`Duration: ${msg.duration_seconds}s`);
|
|
3720
|
+
* console.log(`Urgent: ${msg.is_urgent ? 'Yes' : 'No'}`);
|
|
3721
|
+
*
|
|
3722
|
+
* // Play the audio
|
|
3723
|
+
* const audioUrl = voip.buildUrl(msg.audio_url);
|
|
3724
|
+
* audioElement.src = audioUrl;
|
|
3725
|
+
* audioElement.play();
|
|
3726
|
+
* ```
|
|
3727
|
+
*
|
|
3728
|
+
* @see GET /api/voicemail/{id}
|
|
3729
|
+
*/
|
|
1190
3730
|
async getVoicemail(id) {
|
|
1191
3731
|
return this.request('GET', `/api/voicemail/${id}`);
|
|
1192
3732
|
}
|
|
1193
|
-
/**
|
|
3733
|
+
/**
|
|
3734
|
+
* Mark a voicemail message as read (heard).
|
|
3735
|
+
*
|
|
3736
|
+
* @description
|
|
3737
|
+
* Sets the `is_read` flag to `true` and records the `read_at` timestamp for the
|
|
3738
|
+
* specified voicemail message. This also decrements the unread count for the
|
|
3739
|
+
* associated extension.
|
|
3740
|
+
*
|
|
3741
|
+
* **CRM UI guidance:** Call this automatically when the user clicks "Play" on a
|
|
3742
|
+
* voicemail — don't wait for them to finish listening. Also show a "Mark as Read"
|
|
3743
|
+
* button for messages the user has already heard but wants to acknowledge.
|
|
3744
|
+
* After marking, update the row to remove the bold/unread indicator and update
|
|
3745
|
+
* the unread badge count.
|
|
3746
|
+
*
|
|
3747
|
+
* @param id — The UUID of the voicemail message (from `listVoicemails()` → `messages[].id`)
|
|
3748
|
+
*
|
|
3749
|
+
* @returns An object containing:
|
|
3750
|
+
* - `message: string` — Confirmation message (e.g. `'Voicemail marked as read'`)
|
|
3751
|
+
*
|
|
3752
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
3753
|
+
* @throws {HttpError} 404 — Voicemail not found
|
|
3754
|
+
*
|
|
3755
|
+
* @example
|
|
3756
|
+
* ```typescript
|
|
3757
|
+
* // Mark as read when user clicks Play
|
|
3758
|
+
* async function handlePlayVoicemail(msg: VoicemailMessage) {
|
|
3759
|
+
* const audioUrl = voip.buildUrl(msg.audio_url);
|
|
3760
|
+
* audioElement.src = audioUrl;
|
|
3761
|
+
* audioElement.play();
|
|
3762
|
+
*
|
|
3763
|
+
* // Mark as read immediately on play
|
|
3764
|
+
* if (!msg.is_read) {
|
|
3765
|
+
* await voip.markVoicemailRead(msg.id);
|
|
3766
|
+
* markRowAsRead(msg.id); // Remove bold/unread dot
|
|
3767
|
+
* decrementUnreadBadge();
|
|
3768
|
+
* }
|
|
3769
|
+
* }
|
|
3770
|
+
*
|
|
3771
|
+
* // CRM UI: "Mark Read" button
|
|
3772
|
+
* async function handleMarkRead(voicemailId: string) {
|
|
3773
|
+
* await voip.markVoicemailRead(voicemailId);
|
|
3774
|
+
* markRowAsRead(voicemailId);
|
|
3775
|
+
* showNotification('Marked as read');
|
|
3776
|
+
* }
|
|
3777
|
+
* ```
|
|
3778
|
+
*
|
|
3779
|
+
* @see PUT /api/voicemail/{id}/read
|
|
3780
|
+
*/
|
|
1194
3781
|
async markVoicemailRead(id) {
|
|
1195
3782
|
return this.request('PUT', `/api/voicemail/${id}/read`);
|
|
1196
3783
|
}
|
|
1197
|
-
/**
|
|
3784
|
+
/**
|
|
3785
|
+
* Permanently delete a voicemail message (hard delete — removes audio file from disk).
|
|
3786
|
+
*
|
|
3787
|
+
* @description
|
|
3788
|
+
* Deletes the voicemail record from the database AND removes the audio file from
|
|
3789
|
+
* disk storage. This is irreversible. The voicemail will disappear from the inbox
|
|
3790
|
+
* and cannot be recovered.
|
|
3791
|
+
*
|
|
3792
|
+
* **CRM UI guidance:** Show a confirmation dialog before deleting: "Permanently
|
|
3793
|
+
* delete this voicemail from [caller]? This cannot be undone." After deletion,
|
|
3794
|
+
* remove the row from the list immediately to avoid showing a stale entry.
|
|
3795
|
+
*
|
|
3796
|
+
* @param id — The UUID of the voicemail message (from `listVoicemails()` → `messages[].id`)
|
|
3797
|
+
*
|
|
3798
|
+
* @returns An object containing:
|
|
3799
|
+
* - `message: string` — Confirmation message (e.g. `'Voicemail deleted'`)
|
|
3800
|
+
*
|
|
3801
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
3802
|
+
* @throws {HttpError} 404 — Voicemail not found
|
|
3803
|
+
*
|
|
3804
|
+
* @example
|
|
3805
|
+
* ```typescript
|
|
3806
|
+
* // Delete a single voicemail
|
|
3807
|
+
* await voip.deleteVoicemail('voicemail-uuid-here');
|
|
3808
|
+
*
|
|
3809
|
+
* // CRM UI: Delete button with confirmation
|
|
3810
|
+
* async function handleDeleteVoicemail(msg: VoicemailMessage) {
|
|
3811
|
+
* if (!confirm(`Delete voicemail from ${msg.caller_id}? This cannot be undone.`)) {
|
|
3812
|
+
* return;
|
|
3813
|
+
* }
|
|
3814
|
+
* try {
|
|
3815
|
+
* await voip.deleteVoicemail(msg.id);
|
|
3816
|
+
* removeVoicemailRow(msg.id); // Remove from list immediately
|
|
3817
|
+
* showNotification('Voicemail deleted');
|
|
3818
|
+
* } catch (err) {
|
|
3819
|
+
* showError('Failed to delete voicemail');
|
|
3820
|
+
* }
|
|
3821
|
+
* }
|
|
3822
|
+
* ```
|
|
3823
|
+
*
|
|
3824
|
+
* @see DELETE /api/voicemail/{id}
|
|
3825
|
+
*/
|
|
1198
3826
|
async deleteVoicemail(id) {
|
|
1199
3827
|
return this.request('DELETE', `/api/voicemail/${id}`);
|
|
1200
3828
|
}
|
|
@@ -1273,17 +3901,59 @@ export class VoIPClient {
|
|
|
1273
3901
|
// Presence (REST)
|
|
1274
3902
|
// -------------------------------------------------------------------
|
|
1275
3903
|
/**
|
|
1276
|
-
* Get presence for all extensions.
|
|
1277
|
-
*
|
|
3904
|
+
* Get presence status for all extensions in the tenant.
|
|
3905
|
+
*
|
|
3906
|
+
* @description
|
|
3907
|
+
* Retrieves real-time presence information for every extension. Two modes:
|
|
3908
|
+
*
|
|
3909
|
+
* - **Simple** (default): Returns a map of extension number → status string.
|
|
3910
|
+
* Status values: `'available'`, `'on_call'`, `'offline'`, `'do_not_disturb'`
|
|
3911
|
+
* - **Detailed** (`{ detailed: true }`): Returns rich objects with call info when
|
|
3912
|
+
* the extension is on a call, including `callUuid`, `caller`, `direction`, `since`.
|
|
3913
|
+
*
|
|
3914
|
+
* Use simple mode for status indicators (colored dots) and detailed mode for
|
|
3915
|
+
* call-aware features like "Busy — on call with +1555..." tooltips.
|
|
3916
|
+
*
|
|
3917
|
+
* **CRM UI guidance (simple):** Show presence as a colored dot next to each
|
|
3918
|
+
* extension name. Green = available, orange = on call, gray = offline,
|
|
3919
|
+
* red = do not disturb. Poll every 5-10 seconds.
|
|
3920
|
+
*
|
|
3921
|
+
* **CRM UI guidance (detailed):** Hover over the colored dot to show a tooltip:
|
|
3922
|
+
* "On call with +17865551234 (3:42)". Use this for the extension detail panel
|
|
3923
|
+
* or operator console.
|
|
3924
|
+
*
|
|
3925
|
+
* @param options — Optional mode selector
|
|
3926
|
+
* @param options.detailed — When `true`, returns detailed presence with call info.
|
|
3927
|
+
* Each value is a `PresenceDetail` object instead of a string
|
|
3928
|
+
*
|
|
3929
|
+
* @returns An object containing:
|
|
3930
|
+
* - `presence: PresenceMap | PresenceDetailMap` — Map of `extension_number → status_string`
|
|
3931
|
+
* (simple) or `extension_number → PresenceDetail` (detailed)
|
|
3932
|
+
*
|
|
3933
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
1278
3934
|
*
|
|
1279
3935
|
* @example
|
|
1280
|
-
*
|
|
3936
|
+
* ```typescript
|
|
3937
|
+
* // Simple presence — extension list with colored dots
|
|
1281
3938
|
* const { presence } = await voip.getPresence();
|
|
1282
|
-
* // { '1001': 'available', '1002': 'on_call' }
|
|
3939
|
+
* // { '1001': 'available', '1002': 'on_call', '1003': 'offline' }
|
|
3940
|
+
*
|
|
3941
|
+
* Object.entries(presence).forEach(([ext, status]) => {
|
|
3942
|
+
* const color = { available: 'green', on_call: 'orange', offline: 'gray', do_not_disturb: 'red' }[status];
|
|
3943
|
+
* console.log(`${ext}: ${color} ${status}`);
|
|
3944
|
+
* });
|
|
1283
3945
|
*
|
|
1284
3946
|
* // Detailed presence with call info
|
|
1285
3947
|
* const { presence } = await voip.getPresence({ detailed: true });
|
|
1286
|
-
* // { '
|
|
3948
|
+
* // { '1002': { status: 'on_call', callUuid: '...', caller: '+1555...', direction: 'inbound' } }
|
|
3949
|
+
*
|
|
3950
|
+
* if (presence['1002'].status === 'on_call') {
|
|
3951
|
+
* console.log(`1002 is on call with ${presence['1002'].caller}`);
|
|
3952
|
+
* }
|
|
3953
|
+
* ```
|
|
3954
|
+
*
|
|
3955
|
+
* @see GET /api/presence
|
|
3956
|
+
* @see GET /api/presence/detailed
|
|
1287
3957
|
*/
|
|
1288
3958
|
async getPresence(options) {
|
|
1289
3959
|
if (options?.detailed) {
|
|
@@ -1291,33 +3961,318 @@ export class VoIPClient {
|
|
|
1291
3961
|
}
|
|
1292
3962
|
return this.request('GET', '/api/presence');
|
|
1293
3963
|
}
|
|
1294
|
-
/**
|
|
3964
|
+
/**
|
|
3965
|
+
* Set manual presence status for an extension.
|
|
3966
|
+
*
|
|
3967
|
+
* @description
|
|
3968
|
+
* Overrides the automatic presence detection (which is based on SIP registration and
|
|
3969
|
+
* call activity) with a manual status. Use this when an agent wants to appear
|
|
3970
|
+
* "busy" even though they're not on a call, or "offline" when they're leaving for
|
|
3971
|
+
* the day.
|
|
3972
|
+
*
|
|
3973
|
+
* Manual presence is sticky — it remains until the extension changes it again
|
|
3974
|
+
* or until the extension's call activity forces a natural status update (e.g.
|
|
3975
|
+
* the extension goes on a call, which sets it to `'on_call'`).
|
|
3976
|
+
*
|
|
3977
|
+
* **CRM UI guidance:** Show a presence selector dropdown next to the user's name:
|
|
3978
|
+
* Available (green dot), Busy (orange dot), Offline (gray dot). Store the selected
|
|
3979
|
+
* value in local state. On change, call `setPresence()` and update the local UI.
|
|
3980
|
+
*
|
|
3981
|
+
* @param extensionId — The UUID of the extension (from `listExtensions()` → `extensions[].id`)
|
|
3982
|
+
* @param status — The desired manual presence:
|
|
3983
|
+
* - `'available'` — Available to take calls (green)
|
|
3984
|
+
* - `'busy'` — Not accepting calls (orange)
|
|
3985
|
+
* - `'offline'` — Signed out / away (gray)
|
|
3986
|
+
*
|
|
3987
|
+
* @returns An object containing:
|
|
3988
|
+
* - `ok: boolean` — `true` if the status was set
|
|
3989
|
+
* - `extension: string` — The extension number
|
|
3990
|
+
* - `status: string` — The new presence status
|
|
3991
|
+
*
|
|
3992
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
3993
|
+
* @throws {HttpError} 404 — Extension not found
|
|
3994
|
+
*
|
|
3995
|
+
* @example
|
|
3996
|
+
* ```typescript
|
|
3997
|
+
* // Set yourself as busy — don't route calls to me
|
|
3998
|
+
* await voip.setPresence('extension-uuid', 'busy');
|
|
3999
|
+
*
|
|
4000
|
+
* // Set yourself as available again
|
|
4001
|
+
* await voip.setPresence('extension-uuid', 'available');
|
|
4002
|
+
*
|
|
4003
|
+
* // Go offline at end of day
|
|
4004
|
+
* await voip.setPresence('extension-uuid', 'offline');
|
|
4005
|
+
*
|
|
4006
|
+
* // CRM UI: presence selector dropdown
|
|
4007
|
+
* async function handlePresenceChange(extId: string, newStatus: string) {
|
|
4008
|
+
* try {
|
|
4009
|
+
* await voip.setPresence(extId, newStatus);
|
|
4010
|
+
* updatePresenceIndicator(newStatus);
|
|
4011
|
+
* showNotification(`Status changed to ${newStatus}`);
|
|
4012
|
+
* } catch (err) {
|
|
4013
|
+
* showError('Failed to update presence');
|
|
4014
|
+
* }
|
|
4015
|
+
* }
|
|
4016
|
+
* ```
|
|
4017
|
+
*
|
|
4018
|
+
* @see PUT /api/extensions/{id}/presence
|
|
4019
|
+
*/
|
|
1295
4020
|
async setPresence(extensionId, status) {
|
|
1296
4021
|
return this.request('PUT', `/api/extensions/${extensionId}/presence`, { status });
|
|
1297
4022
|
}
|
|
1298
4023
|
// -------------------------------------------------------------------
|
|
1299
4024
|
// Reports
|
|
1300
4025
|
// -------------------------------------------------------------------
|
|
1301
|
-
/**
|
|
4026
|
+
/**
|
|
4027
|
+
* Get dashboard summary statistics for the current tenant.
|
|
4028
|
+
*
|
|
4029
|
+
* @description
|
|
4030
|
+
* Returns high-level KPIs for the admin dashboard landing page:
|
|
4031
|
+
* - Total extensions configured
|
|
4032
|
+
* - Total DIDs (phone numbers) assigned
|
|
4033
|
+
* - Calls today (since midnight)
|
|
4034
|
+
* - Calls this month
|
|
4035
|
+
*
|
|
4036
|
+
* **CRM UI guidance:** Display as 4 KPI cards at the top of the dashboard.
|
|
4037
|
+
* Each card shows a large number with a label below, using icons:
|
|
4038
|
+
* - 📞 Extensions (phone icon)
|
|
4039
|
+
* - 📱 DIDs (smartphone icon)
|
|
4040
|
+
* - 📊 Calls Today (chart icon)
|
|
4041
|
+
* - 📅 Calls This Month (calendar icon)
|
|
4042
|
+
* Refresh every 60 seconds or on page reload.
|
|
4043
|
+
*
|
|
4044
|
+
* @returns An object containing:
|
|
4045
|
+
* - `dashboard: object` — `{ extensions, dids, callsToday, callsThisMonth }`
|
|
4046
|
+
*
|
|
4047
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
4048
|
+
*
|
|
4049
|
+
* @example
|
|
4050
|
+
* ```typescript
|
|
4051
|
+
* const { dashboard } = await voip.getDashboardStats();
|
|
4052
|
+
*
|
|
4053
|
+
* console.log(`Extensions: ${dashboard.extensions}`);
|
|
4054
|
+
* console.log(`DIDs: ${dashboard.dids}`);
|
|
4055
|
+
* console.log(`Calls Today: ${dashboard.callsToday}`);
|
|
4056
|
+
* console.log(`Calls This Month: ${dashboard.callsThisMonth}`);
|
|
4057
|
+
*
|
|
4058
|
+
* // CRM UI: render KPI cards
|
|
4059
|
+
* function renderDashboardStats(stats: DashboardStats['dashboard']) {
|
|
4060
|
+
* showKpiCard('Extensions', stats.extensions, '📞');
|
|
4061
|
+
* showKpiCard('DIDs', stats.dids, '📱');
|
|
4062
|
+
* showKpiCard('Calls Today', stats.callsToday, '📊');
|
|
4063
|
+
* showKpiCard('Calls This Month', stats.callsThisMonth, '📅');
|
|
4064
|
+
* }
|
|
4065
|
+
* ```
|
|
4066
|
+
*
|
|
4067
|
+
* @see GET /api/reports/dashboard
|
|
4068
|
+
*/
|
|
1302
4069
|
async getDashboardStats() {
|
|
1303
4070
|
return this.request('GET', '/api/reports/dashboard');
|
|
1304
4071
|
}
|
|
1305
|
-
/**
|
|
4072
|
+
/**
|
|
4073
|
+
* Get call counts grouped by day for charting and trend analysis.
|
|
4074
|
+
*
|
|
4075
|
+
* @description
|
|
4076
|
+
* Returns a date → stats map showing call volume per day, broken down by direction
|
|
4077
|
+
* (inbound, outbound, internal) with total duration. Use this to render a bar chart
|
|
4078
|
+
* or line chart showing call trends over time.
|
|
4079
|
+
*
|
|
4080
|
+
* **CRM UI guidance:** Render as a bar chart with days on the X-axis and call counts
|
|
4081
|
+
* on the Y-axis. Use stacked bars for inbound/outbound/internal. Hover over a bar
|
|
4082
|
+
* to show a tooltip: "Mon: 45 calls (28 inbound, 12 outbound, 5 internal)".
|
|
4083
|
+
* Use a chart library like Chart.js, Recharts, or visx.
|
|
4084
|
+
*
|
|
4085
|
+
* @param days — Number of recent days to include. Default: 7. Example: `30` for a monthly chart
|
|
4086
|
+
*
|
|
4087
|
+
* @returns An object containing:
|
|
4088
|
+
* - `callsByDay: Record<string, DayStats>` — Map of `'YYYY-MM-DD'` → `{ total, inbound, outbound, internal, totalDuration }`
|
|
4089
|
+
*
|
|
4090
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
4091
|
+
*
|
|
4092
|
+
* @example
|
|
4093
|
+
* ```typescript
|
|
4094
|
+
* // Get last 7 days for a weekly trend chart
|
|
4095
|
+
* const { callsByDay } = await voip.getCallsByDay(7);
|
|
4096
|
+
*
|
|
4097
|
+
* // Format for charting
|
|
4098
|
+
* const chartData = Object.entries(callsByDay).map(([date, stats]) => ({
|
|
4099
|
+
* date,
|
|
4100
|
+
* inbound: stats.inbound,
|
|
4101
|
+
* outbound: stats.outbound,
|
|
4102
|
+
* internal: stats.internal,
|
|
4103
|
+
* total: stats.total,
|
|
4104
|
+
* }));
|
|
4105
|
+
*
|
|
4106
|
+
* // Last 30 days for monthly trend
|
|
4107
|
+
* const { callsByDay } = await voip.getCallsByDay(30);
|
|
4108
|
+
*
|
|
4109
|
+
* // CRM UI: chart rendering
|
|
4110
|
+
* const labels = chartData.map(d => d.date.slice(5)); // '06-01', '06-02', ...
|
|
4111
|
+
* const inboundData = chartData.map(d => d.inbound);
|
|
4112
|
+
* const outboundData = chartData.map(d => d.outbound);
|
|
4113
|
+
* renderStackedBarChart(labels, [
|
|
4114
|
+
* { label: 'Inbound', data: inboundData, color: '#4CAF50' },
|
|
4115
|
+
* { label: 'Outbound', data: outboundData, color: '#2196F3' },
|
|
4116
|
+
* ]);
|
|
4117
|
+
* ```
|
|
4118
|
+
*
|
|
4119
|
+
* @see GET /api/reports/calls-by-day
|
|
4120
|
+
*/
|
|
1306
4121
|
async getCallsByDay(days) {
|
|
1307
4122
|
return this.request('GET', '/api/reports/calls-by-day', undefined, days ? { days } : undefined);
|
|
1308
4123
|
}
|
|
1309
|
-
/**
|
|
4124
|
+
/**
|
|
4125
|
+
* Get top extensions ranked by call volume.
|
|
4126
|
+
*
|
|
4127
|
+
* @description
|
|
4128
|
+
* Returns the busiest extensions in the tenant, ranked by total call count and
|
|
4129
|
+
* total call duration. Use this for:
|
|
4130
|
+
* - "Top Performers" leaderboard on the dashboard
|
|
4131
|
+
* - Identifying overloaded extensions that need backup
|
|
4132
|
+
* - Call center agent performance reports
|
|
4133
|
+
*
|
|
4134
|
+
* **CRM UI guidance:** Render as a horizontal bar chart or ranked table.
|
|
4135
|
+
* Show: Rank, Extension Number, Display Name, Total Calls, Total Duration.
|
|
4136
|
+
* Use a podium icon for the top 3. Show a period selector (7d, 30d, 90d).
|
|
4137
|
+
*
|
|
4138
|
+
* @param days — Number of recent days to analyze. Default: 7. Example: `30`
|
|
4139
|
+
* @param limit — Maximum number of extensions to return. Default: 10. Example: `5`
|
|
4140
|
+
*
|
|
4141
|
+
* @returns An object containing:
|
|
4142
|
+
* - `topExtensions: TopExtension[]` — Array of `{ extension, totalCalls, totalDuration }` sorted by call count descending
|
|
4143
|
+
*
|
|
4144
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
4145
|
+
*
|
|
4146
|
+
* @example
|
|
4147
|
+
* ```typescript
|
|
4148
|
+
* // Top 10 extensions in last 7 days
|
|
4149
|
+
* const { topExtensions } = await voip.getTopExtensions(7, 10);
|
|
4150
|
+
*
|
|
4151
|
+
* topExtensions.forEach((ext, i) => {
|
|
4152
|
+
* const hours = Math.floor(ext.totalDuration / 3600);
|
|
4153
|
+
* const mins = Math.floor((ext.totalDuration % 3600) / 60);
|
|
4154
|
+
* console.log(`#${i + 1} ${ext.extension}: ${ext.totalCalls} calls (${hours}h ${mins}m)`);
|
|
4155
|
+
* });
|
|
4156
|
+
*
|
|
4157
|
+
* // CRM UI: leaderboard
|
|
4158
|
+
* function renderTopExtensions(extensions: TopExtension[]) {
|
|
4159
|
+
* extensions.forEach((ext, i) => {
|
|
4160
|
+
* const rank = i + 1;
|
|
4161
|
+
* const medal = rank <= 3 ? ['🥇', '🥈', '🥉'][i] : `#${rank}`;
|
|
4162
|
+
* const duration = formatDuration(ext.totalDuration); // "3h 24m"
|
|
4163
|
+
* renderLeaderboardRow(medal, ext.extension, ext.totalCalls, duration);
|
|
4164
|
+
* });
|
|
4165
|
+
* }
|
|
4166
|
+
* ```
|
|
4167
|
+
*
|
|
4168
|
+
* @see GET /api/reports/top-extensions
|
|
4169
|
+
*/
|
|
1310
4170
|
async getTopExtensions(days, limit) {
|
|
1311
4171
|
return this.request('GET', '/api/reports/top-extensions', undefined, { days, limit });
|
|
1312
4172
|
}
|
|
1313
4173
|
// -------------------------------------------------------------------
|
|
1314
4174
|
// Blocklist (Call Blocking)
|
|
1315
4175
|
// -------------------------------------------------------------------
|
|
1316
|
-
/**
|
|
4176
|
+
/**
|
|
4177
|
+
* List all blocked phone numbers for the tenant.
|
|
4178
|
+
*
|
|
4179
|
+
* @description
|
|
4180
|
+
* Returns every number that has been added to the tenant's call blocklist.
|
|
4181
|
+
* Inbound calls from blocked numbers are automatically rejected with a "Number
|
|
4182
|
+
* not in service" tone or a custom message. Outbound calls to blocked numbers
|
|
4183
|
+
* are prevented.
|
|
4184
|
+
*
|
|
4185
|
+
* Each blocked number includes:
|
|
4186
|
+
* - `number` — The blocked phone number
|
|
4187
|
+
* - `reason` — Optional reason for blocking (e.g. "Spam caller")
|
|
4188
|
+
* - `direction` — `'inbound'` (block incoming), `'outbound'` (block outgoing), or `'both'`
|
|
4189
|
+
* - `created_at` — When the number was blocked
|
|
4190
|
+
*
|
|
4191
|
+
* **CRM UI guidance:** Show as a table with columns: Phone Number, Direction,
|
|
4192
|
+
* Reason, Blocked Date, and an "Unblock" button. Add a search bar to find
|
|
4193
|
+
* specific numbers. Show a "Block Number" button that opens a form with
|
|
4194
|
+
* number, direction selector, and reason field.
|
|
4195
|
+
*
|
|
4196
|
+
* @returns An object containing:
|
|
4197
|
+
* - `blocked: BlockedNumber[]` — Array of blocked number entries
|
|
4198
|
+
*
|
|
4199
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
4200
|
+
*
|
|
4201
|
+
* @example
|
|
4202
|
+
* ```typescript
|
|
4203
|
+
* const { blocked } = await voip.listBlockedNumbers();
|
|
4204
|
+
*
|
|
4205
|
+
* blocked.forEach(b => {
|
|
4206
|
+
* console.log(`${b.number} — ${b.direction || 'both'} — ${b.reason || 'No reason'}`);
|
|
4207
|
+
* });
|
|
4208
|
+
* ```
|
|
4209
|
+
*
|
|
4210
|
+
* @see GET /api/blocklist
|
|
4211
|
+
*/
|
|
1317
4212
|
async listBlockedNumbers() {
|
|
1318
4213
|
return this.request('GET', '/api/blocklist');
|
|
1319
4214
|
}
|
|
1320
|
-
/**
|
|
4215
|
+
/**
|
|
4216
|
+
* Block a phone number from calling or being called.
|
|
4217
|
+
*
|
|
4218
|
+
* @description
|
|
4219
|
+
* Adds a number to the tenant's call blocklist. Once blocked:
|
|
4220
|
+
* - Inbound calls from this number are automatically rejected
|
|
4221
|
+
* - Outbound calls to this number are prevented (when direction is `'outbound'` or `'both'`)
|
|
4222
|
+
*
|
|
4223
|
+
* **Superadmin mode:** Pass `{ tenantId: 'uuid' }` when using a global superadmin
|
|
4224
|
+
* key to block a number in a specific tenant.
|
|
4225
|
+
*
|
|
4226
|
+
* **CRM UI guidance:** Show a "Block Number" button on call records, voicemail
|
|
4227
|
+
* messages, and the blocklist page. On click, open a form with: Number (pre-filled
|
|
4228
|
+
* if coming from a call record), Direction (inbound/outbound/both), Reason (optional
|
|
4229
|
+
* text — "Spam", "Harassment", "Wrong number", etc.). After blocking, show a
|
|
4230
|
+
* confirmation and refresh the blocklist.
|
|
4231
|
+
*
|
|
4232
|
+
* @param params — Block parameters
|
|
4233
|
+
* @param params.number — The phone number to block (E.164 format recommended).
|
|
4234
|
+
* Example: `'+17865559999'`
|
|
4235
|
+
* @param params.reason — Optional reason for blocking. Example: `'Spam caller'`
|
|
4236
|
+
* @param params.direction — Which direction to block:
|
|
4237
|
+
* `'inbound'` — Calls FROM this number are blocked,
|
|
4238
|
+
* `'outbound'` — Calls TO this number are blocked,
|
|
4239
|
+
* `'both'` — Both directions (default)
|
|
4240
|
+
* @param options — Optional request overrides
|
|
4241
|
+
* @param options.tenantId — Target tenant UUID (required for superadmin keys only)
|
|
4242
|
+
*
|
|
4243
|
+
* @returns An object containing:
|
|
4244
|
+
* - `blocked: BlockedNumber` — The created blocklist entry with `id`, `number`, `reason`, `direction`, `created_at`
|
|
4245
|
+
*
|
|
4246
|
+
* @throws {HttpError} 400 — Number is already blocked or format is invalid
|
|
4247
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
4248
|
+
*
|
|
4249
|
+
* @example
|
|
4250
|
+
* ```typescript
|
|
4251
|
+
* // Block a spam caller
|
|
4252
|
+
* await voip.blockNumber({ number: '+17865559999', reason: 'Spam caller', direction: 'inbound' });
|
|
4253
|
+
*
|
|
4254
|
+
* // Block outgoing calls to a premium number
|
|
4255
|
+
* await voip.blockNumber({ number: '+19005551234', reason: 'Premium rate', direction: 'outbound' });
|
|
4256
|
+
*
|
|
4257
|
+
* // Block both directions
|
|
4258
|
+
* await voip.blockNumber({ number: '+17865558888', reason: 'Harassment', direction: 'both' });
|
|
4259
|
+
*
|
|
4260
|
+
* // CRM UI: Block button on call detail
|
|
4261
|
+
* async function blockCaller(call: CallRecord) {
|
|
4262
|
+
* if (!confirm(`Block ${call.caller_id_number}? They will no longer be able to call you.`)) {
|
|
4263
|
+
* return;
|
|
4264
|
+
* }
|
|
4265
|
+
* await voip.blockNumber({
|
|
4266
|
+
* number: call.caller_id_number,
|
|
4267
|
+
* reason: 'Blocked by user',
|
|
4268
|
+
* direction: 'inbound',
|
|
4269
|
+
* });
|
|
4270
|
+
* showNotification(`${call.caller_id_number} has been blocked`);
|
|
4271
|
+
* }
|
|
4272
|
+
* ```
|
|
4273
|
+
*
|
|
4274
|
+
* @see POST /api/blocklist
|
|
4275
|
+
*/
|
|
1321
4276
|
async blockNumber(params, options) {
|
|
1322
4277
|
return this.request('POST', '/api/blocklist', params, undefined, options);
|
|
1323
4278
|
}
|
|
@@ -1325,14 +4280,108 @@ export class VoIPClient {
|
|
|
1325
4280
|
async unblockNumber(id) {
|
|
1326
4281
|
return this.request('DELETE', `/api/blocklist/${id}`);
|
|
1327
4282
|
}
|
|
1328
|
-
/**
|
|
4283
|
+
/**
|
|
4284
|
+
* Check if a specific phone number is blocked.
|
|
4285
|
+
*
|
|
4286
|
+
* @description
|
|
4287
|
+
* Looks up a phone number in the tenant's blocklist and returns whether it's blocked
|
|
4288
|
+
* along with the blocklist entry details. Use this for:
|
|
4289
|
+
* - Incoming call screen pop — check if the caller is blocked before answering
|
|
4290
|
+
* - Warning indicator on call detail pages — show "Number is blocked" banner
|
|
4291
|
+
* - Pre-dial validation — check if a number is blocked before originating a call
|
|
4292
|
+
*
|
|
4293
|
+
* **CRM UI guidance:** Call this when a call comes in (in the WebSocket `call_start`
|
|
4294
|
+
* handler) and show a red "BLOCKED" banner if the number is on the blocklist.
|
|
4295
|
+
* Also call this before originating outbound calls — show a warning if the
|
|
4296
|
+
* destination is blocked.
|
|
4297
|
+
*
|
|
4298
|
+
* @param number — The phone number to check (E.164 format recommended).
|
|
4299
|
+
* Example: `'+17865559999'`
|
|
4300
|
+
*
|
|
4301
|
+
* @returns An object containing:
|
|
4302
|
+
* - `blocked: boolean` — `true` if the number is in the blocklist
|
|
4303
|
+
* - `entry: BlockedNumber | null` — The blocklist entry if blocked, `null` otherwise.
|
|
4304
|
+
* Contains `id`, `number`, `reason`, `direction`, `created_at`
|
|
4305
|
+
*
|
|
4306
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
4307
|
+
*
|
|
4308
|
+
* @example
|
|
4309
|
+
* ```typescript
|
|
4310
|
+
* // Check a caller before answering
|
|
4311
|
+
* const { blocked, entry } = await voip.checkBlocked('+17865559999');
|
|
4312
|
+
* if (blocked) {
|
|
4313
|
+
* console.log(`Blocked: ${entry.number} — Reason: ${entry.reason}`);
|
|
4314
|
+
* showBlockedBanner(entry);
|
|
4315
|
+
* } else {
|
|
4316
|
+
* showIncomingCallBanner('+17865559999');
|
|
4317
|
+
* }
|
|
4318
|
+
*
|
|
4319
|
+
* // Pre-dial check before originating a call
|
|
4320
|
+
* async function safeOriginate(fromExt: string, toNumber: string) {
|
|
4321
|
+
* const { blocked, entry } = await voip.checkBlocked(toNumber);
|
|
4322
|
+
* if (blocked) {
|
|
4323
|
+
* showWarning(`Cannot call ${toNumber} — it is blocked (${entry.reason || 'no reason given'})`);
|
|
4324
|
+
* return;
|
|
4325
|
+
* }
|
|
4326
|
+
* return voip.originate({ fromExtension: fromExt, toNumber });
|
|
4327
|
+
* }
|
|
4328
|
+
*
|
|
4329
|
+
* // CRM UI: screen pop on incoming call
|
|
4330
|
+
* voip.on('call_start', async (data) => {
|
|
4331
|
+
* const { blocked, entry } = await voip.checkBlocked(data.caller_id_number);
|
|
4332
|
+
* if (blocked) {
|
|
4333
|
+
* showScreenPop({ type: 'blocked', caller: data.caller_id_number, reason: entry.reason });
|
|
4334
|
+
* } else {
|
|
4335
|
+
* showScreenPop({ type: 'incoming', caller: data.caller_id_number, name: data.caller_id_name });
|
|
4336
|
+
* }
|
|
4337
|
+
* });
|
|
4338
|
+
* ```
|
|
4339
|
+
*
|
|
4340
|
+
* @see GET /api/blocklist/check/{number}
|
|
4341
|
+
*/
|
|
1329
4342
|
async checkBlocked(number) {
|
|
1330
4343
|
return this.request('GET', `/api/blocklist/check/${encodeURIComponent(number)}`);
|
|
1331
4344
|
}
|
|
1332
4345
|
// -------------------------------------------------------------------
|
|
1333
4346
|
// Business Hours / Schedules
|
|
1334
4347
|
// -------------------------------------------------------------------
|
|
1335
|
-
/**
|
|
4348
|
+
/**
|
|
4349
|
+
* List all business hour schedules for the tenant.
|
|
4350
|
+
*
|
|
4351
|
+
* @description
|
|
4352
|
+
* Returns every business hours schedule configured for the tenant. Each schedule
|
|
4353
|
+
* defines open hours per day of the week, holidays, and after-hours call routing.
|
|
4354
|
+
*
|
|
4355
|
+
* Each schedule includes:
|
|
4356
|
+
* - `name` — Schedule name (e.g. "Standard Business Hours")
|
|
4357
|
+
* - `timezone` — IANA timezone (e.g. `'America/Chicago'`)
|
|
4358
|
+
* - `schedules` — Array of day definitions with enabled flag and start/end times
|
|
4359
|
+
* - `holidays` — Holiday dates with names
|
|
4360
|
+
* - `after_hours_action` — What happens when the schedule is closed
|
|
4361
|
+
* (`'voicemail'`, `'ivr'`, `'extension'`, `'hangup'`, `'ai_agent'`)
|
|
4362
|
+
* - `after_hours_target` — The target for after-hours routing
|
|
4363
|
+
*
|
|
4364
|
+
* **CRM UI guidance:** Show as a table with columns: Name, Timezone, Open Days
|
|
4365
|
+
* (icons for each day), After-Hours Action, Status, Actions (Edit, Delete).
|
|
4366
|
+
* Add a "Check Status" button that calls `getScheduleStatus()`.
|
|
4367
|
+
*
|
|
4368
|
+
* @returns An object containing:
|
|
4369
|
+
* - `schedules: BusinessSchedule[]` — Array of schedule objects
|
|
4370
|
+
*
|
|
4371
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
4372
|
+
*
|
|
4373
|
+
* @example
|
|
4374
|
+
* ```typescript
|
|
4375
|
+
* const { schedules } = await voip.listSchedules();
|
|
4376
|
+
*
|
|
4377
|
+
* schedules.forEach(s => {
|
|
4378
|
+
* const openDays = s.schedules?.filter(d => d.enabled).map(d => d.day).join(', ') || 'None';
|
|
4379
|
+
* console.log(`${s.name} | ${s.timezone} | Open: ${openDays}`);
|
|
4380
|
+
* });
|
|
4381
|
+
* ```
|
|
4382
|
+
*
|
|
4383
|
+
* @see GET /api/schedules
|
|
4384
|
+
*/
|
|
1336
4385
|
async listSchedules() {
|
|
1337
4386
|
return this.request('GET', '/api/schedules');
|
|
1338
4387
|
}
|
|
@@ -1340,7 +4389,67 @@ export class VoIPClient {
|
|
|
1340
4389
|
async getSchedule(id) {
|
|
1341
4390
|
return this.request('GET', `/api/schedules/${id}`);
|
|
1342
4391
|
}
|
|
1343
|
-
/**
|
|
4392
|
+
/**
|
|
4393
|
+
* Create a business hours schedule.
|
|
4394
|
+
*
|
|
4395
|
+
* @description
|
|
4396
|
+
* Defines when a business is open/closed for call routing purposes. Incoming calls
|
|
4397
|
+
* during open hours follow normal routing; calls outside open hours are routed
|
|
4398
|
+
* according to the `afterHoursAction` setting (voicemail, IVR, another extension,
|
|
4399
|
+
* or an external number).
|
|
4400
|
+
*
|
|
4401
|
+
* **Superadmin mode:** Pass `{ tenantId: 'uuid' }` when using a global superadmin
|
|
4402
|
+
* key to create a schedule for a specific tenant.
|
|
4403
|
+
*
|
|
4404
|
+
* **CRM UI guidance:** Show a form with: Name (required), Timezone (dropdown of
|
|
4405
|
+
* IANA timezones), Day-of-week grid (toggle + start/end time per day), Holidays
|
|
4406
|
+
* (date picker + name), After-Hours Action (select: voicemail/IVR/extension),
|
|
4407
|
+
* After-Hours Target (dynamic based on action type).
|
|
4408
|
+
*
|
|
4409
|
+
* @param params — Schedule creation parameters
|
|
4410
|
+
* @param params.name — Human-readable schedule name. Example: `'Standard Business Hours'`
|
|
4411
|
+
* @param params.timezone — IANA timezone string. Example: `'America/Chicago'`
|
|
4412
|
+
* @param params.schedules — Array of day definitions. Each has:
|
|
4413
|
+
* `day` (`'monday'`-`'sunday'`), `enabled` (boolean), `startTime` (HH:MM), `endTime` (HH:MM)
|
|
4414
|
+
* @param params.holidays — Array of holiday dates with optional names.
|
|
4415
|
+
* Example: `[{ date: '2026-12-25', name: 'Christmas' }]`
|
|
4416
|
+
* @param params.afterHoursAction — Action when closed:
|
|
4417
|
+
* `'voicemail'`, `'ivr'`, `'extension'`, `'hangup'`, `'external'`, `'ai_agent'`
|
|
4418
|
+
* @param params.afterHoursTarget — Target for after-hours routing
|
|
4419
|
+
* (extension number, IVR name, phone number, etc.)
|
|
4420
|
+
* @param options — Optional request overrides
|
|
4421
|
+
* @param options.tenantId — Target tenant UUID (required for superadmin keys only)
|
|
4422
|
+
*
|
|
4423
|
+
* @returns An object containing:
|
|
4424
|
+
* - `schedule: BusinessSchedule` — The created schedule object
|
|
4425
|
+
*
|
|
4426
|
+
* @throws {HttpError} 400 — Invalid timezone, missing required fields, or name already exists
|
|
4427
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
4428
|
+
*
|
|
4429
|
+
* @example
|
|
4430
|
+
* ```typescript
|
|
4431
|
+
* // Create standard 9-to-5 business hours
|
|
4432
|
+
* const { schedule } = await voip.createSchedule({
|
|
4433
|
+
* name: 'Standard Business Hours',
|
|
4434
|
+
* timezone: 'America/Chicago',
|
|
4435
|
+
* schedules: [
|
|
4436
|
+
* { day: 'monday', enabled: true, startTime: '09:00', endTime: '17:00' },
|
|
4437
|
+
* { day: 'tuesday', enabled: true, startTime: '09:00', endTime: '17:00' },
|
|
4438
|
+
* { day: 'wednesday', enabled: true, startTime: '09:00', endTime: '17:00' },
|
|
4439
|
+
* { day: 'thursday', enabled: true, startTime: '09:00', endTime: '17:00' },
|
|
4440
|
+
* { day: 'friday', enabled: true, startTime: '09:00', endTime: '17:00' },
|
|
4441
|
+
* ],
|
|
4442
|
+
* holidays: [
|
|
4443
|
+
* { date: '2026-12-25', name: 'Christmas Day' },
|
|
4444
|
+
* { date: '2027-01-01', name: "New Year's Day" },
|
|
4445
|
+
* ],
|
|
4446
|
+
* afterHoursAction: 'voicemail',
|
|
4447
|
+
* afterHoursTarget: '1001',
|
|
4448
|
+
* });
|
|
4449
|
+
* ```
|
|
4450
|
+
*
|
|
4451
|
+
* @see POST /api/schedules
|
|
4452
|
+
*/
|
|
1344
4453
|
async createSchedule(params, options) {
|
|
1345
4454
|
return this.request('POST', '/api/schedules', params, undefined, options);
|
|
1346
4455
|
}
|
|
@@ -1352,14 +4461,93 @@ export class VoIPClient {
|
|
|
1352
4461
|
async deleteSchedule(id) {
|
|
1353
4462
|
return this.request('DELETE', `/api/schedules/${id}`);
|
|
1354
4463
|
}
|
|
1355
|
-
/**
|
|
4464
|
+
/**
|
|
4465
|
+
* Check if a schedule is currently open or closed based on its timezone and current time.
|
|
4466
|
+
*
|
|
4467
|
+
* @description
|
|
4468
|
+
* Evaluates the schedule's day-of-week definitions and holiday list against the
|
|
4469
|
+
* current time in the schedule's timezone. Returns whether the business is
|
|
4470
|
+
* currently "open" (accepting calls normally) or "closed" (after-hours routing active).
|
|
4471
|
+
*
|
|
4472
|
+
* **CRM UI guidance:** Show an "Open" or "Closed" badge on the schedule detail
|
|
4473
|
+
* page and in the schedule list. Use a green badge for "Open", red for "Closed".
|
|
4474
|
+
* Poll this every 60 seconds to keep the status indicator accurate.
|
|
4475
|
+
*
|
|
4476
|
+
* @param id — The UUID of the schedule to check (from `listSchedules()` → `schedules[].id`)
|
|
4477
|
+
*
|
|
4478
|
+
* @returns An object containing:
|
|
4479
|
+
* - `isOpen: boolean` — `true` if current time is within business hours
|
|
4480
|
+
* - `schedule: string` — The schedule name
|
|
4481
|
+
* - `timezone: string` — The schedule's timezone
|
|
4482
|
+
* - `currentTime: string` — Current time in the schedule's timezone (ISO string)
|
|
4483
|
+
*
|
|
4484
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
4485
|
+
* @throws {HttpError} 404 — Schedule not found
|
|
4486
|
+
*
|
|
4487
|
+
* @example
|
|
4488
|
+
* ```typescript
|
|
4489
|
+
* const { isOpen, schedule, currentTime } = await voip.getScheduleStatus('schedule-uuid');
|
|
4490
|
+
*
|
|
4491
|
+
* if (isOpen) {
|
|
4492
|
+
* console.log(`${schedule} is OPEN — ${currentTime}`);
|
|
4493
|
+
* } else {
|
|
4494
|
+
* console.log(`${schedule} is CLOSED — calls go to after-hours routing`);
|
|
4495
|
+
* }
|
|
4496
|
+
*
|
|
4497
|
+
* // CRM UI: show open/closed badge
|
|
4498
|
+
* const { isOpen } = await voip.getScheduleStatus(scheduleId);
|
|
4499
|
+
* if (isOpen) {
|
|
4500
|
+
* showBadge('Open', 'green');
|
|
4501
|
+
* } else {
|
|
4502
|
+
* showBadge('Closed', 'red');
|
|
4503
|
+
* }
|
|
4504
|
+
* ```
|
|
4505
|
+
*
|
|
4506
|
+
* @see GET /api/schedules/{id}/status
|
|
4507
|
+
*/
|
|
1356
4508
|
async getScheduleStatus(id) {
|
|
1357
4509
|
return this.request('GET', `/api/schedules/${id}/status`);
|
|
1358
4510
|
}
|
|
1359
4511
|
// -------------------------------------------------------------------
|
|
1360
4512
|
// SIP Trunks
|
|
1361
4513
|
// -------------------------------------------------------------------
|
|
1362
|
-
/**
|
|
4514
|
+
/**
|
|
4515
|
+
* List all SIP trunks (passwords are masked in the response).
|
|
4516
|
+
*
|
|
4517
|
+
* @description
|
|
4518
|
+
* Returns every SIP trunk configured in the system. Trunks can be:
|
|
4519
|
+
* - **Global** (`tenant_id: null`) — Shared across all tenants for outbound calling
|
|
4520
|
+
* - **Tenant-specific** — Assigned to a single tenant
|
|
4521
|
+
*
|
|
4522
|
+
* Each trunk includes gateway configuration (proxy, username, transport, codec prefs),
|
|
4523
|
+
* registration settings, channel limits, and optional live gateway status from FreeSWITCH.
|
|
4524
|
+
*
|
|
4525
|
+
* For security, SIP passwords are ALWAYS masked in list/get responses.
|
|
4526
|
+
* Only `createTrunk()` returns the raw password (and only if you're a superadmin).
|
|
4527
|
+
*
|
|
4528
|
+
* **CRM UI guidance (superadmin only):** Show as a table with columns: Name,
|
|
4529
|
+
* Provider, Gateway, Status (active/inactive), Tenant (or "Global"), and Actions
|
|
4530
|
+
* (Edit, Sync, Delete). Show a live gateway status indicator (UP/DOWN) if available.
|
|
4531
|
+
*
|
|
4532
|
+
* @returns An object containing:
|
|
4533
|
+
* - `trunks: SipTrunk[]` — Array of SIP trunk objects (passwords masked)
|
|
4534
|
+
*
|
|
4535
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
4536
|
+
* @throws {HttpError} 403 — Not a superadmin
|
|
4537
|
+
*
|
|
4538
|
+
* @example
|
|
4539
|
+
* ```typescript
|
|
4540
|
+
* const { trunks } = await voip.listTrunks();
|
|
4541
|
+
*
|
|
4542
|
+
* trunks.forEach(t => {
|
|
4543
|
+
* const scope = t.tenant_id ? `Tenant: ${t.tenants?.company_name}` : 'Global';
|
|
4544
|
+
* const gwStatus = t.gatewayStatus?.status || 'Unknown';
|
|
4545
|
+
* console.log(`${t.name} | ${t.provider} | ${scope} | Gateway: ${gwStatus}`);
|
|
4546
|
+
* });
|
|
4547
|
+
* ```
|
|
4548
|
+
*
|
|
4549
|
+
* @see GET /api/trunks
|
|
4550
|
+
*/
|
|
1363
4551
|
async listTrunks() {
|
|
1364
4552
|
return this.request('GET', '/api/trunks');
|
|
1365
4553
|
}
|
|
@@ -1406,7 +4594,51 @@ export class VoIPClient {
|
|
|
1406
4594
|
// -------------------------------------------------------------------
|
|
1407
4595
|
// API Keys
|
|
1408
4596
|
// -------------------------------------------------------------------
|
|
1409
|
-
/**
|
|
4597
|
+
/**
|
|
4598
|
+
* List all API keys for the current tenant (admin only).
|
|
4599
|
+
*
|
|
4600
|
+
* @description
|
|
4601
|
+
* Returns every API key created for the tenant. For security, the full key value
|
|
4602
|
+
* is NEVER returned — only a masked preview (e.g. `csk_live_••••••••abcd1234`).
|
|
4603
|
+
* The full key is returned only once during `createApiKey()` or `regenerateApiKey()`.
|
|
4604
|
+
*
|
|
4605
|
+
* Each API key includes:
|
|
4606
|
+
* - `name` — Human-readable label
|
|
4607
|
+
* - `keyPreview` — Masked preview of the key (e.g. `csk_live_••••••••abcd1234`)
|
|
4608
|
+
* - `role` — `'admin'`, `'readonly'`, `'user'`, or `'superadmin'`
|
|
4609
|
+
* - `scopes` — Granular permission scopes
|
|
4610
|
+
* - `lastUsedAt` — Last time the key was used (null if never)
|
|
4611
|
+
* - `expiresAt` — Expiration date (null if never expires)
|
|
4612
|
+
* - `status` — `'active'` or `'revoked'`
|
|
4613
|
+
*
|
|
4614
|
+
* **CRM UI guidance:** Show as a table with columns: Name, Key Preview (masked),
|
|
4615
|
+
* Role, Last Used, Expires, Status (active/revoked badge), and Actions
|
|
4616
|
+
* (Regenerate, Revoke). Show a warning banner if any keys are expiring soon.
|
|
4617
|
+
* Never display full keys in the list — only on create/regenerate modals.
|
|
4618
|
+
*
|
|
4619
|
+
* @returns An object containing:
|
|
4620
|
+
* - `apiKeys: ApiKey[]` — Array of API key objects (keys are masked)
|
|
4621
|
+
*
|
|
4622
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
4623
|
+
* @throws {HttpError} 403 — Not an admin
|
|
4624
|
+
*
|
|
4625
|
+
* @example
|
|
4626
|
+
* ```typescript
|
|
4627
|
+
* const { apiKeys } = await voip.listApiKeys();
|
|
4628
|
+
*
|
|
4629
|
+
* // CRM UI: API key table
|
|
4630
|
+
* apiKeys.forEach(key => {
|
|
4631
|
+
* console.log(`${key.name} | ${key.keyPreview} | ${key.role} | ${key.status}`);
|
|
4632
|
+
*
|
|
4633
|
+
* // Warning: key expires soon
|
|
4634
|
+
* if (key.expiresAt && new Date(key.expiresAt).getTime() - Date.now() < 7 * 86400000) {
|
|
4635
|
+
* showWarning(`Key "${key.name}" expires soon`);
|
|
4636
|
+
* }
|
|
4637
|
+
* });
|
|
4638
|
+
* ```
|
|
4639
|
+
*
|
|
4640
|
+
* @see GET /api/api-keys
|
|
4641
|
+
*/
|
|
1410
4642
|
async listApiKeys() {
|
|
1411
4643
|
return this.request('GET', '/api/api-keys');
|
|
1412
4644
|
}
|
|
@@ -1414,7 +4646,95 @@ export class VoIPClient {
|
|
|
1414
4646
|
async getApiKeyDetail(id) {
|
|
1415
4647
|
return this.request('GET', `/api/api-keys/${id}`);
|
|
1416
4648
|
}
|
|
1417
|
-
/**
|
|
4649
|
+
/**
|
|
4650
|
+
* Create a new API key. The full key value is only returned once in this response.
|
|
4651
|
+
*
|
|
4652
|
+
* @description
|
|
4653
|
+
* Generates a new API key for programmatic access to the VoIP API. The full key
|
|
4654
|
+
* (in the format `csk_live_...`) is returned in the `key` field of the response —
|
|
4655
|
+
* **this is the ONLY time you will see the full key**. Store it immediately in a
|
|
4656
|
+
* secure location (password manager, environment variable, or secrets vault).
|
|
4657
|
+
*
|
|
4658
|
+
* API keys support granular role-based access:
|
|
4659
|
+
* - `'admin'` — Full CRUD access within the tenant
|
|
4660
|
+
* - `'readonly'` — Read-only access (GET endpoints only)
|
|
4661
|
+
* - `'user'` — Extension-level access (make/receive calls)
|
|
4662
|
+
* - `'superadmin'` — Cross-tenant access (superadmin only, requires `global: true`)
|
|
4663
|
+
*
|
|
4664
|
+
* **Superadmin mode:** Set `global: true` to create a global key with no tenant
|
|
4665
|
+
* binding (access to ALL tenants). Pass `tenantId` to create a key for a specific
|
|
4666
|
+
* tenant when authenticated as superadmin.
|
|
4667
|
+
*
|
|
4668
|
+
* **CRM UI guidance:** After creation, show a modal with the full key prominently
|
|
4669
|
+
* displayed: "Copy this key now — it will not be shown again." Include a "Copy"
|
|
4670
|
+
* button and a "I have saved this key" confirmation before closing. Never store
|
|
4671
|
+
* or log the full key client-side.
|
|
4672
|
+
*
|
|
4673
|
+
* @param params — API key creation parameters
|
|
4674
|
+
* @param params.name — Human-readable name for this key. Example: `'CRM Integration Key'`
|
|
4675
|
+
* @param params.role — Access role: `'admin'` (default), `'readonly'`, `'user'`, or `'superadmin'`
|
|
4676
|
+
* @param params.scopes — Optional granular permission scopes. Example: `['calls:read', 'extensions:write']`
|
|
4677
|
+
* @param params.expiresAt — Optional ISO date string when the key expires.
|
|
4678
|
+
* Example: `'2027-01-01T00:00:00Z'`. Omit for no expiry
|
|
4679
|
+
* @param params.global — Superadmin only: create a global key with no tenant binding
|
|
4680
|
+
* @param params.tenantId — Superadmin only: specify the tenant this key belongs to
|
|
4681
|
+
* @param options — Optional request overrides
|
|
4682
|
+
* @param options.tenantId — Target tenant UUID (required for superadmin keys only)
|
|
4683
|
+
*
|
|
4684
|
+
* @returns An object containing:
|
|
4685
|
+
* - `apiKey: ApiKey` — The created API key object. The full key is in `apiKey.key`
|
|
4686
|
+
* (only returned once). Also includes `keyPreview` (masked), `role`, `scopes`,
|
|
4687
|
+
* `expiresAt`, `status`, `createdAt`
|
|
4688
|
+
*
|
|
4689
|
+
* @throws {HttpError} 400 — Invalid role, scope, or duplicate name
|
|
4690
|
+
* @throws {HttpError} 401 — Missing or invalid API key
|
|
4691
|
+
* @throws {HttpError} 403 — Not an admin or trying to create a superadmin key without permission
|
|
4692
|
+
*
|
|
4693
|
+
* @example
|
|
4694
|
+
* ```typescript
|
|
4695
|
+
* // Create an admin key for the CRM integration
|
|
4696
|
+
* const { apiKey } = await voip.createApiKey({
|
|
4697
|
+
* name: 'CRM Integration',
|
|
4698
|
+
* role: 'admin',
|
|
4699
|
+
* });
|
|
4700
|
+
*
|
|
4701
|
+
* console.log(`Full key (save this!): ${apiKey.key}`); // csk_live_xxxx...
|
|
4702
|
+
* console.log(`Masked preview: ${apiKey.keyPreview}`); // csk_live_••••••••xxxx
|
|
4703
|
+
*
|
|
4704
|
+
* // Create a readonly key that expires in 90 days
|
|
4705
|
+
* const expiry = new Date(Date.now() + 90 * 86400000).toISOString();
|
|
4706
|
+
* const { apiKey } = await voip.createApiKey({
|
|
4707
|
+
* name: 'Read-Only Report Key',
|
|
4708
|
+
* role: 'readonly',
|
|
4709
|
+
* scopes: ['calls:read', 'recordings:read'],
|
|
4710
|
+
* expiresAt: expiry,
|
|
4711
|
+
* });
|
|
4712
|
+
*
|
|
4713
|
+
* // Superadmin: create a global key
|
|
4714
|
+
* const { apiKey } = await voip.createApiKey({
|
|
4715
|
+
* name: 'Superadmin Global',
|
|
4716
|
+
* role: 'superadmin',
|
|
4717
|
+
* global: true,
|
|
4718
|
+
* });
|
|
4719
|
+
*
|
|
4720
|
+
* // CRM UI: handle key creation with secret modal
|
|
4721
|
+
* async function handleCreateApiKey(formData: CreateApiKeyParams) {
|
|
4722
|
+
* try {
|
|
4723
|
+
* const { apiKey } = await voip.createApiKey(formData);
|
|
4724
|
+
* showSecretModal({
|
|
4725
|
+
* title: 'API Key Created',
|
|
4726
|
+
* secret: apiKey.key,
|
|
4727
|
+
* message: 'Copy this key now — it will not be shown again.',
|
|
4728
|
+
* });
|
|
4729
|
+
* refreshApiKeyList();
|
|
4730
|
+
* } catch (err) {
|
|
4731
|
+
* showError('Failed to create API key');
|
|
4732
|
+
* }
|
|
4733
|
+
* }
|
|
4734
|
+
* ```
|
|
4735
|
+
*
|
|
4736
|
+
* @see POST /api/api-keys
|
|
4737
|
+
*/
|
|
1418
4738
|
async createApiKey(params, options) {
|
|
1419
4739
|
return this.request('POST', '/api/api-keys', params, undefined, options);
|
|
1420
4740
|
}
|