diff --git a/README.md b/README.md index a6f427dd..980f9a97 100644 --- a/README.md +++ b/README.md @@ -2652,6 +2652,44 @@ 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. +### 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 --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 +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. + +`--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/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 97f2c5b9..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", @@ -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..c6d37698 --- /dev/null +++ b/src/commands/campaign/profiles/imports/get.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 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..891770fa --- /dev/null +++ b/src/commands/campaign/profiles/list.ts @@ -0,0 +1,49 @@ +import {PolymorfaValidationError} from '@polymorfa/sdk' +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. 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({ + min: 1, + max: 100, + description: 'Profiles per page (default 25).', + }), + tag: Flags.string({ + description: + 'Unavailable: requires API support not yet available (API #567).', + }), + 'phone-prefix': Flags.string({ + 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, + ) + + const response = await resource.list({ + ...(flags.cursor === undefined ? {} : {cursor: flags.cursor}), + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + }) + 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..7f6ff5e3 --- /dev/null +++ b/src/sdk/audience-profiles.ts @@ -0,0 +1,356 @@ +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 + /** 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}), + }, + ...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, + maxNetworkRetries: 0, + }) + } + + /** + * 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, + maxNetworkRetries: 0, + }) + } + + /** 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) + return this.transport.request({ + method: 'POST', + path: IMPORTS, + body: { + replayKey: request.replayKey, + csvBase64, + ...(request.mapping === undefined ? {} : {mapping: request.mapping}), + }, + ...options, + maxNetworkRetries: 0, + }) + } + + 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, + maxNetworkRetries: 0, + }) + } +} + +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..52039ea3 --- /dev/null +++ b/src/services/audience-profiles.ts @@ -0,0 +1,95 @@ +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)}.`) + 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..a04b8e49 --- /dev/null +++ b/test/commands/audience-profiles.test.ts @@ -0,0 +1,256 @@ +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('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 --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({ + limit: '2', + cursor: 'prev', + }) + 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}) + const result = await run('campaign profiles list --json') + expect(result.error).toBeUndefined() + expect((JSON.parse(result.stdout) as {data: unknown}).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, + 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) + }) +})