From bca9b396ca5280cb063355449fed3c544a391f3b Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Fri, 4 Sep 2026 18:08:00 +0300 Subject: [PATCH] feat(customers): add complete CLI workflow --- README.md | 35 ++ docs/command-research/customers.md | 27 ++ package-lock.json | 6 +- package.json | 14 +- src/commands/customer/archive.ts | 38 ++ src/commands/customer/create.ts | 41 +++ src/commands/customer/enable.ts | 33 ++ src/commands/customer/event/list.ts | 37 ++ src/commands/customer/list.ts | 63 ++++ src/commands/customer/number/list.ts | 26 ++ src/commands/customer/number/transfer.ts | 57 +++ src/commands/customer/pairing-link/create.ts | 72 ++++ src/commands/customer/pairing-link/list.ts | 26 ++ src/commands/customer/pairing-link/revoke.ts | 34 ++ src/commands/customer/restore.ts | 34 ++ src/commands/customer/status.ts | 20 ++ src/commands/customer/update.ts | 62 ++++ src/commands/customer/view.ts | 26 ++ src/services/customers.ts | 298 ++++++++++++++++ test/commands/customers.test.ts | 357 +++++++++++++++++++ 20 files changed, 1302 insertions(+), 4 deletions(-) create mode 100644 docs/command-research/customers.md create mode 100644 src/commands/customer/archive.ts create mode 100644 src/commands/customer/create.ts create mode 100644 src/commands/customer/enable.ts create mode 100644 src/commands/customer/event/list.ts create mode 100644 src/commands/customer/list.ts create mode 100644 src/commands/customer/number/list.ts create mode 100644 src/commands/customer/number/transfer.ts create mode 100644 src/commands/customer/pairing-link/create.ts create mode 100644 src/commands/customer/pairing-link/list.ts create mode 100644 src/commands/customer/pairing-link/revoke.ts create mode 100644 src/commands/customer/restore.ts create mode 100644 src/commands/customer/status.ts create mode 100644 src/commands/customer/update.ts create mode 100644 src/commands/customer/view.ts create mode 100644 src/services/customers.ts create mode 100644 test/commands/customers.test.ts diff --git a/README.md b/README.md index 1b7d8381..6c60d690 100644 --- a/README.md +++ b/README.md @@ -59,6 +59,41 @@ in this development slice and does not yet claim remote identity validation. Environment credentials take precedence over the active profile for automation. An explicit `--profile ` takes precedence over environment credentials. +## Customers + +Manage Customers through the Platform API with an organization server key and +an explicit project ID: + +```shell +polymorfa customer status --project project_123 +polymorfa customer enable \ + --project project_123 \ + --idempotency-key enable-customers-project-123 + +polymorfa customer create \ + --project project_123 \ + --name Ada \ + --phone +15551234567 \ + --external-id crm_456 \ + --idempotency-key create-customer-crm-456 + +polymorfa customer pairing-link create customer_123 \ + --project project_123 \ + --method qr \ + --method phone \ + --idempotency-key pair-customer-123 +``` + +The `customer` commands cover project enablement, list filters and cursors, +profile creation and updates, archive and restore, assigned Numbers, recent +events, pairing-link creation/listing/revocation, and confirmed Number +transfers. Mutations that require retry identity also require +`--idempotency-key`. Archive, pairing-link revocation, and Number transfer +require explicit confirmation with `--yes`. + +Pairing-link creation prints the one-time URL in human output. Store it when it +is returned. An idempotent replay returns the link record without the URL. + ## Groups The first curated Messaging resource commands are read-only and require an diff --git a/docs/command-research/customers.md b/docs/command-research/customers.md new file mode 100644 index 00000000..2c58aef6 --- /dev/null +++ b/docs/command-research/customers.md @@ -0,0 +1,27 @@ +# Customer command contract + +The Customer command family maps the complete public Customers surface in the +Platform contract. Every request uses an organization server API key and an +explicit project ID. + +| Command | Platform operation | +| ------------------------------ | -------------------------------------------------------------------- | +| `customer status` | Check project enablement | +| `customer enable` | Enable Customers and assign existing Numbers to the default Customer | +| `customer list` | List and filter Customer summaries with cursor metadata | +| `customer create` | Create a Customer with optional name, phone, and external ID | +| `customer view` | Retrieve one Customer | +| `customer update` | Update or clear Customer profile fields | +| `customer archive` | Archive a non-default Customer | +| `customer restore` | Restore an archived Customer | +| `customer number list` | List assigned Numbers | +| `customer event list` | List recent lifecycle events | +| `customer pairing-link create` | Create a one-time pairing URL | +| `customer pairing-link list` | List pairing-link records | +| `customer pairing-link revoke` | Revoke a pairing link | +| `customer number transfer` | Transfer a Number from a confirmed source Customer | + +The CLI preserves `Idempotency-Key` headers for every operation that requires +them. It also keeps list cursor metadata in JSON output. Human output masks +phone numbers returned by list and Number endpoints; full Customer retrieval +returns the API response unchanged under `--json`. diff --git a/package-lock.json b/package-lock.json index 7471c584..57cc7b69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,7 +11,7 @@ "dependencies": { "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#b4d5f3c8ce51bb0c171264eec8961797314cb172" + "@polymorfa/sdk": "github:polymorfa/sdks#bf5969e68eda2c0cf5541076bd2c55eb51f09f13" }, "bin": { "polymorfa": "bin/run.js" @@ -972,8 +972,8 @@ }, "node_modules/@polymorfa/sdk": { "version": "0.1.0-dev.0", - "resolved": "git+ssh://git@github.com/polymorfa/sdks.git#b4d5f3c8ce51bb0c171264eec8961797314cb172", - "integrity": "sha512-jbQbc1V7dkciqbo3cMaod7J7VpCYD5v9SJkZ3pVtLmCX3WRv9dESeBf3LRbzC6n32QPGGgmpDtdp0ag6kUFC6Q==", + "resolved": "git+ssh://git@github.com/polymorfa/sdks.git#bf5969e68eda2c0cf5541076bd2c55eb51f09f13", + "integrity": "sha512-aF4esaJcCTdJkn3p7qfKVJjEFKNNURrZl/p3ovnmWYbXxEfTu/mjDNV//resa1eYjBhiXxDYwDitPgDpAVRJhQ==", "workspaces": [ "packages/browser", "packages/ui", diff --git a/package.json b/package.json index f2eadab0..cbcb6beb 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "dependencies": { "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#b4d5f3c8ce51bb0c171264eec8961797314cb172" + "@polymorfa/sdk": "github:polymorfa/sdks#bf5969e68eda2c0cf5541076bd2c55eb51f09f13" }, "devDependencies": { "@eslint/js": "^9.39.5", @@ -109,6 +109,18 @@ "contact": { "description": "Inspect contacts and manage the contact blocklist." }, + "customer": { + "description": "Manage project Customers, pairing links, and assigned Numbers." + }, + "customer:event": { + "description": "Inspect Customer lifecycle events." + }, + "customer:number": { + "description": "Inspect and transfer Customer Numbers." + }, + "customer:pairing-link": { + "description": "Create, inspect, and revoke Customer pairing links." + }, "group": { "description": "Inspect and manage Messaging groups." }, diff --git a/src/commands/customer/archive.ts b/src/commands/customer/archive.ts new file mode 100644 index 00000000..aa161083 --- /dev/null +++ b/src/commands/customer/archive.ts @@ -0,0 +1,38 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import { + archiveCustomer, + renderCustomer, + requireCustomerProject, +} from '../../services/customers.js' + +export default class CustomerArchive extends BaseCommand { + static override args = { + customer: Args.string({description: 'Customer ID.', required: true}), + } + + static override description = 'Archive a non-default Customer.' + + static override flags = { + 'idempotency-key': Flags.string({ + description: 'Identify this archive attempt for safe retries.', + required: true, + }), + yes: Flags.boolean({ + description: 'Confirm Customer archival.', + required: true, + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(CustomerArchive) + const result = await archiveCustomer( + await this.platformClient(flags), + args.customer, + {projectId: requireCustomerProject(flags.project)}, + {idempotencyKey: flags['idempotency-key']}, + ) + this.writeValue(result, renderCustomer(result.data), flags) + } +} diff --git a/src/commands/customer/create.ts b/src/commands/customer/create.ts new file mode 100644 index 00000000..0117eb6d --- /dev/null +++ b/src/commands/customer/create.ts @@ -0,0 +1,41 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import { + createCustomer, + renderCustomer, + requireCustomerProject, +} from '../../services/customers.js' + +export default class CustomerCreate extends BaseCommand { + static override description = 'Create a Customer.' + + static override flags = { + 'external-id': Flags.string({description: 'Caller-defined Customer ID.'}), + 'idempotency-key': Flags.string({ + description: 'Identify this create attempt for safe retries.', + required: true, + }), + name: Flags.string({description: 'Customer name.'}), + phone: Flags.string({ + description: 'Customer phone number in E.164 format.', + }), + } + + async run(): Promise { + const {flags} = await this.parse(CustomerCreate) + const result = await createCustomer( + await this.platformClient(flags), + { + projectId: requireCustomerProject(flags.project), + ...(flags.name === undefined ? {} : {name: flags.name}), + ...(flags.phone === undefined ? {} : {phone: flags.phone}), + ...(flags['external-id'] === undefined + ? {} + : {externalCustomerId: flags['external-id']}), + }, + {idempotencyKey: flags['idempotency-key']}, + ) + this.writeValue(result, renderCustomer(result.data), flags) + } +} diff --git a/src/commands/customer/enable.ts b/src/commands/customer/enable.ts new file mode 100644 index 00000000..08725b3c --- /dev/null +++ b/src/commands/customer/enable.ts @@ -0,0 +1,33 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import { + enableCustomers, + requireCustomerProject, +} from '../../services/customers.js' + +export default class CustomerEnable extends BaseCommand { + static override description = + 'Enable Customers and assign existing Numbers to the default Customer.' + + static override flags = { + 'idempotency-key': Flags.string({ + description: 'Identify this enablement attempt for safe retries.', + required: true, + }), + } + + async run(): Promise { + const {flags} = await this.parse(CustomerEnable) + const result = await enableCustomers( + await this.platformClient(flags), + requireCustomerProject(flags.project), + {idempotencyKey: flags['idempotency-key']}, + ) + this.writeValue( + result, + `Enabled Customers and assigned ${result.data.migratedNumberCount} existing Numbers.`, + flags, + ) + } +} diff --git a/src/commands/customer/event/list.ts b/src/commands/customer/event/list.ts new file mode 100644 index 00000000..77267ea8 --- /dev/null +++ b/src/commands/customer/event/list.ts @@ -0,0 +1,37 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import { + listCustomerEvents, + renderCustomerEvents, + requireCustomerProject, +} from '../../../services/customers.js' + +export default class CustomerEventList extends BaseCommand { + static override args = { + customer: Args.string({description: 'Customer ID.', required: true}), + } + + static override description = 'List recent events for a Customer.' + + static override flags = { + limit: Flags.integer({ + description: 'Maximum events to return.', + max: 100, + min: 1, + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(CustomerEventList) + const result = await listCustomerEvents( + await this.platformClient(flags), + args.customer, + { + projectId: requireCustomerProject(flags.project), + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + }, + ) + this.writeValue(result, renderCustomerEvents(result.data), flags) + } +} diff --git a/src/commands/customer/list.ts b/src/commands/customer/list.ts new file mode 100644 index 00000000..133207a6 --- /dev/null +++ b/src/commands/customer/list.ts @@ -0,0 +1,63 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import { + listCustomers, + renderCustomers, + requireCustomerProject, +} from '../../services/customers.js' + +export default class CustomerList extends BaseCommand { + static override description = 'List Customers in one project.' + + static override flags = { + cursor: Flags.string({description: 'Continue from this cursor.'}), + default: Flags.string({ + description: 'Filter by default-Customer status.', + options: ['true', 'false'], + }), + 'has-numbers': Flags.string({ + description: 'Filter by whether a Customer has assigned Numbers.', + options: ['true', 'false'], + }), + limit: Flags.integer({ + description: 'Maximum Customers to return.', + max: 100, + min: 1, + }), + 'needs-attention': Flags.string({ + description: 'Filter by whether a Customer needs attention.', + options: ['true', 'false'], + }), + search: Flags.string({description: 'Search Customer profile fields.'}), + status: Flags.string({ + description: 'Filter by Customer status.', + options: ['active', 'archiving', 'archived', 'all'], + }), + } + + async run(): Promise { + const {flags} = await this.parse(CustomerList) + const result = await listCustomers(await this.platformClient(flags), { + projectId: requireCustomerProject(flags.project), + ...(flags.cursor === undefined ? {} : {cursor: flags.cursor}), + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + ...(flags.search === undefined ? {} : {search: flags.search}), + ...(flags.status === undefined + ? {} + : { + status: flags.status as 'active' | 'archiving' | 'archived' | 'all', + }), + ...(flags.default === undefined + ? {} + : {isDefault: flags.default === 'true'}), + ...(flags['has-numbers'] === undefined + ? {} + : {hasNumbers: flags['has-numbers'] === 'true'}), + ...(flags['needs-attention'] === undefined + ? {} + : {needsAttention: flags['needs-attention'] === 'true'}), + }) + this.writeValue(result, renderCustomers(result.data), flags) + } +} diff --git a/src/commands/customer/number/list.ts b/src/commands/customer/number/list.ts new file mode 100644 index 00000000..fbc8a08b --- /dev/null +++ b/src/commands/customer/number/list.ts @@ -0,0 +1,26 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import { + listCustomerNumbers, + renderCustomerNumbers, + requireCustomerProject, +} from '../../../services/customers.js' + +export default class CustomerNumberList extends BaseCommand { + static override args = { + customer: Args.string({description: 'Customer ID.', required: true}), + } + + static override description = 'List Numbers assigned to a Customer.' + + async run(): Promise { + const {args, flags} = await this.parse(CustomerNumberList) + const result = await listCustomerNumbers( + await this.platformClient(flags), + args.customer, + requireCustomerProject(flags.project), + ) + this.writeValue(result, renderCustomerNumbers(result.data), flags) + } +} diff --git a/src/commands/customer/number/transfer.ts b/src/commands/customer/number/transfer.ts new file mode 100644 index 00000000..c98a6224 --- /dev/null +++ b/src/commands/customer/number/transfer.ts @@ -0,0 +1,57 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import { + requireCustomerProject, + transferCustomerNumber, +} from '../../../services/customers.js' + +export default class CustomerNumberTransfer extends BaseCommand { + static override args = { + customer: Args.string({ + description: 'Destination Customer ID.', + required: true, + }), + session: Args.string({ + description: 'Number session ID or slug.', + required: true, + }), + } + + static override description = 'Transfer a Number between Customers.' + + static override flags = { + from: Flags.string({ + description: 'Source Customer ID.', + required: true, + }), + 'idempotency-key': Flags.string({ + description: 'Identify this transfer attempt for safe retries.', + required: true, + }), + yes: Flags.boolean({ + description: 'Confirm the Number transfer.', + required: true, + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(CustomerNumberTransfer) + const result = await transferCustomerNumber( + await this.platformClient(flags), + args.customer, + args.session, + { + confirm: true, + projectId: requireCustomerProject(flags.project), + sourceCustomerId: flags.from, + }, + {idempotencyKey: flags['idempotency-key']}, + ) + this.writeValue( + result, + `Transferred Number ${result.data.sessionId} to Customer ${args.customer}.`, + flags, + ) + } +} diff --git a/src/commands/customer/pairing-link/create.ts b/src/commands/customer/pairing-link/create.ts new file mode 100644 index 00000000..ad5402e4 --- /dev/null +++ b/src/commands/customer/pairing-link/create.ts @@ -0,0 +1,72 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import { + createCustomerPairingLink, + renderCreatedPairingLink, + requireCustomerProject, +} from '../../../services/customers.js' + +export default class CustomerPairingLinkCreate extends BaseCommand { + static override args = { + customer: Args.string({description: 'Customer ID.', required: true}), + } + + static override description = 'Create a Customer pairing link.' + + static override flags = { + 'expected-phone': Flags.string({ + description: 'Expected phone number in E.164 format.', + }), + 'expires-in': Flags.integer({ + description: 'Link lifetime in seconds.', + max: 604_800, + min: 900, + }), + 'idempotency-key': Flags.string({ + description: 'Identify this pairing-link attempt for safe retries.', + required: true, + }), + locale: Flags.string({ + description: 'Pairing page locale.', + options: ['en', 'pt-BR'], + }), + method: Flags.string({ + description: 'Allowed pairing method.', + multiple: true, + options: ['qr', 'phone'], + }), + theme: Flags.string({ + description: 'Pairing page theme.', + options: ['light', 'dark', 'system'], + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(CustomerPairingLinkCreate) + const result = await createCustomerPairingLink( + await this.platformClient(flags), + args.customer, + { + projectId: requireCustomerProject(flags.project), + ...(flags['expected-phone'] === undefined + ? {} + : {expectedPhone: flags['expected-phone']}), + ...(flags['expires-in'] === undefined + ? {} + : {expiresInSeconds: flags['expires-in']}), + ...(flags.locale === undefined + ? {} + : {locale: flags.locale as 'en' | 'pt-BR'}), + ...(flags.method === undefined + ? {} + : {methods: flags.method as Array<'qr' | 'phone'>}), + ...(flags.theme === undefined + ? {} + : {theme: flags.theme as 'light' | 'dark' | 'system'}), + }, + {idempotencyKey: flags['idempotency-key']}, + ) + this.writeValue(result, renderCreatedPairingLink(result.data), flags) + } +} diff --git a/src/commands/customer/pairing-link/list.ts b/src/commands/customer/pairing-link/list.ts new file mode 100644 index 00000000..a11f7071 --- /dev/null +++ b/src/commands/customer/pairing-link/list.ts @@ -0,0 +1,26 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import { + listCustomerPairingLinks, + renderCustomerPairingLinks, + requireCustomerProject, +} from '../../../services/customers.js' + +export default class CustomerPairingLinkList extends BaseCommand { + static override args = { + customer: Args.string({description: 'Customer ID.', required: true}), + } + + static override description = 'List pairing links for a Customer.' + + async run(): Promise { + const {args, flags} = await this.parse(CustomerPairingLinkList) + const result = await listCustomerPairingLinks( + await this.platformClient(flags), + args.customer, + requireCustomerProject(flags.project), + ) + this.writeValue(result, renderCustomerPairingLinks(result.data), flags) + } +} diff --git a/src/commands/customer/pairing-link/revoke.ts b/src/commands/customer/pairing-link/revoke.ts new file mode 100644 index 00000000..6b388d90 --- /dev/null +++ b/src/commands/customer/pairing-link/revoke.ts @@ -0,0 +1,34 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import { + requireCustomerProject, + revokeCustomerPairingLink, +} from '../../../services/customers.js' + +export default class CustomerPairingLinkRevoke extends BaseCommand { + static override args = { + customer: Args.string({description: 'Customer ID.', required: true}), + link: Args.string({description: 'Pairing-link ID.', required: true}), + } + + static override description = 'Revoke an active Customer pairing link.' + + static override flags = { + yes: Flags.boolean({ + description: 'Confirm pairing-link revocation.', + required: true, + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(CustomerPairingLinkRevoke) + const result = await revokeCustomerPairingLink( + await this.platformClient(flags), + args.customer, + args.link, + requireCustomerProject(flags.project), + ) + this.writeValue(result, `Revoked pairing link ${args.link}.`, flags) + } +} diff --git a/src/commands/customer/restore.ts b/src/commands/customer/restore.ts new file mode 100644 index 00000000..b9a6ac0d --- /dev/null +++ b/src/commands/customer/restore.ts @@ -0,0 +1,34 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import { + renderCustomer, + requireCustomerProject, + restoreCustomer, +} from '../../services/customers.js' + +export default class CustomerRestore extends BaseCommand { + static override args = { + customer: Args.string({description: 'Customer ID.', required: true}), + } + + static override description = 'Restore an archived Customer.' + + static override flags = { + 'idempotency-key': Flags.string({ + description: 'Identify this restore attempt for safe retries.', + required: true, + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(CustomerRestore) + const result = await restoreCustomer( + await this.platformClient(flags), + args.customer, + {projectId: requireCustomerProject(flags.project)}, + {idempotencyKey: flags['idempotency-key']}, + ) + this.writeValue(result, renderCustomer(result.data), flags) + } +} diff --git a/src/commands/customer/status.ts b/src/commands/customer/status.ts new file mode 100644 index 00000000..c8c276e5 --- /dev/null +++ b/src/commands/customer/status.ts @@ -0,0 +1,20 @@ +import {BaseCommand} from '../../base-command.js' +import { + customerStatus, + renderCustomerStatus, + requireCustomerProject, +} from '../../services/customers.js' + +export default class CustomerStatus extends BaseCommand { + static override description = + 'Check whether Customers is enabled for a project.' + + async run(): Promise { + const {flags} = await this.parse(CustomerStatus) + const result = await customerStatus( + await this.platformClient(flags), + requireCustomerProject(flags.project), + ) + this.writeValue(result, renderCustomerStatus(result.data), flags) + } +} diff --git a/src/commands/customer/update.ts b/src/commands/customer/update.ts new file mode 100644 index 00000000..2db3f0c7 --- /dev/null +++ b/src/commands/customer/update.ts @@ -0,0 +1,62 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import { + renderCustomer, + requireCustomerProject, + updateCustomer, +} from '../../services/customers.js' + +export default class CustomerUpdate extends BaseCommand { + static override args = { + customer: Args.string({description: 'Customer ID.', required: true}), + } + + static override description = 'Update Customer profile fields.' + + static override flags = { + 'clear-external-id': Flags.boolean({ + description: 'Clear the caller-defined Customer ID.', + exclusive: ['external-id'], + }), + 'clear-name': Flags.boolean({ + description: 'Clear the Customer name.', + exclusive: ['name'], + }), + 'clear-phone': Flags.boolean({ + description: 'Clear the Customer phone number.', + exclusive: ['phone'], + }), + 'external-id': Flags.string({ + description: 'New caller-defined Customer ID.', + }), + name: Flags.string({description: 'New Customer name.'}), + phone: Flags.string({ + description: 'New Customer phone number in E.164 format.', + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(CustomerUpdate) + const result = await updateCustomer( + await this.platformClient(flags), + args.customer, + { + projectId: requireCustomerProject(flags.project), + ...(flags.name === undefined && flags['clear-name'] !== true + ? {} + : {name: flags.name ?? null}), + ...(flags.phone === undefined && flags['clear-phone'] !== true + ? {} + : {phone: flags.phone ?? null}), + ...(flags['external-id'] === undefined && + flags['clear-external-id'] !== true + ? {} + : { + externalCustomerId: flags['external-id'] ?? null, + }), + }, + ) + this.writeValue(result, renderCustomer(result.data), flags) + } +} diff --git a/src/commands/customer/view.ts b/src/commands/customer/view.ts new file mode 100644 index 00000000..5a25e20a --- /dev/null +++ b/src/commands/customer/view.ts @@ -0,0 +1,26 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import { + renderCustomer, + requireCustomerProject, + viewCustomer, +} from '../../services/customers.js' + +export default class CustomerView extends BaseCommand { + static override args = { + customer: Args.string({description: 'Customer ID.', required: true}), + } + + static override description = 'View one Customer.' + + async run(): Promise { + const {args, flags} = await this.parse(CustomerView) + const result = await viewCustomer( + await this.platformClient(flags), + args.customer, + requireCustomerProject(flags.project), + ) + this.writeValue(result, renderCustomer(result.data), flags) + } +} diff --git a/src/services/customers.ts b/src/services/customers.ts new file mode 100644 index 00000000..204c1131 --- /dev/null +++ b/src/services/customers.ts @@ -0,0 +1,298 @@ +import { + type CreateCustomerPairingLinkRequest, + type CreateCustomerRequest, + type CreatedCustomerPairingLink, + type Customer, + type CustomerEvent, + type CustomerListEnvelope, + type CustomerNumber, + type CustomerPairingLink, + type CustomerProjectRequest, + type CustomersEnablement, + type CustomersStatus, + type ListCustomerEventsParams, + type ListCustomersParams, + type PlatformClient, + PolymorfaConfigurationError, + PolymorfaValidationError, + type RequestOptions, + type ResponseMetadata, + type TransferCustomerNumberRequest, + type UpdateCustomerRequest, +} from '@polymorfa/sdk' + +export interface CustomerResult { + readonly data: T + readonly metadata: { + readonly apiVersion?: string + readonly attempts: number + readonly requestId?: string + readonly status: number + } +} + +export function requireCustomerProject(projectId?: string): string { + if (projectId === undefined || projectId.trim().length === 0) { + throw new PolymorfaConfigurationError( + 'Provide --project with the project ID for this Customer operation.', + 'project', + ) + } + return projectId +} + +export async function customerStatus( + client: PlatformClient, + projectId: string, +): Promise> { + const response = await client.customers.status(projectId) + return result(response.data.data, response.metadata) +} + +export async function enableCustomers( + client: PlatformClient, + projectId: string, + options: RequestOptions, +): Promise> { + const response = await client.customers.enable(projectId, options) + return result(response.data.data, response.metadata) +} + +export async function listCustomers( + client: PlatformClient, + params: ListCustomersParams, +): Promise> { + const response = await client.customers.list(params) + return result(response.data, response.metadata) +} + +export async function createCustomer( + client: PlatformClient, + body: CreateCustomerRequest, + options: RequestOptions, +): Promise> { + const response = await client.customers.create(body, options) + return result(response.data.data, response.metadata) +} + +export async function viewCustomer( + client: PlatformClient, + customerId: string, + projectId: string, +): Promise> { + const response = await client.customers.retrieve(customerId, projectId) + return result(response.data.data, response.metadata) +} + +export async function updateCustomer( + client: PlatformClient, + customerId: string, + body: UpdateCustomerRequest, +): Promise> { + if ( + body.name === undefined && + body.phone === undefined && + body.externalCustomerId === undefined + ) { + throw new PolymorfaValidationError( + 'Provide at least one Customer profile field to update.', + {code: 'missing_customer_update'}, + ) + } + const response = await client.customers.update(customerId, body) + return result(response.data.data, response.metadata) +} + +export async function archiveCustomer( + client: PlatformClient, + customerId: string, + body: CustomerProjectRequest, + options: RequestOptions, +): Promise> { + const response = await client.customers.archive(customerId, body, options) + return result(response.data.data, response.metadata) +} + +export async function restoreCustomer( + client: PlatformClient, + customerId: string, + body: CustomerProjectRequest, + options: RequestOptions, +): Promise> { + const response = await client.customers.restore(customerId, body, options) + return result(response.data.data, response.metadata) +} + +export async function listCustomerNumbers( + client: PlatformClient, + customerId: string, + projectId: string, +): Promise> { + const response = await client.customers.listNumbers(customerId, projectId) + return result(response.data.data, response.metadata) +} + +export async function listCustomerEvents( + client: PlatformClient, + customerId: string, + params: ListCustomerEventsParams, +): Promise> { + const response = await client.customers.listEvents(customerId, params) + return result(response.data.data, response.metadata) +} + +export async function createCustomerPairingLink( + client: PlatformClient, + customerId: string, + body: CreateCustomerPairingLinkRequest, + options: RequestOptions, +): Promise> { + const response = await client.customers.createPairingLink( + customerId, + body, + options, + ) + return result(response.data.data, response.metadata) +} + +export async function listCustomerPairingLinks( + client: PlatformClient, + customerId: string, + projectId: string, +): Promise> { + const response = await client.customers.listPairingLinks( + customerId, + projectId, + ) + return result(response.data.data, response.metadata) +} + +export async function revokeCustomerPairingLink( + client: PlatformClient, + customerId: string, + pairingLinkId: string, + projectId: string, +): Promise> { + const response = await client.customers.revokePairingLink( + customerId, + pairingLinkId, + projectId, + ) + return result(response.data.data, response.metadata) +} + +export async function transferCustomerNumber( + client: PlatformClient, + customerId: string, + sessionId: string, + body: TransferCustomerNumberRequest, + options: RequestOptions, +): Promise> { + const response = await client.customers.transferNumber( + customerId, + sessionId, + body, + options, + ) + return result(response.data.data, response.metadata) +} + +export function renderCustomers(customers: CustomerListEnvelope): string { + if (customers.data.length === 0) return 'No Customers found.' + return customers.data + .map((customer) => + [ + customer.id, + customer.name ?? '', + customer.phoneMasked ?? '', + customer.status, + `${customer.connectedNumberCount}/${customer.numberCount} connected`, + customer.needsAttention ? 'needs attention' : '', + ].join('\t'), + ) + .join('\n') +} + +export function renderCustomer(customer: Customer): string { + return [ + `ID\t${customer.id}`, + `Name\t${customer.name ?? ''}`, + `Phone\t${customer.phone ?? ''}`, + `External ID\t${customer.externalCustomerId ?? ''}`, + `Status\t${customer.status}`, + `Default\t${customer.isDefault}`, + ].join('\n') +} + +export function renderCustomerStatus(status: CustomersStatus): string { + return status.enabled + ? `Customers is enabled. Default Customer: ${status.defaultCustomer?.id ?? 'unknown'}.` + : 'Customers is not enabled for this project.' +} + +export function renderCustomerNumbers( + numbers: readonly CustomerNumber[], +): string { + if (numbers.length === 0) return 'No Numbers are assigned to this Customer.' + return numbers + .map((number) => + [ + number.sessionId, + number.name ?? '', + number.phoneMasked ?? '', + number.status, + ].join('\t'), + ) + .join('\n') +} + +export function renderCustomerEvents(events: readonly CustomerEvent[]): string { + if (events.length === 0) return 'No Customer events found.' + return events + .map((event) => + [new Date(event.occurredAt).toISOString(), event.action, event.id].join( + '\t', + ), + ) + .join('\n') +} + +export function renderCustomerPairingLinks( + links: readonly CustomerPairingLink[], +): string { + if (links.length === 0) return 'No pairing links found.' + return links + .map((link) => + [ + link.id, + link.status, + link.methods.join(','), + new Date(link.expiresAt).toISOString(), + ].join('\t'), + ) + .join('\n') +} + +export function renderCreatedPairingLink( + link: CreatedCustomerPairingLink, +): string { + return link.url === null + ? `Pairing link ${link.id} already exists. Its one-time URL is no longer available.` + : link.url +} + +function result(data: T, metadata: ResponseMetadata): CustomerResult { + return { + data, + metadata: { + attempts: metadata.attempts, + status: metadata.status, + ...(metadata.apiVersion === undefined + ? {} + : {apiVersion: metadata.apiVersion}), + ...(metadata.requestId === undefined + ? {} + : {requestId: metadata.requestId}), + }, + } +} diff --git a/test/commands/customers.test.ts b/test/commands/customers.test.ts new file mode 100644 index 00000000..a2799720 --- /dev/null +++ b/test/commands/customers.test.ts @@ -0,0 +1,357 @@ +import {runCommand} from '@oclif/test' +import {afterEach, describe, expect, test} from 'vitest' + +import { + startHttpSequenceServer, + startHttpServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' + +describe('Customer commands', () => { + let server: TestHttpServer | undefined + + afterEach(async () => { + await server?.close() + server = undefined + }) + + test('checks and enables Customers for the selected project', async () => { + server = await startHttpSequenceServer([ + { + body: { + data: { + defaultCustomer: null, + enabled: false, + enabledAt: null, + enabledBy: null, + }, + }, + }, + { + body: { + data: { + defaultCustomer: customer(), + enabled: true, + enabledAt: 1_725_000_000_000, + enabledBy: 'user_1', + migratedNumberCount: 2, + }, + }, + }, + ]) + + const status = await environment(() => + runCommand('customer status --project project/1 --json'), + ) + const enable = await environment(() => + runCommand( + 'customer enable --project project/1 --idempotency-key enable_1 --json', + ), + ) + + expect(status.error).toBeUndefined() + expect(enable.error).toBeUndefined() + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'GET /v1/projects/project%2F1/customers/status', + 'POST /v1/projects/project%2F1/customers/enable', + ]) + expect(server.requests[1]?.headers['idempotency-key']).toBe('enable_1') + }) + + test('lists Customers with exact filters and cursor metadata', async () => { + server = await startHttpServer({ + body: { + data: [customerSummary()], + page: {hasMore: true, nextCursor: 'cursor_2'}, + }, + }) + + const result = await environment(() => + runCommand( + 'customer list --project project/1 --cursor cursor/1 --limit 20 --search Ada --status active --default false --has-numbers true --needs-attention false --json', + ), + ) + + expect(result.error).toBeUndefined() + expect(server.requests[0]?.url).toBe( + '/v1/customers?projectId=project%2F1&cursor=cursor%2F1&limit=20&search=Ada&status=active&isDefault=false&hasNumbers=true&needsAttention=false', + ) + expect(JSON.parse(result.stdout)).toMatchObject({ + data: { + data: [customerSummary()], + page: {hasMore: true, nextCursor: 'cursor_2'}, + }, + }) + }) + + test('creates, views, and updates a Customer profile', async () => { + server = await startHttpSequenceServer([ + {body: {data: customer()}}, + {body: {data: customer()}}, + {body: {data: {...customer(), name: null}}}, + ]) + + const created = await environment(() => + runCommand( + 'customer create --project project/1 --name Ada --phone +15551234567 --external-id crm/1 --idempotency-key create_1 --json', + ), + ) + const viewed = await environment(() => + runCommand('customer view customer/1 --project project/1 --json'), + ) + const updated = await environment(() => + runCommand( + 'customer update customer/1 --project project/1 --clear-name --phone +15557654321 --json', + ), + ) + + expect(created.error).toBeUndefined() + expect(viewed.error).toBeUndefined() + expect(updated.error).toBeUndefined() + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'POST /v1/customers', + 'GET /v1/customers/customer%2F1?projectId=project%2F1', + 'PATCH /v1/customers/customer%2F1', + ]) + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + externalCustomerId: 'crm/1', + name: 'Ada', + phone: '+15551234567', + projectId: 'project/1', + }) + expect(JSON.parse(server.requests[2]?.body ?? '')).toEqual({ + name: null, + phone: '+15557654321', + projectId: 'project/1', + }) + }) + + test('archives and restores a Customer with retry identity', async () => { + server = await startHttpSequenceServer([ + {body: {data: {...customer(), status: 'archived'}}}, + {body: {data: customer()}}, + ]) + + const archived = await environment(() => + runCommand( + 'customer archive customer/1 --project project/1 --yes --idempotency-key archive_1 --json', + ), + ) + const restored = await environment(() => + runCommand( + 'customer restore customer/1 --project project/1 --idempotency-key restore_1 --json', + ), + ) + + expect(archived.error).toBeUndefined() + expect(restored.error).toBeUndefined() + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'POST /v1/customers/customer%2F1/archive', + 'POST /v1/customers/customer%2F1/restore', + ]) + expect( + server.requests.map(({headers}) => headers['idempotency-key']), + ).toEqual(['archive_1', 'restore_1']) + }) + + test('lists Customer Numbers, events, and pairing links', async () => { + server = await startHttpSequenceServer([ + {body: {data: [number()]}}, + {body: {data: [event()]}}, + {body: {data: [pairingLink()]}}, + ]) + + const numbers = await environment(() => + runCommand('customer number list customer/1 --project project/1 --json'), + ) + const events = await environment(() => + runCommand( + 'customer event list customer/1 --project project/1 --limit 25 --json', + ), + ) + const links = await environment(() => + runCommand( + 'customer pairing-link list customer/1 --project project/1 --json', + ), + ) + + expect(numbers.error).toBeUndefined() + expect(events.error).toBeUndefined() + expect(links.error).toBeUndefined() + expect(server.requests.map(({url}) => url)).toEqual([ + '/v1/customers/customer%2F1/numbers?projectId=project%2F1', + '/v1/customers/customer%2F1/events?projectId=project%2F1&limit=25', + '/v1/customers/customer%2F1/pairing-links?projectId=project%2F1', + ]) + }) + + test('creates and revokes a pairing link, then transfers a Number', async () => { + server = await startHttpSequenceServer([ + {body: {data: {...pairingLink(), url: 'https://pair.example/link'}}}, + {body: {data: {...pairingLink(), status: 'revoked'}}}, + {body: {data: number()}}, + ]) + + const created = await environment(() => + runCommand( + 'customer pairing-link create customer/1 --project project/1 --expected-phone +15551234567 --method qr --method phone --expires-in 3600 --locale en --theme system --idempotency-key link_1 --json', + ), + ) + const revoked = await environment(() => + runCommand( + 'customer pairing-link revoke customer/1 link/1 --project project/1 --yes --json', + ), + ) + const transferred = await environment(() => + runCommand( + 'customer number transfer customer/1 session/1 --from customer/source --project project/1 --yes --idempotency-key transfer_1 --json', + ), + ) + + expect(created.error).toBeUndefined() + expect(revoked.error).toBeUndefined() + expect(transferred.error).toBeUndefined() + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'POST /v1/customers/customer%2F1/pairing-links', + 'DELETE /v1/customers/customer%2F1/pairing-links/link%2F1?projectId=project%2F1', + 'POST /v1/customers/customer%2F1/numbers/session%2F1/transfer', + ]) + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + expectedPhone: '+15551234567', + expiresInSeconds: 3600, + locale: 'en', + methods: ['qr', 'phone'], + projectId: 'project/1', + theme: 'system', + }) + expect(JSON.parse(server.requests[2]?.body ?? '')).toEqual({ + confirm: true, + projectId: 'project/1', + sourceCustomerId: 'customer/source', + }) + expect(server.requests[2]?.headers['idempotency-key']).toBe('transfer_1') + }) + + test('rejects missing project context and empty updates before transport', async () => { + server = await startHttpServer({body: {data: customer()}}) + + const noProject = await environment(() => + runCommand('customer view customer_1 --json'), + ) + const emptyUpdate = await environment(() => + runCommand('customer update customer_1 --project project_1 --json'), + ) + + expect(exitCode(noProject.error)).toBe(2) + expect(noProject.stderr).toContain('Provide --project') + expect(exitCode(emptyUpdate.error)).toBe(2) + expect(emptyUpdate.stderr).toContain( + 'Provide at least one Customer profile field to update', + ) + expect(server.requests).toHaveLength(0) + }) + + async function environment(run: () => Promise): Promise { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: 'pmfa_test-key', + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + run, + ) + } +}) + +function customer() { + return { + archivedAt: null, + createdAt: 1_725_000_000_000, + externalCustomerId: 'crm/1', + id: 'customer/1', + isDefault: false, + name: 'Ada', + orgId: 'org_1', + phone: '+15551234567', + projectId: 'project/1', + status: 'active', + updatedAt: 1_725_000_000_000, + } +} + +function customerSummary() { + const {phone: _phone, ...value} = customer() + void _phone + return { + ...value, + activePairingLinkState: 'active', + connectedNumberCount: 1, + lastActivityAt: 1_725_000_000_000, + needsAttention: false, + numberCount: 1, + phoneMasked: '+1•••4567', + } +} + +function number() { + return { + backend: 'whatsmeow', + createdAt: 1_725_000_000_000, + customerId: 'customer/1', + id: 'number_1', + name: 'Support', + phoneMasked: '+1•••4567', + sessionId: 'session/1', + status: 'connected', + } +} + +function event() { + return { + action: 'customer.updated', + fromStatus: 'active', + id: 'event_1', + metadata: {fields: ['name']}, + occurredAt: 1_725_000_000_000, + pairingLinkId: null, + sessionId: null, + toStatus: 'active', + } +} + +function pairingLink() { + return { + attemptCount: 0, + connectedAt: null, + connectingAt: null, + createdAt: 1_725_000_000_000, + createdBy: 'user_1', + customerId: 'customer/1', + expectedPhoneMasked: '+1•••4567', + expiredAt: null, + expiresAt: 1_725_003_600_000, + failedAt: null, + failedExchangeCount: 0, + id: 'link/1', + lastErrorCode: null, + locale: 'en', + maxAttempts: 3, + methods: ['qr', 'phone'], + openedAt: null, + orgId: 'org_1', + pendingSessionId: null, + phoneMismatchCount: 0, + projectId: 'project/1', + reservedAt: null, + revokedAt: null, + status: 'active', + theme: 'system', + updatedAt: 1_725_000_000_000, + } +} + +function exitCode(error: unknown): number | undefined { + return (error as {oclif?: {exit?: number}} | undefined)?.oclif?.exit +}