diff --git a/README.md b/README.md index 3845eaa7..5b553c68 100644 --- a/README.md +++ b/README.md @@ -1342,13 +1342,17 @@ polymorfa campaign launch campaign_123 \ --scheduled-at 2026-09-01T12:00:00Z polymorfa campaign pause campaign_123 --project-slug launch-project polymorfa campaign resume campaign_123 --project-slug launch-project +polymorfa campaign reschedule campaign_123 \ + --project-slug launch-project \ + --scheduled-at 2026-09-02T12:00:00Z +polymorfa campaign reschedule campaign_123 --project-slug launch-project --now polymorfa campaign stop campaign_123 --project-slug launch-project --yes polymorfa campaign requeue campaign_123 \ --project-slug launch-project \ --include-skipped-error ``` -Launch, pause, and resume return accepted durable work; they do not wait for +Launch, pause, resume, and reschedule return accepted durable work; they do not wait for the state transition. Stop returns accepted work for an active delivery run, or reports an immediate cancellation with no operation ID when none is active. Check the campaign with `polymorfa campaign view`. When an operation ID is diff --git a/src/commands/campaign/reschedule.ts b/src/commands/campaign/reschedule.ts new file mode 100644 index 00000000..0cd2586d --- /dev/null +++ b/src/commands/campaign/reschedule.ts @@ -0,0 +1,79 @@ +import {Args, Flags} from '@oclif/core' +import {PolymorfaValidationError} from '@polymorfa/sdk' + +import {BaseCommand} from '../../base-command.js' +import {idempotencyKey} from '../../input/idempotency.js' +import { + parseCampaignSchedule, + renderCampaignOperation, + rescheduleCampaign, +} from '../../services/campaigns.js' + +export default class CampaignReschedule extends BaseCommand { + static override args = { + campaign: Args.string({description: 'Campaign ID.', required: true}), + } + + static override description = + 'Change a launched campaign start time before it begins sending, or start it now.' + + static override flags = { + 'idempotency-key': Flags.string({ + description: + 'Idempotency-Key header. Generated per invocation when omitted; reuse an explicit key across process retries (24-hour retention).', + }), + now: Flags.boolean({ + description: 'Start the scheduled campaign now.', + }), + 'project-slug': Flags.string({ + description: 'Project slug in the Messaging API path.', + required: true, + }), + 'scheduled-at': Flags.string({ + description: + 'New start time as epoch milliseconds or an ISO 8601 instant.', + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(CampaignReschedule) + if (flags.now === (flags['scheduled-at'] !== undefined)) { + throw new PolymorfaValidationError( + 'Specify exactly one of --scheduled-at or --now.', + {code: 'invalid_campaign_schedule'}, + ) + } + + const scheduledAt = flags.now + ? null + : parseCampaignSchedule(flags['scheduled-at']) + if (scheduledAt === undefined) { + throw new PolymorfaValidationError( + 'Specify exactly one of --scheduled-at or --now.', + {code: 'invalid_campaign_schedule'}, + ) + } + if ( + scheduledAt !== null && + (scheduledAt <= 0 || scheduledAt > 8_640_000_000_000_000) + ) { + throw new PolymorfaValidationError( + 'Scheduled time must be a positive, representable Unix timestamp in milliseconds.', + {code: 'invalid_campaign_schedule'}, + ) + } + + const result = await rescheduleCampaign( + await this.messagingClient(flags, {serverApiKeyOnly: 'Campaign'}), + flags['project-slug'], + args.campaign, + scheduledAt, + {idempotencyKey: idempotencyKey(flags['idempotency-key'])}, + ) + this.writeValue( + result, + renderCampaignOperation('reschedule', result.data), + flags, + ) + } +} diff --git a/src/services/campaigns.ts b/src/services/campaigns.ts index 89a2b155..774bd023 100644 --- a/src/services/campaigns.ts +++ b/src/services/campaigns.ts @@ -84,6 +84,22 @@ export async function launchCampaign( return result(response.data.data, response.metadata) } +export async function rescheduleCampaign( + client: MessagingClient, + projectSlug: string, + campaignId: string, + scheduledAt: number | null, + options: RequestOptions = {}, +): Promise> { + const response = await client.campaigns.reschedule( + projectSlug, + campaignId, + {scheduledAt}, + options, + ) + return result(response.data.data, response.metadata) +} + export async function pauseCampaign( client: MessagingClient, projectSlug: string, diff --git a/test/commands/campaigns.test.ts b/test/commands/campaigns.test.ts index 41c71447..e9dbbee8 100644 --- a/test/commands/campaigns.test.ts +++ b/test/commands/campaigns.test.ts @@ -175,6 +175,125 @@ describe('Messaging campaign commands', () => { expect(server.requests).toHaveLength(0) }) + test.each([ + ['2026-09-02T12:00:00Z', 1_788_350_400_000], + ['1788350400000', 1_788_350_400_000], + ])('reschedules with a precise instant (%s)', async (at, milliseconds) => { + server = await startHttpServer({ + body: { + data: { + ...campaign(), + operationId: 'op_reschedule', + status: 'scheduled', + }, + success: true, + }, + }) + + const result = await apiKeyEnvironment(() => + runCommand( + `campaign reschedule campaign/1 --project-slug project/main --scheduled-at ${at} --idempotency-key move_1 --json`, + ), + ) + + expect(result.error).toBeUndefined() + expect(JSON.parse(result.stdout)).toMatchObject({ + data: {operationId: 'op_reschedule', status: 'scheduled'}, + metadata: {status: 200}, + }) + expect(server.requests[0]).toMatchObject({ + body: JSON.stringify({scheduledAt: milliseconds}), + method: 'POST', + url: '/messaging/projects/project%2Fmain/campaigns/campaign%2F1/reschedule', + }) + expect(server.requests[0]?.headers['idempotency-key']).toBe('move_1') + }) + + test('starts a scheduled campaign now and generates an idempotency key', async () => { + server = await startHttpServer({ + body: {data: {...campaign(), operationId: 'op_now'}, success: true}, + }) + + const result = await apiKeyEnvironment(() => + runCommand( + 'campaign reschedule campaign_1 --project-slug project_1 --now', + ), + ) + + expect(result.error).toBeUndefined() + expect(result.stdout).toContain( + 'Accepted campaign reschedule as operation op_now.', + ) + expect(server.requests[0]).toMatchObject({ + body: JSON.stringify({scheduledAt: null}), + method: 'POST', + url: '/messaging/projects/project_1/campaigns/campaign_1/reschedule', + }) + expect(server.requests[0]?.headers['idempotency-key']).toMatch( + /^pmfa_cli_[0-9a-f-]{36}$/, + ) + }) + + test.each([ + '', + '--now --scheduled-at 2026-09-02T12:00:00Z', + '--scheduled-at someday', + '--scheduled-at 0', + '--scheduled-at 8640000000000001', + '--scheduled-at 2026-09-02T12:00:00', + ])( + 'rejects invalid reschedule flags before transport (%s)', + async (flags) => { + server = await startHttpServer({body: {data: campaign(), success: true}}) + + const result = await apiKeyEnvironment(() => + runCommand( + `campaign reschedule campaign_1 --project-slug project_1 ${flags} --json`, + ), + ) + + expect(exitCode(result.error)).toBe(2) + expect(server.requests).toHaveLength(0) + }, + ) + + test('rejects a browser client token before reschedule transport', async () => { + server = await startHttpServer({body: {data: campaign(), success: true}}) + + const result = await clientTokenEnvironment(() => + runCommand( + 'campaign reschedule campaign_1 --project-slug project_1 --now', + ), + ) + + expect(exitCode(result.error)).toBe(2) + expect(server.requests).toHaveLength(0) + }) + + test('reports a changed campaign state as a failed reschedule', async () => { + server = await startHttpServer({ + status: 409, + body: { + success: false, + error: { + code: 'campaign_state_conflict', + message: + 'This campaign has already started, so its start time can no longer be changed.', + }, + }, + }) + + const result = await apiKeyEnvironment(() => + runCommand( + 'campaign reschedule campaign_1 --project-slug project_1 --now --json', + ), + ) + + expect(result.error).toBeDefined() + expect(result.stderr).toContain('already started') + expect(server.requests).toHaveLength(1) + }) + test('pauses, resumes, and explicitly stops through distinct lifecycle routes', async () => { server = await startHttpSequenceServer([ {body: {data: {...campaign(), operationId: 'op_pause'}, success: true}},