diff --git a/docs/commands.md b/docs/commands.md index 7f65e8a..3609ba3 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -850,17 +850,18 @@ dm lists remove list_abc123 --ids 111,222,333 #### `dm lists export ` -Export list items. Credits are charged per record. +Export items from a completed list. A person-anchored export uses credits for distinct people your organization has not already unlocked this cycle. ```bash -dm lists export list_abc123 +dm lists export list_abc123 --require-phone dm lists export list_abc123 --fields "full_address,estimated_value,owner_name" --anchor property ``` | Option | Description | | ----------------- | ---------------------------------------- | | `--fields ` | Comma-separated list of fields to export | -| `--anchor ` | `property` or `person` | +| `--anchor ` | `property` or `person` (default) | +| `--require-phone` | Only export people with a phone number | --- diff --git a/src/commands/lists.ts b/src/commands/lists.ts index 9f71018..896b906 100644 --- a/src/commands/lists.ts +++ b/src/commands/lists.ts @@ -486,7 +486,7 @@ export async function listsRemove( export async function listsExport( listId: string, - options: { fields?: string; anchor?: string; json?: boolean } + options: { fields?: string; anchor?: string; requirePhone?: boolean; json?: boolean } ): Promise { const requestBody: Record = {}; if (options.fields) { @@ -496,8 +496,11 @@ export async function listsExport( requestBody.anchor = options.anchor; } + if (options.requirePhone && options.anchor !== 'property') requestBody.require_phone = true; + const spinner = createSpinner('Starting export...').start(); - const data = await apiRequest<{ data: { export_id: string; status: string; record_count: number } }>( + type ExportResult = { export_id: string; status: string; record_count: number }; + const data = await apiRequest( `/lists/${listId}/export`, { method: 'POST', body: requestBody } ); @@ -508,7 +511,7 @@ export async function listsExport( return; } - const e = data.data; + const e = 'data' in data ? data.data : data; printHeader('Export Started'); printKeyValue({ 'Export ID': e.export_id, diff --git a/src/index.ts b/src/index.ts index 94e20d8..fdfe7ee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1032,7 +1032,8 @@ Examples: listsCmd .command('export ') - .description('Export list items (credits charged per record)') + .description('Export items from a completed list') + .option('--require-phone', 'Only export people with a phone number') .option('--fields ', 'Comma-separated list of fields to export') .option('--anchor ', 'Anchor type: property or person') .option('--json', 'Output as JSON') diff --git a/src/lib/client.ts b/src/lib/client.ts index 22bd656..0a49c96 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -122,6 +122,9 @@ export async function apiRequest( console.error( chalk.dim(' Add capacity in Billing settings or remove prospects, then retry.') ); + } else if (response.status === 402 && error.error?.code === 'export_limit_exceeded' && + typeof error.error.details?.required_credits === 'number') { + console.error(chalk.dim(' Check your data credit balance with dm usage before retrying.')); } else if (response.status === 402) { console.error(chalk.dim(' Your plan does not cover this request. Check Billing settings.')); } else if (response.status === 422) { diff --git a/tests/commands/publicApiExtensions.test.ts b/tests/commands/publicApiExtensions.test.ts index 352d928..b5c8497 100644 --- a/tests/commands/publicApiExtensions.test.ts +++ b/tests/commands/publicApiExtensions.test.ts @@ -14,6 +14,7 @@ vi.mock('../../src/lib/output.js', () => ({ parseRequestBody: mockParseRequestBody, printJson: vi.fn(), printHeader: vi.fn(), + printKeyValue: vi.fn(), printTable: vi.fn(), printPagination: vi.fn(), printTotals: vi.fn(), @@ -30,7 +31,7 @@ vi.mock('../../src/lib/output.js', () => ({ })), })); -import { printJson, printTable } from '../../src/lib/output.js'; +import { printJson, printKeyValue, printTable } from '../../src/lib/output.js'; import { peopleGet, peopleIds, peopleSearch, peopleExport } from '../../src/commands/people.js'; import { propertiesGet, propertiesIds, propertiesSearch, propertiesExport } from '../../src/commands/properties.js'; import { @@ -44,7 +45,7 @@ import { import { addressesAutocomplete, locationsAutocomplete } from '../../src/commands/locations.js'; import { drivingGet, drivingList } from '../../src/commands/driving.js'; import { prospectsList } from '../../src/commands/prospects.js'; -import { listsItems } from '../../src/commands/lists.js'; +import { listsExport, listsItems } from '../../src/commands/lists.js'; import { phonesDnc } from '../../src/commands/phones.js'; import { comps } from '../../src/commands/comps.js'; @@ -487,6 +488,15 @@ describe('CLI public API extensions', () => { } ); + it('sends the phone requirement on a person-anchored list export', async () => { + mockApiRequest.mockResolvedValue({ export_id: 'export-1', status: 'completed', record_count: 2 }); + await listsExport('list_1', { requirePhone: true }); + expect(printKeyValue).toHaveBeenCalledWith({ 'Export ID': 'export-1', 'Status': 'completed', 'Record Count': 2 }); + expect(mockApiRequest).toHaveBeenCalledWith('/lists/list_1/export', { + method: 'POST', body: { require_phone: true }, + }); + }); + it('keeps numeric list person IDs and uses opaque IDs only when the numeric ID is unavailable', async () => { mockApiRequest.mockResolvedValue({ data: [ diff --git a/tests/lib/client.test.ts b/tests/lib/client.test.ts index eb8074d..e16ef5f 100644 --- a/tests/lib/client.test.ts +++ b/tests/lib/client.test.ts @@ -81,3 +81,21 @@ it('names each rejected field when the API returns a validation error', async () 'status: status must be one of draft, ready, active, paused, ended, completed, cancelled' ); }); + +it('shows the list export credit shortfall and points to the balance', async () => { + readConfig.mockReturnValue({ apiKey: 'stored-account-key' }); + const message = 'This export requires 12 data credits. You have 8 and need 4 more.'; + vi.stubGlobal('fetch', async () => Response.json({ + error: { + code: 'export_limit_exceeded', message, + details: { required_credits: 12, remaining_credits: 8, reason: 'insufficient_for_request' }, + }, + }, { status: 402 })); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(process, 'exit').mockImplementation(() => { throw new Error('exit'); }); + await expect(apiRequest('/lists/example/export', { method: 'POST', body: {} })).rejects.toThrow('exit'); + const output = error.mock.calls.flat().join(' '); + expect(output).toContain(message); + expect(output).toContain('dm usage'); + expect(output).not.toContain('plan does not cover'); +});