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
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions src/commands/campaign/update.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
40 changes: 40 additions & 0 deletions src/services/campaigns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
PolymorfaValidationError,
type RequestOptions,
type ResponseMetadata,
type UpdateCampaignRequest,

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

View workflow job for this annotation

GitHub Actions / Verify Node 22

'"@polymorfa/sdk"' has no exported member named 'UpdateCampaignRequest'. Did you mean 'CreateCampaignRequest'?
} from '@polymorfa/sdk'

export interface CampaignResult<T> {
Expand Down Expand Up @@ -59,6 +60,20 @@
return result(response.data.data, response.metadata)
}

export async function updateCampaign(
client: MessagingClient,
projectSlug: string,
campaignId: string,
input: unknown,
): Promise<CampaignResult<Campaign>> {
const response = await client.campaigns.update(

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

View workflow job for this annotation

GitHub Actions / Verify Node 22

Property 'update' does not exist on type 'MessagingCampaignsResource'.
projectSlug,
campaignId,
campaignUpdateInput(input),
)
return result(response.data.data, response.metadata)
}

export async function viewCampaignAnalytics(
client: MessagingClient,
projectSlug: string,
Expand Down Expand Up @@ -351,6 +366,31 @@
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<T>(data: T, metadata: ResponseMetadata): CampaignResult<T> {
return {
data,
Expand Down
76 changes: 76 additions & 0 deletions test/commands/campaigns.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}},
Expand Down
Loading