From 15324be33877e06590c3d47cae5f3876f663487f Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 12:45:02 +0200 Subject: [PATCH 1/2] Add scheduled campaign reschedule command --- README.md | 6 +- src/commands/campaign/reschedule.ts | 70 +++++++++++++++++ src/services/campaigns.ts | 16 ++++ test/commands/campaigns.test.ts | 117 ++++++++++++++++++++++++++++ 4 files changed, 208 insertions(+), 1 deletion(-) create mode 100644 src/commands/campaign/reschedule.ts 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..b786fa3b --- /dev/null +++ b/src/commands/campaign/reschedule.ts @@ -0,0 +1,70 @@ +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'}, + ) + } + + 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..c6e824f3 100644 --- a/test/commands/campaigns.test.ts +++ b/test/commands/campaigns.test.ts @@ -175,6 +175,123 @@ 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 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}}, From f81a7c8b0d95c93cb8d3217fd14b33aa1779dd8f Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 12:57:46 +0200 Subject: [PATCH 2/2] Reject unrepresentable campaign reschedule times --- src/commands/campaign/reschedule.ts | 9 +++++++++ test/commands/campaigns.test.ts | 2 ++ 2 files changed, 11 insertions(+) diff --git a/src/commands/campaign/reschedule.ts b/src/commands/campaign/reschedule.ts index b786fa3b..0cd2586d 100644 --- a/src/commands/campaign/reschedule.ts +++ b/src/commands/campaign/reschedule.ts @@ -53,6 +53,15 @@ export default class CampaignReschedule extends BaseCommand { {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'}), diff --git a/test/commands/campaigns.test.ts b/test/commands/campaigns.test.ts index c6e824f3..e9dbbee8 100644 --- a/test/commands/campaigns.test.ts +++ b/test/commands/campaigns.test.ts @@ -238,6 +238,8 @@ describe('Messaging campaign commands', () => { '', '--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)',