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
60 changes: 60 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2656,6 +2656,66 @@ an API deployment exposing Analytics are separate requirements.

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

### Audience segments and drip sequences (publication pending)

These commands are prepared in source and await the matching SDK prerelease.
The existing SDK dependency is unchanged. Server beta access and scopes still
apply; source CI does not establish package or hosted availability.

```sh
polymorfa campaign segments list --limit 25
polymorfa campaign segments get <segmentId>
polymorfa campaign segments create --input segment.json
polymorfa campaign segments update <segmentId> --input segment-update.json
polymorfa campaign segments delete <segmentId> --revision <observedRevision>
polymorfa campaign segments count <segmentId>
polymorfa campaign segments count --input rules.json
polymorfa campaign create --segment <segmentId> --project-id <projectId> --input campaign.json --drip --drip-enrollment continuous
polymorfa campaign update <campaignId> --project-slug <slug> --drip-enrollment audience
polymorfa campaign enroll <campaignId> --project-slug <slug> --phone +5511999990000
polymorfa campaign analytics <campaignId> --project-slug <slug>
polymorfa campaign view <campaignId> --project-slug <slug>
```

Segment creation input is `{ "name": "VIP", "rules": { "match": "all",
"groups": [{ "match": "all", "conditions": [{ "field": "tag",
"operator": "contains", "value": "vip" }] }] } }`. Update includes the observed
`expectedRevision` and the name, rules or both. Count accepts a saved segment ID
or a file containing only the rules object. Saved count reports the evaluated
revision; launch evaluates again and freezes the snapshot. Counts distinguish
matched, opted out, withheld consent and eligible profiles.

Segments require a team API key. Reads and saved count require `campaigns:read`;
mutations and unsaved count require `campaigns:manage`. Both audience betas are
required for create/update/count. Read/delete remain available after withdrawal.
A stale revision, a segment in use by a draft, a deleted-segment relaunch or a
bounded evaluation timeout returns 409. Re-read and reconcile stale revisions;
simplify rules before retrying a timeout. Permission/beta errors stay 403.

Use `--drip-exit-on-reply` / `--no-drip-exit-on-reply` and
`--drip-exit-on-link-click` / `--no-drip-exit-on-link-click` to set explicit
flags. Omitted PATCH fields preserve stored settings. Close enrollment with
only `--drip-enrollment audience`; use `--remove-drip` only on an unlaunched
draft. Steps belong in the JSON input's `messagesArray`: delays up to 30 days,
whole multiples of `delayUnit`, and later-step `sendWindow` with one to seven
weekdays and one to four hour ranges. Link GETs from scanners/previews also
trigger enabled exits; HEAD does not. Beta, linked-device/test sender, consent,
opt-out and funding checks remain authoritative.

Enrollment is idempotent per phone, including completed/exited contacts. An
existing contact displays "Already enrolled"; an opted-out contact stays
skipped. Add `--variables variables.json` for per-contact variables. View shows
configured enrollment open/closed, current pause reason when present and live
per-step stats. Analytics shows entered/waiting/sent/delivered/read/replied/
exited/failed per step. `--json` retains the response data and metadata.

Composed drips use `campaign create --project-id <projectId> --drip --input
campaign.json` even without a segment. Platform accepts `messagesArray`; the
Messaging create contract does not. Use `campaign update <campaignId>
--project-id <projectId> --input changes.json` to replace composed steps. Use
exactly one project selector. Platform create has no idempotency-key contract;
`--idempotency-key` is supported only with Messaging create (`--project-slug`).

### Campaign contact frequency caps (beta)

