From 93fd6429d0435580a78fd967bced764809ea5ee8 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Fri, 16 Oct 2026 09:46:29 +0300 Subject: [PATCH 1/4] Add audience profile commands behind SDK prerelease gate --- README.md | 38 ++ package.json | 6 + src/commands/campaign/profiles/consent.ts | 39 ++ src/commands/campaign/profiles/delete.ts | 37 ++ src/commands/campaign/profiles/get.ts | 31 ++ src/commands/campaign/profiles/import.ts | 51 +++ .../campaign/profiles/imports/cancel.ts | 27 ++ src/commands/campaign/profiles/imports/get.ts | 28 ++ src/commands/campaign/profiles/list.ts | 45 +++ src/commands/campaign/profiles/upsert.ts | 40 ++ src/sdk/audience-profiles.ts | 361 ++++++++++++++++++ src/services/audience-profiles.ts | 97 +++++ test/commands/audience-profiles.test.ts | 218 +++++++++++ 13 files changed, 1018 insertions(+) create mode 100644 src/commands/campaign/profiles/consent.ts create mode 100644 src/commands/campaign/profiles/delete.ts create mode 100644 src/commands/campaign/profiles/get.ts create mode 100644 src/commands/campaign/profiles/import.ts create mode 100644 src/commands/campaign/profiles/imports/cancel.ts create mode 100644 src/commands/campaign/profiles/imports/get.ts create mode 100644 src/commands/campaign/profiles/list.ts create mode 100644 src/commands/campaign/profiles/upsert.ts create mode 100644 src/sdk/audience-profiles.ts create mode 100644 src/services/audience-profiles.ts create mode 100644 test/commands/audience-profiles.test.ts diff --git a/README.md b/README.md index efbdc3c2..4993200a 100644 --- a/README.md +++ b/README.md @@ -2642,3 +2642,41 @@ 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 profiles (beta) + +Use a team API key with `campaigns:read` for reads or `campaigns:manage` for +changes. Project and client tokens cannot access team profiles. Profile writes +and import creation require beta enrollment; reads, cancellation and deletion +remain available after withdrawal. These commands are source additions pending +the matching SDK prerelease and API acceptance. + +```sh +polymorfa campaign profiles list --tag vip --phone-prefix +44 --limit 50 +polymorfa campaign profiles list --tag vip --phone-prefix +44 --cursor CURSOR +polymorfa campaign profiles get +14155550123 +polymorfa campaign profiles upsert +14155550123 --input profile.json +polymorfa campaign profiles consent +14155550123 --limit 25 +polymorfa campaign profiles import --csv contacts.csv --replay-key contacts-1 +polymorfa campaign profiles imports get JOB_ID +polymorfa campaign profiles imports cancel JOB_ID +polymorfa campaign profiles delete +14155550123 --expected-revision 2 +``` + +`profile.json` must contain `tags` and `attributes`. Include the latest +`expectedRevision` when replacing an existing profile. Optional `provenance` +and `consent` match the API body; assertions never override opt-outs. A stale +revision returns 409. Deletion preserves opt-outs and existing campaign recipients. + +Imports accept at most 10 MiB and 10,000 rows. Use `--mapping mapping.json` +for explicit CSV columns, for example +`{"phone":"Phone","tags":"Tags","attributes":"Attributes"}`. Omitted mapping +uses the API's standard column names. Poll the job until `completed` or +`cancelled`. Cancellation leaves committed edits intact. Replay keys must be +reused only with the same CSV, mapping and credential. + +List filters apply together before paging. Tags match exactly (1-48 UTF-8 +bytes, without ASCII control characters); phone prefixes contain `+` followed +by 1-15 digits. Keep the same filters when using `--cursor`. `--json` preserves +API envelopes and pagination. The CLI keeps its existing SDK pin and uses a +local typed adapter until a matching SDK prerelease is published. diff --git a/package.json b/package.json index 97f2c5b9..cc2f598c 100644 --- a/package.json +++ b/package.json @@ -332,6 +332,12 @@ }, "analytics": { "description": "Read WhatsApp number activity and operational vitals." + }, + "campaign:profiles": { + "description": "Manage customer-supplied team audience profiles (beta)." + }, + "campaign:profiles:imports": { + "description": "Read and cancel audience profile CSV imports." } } }, diff --git a/src/commands/campaign/profiles/consent.ts b/src/commands/campaign/profiles/consent.ts new file mode 100644 index 00000000..06e60268 --- /dev/null +++ b/src/commands/campaign/profiles/consent.ts @@ -0,0 +1,39 @@ +import {Args, Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {AudienceProfilesResource} from '../../../sdk/audience-profiles.js' +import {profileResult} from '../../../services/audience-profiles.js' +import {terminalText} from '../../../developer/render.js' +export default class ProfilesConsent extends BaseCommand { + static override args = { + phone: Args.string({ + required: true, + description: 'E.164 phone including +.', + }), + } + static override description = + 'List one page of supplied campaign consent assertions. Assertions do not clear opt-outs. Requires campaigns:read.' + static override flags = { + cursor: Flags.string({description: 'Continue from this cursor.'}), + limit: Flags.integer({ + min: 1, + max: 100, + description: 'Assertions per page (default 25).', + }), + } + async run(): Promise { + const {args, flags} = await this.parse(ProfilesConsent) + const resource = new AudienceProfilesResource( + (await this.platformClient(flags)).raw, + ) + + const response = await resource.listConsent(args.phone, { + ...(flags.cursor === undefined ? {} : {cursor: flags.cursor}), + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + }) + this.writeValue( + profileResult(response), + terminalText(JSON.stringify(response.data)), + flags, + ) + } +} diff --git a/src/commands/campaign/profiles/delete.ts b/src/commands/campaign/profiles/delete.ts new file mode 100644 index 00000000..6af3c29b --- /dev/null +++ b/src/commands/campaign/profiles/delete.ts @@ -0,0 +1,37 @@ +import {Args, Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {AudienceProfilesResource} from '../../../sdk/audience-profiles.js' +import {profileResult} from '../../../services/audience-profiles.js' +export default class ProfilesDelete extends BaseCommand { + static override args = { + phone: Args.string({ + required: true, + description: 'E.164 phone including +.', + }), + } + static override description = + 'Delete a profile at its latest revision. Removes profile data and audience memberships; preserves opt-outs and campaign recipients. Requires campaigns:manage.' + static override flags = { + 'expected-revision': Flags.integer({ + min: 1, + required: true, + description: 'Latest profile revision. Stale revisions return 409.', + }), + } + async run(): Promise { + const {args, flags} = await this.parse(ProfilesDelete) + const resource = new AudienceProfilesResource( + (await this.platformClient(flags)).raw, + ) + + const response = await resource.delete( + args.phone, + flags['expected-revision'], + ) + this.writeValue( + profileResult(response), + `Deleted ${response.data.data.phone}; retained revision ${response.data.data.revision}.`, + flags, + ) + } +} diff --git a/src/commands/campaign/profiles/get.ts b/src/commands/campaign/profiles/get.ts new file mode 100644 index 00000000..c6ef7dec --- /dev/null +++ b/src/commands/campaign/profiles/get.ts @@ -0,0 +1,31 @@ +import {Args} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {AudienceProfilesResource} from '../../../sdk/audience-profiles.js' +import { + profileResult, + renderProfile, +} from '../../../services/audience-profiles.js' +export default class ProfilesGet extends BaseCommand { + static override args = { + phone: Args.string({ + required: true, + description: 'E.164 phone including +.', + }), + } + static override description = + 'Get a team audience profile by E.164 phone. Requires campaigns:read.' + + async run(): Promise { + const {args, flags} = await this.parse(ProfilesGet) + const resource = new AudienceProfilesResource( + (await this.platformClient(flags)).raw, + ) + + const response = await resource.retrieve(args.phone) + this.writeValue( + profileResult(response), + renderProfile(response.data.data), + flags, + ) + } +} diff --git a/src/commands/campaign/profiles/import.ts b/src/commands/campaign/profiles/import.ts new file mode 100644 index 00000000..70357f65 --- /dev/null +++ b/src/commands/campaign/profiles/import.ts @@ -0,0 +1,51 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {AudienceProfilesResource} from '../../../sdk/audience-profiles.js' +import { + profileResult, + renderImport, +} from '../../../services/audience-profiles.js' +import {readJsonInput, readTextInput} from '../../../api/request.js' +import {importMapping} from '../../../services/audience-profiles.js' +export default class ProfilesImport extends BaseCommand { + static override description = + 'Queue a profile CSV import (at most 10 MiB and 10,000 data rows). Requires campaigns:manage and beta enrollment. Reuse the replay key only with identical CSV, mapping and credential; poll imports get for progress.' + static override flags = { + csv: Flags.string({ + required: true, + description: 'UTF-8 CSV file or stdin (-).', + }), + 'replay-key': Flags.string({ + required: true, + description: + 'Stable import identity, 1-128 letters, digits, dots, underscores, colons or hyphens.', + }), + mapping: Flags.string({ + description: 'JSON file mapping profile fields to CSV header names.', + }), + } + async run(): Promise { + const {flags} = await this.parse(ProfilesImport) + if (flags.csv === '-' && flags.mapping === '-') + throw new Error('CSV and mapping cannot both read stdin.') + const csv = await readTextInput(flags.csv) + const mapping = + flags.mapping === undefined + ? undefined + : importMapping(await readJsonInput(flags.mapping)) + const resource = new AudienceProfilesResource( + (await this.platformClient(flags)).raw, + ) + + const response = await resource.createImport({ + csv, + replayKey: flags['replay-key'], + ...(mapping === undefined ? {} : {mapping}), + }) + this.writeValue( + profileResult(response), + renderImport(response.data.data), + flags, + ) + } +} diff --git a/src/commands/campaign/profiles/imports/cancel.ts b/src/commands/campaign/profiles/imports/cancel.ts new file mode 100644 index 00000000..64710477 --- /dev/null +++ b/src/commands/campaign/profiles/imports/cancel.ts @@ -0,0 +1,27 @@ +import {Args} from '@oclif/core' +import {BaseCommand} from '../../../../base-command.js' +import {AudienceProfilesResource} from '../../../../sdk/audience-profiles.js' +import { + profileResult, + renderImport, +} from '../../../../services/audience-profiles.js' +export default class ProfileImportsCancel extends BaseCommand { + static override args = { + job: Args.string({required: true, description: 'Import job ID.'}), + } + static override description = + 'Cancel remaining import rows. Completed edits remain. Requires campaigns:manage; available after beta withdrawal.' + async run(): Promise { + const {args, flags} = await this.parse(ProfileImportsCancel) + const resource = new AudienceProfilesResource( + (await this.platformClient(flags)).raw, + ) + + const response = await resource.cancelImport(args.job) + this.writeValue( + profileResult(response), + renderImport(response.data.data), + flags, + ) + } +} diff --git a/src/commands/campaign/profiles/imports/get.ts b/src/commands/campaign/profiles/imports/get.ts new file mode 100644 index 00000000..40bbfed5 --- /dev/null +++ b/src/commands/campaign/profiles/imports/get.ts @@ -0,0 +1,28 @@ +import {Args} from '@oclif/core' +import {BaseCommand} from '../../../../base-command.js' +import {AudienceProfilesResource} from '../../../../sdk/audience-profiles.js' +import { + profileResult, + renderProfile, + renderImport, +} from '../../../../services/audience-profiles.js' +export default class ProfileImportsGet extends BaseCommand { + static override args = { + job: Args.string({required: true, description: 'Import job ID.'}), + } + static override description = + 'Read an aggregate profile import report. Requires campaigns:read.' + async run(): Promise { + const {args, flags} = await this.parse(ProfileImportsGet) + const resource = new AudienceProfilesResource( + (await this.platformClient(flags)).raw, + ) + + const response = await resource.retrieveImport(args.job) + this.writeValue( + profileResult(response), + renderImport(response.data.data), + flags, + ) + } +} diff --git a/src/commands/campaign/profiles/list.ts b/src/commands/campaign/profiles/list.ts new file mode 100644 index 00000000..1711eaf0 --- /dev/null +++ b/src/commands/campaign/profiles/list.ts @@ -0,0 +1,45 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {AudienceProfilesResource} from '../../../sdk/audience-profiles.js' +import { + profileResult, + renderProfilePage, +} from '../../../services/audience-profiles.js' +export default class ProfilesList extends BaseCommand { + static override description = + 'List one page of team audience profiles. Combine exact tag and phone prefix filters; keep them on subsequent pages. Requires a team key with campaigns:read.' + static override flags = { + cursor: Flags.string({description: 'Continue from this cursor.'}), + limit: Flags.integer({ + min: 1, + max: 100, + description: 'Profiles per page (default 25).', + }), + tag: Flags.string({ + description: 'Exact tag, 1-48 UTF-8 bytes without control characters.', + }), + 'phone-prefix': Flags.string({ + description: 'International prefix: + and 1-15 digits.', + }), + } + async run(): Promise { + const {flags} = await this.parse(ProfilesList) + const resource = new AudienceProfilesResource( + (await this.platformClient(flags)).raw, + ) + + const response = await resource.list({ + ...(flags.cursor === undefined ? {} : {cursor: flags.cursor}), + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + ...(flags.tag === undefined ? {} : {tag: flags.tag}), + ...(flags['phone-prefix'] === undefined + ? {} + : {phonePrefix: flags['phone-prefix']}), + }) + this.writeValue( + profileResult(response), + renderProfilePage(response.data), + flags, + ) + } +} diff --git a/src/commands/campaign/profiles/upsert.ts b/src/commands/campaign/profiles/upsert.ts new file mode 100644 index 00000000..a83cec8d --- /dev/null +++ b/src/commands/campaign/profiles/upsert.ts @@ -0,0 +1,40 @@ +import {Args, Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {AudienceProfilesResource} from '../../../sdk/audience-profiles.js' +import { + profileResult, + renderProfile, +} from '../../../services/audience-profiles.js' +import {readJsonInput} from '../../../api/request.js' +import {profileInput} from '../../../services/audience-profiles.js' +export default class ProfilesUpsert extends BaseCommand { + static override args = { + phone: Args.string({ + required: true, + description: 'E.164 phone including +.', + }), + } + static override description = + 'Create or replace a profile from JSON. tags and attributes are required; use expectedRevision from the latest read to replace an existing profile. Requires campaigns:manage and audience profiles beta enrollment.' + static override flags = { + input: Flags.string({ + required: true, + description: + 'JSON file or stdin (-): tags, attributes, optional expectedRevision, provenance and consent.', + }), + } + async run(): Promise { + const {args, flags} = await this.parse(ProfilesUpsert) + const body = profileInput(await readJsonInput(flags.input)) + const resource = new AudienceProfilesResource( + (await this.platformClient(flags)).raw, + ) + + const response = await resource.put(args.phone, body) + this.writeValue( + profileResult(response), + renderProfile(response.data.data), + flags, + ) + } +} diff --git a/src/sdk/audience-profiles.ts b/src/sdk/audience-profiles.ts new file mode 100644 index 00000000..afac9b45 --- /dev/null +++ b/src/sdk/audience-profiles.ts @@ -0,0 +1,361 @@ +import { + PolymorfaValidationError, + type RawClient, + type ApiResponse, + type RequestOptions, +} from '@polymorfa/sdk' +// Temporary contract adapter matching polymorfa/sdks audience-profile PR. +// Replace with the published audienceProfiles resource after its prerelease. +interface DataEnvelope { + readonly data: T +} +interface CursorEnvelope { + readonly data: readonly T[] + readonly page: {readonly nextCursor: string | null; readonly hasMore: boolean} +} + +/** + * Customer-supplied campaign audience profiles (`campaigns.audience-profiles` + * beta). Profiles belong to the team and are keyed by E.164 phone number. + * Reads, import polling, import cancellation and deletion need + * `campaigns:read` for reads, or `campaigns:manage` for cancellation and deletion; + * creating, updating and starting an + * import also need the team to be enrolled in the beta. Project tokens cannot + * access team profiles. + */ + +export type AudienceProfileSource = 'api' | 'csv' | 'form' +export type AudienceProfileAttributeValue = string | number | boolean | null + +export interface AudienceProfileProvenance { + readonly source: AudienceProfileSource + /** 1 to 128 UTF-8 bytes. */ + readonly reference: string +} + +export interface AudienceProfile { + readonly id: string + /** E.164, for example `+14155550123`. */ + readonly phone: string + /** Send this as `expectedRevision` to update or delete the profile. */ + readonly revision: number + readonly tags: readonly string[] + readonly attributes: Readonly> + readonly provenance: AudienceProfileProvenance | null + /** Unix milliseconds. */ + readonly createdAt: number + /** Unix milliseconds. */ + readonly updatedAt: number +} + +/** A consent assertion you supply. It never clears an opt-out. */ +export interface AudienceProfileConsentInput { + readonly purpose: 'campaigns' + readonly source: AudienceProfileSource + /** Reference to your evidence, 1 to 128 UTF-8 bytes. */ + readonly reference: string + readonly allowed: boolean + /** Unix milliseconds. */ + readonly assertedAt: number +} + +export interface AudienceProfileConsent extends AudienceProfileConsentInput { + readonly id: string + /** The profile revision this assertion was recorded with. */ + readonly profileRevision: number + /** Unix milliseconds. */ + readonly createdAt: number +} + +export interface PutAudienceProfileRequest { + /** + * Omit only when creating a profile. Required to replace an existing profile + * and to recreate one deleted in the last 30 days (use the revision the + * delete returned). + */ + readonly expectedRevision?: number + /** Up to 20 distinct tags of 1 to 48 UTF-8 bytes. Replaces stored tags. */ + readonly tags: readonly string[] + /** + * Up to 20 attributes, names matching `^[A-Za-z_][A-Za-z0-9_]{0,47}$`, at + * most 8 KiB in total. Replaces stored attributes. + */ + readonly attributes: Readonly> + /** Replaces stored provenance; omit to clear it. */ + readonly provenance?: AudienceProfileProvenance + /** Appends a consent assertion to the profile's history. */ + readonly consent?: AudienceProfileConsentInput +} + +export interface DeleteAudienceProfileResult { + readonly deleted: true + readonly phone: string + /** Revision of the retained deletion record; send it to recreate within 30 days. */ + readonly revision: number +} + +export interface ListAudienceProfilesParams { + /** `page.nextCursor` from the previous page. */ + readonly cursor?: string + /** Exact, case-sensitive tag. 1-48 UTF-8 bytes without control characters. */ + readonly tag?: string + /** `+` followed by 1-15 digits. Combined with tag before pagination. */ + readonly phonePrefix?: string + /** Profiles per page, 1 to 100. Defaults to 25. */ + readonly limit?: number +} + +export interface ListAudienceProfileConsentParams { + readonly cursor?: string + readonly limit?: number +} + +export type AudienceProfileImportField = + | 'phone' + | 'tags' + | 'attributes' + | 'expectedRevision' + | 'provenanceReference' + | 'consentAllowed' + | 'consentAssertedAt' + | 'consentReference' + +/** Profile fields mapped to exact CSV header names; unmapped columns are ignored. */ +export type AudienceProfileImportMapping = { + readonly phone: string + readonly tags: string + readonly attributes: string +} & Partial< + Readonly< + Record< + Exclude, + string + > + > +> + +interface CreateAudienceProfileImportBase { + /** + * 1 to 128 of `A-Z a-z 0-9 . _ : -`. Repeating a key with the same file, + * mapping and credential returns the existing job. + */ + readonly replayKey: string + readonly mapping?: AudienceProfileImportMapping +} + +/** A UTF-8 CSV of at most 10 MiB and 10,000 data rows. */ +export type CreateAudienceProfileImportRequest = + CreateAudienceProfileImportBase & + ( + | { + /** CSV text or bytes; the SDK encodes it as padded base64. */ + readonly csv: string | Uint8Array + readonly csvBase64?: never + } + | { + /** The CSV already encoded as standard padded base64. */ + readonly csvBase64: string + readonly csv?: never + } + ) + +export type AudienceProfileImportState = 'queued' | 'completed' | 'cancelled' + +export interface AudienceProfileImportRowSample { + /** CSV row number; the header is row 1. */ + readonly row: number + /** For example `invalid_phone`, `invalid_profile` or `duplicate_phone`. */ + readonly reason: string +} + +export interface AudienceProfileImportConflictSample { + readonly row: number + readonly reason: 'quota' | 'revision_conflict' +} + +/** Aggregate import report; it never contains submitted phones or values. */ +export interface AudienceProfileImport { + readonly id: string + readonly state: AudienceProfileImportState + /** Rows that passed validation and will be written. */ + readonly totalValid: number + readonly invalidCount: number + /** Up to 20 refused rows. */ + readonly invalidSamples: readonly AudienceProfileImportRowSample[] + /** Up to 20 valid rows not written. */ + readonly conflictSamples: readonly AudienceProfileImportConflictSample[] + /** Valid rows written so far. */ + readonly processedValid: number + readonly createdCount: number + readonly updatedCount: number + readonly conflictCount: number + readonly createdAt: number + readonly updatedAt: number + readonly completedAt: number | null +} + +export type AudienceProfilesEnvelope = CursorEnvelope +export type AudienceProfileConsentEnvelope = + CursorEnvelope + +const PROFILES = '/platform/audience-profiles' +const IMPORTS = '/platform/audience-profile-imports' + +export class AudienceProfilesResource { + constructor(private readonly transport: RawClient) {} + + /** One cursor page of the team's profiles. */ + list( + params: ListAudienceProfilesParams = {}, + options: RequestOptions = {}, + ): Promise> { + return this.transport.request({ + method: 'GET', + path: PROFILES, + query: { + ...(params.cursor === undefined ? {} : {cursor: params.cursor}), + ...(params.limit === undefined ? {} : {limit: params.limit}), + ...(params.tag === undefined ? {} : {tag: params.tag}), + ...(params.phonePrefix === undefined + ? {} + : {phonePrefix: params.phonePrefix}), + }, + ...options, + }) + } + + retrieve( + phone: string, + options: RequestOptions = {}, + ): Promise>> { + return this.transport.request({ + method: 'GET', + path: profilePath(phone), + ...options, + }) + } + + /** + * Create or replace a profile. A stale or missing `expectedRevision` for an + * existing profile returns 409; read it again before retrying. + */ + put( + phone: string, + body: PutAudienceProfileRequest, + options: RequestOptions = {}, + ): Promise>> { + return this.transport.request({ + method: 'PUT', + path: profilePath(phone), + body, + ...options, + }) + } + + /** + * Delete a profile at its current revision. Removes its tags, attributes, + * consent assertions and audience memberships; opt-outs are kept. + */ + delete( + phone: string, + expectedRevision: number, + options: RequestOptions = {}, + ): Promise>> { + if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 1) + throw invalid('expectedRevision must be a positive integer') + return this.transport.request({ + method: 'DELETE', + path: profilePath(phone), + query: {expectedRevision}, + ...options, + }) + } + + /** One cursor page of a profile's consent assertions, oldest first. */ + listConsent( + phone: string, + params: ListAudienceProfileConsentParams = {}, + options: RequestOptions = {}, + ): Promise> { + return this.transport.request({ + method: 'GET', + path: `${profilePath(phone)}/consent`, + query: { + ...(params.cursor === undefined ? {} : {cursor: params.cursor}), + ...(params.limit === undefined ? {} : {limit: params.limit}), + }, + ...options, + }) + } + + /** + * Queue a CSV import. Only one import per team can be queued or running. + * Poll {@link retrieveImport} until `state` is no longer `queued`. + */ + createImport( + request: CreateAudienceProfileImportRequest, + options: RequestOptions = {}, + ): Promise>> { + const csvBase64 = + request.csvBase64 ?? encodeBase64(request.csv as string | Uint8Array) + return this.transport.request({ + method: 'POST', + path: IMPORTS, + body: { + replayKey: request.replayKey, + csvBase64, + ...(request.mapping === undefined ? {} : {mapping: request.mapping}), + }, + ...options, + }) + } + + retrieveImport( + jobId: string, + options: RequestOptions = {}, + ): Promise>> { + return this.transport.request({ + method: 'GET', + path: importPath(jobId), + ...options, + }) + } + + /** Cancel the remaining rows. Batches already written stay. */ + cancelImport( + jobId: string, + options: RequestOptions = {}, + ): Promise>> { + return this.transport.request({ + method: 'DELETE', + path: importPath(jobId), + ...options, + }) + } +} + +function invalid(message: string): PolymorfaValidationError { + return new PolymorfaValidationError(message, {code: 'invalid_parameter'}) +} + +function profilePath(phone: string): string { + if (!/^\+[1-9][0-9]{6,14}$/.test(phone)) + throw invalid('phone must be E.164, for example +14155550123') + return `${PROFILES}/${encodeURIComponent(phone)}` +} + +function importPath(jobId: string): string { + if (!jobId) throw invalid('jobId is required') + return `${IMPORTS}/${encodeURIComponent(jobId)}` +} + +/** Standard padded base64 without relying on Node's Buffer. */ +export function encodeBase64(input: string | Uint8Array): string { + const bytes = + typeof input === 'string' ? new TextEncoder().encode(input) : input + let binary = '' + for (let index = 0; index < bytes.length; index += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000)) + } + return btoa(binary) +} diff --git a/src/services/audience-profiles.ts b/src/services/audience-profiles.ts new file mode 100644 index 00000000..af3e0d00 --- /dev/null +++ b/src/services/audience-profiles.ts @@ -0,0 +1,97 @@ +import {PolymorfaValidationError, type ApiResponse} from '@polymorfa/sdk' +import {terminalText} from '../developer/render.js' +import type { + AudienceProfile, + AudienceProfilesEnvelope, + AudienceProfileImport, + PutAudienceProfileRequest, + AudienceProfileImportMapping, +} from '../sdk/audience-profiles.js' + +export function profileResult(response: ApiResponse) { + return { + data: response.data, + metadata: { + status: response.metadata.status, + attempts: response.metadata.attempts, + ...(response.metadata.requestId === undefined + ? {} + : {requestId: response.metadata.requestId}), + ...(response.metadata.apiVersion === undefined + ? {} + : {apiVersion: response.metadata.apiVersion}), + }, + } +} +export function renderProfile(profile: AudienceProfile): string { + return [ + ['Phone', profile.phone], + ['Revision', profile.revision], + ['Tags', profile.tags.join(', ')], + ['Attributes', JSON.stringify(profile.attributes)], + ] + .map(([key, value]) => `${key}\t${terminalText(value!)}`) + .join('\n') +} +export function renderProfilePage(page: AudienceProfilesEnvelope): string { + const rows = page.data.map( + (p) => + `${terminalText(p.phone)}\t${p.revision}\t${terminalText(p.tags.join(', '))}`, + ) + if (rows.length === 0) rows.push('No profiles found.') + if (page.page.hasMore && page.page.nextCursor !== null) + rows.push( + `Continue with --cursor ${terminalText(page.page.nextCursor)} and the same filters.`, + ) + return rows.join('\n') +} +export function renderImport(job: AudienceProfileImport): string { + return [ + ['ID', job.id], + ['State', job.state], + ['Valid rows', job.totalValid], + ['Processed', job.processedValid], + ['Created', job.createdCount], + ['Updated', job.updatedCount], + ['Invalid', job.invalidCount], + ['Conflicts', job.conflictCount], + ['Invalid samples', JSON.stringify(job.invalidSamples)], + ['Conflict samples', JSON.stringify(job.conflictSamples)], + ] + .map(([key, value]) => `${key}\t${terminalText(value!)}`) + .join('\n') +} +function record(input: unknown): Record { + if (typeof input !== 'object' || input === null || Array.isArray(input)) + throw new PolymorfaValidationError('Input must be a JSON object.', { + code: 'invalid_parameter', + }) + return input as Record +} +export function profileInput(input: unknown): PutAudienceProfileRequest { + const body = record(input) + if ( + !Array.isArray(body.tags) || + typeof body.attributes !== 'object' || + body.attributes === null || + Array.isArray(body.attributes) + ) + throw new PolymorfaValidationError( + 'tags (array) and attributes (object) are required.', + {code: 'invalid_parameter'}, + ) + return body as unknown as PutAudienceProfileRequest +} +export function importMapping(input: unknown): AudienceProfileImportMapping { + const mapping = record(input) + if ( + ['phone', 'tags', 'attributes'].some( + (key) => typeof mapping[key] !== 'string' || mapping[key] === '', + ) + ) + throw new PolymorfaValidationError( + 'Mapping must name phone, tags and attributes columns.', + {code: 'invalid_parameter'}, + ) + return mapping as AudienceProfileImportMapping +} diff --git a/test/commands/audience-profiles.test.ts b/test/commands/audience-profiles.test.ts new file mode 100644 index 00000000..956917ef --- /dev/null +++ b/test/commands/audience-profiles.test.ts @@ -0,0 +1,218 @@ +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, describe, expect, test} from 'vitest' +import { + startHttpServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' + +const phone = '+14155550123' +const profile = { + id: 'profile-1', + phone, + revision: 2, + tags: ['vip'], + attributes: {name: 'Ada'}, + provenance: null, + createdAt: 1, + updatedAt: 2, +} +const job = { + id: 'job-1', + state: 'queued', + totalValid: 1, + invalidCount: 0, + invalidSamples: [], + conflictSamples: [], + processedValid: 0, + createdCount: 0, + updatedCount: 0, + conflictCount: 0, + createdAt: 1, + updatedAt: 1, + completedAt: null, +} +async function testDirectory(prefix: string) { + const parent = join( + homedir(), + '.polymorfa-agent-work', + 'audience-profiles', + 'cli-tests', + ) + await mkdir(parent, {recursive: true}) + return mkdtemp(join(parent, prefix)) +} +describe('campaign profiles commands', () => { + 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 + }) + function run(command: string, env: Record = {}) { + return withEnvironment( + { + POLYMORFA_API_KEY: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_BASE_URL: server!.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + ...env, + }, + () => runCommand(command), + ) + } + test('combines list filters and cursor, escaping terminal control characters', async () => { + server = await startHttpServer({ + body: { + data: [{...profile, tags: ['vip\u001b[2J']}], + page: {nextCursor: 'next', hasMore: true}, + }, + }) + const result = await run( + 'campaign profiles list --tag vip --phone-prefix +44 --limit 2 --cursor prev', + ) + expect(result.error).toBeUndefined() + const url = new URL(server.requests[0]!.url, 'https://example.com') + expect(url.pathname).toBe('/platform/audience-profiles') + expect(Object.fromEntries(url.searchParams)).toEqual({ + tag: 'vip', + phonePrefix: '+44', + limit: '2', + cursor: 'prev', + }) + expect(server.requests[0]!.url).toContain('%2B44') + expect(result.stdout).toContain('\\u001b[2J') + expect(result.stdout).toContain('--cursor next') + }) + test('preserves list envelope and pagination in JSON output', async () => { + const envelope = {data: [profile], page: {nextCursor: null, hasMore: false}} + server = await startHttpServer({body: envelope}) + const result = await run('campaign profiles list --json') + expect(result.error).toBeUndefined() + expect(JSON.parse(result.stdout).data).toEqual(envelope) + }) + test('retrieves an encoded phone', async () => { + server = await startHttpServer({body: {data: profile}}) + const result = await run(`campaign profiles get ${phone}`) + expect(result.error).toBeUndefined() + expect(server.requests[0]!.url).toBe( + '/platform/audience-profiles/%2B14155550123', + ) + expect(result.stdout).toContain('Revision\t2') + }) + test('upserts with replacement revision, tags, attributes and consent unchanged', async () => { + directory = await testDirectory('cli-profile-') + const input = join(directory, 'profile.json') + const body = { + expectedRevision: 2, + tags: [], + attributes: {active: false}, + consent: { + purpose: 'campaigns', + source: 'form', + reference: 'signup-1', + allowed: true, + assertedAt: 1, + }, + } + await writeFile(input, JSON.stringify(body)) + server = await startHttpServer({body: {data: profile}}) + const result = await run( + `campaign profiles upsert ${phone} --input ${input}`, + ) + expect(result.error).toBeUndefined() + expect(server.requests[0]!.method).toBe('PUT') + expect(JSON.parse(server.requests[0]!.body)).toEqual(body) + }) + test('deletes with required revision', async () => { + server = await startHttpServer({ + body: {data: {deleted: true, phone, revision: 3}}, + }) + const result = await run( + `campaign profiles delete ${phone} --expected-revision 2`, + ) + expect(result.error).toBeUndefined() + expect(server.requests[0]!.method).toBe('DELETE') + expect(server.requests[0]!.url).toContain('expectedRevision=2') + expect(result.stdout).toContain('retained revision 3') + }) + test.each([ + `campaign profiles delete ${phone}`, + `campaign profiles delete ${phone} --expected-revision 0`, + 'campaign profiles get 4155550123', + 'campaign profiles list --limit 101', + ])('refuses invalid inputs: %s', async (command) => { + server = await startHttpServer({body: {data: profile}}) + expect((await run(command)).error).toBeDefined() + expect(server.requests).toHaveLength(0) + }) + test('lists consent without profile-list filters', async () => { + server = await startHttpServer({ + body: {data: [], page: {nextCursor: null, hasMore: false}}, + }) + const result = await run( + `campaign profiles consent ${phone} --limit 2 --cursor next`, + ) + expect(result.error).toBeUndefined() + expect(server.requests[0]!.url).toBe( + '/platform/audience-profiles/%2B14155550123/consent?cursor=next&limit=2', + ) + }) + test('imports UTF-8 CSV with explicit mapping and stable replay identity', async () => { + directory = await testDirectory('cli-import-') + const csv = join(directory, 'contacts.csv'), + mappingFile = join(directory, 'mapping.json') + const text = 'Phone,Tags,Attributes\n+14155550123,vip,é\n', + mapping = {phone: 'Phone', tags: 'Tags', attributes: 'Attributes'} + await writeFile(csv, text) + await writeFile(mappingFile, JSON.stringify(mapping)) + server = await startHttpServer({body: {data: job}}) + const result = await run( + `campaign profiles import --csv ${csv} --mapping ${mappingFile} --replay-key import-1`, + ) + expect(result.error).toBeUndefined() + expect(server.requests[0]!.url).toBe('/platform/audience-profile-imports') + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + replayKey: 'import-1', + csvBase64: Buffer.from(text).toString('base64'), + mapping, + }) + expect(result.stdout).toContain('State\tqueued') + }) + test.each([ + ['get', 'GET'], + ['cancel', 'DELETE'], + ])('supports import %s', async (command, method) => { + server = await startHttpServer({body: {data: job}}) + const result = await run(`campaign profiles imports ${command} job-1`) + expect(result.error).toBeUndefined() + expect(server.requests[0]!.method).toBe(method) + expect(server.requests[0]!.url).toBe( + '/platform/audience-profile-imports/job-1', + ) + }) + test.each([ + [403, 'feature_unavailable'], + [409, 'conflict'], + ])('preserves %s API failures', async (status, code) => { + server = await startHttpServer({ + status: status as number, + body: {error: {code, message: 'Denied'}}, + }) + expect((await run(`campaign profiles get ${phone}`)).error).toBeDefined() + }) + test('rejects client tokens before accessing team profiles', async () => { + server = await startHttpServer({body: {data: profile}}) + const result = await run('campaign profiles list', { + POLYMORFA_API_KEY: undefined, + POLYMORFA_CLIENT_TOKEN: 'client-token', + }) + expect(result.error).toBeDefined() + expect(server.requests).toHaveLength(0) + }) +}) From 22c43954dc2333b45e1cd204f92555fe98eaeaee Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Fri, 16 Oct 2026 16:41:14 +0300 Subject: [PATCH 2/4] Fix audience profile command lint errors without changing SDK pin --- src/commands/campaign/profiles/imports/get.ts | 1 - src/sdk/audience-profiles.ts | 3 +-- test/commands/audience-profiles.test.ts | 6 ++++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/commands/campaign/profiles/imports/get.ts b/src/commands/campaign/profiles/imports/get.ts index 40bbfed5..c6d37698 100644 --- a/src/commands/campaign/profiles/imports/get.ts +++ b/src/commands/campaign/profiles/imports/get.ts @@ -3,7 +3,6 @@ import {BaseCommand} from '../../../../base-command.js' import {AudienceProfilesResource} from '../../../../sdk/audience-profiles.js' import { profileResult, - renderProfile, renderImport, } from '../../../../services/audience-profiles.js' export default class ProfileImportsGet extends BaseCommand { diff --git a/src/sdk/audience-profiles.ts b/src/sdk/audience-profiles.ts index afac9b45..8c81a215 100644 --- a/src/sdk/audience-profiles.ts +++ b/src/sdk/audience-profiles.ts @@ -296,8 +296,7 @@ export class AudienceProfilesResource { request: CreateAudienceProfileImportRequest, options: RequestOptions = {}, ): Promise>> { - const csvBase64 = - request.csvBase64 ?? encodeBase64(request.csv as string | Uint8Array) + const csvBase64 = request.csvBase64 ?? encodeBase64(request.csv) return this.transport.request({ method: 'POST', path: IMPORTS, diff --git a/test/commands/audience-profiles.test.ts b/test/commands/audience-profiles.test.ts index 956917ef..66873706 100644 --- a/test/commands/audience-profiles.test.ts +++ b/test/commands/audience-profiles.test.ts @@ -94,7 +94,9 @@ describe('campaign profiles commands', () => { server = await startHttpServer({body: envelope}) const result = await run('campaign profiles list --json') expect(result.error).toBeUndefined() - expect(JSON.parse(result.stdout).data).toEqual(envelope) + expect((JSON.parse(result.stdout) as {data: unknown}).data).toEqual( + envelope, + ) }) test('retrieves an encoded phone', async () => { server = await startHttpServer({body: {data: profile}}) @@ -201,7 +203,7 @@ describe('campaign profiles commands', () => { [409, 'conflict'], ])('preserves %s API failures', async (status, code) => { server = await startHttpServer({ - status: status as number, + status, body: {error: {code, message: 'Denied'}}, }) expect((await run(`campaign profiles get ${phone}`)).error).toBeDefined() From a2652bd6a6f9cbc7fd61f667f3ea3cb317c5950e Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 21 Oct 2026 10:38:27 +0300 Subject: [PATCH 3/4] fix: patch MCP dependency to pass production security audit --- package-lock.json | 18 ++++++++++-------- package.json | 2 +- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2da282d3..d4c1a04c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0-dev.0", "license": "MIT", "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", @@ -708,9 +708,10 @@ "dev": true }, "node_modules/@modelcontextprotocol/core": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", - "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.3.1.tgz", + "integrity": "sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g==", + "license": "Apache-2.0", "dependencies": { "zod": "^4.2.0" }, @@ -719,11 +720,12 @@ } }, "node_modules/@modelcontextprotocol/server": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", - "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.3.1.tgz", + "integrity": "sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==", + "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/core": "2.0.0", + "@modelcontextprotocol/core": "2.3.1", "zod": "^4.2.0" }, "engines": { diff --git a/package.json b/package.json index cc2f598c..2f926e63 100644 --- a/package.json +++ b/package.json @@ -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", From 26e5cbd358b7b820865ba178df373dff1d0ce904 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 21 Oct 2026 10:43:32 +0300 Subject: [PATCH 4/4] fix: gate unavailable audience profile filters and disable mutation retries --- README.md | 18 ++++----- src/commands/campaign/profiles/list.ts | 18 +++++---- src/sdk/audience-profiles.ts | 12 ++---- src/services/audience-profiles.ts | 4 +- test/commands/audience-profiles.test.ts | 50 +++++++++++++++++++++---- 5 files changed, 68 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 3dee5bb1..980f9a97 100644 --- a/README.md +++ b/README.md @@ -2652,10 +2652,6 @@ These commands use the pinned SDK's `analytics.get` and `analytics.metrics` methods and shared response types. A CLI release containing these commands and 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 profiles (beta) Use a team API key with `campaigns:read` for reads or `campaigns:manage` for @@ -2665,8 +2661,8 @@ remain available after withdrawal. These commands are source additions pending the matching SDK prerelease and API acceptance. ```sh -polymorfa campaign profiles list --tag vip --phone-prefix +44 --limit 50 -polymorfa campaign profiles list --tag vip --phone-prefix +44 --cursor CURSOR +polymorfa campaign profiles list --limit 50 +polymorfa campaign profiles list --cursor CURSOR polymorfa campaign profiles get +14155550123 polymorfa campaign profiles upsert +14155550123 --input profile.json polymorfa campaign profiles consent +14155550123 --limit 25 @@ -2688,8 +2684,12 @@ uses the API's standard column names. Poll the job until `completed` or `cancelled`. Cancellation leaves committed edits intact. Replay keys must be reused only with the same CSV, mapping and credential. -List filters apply together before paging. Tags match exactly (1-48 UTF-8 -bytes, without ASCII control characters); phone prefixes contain `+` followed -by 1-15 digits. Keep the same filters when using `--cursor`. `--json` preserves +`--tag` and `--phone-prefix` require API support not yet available (API #567). +They fail locally without sending a request; this CLI does not offer profile +filtering. Listing supports `--cursor` and `--limit`. `--json` preserves API envelopes and pagination. The CLI keeps its existing SDK pin and uses a local typed adapter until a matching SDK prerelease is published. + +## 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). diff --git a/src/commands/campaign/profiles/list.ts b/src/commands/campaign/profiles/list.ts index 1711eaf0..891770fa 100644 --- a/src/commands/campaign/profiles/list.ts +++ b/src/commands/campaign/profiles/list.ts @@ -1,3 +1,4 @@ +import {PolymorfaValidationError} from '@polymorfa/sdk' import {Flags} from '@oclif/core' import {BaseCommand} from '../../../base-command.js' import {AudienceProfilesResource} from '../../../sdk/audience-profiles.js' @@ -7,7 +8,7 @@ import { } from '../../../services/audience-profiles.js' export default class ProfilesList extends BaseCommand { static override description = - 'List one page of team audience profiles. Combine exact tag and phone prefix filters; keep them on subsequent pages. Requires a team key with campaigns:read.' + 'List one page of team audience profiles. Requires a team key with campaigns:read. Tag and phone prefix filters are unavailable.' static override flags = { cursor: Flags.string({description: 'Continue from this cursor.'}), limit: Flags.integer({ @@ -16,14 +17,21 @@ export default class ProfilesList extends BaseCommand { description: 'Profiles per page (default 25).', }), tag: Flags.string({ - description: 'Exact tag, 1-48 UTF-8 bytes without control characters.', + description: + 'Unavailable: requires API support not yet available (API #567).', }), 'phone-prefix': Flags.string({ - description: 'International prefix: + and 1-15 digits.', + description: + 'Unavailable: requires API support not yet available (API #567).', }), } async run(): Promise { const {flags} = await this.parse(ProfilesList) + if (flags.tag !== undefined || flags['phone-prefix'] !== undefined) + throw new PolymorfaValidationError( + '--tag and --phone-prefix require API support not yet available (API #567).', + {code: 'audience_profile_filters_unavailable'}, + ) const resource = new AudienceProfilesResource( (await this.platformClient(flags)).raw, ) @@ -31,10 +39,6 @@ export default class ProfilesList extends BaseCommand { const response = await resource.list({ ...(flags.cursor === undefined ? {} : {cursor: flags.cursor}), ...(flags.limit === undefined ? {} : {limit: flags.limit}), - ...(flags.tag === undefined ? {} : {tag: flags.tag}), - ...(flags['phone-prefix'] === undefined - ? {} - : {phonePrefix: flags['phone-prefix']}), }) this.writeValue( profileResult(response), diff --git a/src/sdk/audience-profiles.ts b/src/sdk/audience-profiles.ts index 8c81a215..7f6ff5e3 100644 --- a/src/sdk/audience-profiles.ts +++ b/src/sdk/audience-profiles.ts @@ -97,10 +97,6 @@ export interface DeleteAudienceProfileResult { export interface ListAudienceProfilesParams { /** `page.nextCursor` from the previous page. */ readonly cursor?: string - /** Exact, case-sensitive tag. 1-48 UTF-8 bytes without control characters. */ - readonly tag?: string - /** `+` followed by 1-15 digits. Combined with tag before pagination. */ - readonly phonePrefix?: string /** Profiles per page, 1 to 100. Defaults to 25. */ readonly limit?: number } @@ -215,10 +211,6 @@ export class AudienceProfilesResource { query: { ...(params.cursor === undefined ? {} : {cursor: params.cursor}), ...(params.limit === undefined ? {} : {limit: params.limit}), - ...(params.tag === undefined ? {} : {tag: params.tag}), - ...(params.phonePrefix === undefined - ? {} - : {phonePrefix: params.phonePrefix}), }, ...options, }) @@ -249,6 +241,7 @@ export class AudienceProfilesResource { path: profilePath(phone), body, ...options, + maxNetworkRetries: 0, }) } @@ -268,6 +261,7 @@ export class AudienceProfilesResource { path: profilePath(phone), query: {expectedRevision}, ...options, + maxNetworkRetries: 0, }) } @@ -306,6 +300,7 @@ export class AudienceProfilesResource { ...(request.mapping === undefined ? {} : {mapping: request.mapping}), }, ...options, + maxNetworkRetries: 0, }) } @@ -329,6 +324,7 @@ export class AudienceProfilesResource { method: 'DELETE', path: importPath(jobId), ...options, + maxNetworkRetries: 0, }) } } diff --git a/src/services/audience-profiles.ts b/src/services/audience-profiles.ts index af3e0d00..52039ea3 100644 --- a/src/services/audience-profiles.ts +++ b/src/services/audience-profiles.ts @@ -40,9 +40,7 @@ export function renderProfilePage(page: AudienceProfilesEnvelope): string { ) if (rows.length === 0) rows.push('No profiles found.') if (page.page.hasMore && page.page.nextCursor !== null) - rows.push( - `Continue with --cursor ${terminalText(page.page.nextCursor)} and the same filters.`, - ) + rows.push(`Continue with --cursor ${terminalText(page.page.nextCursor)}.`) return rows.join('\n') } export function renderImport(job: AudienceProfileImport): string { diff --git a/test/commands/audience-profiles.test.ts b/test/commands/audience-profiles.test.ts index 66873706..a04b8e49 100644 --- a/test/commands/audience-profiles.test.ts +++ b/test/commands/audience-profiles.test.ts @@ -66,29 +66,65 @@ describe('campaign profiles commands', () => { () => runCommand(command), ) } - test('combines list filters and cursor, escaping terminal control characters', async () => { + test('lists with pagination, escaping terminal control characters', async () => { server = await startHttpServer({ body: { data: [{...profile, tags: ['vip\u001b[2J']}], page: {nextCursor: 'next', hasMore: true}, }, }) - const result = await run( - 'campaign profiles list --tag vip --phone-prefix +44 --limit 2 --cursor prev', - ) + const result = await run('campaign profiles list --limit 2 --cursor prev') expect(result.error).toBeUndefined() const url = new URL(server.requests[0]!.url, 'https://example.com') expect(url.pathname).toBe('/platform/audience-profiles') expect(Object.fromEntries(url.searchParams)).toEqual({ - tag: 'vip', - phonePrefix: '+44', limit: '2', cursor: 'prev', }) - expect(server.requests[0]!.url).toContain('%2B44') expect(result.stdout).toContain('\\u001b[2J') expect(result.stdout).toContain('--cursor next') }) + test.each([ + '--tag vip', + '--phone-prefix +44', + '--tag vip --phone-prefix +44', + ])('refuses unavailable filters before HTTP: %s', async (flags) => { + server = await startHttpServer({ + body: {data: [profile], page: {hasMore: false, nextCursor: null}}, + }) + const result = await run(`campaign profiles list ${flags} --json`) + expect(result.error).toBeDefined() + expect(result.stdout + result.stderr).toContain( + 'API support not yet available', + ) + expect(server.requests).toHaveLength(0) + }) + test.each(['upsert', 'delete', 'import', 'cancel'])( + 'does not retry transient %s failures', + async (operation) => { + directory = await testDirectory('cli-profile-no-retry-') + const bodyFile = join(directory, 'profile.json') + const csv = join(directory, 'contacts.csv') + await writeFile( + bodyFile, + JSON.stringify({expectedRevision: 2, tags: [], attributes: {}}), + ) + await writeFile(csv, 'phone,tags,attributes\n') + const commands = { + upsert: `campaign profiles upsert ${phone} --input ${bodyFile}`, + delete: `campaign profiles delete ${phone} --expected-revision 2`, + import: `campaign profiles import --csv ${csv} --replay-key no-retry`, + cancel: 'campaign profiles imports cancel job-1', + } + server = await startHttpServer({ + status: 503, + body: {error: {code: 'unavailable', message: 'Try later'}}, + }) + const result = await run(commands[operation as keyof typeof commands]) + expect(result.error).toBeDefined() + expect(server.requests).toHaveLength(1) + }, + ) test('preserves list envelope and pagination in JSON output', async () => { const envelope = {data: [profile], page: {nextCursor: null, hasMore: false}} server = await startHttpServer({body: envelope})