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
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2655,3 +2655,28 @@ an API deployment exposing Analytics are separate requirements.
## Package release workflow

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).

### Campaign contact frequency caps (beta)

```sh
polymorfa campaign settings frequency-cap get
polymorfa campaign settings frequency-cap set --max-per-24-hours 2 --max-per-7-days 5 --revision 0
polymorfa campaign settings frequency-cap remove --revision 1
polymorfa campaign create --project-slug launch --input campaign.json --frequency-cap-24-hours 1
polymorfa campaign update CAMPAIGN_ID --project-slug launch --frequency-cap-24-hours 1
polymorfa campaign update CAMPAIGN_ID --project-slug launch --remove-frequency-cap
polymorfa campaign recipients list CAMPAIGN_ID --project-slug launch --status skipped --reason frequency_cap
```

Read the revision before setting or removing team limits. A stale write returns
409; read again and reconcile before retrying. Set replaces both windows; an
omitted window means no limit. Team settings require a team API key with
`campaigns:read` or `campaigns:manage`. New limits require beta access and can
return `403 feature_unavailable`; removal does not require beta access.
Campaign flags replace the campaign cap and keep the team cap in force.
Campaign analytics shows `Skipped by frequency cap`; recipient output includes
`frequency_cap`. Changing limits never retries settled skips.

This implementation keeps the published SDK pin unchanged. Release waits for a
matching SDK prerelease and accepted API contract; source support does not imply
package publication or beta availability.
18 changes: 10 additions & 8 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"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"
},
"dependencies": {
"@modelcontextprotocol/server": "2.0.0",
"@modelcontextprotocol/server": "2.3.1",
"@napi-rs/keyring": "1.3.0",
"@oclif/core": "^4.13.5",
"@polymorfa/sdk": "0.1.0-dev.20261004024706",
Expand Down
7 changes: 6 additions & 1 deletion src/commands/campaign/create.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
frequencyCapFlags,
frequencyCapInput,
} from '../../services/campaign-frequency-cap.js'
import {Flags} from '@oclif/core'

