Skip to content

Commit f1ecd8d

Browse files
authored
Merge pull request #80 from polymorfa/feat/campaign-frequency-cap
Add campaign frequency cap commands (awaits SDK prerelease)
2 parents 301bd6a + eb88aa7 commit f1ecd8d

13 files changed

Lines changed: 556 additions & 17 deletions

File tree

‎README.md‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2655,3 +2655,28 @@ an API deployment exposing Analytics are separate requirements.
26552655
## Package release workflow
26562656

26572657
CLI development and release PRs target `main`. Nightly packages use the `dev` npm tag after matching staging API acceptance; stable packages follow production acceptance. Each package pins an exact accepted SDK version. See [the release guide](RELEASING.md).
2658+
2659+
### Campaign contact frequency caps (beta)
2660+
2661+
```sh
2662+
polymorfa campaign settings frequency-cap get
2663+
polymorfa campaign settings frequency-cap set --max-per-24-hours 2 --max-per-7-days 5 --revision 0
2664+
polymorfa campaign settings frequency-cap remove --revision 1
2665+
polymorfa campaign create --project-slug launch --input campaign.json --frequency-cap-24-hours 1
2666+
polymorfa campaign update CAMPAIGN_ID --project-slug launch --frequency-cap-24-hours 1
2667+
polymorfa campaign update CAMPAIGN_ID --project-slug launch --remove-frequency-cap
2668+
polymorfa campaign recipients list CAMPAIGN_ID --project-slug launch --status skipped --reason frequency_cap
2669+
```
2670+
2671+
Read the revision before setting or removing team limits. A stale write returns
2672+
409; read again and reconcile before retrying. Set replaces both windows; an
2673+
omitted window means no limit. Team settings require a team API key with
2674+
`campaigns:read` or `campaigns:manage`. New limits require beta access and can
2675+
return `403 feature_unavailable`; removal does not require beta access.
2676+
Campaign flags replace the campaign cap and keep the team cap in force.
2677+
Campaign analytics shows `Skipped by frequency cap`; recipient output includes
2678+
`frequency_cap`. Changing limits never retries settled skips.
2679+
2680+
This implementation keeps the published SDK pin unchanged. Release waits for a
2681+
matching SDK prerelease and accepted API contract; source support does not imply
2682+
package publication or beta availability.

‎package-lock.json‎

