@cemscale-voip/voip-sdk 2.0.11 → 2.0.12

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/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
- /** Set JWT token (only for browser login flows — most apps should use apiKey) */
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
- /** Login as an extension user */
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
- /** Login as a tenant admin */
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
- /** Login as platform superadmin */
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
- /** Get TURN credentials for WebRTC */
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 filters.
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
- * Each call with a recording includes `cdn_url` (CloudFront, instant playback)
185
- * and `audio_url` (API fallback). Always prefer `cdn_url` when non-null.
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-04-01',
193
- * limit: 50,
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
- * console.log(`${call.caller_id_number} -> ${call.destination}`);
198
- * if (call.cdn_url) {
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
- /** Originate a call via FreeSWITCH. Pass `options.tenantId` when using a global superadmin key. */
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
- /** Hang up an active call */
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
- /** Transfer an active call (blind or attended) */
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
- /** List active calls from FreeSWITCH */
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
- * Resolve a call's FreeSWITCH channel UUID from the current active calls.
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 required for three-way calling operationsthe SDK's WebRTC phone
247
- * uses local SIP.js session IDs internally, but the server needs FreeSWITCH
248
- * channel UUIDs for `addCallParticipant`, `mergeCalls`, and `mergeDirectCalls`.
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
- * **CRM developers:** you can use this function to map your SIP.js session IDs
251
- * to server-side UUIDs when building custom three-way UIs.
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 - The user's extension number (e.g., "1001")
254
- * @param remoteNumber - The remote party's phone number or extension
255
- * @param direction - 'inbound' or 'outbound'
256
- * @returns The FreeSWITCH channel UUID, or `null` if not found
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 connects, resolve the FS UUID for three-way operations
261
- * const fsUuid = await voip.resolveCallFsUuid('1001', '+15551234567', 'outbound');
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
- * await voip.addCallParticipant(fsUuid, { toNumber: '+15559999999' });
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
- /** Get call statistics for a period */
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
- /** Place a call on hold or resume */
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
- /** Park an active call */
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
- * DTMF controls after answering:
312
- * 1 = whisper, 2 = barge, 3 = mute, 0 = toggle mute
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
- /** Export CDR as CSV. Returns raw CSV text. */
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
- * Step 1 Hold the current call and originate a new leg to a third party.
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
- * @param callUuid UUID of the user's active WebRTC session
338
- * @param params `{ toNumber }` for PSTN or `{ toExtension }` for internal
339
- */
340
- async addCallParticipant(callUuid, params, options) {
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
- * Step 2 — Merge two active call legs into a FreeSWITCH conference room.
345
- * All three parties (user + A + B) will hear each other.
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
- * @param params `{ callUuidA, callUuidB, conferenceName? }`
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 go "private" with one participant by removing the other from the
354
- * conference and placing them on hold. Call `mergeCalls()` again to restore.
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
- * @param params `{ conferenceName, keepUuid, holdUuid }`
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
- * Does NOT require `addCallParticipant()` first — works with any two active
364
- * call UUIDs (e.g. from a desk phone where the user held call A and dialed
365
- * call B manually).
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
- * @param params `{ callUuidA, callUuidB, conferenceName? }`
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
- /** List extensions for the current tenant */
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
- /** Get a single extension */
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
- /** Create an extension (also creates Kamailio subscriber). Pass `options.tenantId` when using a global superadmin key. */
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
- /** Update an extension */
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
- /** Delete an extension */
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
- * Returns everything needed to register: extension, password, SIP domain, WebSocket URI.
414
- * Use this so your app never needs to store SIP passwords.
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
- * @param target - Extension UUID, or extension number when `byNumber: true`
417
- * @param options - Optional `{ tenantId }` for superadmin context, plus `{ byNumber: true }` to look up by extension number
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
- * // By UUID (default)
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 (e.g. "1001")
424
- * const { sipCredentials } = await voip.getSipCredentials('1001', { byNumber: true, tenantId: 'tenant-uuid' });
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
- /** List all tenants (superadmin only) */
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
- /** List DID numbers */
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
- * Each recording includes `cdn_url` (CloudFront CDN, instant) and `audio_url` (API fallback).
967
- * Always prefer `cdn_url` when non-null for the fastest playback.
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
- * render(`<audio src="${src}" preload="none" controls />`);
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
- * Returns a response with:
991
- * - `url` CloudFront CDN URL (if in S3) or API URL (local fallback)
992
- * - `source` `'cdn'` (fast, ~5-20ms) or `'local'` (slower, disk stream)
993
- * - `format` `'mp3'` or `'wav'`
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. Use this only when you need to resolve the
997
- * URL programmatically before rendering.
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
- * console.log(`Playing from ${source}: ${url} (${format})`);
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
- * Removes the audio file from:
1013
- * - S3 cloud storage (if uploaded)
1014
- * - Local server disk
1015
- * - Redis cache
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
- * The `recording_url`, `s3_key`, `cdn_url`, and `audio_url` fields for this call
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
- /** List active conferences */
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
- /** Get conference details */
1031
- async getConference(name) {
1032
- return this.request('GET', `/api/conferences/${name}`);
1033
- }
1034
- /** Join a call to a conference */
1035
- async joinConference(conferenceName, callUuid) {
1036
- return this.request('POST', `/api/conferences/${conferenceName}/join`, { callUuid });
1037
- }
1038
- /** Transfer a call to conference */
1039
- async transferToConference(uuid, conferenceName) {
1040
- return this.request('POST', `/api/calls/${uuid}/conference`, conferenceName ? { conferenceName } : undefined);
1041
- }
1042
- /** Kick a member from conference */
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
- /** Mute/unmute a conference member */
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
- /** List ring groups */
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
- /** Create a ring group. Pass `options.tenantId` when using a global superadmin key. */
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
- /** List paging groups */
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
- /** Create a paging group */
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
- /** Broadcast a paging announcement to all online members */
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
- /** List call queues */
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
- /** List webhooks */
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
- /** Create a webhook. Pass `options.tenantId` when using a global superadmin key. */
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
- /** List voicemail messages with pagination */
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
- /** Get voicemail count (total and unread) */
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
- /** Get a single voicemail */
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
- /** Mark voicemail as read */
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
- /** Delete a voicemail (hard delete — removes audio file from disk) */
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
- * Pass `{ detailed: true }` to include call info (callUuid, caller, direction, etc.).
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
- * // Simple presence
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
- * // { '1001': { status: 'on_call', callUuid: '...', caller: '+1555...' } }
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
- /** Set manual presence status for an extension (busy / available / offline) */
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
- /** Get dashboard summary stats */
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
- /** Get calls grouped by day */
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
- /** Get top extensions by call volume */
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
- /** List all blocked numbers for the tenant */
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
- /** Block a phone number. Pass `options.tenantId` when using a global superadmin key. */
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
- /** Check if a specific number is blocked */
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
- /** List business hour schedules */
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
- /** Create a business hours schedule. Pass `options.tenantId` when using a global superadmin key. */
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
- /** Check if a schedule is currently open or closed */
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
- /** List SIP trunks (passwords masked) */
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
- /** List API keys for the current tenant (admin only) */
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
- /** Create a new API key. The full key is only returned once in the response. Pass `options.tenantId` when using a global superadmin key. */
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
  }