import {readJsonInput} from '../../api/request.js'
Expand All @@ -10,6 +14,7 @@ export default class CampaignCreate extends BaseCommand {
'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.'

static override flags = {
...frequencyCapFlags,
'idempotency-key': Flags.string({
description:
'Idempotency-Key header. Generated per invocation when omitted; reuse an explicit key across process retries (24-hour retention).',
Expand All @@ -29,7 +34,7 @@ export default class CampaignCreate extends BaseCommand {
const result = await createCampaign(
await this.messagingClient(flags, {serverApiKeyOnly: 'Campaign'}),
flags['project-slug'],
await readJsonInput(flags.input),
frequencyCapInput(await readJsonInput(flags.input), flags),
{idempotencyKey: idempotencyKey(flags['idempotency-key'])},
)
this.writeValue(result, renderCampaign(result.data), flags)
Expand Down
5 changes: 5 additions & 0 deletions src/commands/campaign/recipients/list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ export default class CampaignRecipientsList extends BaseCommand {
]

static override flags = {
reason: Flags.string({
description:
'Filter by recipient failure reason, including frequency_cap.',
}),
cursor: Flags.string({description: 'Continue from this opaque cursor.'}),
limit: Flags.integer({
description: 'Request this many recipients. The API defaults to 25.',
Expand All @@ -45,6 +49,7 @@ export default class CampaignRecipientsList extends BaseCommand {
flags['project-slug'],
args.campaign,
{
...(flags.reason === undefined ? {} : {reason: flags.reason}),
...(flags.cursor === undefined ? {} : {cursor: flags.cursor}),
...(flags.limit === undefined ? {} : {limit: flags.limit}),
...(flags.status === undefined
Expand Down
16 changes: 16 additions & 0 deletions src/commands/campaign/settings/frequency-cap/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import {BaseCommand} from '../../../../base-command.js'
import {
getFrequencyCap,
renderFrequencyCap,
} from '../../../../services/campaign-frequency-cap.js'
export default class FrequencyCapGet extends BaseCommand {
static override description =
'Read team contact frequency caps and their revision.'

async run(): Promise<void> {
const {flags} = await this.parse(FrequencyCapGet)

const result = await getFrequencyCap(await this.platformClient(flags))
this.writeValue(result, renderFrequencyCap(result.data), flags)
}
}
29 changes: 29 additions & 0 deletions src/commands/campaign/settings/frequency-cap/remove.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {Flags} from '@oclif/core'
import {BaseCommand} from '../../../../base-command.js'
import {
updateFrequencyCap,
renderFrequencyCap,
} from '../../../../services/campaign-frequency-cap.js'
export default class FrequencyCapRemove extends BaseCommand {
static override description =
'Remove both team limits, including without beta access. Requires the last read revision.'
static override flags = {
revision: Flags.integer({
required: true,
min: 0,
max: 2147483646,
description:
'Revision from get. On 409, read again and reconcile before retrying.',
}),
}
async run(): Promise<void> {
const {flags} = await this.parse(FrequencyCapRemove)

const result = await updateFrequencyCap(await this.platformClient(flags), {
revision: flags.revision,
maxPer24Hours: null,
maxPer7Days: null,
})
this.writeValue(result, renderFrequencyCap(result.data), flags)
}
}
56 changes: 56 additions & 0 deletions src/commands/campaign/settings/frequency-cap/set.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import {Flags} from '@oclif/core'
import {PolymorfaValidationError} from '@polymorfa/sdk'
import {BaseCommand} from '../../../../base-command.js'
import {
updateFrequencyCap,
renderFrequencyCap,
} from '../../../../services/campaign-frequency-cap.js'
export default class FrequencyCapSet extends BaseCommand {
static override description =
'Replace team limits (beta). Omitted windows become unlimited. Stale revisions return 409; no automatic retry.'
static override flags = {
revision: Flags.integer({
required: true,
min: 0,
max: 2147483646,
description:
'Revision from get. On 409, read again and reconcile before retrying.',
}),
'max-per-24-hours': Flags.integer({
min: 1,
max: 50,
description: 'Rolling 24-hour limit; omitted means no limit.',
}),
'max-per-7-days': Flags.integer({
min: 1,
max: 200,
description: 'Rolling 7-day limit; omitted means no limit.',
}),
}
async run(): Promise<void> {
const {flags} = await this.parse(FrequencyCapSet)
if (
flags['max-per-24-hours'] === undefined &&
flags['max-per-7-days'] === undefined
)
throw new PolymorfaValidationError(
'Set at least one window, or use remove.',
{code: 'invalid_frequency_cap'},
)
if (
flags['max-per-24-hours'] !== undefined &&
flags['max-per-7-days'] !== undefined &&
flags['max-per-24-hours'] > flags['max-per-7-days']
)
throw new PolymorfaValidationError(
'The 24-hour limit cannot exceed the 7-day limit.',
{code: 'invalid_frequency_cap'},
)
const result = await updateFrequencyCap(await this.platformClient(flags), {
revision: flags.revision,
maxPer24Hours: flags['max-per-24-hours'] ?? null,
maxPer7Days: flags['max-per-7-days'] ?? null,
})
this.writeValue(result, renderFrequencyCap(result.data), flags)
}
}
42 changes: 42 additions & 0 deletions src/commands/campaign/update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import {Args, Flags} from '@oclif/core'
import {BaseCommand} from '../../base-command.js'
import {readJsonInput} from '../../api/request.js'
import {
frequencyCapFlags,
frequencyCapInput,
updateCampaign,
} from '../../services/campaign-frequency-cap.js'
import {renderCampaign} from '../../services/campaigns.js'
export default class CampaignUpdate extends BaseCommand {
static override description =
'Update a draft or paused campaign. Cap flags replace the campaign cap; omitted windows have no campaign limit.'
static override args = {
campaign: Args.string({required: true, description: 'Campaign ID.'}),
}
static override flags = {
...frequencyCapFlags,
input: Flags.string({
description: 'Campaign changes as JSON from file or stdin (-).',
}),
'project-slug': Flags.string({
required: true,
description: 'Project slug in the Messaging API path.',
}),
}
async run(): Promise<void> {
const {args, flags} = await this.parse(CampaignUpdate)
const body = frequencyCapInput(
flags.input === undefined ? {} : await readJsonInput(flags.input),
flags,
)
if (Object.keys(body).length === 0)
this.error('Pass --input or a frequency cap flag.')
const result = await updateCampaign(
await this.messagingClient(flags, {serverApiKeyOnly: 'Campaign'}),
flags['project-slug'],
args.campaign,
body,
)
this.writeValue(result, renderCampaign(result.data), flags)
}
}
Loading
Loading