Skip to content

Commit 96aefdf

Browse files
committed
Merge remote-tracking branch 'origin/codex/campaign-messaging-patch-cli' into t3code/campaigns-contracts-cli
# Conflicts: # README.md
2 parents 2b5fccf + 1620b69 commit 96aefdf

4 files changed

Lines changed: 165 additions & 1 deletion

File tree

‎README.md‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1669,6 +1669,8 @@ project slug because the source routes are slug-scoped:
16691669
```shell
16701670
polymorfa campaign list --project-slug launch-project
16711671
polymorfa campaign create --project-slug launch-project --input campaign.json
1672+
polymorfa campaign update 0198a2cb-0000-7000-8000-0000000000e3 \
1673+
--project-slug launch-project --input campaign-patch.json
16721674
polymorfa campaign view campaign_123 --project-slug launch-project
16731675
polymorfa campaign analytics campaign_123 --project-slug launch-project
16741676

@@ -1703,6 +1705,15 @@ Launch times accept epoch milliseconds or timezone-qualified ISO 8601
17031705
instants. List returns one complete newest-first array with no cursor. Requeue
17041706
is synchronous and reports only the number moved.
17051707

1708+
Update reads one JSON object with at least one of `name`, `recipientListId`,
1709+
`senderConfig`, or `scheduledAt`. Send only fields to change. Set
1710+
`recipientListId` or `scheduledAt` to `null` to detach the audience or start at
1711+
launch. A non-null `scheduledAt` must be integer Unix milliseconds within the
1712+
JavaScript Date range. The API refuses audience and start-time changes after launch. A sender
1713+
change after launch requires the sending numbers to pass live eligibility
1714+
checks. If a PATCH result is lost, view the campaign before deciding whether
1715+
to send another update.
1716+
17061717
Create, launch, pause, resume and stop accept `--idempotency-key` to deduplicate
17071718
the same request for 24 hours. A successful request repeated with the same key
17081719
returns `409 idempotency_completed`, not the original response. Changed input
@@ -1793,7 +1804,7 @@ recorded`; changed details are refused. `--amount-minor` is in the currency's
17931804
minor unit and needs `--currency`. Human output never prints the event ID.
17941805
The report lists reported value per currency and never converts between them.
17951806

1796-
The source exposes no campaign update, delete, validate, archive, duplicate,
1807+
The source exposes no campaign delete, validate, archive, duplicate,
17971808
event, history, or replay route on the Messaging surface. The CLI does not
17981809
substitute similarly named Platform campaign methods for these gaps. To follow
17991810
or stop a campaign's operation, use the `operation` commands in

‎src/commands/campaign/update.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {Args, Flags} from '@oclif/core'
2+
3+
import {readJsonInput} from '../../api/request.js'
4+
import {BaseCommand} from '../../base-command.js'
5+
import {renderCampaign, updateCampaign} from '../../services/campaigns.js'
6+
7+
export default class CampaignUpdate extends BaseCommand {
8+
static override args = {
9+
campaign: Args.string({description: 'Campaign UUID.', required: true}),
10+
}
11+
12+
static override description =
13+
'Update campaign fields. A launched campaign cannot change its audience or start time.'
14+
15+
static override flags = {
16+
input: Flags.string({
17+
description:
18+
'Read the campaign patch JSON object from a file or stdin (-).',
19+
required: true,
20+
}),
21+
'project-slug': Flags.string({
22+
description: 'Project slug in the Messaging API path.',
23+
required: true,
24+
}),
25+
}
26+
27+
async run(): Promise<void> {
28+
const {args, flags} = await this.parse(CampaignUpdate)
29+
const result = await updateCampaign(
30+
await this.messagingClient(flags, {serverApiKeyOnly: 'Campaign'}),
31+
flags['project-slug'],
32+
args.campaign,
33+
await readJsonInput(flags.input),
34+
)
35+
this.writeValue(result, renderCampaign(result.data), flags)
36+
}
37+
}

‎src/services/campaigns.ts‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
PolymorfaValidationError,
1717
type RequestOptions,
1818
type ResponseMetadata,
19+
type UpdateCampaignRequest,
1920
} from '@polymorfa/sdk'
2021

