diff --git a/README.md b/README.md index 3845eaa7..20b95c89 100644 --- a/README.md +++ b/README.md @@ -1334,6 +1334,8 @@ project slug because the source routes are slug-scoped: ```shell polymorfa campaign list --project-slug launch-project polymorfa campaign create --project-slug launch-project --input campaign.json +polymorfa campaign update 0198a2cb-0000-7000-8000-0000000000e3 \ + --project-slug launch-project --input campaign-patch.json polymorfa campaign view campaign_123 --project-slug launch-project polymorfa campaign analytics campaign_123 --project-slug launch-project @@ -1364,6 +1366,15 @@ 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. +Update reads one JSON object with at least one of `name`, `recipientListId`, +`senderConfig`, or `scheduledAt`. Send only fields to change. Set +`recipientListId` or `scheduledAt` to `null` to detach the audience or start at +launch. A non-null `scheduledAt` must be integer Unix milliseconds within the +JavaScript Date range. The API refuses audience and start-time changes after launch. A sender +change after launch requires the sending numbers to pass live eligibility +checks. If a PATCH result is lost, view the campaign before deciding whether +to send another update. + 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 @@ -1427,7 +1438,7 @@ List returns `{data, page}`. Pass `page.nextCursor` back as `--cursor` while 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, +The source exposes no campaign delete, validate, archive, duplicate, event, history, or replay route on the Messaging surface. The CLI does not substitute similarly named Platform campaign methods for these gaps. To follow or stop a campaign's operation, use the `operation` commands in diff --git a/src/commands/campaign/update.ts b/src/commands/campaign/update.ts new file mode 100644 index 00000000..972a856a --- /dev/null +++ b/src/commands/campaign/update.ts @@ -0,0 +1,37 @@ +import {Args, Flags} from '@oclif/core' + +import {readJsonInput} from '../../api/request.js' +import {BaseCommand} from '../../base-command.js' +import {renderCampaign, updateCampaign} from '../../services/campaigns.js' + +export default class CampaignUpdate extends BaseCommand { + static override args = { + campaign: Args.string({description: 'Campaign UUID.', required: true}), + } + + static override description = + 'Update campaign fields. A launched campaign cannot change its audience or start time.' + + static override flags = { + input: Flags.string({ + description: + 'Read the campaign patch JSON object from a file or stdin (-).', + required: true, + }), + 'project-slug': Flags.string({ + description: 'Project slug in the Messaging API path.', + required: true, + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(CampaignUpdate) + const result = await updateCampaign( + await this.messagingClient(flags, {serverApiKeyOnly: 'Campaign'}), + flags['project-slug'], + args.campaign, + await readJsonInput(flags.input), + ) + this.writeValue(result, renderCampaign(result.data), flags) + } +} diff --git a/src/services/campaigns.ts b/src/services/campaigns.ts index 89a2b155..0937807a 100644 --- a/src/services/campaigns.ts +++ b/src/services/campaigns.ts @@ -16,6 +16,7 @@ import { PolymorfaValidationError, type RequestOptions, type ResponseMetadata, + type UpdateCampaignRequest, } from '@polymorfa/sdk' export interface CampaignResult { @@ -59,6 +60,20 @@ export async function viewCampaign( return result(response.data.data, response.metadata) } +export async function updateCampaign( + client: MessagingClient, + projectSlug: string, + campaignId: string, + input: unknown, +): Promise> { + const response = await client.campaigns.update( + projectSlug, + campaignId, + campaignUpdateInput(input), + ) + return result(response.data.data, response.metadata) +} + export async function viewCampaignAnalytics( client: MessagingClient, projectSlug: string, @@ -351,6 +366,31 @@ function campaignInput(input: unknown): CreateCampaignRequest { return input as unknown as CreateCampaignRequest } +function campaignUpdateInput(input: unknown): UpdateCampaignRequest { + if (!isRecord(input)) { + throw new PolymorfaValidationError( + 'Campaign update input must contain one JSON object.', + {code: 'invalid_campaign_update_input'}, + ) + } + + const allowed = new Set([ + 'name', + 'recipientListId', + 'senderConfig', + 'scheduledAt', + ]) + const names = Object.keys(input) + if (names.length === 0 || names.some((name) => !allowed.has(name))) { + throw new PolymorfaValidationError( + 'Campaign update input must contain only name, recipientListId, senderConfig, or scheduledAt.', + {code: 'invalid_campaign_update_fields'}, + ) + } + + return input +} + function result(data: T, metadata: ResponseMetadata): CampaignResult { return { data, diff --git a/test/commands/campaigns.test.ts b/test/commands/campaigns.test.ts index 41c71447..c672e456 100644 --- a/test/commands/campaigns.test.ts +++ b/test/commands/campaigns.test.ts @@ -82,6 +82,82 @@ describe('Messaging campaign commands', () => { }, ) + test('updates a campaign with a partial JSON body through Messaging PATCH', async () => { + const input = await jsonInput('campaign-update.json', { + name: 'Autumn launch', + recipientListId: null, + scheduledAt: null, + }) + server = await startHttpServer({ + body: {data: {...campaign(), name: 'Autumn launch'}, success: true}, + headers: {'request-id': 'req_campaign_update'}, + }) + + const result = await apiKeyEnvironment(() => + runCommand( + `campaign update 0198a2cb-0000-7000-8000-0000000000e3 --project-slug project/main --input ${input} --json`, + ), + ) + + expect(result.error).toBeUndefined() + expect(JSON.parse(result.stdout)).toMatchObject({ + data: {name: 'Autumn launch'}, + metadata: {attempts: 1, requestId: 'req_campaign_update', status: 200}, + }) + expect(server.requests[0]).toMatchObject({ + method: 'PATCH', + url: '/messaging/projects/project%2Fmain/campaigns/0198a2cb-0000-7000-8000-0000000000e3', + body: JSON.stringify({ + name: 'Autumn launch', + recipientListId: null, + scheduledAt: null, + }), + }) + }) + + test.each([{}, {status: 'sent'}, []])( + 'rejects unsupported or empty campaign patches before transport', + async (value) => { + const input = await jsonInput('campaign-update-invalid.json', value) + server = await startHttpServer({body: {data: campaign(), success: true}}) + const result = await apiKeyEnvironment(() => + runCommand( + `campaign update 0198a2cb-0000-7000-8000-0000000000e3 --project-slug project_1 --input ${input}`, + ), + ) + expect(exitCode(result.error)).toBe(2) + expect(server.requests).toHaveLength(0) + }, + ) + + test('reports a launched-audience conflict without replaying the PATCH', async () => { + const input = await jsonInput('campaign-update-conflict.json', { + recipientListId: null, + }) + server = await startHttpServer({ + body: { + error: { + code: 'state_conflict', + message: 'The audience is fixed after launch.', + }, + success: false, + }, + status: 409, + }) + + const result = await apiKeyEnvironment(() => + runCommand( + `campaign update 0198a2cb-0000-7000-8000-0000000000e3 --project-slug project_1 --input ${input} --json`, + ), + ) + + expect(exitCode(result.error)).toBe(4) + expect(JSON.parse(result.stderr)).toMatchObject({ + error: {category: 'conflict', code: 'state_conflict'}, + }) + expect(server.requests).toHaveLength(1) + }) + test('views one campaign and its exact live analytics counters', async () => { server = await startHttpSequenceServer([ {body: {data: campaign(), success: true}},