Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,17 +73,23 @@ polymorfa customer enable \
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 \
--expected-phone +15551234567 \
--idempotency-key pair-customer-123
```

Customer profiles hold a name and an external ID; they have no phone field. To
reserve an invitation for one number, pass `--expected-phone` to
`customer pairing-link create`. Pairing links accept `--method`,
`--expected-phone`, and `--expires-in`; page language and theme are not
per-link options.

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
Expand Down Expand Up @@ -535,12 +541,28 @@ API-key creation, token mint/revoke, incident detail, or cross-project token
aggregate in this typed slice. The raw API command is not a credential-boundary
bypass.

Widget view returns `null` in JSON when no settings row has been saved. Update
`widget view` and `widget update` manage saved QuickLink settings. View
returns `null` in JSON when no settings row has been saved. Update
accepts one bounded JSON object from `--input`; the target project is selected
separately with `--project` and cannot be hidden in that input. Omitting the
project targets organization defaults. Widget logo fields are existing storage
identifiers, not file, URL, base64, upload, or download operations. Widget
access requires the source `widget:create` scope.
identifiers, not file, URL, base64, upload, or download operations. Both
commands require the `quicklink:manage` scope.

Settings accept `successCallbackUrl` and `failureCallbackUrl`: project-only
HTTPS destinations that the API copies into each link when it is issued. Set
`allowPhoneChange` to `true` to let recipients replace a prefilled number; the
default is `false`. `hideWatermark: true` requires Premium team access. Settings
have no redirect-URI allowlist. Human output shows both callback URLs and the
phone-change policy.

```json
{
"successCallbackUrl": "https://app.example.com/whatsapp/connected",
"failureCallbackUrl": "https://app.example.com/whatsapp/cancelled",
"allowPhoneChange": false
}
```

Session stop and delete accept 1 through 100 unique UUIDs or stable slugs. The
CLI trims IDs and rejects duplicates or oversized batches before transport.
Expand Down
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
"dependencies": {
"@napi-rs/keyring": "1.3.0",
"@oclif/core": "^4.13.5",
"@polymorfa/sdk": "github:polymorfa/sdks#08452992a32a392de5fc3d0fa3954ba6addd7f32"
"@polymorfa/sdk": "github:polymorfa/sdks#8803f0554314214519971cb3306e0c0efe21cb76"
},
"devDependencies": {
"@eslint/js": "^9.39.5",
Expand Down
4 changes: 0 additions & 4 deletions src/commands/customer/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,6 @@ export default class CustomerCreate extends BaseCommand {
required: true,
}),
name: Flags.string({description: 'Customer name.'}),
phone: Flags.string({
description: 'Customer phone number in E.164 format.',
}),
}

async run(): Promise<void> {
Expand All @@ -29,7 +26,6 @@ export default class CustomerCreate extends BaseCommand {
{
projectId: requireCustomerProject(flags.project),
...(flags.name === undefined ? {} : {name: flags.name}),
...(flags.phone === undefined ? {} : {phone: flags.phone}),
...(flags['external-id'] === undefined
? {}
: {externalCustomerId: flags['external-id']}),
Expand Down
14 changes: 0 additions & 14 deletions src/commands/customer/pairing-link/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,11 @@ export default class CustomerPairingLinkCreate extends BaseCommand {
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<void> {
Expand All @@ -55,15 +47,9 @@ export default class CustomerPairingLinkCreate extends BaseCommand {
...(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']},
)
Expand Down
10 changes: 0 additions & 10 deletions src/commands/customer/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,10 @@ export default class CustomerUpdate extends BaseCommand {
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<void> {
Expand All @@ -46,9 +39,6 @@ export default class CustomerUpdate extends BaseCommand {
...(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
? {}
Expand Down
9 changes: 2 additions & 7 deletions src/services/customers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,7 @@ export async function updateCustomer(
customerId: string,
body: UpdateCustomerRequest,
): Promise<CustomerResult<Customer>> {
if (
body.name === undefined &&
body.phone === undefined &&
body.externalCustomerId === undefined
) {
if (body.name === undefined && body.externalCustomerId === undefined) {
throw new PolymorfaValidationError(
'Provide at least one Customer profile field to update.',
{code: 'missing_customer_update'},
Expand Down Expand Up @@ -204,7 +200,7 @@ export function renderCustomers(customers: CustomerListEnvelope): string {
[
customer.id,
customer.name ?? '',
customer.phoneMasked ?? '',
customer.externalCustomerId ?? '',
customer.status,
`${customer.connectedNumberCount}/${customer.numberCount} connected`,
customer.needsAttention ? 'needs attention' : '',
Expand All @@ -217,7 +213,6 @@ 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}`,
Expand Down
4 changes: 3 additions & 1 deletion src/services/platform-management.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,9 @@ export function renderWidgetSettings(
`Methods\t${settings.methods?.join(', ') ?? 'default'}`,
`Theme\t${settings.theme}`,
`Business name\t${settings.businessName ?? ''}`,
`Redirect URIs\t${settings.allowedRedirectUris.join(', ')}`,
`Success callback URL\t${settings.successCallbackUrl ?? ''}`,
`Failure callback URL\t${settings.failureCallbackUrl ?? ''}`,
`Allow phone change\t${settings.allowPhoneChange}`,
`Hide watermark\t${settings.hideWatermark}`,
`Shape\t${settings.shape ?? 'default'}`,
`Radius\t${settings.radiusPx ?? 'default'}`,
Expand Down
70 changes: 56 additions & 14 deletions test/commands/customers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,30 @@ describe('Customer commands', () => {
expect(server.requests[0]?.url).toBe(
'/platform/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({
expect(JSON.parse(result.stdout)).toEqual({
data: {
data: [customerSummary()],
data: [
{
activePairingLinkState: 'active',
archivedAt: null,
connectedNumberCount: 1,
createdAt: 1_725_000_000_000,
externalCustomerId: 'crm/1',
id: 'customer/1',
isDefault: false,
lastActivityAt: 1_725_000_000_000,
name: 'Ada',
needsAttention: false,
numberCount: 1,
orgId: 'org_1',
projectId: 'project/1',
status: 'active',
updatedAt: 1_725_000_000_000,
},
],
page: {hasMore: true, nextCursor: 'cursor_2'},
},
metadata: {attempts: 1, status: 200},
})
})

Expand All @@ -94,15 +113,15 @@ describe('Customer commands', () => {

const created = await environment(() =>
runCommand(
'customer create --project project/1 --name Ada --phone +15551234567 --external-id crm/1 --idempotency-key create_1 --json',
'customer create --project project/1 --name Ada --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',
'customer update customer/1 --project project/1 --clear-name --external-id crm/2 --json',
),
)

Expand All @@ -117,12 +136,11 @@ describe('Customer commands', () => {
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({
externalCustomerId: 'crm/2',
name: null,
phone: '+15557654321',
projectId: 'project/1',
})
})
Expand Down Expand Up @@ -195,7 +213,7 @@ describe('Customer commands', () => {

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',
'customer pairing-link create customer/1 --project project/1 --expected-phone +15551234567 --method qr --method phone --expires-in 3600 --idempotency-key link_1 --json',
),
)
const revoked = await environment(() =>
Expand All @@ -220,10 +238,8 @@ describe('Customer commands', () => {
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,
Expand All @@ -233,6 +249,36 @@ describe('Customer commands', () => {
expect(server.requests[2]?.headers['idempotency-key']).toBe('transfer_1')
})

test('renders Customer profiles without a phone field', async () => {
server = await startHttpServer({body: {data: customer()}})

const viewed = await environment(() =>
runCommand('customer view customer/1 --project project/1'),
)

expect(viewed.error).toBeUndefined()
expect(viewed.stdout).toContain('External ID\tcrm/1')
expect(viewed.stdout).not.toMatch(/phone/i)
})

test('rejects retired Customer phone and invitation presentation flags before transport', async () => {
server = await startHttpServer({body: {data: customer()}})

const commands = [
'customer create --project project/1 --phone +15551234567 --idempotency-key create_2 --json',
'customer update customer/1 --project project/1 --phone +15551234567 --json',
'customer update customer/1 --project project/1 --clear-phone --json',
'customer pairing-link create customer/1 --project project/1 --locale en --idempotency-key link_2 --json',
'customer pairing-link create customer/1 --project project/1 --theme dark --idempotency-key link_3 --json',
]
for (const command of commands) {
const result = await environment(() => runCommand(command))
expect(exitCode(result.error), command).toBe(2)
expect(result.stderr, command).toMatch(/Nonexistent flag/)
}
expect(server.requests).toHaveLength(0)
})

test('rejects missing project context and empty updates before transport', async () => {
server = await startHttpServer({body: {data: customer()}})

Expand Down Expand Up @@ -275,24 +321,20 @@ function customer() {
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,
...customer(),
activePairingLinkState: 'active',
connectedNumberCount: 1,
lastActivityAt: 1_725_000_000_000,
needsAttention: false,
numberCount: 1,
phoneMasked: '+1•••4567',
}
}

Expand Down
Loading