diff --git a/README.md b/README.md index 897e244f..92baf218 100644 --- a/README.md +++ b/README.md @@ -641,7 +641,9 @@ pagination. Block and unblock are reversible actions and accept ## Calls The `calls` commands place, answer, join, decline, leave, and end calls, invite -participants, follow a session's calls, and carry call audio: +participants, follow a session's calls, and carry call audio. `calls stats` and +`calls export` report on past calls; see +[Call statistics and export](#call-statistics-and-export). For example: ```shell polymorfa calls place +15550100 --session support --exclusive @@ -775,6 +777,55 @@ sox greeting.wav -t raw -r 16000 -e signed -b 16 -c 1 - | \ --input - --leave-on-eof > reply.pcm ``` +### Call statistics and export + +`calls stats` summarizes calls and `calls export` writes call detail records. +Both accept an organization API key or an exact-project token with the +`sessions:read` scope. Pass `--project` to read one project. Without it, an +organization API key reads every project in the organization. A project token +reads only its own project and requires `--project`. + +```shell +polymorfa calls stats +polymorfa calls stats --project 018f0000-0000-7000-8000-000000000002 \ + --group-by session +polymorfa calls stats --since 2026-09-01T00:00:00Z --until 2026-09-02T00:00:00Z \ + --group-by hour --timezone Europe/Lisbon --heatmap + +polymorfa calls export --since 2026-09-01T00:00:00Z --output calls.csv +polymorfa calls export --format ndjson --outcome missed > missed.ndjson +polymorfa calls export --max-pages 1 --limit 100 +polymorfa calls export --since 2026-09-01T00:00:00Z --cursor \ + --output rest.csv +``` + +Both commands filter with `--session`, `--direction` (`inbound` or +`outbound`), `--upstream` (`linked_device` or `cloud_api`), `--outcome` +(`answered`, `missed`, `declined`, `failed`, or `in_progress`), `--since`, and +`--until` (RFC 3339 times; a call matches by its start time). + +- **Statistics.** The range defaults to the last 7 days. `--group-by` accepts + `day` (the default, up to 366 days), `hour` (up to 31 days), `session`, or + `outcome`. Day and hour groups, and the `--heatmap` grid of calls by day of + week and hour, use `--timezone`, an IANA name such as `Europe/Lisbon` (UTC by + default). Human output prints the totals, with the answer rate as a + percentage and durations as hours, minutes, and seconds, and then one row per + group. `--json` prints the complete response, including the heatmap. Grouping + by session lists at most 500 numbers; the totals still count every call. +- **Export.** `--format` is `csv` (the default) or `ndjson`. The command requests + pages of `--limit` records (1 to 1,000; default 1,000), newest first, until + none remain, and writes one CSV header. Records go to standard output, or to + the `--output` file, which must not exist unless you pass `--force`. The file + is created, or emptied with `--force`, when the first page arrives, even if + no records match. Progress, + the final summary, and the next cursor go to standard error, as JSON lines + with `--json`. `--max-pages` stops early and prints the cursor for the rest; + `--cursor` resumes from it with the same filters. If a page fails, the + command keeps the records already written, prints the cursor that resumes + from the failed page, and exits with the API error's exit code. The other + party appears only as `peerRef`, a team-specific pseudonym, never a phone + number. + ### SIP trunks SIP trunks connect a PBX to WhatsApp calls. They are part of Calls and need no diff --git a/docs/command-research/calls.md b/docs/command-research/calls.md index e4f47d17..a26b0485 100644 --- a/docs/command-research/calls.md +++ b/docs/command-research/calls.md @@ -1,7 +1,7 @@ # Calls command research - **Status:** Implemented against Calls contract revision 1, unpublished -- **Reviewed:** 2026-09-17 +- **Reviewed:** 2026-09-19 - **SDK baseline:** `@polymorfa/sdk` (including the `@polymorfa/sdk/calls` subpath) `0.1.0-dev.20260922115749` from npm, published from `polymorfa/sdks` commit `8bc00778b3b3aaf05b09369c3f7840eb24cf6a56` by @@ -21,6 +21,11 @@ `session` and `participant` query parameters, 60-second re-validation, and the 4401, 4409, 4429, 4400, and 1013 close codes. - The public guide for Calls. +- Platform operations `getCallStats` (`GET /platform/calls/stats`) and + `exportCallRecords` (`GET /platform/calls/export`) from the management + OpenAPI document at `polymorfa/polymorfa` commit + `4b75534812dc25e839fda3c2ba231e46afe39534`, and the export route's + `Polymorfa-Next-Cursor` header and CSV encoding. - `MessagingClient.voip` and the public `@polymorfa/sdk/calls` entry point (`CallsClient`, `Call`, and the Calls error classes). @@ -37,6 +42,11 @@ calls watch [--session] [--participant] [--count ] media [callId] [--session] [--place ] [--exclusive] [--participant] [--input ] [--output ] [--leave-on-eof] [--video] + stats [--session] [--direction] [--upstream] [--outcome] [--since] [--until] + [--group-by day|hour|session|outcome] [--timezone ] [--heatmap] + export [--session] [--direction] [--upstream] [--outcome] [--since] [--until] + [--format csv|ndjson] [--output ] [--force] [--limit <1-1000>] + [--cursor ] [--max-pages ] settings get --session set --session [--[no-]conference-mode] [--calls-enabled=] ... @@ -54,6 +64,8 @@ calls | `calls invite` | `POST /messaging/voip/calls/{id}/participants` | `voip.addParticipant` | | `calls settings get` | `GET /platform/sessions/{session}/call-settings` | `voip.retrieveCallSettings` | | `calls settings set` | `PUT /platform/sessions/{session}/call-settings` | `voip.updateCallSettings` | +| `calls stats` | `GET /platform/calls/stats` | `calls.stats` | +| `calls export` | `GET /platform/calls/export` | `calls.export` | | `calls retention get` | `GET /platform/call-retention` | `callRetention.retrieve` | | `calls retention set` | `PUT /platform/call-retention` | `callRetention.update` | | `sip-trunk endpoint` | `GET /platform/sip/endpoint` | `sipTrunks.endpoint` | @@ -95,6 +107,11 @@ internal SDK module. Messaging client; the CLI builds that client from the Platform environment. `calls settings set --expected-revision` sends `expectedRevision`; a stale revision returns 409 `state_conflict`, which exits with code 4. +- `calls stats` and `calls export` accept an organization API key or an + exact-project token (`sessions:read`), resolved like the developer + commands. `--project` (or a project token, which requires it) reads one + project; an organization key without `--project` reads the whole + organization. `--owner` still overrides the inferred owner. - `calls retention` resolves a team API key or a project token (`resolveTeamSettingEnvironment`), from the environment or a profile, with no project context, and the API decides what each principal may do. @@ -153,10 +170,31 @@ internal SDK module. newer). On older runtimes they fail with a configuration error before connecting. The CLI as a whole requires Node.js 22 or newer. +- **Call statistics.** Filters and range limits (366 days for `day`, 31 for + `hour`, IANA `--timezone`) are checked before the request; the API default + range of 7 days applies when `--since` or `--until` is omitted. Human output + prints totals, a group table, and, with `--heatmap`, a 7-by-24 grid. `--json` + prints the standard read envelope with the complete response. +- **Call export.** The command follows `Polymorfa-Next-Cursor` until it is + absent and writes the CSV header from the first page only; a later page with + different columns, or a repeated cursor, stops the export with a server + error. Records go to standard output or `--output`, which is created on the + first page (even an empty one, so a zero-record export truncates a + `--force` target) and refused when it exists unless `--force`. Progress, the + summary, and resume cursors go to standard error only (JSON lines with + `--json`). `--cursor` is sent with the filters because the API expects the + same filters on every page. A failed page keeps earlier records, reports + the cursor for the failed page, and exits with the API error's exit code. +- **SDK publication gate.** `src/services/call-analytics.ts` uses typed + `calls.stats` and `calls.export`. The published SDK pin in `package.json` + predates those methods. Verify this branch with the local SDK package until + the exact analytics SDK revision is published, then pin that version before + merging the CLI change. + ## Explicit gaps -- No call list, retrieve, or history command: the public contract has no such - customer operation. +- No call list or retrieve command. `GET /platform/calls` exists; `calls +export --format ndjson` covers the same records for scripts. - `calls media` does not attach to a call that was already ringing or answered before it started, and carries no video. - `calls watch` does not report outbound calls placed by other participants. diff --git a/package-lock.json b/package-lock.json index aafbe4db..4bd54b5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20260923115544", + "@polymorfa/sdk": "0.1.0-dev.20260923124417", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, @@ -997,9 +997,9 @@ } }, "node_modules/@polymorfa/sdk": { - "version": "0.1.0-dev.20260923115544", - "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20260923115544.tgz", - "integrity": "sha512-ctfN+1mQ+H3yRzZMuvX8e/N/gpRP2ZA2eMO54T0hu++HL29SzRuMaAPXPRKMlYcW8iyrn0h4i1Xk/3zA1qBYAg==", + "version": "0.1.0-dev.20260923124417", + "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20260923124417.tgz", + "integrity": "sha512-mwcZVuYgFmLJeTSMT12I4YhFFUVKrdXzO+N4LEPAVNIstIxXiC8K8AB4eEmMIzOsShVndcINyj/BZfoTahdPlw==", "license": "MIT", "engines": { "node": ">=20" diff --git a/package.json b/package.json index 3196f0bd..fefad1fb 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20260923115544", + "@polymorfa/sdk": "0.1.0-dev.20260923124417", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, @@ -106,7 +106,7 @@ "description": "Reject incoming calls through the Linked Device session route." }, "calls": { - "description": "Place, answer, join, decline, leave, end, and watch calls, and carry call audio." + "description": "Place, answer, join, decline, leave, end, and watch calls, carry call audio, and report on past calls." }, "calls:retention": { "description": "Inspect and change how long Polymorfa keeps your team's call data." diff --git a/skills/polymorfa/SKILL.md b/skills/polymorfa/SKILL.md index d7d00122..d3e28ddf 100644 --- a/skills/polymorfa/SKILL.md +++ b/skills/polymorfa/SKILL.md @@ -24,6 +24,8 @@ Use `polymorfa quicklink create --project PROJECT --input request.json` with an - Use `polymorfa mcp serve` for an agent that needs structured tools. Inspect the exposed surface with `polymorfa mcp tools` and diagnose setup with `polymorfa mcp doctor`. - MCP `listen` start/status/stop controls streams owned by that MCP server process. It does not create a detached daemon. +Commands that stream data, such as `polymorfa calls export`, write records to standard output and progress, summaries, and resume cursors to standard error. Keep the streams separate and resume with the reported `--cursor` and the same filters. + Use `--json` when another program consumes the result. Treat `schemaVersion`, `context`, `request`, pagination metadata, mutation receipts, and symbolic errors as the stable automation contract. Do not scrape human tables or prose. ## Mutate safely diff --git a/src/base-command.ts b/src/base-command.ts index b59510fb..6d62a79c 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -7,8 +7,10 @@ import { import { resolveCommandEnvironment, + resolveDeveloperEnvironment, resolveListenerEnvironment, resolveTeamSettingEnvironment, + type DeveloperEnvironment, type TeamSettingEnvironment, type ResolvedEnvironment, } from './context/environment.js' @@ -99,6 +101,34 @@ export abstract class BaseCommand extends Command { return session } + /** + * Resolve an organization API key or exact-project token without building a + * typed client, for commands that span projects. With `inferOwner`, --project + * or a project token reads one project and an organization key otherwise + * reads the whole organization. + */ + protected async developerEnvironment( + flags: GlobalFlags, + options: {readonly inferOwner?: boolean} = {}, + ): Promise { + const environment = await resolveDeveloperEnvironment({ + env: process.env, + ...(flags['api-version'] === undefined + ? {} + : {apiVersion: flags['api-version']}), + ...(options.inferOwner === undefined + ? {} + : {inferOwner: options.inferOwner}), + ...(flags.owner === undefined ? {} : {owner: flags.owner}), + ...(flags.profile === undefined ? {} : {profile: flags.profile}), + ...(flags.project === undefined ? {} : {project: flags.project}), + ...(flags.timeout === undefined ? {} : {timeoutMs: flags.timeout}), + }) + this.registerSecret(environment.credential) + this.errorContext = environment.context + return environment + } + protected async messagingClient( flags: GlobalFlags, options: {readonly serverApiKeyOnly?: string} = {}, diff --git a/src/commands/calls/export.ts b/src/commands/calls/export.ts new file mode 100644 index 00000000..10a74e73 --- /dev/null +++ b/src/commands/calls/export.ts @@ -0,0 +1,210 @@ +import {Flags} from '@oclif/core' +import { + PolymorfaConflictError, + PolymorfaConfigurationError, +} from '@polymorfa/sdk' +import {once} from 'node:events' +import {open, stat, type FileHandle} from 'node:fs/promises' +import {resolve} from 'node:path' + +import {BaseCommand} from '../../base-command.js' +import {callFilterFlags} from '../../developer/flags.js' +import { + CALL_EXPORT_FORMATS, + CallAnalyticsApi, + callFilterQuery, + exportCallRecords, + projectQuery, + type CallExportFormat, + type ExportSummary, +} from '../../services/call-analytics.js' + +export default class CallsExport extends BaseCommand { + static override description = `Export call detail records as CSV or newline-delimited JSON, newest first. + +Each record has callId, projectId, sessionId, direction, upstream, outcome, state, hasVideo, peerRef, startedAt, connectedAt, endedAt, durationSeconds, and endReason. peerRef is a team-specific pseudonym of the other party, never a phone number. + +The command requests every page until no records remain and writes one CSV header. Records go to standard output, or to --output. Progress and the next cursor go to standard error, as JSON lines with --json. --max-pages stops early; resume with --cursor and the same filters. When a page fails, the command reports the cursor that resumes from that page and exits non-zero. + +Pass --project to read one project. Without it, an organization API key reads every project in the organization. A project token reads only its own project and requires --project. Credentials need the sessions:read scope.` + + static override examples = [ + '<%= config.bin %> calls export --since 2026-09-01T00:00:00Z --output calls.csv', + '<%= config.bin %> calls export --project 018f0000-0000-7000-8000-000000000002 --format ndjson > calls.ndjson', + '<%= config.bin %> calls export --outcome missed --max-pages 1 --limit 100', + '<%= config.bin %> calls export --since 2026-09-01T00:00:00Z --cursor --output rest.csv', + ] + + static override flags = { + ...callFilterFlags, + cursor: Flags.string({ + description: + 'Continue from this cursor, reported by an earlier export. Pass the same filters.', + }), + force: Flags.boolean({ + description: 'Overwrite an existing --output file.', + }), + format: Flags.string({ + default: 'csv', + description: 'Write CSV (one header row) or newline-delimited JSON.', + options: [...CALL_EXPORT_FORMATS], + }), + limit: Flags.integer({ + default: 1000, + description: 'Request this many records per page.', + max: 1000, + min: 1, + }), + 'max-pages': Flags.integer({ + description: + 'Stop after this many pages and report the cursor for the rest.', + min: 1, + }), + output: Flags.string({ + char: 'o', + description: + 'Write records to this file instead of standard output. Use - for standard output.', + }), + } + + static override summary = 'Export call detail records.' + + async run(): Promise { + const {flags} = await this.parse(CallsExport) + const cursor = flags.cursor?.trim() + if (cursor !== undefined && (cursor.length === 0 || cursor.length > 256)) { + throw new PolymorfaConfigurationError( + '--cursor must be 1 to 256 characters.', + 'cursor', + ) + } + const query = callFilterQuery(flags) + const format = flags.format as CallExportFormat + const outputPath = + flags.output === undefined || flags.output === '-' + ? undefined + : resolve(flags.output) + // Refuse an existing file before any request (resolving a project slug + // is one), but create it only when the first page arrives, so a failed + // request leaves no empty file. + if (outputPath !== undefined && !flags.force) + await rejectExisting(outputPath) + + const environment = await this.developerEnvironment(flags, { + inferOwner: true, + }) + const api = new CallAnalyticsApi(environment) + + const mode = this.outputMode(flags) + const report = (value: object, human: string): void => { + if (mode === 'json') process.stderr.write(`${JSON.stringify(value)}\n`) + else if (mode === 'human') process.stderr.write(`${human}\n`) + } + let file: FileHandle | undefined + const destination = outputPath ?? 'standard output' + const write = + outputPath === undefined + ? writeStandardOutput + : async (chunk: string) => { + file ??= await openOutput(outputPath, flags.force) + await writeFully(file, chunk) + } + + let summary: ExportSummary + try { + summary = await exportCallRecords({ + api, + cursor, + format, + limit: flags.limit, + maxPages: flags['max-pages'], + onPage: (progress) => + report( + {type: 'page', ...progress}, + `Page ${progress.page}: ${count(progress.records, 'record')} (${progress.totalRecords} total).`, + ), + onStop: (stopped) => + report( + {type: 'stopped', destination, ...stopped}, + stopped.nextCursor === null + ? `Stopped after ${count(stopped.pages, 'page')} and ${count(stopped.records, 'record')}. Run the export again with the same filters.` + : `Stopped after ${count(stopped.pages, 'page')} and ${count(stopped.records, 'record')}. Resume with --cursor ${stopped.nextCursor} and the same filters.`, + ), + query: {...query, ...projectQuery(environment.context)}, + write, + }) + } finally { + await file?.close() + } + + report( + {type: 'done', destination, ...summary}, + summary.complete + ? `Exported ${count(summary.records, 'record')} in ${count(summary.pages, 'page')} to ${destination}.` + : `Exported ${count(summary.records, 'record')} in ${count(summary.pages, 'page')} to ${destination}. More records remain; continue with --cursor ${summary.nextCursor} and the same filters.`, + ) + } +} + +/** + * Writes the whole chunk. A low-level write may persist fewer bytes than it + * was given; keep writing the remainder so no record is silently dropped. + */ +export async function writeFully( + file: Pick, + chunk: string, +): Promise { + const buffer = Buffer.from(chunk, 'utf8') + let offset = 0 + while (offset < buffer.length) { + const {bytesWritten} = await file.write( + buffer, + offset, + buffer.length - offset, + ) + if (bytesWritten <= 0) { + throw new PolymorfaConfigurationError( + 'Could not write the export to the output file.', + 'output', + ) + } + offset += bytesWritten + } +} + +async function rejectExisting(path: string): Promise { + try { + await stat(path) + } catch (error) { + if ((error as {code?: string}).code === 'ENOENT') return + throw error + } + throw outputExists(path) +} + +function outputExists(path: string): PolymorfaConflictError { + return new PolymorfaConflictError( + `Output file ${path} already exists. Pass --force to overwrite it.`, + ) +} + +async function openOutput(path: string, force: boolean): Promise { + try { + return await open(path, force ? 'w' : 'wx') + } catch (error) { + if ((error as {code?: string}).code === 'EEXIST') throw outputExists(path) + throw new PolymorfaConfigurationError( + `Could not open ${path} for writing: ${error instanceof Error ? error.message : String(error)}`, + 'output', + ) + } +} + +async function writeStandardOutput(chunk: string): Promise { + // Wait for the pipe to drain so a slow reader bounds memory use. + if (!process.stdout.write(chunk)) await once(process.stdout, 'drain') +} + +function count(value: number, noun: string): string { + return `${value} ${noun}${value === 1 ? '' : 's'}` +} diff --git a/src/commands/calls/stats.ts b/src/commands/calls/stats.ts new file mode 100644 index 00000000..f40d1eef --- /dev/null +++ b/src/commands/calls/stats.ts @@ -0,0 +1,74 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {callFilterFlags} from '../../developer/flags.js' +import {developerRead} from '../../developer/output.js' +import { + CALL_STATS_GROUP_BY, + CallAnalyticsApi, + callStatsQuery, + projectQuery, + renderCallStats, + type CallStatsGroupBy, +} from '../../services/call-analytics.js' + +export default class CallsStats extends BaseCommand { + static override description = `Summarize calls: counts by outcome, answer rate, and connected time, grouped by day, hour, session, or outcome. + +The range defaults to the last 7 days. --group-by day covers at most 366 days and --group-by hour at most 31. Day and hour buckets, and the heatmap, use --timezone (UTC by default). + +Pass --project to read one project. Without it, an organization API key reads every project in the organization. A project token reads only its own project and requires --project. Credentials need the sessions:read scope.` + + static override examples = [ + '<%= config.bin %> calls stats', + '<%= config.bin %> calls stats --project 018f0000-0000-7000-8000-000000000002 --group-by session', + '<%= config.bin %> calls stats --since 2026-09-01T00:00:00Z --until 2026-09-02T00:00:00Z --group-by hour --timezone Europe/Lisbon', + '<%= config.bin %> calls stats --direction inbound --outcome missed --heatmap', + '<%= config.bin %> calls stats --json', + ] + + static override flags = { + ...callFilterFlags, + 'group-by': Flags.string({ + default: 'day', + description: 'Group the results by day, hour, session, or outcome.', + options: [...CALL_STATS_GROUP_BY], + }), + heatmap: Flags.boolean({ + description: + 'Also print calls by day of week and hour. --json always includes the heatmap.', + }), + timezone: Flags.string({ + description: + 'IANA time zone for day and hour groups and the heatmap, such as Europe/Lisbon. UTC offsets are not accepted.', + }), + } + + static override summary = 'Summarize call volume, outcomes, and duration.' + + async run(): Promise { + const {flags} = await this.parse(CallsStats) + const query = callStatsQuery({ + direction: flags.direction, + groupBy: flags['group-by'] as CallStatsGroupBy, + outcome: flags.outcome, + session: flags.session, + since: flags.since, + timezone: flags.timezone, + until: flags.until, + upstream: flags.upstream, + }) + const environment = await this.developerEnvironment(flags, { + inferOwner: true, + }) + const result = await new CallAnalyticsApi(environment).stats({ + ...query, + ...projectQuery(environment.context), + }) + this.writeValue( + developerRead(result.data, result.metadata, environment.context), + renderCallStats(result.data, {heatmap: flags.heatmap}), + flags, + ) + } +} diff --git a/src/context/environment.ts b/src/context/environment.ts index ece2b08d..1430b2e8 100644 --- a/src/context/environment.ts +++ b/src/context/environment.ts @@ -68,6 +68,13 @@ export interface DeveloperEnvironment { export interface ResolveDeveloperEnvironmentInput { readonly apiVersion?: string | undefined readonly env: Readonly> + /** + * Choose the owner when neither --owner nor the profile names one: a + * project context (--project, or a project token) reads one project, and + * an organization key otherwise reads the whole organization. For commands + * whose results span projects rather than belong to one owner. + */ + readonly inferOwner?: boolean | undefined /** Project from the nearest `polymorfa link` file; below --project. */ readonly linkedProject?: string | undefined readonly owner?: string | undefined @@ -97,12 +104,22 @@ export async function resolveDeveloperEnvironment( if (explicitProfile === undefined && environmentCredential !== undefined) { const baseUrl = nonEmpty(input.env.POLYMORFA_BASE_URL) ?? 'https://api.polymorfa.com' + const explicitProject = nonEmpty(input.project) + const environmentOwner = + input.owner ?? + (input.inferOwner === true + ? inferredOwner( + environmentCredential.principalType, + explicitProject !== undefined, + ) + : undefined) const requestedProject = - nonEmpty(input.project) ?? - (input.owner === 'project' ? nonEmpty(input.linkedProject) : undefined) + environmentOwner === 'project' + ? (explicitProject ?? nonEmpty(input.linkedProject)) + : explicitProject const unresolvedContext = resolveOwnerContext({ principalType: environmentCredential.principalType, - ...(input.owner === undefined ? {} : {owner: input.owner}), + ...(environmentOwner === undefined ? {} : {owner: environmentOwner}), ...(requestedProject === undefined ? {} : {projectId: requestedProject}), }) const projectId = await canonicalProjectId( @@ -163,8 +180,12 @@ export async function resolveDeveloperEnvironment( profile.credential.kind === 'projectToken' ? 'project_token' : 'organization_key' - const owner = input.owner ?? profile.owner const explicitProject = nonEmpty(input.project) + const owner = + input.owner ?? + (input.inferOwner === true + ? inferredOwner(principalType, explicitProject !== undefined) + : profile.owner) if ( principalType === 'project_token' && explicitProject !== undefined && @@ -672,6 +693,15 @@ export async function listProjects( return response.data.data } +function inferredOwner( + principalType: 'organization_key' | 'project_token', + hasProject: boolean, +): 'organization' | 'project' { + return principalType === 'project_token' || hasProject + ? 'project' + : 'organization' +} + function requiredProjectId(projectId: string | undefined): string { if (projectId !== undefined) return projectId throw new PolymorfaConfigurationError( diff --git a/src/developer/flags.ts b/src/developer/flags.ts index 5b1c3518..e5e9ad6f 100644 --- a/src/developer/flags.ts +++ b/src/developer/flags.ts @@ -27,3 +27,29 @@ export const idempotencyFlag = Flags.string({ export const yesFlag = Flags.boolean({ description: 'Confirm the external effect without prompting.', }) + +/** Filters shared by the call statistics and call export commands. */ +export const callFilterFlags = { + direction: Flags.string({ + description: 'Include only inbound or outbound calls.', + options: ['inbound', 'outbound'], + }), + outcome: Flags.string({ + description: + 'Include only calls with this outcome. answered includes answered calls still in progress; in_progress means still ringing.', + options: ['answered', 'missed', 'declined', 'failed', 'in_progress'], + }), + session: Flags.string({ + description: 'Include only calls on this session (number).', + }), + since: Flags.string({ + description: 'Include calls that started at or after this RFC 3339 time.', + }), + until: Flags.string({ + description: 'Include calls that started before this RFC 3339 time.', + }), + upstream: Flags.string({ + description: 'Include only numbers that connect this way.', + options: ['linked_device', 'cloud_api'], + }), +} diff --git a/src/services/call-analytics.ts b/src/services/call-analytics.ts new file mode 100644 index 00000000..b9cef7df --- /dev/null +++ b/src/services/call-analytics.ts @@ -0,0 +1,495 @@ +import { + Client, + PolymorfaServerError, + PolymorfaValidationError, + type CallStats, + type CallStatsHeatmapCell, + type PlatformCallsResource, + type ResponseMetadata, +} from '@polymorfa/sdk' + +import type {DeveloperEnvironment} from '../context/environment.js' +import {assertCursorAdvanced} from '../developer/support.js' +import {terminalText} from '../developer/render.js' +import {optionalTimeRange} from '../input/time.js' +import type {OutputContext} from '../output/contracts.js' + +// Call statistics and call detail record export (`GET /platform/calls/stats` +// and `GET /platform/calls/export`). + +export const CALL_DIRECTIONS = ['inbound', 'outbound'] as const +export const CALL_UPSTREAMS = ['linked_device', 'cloud_api'] as const +export const CALL_OUTCOMES = [ + 'answered', + 'missed', + 'declined', + 'failed', + 'in_progress', +] as const +export const CALL_STATS_GROUP_BY = [ + 'day', + 'hour', + 'session', + 'outcome', +] as const +export const CALL_EXPORT_FORMATS = ['csv', 'ndjson'] as const + +export type CallStatsGroupBy = (typeof CALL_STATS_GROUP_BY)[number] +export type CallExportFormat = (typeof CALL_EXPORT_FORMATS)[number] + +const DAY_MS = 86_400_000 +const MAX_RANGE_DAYS: Readonly> = { + day: 366, + hour: 31, + outcome: undefined, + session: undefined, +} +/** + * An IANA time zone name the runtime recognizes, such as UTC, CET or + * Europe/Lisbon. Offsets such as +03:00 are refused: the API reads their sign + * inverted. The API checks the name against its own time zone database. + */ +export function isTimeZoneName(value: string): boolean { + if (value.length > 64 || !/^[A-Za-z][A-Za-z0-9_+\-/]*$/.test(value)) + return false + try { + new Intl.DateTimeFormat('en', {timeZone: value}) + return true + } catch { + return false + } +} + +export interface CallFilterInput { + readonly direction?: string | undefined + readonly outcome?: string | undefined + readonly session?: string | undefined + readonly since?: string | undefined + readonly until?: string | undefined + readonly upstream?: string | undefined +} + +export interface CallExportPage { + readonly body: string + readonly metadata: ResponseMetadata + readonly nextCursor: string | null +} + +type Query = Readonly> + +export class CallAnalyticsApi { + private readonly calls: + PlatformCallsResource<'organization'> | PlatformCallsResource<'project'> + + constructor( + environment: DeveloperEnvironment, + fetchImplementation: typeof globalThis.fetch = globalThis.fetch, + ) { + const options = { + baseUrl: environment.baseUrl, + fetch: fetchImplementation, + ...(environment.apiVersion === undefined + ? {} + : {apiVersion: environment.apiVersion}), + ...(environment.timeoutMs === undefined + ? {} + : {timeoutMs: environment.timeoutMs}), + } + if (environment.context.principalType === 'project_token') { + const projectId = environment.context.projectId + if (!projectId) throw invalid('A project token requires --project.') + this.calls = new Client({ + ...options, + credential: {type: 'projectToken', value: environment.credential}, + projectId, + }).calls + } else { + const root = new Client({ + ...options, + credential: {type: 'organizationApiKey', value: environment.credential}, + }) + this.calls = + environment.context.owner === 'project' && environment.context.projectId + ? root.project(environment.context.projectId).calls + : root.calls + } + } + + async stats( + query: Query, + ): Promise<{readonly data: CallStats; readonly metadata: ResponseMetadata}> { + const response = await this.calls.stats(query) + return {data: statsData(response.data), metadata: response.metadata} + } + + async exportPage( + query: Query, + format: CallExportFormat, + ): Promise { + const response = await this.calls.export({...query, format}) + const contentType = response.metadata.headers['content-type'] + const expected = format === 'csv' ? 'text/csv' : 'application/x-ndjson' + if ( + contentType !== undefined && + contentType.split(';', 1)[0]?.trim().toLowerCase() !== expected + ) { + throw new PolymorfaServerError( + `The call export did not return ${format === 'csv' ? 'CSV' : 'NDJSON'}.`, + {code: 'call_export_invalid_response'}, + ) + } + return { + body: response.data.body, + metadata: response.metadata, + nextCursor: response.data.nextCursor, + } + } +} + +// ── Queries ──────────────────────────────────────────────────────────────── + +export function callFilterQuery(input: CallFilterInput): Query { + const time = optionalTimeRange(input.since, input.until) + const session = input.session?.trim() + if (session !== undefined && (session.length === 0 || session.length > 128)) { + throw invalid('--session must be 1 to 128 characters.') + } + return { + ...(session === undefined ? {} : {sessionId: session}), + ...(input.direction === undefined ? {} : {direction: input.direction}), + ...(input.upstream === undefined ? {} : {upstream: input.upstream}), + ...(input.outcome === undefined ? {} : {outcome: input.outcome}), + ...time, + } +} + +/** The project filter for the resolved context; none for the organization. */ +export function projectQuery(context: OutputContext): Query { + return context.owner === 'project' && context.projectId + ? {projectId: context.projectId} + : {} +} + +export function callStatsQuery( + input: CallFilterInput & { + readonly groupBy: CallStatsGroupBy + readonly timezone?: string | undefined + }, +): Query { + const filters = callFilterQuery(input) + const timezone = input.timezone?.trim() + if (timezone !== undefined && !isTimeZoneName(timezone)) { + throw invalid( + '--timezone must be an IANA time zone name such as UTC, CET or Europe/Lisbon.', + ) + } + const maxDays = MAX_RANGE_DAYS[input.groupBy] + if ( + maxDays !== undefined && + input.since !== undefined && + input.until !== undefined && + Date.parse(input.until) - Date.parse(input.since) > maxDays * DAY_MS + ) { + throw invalid( + `--group-by ${input.groupBy} covers at most ${maxDays} days; narrow --since and --until.`, + ) + } + return { + ...filters, + groupBy: input.groupBy, + ...(timezone === undefined ? {} : {timezone}), + } +} + +// ── Export ───────────────────────────────────────────────────────────────── + +export interface ExportProgress { + readonly page: number + readonly records: number + readonly totalRecords: number + readonly nextCursor: string | null +} + +export interface ExportSummary { + readonly complete: boolean + readonly nextCursor: string | null + readonly pages: number + readonly records: number +} + +/** + * Request export pages in order and hand each one to `write`. A CSV header is + * written once, from the first page; later pages must repeat it exactly. The + * first page is always written, even when empty, so the destination reflects a + * zero-record export. When a + * page fetch or validation fails, `onStop` receives the progress so far and + * the cursor that retries that page. A failed write may leave partial output, + * so it never advertises a cursor as safe to append from. + */ +export async function exportCallRecords(input: { + readonly api: Pick + readonly cursor?: string | undefined + readonly format: CallExportFormat + readonly limit: number + readonly maxPages?: number | undefined + readonly onPage?: (progress: ExportProgress) => void + readonly onStop?: (summary: ExportSummary) => void + readonly query: Query + readonly write: (chunk: string) => Promise +}): Promise { + let cursor = input.cursor + const seenCursors = new Set(cursor === undefined ? [] : [cursor]) + let header: string | undefined + let pages = 0 + let records = 0 + const stopped = (error: unknown, resumeSafe = true): never => { + input.onStop?.({ + complete: false, + nextCursor: resumeSafe ? (cursor ?? null) : null, + pages, + records, + }) + throw error + } + for (;;) { + let page: CallExportPage + try { + page = await input.api.exportPage( + { + ...input.query, + limit: input.limit, + ...(cursor === undefined ? {} : {cursor}), + }, + input.format, + ) + if (page.nextCursor !== null) { + assertCursorAdvanced(cursor, page.nextCursor) + if (seenCursors.has(page.nextCursor)) { + throw new PolymorfaServerError( + 'The API returned an earlier continuation cursor for the call export.', + {code: 'pagination_cursor_repeated'}, + ) + } + seenCursors.add(page.nextCursor) + } + } catch (error) { + return stopped(error) + } + let chunk: string + let count: number + if (input.format === 'csv') { + let split: ReturnType + try { + split = splitCsvPage(page.body) + if (header !== undefined && split.header !== header) { + throw new PolymorfaServerError( + 'The call export changed its CSV columns between pages.', + {code: 'call_export_header_changed'}, + ) + } + } catch (error) { + return stopped(error) + } + header ??= split.header + chunk = pages === 0 ? split.header + split.rows : split.rows + count = split.count + } else { + chunk = page.body + count = countLines(page.body) + } + // Always hand the first page to `write`, even when it is empty, so an + // export with no matching records still creates or truncates the output. + if (chunk.length > 0 || pages === 0) { + try { + await input.write(chunk) + } catch (error) { + return stopped(error, false) + } + } + pages += 1 + records += count + input.onPage?.({ + nextCursor: page.nextCursor, + page: pages, + records: count, + totalRecords: records, + }) + if (page.nextCursor === null) { + return {complete: true, nextCursor: null, pages, records} + } + cursor = page.nextCursor + if (input.maxPages !== undefined && pages >= input.maxPages) { + return {complete: false, nextCursor: cursor, pages, records} + } + } +} + +/** Split a CSV page into its header line and data rows (CRLF endings). */ +export function splitCsvPage(body: string): { + readonly count: number + readonly header: string + readonly rows: string +} { + const end = body.indexOf('\r\n') + if (end < 0) { + if (body.length === 0) { + throw new PolymorfaServerError( + 'The call export returned an empty page.', + { + code: 'call_export_invalid_response', + }, + ) + } + return {count: 0, header: `${body}\r\n`, rows: ''} + } + const rows = body.slice(end + 2) + return {count: countCsvRows(rows), header: body.slice(0, end + 2), rows} +} + +/** Count CSV records, ignoring line breaks inside quoted fields. */ +function countCsvRows(rows: string): number { + let count = 0 + let quoted = false + for (let index = 0; index < rows.length; index += 1) { + const character = rows[index] + if (character === '"') quoted = !quoted + else if (character === '\n' && !quoted) count += 1 + } + if (rows.length > 0 && !rows.endsWith('\n')) count += 1 + return count +} + +function countLines(body: string): number { + return body.split('\n').filter((line) => line.trim().length > 0).length +} + +// ── Rendering ────────────────────────────────────────────────────────────── + +const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as const + +export function renderCallStats( + stats: CallStats, + options: {readonly heatmap?: boolean} = {}, +): string { + const totals = stats.totals + const sections = [ + [ + `Range\t${terminalText(stats.since)} to ${terminalText(stats.until)}`, + `Time zone\t${terminalText(stats.timezone)}`, + `Calls\t${totals.calls}`, + `Answered\t${totals.answered}`, + `Missed\t${totals.missed}`, + `Declined\t${totals.declined}`, + `Failed\t${totals.failed}`, + `In progress\t${totals.inProgress}`, + `Answer rate\t${percent(totals.answerRate)}`, + `Talk time\t${duration(totals.totalDurationSeconds)}`, + `Average call\t${duration(totals.averageDurationSeconds)}`, + ].join('\n'), + renderGroups(stats), + ] + if (options.heatmap === true) sections.push(renderHeatmap(stats.heatmap)) + return sections.join('\n\n') +} + +function renderGroups(stats: CallStats): string { + const label = { + day: 'Day', + hour: 'Hour', + outcome: 'Outcome', + session: 'Session', + }[stats.groupBy] + if (stats.groups.length === 0) return `No calls in this range.` + const lines = [ + [ + label, + 'Calls', + 'Answered', + 'Missed', + 'Declined', + 'Failed', + 'In progress', + 'Answer rate', + 'Talk time', + 'Average', + ].join('\t'), + ...stats.groups.map((group) => + [ + terminalText(group.key), + group.calls, + group.answered, + group.missed, + group.declined, + group.failed, + group.inProgress, + percent(group.answerRate), + duration(group.totalDurationSeconds), + duration(group.averageDurationSeconds), + ].join('\t'), + ), + ] + if (stats.groupsTruncated) { + lines.push( + 'Only the 500 numbers with the most calls are listed; totals count every call.', + ) + } + return lines.join('\n') +} + +function renderHeatmap(cells: readonly CallStatsHeatmapCell[]): string { + const counts = new Map() + for (const cell of cells) + counts.set(`${cell.dayOfWeek}:${cell.hour}`, cell.calls) + const hours = Array.from({length: 24}, (_, hour) => + String(hour).padStart(2, '0'), + ) + return [ + ['Calls by hour', ...hours].join('\t'), + ...DAY_NAMES.map((day, index) => + [ + day, + ...hours.map((_, hour) => + String(counts.get(`${index + 1}:${hour}`) ?? 0), + ), + ].join('\t'), + ), + ].join('\n') +} + +export function percent(rate: number | null): string { + if (rate === null) return '-' + return `${(Math.round(rate * 1000) / 10).toFixed(1)}%` +} + +export function duration(seconds: number | null): string { + if (seconds === null) return '-' + const total = Math.round(seconds) + const hours = Math.floor(total / 3600) + const minutes = Math.floor((total % 3600) / 60) + const rest = total % 60 + if (hours > 0) return `${hours}h ${minutes}m ${rest}s` + if (minutes > 0) return `${minutes}m ${rest}s` + return `${rest}s` +} + +// ── Helpers ──────────────────────────────────────────────────────────────── + +function statsData(value: unknown): CallStats { + if ( + typeof value !== 'object' || + value === null || + !('totals' in value) || + !('groups' in value) || + !Array.isArray(value.groups) + ) { + throw new PolymorfaServerError( + 'The call statistics response was not recognized.', + {code: 'call_stats_invalid_response'}, + ) + } + return value as CallStats +} + +function invalid(message: string): PolymorfaValidationError { + return new PolymorfaValidationError(message, {code: 'invalid_call_filter'}) +} diff --git a/test/commands/call-analytics.test.ts b/test/commands/call-analytics.test.ts new file mode 100644 index 00000000..7df50f74 --- /dev/null +++ b/test/commands/call-analytics.test.ts @@ -0,0 +1,712 @@ +import {runCommand} from '@oclif/test' +import {readFile, rm, writeFile} from 'node:fs/promises' +import {join} from 'node:path' +import {afterEach, describe, expect, test} from 'vitest' + +import {writeFully} from '../../src/commands/calls/export.js' +import {exportCallRecords} from '../../src/services/call-analytics.js' + +import { + startHttpSequenceServer, + withEnvironment, + type ResponseFixture, + type TestHttpServer, +} from '../helpers/http-server.js' +import {temporaryDirectory} from '../support/fs.js' + +const API_KEY = + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const PROJECT_TOKEN = + 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const PROJECT = '11111111-1111-4111-8111-111111111111' + +const HEADER = + 'callId,projectId,sessionId,direction,upstream,outcome,state,hasVideo,peerRef,startedAt,connectedAt,endedAt,durationSeconds,endReason' + +function metrics(overrides: Record = {}) { + return { + calls: 12, + answered: 9, + missed: 2, + declined: 1, + failed: 0, + inProgress: 0, + answerRate: 0.75, + totalDurationSeconds: 3725, + averageDurationSeconds: 413.9, + ...overrides, + } +} + +function stats(overrides: Record = {}) { + return { + since: '2026-09-11T00:00:00.000Z', + until: '2026-09-18T00:00:00.000Z', + timezone: 'UTC', + groupBy: 'day', + totals: metrics(), + groups: [ + {key: '2026-09-17', start: '2026-09-17T00:00:00.000Z', ...metrics()}, + { + key: '2026-09-18', + start: '2026-09-18T00:00:00.000Z', + ...metrics({ + calls: 0, + answered: 0, + missed: 0, + declined: 0, + answerRate: null, + totalDurationSeconds: 0, + averageDurationSeconds: null, + }), + }, + ], + groupsTruncated: false, + heatmap: Array.from({length: 168}, (_, index) => ({ + dayOfWeek: Math.floor(index / 24) + 1, + hour: index % 24, + calls: index === 14 ? 7 : 0, + answered: index === 14 ? 5 : 0, + })), + ...overrides, + } +} + +function csvRow(id: string): string { + return `${id},${PROJECT},support,inbound,linked_device,answered,ended,false,peer_1,2026-09-17T10:00:00.000Z,2026-09-17T10:00:05.000Z,2026-09-17T10:01:05.000Z,60,user_hangup` +} + +function csvPage(ids: readonly string[], next?: string): ResponseFixture { + return { + headers: { + 'content-type': 'text/csv; charset=utf-8', + ...(next === undefined ? {} : {'Polymorfa-Next-Cursor': next}), + }, + rawBody: `${[HEADER, ...ids.map(csvRow)].join('\r\n')}\r\n`, + } +} + +function ndjsonPage(ids: readonly string[], next?: string): ResponseFixture { + return { + headers: { + 'content-type': 'application/x-ndjson; charset=utf-8', + ...(next === undefined ? {} : {'Polymorfa-Next-Cursor': next}), + }, + rawBody: ids.map((callId) => `${JSON.stringify({callId})}\n`).join(''), + } +} + +describe('calls stats', () => { + let server: TestHttpServer | undefined + + afterEach(async () => { + await server?.close() + server = undefined + }) + + test('prints totals and groups for one project with every filter', async () => { + server = await startHttpSequenceServer([{body: {data: stats()}}]) + + const result = await apiKey(() => + runCommand([ + 'calls', + 'stats', + '--project', + PROJECT, + '--session', + 'support', + '--direction', + 'inbound', + '--upstream', + 'linked_device', + '--outcome', + 'answered', + '--since', + '2026-09-11T00:00:00Z', + '--until', + '2026-09-18T00:00:00Z', + '--group-by', + 'day', + '--timezone', + 'Europe/Lisbon', + ]), + ) + + expect(result.error).toBeUndefined() + const url = new URL(server.requests[0]?.url ?? '', 'http://localhost') + expect(url.pathname).toBe('/platform/calls/stats') + expect(Object.fromEntries(url.searchParams)).toEqual({ + direction: 'inbound', + groupBy: 'day', + outcome: 'answered', + projectId: PROJECT, + sessionId: 'support', + since: '2026-09-11T00:00:00Z', + timezone: 'Europe/Lisbon', + until: '2026-09-18T00:00:00Z', + upstream: 'linked_device', + }) + expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${API_KEY}`) + expect(result.stdout).toContain('Calls\t12\n') + expect(result.stdout).toContain('Answer rate\t75.0%\n') + expect(result.stdout).toContain('Talk time\t1h 2m 5s\n') + expect(result.stdout).toContain('Average call\t6m 54s\n') + expect(result.stdout).toContain( + 'Day\tCalls\tAnswered\tMissed\tDeclined\tFailed\tIn progress\tAnswer rate\tTalk time\tAverage\n', + ) + expect(result.stdout).toContain( + '2026-09-17\t12\t9\t2\t1\t0\t0\t75.0%\t1h 2m 5s\t6m 54s\n', + ) + expect(result.stdout).toContain('2026-09-18\t0\t0\t0\t0\t0\t0\t-\t0s\t-\n') + expect(result.stdout).not.toContain('Calls by hour') + }) + + test('reads the whole organization without --project and prints the heatmap', async () => { + server = await startHttpSequenceServer([ + { + body: { + data: stats({ + groupBy: 'session', + groups: [{key: 'support', start: null, ...metrics()}], + groupsTruncated: true, + }), + }, + }, + ]) + + const result = await apiKey(() => + runCommand('calls stats --group-by session --heatmap'), + ) + + expect(result.error).toBeUndefined() + expect(server.requests[0]?.url).toBe( + '/platform/calls/stats?groupBy=session', + ) + expect(result.stdout).toContain('Session\tCalls') + expect(result.stdout).toContain('Only the 500 numbers with the most calls') + expect(result.stdout).toContain('Calls by hour\t00\t01') + expect(result.stdout).toMatch(/\nMon\t(0\t){14}7\t0/) + }) + + test('prints the standard JSON envelope', async () => { + server = await startHttpSequenceServer([ + {body: {data: stats()}, headers: {'x-request-id': 'req_1'}}, + ]) + + const result = await apiKey(() => runCommand('calls stats --json')) + + expect(result.error).toBeUndefined() + expect(JSON.parse(result.stdout)).toEqual({ + schemaVersion: 1, + data: stats(), + context: {owner: 'organization', principalType: 'organization_key'}, + request: {apiVersion: null, id: 'req_1', status: 200}, + }) + }) + + test('uses a project token only for its explicit project', async () => { + server = await startHttpSequenceServer([{body: {data: stats()}}]) + + const missing = await projectToken(() => runCommand('calls stats')) + const scoped = await projectToken(() => + runCommand(['calls', 'stats', '--project', PROJECT]), + ) + + expect(exitCode(missing.error)).toBe(2) + expect(missing.stderr).toContain('--project') + expect(scoped.error).toBeUndefined() + expect(server.requests).toHaveLength(1) + expect(server.requests[0]?.url).toBe( + `/platform/calls/stats?projectId=${PROJECT}&groupBy=day`, + ) + expect(server.requests[0]?.headers.authorization).toBe( + `Bearer ${PROJECT_TOKEN}`, + ) + }) + + test('accepts single-name IANA time zones', async () => { + server = await startHttpSequenceServer([ + {body: {data: stats()}}, + {body: {data: stats()}}, + ]) + + const results = await apiKey(async () => [ + await runCommand('calls stats --timezone CET'), + await runCommand('calls stats --timezone GMT'), + ]) + + for (const result of results) expect(result.error).toBeUndefined() + expect( + server.requests.map((request) => + new URL(request.url, 'http://localhost').searchParams.get('timezone'), + ), + ).toEqual(['CET', 'GMT']) + }) + + test('rejects invalid filters before sending a request', async () => { + server = await startHttpSequenceServer([{body: {data: stats()}}]) + + const results = await apiKey(async () => [ + await runCommand('calls stats --since yesterday'), + await runCommand( + 'calls stats --since 2026-09-18T00:00:00Z --until 2026-09-11T00:00:00Z', + ), + await runCommand('calls stats --timezone +01:00'), + await runCommand( + 'calls stats --group-by hour --since 2026-08-01T00:00:00Z --until 2026-09-18T00:00:00Z', + ), + await runCommand('calls stats --group-by week'), + await runCommand('calls stats --outcome busy'), + ]) + + for (const result of results) expect(exitCode(result.error)).toBe(2) + expect(results[2]?.stderr).toContain('IANA time zone') + expect(results[3]?.stderr).toContain('at most 31 days') + expect(server.requests).toHaveLength(0) + }) + + test('exits non-zero with the API error', async () => { + server = await startHttpSequenceServer([ + { + body: { + error: { + code: 'insufficient_scope', + message: 'This credential lacks the sessions:read scope.', + }, + }, + headers: {'x-request-id': 'req_denied'}, + status: 403, + }, + ]) + + const result = await apiKey(() => runCommand('calls stats')) + + expect(exitCode(result.error)).toBe(3) + expect(result.stderr).toContain('sessions:read') + expect(result.stdout).toBe('') + }) + + test('rejects a malformed statistics response without printing partial output', async () => { + server = await startHttpSequenceServer([ + {body: {data: {totals: metrics()}}}, + ]) + + const result = await apiKey(() => runCommand('calls stats --json')) + + expect(exitCode(result.error)).not.toBe(0) + expect(result.stderr).toContain('not recognized') + expect(result.stdout).toBe('') + }) + + async function apiKey(run: () => Promise): Promise { + return credentials({POLYMORFA_API_KEY: API_KEY}, run) + } + + async function projectToken(run: () => Promise): Promise { + return credentials({POLYMORFA_PROJECT_TOKEN: PROJECT_TOKEN}, run) + } + + async function credentials( + values: Record, + run: () => Promise, + ): Promise { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: undefined, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: undefined, + ...values, + }, + run, + ) + } +}) + +describe('calls export', () => { + let server: TestHttpServer | undefined + let directory: string | undefined + + afterEach(async () => { + await server?.close() + server = undefined + if (directory !== undefined) + await rm(directory, {force: true, recursive: true}) + directory = undefined + }) + + test('walks every page and writes the CSV header once', async () => { + server = await startHttpSequenceServer([ + csvPage(['call_1', 'call_2'], 'cursor_2'), + csvPage(['call_3', 'call_4'], 'cursor_3'), + csvPage(['call_5']), + ]) + + const result = await apiKey(() => + runCommand([ + 'calls', + 'export', + '--project', + PROJECT, + '--outcome', + 'answered', + '--since', + '2026-09-01T00:00:00Z', + '--limit', + '2', + ]), + ) + + expect(result.error).toBeUndefined() + expect(result.stdout).toBe( + `${[HEADER, ...['call_1', 'call_2', 'call_3', 'call_4', 'call_5'].map(csvRow)].join('\r\n')}\r\n`, + ) + expect(result.stdout.split(HEADER)).toHaveLength(2) + const queries = server.requests.map(({url}) => + Object.fromEntries(new URL(url, 'http://localhost').searchParams), + ) + const filters = { + format: 'csv', + limit: '2', + outcome: 'answered', + projectId: PROJECT, + since: '2026-09-01T00:00:00Z', + } + expect(queries).toEqual([ + filters, + {...filters, cursor: 'cursor_2'}, + {...filters, cursor: 'cursor_3'}, + ]) + expect(server.requests[0]?.headers.accept).toBe('text/csv') + expect(result.stderr).toContain('Page 1: 2 records (2 total).') + expect(result.stderr).toContain( + 'Exported 5 records in 3 pages to standard output.', + ) + }) + + test('writes NDJSON to a new file and refuses to overwrite it', async () => { + server = await startHttpSequenceServer([ + ndjsonPage(['call_1'], 'cursor_2'), + ndjsonPage(['call_2']), + ]) + directory = await temporaryDirectory() + const output = join(directory, 'calls.ndjson') + + const written = await apiKey(() => + runCommand(['calls', 'export', '--format', 'ndjson', '--output', output]), + ) + const refused = await apiKey(() => + runCommand(['calls', 'export', '--format', 'ndjson', '-o', output]), + ) + + expect(written.error).toBeUndefined() + expect(written.stdout).toBe('') + expect(await readFile(output, 'utf8')).toBe( + '{"callId":"call_1"}\n{"callId":"call_2"}\n', + ) + expect(server.requests[0]?.url).toBe( + '/platform/calls/export?format=ndjson&limit=1000', + ) + expect(exitCode(refused.error)).toBe(4) + expect(refused.stderr).toContain('--force') + expect(server.requests).toHaveLength(2) + }) + + test('creates or truncates the output file for an empty NDJSON export', async () => { + server = await startHttpSequenceServer([ndjsonPage([]), ndjsonPage([])]) + directory = await temporaryDirectory() + const created = join(directory, 'new.ndjson') + const replaced = join(directory, 'old.ndjson') + await writeFile(replaced, '{"callId":"stale"}\n') + + const first = await apiKey(() => + runCommand(['calls', 'export', '--format', 'ndjson', '-o', created]), + ) + const second = await apiKey(() => + runCommand([ + 'calls', + 'export', + '--format', + 'ndjson', + '-o', + replaced, + '--force', + ]), + ) + + expect(first.error).toBeUndefined() + expect(second.error).toBeUndefined() + expect(await readFile(created, 'utf8')).toBe('') + expect(await readFile(replaced, 'utf8')).toBe('') + expect(second.stderr).toContain('Exported 0 records in 1 page') + }) + + test('refuses an existing output file before resolving a project slug', async () => { + server = await startHttpSequenceServer([ndjsonPage(['call_1'])]) + directory = await temporaryDirectory() + const output = join(directory, 'calls.csv') + await writeFile(output, 'keep') + + const result = await apiKey(() => + runCommand(['calls', 'export', '--project', 'support', '-o', output]), + ) + + expect(exitCode(result.error)).toBe(4) + expect(result.stderr).toContain('--force') + expect(server.requests).toHaveLength(0) + expect(await readFile(output, 'utf8')).toBe('keep') + }) + + test('overwrites with --force and writes the header for a resumed export', async () => { + server = await startHttpSequenceServer([csvPage(['call_9'])]) + directory = await temporaryDirectory() + const output = join(directory, 'calls.csv') + await writeFile(output, 'old contents') + + const result = await apiKey(() => + runCommand([ + 'calls', + 'export', + '--cursor', + 'cursor_9', + '--output', + output, + '--force', + ]), + ) + + expect(result.error).toBeUndefined() + expect(await readFile(output, 'utf8')).toBe( + `${HEADER}\r\n${csvRow('call_9')}\r\n`, + ) + expect(server.requests[0]?.url).toBe( + '/platform/calls/export?format=csv&limit=1000&cursor=cursor_9', + ) + }) + + test('stops after --max-pages and reports the cursor on standard error', async () => { + server = await startHttpSequenceServer([ + csvPage(['call_1'], 'cursor_2'), + csvPage(['call_2'], 'cursor_3'), + ]) + + const human = await apiKey(() => runCommand('calls export --max-pages 1')) + const json = await apiKey(() => + runCommand('calls export --max-pages 1 --json'), + ) + + expect(human.error).toBeUndefined() + expect(human.stdout).toBe(`${HEADER}\r\n${csvRow('call_1')}\r\n`) + expect(human.stderr).toContain('continue with --cursor cursor_2') + expect(json.error).toBeUndefined() + const lines = json.stderr + .trim() + .split('\n') + .map((line) => JSON.parse(line) as Record) + expect(lines).toEqual([ + { + type: 'page', + nextCursor: 'cursor_3', + page: 1, + records: 1, + totalRecords: 1, + }, + { + type: 'done', + destination: 'standard output', + complete: false, + nextCursor: 'cursor_3', + pages: 1, + records: 1, + }, + ]) + expect(server.requests).toHaveLength(2) + }) + + test('exits non-zero when a later page fails and reports the resume cursor', async () => { + server = await startHttpSequenceServer([ + csvPage(['call_1'], 'cursor_2'), + { + body: {error: {code: 'invalid_request', message: 'invalid cursor'}}, + status: 400, + }, + ]) + + const result = await apiKey(() => runCommand('calls export')) + + expect(exitCode(result.error)).toBe(2) + expect(result.stdout).toBe(`${HEADER}\r\n${csvRow('call_1')}\r\n`) + expect(result.stderr).toContain( + 'Stopped after 1 page and 1 record. Resume with --cursor cursor_2', + ) + expect(result.stderr).toContain('invalid cursor') + }) + + test('rejects an HTML export response without writing it to standard output', async () => { + server = await startHttpSequenceServer([ + { + headers: {'content-type': 'text/html'}, + rawBody: 'proxy error', + }, + ]) + + const result = await apiKey(() => runCommand('calls export')) + + expect(exitCode(result.error)).not.toBe(0) + expect(result.stdout).toBe('') + expect(result.stderr).not.toContain('') + }) + + test('fails when the API repeats a cursor or changes the CSV columns', async () => { + server = await startHttpSequenceServer([ + csvPage(['call_1'], 'cursor_2'), + csvPage(['call_2'], 'cursor_2'), + ]) + const repeated = await apiKey(() => + runCommand('calls export --cursor cursor_2'), + ) + await server.close() + + server = await startHttpSequenceServer([ + csvPage(['call_1'], 'cursor_2'), + { + headers: {'content-type': 'text/csv; charset=utf-8'}, + rawBody: 'callId,other\r\ncall_2,x\r\n', + }, + ]) + const changed = await apiKey(() => runCommand('calls export')) + + expect(exitCode(repeated.error)).toBe(5) + expect(repeated.stderr).toContain('same continuation cursor') + expect(exitCode(changed.error)).toBe(5) + expect(changed.stderr).toContain('changed its CSV columns') + expect(changed.stdout).toBe(`${HEADER}\r\n${csvRow('call_1')}\r\n`) + }) + + test('rejects a cursor cycle before writing the repeated page', async () => { + server = await startHttpSequenceServer([ + csvPage(['call_1'], 'cursor_a'), + csvPage(['call_2'], 'cursor_b'), + csvPage(['call_3'], 'cursor_a'), + ]) + + const result = await apiKey(() => runCommand('calls export')) + + expect(exitCode(result.error)).toBe(5) + expect(result.stderr).toContain('earlier continuation cursor') + expect(result.stdout).toBe( + `${HEADER}\r\n${csvRow('call_1')}\r\n${csvRow('call_2')}\r\n`, + ) + expect(server.requests).toHaveLength(3) + }) + + test('validates flags before sending a request', async () => { + server = await startHttpSequenceServer([csvPage([])]) + + const results = await apiKey(async () => [ + await runCommand('calls export --format xml'), + await runCommand('calls export --limit 0'), + await runCommand('calls export --limit 1001'), + await runCommand('calls export --max-pages 0'), + await runCommand('calls export --until not-a-time'), + await runCommand('calls export --direction sideways'), + ]) + + for (const result of results) expect(exitCode(result.error)).toBe(2) + expect(server.requests).toHaveLength(0) + }) + + test('writes only the header when no calls match', async () => { + server = await startHttpSequenceServer([csvPage([])]) + + const result = await apiKey(() => runCommand('calls export --quiet')) + + expect(result.error).toBeUndefined() + expect(result.stdout).toBe(`${HEADER}\r\n`) + expect(result.stderr).toBe('') + }) + + async function apiKey(run: () => Promise): Promise { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: API_KEY, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: undefined, + }, + run, + ) + } +}) + +function exitCode(error: unknown): number | undefined { + return (error as {oclif?: {exit?: number}} | undefined)?.oclif?.exit +} + +describe('writeFully', () => { + test('keeps writing after a short write', async () => { + const written: Buffer[] = [] + const file = { + write: (buffer: Buffer, offset: number, length: number) => { + // Persist at most three bytes per call, as a short write may. + const size = Math.min(3, length) + written.push(Buffer.from(buffer.subarray(offset, offset + size))) + return Promise.resolve({bytesWritten: size, buffer}) + }, + } + + await writeFully(file as never, 'callId,peerRef\r\nçall_1,f00d\r\n') + + expect(Buffer.concat(written).toString('utf8')).toBe( + 'callId,peerRef\r\nçall_1,f00d\r\n', + ) + }) + + test('fails instead of looping when nothing is written', async () => { + const file = {write: () => Promise.resolve({bytesWritten: 0})} + await expect(writeFully(file as never, 'x')).rejects.toThrow( + 'Could not write', + ) + }) +}) + +describe('exportCallRecords', () => { + test('does not advertise a resume cursor after a partial write', async () => { + let partialOutput = '' + let stopped: + {nextCursor: string | null; pages: number; records: number} | undefined + + await expect( + exportCallRecords({ + api: { + exportPage: () => + Promise.resolve({ + body: '{"callId":"call_1"}\n', + metadata: {}, + nextCursor: 'cursor_2', + }), + } as never, + format: 'ndjson', + limit: 1, + onStop: (summary) => { + stopped = summary + }, + query: {}, + write: (chunk) => { + partialOutput = chunk.slice(0, 5) + return Promise.reject(new Error('disk full')) + }, + }), + ).rejects.toThrow('disk full') + + expect(partialOutput).toBe('{"cal') + expect(stopped).toEqual({ + complete: false, + nextCursor: null, + pages: 0, + records: 0, + }) + }) +}) diff --git a/test/context/environment.test.ts b/test/context/environment.test.ts index aa92cfb1..f1a8a43e 100644 --- a/test/context/environment.test.ts +++ b/test/context/environment.test.ts @@ -420,6 +420,97 @@ describe('resolveEnvironment', () => { }) }) +describe('resolveDeveloperEnvironment owner inference', () => { + const key = + 'pmfa_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB' + + test('reads the whole organization despite a saved project profile', async () => { + const config: ProfileConfig = { + activeProfile: 'scoped', + profiles: { + scoped: { + credential: {account: 'profile:scoped:apiKey', kind: 'apiKey'}, + owner: 'project', + project: PROJECT, + }, + }, + version: 2, + } + const profiles = { + load: () => Promise.resolve(config), + save: () => Promise.resolve(), + } + + const analytics = await resolveDeveloperEnvironment( + {env: {}, inferOwner: true, profile: 'scoped'}, + profiles, + fakeCredentials(key), + ) + expect(analytics.context.owner).toBe('organization') + expect(analytics.context.projectId).toBeUndefined() + + const explicitProfileScope = await resolveDeveloperEnvironment( + {env: {}, profile: 'scoped'}, + profiles, + fakeCredentials(key), + ) + expect(explicitProfileScope.context).toMatchObject({ + owner: 'project', + projectId: PROJECT, + }) + }) + + test('reads the organization with an organization key and no project', async () => { + await expect( + resolveDeveloperEnvironment( + {env: {POLYMORFA_API_KEY: key}, inferOwner: true}, + fakeProfiles(), + fakeCredentials('unused'), + ), + ).resolves.toMatchObject({ + context: {owner: 'organization', principalType: 'organization_key'}, + }) + }) + + test('reads one project when --project is given', async () => { + await expect( + resolveDeveloperEnvironment( + {env: {}, inferOwner: true, profile: 'work', project: PROJECT}, + fakeProfiles(), + fakeCredentials(key), + ), + ).resolves.toMatchObject({ + context: {owner: 'project', profile: 'work', projectId: PROJECT}, + }) + }) + + test('requires --project for a project token from the environment', async () => { + await expect( + resolveDeveloperEnvironment( + { + env: { + POLYMORFA_PROJECT_TOKEN: + 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + inferOwner: true, + }, + fakeProfiles(), + fakeCredentials('unused'), + ), + ).rejects.toThrow('explicit --project') + }) + + test('keeps an explicit --owner', async () => { + await expect( + resolveDeveloperEnvironment( + {env: {POLYMORFA_API_KEY: key}, inferOwner: true, owner: 'project'}, + fakeProfiles(), + fakeCredentials('unused'), + ), + ).rejects.toThrow('explicit --project') + }) +}) + const PROJECT = '11111111-1111-4111-8111-111111111111' const OTHER_PROJECT = '22222222-2222-4222-8222-222222222222'