2122
export interface CampaignResult<T> {
@@ -59,6 +60,20 @@ export async function viewCampaign(
5960
return result(response.data.data, response.metadata)
6061
}
6162

63+
export async function updateCampaign(
64+
client: MessagingClient,
65+
projectSlug: string,
66+
campaignId: string,
67+
input: unknown,
68+
): Promise<CampaignResult<Campaign>> {
69+
const response = await client.campaigns.update(
70+
projectSlug,
71+
campaignId,
72+
campaignUpdateInput(input),
73+
)
74+
return result(response.data.data, response.metadata)
75+
}
76+
6277
export async function viewCampaignAnalytics(
6378
client: MessagingClient,
6479
projectSlug: string,
@@ -367,6 +382,31 @@ function campaignInput(input: unknown): CreateCampaignRequest {
367382
return input as unknown as CreateCampaignRequest
368383
}
369384

385+
function campaignUpdateInput(input: unknown): UpdateCampaignRequest {
386+
if (!isRecord(input)) {
387+
throw new PolymorfaValidationError(
388+
'Campaign update input must contain one JSON object.',
389+
{code: 'invalid_campaign_update_input'},
390+
)
391+
}
392+
393+
const allowed = new Set([
394+
'name',
395+
'recipientListId',
396+
'senderConfig',
397+
'scheduledAt',
398+
])
399+
const names = Object.keys(input)
400+
if (names.length === 0 || names.some((name) => !allowed.has(name))) {
401+
throw new PolymorfaValidationError(
402+
'Campaign update input must contain only name, recipientListId, senderConfig, or scheduledAt.',
403+
{code: 'invalid_campaign_update_fields'},
404+
)
405+
}
406+
407+
return input
408+
}
409+
370410
function result<T>(data: T, metadata: ResponseMetadata): CampaignResult<T> {
371411
return {
372412
data,

‎test/commands/campaigns.test.ts‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,82 @@ describe('Messaging campaign commands', () => {
8282
},
8383
)
8484

85+
test('updates a campaign with a partial JSON body through Messaging PATCH', async () => {
86+
const input = await jsonInput('campaign-update.json', {
87+
name: 'Autumn launch',
88+
recipientListId: null,
89+
scheduledAt: null,
90+
})
91+
server = await startHttpServer({
92+
body: {data: {...campaign(), name: 'Autumn launch'}, success: true},
93+
headers: {'request-id': 'req_campaign_update'},
94+
})
95+
96+
const result = await apiKeyEnvironment(() =>
97+
runCommand(
98+
`campaign update 0198a2cb-0000-7000-8000-0000000000e3 --project-slug project/main --input ${input} --json`,
99+
),
100+
)
101+
102+
expect(result.error).toBeUndefined()
103+
expect(JSON.parse(result.stdout)).toMatchObject({
104+
data: {name: 'Autumn launch'},
105+
metadata: {attempts: 1, requestId: 'req_campaign_update', status: 200},
106+
})
107+
expect(server.requests[0]).toMatchObject({
108+
method: 'PATCH',
109+
url: '/messaging/projects/project%2Fmain/campaigns/0198a2cb-0000-7000-8000-0000000000e3',
110+
body: JSON.stringify({
111+
name: 'Autumn launch',
112+
recipientListId: null,
113+
scheduledAt: null,
114+
}),
115+
})
116+
})
117+
118+
test.each([{}, {status: 'sent'}, []])(
119+
'rejects unsupported or empty campaign patches before transport',
120+
async (value) => {
121+
const input = await jsonInput('campaign-update-invalid.json', value)
122+
server = await startHttpServer({body: {data: campaign(), success: true}})
123+
const result = await apiKeyEnvironment(() =>
124+
runCommand(
125+
`campaign update 0198a2cb-0000-7000-8000-0000000000e3 --project-slug project_1 --input ${input}`,
126+
),
127+
)
128+
expect(exitCode(result.error)).toBe(2)
129+
expect(server.requests).toHaveLength(0)
130+
},
131+
)
132+
133+
test('reports a launched-audience conflict without replaying the PATCH', async () => {
134+
const input = await jsonInput('campaign-update-conflict.json', {
135+
recipientListId: null,
136+
})
137+
server = await startHttpServer({
138+
body: {
139+
error: {
140+
code: 'state_conflict',
141+
message: 'The audience is fixed after launch.',
142+
},
143+
success: false,
144+
},
145+
status: 409,
146+
})
147+
148+
const result = await apiKeyEnvironment(() =>
149+
runCommand(
150+
`campaign update 0198a2cb-0000-7000-8000-0000000000e3 --project-slug project_1 --input ${input} --json`,
151+
),
152+
)
153+
154+
expect(exitCode(result.error)).toBe(4)
155+
expect(JSON.parse(result.stderr)).toMatchObject({
156+
error: {category: 'conflict', code: 'state_conflict'},
157+
})
158+
expect(server.requests).toHaveLength(1)
159+
})
160+
85161
test('views one campaign and its exact live analytics counters', async () => {
86162
server = await startHttpSequenceServer([
87163
{body: {data: campaign(), success: true}},

0 commit comments

Comments
 (0)