Lines changed: 10 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
"verify": "npm run format:check && npm run lint && npm run typecheck && npm test && npm run build && npm pack --dry-run --ignore-scripts && npm run audit:prod"
3131
},
3232
"dependencies": {
33-
"@modelcontextprotocol/server": "2.0.0",
33+
"@modelcontextprotocol/server": "2.3.1",
3434
"@napi-rs/keyring": "1.3.0",
3535
"@oclif/core": "^4.13.5",
3636
"@polymorfa/sdk": "0.1.0-dev.20261004024706",

‎src/commands/campaign/create.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
import {
2+
frequencyCapFlags,
3+
frequencyCapInput,
4+
} from '../../services/campaign-frequency-cap.js'
15
import {Flags} from '@oclif/core'
26

37
import {readJsonInput} from '../../api/request.js'
@@ -10,6 +14,7 @@ export default class CampaignCreate extends BaseCommand {
1014
'Create a campaign from one JSON object. Optional beta fields include followUp: one receipt-timed follow-up ({delaySeconds, expirySeconds, message: {type: "text", text} or {type: "template", templateId}}); teams that are not enrolled receive 403 feature_unavailable.'
1115

1216
static override flags = {
17+
...frequencyCapFlags,
1318
'idempotency-key': Flags.string({
1419
description:
1520
'Idempotency-Key header. Generated per invocation when omitted; reuse an explicit key across process retries (24-hour retention).',
@@ -29,7 +34,7 @@ export default class CampaignCreate extends BaseCommand {
2934
const result = await createCampaign(
3035
await this.messagingClient(flags, {serverApiKeyOnly: 'Campaign'}),
3136
flags['project-slug'],
32-
await readJsonInput(flags.input),
37+
frequencyCapInput(await readJsonInput(flags.input), flags),
3338
{idempotencyKey: idempotencyKey(flags['idempotency-key'])},
3439
)
3540
this.writeValue(result, renderCampaign(result.data), flags)

‎src/commands/campaign/recipients/list.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ export default class CampaignRecipientsList extends BaseCommand {
2121
]
2222

2323
static override flags = {
24+
reason: Flags.string({
25+
description:
26+
'Filter by recipient failure reason, including frequency_cap.',
27+
}),
2428
cursor: Flags.string({description: 'Continue from this opaque cursor.'}),
2529
limit: Flags.integer({
2630
description: 'Request this many recipients. The API defaults to 25.',
@@ -45,6 +49,7 @@ export default class CampaignRecipientsList extends BaseCommand {
4549
flags['project-slug'],
4650
args.campaign,
4751
{
52+
...(flags.reason === undefined ? {} : {reason: flags.reason}),
4853
...(flags.cursor === undefined ? {} : {cursor: flags.cursor}),
4954
...(flags.limit === undefined ? {} : {limit: flags.limit}),
5055
...(flags.status === undefined
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import {BaseCommand} from '../../../../base-command.js'
2+
import {
3+
getFrequencyCap,
4+
renderFrequencyCap,
5+
} from '../../../../services/campaign-frequency-cap.js'
6+
export default class FrequencyCapGet extends BaseCommand {
7+
static override description =
8+
'Read team contact frequency caps and their revision.'
9+
10+
async run(): Promise<void> {
11+
const {flags} = await this.parse(FrequencyCapGet)
12+
13+
const result = await getFrequencyCap(await this.platformClient(flags))
14+
this.writeValue(result, renderFrequencyCap(result.data), flags)
15+
}
16+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import {Flags} from '@oclif/core'
2+
import {BaseCommand} from '../../../../base-command.js'
3+
import {
4+
updateFrequencyCap,
5+
renderFrequencyCap,
6+
} from '../../../../services/campaign-frequency-cap.js'
7+
export default class FrequencyCapRemove extends BaseCommand {
8+
static override description =
9+
'Remove both team limits, including without beta access. Requires the last read revision.'
10+
static override flags = {
11+
revision: Flags.integer({
12+
required: true,
13+
min: 0,
14+
max: 2147483646,
15+
description:
16+
'Revision from get. On 409, read again and reconcile before retrying.',
17+
}),
18+
}
19+
async run(): Promise<void> {
20+
const {flags} = await this.parse(FrequencyCapRemove)
21+
22+
const result = await updateFrequencyCap(await this.platformClient(flags), {
23+
revision: flags.revision,
24+
maxPer24Hours: null,
25+
maxPer7Days: null,
26+
})
27+
this.writeValue(result, renderFrequencyCap(result.data), flags)
28+
}
29+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import {Flags} from '@oclif/core'
2+
import {PolymorfaValidationError} from '@polymorfa/sdk'
3+
import {BaseCommand} from '../../../../base-command.js'
4+
import {
5+
updateFrequencyCap,
6+
renderFrequencyCap,
7+
} from '../../../../services/campaign-frequency-cap.js'
8+
export default class FrequencyCapSet extends BaseCommand {
9+
static override description =
10+
'Replace team limits (beta). Omitted windows become unlimited. Stale revisions return 409; no automatic retry.'
11+
static override flags = {
12+
revision: Flags.integer({
13+
required: true,
14+
min: 0,
15+
max: 2147483646,
16+
description:
17+
'Revision from get. On 409, read again and reconcile before retrying.',
18+
}),
19+
'max-per-24-hours': Flags.integer({
20+
min: 1,
21+
max: 50,
22+
description: 'Rolling 24-hour limit; omitted means no limit.',
23+
}),
24+
'max-per-7-days': Flags.integer({
25+
min: 1,
26+
max: 200,
27+
description: 'Rolling 7-day limit; omitted means no limit.',
28+
}),
29+
}
30+
async run(): Promise<void> {
31+
const {flags} = await this.parse(FrequencyCapSet)
32+
if (
33+
flags['max-per-24-hours'] === undefined &&
34+
flags['max-per-7-days'] === undefined
35+
)
36+
throw new PolymorfaValidationError(
37+
'Set at least one window, or use remove.',
38+
{code: 'invalid_frequency_cap'},
39+
)
40+
if (
41+
flags['max-per-24-hours'] !== undefined &&
42+
flags['max-per-7-days'] !== undefined &&
43+
flags['max-per-24-hours'] > flags['max-per-7-days']
44+
)
45+
throw new PolymorfaValidationError(
46+
'The 24-hour limit cannot exceed the 7-day limit.',
47+
{code: 'invalid_frequency_cap'},
48+
)
49+
const result = await updateFrequencyCap(await this.platformClient(flags), {
50+
revision: flags.revision,
51+
maxPer24Hours: flags['max-per-24-hours'] ?? null,
52+
maxPer7Days: flags['max-per-7-days'] ?? null,
53+
})
54+
this.writeValue(result, renderFrequencyCap(result.data), flags)
55+
}
56+
}

‎src/commands/campaign/update.ts‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import {Args, Flags} from '@oclif/core'
2+
import {BaseCommand} from '../../base-command.js'
3+
import {readJsonInput} from '../../api/request.js'
4+
import {
5+
frequencyCapFlags,
6+
frequencyCapInput,
7+
updateCampaign,
8+
} from '../../services/campaign-frequency-cap.js'
9+
import {renderCampaign} from '../../services/campaigns.js'
10+
export default class CampaignUpdate extends BaseCommand {
11+
static override description =
12+
'Update a draft or paused campaign. Cap flags replace the campaign cap; omitted windows have no campaign limit.'
13+
static override args = {
14+
campaign: Args.string({required: true, description: 'Campaign ID.'}),
15+
}
16+
static override flags = {
17+
...frequencyCapFlags,
18+
input: Flags.string({
19+
description: 'Campaign changes as JSON from file or stdin (-).',
20+
}),
21+
'project-slug': Flags.string({
22+
required: true,
23+
description: 'Project slug in the Messaging API path.',
24+
}),
25+
}
26+
async run(): Promise<void> {
27+
const {args, flags} = await this.parse(CampaignUpdate)
28+
const body = frequencyCapInput(
29+
flags.input === undefined ? {} : await readJsonInput(flags.input),
30+
flags,
31+
)
32+
if (Object.keys(body).length === 0)
33+
this.error('Pass --input or a frequency cap flag.')
34+
const result = await updateCampaign(
35+
await this.messagingClient(flags, {serverApiKeyOnly: 'Campaign'}),
36+
flags['project-slug'],
37+
args.campaign,
38+
body,
39+
)
40+
this.writeValue(result, renderCampaign(result.data), flags)
41+
}
42+
}

0 commit comments

Comments
 (0)