diff --git a/README.md b/README.md index c782e516..897e244f 100644 --- a/README.md +++ b/README.md @@ -38,12 +38,12 @@ prerelease in `package.json`. The version output includes both runtime components: ```text -polymorfa/0.1.0-dev.0 sdk/0.1.0-dev.0 +polymorfa/0.1.0-dev.0 sdk/0.1.0-dev.20260923115544 ``` -The SDK component reports the version constant built into the SDK. SDK dev -prereleases report `0.1.0-dev.0`, not their full prerelease version; run -`npm ls @polymorfa/sdk` to see the installed build. +The SDK component reports the version built into the installed SDK. Dev +prereleases include their full timestamped version. Run `npm ls @polymorfa/sdk` +to see the installed build. ## Authentication profiles @@ -1063,31 +1063,142 @@ polymorfa campaign requeue campaign_123 \ --include-skipped-error ``` -Launch, pause, resume, and stop return accepted durable work; they do not wait -for the state transition. Check the campaign with `polymorfa campaign view`. +Launch, pause, and resume return accepted durable work; they do not wait for +the state transition. Check the campaign with `polymorfa campaign view`. Follow the returned operation with `polymorfa operation wait` or `polymorfa operation watch`; see [Operations](#operations). +Stop is terminal and requires `--yes`. A campaign that has an active delivery +run answers with an operation ID, as the other lifecycle commands do. A +campaign with no active run is cancelled immediately and answers without one; +the CLI reports that outcome instead of printing an empty operation. + Launch times accept epoch milliseconds or timezone-qualified ISO 8601 instants. List returns one complete newest-first array with no cursor. Requeue -is synchronous and reports only the number moved. Every mutation accepts -`--idempotency-key`. `campaign create` and `campaign launch` send an -`Idempotency-Key` by default, generating a fresh key for each invocation when -you omit the flag. Pass the same explicit key from the first attempt when -retrying either command in another process, with the same input, credential, -and API version. - -Both commands use the [retry identity](#retry-identity) contract above: within -24 hours, a recorded success returns `409 idempotency_completed` without the -original response body; an uncertain outcome stays blocked rather than running -again. Save the first campaign or operation response and inspect campaign -state before using a new key. Pause, resume, stop, and requeue do not persist -the header, so it is not server-side deduplication for them. +is synchronous and reports only the number moved. + +Create, launch, pause, resume and stop accept `--idempotency-key` to deduplicate +the same request for 24 hours. A successful request repeated with the same key +returns `409 idempotency_completed`, not the original response. Changed input +returns `409 idempotency_conflict`; an in-progress or uncertain result must be +resolved before trying a new key. The SDK generates a fresh key for create and +launch when the flag is omitted. Supply an explicit key from the first attempt +to reuse it across CLI invocations with the same input, credential and API +version. Save the first campaign or operation response and inspect campaign +state before using a new key. Pause, resume and stop persist an explicit key; +they do not generate one when the flag is omitted. + +Requeue also accepts the header, but the API does not deduplicate it. Each +invocation moves recipients eligible at that time, so a retry can move newly +failed recipients rather than returning the first result. + +### Campaign recipients + +Recipients are appended to a campaign that has not started sending, and read +back one cursor page at a time: + +```shell +polymorfa campaign recipients add campaign_123 \ + --project-slug launch-project \ + --csv contacts.csv \ + --phone-column Phone \ + --variable "firstName=First name" + +polymorfa campaign recipients add campaign_123 \ + --project-slug launch-project \ + --input recipients.json + +polymorfa campaign recipients list campaign_123 \ + --project-slug launch-project \ + --status skipped \ + --limit 100 +``` + +One append accepts at most 1,000 entries. `--csv` reads the file locally and +sends the rows inline; it does not upload the file. Blank rows are ignored, but +a row with other values and an empty phone cell stops the command before +anything is sent, naming the row numbers (the header is row 1). `--input` +entries with an empty phone, and any phone longer than 64 characters, are +refused the same way, as is an empty `--input` array, because the API would +reject the whole request. JSON variables must use valid names and string values; +CSV and JSON variable values are limited to 1,024 characters. Reported invalid +rows for `--csv` refer to the +original spreadsheet rows, including blank rows. `__proto__` is not accepted as a `--variable` name. Comma, +semicolon and tab separators are detected from the header row; separators +inside quoted fields are ignored. A phone already on the +campaign is skipped, and invalid entries are reported rather than added, so the +result carries `added`, `duplicateCount`, `invalidCount` and up to 20 +`invalidRows`. + +The API does not replay an append, so the CLI sends it once and never retries +it automatically, with or without `--idempotency-key`. A resent append would +count the first attempt's rows as duplicates. If a connection drops before the +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`. The source exposes no campaign update, delete, validate, archive, duplicate, -recipient, event, history, or replay route. The CLI does not substitute -similarly named Platform campaign methods for these gaps. To follow or stop a -campaign's operation, use the `operation` commands in [Operations](#operations). +event, history, or replay route on the Messaging surface. The CLI does not +substitute similarly named Platform campaign methods for these gaps. To follow +or stop a campaign's operation, use the `operation` commands in +[Operations](#operations). + +## Campaign audiences + +Audience commands are organization-scoped and use the Platform API: + +```shell +polymorfa audience create --name "August launch" \ + --csv contacts.csv \ + --phone-column Phone \ + --variable "firstName=First name" + +polymorfa audience create --name "August launch" \ + --file-id upload_1 \ + --mapping mapping.json + +polymorfa audience members add list_123 --csv contacts.csv +polymorfa audience members list list_123 --limit 100 +polymorfa audience members remove list_123 +15551234567 +``` + +`--csv` sends up to 1,000 members inline. `--file-id` imports a file that was +already uploaded through the API and needs `--mapping`, a JSON document naming +the phone column and any variable columns: + +```json +{"phone": "Phone", "variables": {"firstName": "First name"}} +``` + +Creation and appends report the same import counts as campaign recipients, so a +caller can reconcile what was accepted. Like recipient appends, `members add` is +sent once and never retried automatically. Member listing is cursor-paged like +recipient listing. + +## Campaign opt-outs + +Opt-out suppression is organization-wide. Keyword capture has its own settings: + +```shell +polymorfa optout settings view +polymorfa optout settings update \ + --enabled \ + --opt-out-keyword STOP \ + --opt-out-keyword BAJA \ + --opt-in-keyword START +polymorfa optout settings update --no-enabled --opt-out-keyword STOP +``` + +Update replaces the whole settings document, so pass the complete keyword set +every time; the CLI refuses a partial update rather than clearing keywords the +caller did not mention. At least one opt-out keyword is required even when +capture is off. When capture is on, a matching reply suppresses the contact and +the API emits `contact.opted_out`; an opt-in keyword emits `contact.opted_in`. +A suppressed contact is skipped at dispatch, which is visible as a `skipped` +recipient with `lastError` `opted_out`. ## Developer events and webhooks diff --git a/docs/command-research/campaigns.md b/docs/command-research/campaigns.md index 5085d0ba..4260d4b8 100644 --- a/docs/command-research/campaigns.md +++ b/docs/command-research/campaigns.md @@ -1,8 +1,14 @@ # Messaging campaign command research -- **Status:** Complete nine-operation organization-key implementation slice -- **Reviewed:** 2026-08-20 -- **SDK baseline:** `b4d5f3c8ce51bb0c171264eec8961797314cb172` +- **Status:** Campaigns P0 slice: nine lifecycle operations plus recipients, + audiences and opt-out settings +- **Reviewed:** 2026-09-23 +- **SDK baseline:** `8ce8f7bae55fbc03da5a68e5609864d5742dc201` +- **Contract:** `polymorfa/polymorfa` `f4a340da3b74248232ebea73f3e72b42f667beef` (merged `dev`) + +The API contract is merged into `dev`, and the SDK dev prerelease +`0.1.0-dev.20260923115544` includes the Campaigns P0 methods. These notes do +not claim deployed availability. ## Sources reviewed @@ -33,6 +39,21 @@ campaign resume --project-slug stop --project-slug --yes requeue --project-slug [--include-skipped-error] + recipients + add --project-slug (--csv | --input ) + list --project-slug [--status ] [--cursor] [--limit] + +audience + create --name [--csv | --file-id --mapping ] + members + add (--csv | --input ) + list [--cursor] [--limit] + remove + +optout + settings + view + update --enabled|--no-enabled --opt-out-keyword ... [--opt-in-keyword ...] ``` The CLI uses only `MessagingClient.campaigns`. Every campaign command requires an @@ -61,38 +82,121 @@ a terminal transition and requires `--yes`. Launch, pause, resume, and stop append durable commands and return an operation ID plus the campaign's present state. Human output says “Accepted” and does not -say the transition completed. The returned IDs belong to the Messaging campaign -API. Operation inspection is console-only, so the CLI cannot poll them. The -unpublished `--surface messaging` selector was removed rather than retained as -a compatibility alias. +say the transition completed. Use `operation view`, `operation wait`, or +`operation watch` to inspect the returned ID through the public Platform +operations resource; `operation cancel` requests supported cancellation. +These commands use their own team/project credential context. The unpublished +`--surface messaging` selector remains removed. Requeue is different: it is a direct transaction and returns only the number of recipients moved. Failed recipients are always considered; `--include-skipped-error` adds recipients skipped with an error. The CLI reports that count without inventing per-recipient outcomes. -Every mutation accepts `--idempotency-key` as request identity, but the pinned -handlers do not persist the header. The help text does not claim deduplication: -an unseen successful create can duplicate, a repeated lifecycle request can -conflict or append another intent, and a repeated requeue normally returns -zero after the first transaction moved its eligible recipients. +Create, launch, pause, resume and stop accept `--idempotency-key` with a 24-hour +server deduplication window. The middleware is mounted after authentication, +rate limits and client rules, before the campaign routes under `/messaging`. +Its identity includes the credential, project, method, route, API version and +key; the fingerprint includes path parameters and the canonical request body. +A repeated successful request returns `409 idempotency_completed`, not the +original response body. Changed input returns `409 idempotency_conflict`; +in-flight requests return `idempotency_in_progress`, and uncertain outcomes +remain fenced as `idempotency_outcome_unknown`. Do not switch keys merely to +bypass an uncertain result; inspect the campaign or operation first. The SDK +generates a key for create and launch when none is supplied; pause, resume and +stop require the flag to opt into this protection across calls. + +Requeue is not in that middleware's operation list. Its handler calls +`CampaignRepository.apiRequeue` directly, so `--idempotency-key` carries no +replay guarantee. Each invocation moves the rows eligible at that moment; a +retry can return zero or move recipients that failed since the first attempt. + +Recipient and audience-member appends have no declared replay at all. From SDK +`b663914` the SDK generates no key for them and sends each append once unless +the caller sets both a per-request retry count and a key, and the CLI never +sets a retry count. A resent +append would report the first attempt's rows as duplicates, so the flag help +says the request is not retried. List returns the complete newest-first array. There is no cursor or automatic pagination. JSON output preserves complete campaign and analytics objects, including live counters and forward-compatible fields; human output selects stable operational fields. +## Campaigns P0 additions + +Stop no longer always starts an operation. The contract answers with a nullable +`operationId`: a campaign with no active delivery run is cancelled immediately. +Human output distinguishes the two outcomes instead of printing +"operation null", which is what the previous renderer would have produced. + +Recipient listing is the first cursor-paged campaign read. It mirrors the +Platform response exactly (`{data, page}`) rather than collapsing it, so JSON +consumers keep `page.nextCursor` and `page.hasMore`. Automatic multi-page +collection was deliberately not added: the existing `--paginate`/`--max-items` +machinery belongs to the developer-event commands and their owner semantics, +and campaign recipient reads have no owner flag. One page per invocation keeps +the command honest about what it fetched. + +Appends accept partial success. The API skips repeated phone numbers and +reports invalid entries rather than rejecting the batch, so the CLI prints +`added`, `duplicateCount`, `invalidCount` and the rejected rows instead of +claiming every entry landed. The 1,000-entry cap is checked before transport so +an oversized file fails with a specific message rather than a 400. + +`--csv` reads a spreadsheet locally and sends its rows inline. It does not +implement the API's two-step file upload: that flow issues an upload URL, takes +a POST of the bytes, and returns a storage ID. Inventing a client for it was +out of scope, so `--file-id` accepts a storage ID the caller already obtained +and reaches the server-side import through the documented `mapping` shape. + +Column matching follows the API: the exact header first, then a case-folded +match. `--variable name=column` is repeatable. oclif splits repeated flag values +on whitespace, so a column name containing a space must be quoted; this is a +shell-level requirement, not a CLI limitation, and the help text says so. + +Opt-out settings are a replace, not a patch. The API requires `enabled`, +`optOutKeywords` and `optInKeywords` together, so the CLI requires +`--enabled`/`--no-enabled` and at least one `--opt-out-keyword`, and refuses a +partial update rather than silently clearing keywords the caller omitted. +Keywords are de-duplicated in order before transport. + +The Platform campaign update route accepts `recipientListId` to point an +unlaunched draft at another audience. The CLI exposes no campaign update +command: the Messaging surface the `campaign` topic is built on has no update +route, and the Platform one keeps an open request body. That operation stays +reachable through `polymorfa api`. Adding a `campaign update` command would +mean introducing a Platform-backed command under a Messaging-scoped topic that +already takes `--project-slug` rather than a project ID, which is the confusion +this topic was designed to avoid. + +The same revision declares `projectId` as a query parameter on the +single-campaign Platform operations, and a later one declares the `409` that +update answers after launch. No CLI command calls those operations, so no flag +changed. The API schema permits an omitted `projectId` for project tokens; +the SDK's organization-only campaign resource requires it. Its create method +also requires the typed `CreatePlatformCampaignRequest` with a name and project +ID. CLI campaign commands still use the separate Messaging resource with an +explicit project slug. + ## Explicit gaps -The source exposes no Messaging campaign update, delete, archive, duplicate, -validation, recipient list, event inspection, history, replay, watch, listener, -or operation cancel route. It also exposes no response cursor. The CLI does not -substitute similarly named Platform campaign operations or use `raw.request` to -invent those workflows. +The Messaging surface now exposes recipient listing and appending; the CLI +covers both. It still exposes no campaign update, delete, archive, duplicate, +validation, event inspection, history, replay, watch, listener, or operation +cancel route. The separate Platform-backed `operation` commands inspect, wait +for and cancel supported campaign operations; they do not add those missing +Messaging campaign routes. + +Audience listing, retrieval and deletion, the upload-URL route, and the +single/batch opt-out phone routes remain uncovered by dedicated commands in +this slice: they keep open `PlatformPayload` contracts in the SDK and are +reachable through `polymorfa api`. Only the typed Campaigns P0 operations got +commands. Stripe `listen` and Wrangler `tail` informed the separate Polymorfa developer event listener. It is not a campaign watch command and does not change the campaign API gaps above. -At this exact baseline the SDK ledger records 331 total / 207 covered / 58 -missing / 66 excluded / 0 partial / 0 changed operations. +At this exact baseline the SDK ledger records 441 total / 325 covered / 5 +missing / 111 excluded / 0 partial / 0 changed operations. diff --git a/package.json b/package.json index 1960af08..3196f0bd 100644 --- a/package.json +++ b/package.json @@ -54,12 +54,18 @@ "commands": "./dist/commands", "topicSeparator": " ", "topics": { - "auth": { - "description": "Manage authentication profiles and credentials." + "audience": { + "description": "Manage campaign audiences and their members." + }, + "audience:members": { + "description": "Add, list, and remove audience members." }, "audit-log": { "description": "Inspect organization audit activity." }, + "auth": { + "description": "Manage authentication profiles and credentials." + }, "business": { "description": "Manage Linked Device Business App resources." }, @@ -111,6 +117,9 @@ "campaign": { "description": "Inspect and manage Messaging campaigns." }, + "campaign:recipients": { + "description": "Inspect and append campaign recipients." + }, "channel": { "description": "Inspect and manage Messaging channels." }, @@ -144,6 +153,9 @@ "group:participants": { "description": "List and change group participants and admins." }, + "identity": { + "description": "Resolve phone numbers and usernames to Polymorfa user IDs." + }, "key": { "description": "Inspect and revoke organization API keys." }, @@ -153,8 +165,11 @@ "label:chat": { "description": "Manage the complete label set assigned to a chat." }, - "identity": { - "description": "Resolve phone numbers and usernames to Polymorfa user IDs." + "media": { + "description": "Inspect, download, and persist Messaging media." + }, + "member": { + "description": "Inspect organization members." }, "message": { "description": "Send messages and perform message actions." @@ -162,11 +177,23 @@ "message:send": { "description": "Send a message by content type." }, - "media": { - "description": "Inspect, download, and persist Messaging media." + "optout": { + "description": "Manage organization opt-out suppression." }, - "member": { - "description": "Inspect organization members." + "optout:settings": { + "description": "Inspect and replace STOP/START keyword capture." + }, + "presence": { + "description": "View and manage Messaging presence state." + }, + "presence:chat": { + "description": "View and renew policy-governed chat presence observations." + }, + "privacy": { + "description": "View and manage account-level Messaging privacy." + }, + "privacy:disappearing-timer": { + "description": "Manage the account-default disappearing timer." }, "profile": { "description": "View and manage the connected account profile." @@ -180,18 +207,6 @@ "profile:status": { "description": "Manage the connected account profile status." }, - "privacy": { - "description": "View and manage account-level Messaging privacy." - }, - "privacy:disappearing-timer": { - "description": "Manage the account-default disappearing timer." - }, - "presence": { - "description": "View and manage Messaging presence state." - }, - "presence:chat": { - "description": "View and renew policy-governed chat presence observations." - }, "project": { "description": "Select, list, and inspect the project context." }, @@ -210,27 +225,24 @@ "session": { "description": "Manage Platform sessions." }, - "sip-trunk": { - "description": "Connect a PBX to WhatsApp calls with SIP trunks." - }, "session-ban": { "description": "Inspect recorded organization session bans." }, - "user": { - "description": "Inspect Linked Device user identity data." - }, - "widget": { - "description": "Inspect and update saved QuickLink widget settings." - }, "session:configuration": { "description": "Inspect and update inherited session configuration." }, + "sip-trunk": { + "description": "Connect a PBX to WhatsApp calls with SIP trunks." + }, "testing": { "description": "Manage simulated session fixtures." }, "testing:history-fixture": { "description": "Upload project-scoped test history." }, + "user": { + "description": "Inspect Linked Device user identity data." + }, "webhook": { "description": "Inspect and manage developer webhooks." }, @@ -243,6 +255,9 @@ "webhook:secret": { "description": "Manage one-time webhook signing-secret rotation." }, + "widget": { + "description": "Inspect and update saved QuickLink widget settings." + }, "function": { "description": "Manage project Functions and pinned deployments." }, diff --git a/src/commands/audience/create.ts b/src/commands/audience/create.ts new file mode 100644 index 00000000..ebce957d --- /dev/null +++ b/src/commands/audience/create.ts @@ -0,0 +1,119 @@ +import {Flags} from '@oclif/core' + +import {readJsonInput} from '../../api/request.js' +import {BaseCommand} from '../../base-command.js' +import { + assertBatchLimit, + csvInvalidRows, + parseVariableMapping, + readCsvRecipients, + RECIPIENT_BATCH_LIMIT, +} from '../../input/recipients.js' +import { + audienceMapping, + createAudience, + renderAudienceImport, +} from '../../services/audiences.js' + +export default class AudienceCreate extends BaseCommand { + static override description = `Create an audience. --csv reads a spreadsheet and sends up to ${RECIPIENT_BATCH_LIMIT} members inline; --file-id imports a file already uploaded through the API.` + + static override examples = [ + '<%= config.bin %> <%= command.id %> --name "August launch" --csv contacts.csv --variable firstName="First name"', + '<%= config.bin %> <%= command.id %> --name "August launch" --file-id upload_1 --mapping mapping.json', + ] + + static override flags = { + csv: Flags.string({ + description: 'Read members from this spreadsheet and send them inline.', + exclusive: ['file-id'], + }), + 'file-id': Flags.string({ + dependsOn: ['mapping'], + description: + 'Storage ID of a file already uploaded through the API. Requires --mapping.', + exclusive: ['csv'], + }), + 'idempotency-key': Flags.string({ + description: + 'Send this Idempotency-Key header. None is generated when omitted.', + }), + mapping: Flags.string({ + dependsOn: ['file-id'], + description: + 'Read the column mapping JSON ({"phone": "...", "variables": {...}}) from a file or stdin (-).', + required: false, + }), + name: Flags.string({description: 'Audience name.', required: true}), + 'phone-column': Flags.string({ + dependsOn: ['csv'], + description: 'Header naming the phone column. Defaults to phone.', + }), + variable: Flags.string({ + dependsOn: ['csv'], + description: + 'Map a template variable to a column, as name=column. Repeatable; quote a column name that contains spaces.', + multiple: true, + }), + } + + async run(): Promise { + const {flags} = await this.parse(AudienceCreate) + const body = await this.buildBody(flags) + const result = await createAudience( + await this.platformClient(flags), + body, + flags['idempotency-key'] === undefined + ? {} + : {idempotencyKey: flags['idempotency-key']}, + ) + const presented = { + ...result, + data: { + ...result.data, + invalidRows: + 'members' in body && body.members !== undefined + ? csvInvalidRows(body.members, result.data.invalidRows) + : result.data.invalidRows, + }, + } + this.writeValue(presented, renderAudienceImport(presented.data), flags) + } + + private async buildBody(flags: { + readonly csv?: string | undefined + readonly 'file-id'?: string | undefined + readonly mapping?: string | undefined + readonly name: string + readonly 'phone-column'?: string | undefined + readonly variable?: string[] | undefined + }) { + if (flags['file-id'] !== undefined) { + return { + fileId: flags['file-id'], + mapping: audienceMapping(await readJsonInput(requiredMapping(flags))), + name: flags.name, + source: 'csv' as const, + } + } + if (flags.csv !== undefined) { + const members = await readCsvRecipients(flags.csv, { + phoneColumn: flags['phone-column'] ?? 'phone', + variables: parseVariableMapping(flags.variable ?? []), + }) + assertBatchLimit(members) + return {members, name: flags.name, source: 'csv' as const} + } + return {name: flags.name, source: 'manual' as const} + } +} + +/** The API refuses a file import without a mapping; oclif enforces the pair. */ +function requiredMapping(flags: { + readonly mapping?: string | undefined +}): string { + if (flags.mapping === undefined) { + throw new Error('--mapping is required with --file-id.') + } + return flags.mapping +} diff --git a/src/commands/audience/members/add.ts b/src/commands/audience/members/add.ts new file mode 100644 index 00000000..14935d77 --- /dev/null +++ b/src/commands/audience/members/add.ts @@ -0,0 +1,83 @@ +import {Args, Flags} from '@oclif/core' + +import {readJsonInput} from '../../../api/request.js' +import {BaseCommand} from '../../../base-command.js' +import { + assertBatchLimit, + csvInvalidRows, + parseVariableMapping, + readCsvRecipients, + recipientsFromJson, + RECIPIENT_BATCH_LIMIT, +} from '../../../input/recipients.js' +import { + addAudienceMembers, + renderMemberAppend, +} from '../../../services/audiences.js' + +export default class AudienceMembersAdd extends BaseCommand { + static override args = { + audience: Args.string({description: 'Audience ID.', required: true}), + } + + static override description = `Add up to ${RECIPIENT_BATCH_LIMIT} members to an audience. Repeated phone numbers are skipped and invalid entries are reported, not added.` + + static override examples = [ + '<%= config.bin %> <%= command.id %> 018f0000-0000-7000-8000-000000000001 --csv contacts.csv', + ] + + static override flags = { + csv: Flags.string({ + description: 'Read members from this spreadsheet and send them inline.', + exactlyOne: ['csv', 'input'], + }), + 'idempotency-key': Flags.string({ + description: + 'Send this Idempotency-Key header. The API does not replay appends, so the request is sent once and not retried.', + }), + input: Flags.string({ + description: + 'Read a JSON array of {phone, variables} from a file or stdin (-).', + exactlyOne: ['csv', 'input'], + }), + 'phone-column': Flags.string({ + dependsOn: ['csv'], + description: 'Header naming the phone column. Defaults to phone.', + }), + variable: Flags.string({ + dependsOn: ['csv'], + description: + 'Map a template variable to a column, as name=column. Repeatable; quote a column name that contains spaces.', + multiple: true, + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(AudienceMembersAdd) + const members = + flags.csv === undefined + ? recipientsFromJson(await readJsonInput(flags.input!)) + : await readCsvRecipients(flags.csv, { + phoneColumn: flags['phone-column'] ?? 'phone', + variables: parseVariableMapping(flags.variable ?? []), + }) + assertBatchLimit(members) + + const result = await addAudienceMembers( + await this.platformClient(flags), + args.audience, + members, + flags['idempotency-key'] === undefined + ? {} + : {idempotencyKey: flags['idempotency-key']}, + ) + const presented = { + ...result, + data: { + ...result.data, + invalidRows: csvInvalidRows(members, result.data.invalidRows), + }, + } + this.writeValue(presented, renderMemberAppend(presented.data), flags) + } +} diff --git a/src/commands/audience/members/list.ts b/src/commands/audience/members/list.ts new file mode 100644 index 00000000..8eb15c1e --- /dev/null +++ b/src/commands/audience/members/list.ts @@ -0,0 +1,37 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import { + listAudienceMembers, + renderAudienceMembers, +} from '../../../services/audiences.js' + +export default class AudienceMembersList extends BaseCommand { + static override args = { + audience: Args.string({description: 'Audience ID.', required: true}), + } + + static override description = 'List one page of an audience’s members.' + + static override flags = { + cursor: Flags.string({description: 'Continue from this opaque cursor.'}), + limit: Flags.integer({ + description: 'Request this many members. The API defaults to 25.', + max: 100, + min: 1, + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(AudienceMembersList) + const result = await listAudienceMembers( + await this.platformClient(flags), + args.audience, + { + ...(flags.cursor === undefined ? {} : {cursor: flags.cursor}), + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + }, + ) + this.writeValue(result, renderAudienceMembers(result.data), flags) + } +} diff --git a/src/commands/audience/members/remove.ts b/src/commands/audience/members/remove.ts new file mode 100644 index 00000000..84889d39 --- /dev/null +++ b/src/commands/audience/members/remove.ts @@ -0,0 +1,29 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import { + removeAudienceMember, + renderMemberRemoval, +} from '../../../services/audiences.js' + +export default class AudienceMembersRemove extends BaseCommand { + static override args = { + audience: Args.string({description: 'Audience ID.', required: true}), + phone: Args.string({ + description: 'Member phone number in international format.', + required: true, + }), + } + + static override description = 'Remove one member from an audience.' + + async run(): Promise { + const {args, flags} = await this.parse(AudienceMembersRemove) + const result = await removeAudienceMember( + await this.platformClient(flags), + args.audience, + args.phone, + ) + this.writeValue(result, renderMemberRemoval(result.data), flags) + } +} diff --git a/src/commands/campaign/pause.ts b/src/commands/campaign/pause.ts index cfbe7935..232c3685 100644 --- a/src/commands/campaign/pause.ts +++ b/src/commands/campaign/pause.ts @@ -37,7 +37,7 @@ function lifecycleFlags() { return { 'idempotency-key': Flags.string({ description: - 'Identify this attempt (the API does not deduplicate lifecycle commands).', + 'Deduplicate this request for 24 hours. A completed key returns 409 idempotency_completed.', }), 'project-slug': Flags.string({ description: 'Project slug in the Messaging API path.', diff --git a/src/commands/campaign/recipients/add.ts b/src/commands/campaign/recipients/add.ts new file mode 100644 index 00000000..f4537d19 --- /dev/null +++ b/src/commands/campaign/recipients/add.ts @@ -0,0 +1,90 @@ +import {Args, Flags} from '@oclif/core' + +import {readJsonInput} from '../../../api/request.js' +import {BaseCommand} from '../../../base-command.js' +import { + assertBatchLimit, + csvInvalidRows, + parseVariableMapping, + readCsvRecipients, + recipientsFromJson, + RECIPIENT_BATCH_LIMIT, +} from '../../../input/recipients.js' +import { + addCampaignRecipients, + renderRecipientAppend, +} from '../../../services/campaigns.js' + +export default class CampaignRecipientsAdd extends BaseCommand { + static override args = { + campaign: Args.string({description: 'Campaign ID.', required: true}), + } + + static override description = `Add up to ${RECIPIENT_BATCH_LIMIT} recipients to a campaign that has not started sending. Repeated phone numbers are skipped and invalid entries are reported, not added.` + + static override examples = [ + '<%= config.bin %> <%= command.id %> 018f0000-0000-7000-8000-000000000001 --project-slug launch --csv contacts.csv --variable firstName="First name"', + '<%= config.bin %> <%= command.id %> 018f0000-0000-7000-8000-000000000001 --project-slug launch --input recipients.json', + ] + + static override flags = { + csv: Flags.string({ + description: + 'Read recipients from this spreadsheet and send them inline.', + exactlyOne: ['csv', 'input'], + }), + 'idempotency-key': Flags.string({ + description: + 'Send this Idempotency-Key header. The API does not replay appends, so the request is sent once and not retried.', + }), + input: Flags.string({ + description: + 'Read a JSON array of {phone, variables} from a file or stdin (-).', + exactlyOne: ['csv', 'input'], + }), + 'phone-column': Flags.string({ + dependsOn: ['csv'], + description: 'Header naming the phone column. Defaults to phone.', + }), + 'project-slug': Flags.string({ + description: 'Project slug in the Messaging API path.', + required: true, + }), + variable: Flags.string({ + dependsOn: ['csv'], + description: + 'Map a template variable to a column, as name=column. Repeatable; quote a column name that contains spaces.', + multiple: true, + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(CampaignRecipientsAdd) + const recipients = + flags.csv === undefined + ? recipientsFromJson(await readJsonInput(flags.input!)) + : await readCsvRecipients(flags.csv, { + phoneColumn: flags['phone-column'] ?? 'phone', + variables: parseVariableMapping(flags.variable ?? []), + }) + assertBatchLimit(recipients) + + const result = await addCampaignRecipients( + await this.messagingClient(flags, {serverApiKeyOnly: 'Campaign'}), + flags['project-slug'], + args.campaign, + recipients, + flags['idempotency-key'] === undefined + ? {} + : {idempotencyKey: flags['idempotency-key']}, + ) + const presented = { + ...result, + data: { + ...result.data, + invalidRows: csvInvalidRows(recipients, result.data.invalidRows), + }, + } + this.writeValue(presented, renderRecipientAppend(presented.data), flags) + } +} diff --git a/src/commands/campaign/recipients/list.ts b/src/commands/campaign/recipients/list.ts new file mode 100644 index 00000000..9a7354d2 --- /dev/null +++ b/src/commands/campaign/recipients/list.ts @@ -0,0 +1,57 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import { + listCampaignRecipients, + recipientStatus, + renderCampaignRecipients, + RECIPIENT_STATUSES, +} from '../../../services/campaigns.js' + +export default class CampaignRecipientsList extends BaseCommand { + static override args = { + campaign: Args.string({description: 'Campaign ID.', required: true}), + } + + static override description = + 'List one page of a campaign’s recipients in queue order.' + + static override examples = [ + '<%= config.bin %> <%= command.id %> 018f0000-0000-7000-8000-000000000001 --project-slug launch --status skipped', + ] + + static override flags = { + cursor: Flags.string({description: 'Continue from this opaque cursor.'}), + limit: Flags.integer({ + description: 'Request this many recipients. The API defaults to 25.', + max: 100, + min: 1, + }), + 'project-slug': Flags.string({ + description: 'Project slug in the Messaging API path.', + required: true, + }), + status: Flags.string({ + description: + 'Filter by delivery status. Use skipped to find recipients suppressed by an opt-out.', + options: [...RECIPIENT_STATUSES], + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(CampaignRecipientsList) + const result = await listCampaignRecipients( + await this.messagingClient(flags, {serverApiKeyOnly: 'Campaign'}), + flags['project-slug'], + args.campaign, + { + ...(flags.cursor === undefined ? {} : {cursor: flags.cursor}), + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + ...(flags.status === undefined + ? {} + : {status: recipientStatus(flags.status)!}), + }, + ) + this.writeValue(result, renderCampaignRecipients(result.data), flags) + } +} diff --git a/src/commands/campaign/requeue.ts b/src/commands/campaign/requeue.ts index 0dbeb171..8c41539a 100644 --- a/src/commands/campaign/requeue.ts +++ b/src/commands/campaign/requeue.ts @@ -17,7 +17,7 @@ export default class CampaignRequeue extends BaseCommand { static override flags = { 'idempotency-key': Flags.string({ description: - 'Identify this attempt (retries normally find no remaining recipients).', + 'Send this request identity without replay protection. A retry can requeue newly failed recipients.', }), 'include-skipped-error': Flags.boolean({ description: 'Also requeue recipients skipped with an error.', diff --git a/src/commands/campaign/resume.ts b/src/commands/campaign/resume.ts index 3aa4eaa0..1f01de72 100644 --- a/src/commands/campaign/resume.ts +++ b/src/commands/campaign/resume.ts @@ -16,7 +16,7 @@ export default class CampaignResume extends BaseCommand { static override flags = { 'idempotency-key': Flags.string({ description: - 'Identify this attempt (the API does not deduplicate lifecycle commands).', + 'Deduplicate this request for 24 hours. A completed key returns 409 idempotency_completed.', }), 'project-slug': Flags.string({ description: 'Project slug in the Messaging API path.', diff --git a/src/commands/campaign/stop.ts b/src/commands/campaign/stop.ts index 6a386464..a9bfeb68 100644 --- a/src/commands/campaign/stop.ts +++ b/src/commands/campaign/stop.ts @@ -1,22 +1,20 @@ import {Args, Flags} from '@oclif/core' import {BaseCommand} from '../../base-command.js' -import { - renderCampaignOperation, - stopCampaign, -} from '../../services/campaigns.js' +import {renderCampaignStop, stopCampaign} from '../../services/campaigns.js' export default class CampaignStop extends BaseCommand { static override args = { campaign: Args.string({description: 'Campaign ID.', required: true}), } - static override description = 'Permanently stop a campaign.' + static override description = + 'Permanently stop a campaign. A campaign with no active delivery run is cancelled immediately and returns no operation ID.' static override flags = { 'idempotency-key': Flags.string({ description: - 'Identify this attempt (the API does not deduplicate lifecycle commands).', + 'Deduplicate this request for 24 hours. A completed key returns 409 idempotency_completed.', }), 'project-slug': Flags.string({ description: 'Project slug in the Messaging API path.', @@ -38,6 +36,6 @@ export default class CampaignStop extends BaseCommand { ? {} : {idempotencyKey: flags['idempotency-key']}, ) - this.writeValue(result, renderCampaignOperation('stop', result.data), flags) + this.writeValue(result, renderCampaignStop(result.data), flags) } } diff --git a/src/commands/optout/settings/update.ts b/src/commands/optout/settings/update.ts new file mode 100644 index 00000000..293be9ec --- /dev/null +++ b/src/commands/optout/settings/update.ts @@ -0,0 +1,53 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import { + optOutSettingsUpdate, + renderOptOutSettings, + updateOptOutSettings, +} from '../../../services/opt-outs.js' + +export default class OptOutSettingsUpdate extends BaseCommand { + static override description = + 'Replace the organization’s STOP and START keyword capture settings. Every field is replaced, so pass the complete keyword set.' + + static override examples = [ + '<%= config.bin %> <%= command.id %> --enabled --opt-out-keyword STOP --opt-out-keyword BAJA --opt-in-keyword START', + '<%= config.bin %> <%= command.id %> --no-enabled --opt-out-keyword STOP', + ] + + static override flags = { + enabled: Flags.boolean({ + allowNo: true, + description: + 'Capture keyword replies. Pass --no-enabled to stop capturing.', + required: true, + }), + 'idempotency-key': Flags.string({ + description: + 'Send this Idempotency-Key header. None is generated when omitted.', + }), + 'opt-in-keyword': Flags.string({ + description: 'A keyword that opts a contact back in. Repeatable.', + multiple: true, + }), + 'opt-out-keyword': Flags.string({ + description: + 'A keyword that suppresses a contact. Repeatable; at least one is required.', + multiple: true, + required: true, + }), + } + + async run(): Promise { + const {flags} = await this.parse(OptOutSettingsUpdate) + const result = await updateOptOutSettings( + await this.platformClient(flags), + optOutSettingsUpdate(flags), + flags['idempotency-key'] === undefined + ? {} + : {idempotencyKey: flags['idempotency-key']}, + ) + this.writeValue(result, renderOptOutSettings(result.data), flags) + } +} diff --git a/src/commands/optout/settings/view.ts b/src/commands/optout/settings/view.ts new file mode 100644 index 00000000..3062928f --- /dev/null +++ b/src/commands/optout/settings/view.ts @@ -0,0 +1,16 @@ +import {BaseCommand} from '../../../base-command.js' +import { + renderOptOutSettings, + viewOptOutSettings, +} from '../../../services/opt-outs.js' + +export default class OptOutSettingsView extends BaseCommand { + static override description = + 'Show the organization’s STOP and START keyword capture settings.' + + async run(): Promise { + const {flags} = await this.parse(OptOutSettingsView) + const result = await viewOptOutSettings(await this.platformClient(flags)) + this.writeValue(result, renderOptOutSettings(result.data), flags) + } +} diff --git a/src/input/recipients.ts b/src/input/recipients.ts new file mode 100644 index 00000000..59064cec --- /dev/null +++ b/src/input/recipients.ts @@ -0,0 +1,404 @@ +import {readFile, stat} from 'node:fs/promises' + +import { + type CampaignRecipientInput, + type InvalidRecipientRow, + PolymorfaValidationError, +} from '@polymorfa/sdk' + +/** The API accepts at most this many entries in one append. */ +export const RECIPIENT_BATCH_LIMIT = 1000 + +const MAX_CSV_BYTES = 10 * 1024 * 1024 +const VARIABLE_NAME = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/ +const MAX_VARIABLES = 50 +const MAX_VARIABLE_VALUE_LENGTH = 1024 +const MAX_REPORTED_ROWS = 20 +/** The request schema's maximum phone length. */ +const MAX_PHONE_LENGTH = 64 +const csvSourceRows = new WeakMap< + readonly CampaignRecipientInput[], + readonly number[] +>() + +export interface CsvRecipientOptions { + /** Header naming the phone column. */ + readonly phoneColumn: string + /** Variable name to header name. */ + readonly variables: Readonly> +} + +/** + * Read recipients from a spreadsheet the caller already has. + * + * This sends the rows inline. It does not upload the file: the API's file + * import is a separate two-step flow, and `--file-id` reaches it for callers + * who have already uploaded through the API. + */ +export async function readCsvRecipients( + path: string, + options: CsvRecipientOptions, +): Promise { + const text = await readCsvFile(path) + const records = parseCsv(text, options.phoneColumn) + const header = records[0]?.map((cell) => cell.trim()) + if (header === undefined || header.every((cell) => cell === '')) { + throw invalid(`${path} has no header row.`, 'csv_missing_header') + } + + const phoneIndex = columnIndex(header, options.phoneColumn) + if (phoneIndex === -1) { + throw invalid( + `${path} has no ${options.phoneColumn} column. Columns: ${header.join(', ')}.`, + 'csv_missing_phone_column', + ) + } + + const variableIndexes = new Map() + for (const [name, column] of Object.entries(options.variables)) { + const index = columnIndex(header, column) + if (index === -1) { + throw invalid( + `${path} has no ${column} column for variable ${name}.`, + 'csv_missing_variable_column', + ) + } + variableIndexes.set(name, index) + } + + const recipients: CampaignRecipientInput[] = [] + const sourceRows: number[] = [] + const missingPhoneRows: number[] = [] + for (const [index, record] of records.slice(1).entries()) { + if (record.every((cell) => cell.trim() === '')) continue + const phone = (record[phoneIndex] ?? '').trim() + if (phone === '') { + // Header is row 1, matching the API's row numbers for file imports. + missingPhoneRows.push(index + 2) + continue + } + if (phone.length > MAX_PHONE_LENGTH) { + throw invalid( + `${path} row ${index + 2} has a phone longer than ${MAX_PHONE_LENGTH} characters (the header is row 1); nothing was sent.`, + 'invalid_phone', + ) + } + const variables: Record = {} + for (const [name, column] of variableIndexes) { + const value = (record[column] ?? '').trim() + if (value.length > MAX_VARIABLE_VALUE_LENGTH) { + throw invalid( + `${path} row ${index + 2} has a ${name} value longer than ${MAX_VARIABLE_VALUE_LENGTH} characters; nothing was sent.`, + 'invalid_recipient_variables', + ) + } + if (value !== '') variables[name] = value + } + recipients.push( + Object.keys(variables).length === 0 ? {phone} : {phone, variables}, + ) + sourceRows.push(index + 2) + } + + if (missingPhoneRows.length > 0) { + // Refuse rather than drop them: the API can only report entries it + // receives, so a skipped row would vanish from the invalid counts. + const shown = missingPhoneRows.slice(0, MAX_REPORTED_ROWS).join(', ') + const more = + missingPhoneRows.length > MAX_REPORTED_ROWS + ? ` and ${missingPhoneRows.length - MAX_REPORTED_ROWS} more` + : '' + throw invalid( + `${path} has ${missingPhoneRows.length} row${missingPhoneRows.length === 1 ? '' : 's'} with an empty ${options.phoneColumn} value (row ${shown}${more}; the header is row 1). Fill or remove them; nothing was sent.`, + 'csv_missing_phone', + ) + } + if (recipients.length === 0) { + throw invalid(`${path} has no rows with a phone number.`, 'csv_empty') + } + csvSourceRows.set(recipients, sourceRows) + return recipients +} + +/** Translate API input indexes back to spreadsheet rows, including the header. */ +export function csvInvalidRows( + recipients: readonly CampaignRecipientInput[], + rows: readonly InvalidRecipientRow[], +): InvalidRecipientRow[] { + const sourceRows = csvSourceRows.get(recipients) + if (sourceRows === undefined) return [...rows] + return rows.map((row) => ({ + ...row, + row: sourceRows[row.row - 1] ?? row.row, + })) +} + +/** `name=Column` pairs from repeated --variable flags. */ +export function parseVariableMapping( + pairs: readonly string[], +): Record { + const mapping: Record = {} + for (const pair of pairs) { + const separator = pair.indexOf('=') + if (separator < 1) { + throw invalid( + `--variable must be name=column, received: ${pair}`, + 'invalid_variable_mapping', + ) + } + const name = pair.slice(0, separator).trim() + const column = pair.slice(separator + 1).trim() + // `__proto__` passes the name pattern but a plain-object assignment would + // hit the prototype setter and silently drop the mapping. + if (name === '__proto__') { + throw invalid( + '__proto__ is reserved and cannot be a variable name.', + 'invalid_variable_name', + ) + } + if (!VARIABLE_NAME.test(name)) { + throw invalid( + `${name} is not a valid variable name. Use letters, digits, _, . or -.`, + 'invalid_variable_name', + ) + } + if (column === '') { + throw invalid( + `--variable ${name} must name a column.`, + 'invalid_variable_mapping', + ) + } + mapping[name] = column + } + if (Object.keys(mapping).length > MAX_VARIABLES) { + throw invalid( + `At most ${MAX_VARIABLES} variables are accepted.`, + 'too_many_variables', + ) + } + return mapping +} + +/** Accept the same inline array the API takes, read from a file or stdin. */ +export function recipientsFromJson(input: unknown): CampaignRecipientInput[] { + const entries = Array.isArray(input) + ? input + : isRecord(input) && Array.isArray(input.recipients) + ? input.recipients + : isRecord(input) && Array.isArray(input.members) + ? input.members + : undefined + if (entries === undefined) { + throw invalid( + 'Recipient input must be a JSON array, or an object with a recipients or members array.', + 'invalid_recipient_input', + ) + } + if (entries.length === 0) { + // Both append request schemas require at least one entry. + throw invalid( + 'Recipient input has no entries; nothing was sent.', + 'invalid_recipient_input', + ) + } + return entries.map((entry, index) => { + if (!isRecord(entry) || typeof entry.phone !== 'string') { + throw invalid( + `Entry ${index + 1} must be an object with a phone string.`, + 'invalid_recipient_entry', + ) + } + // The request schema requires 1 to 64 characters, so an entry outside + // that range fails the whole request instead of becoming an invalid row. + if (entry.phone.trim() === '') { + throw invalid( + `Entry ${index + 1} has an empty phone. Fill or remove it; nothing was sent.`, + 'missing_phone', + ) + } + if (entry.phone.length > MAX_PHONE_LENGTH) { + throw invalid( + `Entry ${index + 1} has a phone longer than ${MAX_PHONE_LENGTH} characters; nothing was sent.`, + 'invalid_phone', + ) + } + if (entry.variables !== undefined) { + if (!isRecord(entry.variables)) { + throw invalid( + `Entry ${index + 1} variables must be an object of strings.`, + 'invalid_recipient_variables', + ) + } + const variables = Object.entries(entry.variables) + if ( + variables.length > MAX_VARIABLES || + variables.some( + ([name, value]) => + name === '__proto__' || + !VARIABLE_NAME.test(name) || + typeof value !== 'string' || + value.length > MAX_VARIABLE_VALUE_LENGTH, + ) + ) { + throw invalid( + `Entry ${index + 1} has invalid variables; use at most ${MAX_VARIABLES} named string values of at most ${MAX_VARIABLE_VALUE_LENGTH} characters.`, + 'invalid_recipient_variables', + ) + } + } + return entry as unknown as CampaignRecipientInput + }) +} + +export function assertBatchLimit( + recipients: readonly CampaignRecipientInput[], +): void { + if (recipients.length > RECIPIENT_BATCH_LIMIT) { + throw invalid( + `One request accepts at most ${RECIPIENT_BATCH_LIMIT} entries; received ${recipients.length}. Split the file and run the command again.`, + 'too_many_recipients', + ) + } +} + +async function readCsvFile(path: string): Promise { + try { + const details = await stat(path) + if (details.size > MAX_CSV_BYTES) { + throw invalid( + 'Recipient files must not exceed 10 MiB.', + 'input_too_large', + ) + } + return await readFile(path, 'utf8') + } catch (error) { + if (error instanceof PolymorfaValidationError) throw error + throw new PolymorfaValidationError(`Could not read ${path}.`, { + cause: error, + code: 'input_read_failed', + }) + } +} + +/** Exact column name first, then a case-folded match, as the API does. */ +function columnIndex(header: readonly string[], name: string): number { + const exact = header.indexOf(name) + if (exact >= 0) return exact + const folded = name.toLowerCase() + return header.findIndex((cell) => cell.toLowerCase() === folded) +} + +/** + * Count separators outside quoted fields in the header record. Choose the + * most frequent one that exposes the requested phone column; otherwise use + * the most frequent separator so the missing-column error can name the fields. + */ +function detectDelimiter(text: string, phoneColumn: string): string { + const counts = new Map([ + [',', 0], + [';', 0], + ['\t', 0], + ]) + let quoted = false + for (let index = 0; index < text.length; index += 1) { + const char = text[index]! + if (quoted) { + if (char === '"') { + if (text[index + 1] === '"') index += 1 + else quoted = false + } + continue + } + if (char === '"') quoted = true + else if (char === '\n' || char === '\r') break + else if (counts.has(char)) counts.set(char, counts.get(char)! + 1) + } + const candidates = [...counts] + .filter(([, count]) => count > 0) + .sort((left, right) => right[1] - left[1]) + for (const [candidate] of candidates) { + const header = parseCsvWithDelimiter(text, candidate)[0]?.map((cell) => + cell.trim(), + ) + if (header !== undefined && columnIndex(header, phoneColumn) >= 0) { + return candidate + } + } + return candidates[0]?.[0] ?? ',' +} + +function parseCsv(input: string, phoneColumn: string): string[][] { + const text = input.charCodeAt(0) === 0xfe_ff ? input.slice(1) : input + const delimiter = detectDelimiter(text, phoneColumn) + return parseCsvWithDelimiter(text, delimiter) +} + +function parseCsvWithDelimiter(text: string, delimiter: string): string[][] { + const records: string[][] = [] + let record: string[] = [] + let field = '' + let quoted = false + let index = 0 + + const endField = () => { + record.push(field) + field = '' + } + const endRecord = () => { + endField() + records.push(record) + record = [] + } + + while (index < text.length) { + const char = text[index]! + if (quoted) { + if (char === '"') { + if (text[index + 1] === '"') { + field += '"' + index += 2 + continue + } + quoted = false + index += 1 + continue + } + field += char + index += 1 + continue + } + if (char === '"' && field === '') { + quoted = true + index += 1 + continue + } + if (char === delimiter) { + endField() + index += 1 + continue + } + if (char === '\n') { + endRecord() + index += 1 + continue + } + if (char === '\r') { + if (text[index + 1] === '\n') index += 1 + endRecord() + index += 1 + continue + } + field += char + index += 1 + } + if (field !== '' || record.length > 0) endRecord() + return records +} + +function invalid(message: string, code: string): PolymorfaValidationError { + return new PolymorfaValidationError(message, {code}) +} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/src/services/audiences.ts b/src/services/audiences.ts new file mode 100644 index 00000000..80975065 --- /dev/null +++ b/src/services/audiences.ts @@ -0,0 +1,150 @@ +import { + type AddAudienceMembersResult, + type AudienceImportResult, + type AudienceMember, + type AudienceImportMapping, + type AudienceMembersEnvelope, + type CampaignRecipientInput, + type ClientInstance, + type CreateAudienceRequest, + type DeleteAudienceMemberResult, + type ListAudienceMembersParams, + PolymorfaValidationError, + type RequestOptions, + type ResponseMetadata, +} from '@polymorfa/sdk' + +import {renderInvalidRows} from './campaigns.js' + +export interface AudienceResult { + readonly data: T + readonly metadata: { + readonly apiVersion?: string + readonly attempts: number + readonly requestId?: string + readonly status: number + } +} + +export async function createAudience( + client: ClientInstance<'organization'>, + body: CreateAudienceRequest, + options: RequestOptions = {}, +): Promise> { + const response = await client.audiences.create(body, options) + return result(response.data.data, response.metadata) +} + +export async function addAudienceMembers( + client: ClientInstance<'organization'>, + listId: string, + members: readonly CampaignRecipientInput[], + options: RequestOptions = {}, +): Promise> { + const response = await client.audiences.addMembers(listId, {members}, options) + return result(response.data.data, response.metadata) +} + +export async function listAudienceMembers( + client: ClientInstance<'organization'>, + listId: string, + params: ListAudienceMembersParams, +): Promise> { + const response = await client.audiences.listMembers(listId, params) + return result(response.data, response.metadata) +} + +export async function removeAudienceMember( + client: ClientInstance<'organization'>, + listId: string, + phone: string, + options: RequestOptions = {}, +): Promise> { + const response = await client.audiences.deleteMember(listId, phone, options) + return result(response.data.data, response.metadata) +} + +/** Parse a `--mapping` JSON document for the API's file import. */ +export function audienceMapping(input: unknown): AudienceImportMapping { + if (typeof input !== 'object' || input === null || Array.isArray(input)) { + throw new PolymorfaValidationError( + 'Mapping must be a JSON object naming the phone column.', + {code: 'invalid_audience_mapping'}, + ) + } + const record = input as Readonly> + if (typeof record.phone !== 'string' || record.phone.trim() === '') { + throw new PolymorfaValidationError( + 'Mapping must name the phone column as a non-empty string.', + {code: 'invalid_audience_mapping'}, + ) + } + return record as unknown as AudienceImportMapping +} + +export function renderAudienceImport(audience: AudienceImportResult): string { + return [ + `ID\t${audience.id}`, + `Name\t${audience.name}`, + `Source\t${audience.source}`, + `Members\t${audience.recipientCount}`, + `Duplicates skipped\t${audience.duplicateCount}`, + `Invalid\t${audience.invalidCount}`, + ...renderInvalidRows(audience.invalidRows), + ].join('\n') +} + +export function renderMemberAppend(result: AddAudienceMembersResult): string { + return [ + `Added\t${result.added}`, + `On audience\t${result.recipientCount}`, + `Duplicates skipped\t${result.duplicateCount}`, + `Invalid\t${result.invalidCount}`, + ...renderInvalidRows(result.invalidRows), + ].join('\n') +} + +export function renderAudienceMembers( + envelope: AudienceMembersEnvelope, +): string { + if (envelope.data.length === 0) return 'No members found.' + const rows = envelope.data.map((member: AudienceMember) => + [ + member.id, + member.phone, + Object.entries(member.variables) + .map(([name, value]) => `${name}=${value}`) + .join(' '), + ].join('\t'), + ) + if (envelope.page.hasMore && envelope.page.nextCursor !== null) { + rows.push( + `More members remain. Continue with --cursor ${envelope.page.nextCursor}.`, + ) + } + return rows.join('\n') +} + +export function renderMemberRemoval( + result: DeleteAudienceMemberResult, +): string { + return result.removed + ? `Removed ${result.phone} from audience ${result.listId}. ${result.recipientCount} members remain.` + : `${result.phone} was not in audience ${result.listId}. ${result.recipientCount} members remain.` +} + +function result(data: T, metadata: ResponseMetadata): AudienceResult { + return { + data, + metadata: { + attempts: metadata.attempts, + status: metadata.status, + ...(metadata.apiVersion === undefined + ? {} + : {apiVersion: metadata.apiVersion}), + ...(metadata.requestId === undefined + ? {} + : {requestId: metadata.requestId}), + }, + } +} diff --git a/src/services/campaigns.ts b/src/services/campaigns.ts index 3e74c0bb..79ef18af 100644 --- a/src/services/campaigns.ts +++ b/src/services/campaigns.ts @@ -1,10 +1,17 @@ import { + type AddCampaignRecipientsResult, type Campaign, type CampaignAnalytics, type CampaignOperation, - type CampaignStopOperation, + type CampaignRecipient, + type CampaignRecipientInput, + type CampaignRecipientPage, + type CampaignRecipientStatus, type CampaignRequeueResult, + type CampaignStopOperation, type CreateCampaignRequest, + type InvalidRecipientRow, + type ListCampaignRecipientsParams, type MessagingClient, PolymorfaValidationError, type RequestOptions, @@ -115,6 +122,44 @@ export async function stopCampaign( return result(response.data.data, response.metadata) } +export interface CampaignRecipientPageResult { + readonly data: readonly CampaignRecipient[] + readonly page: CampaignRecipientPage +} + +export async function listCampaignRecipients( + client: MessagingClient, + projectSlug: string, + campaignId: string, + params: ListCampaignRecipientsParams, +): Promise> { + const response = await client.campaigns.listRecipients( + projectSlug, + campaignId, + params, + ) + return result( + {data: response.data.data, page: response.data.page}, + response.metadata, + ) +} + +export async function addCampaignRecipients( + client: MessagingClient, + projectSlug: string, + campaignId: string, + recipients: readonly CampaignRecipientInput[], + options: RequestOptions = {}, +): Promise> { + const response = await client.campaigns.addRecipients( + projectSlug, + campaignId, + {recipients}, + options, + ) + return result(response.data.data, response.metadata) +} + export async function requeueCampaign( client: MessagingClient, projectSlug: string, @@ -210,6 +255,83 @@ export function renderCampaignRequeue(result: CampaignRequeueResult): string { return `Requeued ${result.requeued} ${result.requeued === 1 ? 'recipient' : 'recipients'}.` } +/** + * Stop answers with a null operation ID when the campaign had no delivery run + * to stop. Saying "accepted as operation null" would be untrue, so the two + * outcomes read differently. + */ +export function renderCampaignStop(campaign: CampaignStopOperation): string { + if (campaign.operationId === null) { + return [ + `Cancelled campaign ${campaign.id}; it had no active delivery run.`, + `The campaign is ${campaign.status}. No remaining recipient is sent.`, + ].join('\n') + } + return [ + `Accepted campaign stop as operation ${campaign.operationId}.`, + `Campaign ${campaign.id} is ${campaign.status}.`, + 'Check the campaign status with polymorfa campaign view.', + ].join('\n') +} + +export function renderCampaignRecipients( + result: CampaignRecipientPageResult, +): string { + if (result.data.length === 0) return 'No recipients found.' + const rows = result.data.map((recipient) => + [ + recipient.id, + recipient.phone, + recipient.status, + String(recipient.attempts), + recipient.lastError ?? '', + formatCampaignTime(recipient.sentAt), + ].join('\t'), + ) + if (result.page.hasMore && result.page.nextCursor !== null) { + rows.push( + `More recipients remain. Continue with --cursor ${result.page.nextCursor}.`, + ) + } + return rows.join('\n') +} + +export function renderRecipientAppend( + result: AddCampaignRecipientsResult, +): string { + return [ + `Added\t${result.added}`, + `On campaign\t${result.recipientCount}`, + `Duplicates skipped\t${result.duplicateCount}`, + `Invalid\t${result.invalidCount}`, + ...renderInvalidRows(result.invalidRows), + ].join('\n') +} + +/** Report why entries were rejected instead of only counting them. */ +export function renderInvalidRows( + rows: readonly InvalidRecipientRow[], +): string[] { + return rows.map((row) => `Row ${row.row}\t${row.reason}`) +} + +export function recipientStatus( + value?: string, +): CampaignRecipientStatus | undefined { + if (value === undefined) return undefined + return value as CampaignRecipientStatus +} + +export const RECIPIENT_STATUSES = [ + 'queued', + 'sending', + 'sent', + 'delivered', + 'read', + 'failed', + 'skipped', +] as const + function campaignInput(input: unknown): CreateCampaignRequest { if (!isRecord(input)) { throw new PolymorfaValidationError( diff --git a/src/services/opt-outs.ts b/src/services/opt-outs.ts new file mode 100644 index 00000000..00ed7424 --- /dev/null +++ b/src/services/opt-outs.ts @@ -0,0 +1,117 @@ +import { + type ClientInstance, + type OptOutSettings, + PolymorfaValidationError, + type RequestOptions, + type ResponseMetadata, + type UpdateOptOutSettingsRequest, +} from '@polymorfa/sdk' + +const MAX_KEYWORDS = 50 +const MAX_KEYWORD_LENGTH = 32 + +export interface OptOutResult { + readonly data: T + readonly metadata: { + readonly apiVersion?: string + readonly attempts: number + readonly requestId?: string + readonly status: number + } +} + +export async function viewOptOutSettings( + client: ClientInstance<'organization'>, +): Promise> { + const response = await client.optOuts.getSettings() + return result(response.data.data, response.metadata) +} + +export async function updateOptOutSettings( + client: ClientInstance<'organization'>, + body: UpdateOptOutSettingsRequest, + options: RequestOptions = {}, +): Promise> { + const response = await client.optOuts.updateSettings(body, options) + return result(response.data.data, response.metadata) +} + +/** + * The API replaces the whole settings document, so every field is required. + * Build the replacement from the flags, refusing a partial update rather than + * silently clearing keywords the caller did not mention. + */ +export function optOutSettingsUpdate(flags: { + readonly enabled?: boolean | undefined + readonly 'opt-in-keyword'?: string[] | undefined + readonly 'opt-out-keyword'?: string[] | undefined +}): UpdateOptOutSettingsRequest { + if (flags.enabled === undefined) { + throw invalid( + 'Pass --enabled or --no-enabled. The API replaces the whole settings document.', + 'missing_optout_enabled', + ) + } + const optOutKeywords = keywords(flags['opt-out-keyword'] ?? [], 'opt-out') + if (optOutKeywords.length === 0) { + throw invalid( + 'Pass at least one --opt-out-keyword. The API requires one keyword.', + 'missing_optout_keywords', + ) + } + return { + enabled: flags.enabled, + optInKeywords: keywords(flags['opt-in-keyword'] ?? [], 'opt-in'), + optOutKeywords, + } +} + +export function renderOptOutSettings(settings: OptOutSettings): string { + return [ + `Enabled\t${settings.enabled}`, + `Opt-out keywords\t${settings.optOutKeywords.join(', ')}`, + `Opt-in keywords\t${settings.optInKeywords.join(', ')}`, + `Updated\t${settings.updatedAt === null ? '' : new Date(settings.updatedAt).toISOString()}`, + ].join('\n') +} + +function keywords(values: readonly string[], label: string): string[] { + const seen = new Set() + for (const value of values) { + const keyword = value.trim() + if (keyword === '' || keyword.length > MAX_KEYWORD_LENGTH) { + throw invalid( + `Each ${label} keyword must be 1 to ${MAX_KEYWORD_LENGTH} characters.`, + 'invalid_optout_keyword', + ) + } + seen.add(keyword) + } + if (seen.size > MAX_KEYWORDS) { + throw invalid( + `At most ${MAX_KEYWORDS} ${label} keywords are accepted.`, + 'too_many_optout_keywords', + ) + } + return [...seen] +} + +function invalid(message: string, code: string): PolymorfaValidationError { + return new PolymorfaValidationError(message, {code}) +} + +function result(data: T, metadata: ResponseMetadata): OptOutResult { + return { + data, + metadata: { + attempts: metadata.attempts, + status: metadata.status, + ...(metadata.apiVersion === undefined + ? {} + : {apiVersion: metadata.apiVersion}), + ...(metadata.requestId === undefined + ? {} + : {requestId: metadata.requestId}), + }, + } +} diff --git a/test/commands/audiences-optouts.test.ts b/test/commands/audiences-optouts.test.ts new file mode 100644 index 00000000..78763a96 --- /dev/null +++ b/test/commands/audiences-optouts.test.ts @@ -0,0 +1,262 @@ +import {mkdtemp, rm, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {runCommand} from '@oclif/test' +import {afterEach, describe, expect, test} from 'vitest' + +import { + startHttpServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' + +describe('Audience and opt-out commands', () => { + let directory: string | undefined + let server: TestHttpServer | undefined + + afterEach(async () => { + await server?.close() + server = undefined + if (directory !== undefined) + await rm(directory, {force: true, recursive: true}) + directory = undefined + }) + + test('creates an audience from a spreadsheet and reports the import counts', async () => { + directory = await mkdtemp(join(tmpdir(), 'polymorfa-audience-')) + const csv = join(directory, 'contacts.csv') + await writeFile(csv, 'phone,plan\n+15551234567,pro\n', 'utf8') + server = await startHttpServer({body: {data: audience()}}) + + const result = await apiKeyEnvironment(() => + runCommand( + `audience create --name "August launch" --csv ${csv} --variable plan=plan`, + ), + ) + + expect(result.error).toBeUndefined() + expect(server.requests[0]?.url).toBe('/platform/audiences') + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + members: [{phone: '+15551234567', variables: {plan: 'pro'}}], + name: 'August launch', + source: 'csv', + }) + expect(result.stdout).toContain('Members\t2') + expect(result.stdout).toContain('Row 2\tmissing_phone') + }) + + test('imports a file already uploaded through the API', async () => { + directory = await mkdtemp(join(tmpdir(), 'polymorfa-audience-')) + const mapping = join(directory, 'mapping.json') + await writeFile( + mapping, + JSON.stringify({phone: 'Phone', variables: {plan: 'Plan'}}), + 'utf8', + ) + server = await startHttpServer({body: {data: audience()}}) + + const result = await apiKeyEnvironment(() => + runCommand( + `audience create --name "August launch" --file-id upload_1 --mapping ${mapping}`, + ), + ) + + expect(result.error).toBeUndefined() + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + fileId: 'upload_1', + mapping: {phone: 'Phone', variables: {plan: 'Plan'}}, + name: 'August launch', + source: 'csv', + }) + }) + + test('refuses a file import without a mapping before sending it', async () => { + server = await startHttpServer({body: {data: audience()}}) + + const result = await apiKeyEnvironment(() => + runCommand('audience create --name August --file-id upload_1'), + ) + + expect(result.error).toBeDefined() + expect(result.stderr).toContain( + 'must be provided when using --file-id: --mapping', + ) + expect(server.requests).toHaveLength(0) + }) + + test('refuses a mapping that does not name the phone column', async () => { + directory = await mkdtemp(join(tmpdir(), 'polymorfa-audience-')) + const mapping = join(directory, 'mapping.json') + await writeFile(mapping, JSON.stringify({variables: {}}), 'utf8') + server = await startHttpServer({body: {data: audience()}}) + + const result = await apiKeyEnvironment(() => + runCommand( + `audience create --name August --file-id upload_1 --mapping ${mapping}`, + ), + ) + + expect(result.error).toBeDefined() + expect(result.stderr).toContain('must name the phone column') + expect(server.requests).toHaveLength(0) + }) + + test('pages audience members and offers the next cursor', async () => { + server = await startHttpServer({ + body: { + data: [ + { + createdAt: 1_723_000_000_000, + id: 'member_1', + phone: '+15551234567', + variables: {plan: 'pro'}, + }, + ], + page: {hasMore: true, nextCursor: 'cursor_2'}, + }, + }) + + const result = await apiKeyEnvironment(() => + runCommand('audience members list list_1 --limit 10'), + ) + + expect(result.error).toBeUndefined() + expect(server.requests[0]?.url).toBe( + '/platform/audiences/list_1/members?limit=10', + ) + expect(result.stdout).toContain('plan=pro') + expect(result.stdout).toContain('--cursor cursor_2') + }) + + test('removes one member through the encoded member route', async () => { + server = await startHttpServer({ + body: { + data: { + listId: 'list_1', + phone: '+15551234567', + recipientCount: 9, + removed: true, + }, + }, + }) + + const result = await apiKeyEnvironment(() => + runCommand('audience members remove list_1 +1/555'), + ) + + expect(result.error).toBeUndefined() + expect(server.requests[0]?.method).toBe('DELETE') + expect(server.requests[0]?.url).toBe( + '/platform/audiences/list_1/members/%2B1%2F555', + ) + expect(result.stdout).toContain('9 members remain') + }) + + test('reports a no-op when the member was not in the audience', async () => { + server = await startHttpServer({ + body: { + data: { + listId: 'list_1', + phone: '+15551234567', + recipientCount: 9, + removed: false, + }, + }, + }) + const result = await apiKeyEnvironment(() => + runCommand('audience members remove list_1 +15551234567'), + ) + expect(result.error).toBeUndefined() + expect(result.stdout).toContain('was not in audience list_1') + expect(result.stdout).not.toContain('Removed') + }) + + test('reads the organization keyword settings', async () => { + server = await startHttpServer({body: {data: settings()}}) + + const result = await apiKeyEnvironment(() => + runCommand('optout settings view'), + ) + + expect(result.error).toBeUndefined() + expect(server.requests[0]?.url).toBe('/platform/optouts/settings') + expect(result.stdout).toContain('Opt-out keywords\tSTOP, BAJA') + }) + + test('replaces the whole keyword document', async () => { + server = await startHttpServer({body: {data: settings()}}) + + const result = await apiKeyEnvironment(() => + runCommand( + 'optout settings update --enabled --opt-out-keyword STOP --opt-out-keyword BAJA --opt-in-keyword START --idempotency-key settings_1', + ), + ) + + expect(result.error).toBeUndefined() + expect(server.requests[0]?.method).toBe('PUT') + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + enabled: true, + optInKeywords: ['START'], + optOutKeywords: ['STOP', 'BAJA'], + }) + expect(server.requests[0]?.headers['idempotency-key']).toBe('settings_1') + }) + + test('turns capture off without clearing the required keyword set', async () => { + server = await startHttpServer({ + body: {data: {...settings(), enabled: false}}, + }) + + const result = await apiKeyEnvironment(() => + runCommand('optout settings update --no-enabled --opt-out-keyword STOP'), + ) + + expect(result.error).toBeUndefined() + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + enabled: false, + optInKeywords: [], + optOutKeywords: ['STOP'], + }) + }) + + async function apiKeyEnvironment(run: () => Promise): Promise { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + run, + ) + } +}) + +function audience() { + return { + columns: ['phone', 'plan'], + createdAt: 1_723_000_000_000, + duplicateCount: 1, + fileId: null, + id: 'list_1', + invalidCount: 1, + invalidRows: [{reason: 'missing_phone', row: 1}], + mapping: null, + name: 'August launch', + recipientCount: 2, + sampleRow: null, + source: 'csv', + updatedAt: 1_723_000_000_000, + } +} + +function settings() { + return { + enabled: true, + optInKeywords: ['START'], + optOutKeywords: ['STOP', 'BAJA'], + updatedAt: 1_723_000_000_000, + } +} diff --git a/test/commands/calls-sockets.test.ts b/test/commands/calls-sockets.test.ts index 4ee70637..a4abe603 100644 --- a/test/commands/calls-sockets.test.ts +++ b/test/commands/calls-sockets.test.ts @@ -367,6 +367,8 @@ describe('calls watch and media', () => { .trim() .split('\n') .map((line) => (JSON.parse(line) as {type: string}).type) + // A placed call applies call.accepted to its own claim and emits claim. + // See @polymorfa/sdk d4d6c81 ("record outbound claims"). expect(statuses).toEqual([ 'ready', 'placed', diff --git a/test/commands/campaign-recipients.test.ts b/test/commands/campaign-recipients.test.ts new file mode 100644 index 00000000..f8bcfe7a --- /dev/null +++ b/test/commands/campaign-recipients.test.ts @@ -0,0 +1,465 @@ +import {mkdtemp, rm, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {runCommand} from '@oclif/test' +import {afterEach, describe, expect, test} from 'vitest' + +import { + startHttpServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' + +describe('Campaign recipient commands', () => { + let directory: string | undefined + let server: TestHttpServer | undefined + + afterEach(async () => { + await server?.close() + server = undefined + if (directory !== undefined) + await rm(directory, {force: true, recursive: true}) + directory = undefined + }) + + test('pages recipients and filters them by status', async () => { + server = await startHttpServer({ + body: { + data: [recipient()], + page: {hasMore: true, nextCursor: 'cursor_2'}, + success: true, + }, + headers: {'request-id': 'req_recipients'}, + }) + + const result = await apiKeyEnvironment(() => + runCommand( + 'campaign recipients list campaign_1 --project-slug project_1 --status skipped --limit 50 --json', + ), + ) + + expect(result.error).toBeUndefined() + expect(server.requests[0]?.url).toBe( + '/messaging/projects/project_1/campaigns/campaign_1/recipients?status=skipped&limit=50', + ) + expect(JSON.parse(result.stdout)).toMatchObject({ + data: { + data: [recipient()], + page: {hasMore: true, nextCursor: 'cursor_2'}, + }, + metadata: {requestId: 'req_recipients', status: 200}, + }) + }) + + test('offers the next cursor in human output when a page remains', async () => { + server = await startHttpServer({ + body: { + data: [recipient()], + page: {hasMore: true, nextCursor: 'cursor_2'}, + success: true, + }, + }) + + const result = await apiKeyEnvironment(() => + runCommand( + 'campaign recipients list campaign_1 --project-slug project_1', + ), + ) + + expect(result.error).toBeUndefined() + expect(result.stdout).toContain('opted_out') + expect(result.stdout).toContain('--cursor cursor_2') + }) + + test('reads recipients from a spreadsheet and reports rejected rows', async () => { + directory = await mkdtemp(join(tmpdir(), 'polymorfa-recipients-')) + const csv = join(directory, 'contacts.csv') + await writeFile( + csv, + 'Phone,First name\n+15551234567,Ada\n\n+15557654321,Grace\n', + 'utf8', + ) + server = await startHttpServer({ + body: { + data: { + added: 1, + campaignId: 'campaign_1', + duplicateCount: 1, + invalidCount: 1, + invalidRows: [{reason: 'invalid_phone', row: 2}], + recipientCount: 11, + }, + success: true, + }, + }) + + const result = await apiKeyEnvironment(() => + runCommand( + `campaign recipients add campaign_1 --project-slug project_1 --csv ${csv} --phone-column Phone --variable "firstName=First name"`, + ), + ) + + expect(result.error).toBeUndefined() + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + recipients: [ + {phone: '+15551234567', variables: {firstName: 'Ada'}}, + {phone: '+15557654321', variables: {firstName: 'Grace'}}, + ], + }) + expect(result.stdout).toContain('Added\t1') + expect(result.stdout).toContain('Duplicates skipped\t1') + expect(result.stdout).toContain('Row 4\tinvalid_phone') + }) + + test('maps invalid input indexes to CSV rows in JSON output', async () => { + directory = await mkdtemp(join(tmpdir(), 'polymorfa-recipients-')) + const csv = join(directory, 'contacts.csv') + await writeFile(csv, 'phone\n+15551234567\n\n+15557654321\n', 'utf8') + server = await startHttpServer({ + body: { + data: { + added: 1, + campaignId: 'campaign_1', + duplicateCount: 0, + invalidCount: 1, + invalidRows: [{reason: 'invalid_phone', row: 2}], + recipientCount: 1, + }, + success: true, + }, + }) + const result = await apiKeyEnvironment(() => + runCommand( + `campaign recipients add campaign_1 --project-slug project_1 --csv ${csv} --json`, + ), + ) + expect(result.error).toBeUndefined() + const output = JSON.parse(result.stdout) as { + data: {invalidRows: {reason: string; row: number}[]} + } + expect(output.data.invalidRows).toEqual([{reason: 'invalid_phone', row: 4}]) + }) + + test('names the missing column instead of sending an empty request', async () => { + directory = await mkdtemp(join(tmpdir(), 'polymorfa-recipients-')) + const csv = join(directory, 'contacts.csv') + await writeFile(csv, 'Mobile,Name\n+15551234567,Ada\n', 'utf8') + server = await startHttpServer({body: {data: {}, success: true}}) + + const result = await apiKeyEnvironment(() => + runCommand( + `campaign recipients add campaign_1 --project-slug project_1 --csv ${csv}`, + ), + ) + + expect(result.error).toBeDefined() + expect(result.stderr).toContain('has no phone column') + expect(server.requests).toHaveLength(0) + }) + + test('refuses an oversized mapped CSV variable before sending', async () => { + directory = await mkdtemp(join(tmpdir(), 'polymorfa-recipients-')) + const csv = join(directory, 'contacts.csv') + await writeFile( + csv, + `phone,Name\n\n+15551234567,${'x'.repeat(1025)}\n`, + 'utf8', + ) + server = await startHttpServer({body: {data: {}, success: true}}) + const result = await apiKeyEnvironment(() => + runCommand( + `campaign recipients add campaign_1 --project-slug project_1 --csv ${csv} --variable firstName=Name`, + ), + ) + expect(result.error).toBeDefined() + expect(result.stderr).toContain( + 'row 3 has a firstName value longer than 1024', + ) + expect(server.requests).toHaveLength(0) + }) + + test('refuses rows with an empty phone instead of dropping them', async () => { + directory = await mkdtemp(join(tmpdir(), 'polymorfa-recipients-')) + const csv = join(directory, 'contacts.csv') + await writeFile( + csv, + 'phone,Name\n,Ada\n+15551234567,Grace\n,,\n\n ,Linus\n', + 'utf8', + ) + server = await startHttpServer({body: {data: {}, success: true}}) + + const result = await apiKeyEnvironment(() => + runCommand( + `campaign recipients add campaign_1 --project-slug project_1 --csv ${csv}`, + ), + ) + + expect(result.error).toBeDefined() + // Wholly blank rows (4 and 5) are ignored; rows 2 and 6 have a name but + // no phone and must be reported, not silently omitted. + expect(result.stderr).toContain('2 rows with an empty phone value') + expect(result.stderr).toContain('row 2, 6') + expect(server.requests).toHaveLength(0) + }) + + test('reads a semicolon file whose quoted header contains a comma', async () => { + directory = await mkdtemp(join(tmpdir(), 'polymorfa-recipients-')) + const csv = join(directory, 'contacts.csv') + await writeFile( + csv, + '"Phone, Intl";"Name"\n+15551234567;"Lovelace, Ada"\n', + 'utf8', + ) + server = await startHttpServer({ + body: { + data: { + added: 1, + campaignId: 'campaign_1', + duplicateCount: 0, + invalidCount: 0, + invalidRows: [], + recipientCount: 1, + }, + success: true, + }, + }) + + const result = await apiKeyEnvironment(() => + runCommand( + `campaign recipients add campaign_1 --project-slug project_1 --csv ${csv} --phone-column "Phone, Intl" --variable name=Name`, + ), + ) + + expect(result.error).toBeUndefined() + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + recipients: [{phone: '+15551234567', variables: {name: 'Lovelace, Ada'}}], + }) + }) + + test.each([ + [ + 'equal separator counts', + 'phone;Last, First\n+15551234567;Lovelace, Ada\n', + 'Last, First', + 'Lovelace, Ada', + ], + [ + 'more commas than semicolons', + 'phone;Last, First, Suffix\n+15551234567;Lovelace, Ada, Jr\n', + 'Last, First, Suffix', + 'Lovelace, Ada, Jr', + ], + ])('uses the phone header with %s', async (_case, content, header, name) => { + directory = await mkdtemp(join(tmpdir(), 'polymorfa-recipients-')) + const csv = join(directory, 'contacts.csv') + await writeFile(csv, content, 'utf8') + server = await startHttpServer({ + body: { + data: { + added: 1, + campaignId: 'campaign_1', + duplicateCount: 0, + invalidCount: 0, + invalidRows: [], + recipientCount: 1, + }, + success: true, + }, + }) + + const result = await apiKeyEnvironment(() => + runCommand( + `campaign recipients add campaign_1 --project-slug project_1 --csv ${csv} --variable "name=${header}"`, + ), + ) + + expect(result.error).toBeUndefined() + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + recipients: [{phone: '+15551234567', variables: {name}}], + }) + }) + + test.each([ + ['an empty phone', '', 'Entry 2 has an empty phone'], + ['a blank phone', ' ', 'Entry 2 has an empty phone'], + ['an overlong phone', '1'.repeat(65), 'longer than 64 characters'], + ])( + 'refuses JSON input with %s before sending it', + async (_name, phone, message) => { + directory = await mkdtemp(join(tmpdir(), 'polymorfa-recipients-')) + const input = join(directory, 'recipients.json') + await writeFile( + input, + JSON.stringify([{phone: '+15551234567'}, {phone}]), + 'utf8', + ) + server = await startHttpServer({body: {data: {}, success: true}}) + + const result = await apiKeyEnvironment(() => + runCommand( + `campaign recipients add campaign_1 --project-slug project_1 --input ${input}`, + ), + ) + + expect(result.error).toBeDefined() + expect(result.stderr).toContain(message) + expect(server.requests).toHaveLength(0) + }, + ) + + test.each([ + ['an empty array', []], + ['an empty recipients array', {recipients: []}], + ])('refuses JSON input with %s before sending it', async (_name, body) => { + directory = await mkdtemp(join(tmpdir(), 'polymorfa-recipients-')) + const input = join(directory, 'recipients.json') + await writeFile(input, JSON.stringify(body), 'utf8') + server = await startHttpServer({body: {data: {}, success: true}}) + + const result = await apiKeyEnvironment(() => + runCommand( + `campaign recipients add campaign_1 --project-slug project_1 --input ${input}`, + ), + ) + + expect(result.error).toBeDefined() + expect(result.stderr).toContain('Recipient input has no entries') + expect(server.requests).toHaveLength(0) + }) + + test.each([ + ['non-string value', {firstName: 42}], + ['invalid name', {'-firstName': 'Ada'}], + [ + 'reserved name', + JSON.parse('{"__proto__":"Ada"}') as Record, + ], + ['overlong value', {firstName: 'x'.repeat(1025)}], + ])( + 'refuses JSON variables with %s before sending', + async (_name, variables) => { + directory = await mkdtemp(join(tmpdir(), 'polymorfa-recipients-')) + const input = join(directory, 'recipients.json') + await writeFile( + input, + JSON.stringify([{phone: '+15551234567', variables}]), + 'utf8', + ) + server = await startHttpServer({body: {data: {}, success: true}}) + const result = await apiKeyEnvironment(() => + runCommand( + `campaign recipients add campaign_1 --project-slug project_1 --input ${input}`, + ), + ) + expect(result.error).toBeDefined() + expect(result.stderr).toContain('invalid variables') + expect(server.requests).toHaveLength(0) + }, + ) + + test('refuses the reserved __proto__ variable name instead of dropping it', async () => { + directory = await mkdtemp(join(tmpdir(), 'polymorfa-recipients-')) + const csv = join(directory, 'contacts.csv') + await writeFile(csv, 'phone,Plan\n+15551234567,pro\n', 'utf8') + server = await startHttpServer({body: {data: {}, success: true}}) + + const result = await apiKeyEnvironment(() => + runCommand( + `campaign recipients add campaign_1 --project-slug project_1 --csv ${csv} --variable __proto__=Plan`, + ), + ) + + expect(result.error).toBeDefined() + expect(result.stderr).toContain('__proto__ is reserved') + expect(server.requests).toHaveLength(0) + }) + + test('says a campaign was cancelled when stop returns no operation', async () => { + server = await startHttpServer({ + body: { + data: {...campaign(), operationId: null, status: 'stopped'}, + success: true, + }, + }) + + const result = await apiKeyEnvironment(() => + runCommand('campaign stop campaign_1 --project-slug project_1 --yes'), + ) + + expect(result.error).toBeUndefined() + expect(result.stdout).toContain('had no active delivery run') + expect(result.stdout).not.toContain('operation null') + }) + + test('still reports the operation when stop starts one', async () => { + server = await startHttpServer({ + body: { + data: {...campaign(), operationId: 'op_stop', status: 'stopping'}, + success: true, + }, + }) + + const result = await apiKeyEnvironment(() => + runCommand('campaign stop campaign_1 --project-slug project_1 --yes'), + ) + + expect(result.error).toBeUndefined() + expect(result.stdout).toContain( + 'Accepted campaign stop as operation op_stop', + ) + }) + + async function apiKeyEnvironment(run: () => Promise): Promise { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + run, + ) + } +}) + +function recipient() { + return { + attempts: 0, + deliveredAt: null, + externalMessageId: null, + failedAt: null, + id: 'recipient_1', + lastError: 'opted_out', + phone: '+15551234567', + queuedAt: 1_723_000_000_000, + readAt: null, + respondedAt: null, + sentAt: null, + status: 'skipped', + variables: {firstName: 'Ada'}, + variantKey: null, + } +} + +function campaign() { + return { + completedAt: null, + createdAt: 1_723_000_000_000, + deliveredCount: 8, + failedCount: 1, + id: 'campaign_1', + launchedAt: 1_723_000_100_000, + name: 'September launch', + readCount: 6, + recipientCount: 10, + recipientListId: 'recipients_1', + scheduledAt: null, + sentCount: 9, + skippedCount: 0, + status: 'running', + templateId: 'template_1', + updatedAt: 1_723_000_200_000, + } +} diff --git a/test/commands/campaigns.test.ts b/test/commands/campaigns.test.ts index 5b6af541..41c71447 100644 --- a/test/commands/campaigns.test.ts +++ b/test/commands/campaigns.test.ts @@ -237,9 +237,11 @@ describe('Messaging campaign commands', () => { expect(result.error).toBeUndefined() expect(result.stdout).toContain( - 'Campaign campaign/1 stopped immediately; no operation was created.', + 'Cancelled campaign campaign/1; it had no active delivery run.', + ) + expect(result.stdout).toContain( + 'The campaign is cancelled. No remaining recipient is sent.', ) - expect(result.stdout).toContain('Campaign campaign/1 is cancelled.') expect(result.stdout).not.toContain('operation null') })