Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
110 changes: 110 additions & 0 deletions src/commands/campaign/recipients/export.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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`)
Comment on lines +107 to +108

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Honor the output mode for continuation diagnostics

When a bounded export is run with --quiet, this branch still emits a successful continuation message, contradicting the inherited flag's promise to suppress successful output; with --json, it emits the same human sentence rather than a machine-readable diagnostic. This is observable whenever --max-pages stops before the final page, especially for scripts using a file destination, so route the message through this.outputMode(flags) as the existing calls export command does.

Useful? React with 👍 / 👎.

}
}
9 changes: 9 additions & 0 deletions src/commands/campaign/recipients/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.',
Expand All @@ -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)
Expand Down
72 changes: 72 additions & 0 deletions src/services/campaigns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
type CampaignAnalytics,
type CampaignOperation,
type CampaignRecipient,
type CampaignRecipientFailureReason,

Check failure on line 7 in src/services/campaigns.ts

View workflow job for this annotation

GitHub Actions / Verify Node 22

'"@polymorfa/sdk"' has no exported member named 'CampaignRecipientFailureReason'. Did you mean 'CampaignRecipientVariables'?
type CampaignRecipientInput,
type CampaignRecipientPage,
type CampaignRecipientStatus,
type ExportCampaignRecipientsParams,

Check failure on line 11 in src/services/campaigns.ts

View workflow job for this annotation

GitHub Actions / Verify Node 22

'"@polymorfa/sdk"' has no exported member named 'ExportCampaignRecipientsParams'. Did you mean 'ListCampaignRecipientsParams'?
type CampaignRequeueResult,
type CampaignStopOperation,
type CreateCampaignRequest,
Expand Down Expand Up @@ -144,6 +146,58 @@
)
}

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<CampaignRecipientCsvResult> {
const chunks: string[] = []

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Stream completed pages instead of buffering the full export

For large campaigns, this array retains every CSV page and chunks.join('') later creates another full-sized string, so the supported 500-page × 1,000-row export can consume hundreds of megabytes before writing anything. Moreover, if a later page request or validation fails, the function throws before returning, causing all successfully fetched rows and their safe resume cursor to be discarded. Write each validated page incrementally, as the existing call-record exporter does, while tracking the cursor needed to resume after a failure.

Useful? React with 👍 / 👎.

let header: string | null = null
let cursor = params.cursor
const seen = new Set<string>()
for (let page = 0; page < maxPages; page += 1) {
const response = await client.campaigns.exportRecipients(

Check failure on line 168 in src/services/campaigns.ts

View workflow job for this annotation

GitHub Actions / Verify Node 22

Property 'exportRecipients' does not exist on type 'MessagingCampaignsResource'.
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,
Expand Down Expand Up @@ -325,6 +379,24 @@
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',
Expand Down
64 changes: 64 additions & 0 deletions test/commands/campaign-recipients.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {afterEach, describe, expect, test} from 'vitest'

import {
startHttpServer,
startHttpSequenceServer,
withEnvironment,
type TestHttpServer,
} from '../helpers/http-server.js'
Expand Down Expand Up @@ -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: {
Expand Down
Loading