diff --git a/README.md b/README.md index 349fe3d..46c7eec 100644 --- a/README.md +++ b/README.md @@ -1399,6 +1399,12 @@ polymorfa campaign recipients list campaign_123 \ --project-slug launch-project \ --status skipped \ --limit 100 + +polymorfa campaign recipients export campaign_123 \ + --project-slug launch-project \ + --status failed \ + --reason send_failed \ + --output recipient-outcomes.csv ``` One append accepts at most 1,000 entries. `--csv` reads the file locally and @@ -1424,8 +1430,15 @@ result arrives, list the recipients before appending again. List returns `{data, page}`. Pass `page.nextCursor` back as `--cursor` while `page.hasMore` is true; the CLI prints the next cursor in human output. Filter -with `--status queued|sending|sent|delivered|read|failed|skipped`. A recipient -suppressed by an opt-out appears as `skipped` with `lastError` `opted_out`. +with `--status queued|sending|sent|delivered|read|failed|skipped` and `--reason` +for a normalized failure reason. A recipient suppressed by an opt-out appears +as `skipped` with `failureReason` and `lastError` `opted_out`. + +`recipients export` fetches up to 1,000 rows per API page and joins them into +one CSV header. It writes to standard output unless `--output` names a file; +an existing file requires `--force`. Use `--max-pages` to stop early, then pass +the cursor printed on standard error back with `--cursor` and the same filters. +Each page reflects recipient outcomes when that page was read. The source exposes no campaign update, delete, validate, archive, duplicate, event, history, or replay route on the Messaging surface. The CLI does not diff --git a/src/commands/campaign/recipients/export.ts b/src/commands/campaign/recipients/export.ts new file mode 100644 index 0000000..b6474e9 --- /dev/null +++ b/src/commands/campaign/recipients/export.ts @@ -0,0 +1,110 @@ +import {Args, Flags} from '@oclif/core' +import {PolymorfaConfigurationError} from '@polymorfa/sdk' +import {stat, writeFile} from 'node:fs/promises' +import {resolve} from 'node:path' + +import {BaseCommand} from '../../../base-command.js' +import { + exportCampaignRecipients, + recipientFailureReason, + recipientStatus, + RECIPIENT_FAILURE_REASONS, + RECIPIENT_STATUSES, +} from '../../../services/campaigns.js' + +export default class CampaignRecipientsExport extends BaseCommand { + static override args = { + campaign: Args.string({description: 'Campaign ID.', required: true}), + } + + static override description = `Export campaign recipient outcomes as CSV. + +The command reads cursor pages with the same filters and writes one header. Use --max-pages to stop early; the remaining cursor is printed on standard error. Resume with --cursor and the same filters. This read reflects each recipient when its page was fetched.` + + static override examples = [ + '<%= config.bin %> <%= command.id %> campaign_1 --project-slug launch --output recipients.csv', + '<%= config.bin %> <%= command.id %> campaign_1 --project-slug launch --status failed --reason send_failed --max-pages 1', + ] + + static override flags = { + cursor: Flags.string({ + description: 'Continue from an earlier export cursor.', + }), + force: Flags.boolean({description: 'Overwrite an existing --output file.'}), + limit: Flags.integer({ + description: 'Rows per API page.', + min: 1, + max: 1000, + default: 1000, + }), + 'max-pages': Flags.integer({ + description: 'Stop after this many pages.', + min: 1, + max: 500, + }), + output: Flags.string({ + char: 'o', + description: 'Write CSV to this file instead of standard output.', + }), + 'project-slug': Flags.string({ + description: 'Project slug in the Messaging API path.', + required: true, + }), + reason: Flags.string({ + description: 'Filter by failure reason.', + options: [...RECIPIENT_FAILURE_REASONS], + }), + status: Flags.string({ + description: 'Filter by delivery status.', + options: [...RECIPIENT_STATUSES], + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(CampaignRecipientsExport) + const cursor = flags.cursor?.trim() + if (cursor !== undefined && (cursor.length === 0 || cursor.length > 1024)) + throw new PolymorfaConfigurationError( + '--cursor must be 1 to 1024 characters.', + 'cursor', + ) + const outputPath = + flags.output && flags.output !== '-' ? resolve(flags.output) : undefined + if (outputPath && !flags.force) { + try { + await stat(outputPath) + throw new PolymorfaConfigurationError( + 'The output file already exists; pass --force to replace it.', + 'output', + ) + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + } + const result = await exportCampaignRecipients( + await this.messagingClient(flags, {serverApiKeyOnly: 'Campaign'}), + flags['project-slug'], + args.campaign, + { + ...(cursor === undefined ? {} : {cursor}), + limit: flags.limit, + ...(flags.status === undefined + ? {} + : {status: recipientStatus(flags.status)!}), + ...(flags.reason === undefined + ? {} + : {reason: recipientFailureReason(flags.reason)!}), + }, + flags['max-pages'] ?? 500, + ) + if (outputPath) { + await writeFile(outputPath, result.csv, { + flag: flags.force ? 'w' : 'wx', + }) + } else { + process.stdout.write(result.csv) + } + if (result.nextCursor !== null) + process.stderr.write(`Continue with --cursor ${result.nextCursor}\n`) + } +} diff --git a/src/commands/campaign/recipients/list.ts b/src/commands/campaign/recipients/list.ts index 9a7354d..a188856 100644 --- a/src/commands/campaign/recipients/list.ts +++ b/src/commands/campaign/recipients/list.ts @@ -3,7 +3,9 @@ import {Args, Flags} from '@oclif/core' import {BaseCommand} from '../../../base-command.js' import { listCampaignRecipients, + recipientFailureReason, recipientStatus, + RECIPIENT_FAILURE_REASONS, renderCampaignRecipients, RECIPIENT_STATUSES, } from '../../../services/campaigns.js' @@ -31,6 +33,10 @@ export default class CampaignRecipientsList extends BaseCommand { description: 'Project slug in the Messaging API path.', required: true, }), + reason: Flags.string({ + description: 'Filter by the normalized failure reason.', + options: [...RECIPIENT_FAILURE_REASONS], + }), status: Flags.string({ description: 'Filter by delivery status. Use skipped to find recipients suppressed by an opt-out.', @@ -50,6 +56,9 @@ export default class CampaignRecipientsList extends BaseCommand { ...(flags.status === undefined ? {} : {status: recipientStatus(flags.status)!}), + ...(flags.reason === undefined + ? {} + : {reason: recipientFailureReason(flags.reason)!}), }, ) this.writeValue(result, renderCampaignRecipients(result.data), flags) diff --git a/src/services/campaigns.ts b/src/services/campaigns.ts index 89a2b15..7765942 100644 --- a/src/services/campaigns.ts +++ b/src/services/campaigns.ts @@ -4,9 +4,11 @@ import { type CampaignAnalytics, type CampaignOperation, type CampaignRecipient, + type CampaignRecipientFailureReason, type CampaignRecipientInput, type CampaignRecipientPage, type CampaignRecipientStatus, + type ExportCampaignRecipientsParams, type CampaignRequeueResult, type CampaignStopOperation, type CreateCampaignRequest, @@ -144,6 +146,58 @@ export async function listCampaignRecipients( ) } +export interface CampaignRecipientCsvResult { + readonly csv: string + readonly nextCursor: string | null + readonly pages: number +} + +/** Download bounded CSV pages, keeping the header only once. */ +export async function exportCampaignRecipients( + client: MessagingClient, + projectSlug: string, + campaignId: string, + params: ExportCampaignRecipientsParams, + maxPages = 500, +): Promise { + const chunks: string[] = [] + let header: string | null = null + let cursor = params.cursor + const seen = new Set() + for (let page = 0; page < maxPages; page += 1) { + const response = await client.campaigns.exportRecipients( + projectSlug, + campaignId, + { + ...params, + ...(cursor === undefined ? {} : {cursor}), + }, + ) + const {csv, nextCursor} = response.data + const headerEnd = csv.indexOf('\r\n') + if (headerEnd < 0) + throw new PolymorfaValidationError( + 'The recipient export did not return a CSV header.', + ) + const pageHeader = csv.slice(0, headerEnd) + if (header === null) header = pageHeader + else if (pageHeader !== header) + throw new PolymorfaValidationError( + 'The recipient export columns changed between pages.', + ) + chunks.push(page === 0 ? csv : csv.slice(headerEnd + 2)) + if (nextCursor === null) + return {csv: chunks.join(''), nextCursor: null, pages: page + 1} + if (seen.has(nextCursor) || nextCursor === cursor) + throw new PolymorfaValidationError( + 'The recipient export returned a repeated cursor.', + ) + seen.add(nextCursor) + cursor = nextCursor + } + return {csv: chunks.join(''), nextCursor: cursor ?? null, pages: maxPages} +} + export async function addCampaignRecipients( client: MessagingClient, projectSlug: string, @@ -325,6 +379,24 @@ export function recipientStatus( return value as CampaignRecipientStatus } +export function recipientFailureReason( + value?: string, +): CampaignRecipientFailureReason | undefined { + return value as CampaignRecipientFailureReason | undefined +} + +export const RECIPIENT_FAILURE_REASONS = [ + 'opted_out', + 'cold_held', + 'campaign_cancelled', + 'invalid_recipient', + 'session_not_connected', + 'ack_timeout', + 'blocked_by_safety', + 'send_failed', + 'other', +] as const + export const RECIPIENT_STATUSES = [ 'queued', 'sending', diff --git a/test/commands/campaign-recipients.test.ts b/test/commands/campaign-recipients.test.ts index f8bcfe7..99623ec 100644 --- a/test/commands/campaign-recipients.test.ts +++ b/test/commands/campaign-recipients.test.ts @@ -7,6 +7,7 @@ import {afterEach, describe, expect, test} from 'vitest' import { startHttpServer, + startHttpSequenceServer, withEnvironment, type TestHttpServer, } from '../helpers/http-server.js' @@ -52,6 +53,69 @@ describe('Campaign recipient commands', () => { }) }) + test('filters recipient pages by failure reason', async () => { + server = await startHttpServer({ + body: { + data: [recipient()], + page: {hasMore: false, nextCursor: null}, + success: true, + }, + }) + const result = await apiKeyEnvironment(() => + runCommand( + 'campaign recipients list campaign_1 --project-slug project_1 --reason opted_out', + ), + ) + expect(result.error).toBeUndefined() + expect(server.requests[0]?.url).toContain('reason=opted_out') + }) + + test('exports paged CSV with one header and a stable filter', async () => { + server = await startHttpSequenceServer([ + { + rawBody: 'recipientId,phone\r\nr1,+14155550100\r\n', + headers: { + 'content-type': 'text/csv', + 'Polymorfa-Next-Cursor': 'next_2', + }, + }, + { + rawBody: 'recipientId,phone\r\nr2,+14155550101\r\n', + headers: {'content-type': 'text/csv'}, + }, + ]) + const result = await apiKeyEnvironment(() => + runCommand( + 'campaign recipients export campaign_1 --project-slug project_1 --reason send_failed --limit 1', + ), + ) + expect(result.error).toBeUndefined() + expect(result.stdout).toBe( + 'recipientId,phone\r\nr1,+14155550100\r\nr2,+14155550101\r\n', + ) + expect(server.requests.map((request) => request.url)).toEqual([ + '/messaging/projects/project_1/campaigns/campaign_1/recipients/export?reason=send_failed&limit=1', + '/messaging/projects/project_1/campaigns/campaign_1/recipients/export?reason=send_failed&cursor=next_2&limit=1', + ]) + }) + + test('refuses to replace an export file before making an API request', async () => { + directory = await mkdtemp(join(tmpdir(), 'polymorfa-export-')) + const output = join(directory, 'recipients.csv') + await writeFile(output, 'existing') + server = await startHttpServer({ + rawBody: 'recipientId,phone\r\n', + headers: {'content-type': 'text/csv'}, + }) + const result = await apiKeyEnvironment(() => + runCommand( + `campaign recipients export campaign_1 --project-slug project_1 --output ${output}`, + ), + ) + expect(result.error).toBeDefined() + expect(server.requests).toHaveLength(0) + }) + test('offers the next cursor in human output when a page remains', async () => { server = await startHttpServer({ body: {