From ae1c32ee6ef180de529b2cf076b5e09054d60008 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 17 Oct 2026 12:39:14 +0300 Subject: [PATCH 1/3] Add campaign segments, drip settings, enrollment and step analytics --- README.md | 60 ++++ src/commands/campaign/create.ts | 60 +++- src/commands/campaign/enroll.ts | 49 ++++ src/commands/campaign/segments/count.ts | 46 ++++ src/commands/campaign/segments/create.ts | 32 +++ src/commands/campaign/segments/delete.ts | 36 +++ src/commands/campaign/segments/get.ts | 22 ++ src/commands/campaign/segments/list.ts | 40 +++ src/commands/campaign/segments/update.ts | 37 +++ src/commands/campaign/update.ts | 63 +++++ src/commands/campaign/view.ts | 26 +- src/services/campaign-drip-types.ts | 73 +++++ src/services/campaign-segment-types.ts | 112 ++++++++ src/services/campaign-segments-drip.ts | 169 ++++++++++++ src/services/campaigns.ts | 6 + test/commands/campaign-segments-drip.test.ts | 271 +++++++++++++++++++ 16 files changed, 1098 insertions(+), 4 deletions(-) create mode 100644 src/commands/campaign/enroll.ts create mode 100644 src/commands/campaign/segments/count.ts create mode 100644 src/commands/campaign/segments/create.ts create mode 100644 src/commands/campaign/segments/delete.ts create mode 100644 src/commands/campaign/segments/get.ts create mode 100644 src/commands/campaign/segments/list.ts create mode 100644 src/commands/campaign/segments/update.ts create mode 100644 src/commands/campaign/update.ts create mode 100644 src/services/campaign-drip-types.ts create mode 100644 src/services/campaign-segment-types.ts create mode 100644 src/services/campaign-segments-drip.ts create mode 100644 test/commands/campaign-segments-drip.test.ts diff --git a/README.md b/README.md index efbdc3c2..0cdcab30 100644 --- a/README.md +++ b/README.md @@ -2642,3 +2642,63 @@ 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). + +### 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 +polymorfa campaign segments create --input segment.json +polymorfa campaign segments update --input segment-update.json +polymorfa campaign segments delete --revision +polymorfa campaign segments count +polymorfa campaign segments count --input rules.json +polymorfa campaign create --segment --project-id --input campaign.json --drip --drip-enrollment continuous +polymorfa campaign update --project-slug --drip-enrollment audience +polymorfa campaign enroll --project-slug --phone +5511999990000 +polymorfa campaign analytics --project-slug +polymorfa campaign view --project-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 --drip --input +campaign.json` even without a segment. Platform accepts `messagesArray`; the +Messaging create contract does not. Use `campaign update +--project-id --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`). diff --git a/src/commands/campaign/create.ts b/src/commands/campaign/create.ts index 7b31018e..e2cae586 100644 --- a/src/commands/campaign/create.ts +++ b/src/commands/campaign/create.ts @@ -1,3 +1,9 @@ +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' @@ -10,6 +16,15 @@ 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, + 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).', @@ -20,16 +35,57 @@ export default class CampaignCreate extends BaseCommand { }), 'project-slug': Flags.string({ description: 'Project slug in the Messaging API path.', - required: true, }), } async run(): Promise { const {flags} = await this.parse(CampaignCreate) + const body = dripInput(await readJsonInput(flags.input), 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( + 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'], - await readJsonInput(flags.input), + body, {idempotencyKey: idempotencyKey(flags['idempotency-key'])}, ) this.writeValue(result, renderCampaign(result.data), flags) diff --git a/src/commands/campaign/enroll.ts b/src/commands/campaign/enroll.ts new file mode 100644 index 00000000..7295f863 --- /dev/null +++ b/src/commands/campaign/enroll.ts @@ -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 { + const {args, flags} = await this.parse(CampaignEnroll) + const variables = + flags.variables === undefined + ? undefined + : objectInput(await readJsonInput(flags.variables)) + const result = await campaignRequest( + 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, + ) + } +} diff --git a/src/commands/campaign/segments/count.ts b/src/commands/campaign/segments/count.ts new file mode 100644 index 00000000..7f89680d --- /dev/null +++ b/src/commands/campaign/segments/count.ts @@ -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 { + 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, + ) + } +} diff --git a/src/commands/campaign/segments/create.ts b/src/commands/campaign/segments/create.ts new file mode 100644 index 00000000..aa63d6a3 --- /dev/null +++ b/src/commands/campaign/segments/create.ts @@ -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 { + const {flags} = await this.parse(SegmentCreate) + const client = await this.platformClient(flags) + const body = objectInput(await readJsonInput(flags.input)) + const result = await campaignRequest(client, { + method: 'POST', + path: segmentPath(), + body, + }) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/campaign/segments/delete.ts b/src/commands/campaign/segments/delete.ts new file mode 100644 index 00000000..6d9fd4b8 --- /dev/null +++ b/src/commands/campaign/segments/delete.ts @@ -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 { + 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, + ) + } +} diff --git a/src/commands/campaign/segments/get.ts b/src/commands/campaign/segments/get.ts new file mode 100644 index 00000000..b2f0e11d --- /dev/null +++ b/src/commands/campaign/segments/get.ts @@ -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 { + const {args, flags} = await this.parse(SegmentGet) + const client = await this.platformClient(flags) + const result = await campaignRequest(client, { + method: 'GET', + path: segmentPath(args.segment), + }) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/campaign/segments/list.ts b/src/commands/campaign/segments/list.ts new file mode 100644 index 00000000..4e36352e --- /dev/null +++ b/src/commands/campaign/segments/list.ts @@ -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 { + const {flags} = await this.parse(SegmentList) + const client = await this.platformClient(flags) + const response = await client.raw.request({ + 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, + ) + } +} diff --git a/src/commands/campaign/segments/update.ts b/src/commands/campaign/segments/update.ts new file mode 100644 index 00000000..066ee017 --- /dev/null +++ b/src/commands/campaign/segments/update.ts @@ -0,0 +1,37 @@ +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, + revision, + 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 SegmentUpdate extends BaseCommand { + static override description = + 'Update 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 = { + input: Flags.string({ + required: true, + description: + 'JSON object from file or stdin (-). Update includes expectedRevision.', + }), + } + async run(): Promise { + const {args, flags} = await this.parse(SegmentUpdate) + const client = await this.platformClient(flags) + const body = objectInput(await readJsonInput(flags.input)) + revision(body.expectedRevision as number) + const result = await campaignRequest(client, { + method: 'PATCH', + path: segmentPath(args.segment), + body, + }) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/campaign/update.ts b/src/commands/campaign/update.ts new file mode 100644 index 00000000..bbc51195 --- /dev/null +++ b/src/commands/campaign/update.ts @@ -0,0 +1,63 @@ +import {Args, Flags} from '@oclif/core' +import type {Campaign} from '@polymorfa/sdk' +import {BaseCommand} from '../../base-command.js' +import {readJsonInput} from '../../api/request.js' +import { + campaignRequest, + dripFlags, + dripInput, +} from '../../services/campaign-segments-drip.js' +import {renderCampaign} from '../../services/campaigns.js' +export default class CampaignUpdate extends BaseCommand { + static override description = + 'Update a campaign. Omitted drip fields preserve stored values; enrollment-only audience closes enrollment.' + static override args = { + campaign: Args.string({required: true, description: 'Campaign ID.'}), + } + static override flags = { + ...dripFlags, + 'project-id': Flags.string({ + description: 'Owning project ID (Platform content and settings).', + }), + input: Flags.string({description: 'Changes from JSON file or stdin (-).'}), + 'project-slug': Flags.string({ + description: 'Owning project slug (Messaging settings).', + }), + } + async run(): Promise { + const {args, flags} = await this.parse(CampaignUpdate) + const body = dripInput( + flags.input === undefined ? {} : await readJsonInput(flags.input), + flags, + ) + if (Object.keys(body).length === 0) + this.error('Pass --input or drip flags.') + if ( + (flags['project-id'] === undefined) === + (flags['project-slug'] === undefined) + ) + this.error('Pass exactly one of --project-id or --project-slug.') + if ( + flags['project-id'] === undefined && + (body.messagesArray !== undefined || body.segmentId !== undefined) + ) + this.error('Content and segment changes require --project-id.') + const result = + flags['project-id'] !== undefined + ? await campaignRequest(await this.platformClient(flags), { + method: 'PATCH', + path: `/platform/campaigns/${encodeURIComponent(args.campaign)}`, + query: {projectId: flags['project-id']}, + body, + }) + : await campaignRequest( + await this.messagingClient(flags, {serverApiKeyOnly: 'Campaign'}), + { + method: 'PATCH', + path: `/messaging/projects/${encodeURIComponent(flags['project-slug']!)}/campaigns/${encodeURIComponent(args.campaign)}`, + body, + }, + ) + this.writeValue(result, renderCampaign(result.data), flags) + } +} diff --git a/src/commands/campaign/view.ts b/src/commands/campaign/view.ts index c18bb4a1..1bb11006 100644 --- a/src/commands/campaign/view.ts +++ b/src/commands/campaign/view.ts @@ -1,7 +1,12 @@ +import {renderDripStats} from '../../services/campaign-segments-drip.js' import {Args, Flags} from '@oclif/core' import {BaseCommand} from '../../base-command.js' -import {renderCampaign, viewCampaign} from '../../services/campaigns.js' +import { + renderCampaign, + viewCampaign, + viewCampaignAnalytics, +} from '../../services/campaigns.js' export default class CampaignView extends BaseCommand { static override args = { @@ -24,6 +29,23 @@ export default class CampaignView extends BaseCommand { flags['project-slug'], args.campaign, ) - this.writeValue(result, renderCampaign(result.data), flags) + const stats = + (result.data as {drip?: unknown}).drip == null + ? undefined + : await viewCampaignAnalytics( + await this.messagingClient(flags, {serverApiKeyOnly: 'Campaign'}), + flags['project-slug'], + args.campaign, + ) + this.writeValue( + stats === undefined + ? result + : {...result, data: {...result.data, analytics: stats.data}}, + [ + renderCampaign(result.data), + ...(stats === undefined ? [] : renderDripStats(stats.data)), + ].join('\n'), + flags, + ) } } diff --git a/src/services/campaign-drip-types.ts b/src/services/campaign-drip-types.ts new file mode 100644 index 00000000..3a753abc --- /dev/null +++ b/src/services/campaign-drip-types.ts @@ -0,0 +1,73 @@ +// Local SDK additions; replace with SDK imports after the accepted prerelease. +export interface CampaignDrip { + readonly enrollment: 'audience' | 'continuous' + readonly exitOnReply: boolean + /** GET opens, including scanners/previews, count; HEAD does not. */ + readonly exitOnLinkClick: boolean +} +/** Create defaults: audience, true, false. Beta, linked-device/test senders only. */ +export type CampaignDripInput = Partial +/** Omitted settings preserve stored values. No defaults are applied to PATCH. + * Closing with only enrollment: audience also works after beta withdrawal. */ +export type CampaignDripPatch = Partial +export interface CampaignDripStepStats { + readonly step: number + readonly entered: number + readonly waiting: number + readonly sent: number + readonly delivered: number + readonly read: number + readonly replied: number + readonly exited: number + readonly failed: number +} +export interface CampaignDripStats { + readonly enrollment: 'audience' | 'continuous' + readonly enrolled: number + readonly enrolledByApi: number + readonly active: number + readonly completed: number + readonly exited: number + readonly steps: readonly CampaignDripStepStats[] +} +export interface EnrollCampaignContactRequest { + readonly phone: string + readonly variables?: Readonly> +} +export interface CampaignEnrollment { + readonly campaignId: string + readonly recipientId: string + readonly phone: string + readonly created: boolean + readonly status: string + readonly enrolledAt: number +} +export interface CampaignSegmentSnapshot { + readonly revision: number + readonly evaluatedAt: number + readonly matchedCount: number +} +/** Later steps only. At most 4 hour ranges, same time zone as the campaign. + * Delay is 0..2592000 seconds, in whole multiples of delayUnit. */ +export interface CampaignStepWindow { + readonly days: readonly ( + | 'monday' + | 'tuesday' + | 'wednesday' + | 'thursday' + | 'friday' + | 'saturday' + | 'sunday' + )[] + readonly hours: readonly {readonly start: string; readonly end: string}[] +} +export interface CampaignSequenceMessage { + readonly version?: 2 + readonly source?: string + readonly delayAfterSec?: number + readonly delayUnit?: 'minutes' | 'hours' | 'days' + readonly sendWindow?: CampaignStepWindow | null + readonly [field: string]: unknown +} +/** Exposed through recipient.sequenceError; arbitrary send failure codes also occur. */ +export type CampaignDripExitReason = 'replied' | 'link_clicked' | 'opted_out' diff --git a/src/services/campaign-segment-types.ts b/src/services/campaign-segment-types.ts new file mode 100644 index 00000000..572d71cb --- /dev/null +++ b/src/services/campaign-segment-types.ts @@ -0,0 +1,112 @@ +// Local SDK additions; replace with SDK imports after the accepted prerelease. +export type AudienceSegmentCondition = + | { + readonly field: 'tag' + readonly operator: 'contains' + readonly value: string + } + | { + readonly field: 'tag' + readonly operator: 'in' + readonly values: readonly string[] + } + | {readonly field: 'tag'; readonly operator: 'exists'} + | { + readonly field: 'attribute' + readonly name: string + readonly operator: 'equals' + readonly value: string | number | boolean + } + | { + readonly field: 'attribute' + readonly name: string + readonly operator: 'contains' | 'before' | 'after' + readonly value: string + } + | { + readonly field: 'attribute' + readonly name: string + readonly operator: 'in' + readonly values: readonly (string | number)[] + } + | { + readonly field: 'attribute' + readonly name: string + readonly operator: 'greater_than' | 'less_than' + readonly value: number + } + | { + readonly field: 'attribute' + readonly name: string + readonly operator: 'exists' + } + | { + readonly field: 'consent' + readonly operator: 'equals' + readonly value: boolean + } + | { + readonly field: 'campaign' + readonly campaignId: string + readonly outcome: + | 'delivered' + | 'not_delivered' + | 'read' + | 'not_read' + | 'replied' + | 'not_replied' + | 'failed' + } + +/** At most 10 groups, 20 conditions/group, 40 total, 5 campaign conditions, + * 50 values per `in`, and 16 KiB. Missing, null or wrong-type attributes do not match. */ +export interface AudienceSegmentRules { + readonly match: 'all' | 'any' + readonly groups: readonly { + readonly match: 'all' | 'any' + readonly negate?: boolean + readonly conditions: readonly AudienceSegmentCondition[] + }[] +} +export interface AudienceSegment { + readonly id: string + readonly name: string + readonly revision: number + readonly rules: AudienceSegmentRules + readonly createdAt: number + readonly updatedAt: number +} +export interface AudienceSegmentPreview { + readonly matchedCount: number + readonly optedOutCount: number + readonly consentWithheldCount: number + readonly eligibleCount: number + readonly sample: readonly { + readonly phone: string + readonly tags: readonly string[] + }[] + readonly evaluatedAt: number +} +export interface SavedAudienceSegmentPreview extends AudienceSegmentPreview { + readonly revision: number +} +export interface CreateAudienceSegmentRequest { + readonly name: string + readonly rules: AudienceSegmentRules +} +export interface UpdateAudienceSegmentRequest { + readonly expectedRevision: number + readonly name?: string + readonly rules?: AudienceSegmentRules +} +export interface ListAudienceSegmentsParams { + readonly cursor?: string + readonly limit?: number +} +export interface AudienceSegmentsEnvelope { + readonly data: readonly AudienceSegment[] + readonly page: { + readonly hasMore: boolean + readonly nextCursor: string | null + } +} diff --git a/src/services/campaign-segments-drip.ts b/src/services/campaign-segments-drip.ts new file mode 100644 index 00000000..83d02a97 --- /dev/null +++ b/src/services/campaign-segments-drip.ts @@ -0,0 +1,169 @@ +import {Flags} from '@oclif/core' +import { + PolymorfaValidationError, + type ClientInstance, + type MessagingClient, + type Campaign, + type RawRequest, +} from '@polymorfa/sdk' +import {terminalText} from '../developer/render.js' +import type { + CampaignDrip, + CampaignDripPatch, + CampaignDripStats, + CampaignEnrollment, +} from './campaign-drip-types.js' +export type { + AudienceSegment, + AudienceSegmentRules, + AudienceSegmentPreview, +} from './campaign-segment-types.js' +export const dripFlags = { + drip: Flags.boolean({description: 'Enable drip sequences (beta).'}), + 'drip-enrollment': Flags.string({ + options: ['audience', 'continuous'], + description: 'Drip enrollment mode. Audience closes continuous enrollment.', + }), + 'drip-exit-on-reply': Flags.boolean({ + allowNo: true, + description: 'Stop remaining steps after a reply.', + }), + 'drip-exit-on-link-click': Flags.boolean({ + allowNo: true, + description: + 'Stop remaining steps on tracked-link GET, including preview/scanner GETs.', + }), + 'remove-drip': Flags.boolean({ + description: 'Remove drip settings from an unlaunched draft.', + }), +} +interface DripFlags { + readonly drip?: boolean | undefined + readonly 'drip-enrollment'?: string | undefined + readonly 'drip-exit-on-reply'?: boolean | undefined + readonly 'drip-exit-on-link-click'?: boolean | undefined + readonly 'remove-drip'?: boolean | undefined +} +export function objectInput(value: unknown): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) + throw new PolymorfaValidationError('Input must be one JSON object.', { + code: 'invalid_campaign_input', + }) + return {...(value as Record)} +} +export function dripInput( + value: unknown, + flags: DripFlags, +): Record { + const body = objectInput(value) + const edits: CampaignDripPatch = { + ...(flags['drip-enrollment'] === undefined + ? {} + : {enrollment: flags['drip-enrollment'] as CampaignDrip['enrollment']}), + ...(flags['drip-exit-on-reply'] === undefined + ? {} + : {exitOnReply: flags['drip-exit-on-reply']}), + ...(flags['drip-exit-on-link-click'] === undefined + ? {} + : {exitOnLinkClick: flags['drip-exit-on-link-click']}), + } + const hasEdits = flags.drip === true || Object.keys(edits).length > 0 + if (flags['remove-drip'] && (hasEdits || body.drip !== undefined)) + throw new PolymorfaValidationError( + 'Use --remove-drip without other drip settings.', + {code: 'invalid_campaign_input'}, + ) + if (flags['remove-drip']) body.drip = null + else if (hasEdits) { + if (body.drip === null) + throw new PolymorfaValidationError( + 'Cannot combine null drip with drip flags.', + {code: 'invalid_campaign_input'}, + ) + body.drip = { + ...(body.drip === undefined ? {} : objectInput(body.drip)), + ...edits, + } + } + return body +} +export async function campaignRequest( + client: ClientInstance<'organization'> | MessagingClient, + request: RawRequest, +) { + const response = await client.raw.request<{data: T}>({ + ...request, + maxNetworkRetries: 0, + }) + return {data: response.data.data, metadata: response.metadata} +} +export function segmentPath(id?: string) { + return ( + '/platform/audience-segments' + + (id === undefined ? '' : '/' + encodeURIComponent(id)) + ) +} +export function revision(value: number): number { + if (!Number.isSafeInteger(value) || value < 1) + throw new PolymorfaValidationError('Use an observed positive revision.', { + code: 'invalid_parameter', + }) + return value +} +export function renderDripConfiguration(value: object): string[] { + const c = value as { + drip?: CampaignDrip | null + enrollmentOpen?: boolean | null + pauseReason?: string | null + segmentId?: string | null + segmentSnapshot?: { + revision: number + matchedCount: number + evaluatedAt: number + } | null + } + return [ + ...(c.segmentId ? [`Segment\t${terminalText(c.segmentId)}`] : []), + ...(c.segmentSnapshot + ? [ + `Segment revision\t${c.segmentSnapshot.revision}`, + `Segment matched\t${c.segmentSnapshot.matchedCount}`, + ] + : []), + ...(c.drip + ? [ + `Drip enrollment\t${c.drip.enrollment}`, + `Enrollment\t${c.enrollmentOpen === true ? 'Open' : c.enrollmentOpen === false ? 'Closed' : 'Unknown'}`, + `Exit on reply\t${c.drip.exitOnReply}`, + `Exit on link GET\t${c.drip.exitOnLinkClick}`, + ] + : []), + ...(c.pauseReason ? [`Pause reason\t${terminalText(c.pauseReason)}`] : []), + ] +} +export function renderDripStats(value: object): string[] { + const {drip} = value as {drip?: CampaignDripStats | null} + if (!drip) return [] + return [ + `Drip enrolled\t${drip.enrolled}`, + `Drip active\t${drip.active}`, + `Drip completed\t${drip.completed}`, + `Drip exited\t${drip.exited}`, + 'Step\tEntered\tWaiting\tSent\tDelivered\tRead\tReplied\tExited\tFailed', + ...drip.steps.map((s) => + [ + s.step, + s.entered, + s.waiting, + s.sent, + s.delivered, + s.read, + s.replied, + s.exited, + s.failed, + ].join('\t'), + ), + ] +} +export type CampaignWithDrip = Campaign & {readonly drip?: CampaignDrip | null} +export type Enrollment = CampaignEnrollment diff --git a/src/services/campaigns.ts b/src/services/campaigns.ts index b00eeeff..78dcf682 100644 --- a/src/services/campaigns.ts +++ b/src/services/campaigns.ts @@ -1,3 +1,7 @@ +import { + renderDripConfiguration, + renderDripStats, +} from './campaign-segments-drip.js' import { followUpCell, renderFollowUpAnalytics, @@ -225,6 +229,7 @@ export function renderCampaign(campaign: Campaign): string { `Skipped\t${campaign.skippedCount}`, `Scheduled\t${formatCampaignTime(campaign.scheduledAt)}`, ...renderFollowUpConfiguration(campaign), + ...renderDripConfiguration(campaign), ].join('\n') } @@ -240,6 +245,7 @@ export function renderCampaignAnalytics(analytics: CampaignAnalytics): string { `Responded\t${analytics.respondedCount}`, `Response rate\t${analytics.responseRate}`, ...renderFollowUpAnalytics(analytics), + ...renderDripStats(analytics), ].join('\n') } diff --git a/test/commands/campaign-segments-drip.test.ts b/test/commands/campaign-segments-drip.test.ts new file mode 100644 index 00000000..6329bff3 --- /dev/null +++ b/test/commands/campaign-segments-drip.test.ts @@ -0,0 +1,271 @@ +import {mkdir, mkdtemp, rm, writeFile} from 'node:fs/promises' +import {join} from 'node:path' +import {homedir} from 'node:os' +import {runCommand} from '@oclif/test' +import {afterEach, expect, test} from 'vitest' +import { + startHttpServer, + startHttpSequenceServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' +let server: TestHttpServer | undefined +let directory: string | undefined +afterEach(async () => { + await server?.close() + server = undefined + if (directory) await rm(directory, {recursive: true, force: true}) + directory = undefined +}) +const rules = { + match: 'all', + groups: [ + { + match: 'all', + conditions: [{field: 'tag', operator: 'contains', value: 'vip'}], + }, + ], +} +const segment = { + id: 's', + revision: 2, + name: 'VIP', + rules, + createdAt: 1, + updatedAt: 1, +} +const campaign = { + id: 'c', + name: 'drip', + status: 'paused', + recipientCount: 3, + sentCount: 2, + deliveredCount: 2, + readCount: 1, + failedCount: 0, + skippedCount: 0, + scheduledAt: null, + drip: {enrollment: 'audience', exitOnReply: false, exitOnLinkClick: true}, + enrollmentOpen: false, + pauseReason: 'feature_unavailable', +} +const stats = { + enrollment: 'audience', + enrolled: 3, + enrolledByApi: 1, + active: 1, + completed: 1, + exited: 1, + steps: [ + { + step: 0, + entered: 3, + waiting: 1, + sent: 2, + delivered: 2, + read: 1, + replied: 1, + exited: 1, + failed: 0, + }, + ], +} +async function input(value: unknown) { + const base = join( + homedir(), + '.polymorfa-agent-work/campaigns-segments-drip-clients/test-inputs', + ) + await mkdir(base, {recursive: true}) + directory ??= await mkdtemp(join(base, 'cli-')) + const path = join(directory, 'input.json') + await writeFile(path, JSON.stringify(value)) + return path +} +async function run(command: string) { + if (!server) throw Error('No server') + return withEnvironment( + { + POLYMORFA_API_KEY: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + () => runCommand(command), + ) +} +test('lists and reads organization segments with pagination', async () => { + server = await startHttpSequenceServer([ + {body: {data: [segment], page: {hasMore: true, nextCursor: 'next'}}}, + {body: {data: segment}}, + ]) + const list = await run('campaign segments list --limit 2 --cursor old') + expect(list.error).toBeUndefined() + expect(list.stdout).toContain('Next cursor\tnext') + const get = await run('campaign segments get s --json') + expect(get.error).toBeUndefined() + expect(server.requests.map((r) => r.url)).toEqual([ + '/platform/audience-segments?cursor=old&limit=2', + '/platform/audience-segments/s', + ]) +}) +test('creates, updates and deletes with observed revisions', async () => { + server = await startHttpServer({body: {data: segment}}) + const create = await run( + `campaign segments create --input ${await input({name: 'VIP', rules})} --json`, + ) + expect(create.error).toBeUndefined() + const update = await run( + `campaign segments update s --input ${await input({expectedRevision: 1, name: 'new'})} --json`, + ) + expect(update.error).toBeUndefined() + const remove = await run('campaign segments delete s --revision 2 --json') + expect(remove.error).toBeUndefined() + expect(server.requests.map((r) => [r.method, r.url])).toEqual([ + ['POST', '/platform/audience-segments'], + ['PATCH', '/platform/audience-segments/s'], + ['DELETE', '/platform/audience-segments/s?expectedRevision=2'], + ]) + expect(JSON.parse(server.requests[1]!.body)).toEqual({ + expectedRevision: 1, + name: 'new', + }) +}) +test('counts saved and unsaved rules, rejects ambiguous input', async () => { + server = await startHttpServer({ + body: { + data: { + matchedCount: 4, + eligibleCount: 2, + optedOutCount: 1, + consentWithheldCount: 1, + revision: 2, + }, + }, + }) + expect((await run('campaign segments count s')).stdout).toContain( + 'Eligible\t2', + ) + const file = await input(rules) + expect( + (await run(`campaign segments count --input ${file}`)).error, + ).toBeUndefined() + expect(JSON.parse(server.requests[1]!.body)).toEqual({rules}) + expect( + (await run(`campaign segments count s --input ${file}`)).error, + ).toBeDefined() + expect(server.requests).toHaveLength(2) +}) +test('selects the Platform audience and preserves close-only PATCH omission', async () => { + server = await startHttpServer({body: {data: campaign, success: true}}) + const create = await run( + `campaign create --segment s --project-id p --input ${await input({name: 'VIP', messagesArray: [{source: 'Hello'}]})} --drip --drip-enrollment continuous --no-drip-exit-on-reply --json`, + ) + expect(create.error).toBeUndefined() + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + name: 'VIP', + messagesArray: [{source: 'Hello'}], + segmentId: 's', + projectId: 'p', + drip: {enrollment: 'continuous', exitOnReply: false}, + }) + const close = await run( + 'campaign update c --project-slug p --drip-enrollment audience --json', + ) + expect(close.error).toBeUndefined() + expect(JSON.parse(server.requests[1]!.body)).toEqual({ + drip: {enrollment: 'audience'}, + }) + expect(server.requests[0]!.url).toBe('/platform/campaigns') +}) +test('enrollment returns existing status without restarting', async () => { + server = await startHttpServer({ + body: { + success: true, + data: { + campaignId: 'c', + recipientId: 'r', + phone: '+5511999990000', + created: false, + status: 'skipped', + enrolledAt: 1, + }, + }, + }) + const result = await run( + 'campaign enroll c --project-slug p --phone +5511999990000', + ) + expect(result.error).toBeUndefined() + expect(result.stdout).toContain('Already enrolled\tr') + expect(server.requests[0]!.url).toBe( + '/messaging/projects/p/campaigns/c/enrollments', + ) +}) +test('renders closed enrollment, pause reason and live step stats in view and analytics', async () => { + server = await startHttpSequenceServer([ + {body: {data: campaign}}, + {body: {data: {campaignId: 'c', drip: stats}}}, + {body: {data: {campaignId: 'c', drip: stats}}}, + ]) + const view = await run('campaign view c --project-slug p') + expect(view.error).toBeUndefined() + expect(view.stdout).toContain('Enrollment\tClosed') + expect(view.stdout).toContain('Pause reason\tfeature_unavailable') + expect(view.stdout).toContain('Step\tEntered\tWaiting') + const analytics = await run('campaign analytics c --project-slug p') + expect(analytics.error).toBeUndefined() + expect(analytics.stdout).toContain('Drip enrolled\t3') +}) +test.each([ + [403, 'feature_unavailable'], + [403, 'permission_denied'], + [409, 'state_conflict'], +] as const)('preserves %s %s with one write', async (status, code) => { + server = await startHttpServer({ + status, + body: { + error: { + code, + message: 'Unavailable, deleted segment or retryable timeout', + }, + }, + }) + const result = await run( + `campaign segments update s --input ${await input({expectedRevision: 2, name: 'VIP'})}`, + ) + expect(result.error).toBeDefined() + expect(server.requests).toHaveLength(1) +}) + +test('creates and updates composed drips through Platform without a segment', async () => { + server = await startHttpServer({body: {data: campaign}}) + const messagesArray = [ + {source: 'Hello'}, + { + source: 'Later', + delayAfterSec: 3600, + delayUnit: 'hours', + sendWindow: {days: ['monday'], hours: [{start: '09:00', end: '17:00'}]}, + }, + ] + const create = await run( + `campaign create --project-id p --drip --input ${await input({name: 'Drip', messagesArray})} --json`, + ) + expect(create.error).toBeUndefined() + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + projectId: 'p', + name: 'Drip', + messagesArray, + drip: {}, + }) + const update = await run( + `campaign update c --project-id p --input ${await input({messagesArray})} --json`, + ) + expect(update.error).toBeUndefined() + expect(server.requests[1]!.url).toBe('/platform/campaigns/c?projectId=p') + expect(JSON.parse(server.requests[1]!.body)).toEqual({messagesArray}) + const wrong = await run( + `campaign create --project-slug p --drip --input ${await input({name: 'Drip', messagesArray})}`, + ) + expect(wrong.error).toBeDefined() + expect(server.requests).toHaveLength(2) +}) From 1a7e86df7dc41849d41cb06adeb35ef9d4347cee Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 21 Oct 2026 10:40:36 +0300 Subject: [PATCH 2/3] test: cover combined campaign caps and drip commands --- test/commands/campaign-segments-drip.test.ts | 80 ++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/test/commands/campaign-segments-drip.test.ts b/test/commands/campaign-segments-drip.test.ts index 6329bff3..73aa7671 100644 --- a/test/commands/campaign-segments-drip.test.ts +++ b/test/commands/campaign-segments-drip.test.ts @@ -269,3 +269,83 @@ test('creates and updates composed drips through Platform without a segment', as expect(wrong.error).toBeDefined() expect(server.requests).toHaveLength(2) }) + +test('creates a segment drip with frequency caps in one Platform request', async () => { + server = await startHttpServer({body: {data: campaign}}) + const messagesArray = [{source: 'Hello'}] + const result = await run( + `campaign create --project-id p --segment s --drip --drip-enrollment continuous --frequency-cap-24-hours 2 --frequency-cap-7-days 5 --input ${await input({name: 'Combined', messagesArray})} --json`, + ) + expect(result.error).toBeUndefined() + expect(server.requests).toHaveLength(1) + expect(server.requests[0]).toMatchObject({ + method: 'POST', + url: '/platform/campaigns', + }) + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + projectId: 'p', + name: 'Combined', + messagesArray, + segmentId: 's', + drip: {enrollment: 'continuous'}, + frequencyCap: {maxPer24Hours: 2, maxPer7Days: 5}, + }) +}) + +test.each(['--project-id p', '--project-slug p'])( + 'updates cap and drip together through %s without supplying omitted exit flags', + async (selector) => { + server = await startHttpServer({body: {data: campaign}}) + const result = await run( + `campaign update c ${selector} --drip-enrollment audience --frequency-cap-24-hours 2 --json`, + ) + expect(result.error).toBeUndefined() + expect(server.requests).toHaveLength(1) + expect(server.requests[0]).toMatchObject({ + method: 'PATCH', + url: selector.includes('project-id') + ? '/platform/campaigns/c?projectId=p' + : '/messaging/projects/p/campaigns/c', + }) + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + drip: {enrollment: 'audience'}, + frequencyCap: {maxPer24Hours: 2, maxPer7Days: null}, + }) + }, +) + +test.each(['--project-id p', '--project-slug p'])( + 'does not retry a transient combined settings write through %s', + async (selector) => { + server = await startHttpServer({ + status: 503, + body: {error: {code: 'unavailable', message: 'Try later'}}, + }) + const result = await run( + `campaign update c ${selector} --drip-enrollment audience --remove-frequency-cap --json`, + ) + expect(result.error).toBeDefined() + expect(server.requests).toHaveLength(1) + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + drip: {enrollment: 'audience'}, + frequencyCap: null, + }) + }, +) + +test('renders cap skips and drip step analytics together', async () => { + server = await startHttpServer({ + body: { + data: { + campaignId: 'c', + drip: stats, + frequencyCap: {skippedRecipientCount: 4}, + }, + }, + }) + const result = await run('campaign analytics c --project-slug p') + expect(result.error).toBeUndefined() + expect(result.stdout).toContain('Skipped by frequency cap\t4') + expect(result.stdout).toContain('Drip enrolled\t3') + expect(result.stdout).toContain('Step\tEntered\tWaiting') +}) From 7d63b990a957c9425f8ab3d424c85d56ff797252 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 21 Oct 2026 11:08:14 +0300 Subject: [PATCH 3/3] fix: reject drip removal when creating a campaign --- src/commands/campaign/create.ts | 4 ++ test/commands/campaign-segments-drip.test.ts | 51 ++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/src/commands/campaign/create.ts b/src/commands/campaign/create.ts index c726667b..8c349479 100644 --- a/src/commands/campaign/create.ts +++ b/src/commands/campaign/create.ts @@ -45,6 +45,10 @@ export default class CampaignCreate extends BaseCommand { async run(): Promise { 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, diff --git a/test/commands/campaign-segments-drip.test.ts b/test/commands/campaign-segments-drip.test.ts index 73aa7671..7ea0a588 100644 --- a/test/commands/campaign-segments-drip.test.ts +++ b/test/commands/campaign-segments-drip.test.ts @@ -349,3 +349,54 @@ test('renders cap skips and drip step analytics together', async () => { expect(result.stdout).toContain('Drip enrolled\t3') expect(result.stdout).toContain('Step\tEntered\tWaiting') }) + +test.each(['--project-id p', '--project-slug p'])( + 'rejects drip removal on create through %s before HTTP', + async (selector) => { + server = await startHttpServer({body: {data: campaign}}) + const result = await run( + `campaign create ${selector} --remove-drip --input ${await input({name: 'Ordinary'})} --json`, + ) + expect(result.error).toBeDefined() + expect(result.stdout + result.stderr).toContain( + '--remove-drip only applies to an existing unlaunched draft. Use campaign update.', + ) + expect(server.requests).toHaveLength(0) + }, +) + +test.each(['--project-id p', '--project-slug p'])( + 'retains drip removal on update through %s', + async (selector) => { + server = await startHttpServer({body: {data: campaign}}) + const result = await run( + `campaign update c ${selector} --remove-drip --json`, + ) + expect(result.error).toBeUndefined() + expect(server.requests).toHaveLength(1) + expect(server.requests[0]).toMatchObject({ + method: 'PATCH', + url: selector.includes('project-id') + ? '/platform/campaigns/c?projectId=p' + : '/messaging/projects/p/campaigns/c', + }) + expect(JSON.parse(server.requests[0]!.body)).toEqual({drip: null}) + }, +) + +test.each(['--project-id p', '--project-slug p'])( + 'preserves valid team-only cap selection on create through %s', + async (selector) => { + server = await startHttpServer({body: {data: campaign}}) + const result = await run( + `campaign create ${selector} --remove-frequency-cap --input ${await input({name: 'Ordinary'})} --json`, + ) + expect(result.error).toBeUndefined() + expect(server.requests).toHaveLength(1) + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + name: 'Ordinary', + frequencyCap: null, + ...(selector.includes('project-id') ? {projectId: 'p'} : {}), + }) + }, +)