```sh
Expand Down
67 changes: 65 additions & 2 deletions src/commands/campaign/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ import {
frequencyCapFlags,
frequencyCapInput,
} from '../../services/campaign-frequency-cap.js'
import {
campaignRequest,
dripFlags,
dripInput,
} from '../../services/campaign-segments-drip.js'
import type {Campaign} from '@polymorfa/sdk'
import {Flags} from '@oclif/core'

import {readJsonInput} from '../../api/request.js'
Expand All @@ -14,7 +20,16 @@ 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 = {
...dripFlags,
Comment thread
purpshell marked this conversation as resolved.
...frequencyCapFlags,
segment: Flags.string({
description:
'Saved audience segment ID (team key; requires --project-id).',
}),
'project-id': Flags.string({
description:
'Owning project ID for Platform campaign content, segments and drip steps.',
}),
'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 @@ -25,16 +40,64 @@ export default class CampaignCreate extends BaseCommand {
}),
'project-slug': Flags.string({
description: 'Project slug in the Messaging API path.',
required: true,
}),
}

async run(): Promise<void> {
const {flags} = await this.parse(CampaignCreate)
if (flags['remove-drip'])
this.error(
'--remove-drip only applies to an existing unlaunched draft. Use campaign update.',
)
const body = frequencyCapInput(
dripInput(await readJsonInput(flags.input), flags),
flags,
)
if (
(flags.segment !== undefined ||
body.segmentId != null ||
body.drip != null ||
body.messagesArray !== undefined) &&
flags['project-id'] === undefined
)
this.error(
'Segments and composed drip steps require --project-id (Platform API).',
)
if (flags['project-id'] !== undefined) {
if (flags['idempotency-key'] !== undefined)
this.error(
'--idempotency-key is only supported for Messaging campaign creation.',
)
if (flags['project-slug'] !== undefined)
this.error('Use either --project-id or --project-slug.')
if (
(flags.segment !== undefined || body.segmentId != null) &&
(body.recipientListId != null ||
body.recipients !== undefined ||
body.audienceRef != null)
)
this.error('--segment cannot be combined with another audience.')
const result = await campaignRequest<Campaign>(
await this.platformClient(flags),
{
method: 'POST',
path: '/platform/campaigns',
body: {
...body,
projectId: flags['project-id'],
...(flags.segment === undefined ? {} : {segmentId: flags.segment}),
},
},
)
this.writeValue(result, renderCampaign(result.data), flags)
return
}
if (flags['project-slug'] === undefined)
this.error('Pass --project-slug or --project-id.')
const result = await createCampaign(
await this.messagingClient(flags, {serverApiKeyOnly: 'Campaign'}),
flags['project-slug'],
frequencyCapInput(await readJsonInput(flags.input), flags),
body,
{idempotencyKey: idempotencyKey(flags['idempotency-key'])},
)
this.writeValue(result, renderCampaign(result.data), flags)
Expand Down
49 changes: 49 additions & 0 deletions src/commands/campaign/enroll.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import {Args, Flags} from '@oclif/core'
import {BaseCommand} from '../../base-command.js'
import {readJsonInput} from '../../api/request.js'
import {
campaignRequest,
objectInput,
type Enrollment,
} from '../../services/campaign-segments-drip.js'
export default class CampaignEnroll extends BaseCommand {
static override description =
'Enroll one phone in a running or paused continuous drip (beta). Existing enrollments never restart.'
static override args = {
campaign: Args.string({required: true, description: 'Campaign ID.'}),
}
static override flags = {
phone: Flags.string({
required: true,
description: 'International phone number.',
}),
variables: Flags.string({description: 'Variables JSON file or stdin (-).'}),
'project-slug': Flags.string({
required: true,
description: 'Owning project slug.',
}),
}
async run(): Promise<void> {
const {args, flags} = await this.parse(CampaignEnroll)
const variables =
flags.variables === undefined
? undefined
: objectInput(await readJsonInput(flags.variables))
const result = await campaignRequest<Enrollment>(
await this.messagingClient(flags, {serverApiKeyOnly: 'Campaign'}),
{
method: 'POST',
path: `/messaging/projects/${encodeURIComponent(flags['project-slug'])}/campaigns/${encodeURIComponent(args.campaign)}/enrollments`,
body: {
phone: flags.phone,
...(variables === undefined ? {} : {variables}),
},
},
)
this.writeValue(
result,
`${result.data.created ? 'Enrolled' : 'Already enrolled'}\t${result.data.recipientId}\nStatus\t${result.data.status}`,
flags,
)
}
}
46 changes: 46 additions & 0 deletions src/commands/campaign/segments/count.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import {campaignRequest} from '../../../services/campaign-segments-drip.js'
import {Flags} from '@oclif/core'
import {Args} from '@oclif/core'
import {readJsonInput} from '../../../api/request.js'
import {
objectInput,
segmentPath,
} from '../../../services/campaign-segments-drip.js'
import type {AudienceSegmentPreview} from '../../../services/campaign-segment-types.js'
import {BaseCommand} from '../../../base-command.js'
export default class SegmentCount extends BaseCommand {
static override description =
'Count audience segments (team key; beta gates and observed revisions apply).'
static override args = {
segment: Args.string({required: false, description: 'Saved segment ID.'}),
}
static override flags = {
input: Flags.string({
description:
'Unsaved rules object from JSON file or stdin (-). Use instead of a segment ID.',
}),
}
async run(): Promise<void> {
const {args, flags} = await this.parse(SegmentCount)
const client = await this.platformClient(flags)
if ((args.segment === undefined) === (flags.input === undefined))
this.error('Pass a segment ID or --input, exclusively.')
const result = await campaignRequest<
AudienceSegmentPreview & {revision?: number}
>(
client,
flags.input === undefined
? {method: 'GET', path: segmentPath(args.segment) + '/preview'}
: {
method: 'POST',
path: segmentPath() + '/preview',
body: {rules: objectInput(await readJsonInput(flags.input))},
},
)
this.writeValue(
result,
`Matched\t${result.data.matchedCount}\nEligible\t${result.data.eligibleCount}\nOpted out\t${result.data.optedOutCount}\nConsent withheld\t${result.data.consentWithheldCount}\nRevision\t${result.data.revision ?? 'unsaved'}`,
flags,
)
}
}
32 changes: 32 additions & 0 deletions src/commands/campaign/segments/create.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {campaignRequest} from '../../../services/campaign-segments-drip.js'
import {Flags} from '@oclif/core'
import {readJsonInput} from '../../../api/request.js'
import {
objectInput,
segmentPath,
} from '../../../services/campaign-segments-drip.js'
import type {AudienceSegment} from '../../../services/campaign-segment-types.js'
import {BaseCommand} from '../../../base-command.js'
export default class SegmentCreate extends BaseCommand {
static override description =
'Create audience segments (team key; beta gates and observed revisions apply).'
static override args = {}
static override flags = {
input: Flags.string({
required: true,
description:
'JSON object from file or stdin (-). Update includes expectedRevision.',
}),
}
async run(): Promise<void> {
const {flags} = await this.parse(SegmentCreate)
const client = await this.platformClient(flags)
const body = objectInput(await readJsonInput(flags.input))
const result = await campaignRequest<AudienceSegment>(client, {
method: 'POST',
path: segmentPath(),
body,
})
this.writeValue(result, JSON.stringify(result.data, null, 2), flags)
}
}
36 changes: 36 additions & 0 deletions src/commands/campaign/segments/delete.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import {campaignRequest} from '../../../services/campaign-segments-drip.js'
import {Flags} from '@oclif/core'
import {Args} from '@oclif/core'
import {
revision,
segmentPath,
} from '../../../services/campaign-segments-drip.js'
import {terminalText} from '../../../developer/render.js'
import {BaseCommand} from '../../../base-command.js'
export default class SegmentDelete extends BaseCommand {
static override description =
'Delete audience segments (team key; beta gates and observed revisions apply).'
static override args = {
segment: Args.string({required: true, description: 'Saved segment ID.'}),
}
static override flags = {
revision: Flags.integer({
required: true,
description: 'Observed segment revision.',
}),
}
async run(): Promise<void> {
const {args, flags} = await this.parse(SegmentDelete)
const client = await this.platformClient(flags)
const result = await campaignRequest<{deleted: true; id: string}>(client, {
method: 'DELETE',
path: segmentPath(args.segment),
query: {expectedRevision: revision(flags.revision)},
})
this.writeValue(
result,
`Deleted segment ${terminalText(result.data.id)}`,
flags,
)
}
}
22 changes: 22 additions & 0 deletions src/commands/campaign/segments/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {campaignRequest} from '../../../services/campaign-segments-drip.js'
import {Args} from '@oclif/core'
import {segmentPath} from '../../../services/campaign-segments-drip.js'
import type {AudienceSegment} from '../../../services/campaign-segment-types.js'
import {BaseCommand} from '../../../base-command.js'
export default class SegmentGet extends BaseCommand {
static override description =
'Get audience segments (team key; beta gates and observed revisions apply).'
static override args = {
segment: Args.string({required: true, description: 'Saved segment ID.'}),
}
static override flags = {}
async run(): Promise<void> {
const {args, flags} = await this.parse(SegmentGet)
const client = await this.platformClient(flags)
const result = await campaignRequest<AudienceSegment>(client, {
method: 'GET',
path: segmentPath(args.segment),
})
this.writeValue(result, JSON.stringify(result.data, null, 2), flags)
}
}
40 changes: 40 additions & 0 deletions src/commands/campaign/segments/list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {Flags} from '@oclif/core'
import {segmentPath} from '../../../services/campaign-segments-drip.js'
import {terminalText} from '../../../developer/render.js'
import type {AudienceSegmentsEnvelope} from '../../../services/campaign-segment-types.js'
import {BaseCommand} from '../../../base-command.js'
export default class SegmentList extends BaseCommand {
static override description =
'List audience segments (team key; beta gates and observed revisions apply).'
static override args = {}
static override flags = {
cursor: Flags.string({description: 'Next page cursor.'}),
limit: Flags.integer({min: 1, max: 100, description: 'Page size (1-100).'}),
}
async run(): Promise<void> {
const {flags} = await this.parse(SegmentList)
const client = await this.platformClient(flags)
const response = await client.raw.request<AudienceSegmentsEnvelope>({
method: 'GET',
path: segmentPath(),
query: {
...(flags.cursor === undefined ? {} : {cursor: flags.cursor}),
...(flags.limit === undefined ? {} : {limit: flags.limit}),
},
})
const result = {data: response.data, metadata: response.metadata}
this.writeValue(
result,
result.data.data
.map(
(s) =>
`${terminalText(s.id)}\t${s.revision}\t${terminalText(s.name)}`,
)
.join('\n') +
(result.data.page.hasMore
? `\nNext cursor\t${terminalText(result.data.page.nextCursor ?? '')}`
: ''),
flags,
)
}
}
Loading
Loading