Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
79 changes: 79 additions & 0 deletions src/commands/campaign/reschedule.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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'])
Comment on lines +47 to +49

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject normalized invalid calendar dates

When --scheduled-at contains an impossible date such as 2026-02-31T12:00:00Z, parseCampaignSchedule relies on Date.parse, which normalizes it to March 3 rather than rejecting it. The command therefore sends a valid but unintended timestamp, so a user can silently reschedule a campaign to the wrong day; validate that the parsed calendar components match the supplied ISO instant before transport.

Useful? React with 👍 / 👎.

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,
)
}
}
16 changes: 16 additions & 0 deletions src/services/campaigns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,22 @@
return result(response.data.data, response.metadata)
}

export async function rescheduleCampaign(
client: MessagingClient,
projectSlug: string,
campaignId: string,
scheduledAt: number | null,
options: RequestOptions = {},
): Promise<CampaignResult<CampaignOperation>> {
const response = await client.campaigns.reschedule(

Check failure on line 94 in src/services/campaigns.ts

View workflow job for this annotation

GitHub Actions / Verify Node 22

Property 'reschedule' does not exist on type 'MessagingCampaignsResource'.
projectSlug,
campaignId,
{scheduledAt},
options,
)
return result(response.data.data, response.metadata)
}

export async function pauseCampaign(
client: MessagingClient,
projectSlug: string,
Expand Down
119 changes: 119 additions & 0 deletions test/commands/campaigns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}},
Expand Down
Loading