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
7 changes: 4 additions & 3 deletions docs/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -850,17 +850,18 @@ dm lists remove list_abc123 --ids 111,222,333

#### `dm lists export <id>`

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 <csv>` | Comma-separated list of fields to export |
| `--anchor <type>` | `property` or `person` |
| `--anchor <type>` | `property` or `person` (default) |
| `--require-phone` | Only export people with a phone number |

---

Expand Down
9 changes: 6 additions & 3 deletions src/commands/lists.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const requestBody: Record<string, unknown> = {};
if (options.fields) {
Expand All @@ -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<ExportResult | { data: ExportResult }>(
`/lists/${listId}/export`,
{ method: 'POST', body: requestBody }
);
Expand All @@ -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,
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1032,7 +1032,8 @@ Examples:

listsCmd
.command('export <id>')
.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 <csv>', 'Comma-separated list of fields to export')
.option('--anchor <type>', 'Anchor type: property or person')
.option('--json', 'Output as JSON')
Expand Down
3 changes: 3 additions & 0 deletions src/lib/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ export async function apiRequest<T>(
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) {
Expand Down
14 changes: 12 additions & 2 deletions tests/commands/publicApiExtensions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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 {
Expand All @@ -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';

Expand Down Expand Up @@ -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: [
Expand Down
18 changes: 18 additions & 0 deletions tests/lib/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
Loading