Repository navigation
Add campaign recipient CSV export and failure filters #49
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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`) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,9 +4,11 @@ | |
| 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 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[] = [] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For large campaigns, this array retains every CSV page and 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( | ||
| 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 @@ | |
| 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', | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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-pagesstops before the final page, especially for scripts using a file destination, so route the message throughthis.outputMode(flags)as the existingcalls exportcommand does.Useful? React with 👍 / 👎.