From 6104efdcae3514e9ca8c6b3205fabf9c7aaeaba3 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 20 Sep 2026 16:00:09 +0200 Subject: [PATCH 01/17] feat(campaigns): campaign recipients, audience members and opt-out settings Add campaign recipients list/add, audience create with CSV or file import, audience member add/list/remove, and opt-out settings view/update. Fix campaign stop, which the contract now answers with a nullable operationId: a campaign with no active delivery run is cancelled immediately, and the CLI says so instead of reporting 'operation null'. --- package-lock.json | 10 +- package.json | 75 ++++--- src/commands/audience/create.ts | 98 +++++++++ src/commands/audience/members/add.ts | 75 +++++++ src/commands/audience/members/list.ts | 37 ++++ src/commands/audience/members/remove.ts | 29 +++ src/commands/campaign/recipients/add.ts | 81 +++++++ src/commands/campaign/recipients/list.ts | 57 +++++ src/commands/campaign/stop.ts | 10 +- src/commands/optout/settings/update.ts | 53 +++++ src/commands/optout/settings/view.ts | 16 ++ src/input/recipients.ts | 265 +++++++++++++++++++++++ src/services/audiences.ts | 152 +++++++++++++ src/services/campaigns.ts | 121 ++++++++++- src/services/opt-outs.ts | 117 ++++++++++ 15 files changed, 1155 insertions(+), 41 deletions(-) create mode 100644 src/commands/audience/create.ts create mode 100644 src/commands/audience/members/add.ts create mode 100644 src/commands/audience/members/list.ts create mode 100644 src/commands/audience/members/remove.ts create mode 100644 src/commands/campaign/recipients/add.ts create mode 100644 src/commands/campaign/recipients/list.ts create mode 100644 src/commands/optout/settings/update.ts create mode 100644 src/commands/optout/settings/view.ts create mode 100644 src/input/recipients.ts create mode 100644 src/services/audiences.ts create mode 100644 src/services/opt-outs.ts diff --git a/package-lock.json b/package-lock.json index 1bccc14f..13dea0bb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#94bfd09825817f842434e206e6a77adb5cc0c414", + "@polymorfa/sdk": "github:polymorfa/sdks#50c5b1f35c403dd691f72d395b299b8bb51c4801", "zod": "4.2.0" }, "bin": { @@ -996,15 +996,17 @@ } }, "node_modules/@polymorfa/sdk": { - "version": "0.1.0-dev.0", - "resolved": "git+ssh://git@github.com/polymorfa/sdks.git#94bfd09825817f842434e206e6a77adb5cc0c414", - "integrity": "sha512-C12O4fRO1i5poh2PpEB9FwaznqiiloOKV94jroS+NeTXFD9we2km53q/xp4Ftyq8GyWQUO7AY9pwPFsRiW3m6A==", + "name": "polymorfa-sdks", + "resolved": "git+ssh://git@github.com/polymorfa/sdks.git#50c5b1f35c403dd691f72d395b299b8bb51c4801", + "integrity": "sha512-NKzGjWZKYvfdFzVL1UQ8udQSGVKAN/0V330vdJnZNV4k1VMn/gfRXl2t+6eW/yxPygZdmDVULF5UuYs1Sc1FiA==", "workspaces": [ + "packages/typescript", "packages/calls", "packages/browser", "packages/ui", "packages/elements", "packages/react", + "packages/store", "packages/nextjs", "packages/devtools" ], diff --git a/package.json b/package.json index ce99677a..c0bcca43 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#94bfd09825817f842434e206e6a77adb5cc0c414", + "@polymorfa/sdk": "dev", "zod": "4.2.0" }, "devDependencies": { @@ -53,12 +53,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." }, @@ -107,6 +113,9 @@ "campaign": { "description": "Inspect and manage Messaging campaigns." }, + "campaign:recipients": { + "description": "Inspect and append campaign recipients." + }, "channel": { "description": "Inspect and manage Messaging channels." }, @@ -140,6 +149,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." }, @@ -149,8 +161,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." @@ -158,11 +173,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." @@ -176,18 +203,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-token": { "description": "Inspect project-token metadata." }, @@ -203,27 +218,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." }, @@ -235,6 +247,9 @@ }, "webhook:secret": { "description": "Manage one-time webhook signing-secret rotation." + }, + "widget": { + "description": "Inspect and update saved QuickLink widget settings." } } }, diff --git a/src/commands/audience/create.ts b/src/commands/audience/create.ts new file mode 100644 index 00000000..06d7f192 --- /dev/null +++ b/src/commands/audience/create.ts @@ -0,0 +1,98 @@ +import {Flags} from '@oclif/core' + +import {readJsonInput} from '../../api/request.js' +import {BaseCommand} from '../../base-command.js' +import { + assertBatchLimit, + 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({ + description: 'Storage ID of a file already uploaded through the API.', + exclusive: ['csv'], + }), + 'idempotency-key': Flags.string({ + description: + 'Use this key to identify retries; one 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({ + default: 'phone', + dependsOn: ['csv'], + description: 'Header naming the phone column.', + }), + variable: Flags.string({ + dependsOn: ['csv'], + description: 'Map a template variable to a column, as name=column.', + 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']}, + ) + this.writeValue(result, renderAudienceImport(result.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 + readonly variable?: string[] | undefined + }) { + if (flags['file-id'] !== undefined) { + return { + fileId: flags['file-id'], + ...(flags.mapping === undefined + ? {} + : {mapping: audienceMapping(await readJsonInput(flags.mapping))}), + name: flags.name, + source: 'csv' as const, + } + } + if (flags.csv !== undefined) { + const members = await readCsvRecipients(flags.csv, { + phoneColumn: flags['phone-column'], + variables: parseVariableMapping(flags.variable ?? []), + }) + assertBatchLimit(members) + return {members, name: flags.name, source: 'csv' as const} + } + return {name: flags.name, source: 'manual' as const} + } +} diff --git a/src/commands/audience/members/add.ts b/src/commands/audience/members/add.ts new file mode 100644 index 00000000..9c307962 --- /dev/null +++ b/src/commands/audience/members/add.ts @@ -0,0 +1,75 @@ +import {Args, Flags} from '@oclif/core' + +import {readJsonInput} from '../../../api/request.js' +import {BaseCommand} from '../../../base-command.js' +import { + assertBatchLimit, + 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: + 'Use this key to identify retries; one is generated when omitted.', + }), + input: Flags.string({ + description: + 'Read a JSON array of {phone, variables} from a file or stdin (-).', + exactlyOne: ['csv', 'input'], + }), + 'phone-column': Flags.string({ + default: 'phone', + dependsOn: ['csv'], + description: 'Header naming the phone column.', + }), + variable: Flags.string({ + dependsOn: ['csv'], + description: 'Map a template variable to a column, as name=column.', + 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'], + 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']}, + ) + this.writeValue(result, renderMemberAppend(result.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/recipients/add.ts b/src/commands/campaign/recipients/add.ts new file mode 100644 index 00000000..b9e40f83 --- /dev/null +++ b/src/commands/campaign/recipients/add.ts @@ -0,0 +1,81 @@ +import {Args, Flags} from '@oclif/core' + +import {readJsonInput} from '../../../api/request.js' +import {BaseCommand} from '../../../base-command.js' +import { + assertBatchLimit, + 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: + 'Use this key to identify retries; one is generated when omitted.', + }), + input: Flags.string({ + description: + 'Read a JSON array of {phone, variables} from a file or stdin (-).', + exactlyOne: ['csv', 'input'], + }), + 'phone-column': Flags.string({ + default: 'phone', + dependsOn: ['csv'], + description: 'Header naming the phone column.', + }), + '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.', + 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'], + 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']}, + ) + this.writeValue(result, renderRecipientAppend(result.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/stop.ts b/src/commands/campaign/stop.ts index 6a386464..7f78165b 100644 --- a/src/commands/campaign/stop.ts +++ b/src/commands/campaign/stop.ts @@ -1,17 +1,15 @@ 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({ @@ -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..3089c81c --- /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: + 'Use this key to identify retries; one 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..7b928358 --- /dev/null +++ b/src/input/recipients.ts @@ -0,0 +1,265 @@ +import {readFile, stat} from 'node:fs/promises' + +import { + type CampaignRecipientInput, + 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_.-]{1,64}$/ +const MAX_VARIABLES = 50 + +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) + 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[] = [] + for (const record of records.slice(1)) { + if (record.every((cell) => cell.trim() === '')) continue + const phone = (record[phoneIndex] ?? '').trim() + if (phone === '') continue + const variables: Record = {} + for (const [name, index] of variableIndexes) { + const value = (record[index] ?? '').trim() + if (value !== '') variables[name] = value + } + recipients.push( + Object.keys(variables).length === 0 ? {phone} : {phone, variables}, + ) + } + + if (recipients.length === 0) { + throw invalid(`${path} has no rows with a phone number.`, 'csv_empty') + } + return recipients +} + +/** `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() + 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', + ) + } + 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', + ) + } + 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) +} + +function detectDelimiter(text: string): string { + const line = text.slice(0, text.search(/\r?\n/) === -1 ? undefined : text.search(/\r?\n/)) + const counts = [',', ';', '\t'].map((candidate) => ({ + candidate, + count: line.split(candidate).length - 1, + })) + const best = counts.reduce((left, right) => + right.count > left.count ? right : left, + ) + return best.count === 0 ? ',' : best.candidate +} + +function parseCsv(input: string): string[][] { + const text = input.charCodeAt(0) === 0xfe_ff ? input.slice(1) : input + const delimiter = detectDelimiter(text) + 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..0623c4b4 --- /dev/null +++ b/src/services/audiences.ts @@ -0,0 +1,152 @@ +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 `Removed ${result.phone} from 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 d97876b2..ccf7ad2f 100644 --- a/src/services/campaigns.ts +++ b/src/services/campaigns.ts @@ -1,9 +1,17 @@ import { + type AddCampaignRecipientsResult, type Campaign, type CampaignAnalytics, type CampaignOperation, + type CampaignRecipient, + type CampaignRecipientInput, + type CampaignRecipientPage, + type CampaignRecipientStatus, type CampaignRequeueResult, + type CampaignStopOperation, type CreateCampaignRequest, + type InvalidRecipientRow, + type ListCampaignRecipientsParams, type MessagingClient, PolymorfaValidationError, type RequestOptions, @@ -109,11 +117,49 @@ export async function stopCampaign( projectSlug: string, campaignId: string, options: RequestOptions = {}, -): Promise> { +): Promise> { const response = await client.campaigns.stop(projectSlug, campaignId, options) 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, @@ -205,6 +251,79 @@ 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}), + }, + } +} From 51a7c95011e3f5e3f4338158114da62f04d25fb2 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 20 Sep 2026 16:08:31 +0200 Subject: [PATCH 02/17] docs(campaigns): document recipients, audiences and opt-out settings Move the SDK dependency to the published `dev` dist-tag. The previous github:polymorfa/sdks# pin can no longer work: the SDK repository made its root a private workspace, so a git install exposes no package entry points. The lockfile still records the old pin and must be regenerated once the SDK prerelease that carries these methods is published. Update the calls socket expectation for the newer SDK, which emits `claim` on a placed call (sdks d4d6c81). --- README.md | 103 +++++++++- docs/command-research/campaigns.md | 81 +++++++- package-lock.json | 10 +- src/commands/audience/create.ts | 10 +- src/commands/audience/members/add.ts | 8 +- src/commands/campaign/recipients/add.ts | 11 +- src/input/recipients.ts | 5 +- src/services/audiences.ts | 6 +- src/services/campaigns.ts | 8 +- test/commands/audiences-optouts.test.ts | 229 ++++++++++++++++++++++ test/commands/calls-sockets.test.ts | 3 + test/commands/campaign-recipients.test.ts | 219 +++++++++++++++++++++ 12 files changed, 649 insertions(+), 44 deletions(-) create mode 100644 test/commands/audiences-optouts.test.ts create mode 100644 test/commands/campaign-recipients.test.ts diff --git a/README.md b/README.md index 641c23e1..a0c2d435 100644 --- a/README.md +++ b/README.md @@ -900,19 +900,110 @@ 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`. Operation inspection is console-only; the CLI has no operation-polling command. +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`, but the API does not persist the header, so it must not be -treated as server-side deduplication. +`--idempotency-key`. + +### 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. 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`. + +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, replay, watch, or operation-cancel route. The CLI -does not substitute similarly named Platform campaign methods for these gaps. +event, history, replay, watch, or operation-cancel route on the Messaging +surface. The CLI does not substitute similarly named Platform campaign methods +for these gaps. + +## 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. 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..4b9c2f72 100644 --- a/docs/command-research/campaigns.md +++ b/docs/command-research/campaigns.md @@ -1,8 +1,10 @@ # 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-20 +- **SDK baseline:** `50c5b1f35c403dd691f72d395b299b8bb51c4801` +- **Contract:** `polymorfa/polymorfa` `a1be7b131b098aa7a7f827094b05b65cda809eb0` ## Sources reviewed @@ -33,6 +35,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 @@ -82,17 +99,61 @@ 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. + ## 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, and the CLI does not substitute similarly named Platform campaign +operations or use `raw.request` to invent those workflows. + +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 432 total / 316 covered / 0 +missing / 116 excluded / 0 partial / 0 changed operations. diff --git a/package-lock.json b/package-lock.json index 13dea0bb..1bccc14f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#50c5b1f35c403dd691f72d395b299b8bb51c4801", + "@polymorfa/sdk": "github:polymorfa/sdks#94bfd09825817f842434e206e6a77adb5cc0c414", "zod": "4.2.0" }, "bin": { @@ -996,17 +996,15 @@ } }, "node_modules/@polymorfa/sdk": { - "name": "polymorfa-sdks", - "resolved": "git+ssh://git@github.com/polymorfa/sdks.git#50c5b1f35c403dd691f72d395b299b8bb51c4801", - "integrity": "sha512-NKzGjWZKYvfdFzVL1UQ8udQSGVKAN/0V330vdJnZNV4k1VMn/gfRXl2t+6eW/yxPygZdmDVULF5UuYs1Sc1FiA==", + "version": "0.1.0-dev.0", + "resolved": "git+ssh://git@github.com/polymorfa/sdks.git#94bfd09825817f842434e206e6a77adb5cc0c414", + "integrity": "sha512-C12O4fRO1i5poh2PpEB9FwaznqiiloOKV94jroS+NeTXFD9we2km53q/xp4Ftyq8GyWQUO7AY9pwPFsRiW3m6A==", "workspaces": [ - "packages/typescript", "packages/calls", "packages/browser", "packages/ui", "packages/elements", "packages/react", - "packages/store", "packages/nextjs", "packages/devtools" ], diff --git a/src/commands/audience/create.ts b/src/commands/audience/create.ts index 06d7f192..5318fa98 100644 --- a/src/commands/audience/create.ts +++ b/src/commands/audience/create.ts @@ -43,13 +43,13 @@ export default class AudienceCreate extends BaseCommand { }), name: Flags.string({description: 'Audience name.', required: true}), 'phone-column': Flags.string({ - default: 'phone', dependsOn: ['csv'], - description: 'Header naming the phone column.', + 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.', + description: + 'Map a template variable to a column, as name=column. Repeatable; quote a column name that contains spaces.', multiple: true, }), } @@ -72,7 +72,7 @@ export default class AudienceCreate extends BaseCommand { readonly 'file-id'?: string | undefined readonly mapping?: string | undefined readonly name: string - readonly 'phone-column': string + readonly 'phone-column'?: string | undefined readonly variable?: string[] | undefined }) { if (flags['file-id'] !== undefined) { @@ -87,7 +87,7 @@ export default class AudienceCreate extends BaseCommand { } if (flags.csv !== undefined) { const members = await readCsvRecipients(flags.csv, { - phoneColumn: flags['phone-column'], + phoneColumn: flags['phone-column'] ?? 'phone', variables: parseVariableMapping(flags.variable ?? []), }) assertBatchLimit(members) diff --git a/src/commands/audience/members/add.ts b/src/commands/audience/members/add.ts index 9c307962..3b201707 100644 --- a/src/commands/audience/members/add.ts +++ b/src/commands/audience/members/add.ts @@ -40,13 +40,13 @@ export default class AudienceMembersAdd extends BaseCommand { exactlyOne: ['csv', 'input'], }), 'phone-column': Flags.string({ - default: 'phone', dependsOn: ['csv'], - description: 'Header naming the phone column.', + 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.', + description: + 'Map a template variable to a column, as name=column. Repeatable; quote a column name that contains spaces.', multiple: true, }), } @@ -57,7 +57,7 @@ export default class AudienceMembersAdd extends BaseCommand { flags.csv === undefined ? recipientsFromJson(await readJsonInput(flags.input!)) : await readCsvRecipients(flags.csv, { - phoneColumn: flags['phone-column'], + phoneColumn: flags['phone-column'] ?? 'phone', variables: parseVariableMapping(flags.variable ?? []), }) assertBatchLimit(members) diff --git a/src/commands/campaign/recipients/add.ts b/src/commands/campaign/recipients/add.ts index b9e40f83..06061dba 100644 --- a/src/commands/campaign/recipients/add.ts +++ b/src/commands/campaign/recipients/add.ts @@ -28,7 +28,8 @@ export default class CampaignRecipientsAdd extends BaseCommand { static override flags = { csv: Flags.string({ - description: 'Read recipients from this spreadsheet and send them inline.', + description: + 'Read recipients from this spreadsheet and send them inline.', exactlyOne: ['csv', 'input'], }), 'idempotency-key': Flags.string({ @@ -41,9 +42,8 @@ export default class CampaignRecipientsAdd extends BaseCommand { exactlyOne: ['csv', 'input'], }), 'phone-column': Flags.string({ - default: 'phone', dependsOn: ['csv'], - description: 'Header naming the phone column.', + description: 'Header naming the phone column. Defaults to phone.', }), 'project-slug': Flags.string({ description: 'Project slug in the Messaging API path.', @@ -51,7 +51,8 @@ export default class CampaignRecipientsAdd extends BaseCommand { }), variable: Flags.string({ dependsOn: ['csv'], - description: 'Map a template variable to a column, as name=column.', + description: + 'Map a template variable to a column, as name=column. Repeatable; quote a column name that contains spaces.', multiple: true, }), } @@ -62,7 +63,7 @@ export default class CampaignRecipientsAdd extends BaseCommand { flags.csv === undefined ? recipientsFromJson(await readJsonInput(flags.input!)) : await readCsvRecipients(flags.csv, { - phoneColumn: flags['phone-column'], + phoneColumn: flags['phone-column'] ?? 'phone', variables: parseVariableMapping(flags.variable ?? []), }) assertBatchLimit(recipients) diff --git a/src/input/recipients.ts b/src/input/recipients.ts index 7b928358..faf661f8 100644 --- a/src/input/recipients.ts +++ b/src/input/recipients.ts @@ -181,7 +181,10 @@ function columnIndex(header: readonly string[], name: string): number { } function detectDelimiter(text: string): string { - const line = text.slice(0, text.search(/\r?\n/) === -1 ? undefined : text.search(/\r?\n/)) + const line = text.slice( + 0, + text.search(/\r?\n/) === -1 ? undefined : text.search(/\r?\n/), + ) const counts = [',', ';', '\t'].map((candidate) => ({ candidate, count: line.split(candidate).length - 1, diff --git a/src/services/audiences.ts b/src/services/audiences.ts index 0623c4b4..f2639c05 100644 --- a/src/services/audiences.ts +++ b/src/services/audiences.ts @@ -41,11 +41,7 @@ export async function addAudienceMembers( members: readonly CampaignRecipientInput[], options: RequestOptions = {}, ): Promise> { - const response = await client.audiences.addMembers( - listId, - {members}, - options, - ) + const response = await client.audiences.addMembers(listId, {members}, options) return result(response.data.data, response.metadata) } diff --git a/src/services/campaigns.ts b/src/services/campaigns.ts index ccf7ad2f..d57d7703 100644 --- a/src/services/campaigns.ts +++ b/src/services/campaigns.ts @@ -285,7 +285,9 @@ export function renderCampaignRecipients( ].join('\t'), ) if (result.page.hasMore && result.page.nextCursor !== null) { - rows.push(`More recipients remain. Continue with --cursor ${result.page.nextCursor}.`) + rows.push( + `More recipients remain. Continue with --cursor ${result.page.nextCursor}.`, + ) } return rows.join('\n') } @@ -309,7 +311,9 @@ export function renderInvalidRows( return rows.map((row) => `Row ${row.row}\t${row.reason}`) } -export function recipientStatus(value?: string): CampaignRecipientStatus | undefined { +export function recipientStatus( + value?: string, +): CampaignRecipientStatus | undefined { if (value === undefined) return undefined return value as CampaignRecipientStatus } diff --git a/test/commands/audiences-optouts.test.ts b/test/commands/audiences-optouts.test.ts new file mode 100644 index 00000000..948e1034 --- /dev/null +++ b/test/commands/audiences-optouts.test.ts @@ -0,0 +1,229 @@ +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 3\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 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('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: 3}], + 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 0475ad8d..f58ec5b0 100644 --- a/test/commands/calls-sockets.test.ts +++ b/test/commands/calls-sockets.test.ts @@ -347,9 +347,12 @@ 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', + 'claim', 'state', 'state', 'connected', diff --git a/test/commands/campaign-recipients.test.ts b/test/commands/campaign-recipients.test.ts new file mode 100644 index 00000000..397a49d2 --- /dev/null +++ b/test/commands/campaign-recipients.test.ts @@ -0,0 +1,219 @@ +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+15557654321,Grace\n', + 'utf8', + ) + server = await startHttpServer({ + body: { + data: { + added: 1, + campaignId: 'campaign_1', + duplicateCount: 1, + invalidCount: 1, + invalidRows: [{reason: 'invalid_phone', row: 4}], + 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('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('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, + } +} From e4ff1e38f5ba322549b18caf47849f3ec7f44834 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 20 Sep 2026 16:16:23 +0200 Subject: [PATCH 03/17] docs(campaigns): re-pin the contract and SDK baselines Re-sync to polymorfa/polymorfa d48b43c6e24a52175eba3880c1eefda1e07a3bf0. No command changed. The revision declares projectId on the single-campaign Platform operations, which no CLI command calls, and adds recipientListId to the Platform campaign update body, which the CLI does not expose: the Messaging surface behind the campaign topic has no update route. --- docs/command-research/campaigns.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/docs/command-research/campaigns.md b/docs/command-research/campaigns.md index 4b9c2f72..8a1c05b1 100644 --- a/docs/command-research/campaigns.md +++ b/docs/command-research/campaigns.md @@ -3,8 +3,8 @@ - **Status:** Campaigns P0 slice: nine lifecycle operations plus recipients, audiences and opt-out settings - **Reviewed:** 2026-09-20 -- **SDK baseline:** `50c5b1f35c403dd691f72d395b299b8bb51c4801` -- **Contract:** `polymorfa/polymorfa` `a1be7b131b098aa7a7f827094b05b65cda809eb0` +- **SDK baseline:** `d146fec7c0fa6ccf830220b41d494ab4bd5e6368` +- **Contract:** `polymorfa/polymorfa` `d48b43c6e24a52175eba3880c1eefda1e07a3bf0` ## Sources reviewed @@ -137,6 +137,19 @@ Opt-out settings are a replace, not a patch. The API requires `enabled`, 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. No CLI command calls them, so no flag +changed. + ## Explicit gaps The Messaging surface now exposes recipient listing and appending; the CLI From 19540249657b1c2d8238a5a4c63ce9d5babdbca2 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 20 Sep 2026 16:23:00 +0200 Subject: [PATCH 04/17] docs(campaigns): re-pin to the final contract revision Re-sync to polymorfa/polymorfa ef1653e3c4fc7a98b9da56fffae5f2184b7e4574. No command changed. The revision declares the 409 that campaign update answers after launch and documents projectId on the single-campaign Platform operations; the CLI calls neither. --- docs/command-research/campaigns.md | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/docs/command-research/campaigns.md b/docs/command-research/campaigns.md index 8a1c05b1..cf4407a6 100644 --- a/docs/command-research/campaigns.md +++ b/docs/command-research/campaigns.md @@ -3,8 +3,8 @@ - **Status:** Campaigns P0 slice: nine lifecycle operations plus recipients, audiences and opt-out settings - **Reviewed:** 2026-09-20 -- **SDK baseline:** `d146fec7c0fa6ccf830220b41d494ab4bd5e6368` -- **Contract:** `polymorfa/polymorfa` `d48b43c6e24a52175eba3880c1eefda1e07a3bf0` +- **SDK baseline:** `3a8e4073281657977d0d1db520e1368d892d66bd` +- **Contract:** `polymorfa/polymorfa` `ef1653e3c4fc7a98b9da56fffae5f2184b7e4574` ## Sources reviewed @@ -147,8 +147,11 @@ 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. No CLI command calls them, so no flag -changed. +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. `projectId` stays optional on purpose: a team API key must send it and +a project token must not, which the CLI's Platform commands already express +through the resolved credential rather than a flag. ## Explicit gaps From 48a2fed35ed0ba22c023068318a6bd3cc39f8da2 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 22 Sep 2026 13:35:04 +0200 Subject: [PATCH 05/17] fix(campaigns): stop promising a generated key and retries on appends The --idempotency-key help on campaign recipients add, audience members add, audience create and optout settings update said a key is generated when omitted. The SDK generates none for these, and from sdks b663914 it sends recipient and member appends once because the API declares no replay for them. The help, README and command research now say so, and the research baseline moves to that SDK commit. Verified with npm run verify against a tarball packed from sdks b663914. package.json and package-lock.json are unchanged: no published prerelease contains the campaign methods yet. --- README.md | 8 +++++++- docs/command-research/campaigns.md | 8 +++++++- src/commands/audience/create.ts | 2 +- src/commands/audience/members/add.ts | 2 +- src/commands/campaign/recipients/add.ts | 2 +- src/commands/optout/settings/update.ts | 2 +- 6 files changed, 18 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a0c2d435..b78c30c9 100644 --- a/README.md +++ b/README.md @@ -942,6 +942,11 @@ 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 @@ -980,7 +985,8 @@ the phone column and any variable columns: ``` Creation and appends report the same import counts as campaign recipients, so a -caller can reconcile what was accepted. Member listing is cursor-paged like +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 diff --git a/docs/command-research/campaigns.md b/docs/command-research/campaigns.md index cf4407a6..ce54536d 100644 --- a/docs/command-research/campaigns.md +++ b/docs/command-research/campaigns.md @@ -3,7 +3,7 @@ - **Status:** Campaigns P0 slice: nine lifecycle operations plus recipients, audiences and opt-out settings - **Reviewed:** 2026-09-20 -- **SDK baseline:** `3a8e4073281657977d0d1db520e1368d892d66bd` +- **SDK baseline:** `b663914f84801fed209945b802d3cf7f87ea4974` - **Contract:** `polymorfa/polymorfa` `ef1653e3c4fc7a98b9da56fffae5f2184b7e4574` ## Sources reviewed @@ -94,6 +94,12 @@ 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. +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 a per-request retry count, which the CLI never does. 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 diff --git a/src/commands/audience/create.ts b/src/commands/audience/create.ts index 5318fa98..730b213f 100644 --- a/src/commands/audience/create.ts +++ b/src/commands/audience/create.ts @@ -33,7 +33,7 @@ export default class AudienceCreate extends BaseCommand { }), 'idempotency-key': Flags.string({ description: - 'Use this key to identify retries; one is generated when omitted.', + 'Send this Idempotency-Key header. None is generated when omitted.', }), mapping: Flags.string({ dependsOn: ['file-id'], diff --git a/src/commands/audience/members/add.ts b/src/commands/audience/members/add.ts index 3b201707..ec147b1e 100644 --- a/src/commands/audience/members/add.ts +++ b/src/commands/audience/members/add.ts @@ -32,7 +32,7 @@ export default class AudienceMembersAdd extends BaseCommand { }), 'idempotency-key': Flags.string({ description: - 'Use this key to identify retries; one is generated when omitted.', + 'Send this Idempotency-Key header. The API does not replay appends, so the request is sent once and not retried.', }), input: Flags.string({ description: diff --git a/src/commands/campaign/recipients/add.ts b/src/commands/campaign/recipients/add.ts index 06061dba..025ae27d 100644 --- a/src/commands/campaign/recipients/add.ts +++ b/src/commands/campaign/recipients/add.ts @@ -34,7 +34,7 @@ export default class CampaignRecipientsAdd extends BaseCommand { }), 'idempotency-key': Flags.string({ description: - 'Use this key to identify retries; one is generated when omitted.', + 'Send this Idempotency-Key header. The API does not replay appends, so the request is sent once and not retried.', }), input: Flags.string({ description: diff --git a/src/commands/optout/settings/update.ts b/src/commands/optout/settings/update.ts index 3089c81c..293be9ec 100644 --- a/src/commands/optout/settings/update.ts +++ b/src/commands/optout/settings/update.ts @@ -25,7 +25,7 @@ export default class OptOutSettingsUpdate extends BaseCommand { }), 'idempotency-key': Flags.string({ description: - 'Use this key to identify retries; one is generated when omitted.', + '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.', From bd0edbd5c91c72ea1c044257580f1d0a6e7c8204 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 22 Sep 2026 13:46:39 +0200 Subject: [PATCH 06/17] fix(campaigns): refuse file imports without a mapping and empty-phone rows audience create --file-id now requires --mapping. The API refuses a file import without one (400), so the command stops before transport with oclif's dependency error instead of sending an unusable request. A CSV row with other values but an empty phone cell was silently dropped, so it never reached the API's invalid counts. readCsvRecipients now refuses the file and names up to 20 such rows (header is row 1); wholly blank rows are still ignored. This covers campaign recipients add, audience members add and audience create --csv. The research baseline moves to sdks 1abacf9. --- README.md | 4 +++- docs/command-research/campaigns.md | 5 +++-- src/commands/audience/create.ts | 18 +++++++++++++---- src/input/recipients.ts | 23 ++++++++++++++++++++-- test/commands/audiences-optouts.test.ts | 14 +++++++++++++ test/commands/campaign-recipients.test.ts | 24 +++++++++++++++++++++++ 6 files changed, 79 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 42294d48..c050edbc 100644 --- a/README.md +++ b/README.md @@ -994,7 +994,9 @@ polymorfa campaign recipients list campaign_123 \ ``` One append accepts at most 1,000 entries. `--csv` reads the file locally and -sends the rows inline; it does not upload the file. A phone already on the +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). 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`. diff --git a/docs/command-research/campaigns.md b/docs/command-research/campaigns.md index ce54536d..9a0c9afc 100644 --- a/docs/command-research/campaigns.md +++ b/docs/command-research/campaigns.md @@ -3,7 +3,7 @@ - **Status:** Campaigns P0 slice: nine lifecycle operations plus recipients, audiences and opt-out settings - **Reviewed:** 2026-09-20 -- **SDK baseline:** `b663914f84801fed209945b802d3cf7f87ea4974` +- **SDK baseline:** `1abacf9af7e2fefeac7384a9b23a537784545d9f` - **Contract:** `polymorfa/polymorfa` `ef1653e3c4fc7a98b9da56fffae5f2184b7e4574` ## Sources reviewed @@ -96,7 +96,8 @@ zero after the first transaction moved its eligible recipients. 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 a per-request retry count, which the CLI never does. A resent +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. diff --git a/src/commands/audience/create.ts b/src/commands/audience/create.ts index 730b213f..63a1d4e3 100644 --- a/src/commands/audience/create.ts +++ b/src/commands/audience/create.ts @@ -28,7 +28,9 @@ export default class AudienceCreate extends BaseCommand { exclusive: ['file-id'], }), 'file-id': Flags.string({ - description: 'Storage ID of a file already uploaded through the API.', + dependsOn: ['mapping'], + description: + 'Storage ID of a file already uploaded through the API. Requires --mapping.', exclusive: ['csv'], }), 'idempotency-key': Flags.string({ @@ -78,9 +80,7 @@ export default class AudienceCreate extends BaseCommand { if (flags['file-id'] !== undefined) { return { fileId: flags['file-id'], - ...(flags.mapping === undefined - ? {} - : {mapping: audienceMapping(await readJsonInput(flags.mapping))}), + mapping: audienceMapping(await readJsonInput(requiredMapping(flags))), name: flags.name, source: 'csv' as const, } @@ -96,3 +96,13 @@ export default class AudienceCreate extends BaseCommand { 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/input/recipients.ts b/src/input/recipients.ts index faf661f8..386f4790 100644 --- a/src/input/recipients.ts +++ b/src/input/recipients.ts @@ -11,6 +11,7 @@ export const RECIPIENT_BATCH_LIMIT = 1000 const MAX_CSV_BYTES = 10 * 1024 * 1024 const VARIABLE_NAME = /^[A-Za-z0-9_.-]{1,64}$/ const MAX_VARIABLES = 50 +const MAX_REPORTED_ROWS = 20 export interface CsvRecipientOptions { /** Header naming the phone column. */ @@ -58,10 +59,15 @@ export async function readCsvRecipients( } const recipients: CampaignRecipientInput[] = [] - for (const record of records.slice(1)) { + 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 === '') continue + if (phone === '') { + // Header is row 1, matching the API's row numbers for file imports. + missingPhoneRows.push(index + 2) + continue + } const variables: Record = {} for (const [name, index] of variableIndexes) { const value = (record[index] ?? '').trim() @@ -72,6 +78,19 @@ export async function readCsvRecipients( ) } + 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') } diff --git a/test/commands/audiences-optouts.test.ts b/test/commands/audiences-optouts.test.ts index 948e1034..776f8f73 100644 --- a/test/commands/audiences-optouts.test.ts +++ b/test/commands/audiences-optouts.test.ts @@ -71,6 +71,20 @@ describe('Audience and opt-out commands', () => { }) }) + 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') diff --git a/test/commands/campaign-recipients.test.ts b/test/commands/campaign-recipients.test.ts index 397a49d2..03e44020 100644 --- a/test/commands/campaign-recipients.test.ts +++ b/test/commands/campaign-recipients.test.ts @@ -129,6 +129,30 @@ describe('Campaign recipient commands', () => { 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('says a campaign was cancelled when stop returns no operation', async () => { server = await startHttpServer({ body: { From ea3719caa3e3cd711400eda6ee69b94e188e329d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 22 Sep 2026 13:49:34 +0200 Subject: [PATCH 07/17] docs(campaigns): move the SDK baseline to sdks 2862336 CreateAudienceRequest is now a members-or-mapped-file union; audience create already sends one of those shapes, and npm run verify passes unchanged against a tarball packed from that commit. --- docs/command-research/campaigns.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/command-research/campaigns.md b/docs/command-research/campaigns.md index 9a0c9afc..970e955d 100644 --- a/docs/command-research/campaigns.md +++ b/docs/command-research/campaigns.md @@ -3,7 +3,7 @@ - **Status:** Campaigns P0 slice: nine lifecycle operations plus recipients, audiences and opt-out settings - **Reviewed:** 2026-09-20 -- **SDK baseline:** `1abacf9af7e2fefeac7384a9b23a537784545d9f` +- **SDK baseline:** `2862336f0ea660402125aeb7720ab57c17439460` - **Contract:** `polymorfa/polymorfa` `ef1653e3c4fc7a98b9da56fffae5f2184b7e4574` ## Sources reviewed From 5fad0baaa3f11356c7b516cc9570f3d56a3b82b5 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 22 Sep 2026 13:59:33 +0200 Subject: [PATCH 08/17] fix(campaigns): refuse phones the request schema rejects; quote-aware delimiter The append request schema requires phone to be 1 to 64 characters, so an --input entry with "" (or a CSV/JSON phone over 64 characters) made the API refuse the whole batch with 400 instead of reporting an invalid row. Those entries are now refused locally, naming the entry, before anything is sent. Whitespace-only JSON phones are refused too, matching the CSV path. CSV delimiter detection counted separators inside quoted header fields, so "Phone, Intl";Name was read as comma-separated and failed the column check. It now counts only separators outside quotes in the header record. --- README.md | 6 +- src/input/recipients.ts | 67 +++++++++++++++++++---- test/commands/campaign-recipients.test.ts | 62 +++++++++++++++++++++ 3 files changed, 122 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index c050edbc..e2e9e013 100644 --- a/README.md +++ b/README.md @@ -996,7 +996,11 @@ polymorfa campaign recipients list campaign_123 \ 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). A phone already on the +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, because the API would reject the whole request. 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`. diff --git a/src/input/recipients.ts b/src/input/recipients.ts index 386f4790..856bddf8 100644 --- a/src/input/recipients.ts +++ b/src/input/recipients.ts @@ -12,6 +12,8 @@ const MAX_CSV_BYTES = 10 * 1024 * 1024 const VARIABLE_NAME = /^[A-Za-z0-9_.-]{1,64}$/ const MAX_VARIABLES = 50 const MAX_REPORTED_ROWS = 20 +/** The request schema's maximum phone length. */ +const MAX_PHONE_LENGTH = 64 export interface CsvRecipientOptions { /** Header naming the phone column. */ @@ -68,6 +70,12 @@ export async function readCsvRecipients( 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, index] of variableIndexes) { const value = (record[index] ?? '').trim() @@ -157,6 +165,20 @@ export function recipientsFromJson(input: unknown): CampaignRecipientInput[] { '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', + ) + } return entry as unknown as CampaignRecipientInput }) } @@ -199,19 +221,40 @@ function columnIndex(header: readonly string[], name: string): number { return header.findIndex((cell) => cell.toLowerCase() === folded) } +/** + * Pick the separator that occurs most often in the header record, counting + * only separators outside quoted fields, so `"Phone, Intl";Name` is read as + * semicolon-separated. + */ function detectDelimiter(text: string): string { - const line = text.slice( - 0, - text.search(/\r?\n/) === -1 ? undefined : text.search(/\r?\n/), - ) - const counts = [',', ';', '\t'].map((candidate) => ({ - candidate, - count: line.split(candidate).length - 1, - })) - const best = counts.reduce((left, right) => - right.count > left.count ? right : left, - ) - return best.count === 0 ? ',' : best.candidate + 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) + } + let best = ',' + let bestCount = 0 + for (const [candidate, count] of counts) { + if (count > bestCount) { + best = candidate + bestCount = count + } + } + return best } function parseCsv(input: string): string[][] { diff --git a/test/commands/campaign-recipients.test.ts b/test/commands/campaign-recipients.test.ts index 03e44020..1d031992 100644 --- a/test/commands/campaign-recipients.test.ts +++ b/test/commands/campaign-recipients.test.ts @@ -153,6 +153,68 @@ describe('Campaign recipient commands', () => { 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([ + ['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('says a campaign was cancelled when stop returns no operation', async () => { server = await startHttpServer({ body: { From c72acac33953a8bf0ec686909a5f87c22de0ad53 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 22 Sep 2026 14:08:04 +0200 Subject: [PATCH 09/17] fix(campaigns): refuse empty JSON batches and the __proto__ variable name Both append request schemas require at least one entry (minItems 1), so an --input of [] or {recipients: []} was rejected wholesale by the API. It is now refused locally before transport. --variable __proto__=Column passed the name pattern, but assigning it to a plain object invoked the prototype setter, so the mapping was silently dropped. The name is now refused with an explicit error. --- README.md | 3 +- src/input/recipients.ts | 15 +++++++++ test/commands/campaign-recipients.test.ts | 37 +++++++++++++++++++++++ 3 files changed, 54 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e2e9e013..57987fe5 100644 --- a/README.md +++ b/README.md @@ -998,7 +998,8 @@ 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, because the API would reject the whole request. Comma, +refused the same way, as is an empty `--input` array, because the API would +reject the whole request. `__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 diff --git a/src/input/recipients.ts b/src/input/recipients.ts index 856bddf8..881392b8 100644 --- a/src/input/recipients.ts +++ b/src/input/recipients.ts @@ -120,6 +120,14 @@ export function parseVariableMapping( } 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 -.`, @@ -158,6 +166,13 @@ export function recipientsFromJson(input: unknown): CampaignRecipientInput[] { '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( diff --git a/test/commands/campaign-recipients.test.ts b/test/commands/campaign-recipients.test.ts index 1d031992..7d00ba96 100644 --- a/test/commands/campaign-recipients.test.ts +++ b/test/commands/campaign-recipients.test.ts @@ -215,6 +215,43 @@ describe('Campaign recipient commands', () => { }, ) + 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('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: { From 484a0c23df96fa4475bbdb0322604c4e0693a844 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 22 Sep 2026 15:31:23 +0200 Subject: [PATCH 10/17] docs(cli): describe campaign deduplication and operation inspection --- README.md | 14 ++++++-- docs/command-research/campaigns.md | 58 ++++++++++++++++++++---------- src/commands/campaign/create.ts | 2 +- src/commands/campaign/launch.ts | 2 +- src/commands/campaign/pause.ts | 2 +- src/commands/campaign/requeue.ts | 2 +- src/commands/campaign/resume.ts | 2 +- src/commands/campaign/stop.ts | 2 +- 8 files changed, 57 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 57987fe5..9c8cf3e7 100644 --- a/README.md +++ b/README.md @@ -968,8 +968,18 @@ 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`. +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 key for create and launch +when the flag is omitted. Supply the flag to reuse a key across CLI invocations. + +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 diff --git a/docs/command-research/campaigns.md b/docs/command-research/campaigns.md index 970e955d..a41d63de 100644 --- a/docs/command-research/campaigns.md +++ b/docs/command-research/campaigns.md @@ -2,9 +2,13 @@ - **Status:** Campaigns P0 slice: nine lifecycle operations plus recipients, audiences and opt-out settings -- **Reviewed:** 2026-09-20 -- **SDK baseline:** `2862336f0ea660402125aeb7720ab57c17439460` -- **Contract:** `polymorfa/polymorfa` `ef1653e3c4fc7a98b9da56fffae5f2184b7e4574` +- **Reviewed:** 2026-09-22 +- **SDK baseline:** `c5ea8f3933687fb677ed60d682ca19ee28ea7464` +- **Contract:** `polymorfa/polymorfa` `8067be507dc818d2418f8724a943de138678b3db` + +The API snapshot is provisional. SDK publication remains blocked on replacing +its source pin with the final merged API `dev` revision; these notes do not +claim package publication or deployed availability. ## Sources reviewed @@ -78,21 +82,34 @@ 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 @@ -156,17 +173,20 @@ 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. `projectId` stays optional on purpose: a team API key must send it and -a project token must not, which the CLI's Platform commands already express -through the resolved credential rather than a 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 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, and the CLI does not substitute similarly named Platform campaign -operations or use `raw.request` to invent those workflows. +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 @@ -178,5 +198,5 @@ 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 432 total / 316 covered / 0 -missing / 116 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/src/commands/campaign/create.ts b/src/commands/campaign/create.ts index 09cce79a..82b3615d 100644 --- a/src/commands/campaign/create.ts +++ b/src/commands/campaign/create.ts @@ -10,7 +10,7 @@ export default class CampaignCreate extends BaseCommand { static override flags = { 'idempotency-key': Flags.string({ description: - 'Identify this attempt (the API does not deduplicate campaign creation).', + 'Deduplicate this request for 24 hours. A completed key returns 409 idempotency_completed.', }), input: Flags.string({ description: 'Read the campaign JSON object from a file or stdin (-).', diff --git a/src/commands/campaign/launch.ts b/src/commands/campaign/launch.ts index 7802c6c4..28c7c3d8 100644 --- a/src/commands/campaign/launch.ts +++ b/src/commands/campaign/launch.ts @@ -18,7 +18,7 @@ export default class CampaignLaunch 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/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/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 7f78165b..a9bfeb68 100644 --- a/src/commands/campaign/stop.ts +++ b/src/commands/campaign/stop.ts @@ -14,7 +14,7 @@ export default class CampaignStop 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.', From f92b3ef0cf9e941bffa5aa5e78674102e1edd7d9 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 12:25:20 +0200 Subject: [PATCH 11/17] Fix CSV row reporting and JSON recipient validation --- README.md | 4 +- src/commands/audience/create.ts | 13 ++++- src/commands/audience/members/add.ts | 10 +++- src/commands/campaign/recipients/add.ts | 10 +++- src/input/recipients.ts | 47 +++++++++++++++++- test/commands/audiences-optouts.test.ts | 4 +- test/commands/campaign-recipients.test.ts | 59 ++++++++++++++++++++++- 7 files changed, 138 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 8570e6a2..f3c0aebc 100644 --- a/README.md +++ b/README.md @@ -1086,7 +1086,9 @@ 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. `__proto__` is not accepted as a `--variable` name. Comma, +reject the whole request. JSON variables must use valid names and string values +of at most 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 diff --git a/src/commands/audience/create.ts b/src/commands/audience/create.ts index 63a1d4e3..ebce957d 100644 --- a/src/commands/audience/create.ts +++ b/src/commands/audience/create.ts @@ -4,6 +4,7 @@ import {readJsonInput} from '../../api/request.js' import {BaseCommand} from '../../base-command.js' import { assertBatchLimit, + csvInvalidRows, parseVariableMapping, readCsvRecipients, RECIPIENT_BATCH_LIMIT, @@ -66,7 +67,17 @@ export default class AudienceCreate extends BaseCommand { ? {} : {idempotencyKey: flags['idempotency-key']}, ) - this.writeValue(result, renderAudienceImport(result.data), flags) + 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: { diff --git a/src/commands/audience/members/add.ts b/src/commands/audience/members/add.ts index ec147b1e..14935d77 100644 --- a/src/commands/audience/members/add.ts +++ b/src/commands/audience/members/add.ts @@ -4,6 +4,7 @@ import {readJsonInput} from '../../../api/request.js' import {BaseCommand} from '../../../base-command.js' import { assertBatchLimit, + csvInvalidRows, parseVariableMapping, readCsvRecipients, recipientsFromJson, @@ -70,6 +71,13 @@ export default class AudienceMembersAdd extends BaseCommand { ? {} : {idempotencyKey: flags['idempotency-key']}, ) - this.writeValue(result, renderMemberAppend(result.data), flags) + const presented = { + ...result, + data: { + ...result.data, + invalidRows: csvInvalidRows(members, result.data.invalidRows), + }, + } + this.writeValue(presented, renderMemberAppend(presented.data), flags) } } diff --git a/src/commands/campaign/recipients/add.ts b/src/commands/campaign/recipients/add.ts index 025ae27d..f4537d19 100644 --- a/src/commands/campaign/recipients/add.ts +++ b/src/commands/campaign/recipients/add.ts @@ -4,6 +4,7 @@ import {readJsonInput} from '../../../api/request.js' import {BaseCommand} from '../../../base-command.js' import { assertBatchLimit, + csvInvalidRows, parseVariableMapping, readCsvRecipients, recipientsFromJson, @@ -77,6 +78,13 @@ export default class CampaignRecipientsAdd extends BaseCommand { ? {} : {idempotencyKey: flags['idempotency-key']}, ) - this.writeValue(result, renderRecipientAppend(result.data), flags) + const presented = { + ...result, + data: { + ...result.data, + invalidRows: csvInvalidRows(recipients, result.data.invalidRows), + }, + } + this.writeValue(presented, renderRecipientAppend(presented.data), flags) } } diff --git a/src/input/recipients.ts b/src/input/recipients.ts index 881392b8..81db3d41 100644 --- a/src/input/recipients.ts +++ b/src/input/recipients.ts @@ -2,6 +2,7 @@ import {readFile, stat} from 'node:fs/promises' import { type CampaignRecipientInput, + type InvalidRecipientRow, PolymorfaValidationError, } from '@polymorfa/sdk' @@ -9,11 +10,16 @@ import { export const RECIPIENT_BATCH_LIMIT = 1000 const MAX_CSV_BYTES = 10 * 1024 * 1024 -const VARIABLE_NAME = /^[A-Za-z0-9_.-]{1,64}$/ +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. */ @@ -61,6 +67,7 @@ export async function readCsvRecipients( } 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 @@ -84,6 +91,7 @@ export async function readCsvRecipients( recipients.push( Object.keys(variables).length === 0 ? {phone} : {phone, variables}, ) + sourceRows.push(index + 2) } if (missingPhoneRows.length > 0) { @@ -102,9 +110,23 @@ export async function readCsvRecipients( 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[], @@ -194,6 +216,29 @@ export function recipientsFromJson(input: unknown): CampaignRecipientInput[] { '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]) => + !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 }) } diff --git a/test/commands/audiences-optouts.test.ts b/test/commands/audiences-optouts.test.ts index 776f8f73..71c05b63 100644 --- a/test/commands/audiences-optouts.test.ts +++ b/test/commands/audiences-optouts.test.ts @@ -43,7 +43,7 @@ describe('Audience and opt-out commands', () => { source: 'csv', }) expect(result.stdout).toContain('Members\t2') - expect(result.stdout).toContain('Row 3\tmissing_phone') + expect(result.stdout).toContain('Row 2\tmissing_phone') }) test('imports a file already uploaded through the API', async () => { @@ -223,7 +223,7 @@ function audience() { fileId: null, id: 'list_1', invalidCount: 1, - invalidRows: [{reason: 'missing_phone', row: 3}], + invalidRows: [{reason: 'missing_phone', row: 1}], mapping: null, name: 'August launch', recipientCount: 2, diff --git a/test/commands/campaign-recipients.test.ts b/test/commands/campaign-recipients.test.ts index 7d00ba96..a9cd0f07 100644 --- a/test/commands/campaign-recipients.test.ts +++ b/test/commands/campaign-recipients.test.ts @@ -77,7 +77,7 @@ describe('Campaign recipient commands', () => { const csv = join(directory, 'contacts.csv') await writeFile( csv, - 'Phone,First name\n+15551234567,Ada\n+15557654321,Grace\n', + 'Phone,First name\n+15551234567,Ada\n\n+15557654321,Grace\n', 'utf8', ) server = await startHttpServer({ @@ -87,7 +87,7 @@ describe('Campaign recipient commands', () => { campaignId: 'campaign_1', duplicateCount: 1, invalidCount: 1, - invalidRows: [{reason: 'invalid_phone', row: 4}], + invalidRows: [{reason: 'invalid_phone', row: 2}], recipientCount: 11, }, success: true, @@ -112,6 +112,35 @@ describe('Campaign recipient commands', () => { 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') @@ -235,6 +264,32 @@ describe('Campaign recipient commands', () => { expect(server.requests).toHaveLength(0) }) + test.each([ + ['non-string value', {firstName: 42}], + ['invalid name', {'-firstName': 'Ada'}], + ['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') From 422abdca34f60b4f87eb55630e9be8af800f1ea4 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 12:28:11 +0200 Subject: [PATCH 12/17] Reject reserved JSON recipient variable name --- src/input/recipients.ts | 1 + test/commands/campaign-recipients.test.ts | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/src/input/recipients.ts b/src/input/recipients.ts index 81db3d41..d10ad094 100644 --- a/src/input/recipients.ts +++ b/src/input/recipients.ts @@ -228,6 +228,7 @@ export function recipientsFromJson(input: unknown): CampaignRecipientInput[] { variables.length > MAX_VARIABLES || variables.some( ([name, value]) => + name === '__proto__' || !VARIABLE_NAME.test(name) || typeof value !== 'string' || value.length > MAX_VARIABLE_VALUE_LENGTH, diff --git a/test/commands/campaign-recipients.test.ts b/test/commands/campaign-recipients.test.ts index a9cd0f07..0ce6d896 100644 --- a/test/commands/campaign-recipients.test.ts +++ b/test/commands/campaign-recipients.test.ts @@ -267,6 +267,10 @@ describe('Campaign recipient commands', () => { 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', From 4402031d8afe50a42e6cb0522320bbcca2fc594a Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 12:35:07 +0200 Subject: [PATCH 13/17] Validate CSV values and report no-op audience removal --- README.md | 5 +++-- src/input/recipients.ts | 10 ++++++++-- src/services/audiences.ts | 4 +++- test/commands/audiences-optouts.test.ts | 19 +++++++++++++++++++ test/commands/campaign-recipients.test.ts | 21 +++++++++++++++++++++ 5 files changed, 54 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index f3c0aebc..75d7032e 100644 --- a/README.md +++ b/README.md @@ -1086,8 +1086,9 @@ 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 -of at most 1,024 characters. Reported invalid rows for `--csv` refer to the +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 diff --git a/src/input/recipients.ts b/src/input/recipients.ts index d10ad094..6ca042f4 100644 --- a/src/input/recipients.ts +++ b/src/input/recipients.ts @@ -84,8 +84,14 @@ export async function readCsvRecipients( ) } const variables: Record = {} - for (const [name, index] of variableIndexes) { - const value = (record[index] ?? '').trim() + 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( diff --git a/src/services/audiences.ts b/src/services/audiences.ts index f2639c05..80975065 100644 --- a/src/services/audiences.ts +++ b/src/services/audiences.ts @@ -128,7 +128,9 @@ export function renderAudienceMembers( export function renderMemberRemoval( result: DeleteAudienceMemberResult, ): string { - return `Removed ${result.phone} from audience ${result.listId}. ${result.recipientCount} members remain.` + 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 { diff --git a/test/commands/audiences-optouts.test.ts b/test/commands/audiences-optouts.test.ts index 71c05b63..78763a96 100644 --- a/test/commands/audiences-optouts.test.ts +++ b/test/commands/audiences-optouts.test.ts @@ -153,6 +153,25 @@ describe('Audience and opt-out commands', () => { 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()}}) diff --git a/test/commands/campaign-recipients.test.ts b/test/commands/campaign-recipients.test.ts index 0ce6d896..4b2e1618 100644 --- a/test/commands/campaign-recipients.test.ts +++ b/test/commands/campaign-recipients.test.ts @@ -158,6 +158,27 @@ describe('Campaign recipient commands', () => { 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') From 934106611802fb39c2400e55c81faddb69d3a78c Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 13:56:36 +0200 Subject: [PATCH 14/17] Pin published Campaigns SDK for CLI --- README.md | 8 ++++---- docs/command-research/campaigns.md | 12 ++++++------ package-lock.json | 8 ++++---- package.json | 2 +- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 75d7032e..5fa20ceb 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.20260923115248 ``` -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 diff --git a/docs/command-research/campaigns.md b/docs/command-research/campaigns.md index a41d63de..cde3efe0 100644 --- a/docs/command-research/campaigns.md +++ b/docs/command-research/campaigns.md @@ -2,13 +2,13 @@ - **Status:** Campaigns P0 slice: nine lifecycle operations plus recipients, audiences and opt-out settings -- **Reviewed:** 2026-09-22 -- **SDK baseline:** `c5ea8f3933687fb677ed60d682ca19ee28ea7464` -- **Contract:** `polymorfa/polymorfa` `8067be507dc818d2418f8724a943de138678b3db` +- **Reviewed:** 2026-09-23 +- **SDK baseline:** `7b8b44e9cd15356724e5ba825db320d90d896b06` +- **Contract:** `polymorfa/polymorfa` `f4a340da3b74248232ebea73f3e72b42f667beef` (merged `dev`) -The API snapshot is provisional. SDK publication remains blocked on replacing -its source pin with the final merged API `dev` revision; these notes do not -claim package publication or deployed availability. +The API contract is merged into `dev`, and the SDK dev prerelease +`0.1.0-dev.20260923115248` includes the Campaigns P0 methods. These notes do +not claim deployed availability. ## Sources reviewed diff --git a/package-lock.json b/package-lock.json index f71d41a6..cb5b1e91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20260922102711", + "@polymorfa/sdk": "0.1.0-dev.20260923115248", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, @@ -997,9 +997,9 @@ } }, "node_modules/@polymorfa/sdk": { - "version": "0.1.0-dev.20260922102711", - "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20260922102711.tgz", - "integrity": "sha512-m2cnvInBZr4HZKslZewikM48mQIcREHuHJ3oF+/My/DN7pscwquTTkN6RM0mIQfxV+848hzsJZYoIqWxTgvqQA==", + "version": "0.1.0-dev.20260923115248", + "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20260923115248.tgz", + "integrity": "sha512-3RYbXmU5dCzh+m+ydy0PExiEhQ4Lg+wY7QEnCPBLs9hNRlpgbhsP1q8WTcW/YGvNV25/8AHRlPxmaGVRQaY8ag==", "engines": { "node": ">=20" } diff --git a/package.json b/package.json index 0420413f..de19ad98 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20260922102711", + "@polymorfa/sdk": "0.1.0-dev.20260923115248", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, From b7342bcd03d760e9788ee846bc381e96aaccfd38 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 14:06:51 +0200 Subject: [PATCH 15/17] Resolve CSV delimiter ties using phone column --- src/input/recipients.ts | 36 +++++++++++++++-------- test/commands/campaign-recipients.test.ts | 34 +++++++++++++++++++++ 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/src/input/recipients.ts b/src/input/recipients.ts index 6ca042f4..d17fa298 100644 --- a/src/input/recipients.ts +++ b/src/input/recipients.ts @@ -40,7 +40,7 @@ export async function readCsvRecipients( options: CsvRecipientOptions, ): Promise { const text = await readCsvFile(path) - const records = parseCsv(text) + 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') @@ -290,10 +290,10 @@ function columnIndex(header: readonly string[], name: string): number { /** * Pick the separator that occurs most often in the header record, counting - * only separators outside quoted fields, so `"Phone, Intl";Name` is read as - * semicolon-separated. + * only separators outside quoted fields. If counts tie, use the delimiter + * that exposes the requested phone column. */ -function detectDelimiter(text: string): string { +function detectDelimiter(text: string, phoneColumn: string): string { const counts = new Map([ [',', 0], [';', 0], @@ -313,20 +313,30 @@ function detectDelimiter(text: string): string { else if (char === '\n' || char === '\r') break else if (counts.has(char)) counts.set(char, counts.get(char)! + 1) } - let best = ',' - let bestCount = 0 - for (const [candidate, count] of counts) { - if (count > bestCount) { - best = candidate - bestCount = count + const bestCount = Math.max(...counts.values()) + if (bestCount === 0) return ',' + const candidates = [...counts] + .filter(([, count]) => count === bestCount) + .map(([candidate]) => candidate) + if (candidates.length === 1) return candidates[0]! + 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 best + return candidates[0]! } -function parseCsv(input: string): string[][] { +function parseCsv(input: string, phoneColumn: string): string[][] { const text = input.charCodeAt(0) === 0xfe_ff ? input.slice(1) : input - const delimiter = detectDelimiter(text) + const delimiter = detectDelimiter(text, phoneColumn) + return parseCsvWithDelimiter(text, delimiter) +} + +function parseCsvWithDelimiter(text: string, delimiter: string): string[][] { const records: string[][] = [] let record: string[] = [] let field = '' diff --git a/test/commands/campaign-recipients.test.ts b/test/commands/campaign-recipients.test.ts index 4b2e1618..40943efb 100644 --- a/test/commands/campaign-recipients.test.ts +++ b/test/commands/campaign-recipients.test.ts @@ -237,6 +237,40 @@ describe('Campaign recipient commands', () => { }) }) + test('uses the phone header when comma and semicolon counts tie', async () => { + directory = await mkdtemp(join(tmpdir(), 'polymorfa-recipients-')) + const csv = join(directory, 'contacts.csv') + await writeFile( + csv, + 'phone;Last, First\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} --variable "name=Last, First"`, + ), + ) + + expect(result.error).toBeUndefined() + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + recipients: [{phone: '+15551234567', variables: {name: 'Lovelace, Ada'}}], + }) + }) + test.each([ ['an empty phone', '', 'Entry 2 has an empty phone'], ['a blank phone', ' ', 'Entry 2 has an empty phone'], From 277001f2f95004ee1ca863d86e860d7c36a50e17 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 14:13:15 +0200 Subject: [PATCH 16/17] Choose CSV separator by phone header --- src/input/recipients.ts | 17 +++++++-------- test/commands/campaign-recipients.test.ts | 25 +++++++++++++++-------- 2 files changed, 24 insertions(+), 18 deletions(-) diff --git a/src/input/recipients.ts b/src/input/recipients.ts index d17fa298..59064cec 100644 --- a/src/input/recipients.ts +++ b/src/input/recipients.ts @@ -289,9 +289,9 @@ function columnIndex(header: readonly string[], name: string): number { } /** - * Pick the separator that occurs most often in the header record, counting - * only separators outside quoted fields. If counts tie, use the delimiter - * that exposes the requested phone column. + * 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([ @@ -313,13 +313,10 @@ function detectDelimiter(text: string, phoneColumn: string): string { else if (char === '\n' || char === '\r') break else if (counts.has(char)) counts.set(char, counts.get(char)! + 1) } - const bestCount = Math.max(...counts.values()) - if (bestCount === 0) return ',' const candidates = [...counts] - .filter(([, count]) => count === bestCount) - .map(([candidate]) => candidate) - if (candidates.length === 1) return candidates[0]! - for (const candidate of candidates) { + .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(), ) @@ -327,7 +324,7 @@ function detectDelimiter(text: string, phoneColumn: string): string { return candidate } } - return candidates[0]! + return candidates[0]?.[0] ?? ',' } function parseCsv(input: string, phoneColumn: string): string[][] { diff --git a/test/commands/campaign-recipients.test.ts b/test/commands/campaign-recipients.test.ts index 40943efb..f8bcfe7a 100644 --- a/test/commands/campaign-recipients.test.ts +++ b/test/commands/campaign-recipients.test.ts @@ -237,14 +237,23 @@ describe('Campaign recipient commands', () => { }) }) - test('uses the phone header when comma and semicolon counts tie', async () => { + 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, - 'phone;Last, First\n+15551234567;Lovelace, Ada\n', - 'utf8', - ) + await writeFile(csv, content, 'utf8') server = await startHttpServer({ body: { data: { @@ -261,13 +270,13 @@ describe('Campaign recipient commands', () => { const result = await apiKeyEnvironment(() => runCommand( - `campaign recipients add campaign_1 --project-slug project_1 --csv ${csv} --variable "name=Last, First"`, + `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: 'Lovelace, Ada'}}], + recipients: [{phone: '+15551234567', variables: {name}}], }) }) From c36cb9690d9e20acaee509228bf3cd587ae11827 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 14:20:53 +0200 Subject: [PATCH 17/17] Remove duplicate CLI topic declarations --- package.json | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/package.json b/package.json index 10eefad3..380c201e 100644 --- a/package.json +++ b/package.json @@ -207,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." },