diff --git a/README.md b/README.md index d4cd6e0..7641b27 100644 --- a/README.md +++ b/README.md @@ -1800,6 +1800,24 @@ Check the campaign with `polymorfa campaign view`. When an operation ID is returned, follow it with `polymorfa operation wait` or `polymorfa operation watch`; see [Operations](#operations). +To create a template campaign, set `templateId` in `campaign.json`. Add +`variableMapping` when template variables use different recipient field names +or need a fallback: + +```json +{ + "name": "September launch", + "templateId": "template_123", + "variableMapping": { + "firstName": {"source": "name", "fallback": "friend"} + } +} +``` + +Launch refuses recipients whose variables have no value or fallback. Pass +`--skip-missing-variables` to skip those recipients instead; their result has +`lastError` `missing_variable`. The flag is never enabled by default. + 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; diff --git a/contracts/cli-reference.json b/contracts/cli-reference.json index 432e7ab..44206d6 100644 --- a/contracts/cli-reference.json +++ b/contracts/cli-reference.json @@ -17307,6 +17307,18 @@ "env": null, "helpGroup": null }, + { + "name": "skip-missing-variables", + "char": null, + "type": "boolean", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Skip recipients missing a template variable with no value or fallback. Without this flag launch fails.", + "env": null, + "helpGroup": null + }, { "name": "timeout", "char": null, diff --git a/package-lock.json b/package-lock.json index ba9ccc6..404e05c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@modelcontextprotocol/server": "2.3.1", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20261004024706", + "@polymorfa/sdk": "0.1.0-dev.20261010020302", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.6.7" }, @@ -1022,9 +1022,10 @@ } }, "node_modules/@polymorfa/sdk": { - "version": "0.1.0-dev.20261004024706", - "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20261004024706.tgz", - "integrity": "sha512-lCgzm4tzkAoy+cl8oyMo+qLrujaBgVwJccqyAKRRpPbGnUVluG3BA7C6+7/wVkckcmo2b1csc+eCU/tPBngx0Q==", + "version": "0.1.0-dev.20261010020302", + "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20261010020302.tgz", + "integrity": "sha512-9ZA5NjZmRSvojsl+SJa90FoiuYcPitRho1EfOVif515Fr4TY5WH7X3Kx3s1mq9tEJMH7mZ8zCTCvIKP/mjT/+g==", + "license": "MIT", "engines": { "node": ">=20" } diff --git a/package.json b/package.json index a0f048b..aa1f6f6 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "@modelcontextprotocol/server": "2.3.1", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20261004024706", + "@polymorfa/sdk": "0.1.0-dev.20261010020302", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.6.7" }, diff --git a/src/commands/campaign/launch.ts b/src/commands/campaign/launch.ts index f70af46..2bceeff 100644 --- a/src/commands/campaign/launch.ts +++ b/src/commands/campaign/launch.ts @@ -28,6 +28,10 @@ export default class CampaignLaunch extends BaseCommand { 'scheduled-at': Flags.string({ description: 'Launch time as epoch milliseconds or an ISO 8601 instant.', }), + 'skip-missing-variables': Flags.boolean({ + description: + 'Skip recipients missing a template variable with no value or fallback. Without this flag launch fails.', + }), } async run(): Promise { @@ -38,6 +42,7 @@ export default class CampaignLaunch extends BaseCommand { flags['project-slug'], args.campaign, scheduledAt, + flags['skip-missing-variables'], {idempotencyKey: idempotencyKey(flags['idempotency-key'])}, ) this.writeValue( diff --git a/src/services/campaigns.ts b/src/services/campaigns.ts index 58ec7ba..2a5c027 100644 --- a/src/services/campaigns.ts +++ b/src/services/campaigns.ts @@ -86,12 +86,16 @@ export async function launchCampaign( projectSlug: string, campaignId: string, scheduledAt: number | undefined, + skipMissingVariables: boolean, options: RequestOptions = {}, ): Promise> { const response = await client.campaigns.launch( projectSlug, campaignId, - scheduledAt === undefined ? {} : {scheduledAt}, + { + ...(scheduledAt === undefined ? {} : {scheduledAt}), + ...(skipMissingVariables ? {skipMissingVariables: true} : {}), + }, options, ) return result(response.data.data, response.metadata) @@ -144,7 +148,9 @@ export async function listCampaignRecipients( client: MessagingClient, projectSlug: string, campaignId: string, - params: ListCampaignRecipientsParams & {readonly reason?: string}, + params: Omit & { + readonly reason?: string + }, ): Promise> { const response = await client.raw.request<{ data: readonly CampaignRecipient[] @@ -233,6 +239,11 @@ export function renderCampaign(campaign: Campaign): string { `ID\t${campaign.id}`, `Name\t${campaign.name}`, `Status\t${campaign.status}`, + `Template\t${campaign.templateId ?? ''}`, + ...(campaign.variableMapping === undefined || + campaign.variableMapping === null + ? [] + : [`Variable mapping\t${JSON.stringify(campaign.variableMapping)}`]), `Recipients\t${campaign.recipientCount}`, `Sent\t${campaign.sentCount}`, `Delivered\t${campaign.deliveredCount}`, diff --git a/test/commands/campaigns.test.ts b/test/commands/campaigns.test.ts index c42c87f..c8a9197 100644 --- a/test/commands/campaigns.test.ts +++ b/test/commands/campaigns.test.ts @@ -52,6 +52,7 @@ describe('Messaging campaign commands', () => { recipientListId: 'recipients_1', senderConfig: {session: 'session_1'}, templateId: 'template_1', + variableMapping: {firstName: {source: 'name', fallback: 'friend'}}, }) server = await startHttpServer({body: {data: campaign(), success: true}}) @@ -67,6 +68,7 @@ describe('Messaging campaign commands', () => { recipientListId: 'recipients_1', senderConfig: {session: 'session_1'}, templateId: 'template_1', + variableMapping: {firstName: {source: 'name', fallback: 'friend'}}, frequencyCap: {maxPer24Hours: 2, maxPer7Days: null}, }) expect(server.requests[0]).toMatchObject({ @@ -176,6 +178,28 @@ describe('Messaging campaign commands', () => { expect(server.requests).toHaveLength(0) }) + test('passes the explicit missing-variable skip policy on launch', async () => { + server = await startHttpServer({ + body: { + data: {...campaign(), operationId: 'operation_1', status: 'draft'}, + success: true, + }, + }) + + const result = await apiKeyEnvironment(() => + runCommand( + 'campaign launch campaign_1 --project-slug project_1 --skip-missing-variables --idempotency-key launch_skip_1', + ), + ) + + expect(result.error).toBeUndefined() + expect(server.requests[0]).toMatchObject({ + body: JSON.stringify({skipMissingVariables: true}), + method: 'POST', + url: '/messaging/projects/project_1/campaigns/campaign_1/launch', + }) + }) + test('pauses, resumes, and explicitly stops through distinct lifecycle routes', async () => { server = await startHttpSequenceServer([ {body: {data: {...campaign(), operationId: 'op_pause'}, success: true}},