diff --git a/docs/commands.md b/docs/commands.md index 0745199..21a7c52 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -25,6 +25,7 @@ This package has **zero** `@dealmachine/*` dependencies -- it is a self-containe - [Comps](#comps-commands) -- comparable property analysis - [Lists](#lists-commands) -- `search`, `create`, `get`, `update`, `delete`, `build`, `import`, `items`, `add`, `remove`, `export` - [Driving](#driving-commands) -- `list`, `get` + - [Mail campaigns](#mail-campaign-commands) -- `list`, `create`, `get`, `add-properties`, `estimate-add`, `pause`, `resume`, `end` - [Filters](#filters-commands) -- list available search filters - [Fields](#fields-commands) -- list available data fields - [Activity](#activity-commands) -- `search`, `get` @@ -861,6 +862,34 @@ dm prospects list --source driving Drive history is read-only. Use `dm prospects` for lifecycle, notes, and tags on linked Prospects. +### Mail Campaign Commands + +A mail campaign is a sequence of mail steps. Campaigns built in the app's campaign creator, campaigns saved as Ready, and campaigns created with `"accepts_properties": true` keep accepting properties while they are Ready, Active or Paused. Other campaigns created with an audience work as before and end when their mail is done. Each added property starts at step 1 on its own schedule; properties already in the campaign are not changed. + +Statuses are `draft`, `ready`, `active`, `paused` and `ended`. Ready means nothing is queued to mail. Completed and Cancelled campaigns from before this change show as `ended`; `--status completed` and `--status cancelled` still work as filters. + +```bash +dm mail campaigns list --accepting-properties # Campaigns you can add properties to now +dm mail campaigns list --status ended --json +dm mail campaigns create --body '{"name":"Seller follow-up","steps":[{"design_id":"des_123"}]}' # No audience: saved as Ready, no charge +dm mail campaigns estimate-add camp_abc123 --count 25 +dm mail campaigns add-properties camp_abc123 --property-ids 101 102 --recipient owner +dm mail campaigns add-properties camp_abc123 --property-ids 101,102 --recipient resident --yes --json +dm mail campaigns pause camp_abc123 +dm mail campaigns end camp_abc123 +``` + +| Command | What it does | +| --- | --- | +| `add-properties --property-ids --recipient owner\|resident` | Shows the charge due today and later, asks for confirmation, then adds up to 5,000 properties. Prints the added count and each skipped property with its reason (already in the campaign, Do Not Contact, no mailing address, duplicate mailing address, archived prospect, not found). | +| `estimate-add --count ` | Shows what adding `n` properties costs: charged today, charged later, pieces per property and whether your wallet covers it. For a paused campaign nothing is charged today; each piece is charged as it goes out after you resume it. Nothing is added. | +| `end ` | Ends the campaign permanently after a confirmation. Unsent mail stops; the campaign cannot be paused, resumed or added to. Mail already printed still delivers. | +| `list --accepting-properties` | Lists only campaigns that properties can be added to now. | + +`--recipient owner` mails the owner's mailing address; `--recipient resident` mails the current resident at the property address. Pieces going out today are charged when added; later pieces are charged as they go out. Auto-reload must be on when the campaign has more than one step, a step that repeats, or any delay. + +`add-properties` and `end` need `--yes` when run by an agent, a script, with `--json`, or without a terminal. Without it, `add-properties` prints the estimate (with `"confirmation_required": true` in `--json` output), adds nothing and exits with code 1; `end` stops with an error and exits with code 1. `end` refuses a draft, which has not started; cancel a draft with `delete `. `delete ` cancels a campaign without a confirmation, gives the same result as `end` for a started campaign, and succeeds without changes for a campaign that has already ended. + ### Filters Commands #### `dm filters` diff --git a/npm-alias/package.json b/npm-alias/package.json index 9151826..522720e 100644 --- a/npm-alias/package.json +++ b/npm-alias/package.json @@ -1,6 +1,6 @@ { "name": "dealmachine", - "version": "0.4.1", + "version": "0.5.0", "description": "Short install alias for the DealMachine property intelligence CLI", "author": "DealMachine", "license": "MIT", @@ -23,7 +23,7 @@ "LICENSE" ], "dependencies": { - "@dealmachine/cli": "0.4.1" + "@dealmachine/cli": "0.5.0" }, "engines": { "node": ">=18" diff --git a/package-lock.json b/package-lock.json index a38dde4..345bb89 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@dealmachine/cli", - "version": "0.4.1", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@dealmachine/cli", - "version": "0.4.1", + "version": "0.5.0", "license": "MIT", "dependencies": { "chalk": "^5.3.0", diff --git a/package.json b/package.json index b5777d8..9f529fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@dealmachine/cli", - "version": "0.4.1", + "version": "0.5.0", "description": "DealMachine CLI for property intelligence, people lookup, and enrichment through the public API", "author": "DealMachine", "license": "MIT", diff --git a/playbook/PLAYBOOK.md b/playbook/PLAYBOOK.md index 98b9b2a..6e7eb67 100644 --- a/playbook/PLAYBOOK.md +++ b/playbook/PLAYBOOK.md @@ -695,6 +695,22 @@ dm lists export --anchor person dm lists export --fields estimated_value,owner_full_name --anchor property ``` +### Mail Campaigns (adding properties costs mail credits) + +```bash +dm mail campaigns list --accepting-properties --json # Campaigns that can take properties now +dm mail campaigns estimate-add --count 25 --json # Charge due today and later (FREE) +dm mail campaigns add-properties --property-ids 101 102 --recipient owner --yes --json +dm mail campaigns end --yes # Permanent +``` + +- Statuses: `draft`, `ready`, `active`, `paused`, `ended`. Completed and Cancelled show as `ended`. +- Ask the user owner or resident before adding. Each added property starts at step 1. +- Always run `estimate-add` first and tell the user the charge due today (`pay_today_cents`) and later. Pass `--yes` only after they approve. Without `--yes`, `add-properties` returns the estimate, adds nothing and exits with code 1. For a paused campaign nothing is charged today; pieces are charged as they go out after it resumes. +- Report `added_count` and every skipped property with its reason. +- Ending is permanent: unsent mail stops and the campaign can never be resumed or added to. Confirm with the user before `end --yes`. +- Create a campaign with no audience to save it as Ready with no charge, then add properties later. To add properties later to a campaign created with an audience, include `"accepts_properties": true` when you create it. + ### Activity (FREE) ```bash diff --git a/src/commands/mail.ts b/src/commands/mail.ts index aaf0863..04ed02c 100644 --- a/src/commands/mail.ts +++ b/src/commands/mail.ts @@ -14,6 +14,7 @@ import { truncate, createSpinner, } from '../lib/output.js'; +import { confirmPrompt, isNonInteractive } from '../lib/confirm.js'; // ============================================================================ // Types @@ -34,6 +35,10 @@ interface Campaign { updated_at: string; launched_at: string | null; completed_at: string | null; + /** True when the campaign was built to take properties after it starts. */ + is_accepting_properties?: boolean; + /** True when properties can be added right now (Ready, Active or Paused). */ + can_add_properties?: boolean; steps: CampaignStep[]; } @@ -105,6 +110,42 @@ interface CostEstimateResponse { }; } +interface AddPropertiesEstimateResponse { + data: { + campaign_id: string; + status: string; + property_count: number; + pay_today_cents: number; + pay_later_cents: number; + total_cost_cents: number; + pieces_per_property: number; + wallet_available_cents: number; + auto_reload_enabled: boolean; + can_afford: boolean; + requires_auto_reload: boolean; + /** True for a paused campaign: nothing is charged today; pieces are charged as they go out after it resumes. */ + charged_on_resume?: boolean; + steps: unknown[]; + }; +} + +interface AddPropertiesResponse { + data: { + campaign_id: string; + status: string; + added_count: number; + skipped: { property_id: string; reason: string }[]; + enqueued_count: number; + scheduled_count: number; + }; +} + +interface EndCampaignResponse { + data: Campaign; + stopped_mail_count?: number; + already_printed_count?: number; +} + interface Design { id: string; name: string; @@ -207,6 +248,7 @@ interface WalletPricingResponse { export async function mailCampaignsList(options: { status?: string; + acceptingProperties?: boolean; search?: string; page?: string; perPage?: string; @@ -216,6 +258,7 @@ export async function mailCampaignsList(options: { const data = await apiRequest('/mail/campaigns', { query: { ...(options.status && { status: options.status }), + ...(options.acceptingProperties && { accepting_properties: true }), ...(options.search && { search: options.search }), ...(options.page && { page: parseInt(options.page, 10) }), ...(options.perPage && { per_page: parseInt(options.perPage, 10) }), @@ -310,6 +353,7 @@ export async function mailCampaignsGet(id: string, options: { json?: boolean }): Name: c.name, Description: c.description || '—', Status: c.status, + 'Accepts Properties': c.can_add_properties ? 'yes' : 'no', Audience: c.audience_kind || '—', Recipients: c.total_recipients, Sent: c.total_sent, @@ -566,6 +610,233 @@ export async function mailCampaignsCostEstimate( console.log(); } +// ============================================================================ +// Campaigns: Add properties +// ============================================================================ + +const MAX_PROPERTIES_PER_ADD = 5000; + +const SKIP_REASON_LABELS: Record = { + already_in_campaign: 'Already in this campaign', + do_not_contact: 'Marked Do Not Contact', + no_mailing_address: 'No complete mailing address', + duplicate_mailing_address: 'Same mailing address as another recipient', + archived_prospect: 'Archived prospect', + not_found: 'Property not found', +}; + +function failInput(message: string): void { + console.error(chalk.red(`Error: ${message}`)); + process.exitCode = 1; +} + +function dollars(cents: number): string { + return `$${(cents / 100).toFixed(2)}`; +} + +/** Accept "1,2 3" style input from one or more --property-ids values. */ +export function parsePropertyIds(values: string | string[] | undefined): string[] { + const raw = (Array.isArray(values) ? values : values ? [values] : []) + .flatMap((value) => value.split(/[\s,]+/)) + .map((value) => value.trim()) + .filter(Boolean); + const invalid = raw.filter((value) => !/^[1-9]\d*$/.test(value)); + if (invalid.length > 0) { + throw new Error(`Property IDs must be positive whole numbers. Not valid: ${invalid.slice(0, 5).join(', ')}`); + } + const ids = [...new Set(raw)]; + if (ids.length === 0) throw new Error('Pass at least one property ID with --property-ids.'); + if (ids.length > MAX_PROPERTIES_PER_ADD) { + throw new Error(`Add at most ${MAX_PROPERTIES_PER_ADD} properties at a time. You passed ${ids.length}.`); + } + return ids; +} + +function printAddEstimate(e: AddPropertiesEstimateResponse['data']): void { + printHeader('Cost to Add Properties'); + printKeyValue({ + 'Campaign ID': e.campaign_id, + Status: e.status, + Properties: e.property_count, + 'Pieces Per Property': e.pieces_per_property, + 'Charged Today': dollars(e.pay_today_cents), + 'Charged Later': dollars(e.pay_later_cents), + 'Total Cost': dollars(e.total_cost_cents), + 'Wallet Available': dollars(e.wallet_available_cents), + 'Auto-reload': e.auto_reload_enabled ? 'on' : 'off', + 'Can Afford': e.can_afford ? 'yes' : 'no', + }); + if (e.charged_on_resume) { + console.log(); + console.log(chalk.dim(' This campaign is paused. Nothing is charged today; each piece is charged as it goes out after you resume it.')); + } + if (e.requires_auto_reload) { + console.log(); + console.log(chalk.yellow(' This campaign has more than one step, a repeating step, or a delay. Turn on auto-reload before adding properties.')); + } + console.log(chalk.dim(' Properties already in the campaign or skipped are not charged.')); +} + +async function fetchAddEstimate(id: string, count: number) { + return apiRequest( + `/mail/campaigns/${encodeURIComponent(id)}/properties/estimate`, + { query: { count } } + ); +} + +export async function mailCampaignsEstimateAdd( + id: string, + options: { count?: string; json?: boolean } +): Promise { + const count = Number(options.count); + if (!Number.isInteger(count) || count < 1 || count > MAX_PROPERTIES_PER_ADD) { + failInput(`--count must be a whole number from 1 to ${MAX_PROPERTIES_PER_ADD}.`); + return; + } + const spinner = createSpinner('Estimating cost...').start(); + const data = await fetchAddEstimate(id, count); + spinner.stop(); + + if (options.json) { + printJson(data); + return; + } + printAddEstimate(data.data); + console.log(); +} + +export async function mailCampaignsAddProperties( + id: string, + options: { propertyIds?: string | string[]; recipient?: string; yes?: boolean; json?: boolean } +): Promise { + if (options.recipient !== 'owner' && options.recipient !== 'resident') { + failInput('Choose who receives the mail with --recipient owner or --recipient resident.'); + return; + } + let propertyIds: string[]; + try { + propertyIds = parsePropertyIds(options.propertyIds); + } catch (err) { + failInput(err instanceof Error ? err.message : String(err)); + return; + } + + if (!options.yes) { + const spinner = createSpinner('Estimating cost...').start(); + const estimate = await fetchAddEstimate(id, propertyIds.length); + spinner.stop(); + + if (isNonInteractive(options)) { + if (options.json) { + printJson({ ...estimate, added: false, confirmation_required: true }); + } else { + printAddEstimate(estimate.data); + console.log(); + } + console.error( + `No properties were added. Review the charge due today, get the user's approval, then rerun dm mail campaigns add-properties ${id} with --yes.` + ); + // Nothing was added, so a script must not read this as success. + process.exitCode = 1; + return; + } + + printAddEstimate(estimate.data); + console.log(); + const confirmed = await confirmPrompt( + `Add ${propertyIds.length} propert${propertyIds.length === 1 ? 'y' : 'ies'} and charge ${dollars(estimate.data.pay_today_cents)} today?` + ); + if (!confirmed) { + console.log(chalk.dim(' No properties were added.')); + return; + } + } + + const spinner = createSpinner('Adding properties...').start(); + const data = await apiRequest( + `/mail/campaigns/${encodeURIComponent(id)}/properties`, + { method: 'POST', body: { property_ids: propertyIds, recipient: options.recipient } } + ); + spinner.stop(); + + if (options.json) { + printJson(data); + return; + } + + const r = data.data; + printHeader('Properties Added'); + printKeyValue({ + 'Campaign ID': r.campaign_id, + Status: r.status, + Added: r.added_count, + Skipped: r.skipped.length, + 'Pieces Going Out Now': r.enqueued_count, + 'Pieces Scheduled Later': r.scheduled_count, + }); + if (r.skipped.length > 0) { + console.log(); + printTable( + r.skipped.map((s) => ({ + property: s.property_id, + reason: SKIP_REASON_LABELS[s.reason] ?? s.reason, + })), + ['property', 'reason'] + ); + } + console.log(); +} + +// ============================================================================ +// Campaigns: End +// ============================================================================ + +export async function mailCampaignsEnd( + id: string, + options: { yes?: boolean; json?: boolean } +): Promise { + if (!options.yes) { + if (isNonInteractive(options)) { + console.error( + `Ending a campaign is permanent: its unsent mail stops and it can never be resumed or added to. Get the user's approval, then rerun dm mail campaigns end ${id} with --yes.` + ); + process.exitCode = 1; + return; + } + console.log( + chalk.yellow( + ' Ending is permanent. Unsent mail stops, and the campaign can never be resumed or added to again. Mail already printed still delivers.' + ) + ); + const confirmed = await confirmPrompt(`End campaign ${id}?`); + if (!confirmed) { + console.log(chalk.dim(' Campaign not ended.')); + return; + } + } + + const spinner = createSpinner('Ending campaign...').start(); + const data = await apiRequest( + `/mail/campaigns/${encodeURIComponent(id)}/end`, + { method: 'POST', body: {} } + ); + spinner.stop(); + + if (options.json) { + printJson(data); + return; + } + + console.log(chalk.green(` Campaign ${id} ended.`)); + if (data.stopped_mail_count !== undefined) { + console.log(chalk.dim(` Stopped ${data.stopped_mail_count} unsent piece(s).`)); + } + if (data.already_printed_count) { + console.log(chalk.dim(` ${data.already_printed_count} piece(s) already printed will still deliver.`)); + } + console.log(); +} + // ============================================================================ // Designs — List // ============================================================================ diff --git a/src/index.ts b/src/index.ts index 52592c5..aa02af8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -81,6 +81,9 @@ import { mailCampaignsRecipients, mailCampaignsAnalytics, mailCampaignsCostEstimate, + mailCampaignsAddProperties, + mailCampaignsEstimateAdd, + mailCampaignsEnd, mailDesignsList, mailDesignsCreate, mailDesignsGet, @@ -2175,7 +2178,11 @@ mailCampaignsCmd .command('list') .alias('ls') .description('List mail campaigns') - .option('--status ', 'Filter by status: draft, active, paused, completed, cancelled') + .option( + '--status ', + 'Filter by status: draft, ready, active, paused, ended (completed and cancelled still work)' + ) + .option('--accepting-properties', 'Only campaigns that properties can be added to now') .option('--search ', 'Search by campaign name') .option('-p, --page ', 'Page number') .option('--per-page ', 'Results per page') @@ -2186,7 +2193,10 @@ mailCampaignsCmd Examples: dm mail campaigns list --json dm mail camp ls --status active - dm mail campaigns list --search "Austin" --per-page 50` + dm mail campaigns list --accepting-properties --json + dm mail campaigns list --search "Austin" --per-page 50 + +Statuses: draft, ready, active, paused, ended. Completed and Cancelled campaigns show as ended.` ) .action(async (options) => { await mailCampaignsList(options); @@ -2205,7 +2215,11 @@ Examples: dm mail campaigns create -f campaign.json --json dm mail campaigns create --body '{"name":"Austin Owners","property_ids":[123],"address_to":"owner","steps":[{"design_prompt":"Professional postcard"}]}' -Note: Set "launch": true in body to send immediately, or leave as draft.` +Note: Set "launch": true in body to send immediately, or leave as draft. +Set "accepts_properties": true to keep adding properties after the campaign starts. +Leave out property_ids, contact_ids and list_ids to save a Ready campaign with no properties +and no charge; it always accepts properties. Add properties later with: +dm mail campaigns add-properties ` ) .action(async (options) => { await mailCampaignsCreate(options); @@ -2245,7 +2259,7 @@ Note: Only draft campaigns can be updated.` mailCampaignsCmd .command('delete ') - .description('Cancel a campaign') + .description('Cancel a campaign (same as end, without the confirmation)') .option('--dry-run', 'Preview what would be cancelled without making changes') .option('--json', 'Output as JSON') .addHelpText( @@ -2290,7 +2304,7 @@ Note: Verify wallet balance first with: dm mail wallet balance` mailCampaignsCmd .command('pause ') - .description('Pause an active campaign') + .description('Pause an active or ready campaign') .option('--json', 'Output as JSON') .addHelpText( 'after', @@ -2361,6 +2375,65 @@ Examples: await mailCampaignsCostEstimate(id, options); }); +mailCampaignsCmd + .command('add-properties ') + .description('Add properties to a Ready, Active or Paused campaign; each starts at step 1') + .requiredOption( + '--property-ids ', + 'Property IDs, space or comma separated (up to 5,000)' + ) + .requiredOption('--recipient ', 'Who receives the mail: owner or resident') + .option('--yes', 'Skip the confirmation (required for agents, scripts and --json)') + .option('--json', 'Output as JSON') + .addHelpText( + 'after', + ` +Examples: + dm mail campaigns add-properties camp_abc123 --property-ids 101 102 --recipient owner + dm mail campaigns add-properties camp_abc123 --property-ids 101,102 --recipient resident --yes --json + +Shows the charge due today and later, then asks before adding. Without --yes, agents and +scripts get the estimate only and nothing is added. Pieces going out today are charged now; +later pieces are charged as they go out. Skipped properties are listed with a reason.` + ) + .action(async (id, options) => { + await mailCampaignsAddProperties(id, options); + }); + +mailCampaignsCmd + .command('estimate-add ') + .description('Estimate the cost of adding properties to a campaign') + .requiredOption('--count ', 'Number of properties to add (1 to 5,000)') + .option('--json', 'Output as JSON') + .addHelpText( + 'after', + ` +Examples: + dm mail campaigns estimate-add camp_abc123 --count 25 --json` + ) + .action(async (id, options) => { + await mailCampaignsEstimateAdd(id, options); + }); + +mailCampaignsCmd + .command('end ') + .description('End a campaign permanently') + .option('--yes', 'Skip the confirmation (required for agents, scripts and --json)') + .option('--json', 'Output as JSON') + .addHelpText( + 'after', + ` +Examples: + dm mail campaigns end camp_abc123 + dm mail campaigns end camp_abc123 --yes --json + +Ending is permanent. Unsent mail stops and the campaign cannot be paused, resumed or added to. +Mail already printed still delivers. To reuse its steps, create a new campaign.` + ) + .action(async (id, options) => { + await mailCampaignsEnd(id, options); + }); + // ── Designs ── const mailDesignsCmd = mailCmd.command('designs').description('Manage postcard designs'); diff --git a/src/lib/client.ts b/src/lib/client.ts index 6e5a036..490f4ba 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -92,6 +92,16 @@ export async function apiRequest( const code = error.error?.code ? ` [${error.error.code}]` : ''; console.error(chalk.red(`Error: ${message}${code}`)); console.error(chalk.dim(` ${method} ${path} -> ${response.status}`)); + // Validation errors name each rejected field in details.issues; show them so + // "Validation failed" says what to change (for example an unsupported --status). + const issues = (error.error?.details as { issues?: unknown } | undefined)?.issues; + if (Array.isArray(issues)) { + for (const issue of issues as Array<{ path?: unknown; message?: unknown }>) { + if (typeof issue?.message !== 'string') continue; + const field = typeof issue.path === 'string' && issue.path ? `${issue.path}: ` : ''; + console.error(chalk.yellow(` ${field}${issue.message}`)); + } + } if (response.status === 401) { console.error(chalk.dim(' Run `dm login` to re-authenticate.')); } else if (response.status === 402 && error.error?.code === 'prospect_limit_reached') { diff --git a/src/lib/confirm.ts b/src/lib/confirm.ts new file mode 100644 index 0000000..cd0bb89 --- /dev/null +++ b/src/lib/confirm.ts @@ -0,0 +1,30 @@ +/** + * Confirmation before a Command changes something that cannot be undone or + * that spends money. Interactive users answer a yes/no question. Agents, + * scripts and --json callers must pass --yes instead. + */ + +import { createInterface } from 'node:readline/promises'; +import { isQuiet } from './output.js'; + +export function isNonInteractive(options: { json?: boolean } = {}): boolean { + return ( + options.json === true || + isQuiet() || + process.env.DM_AGENT === '1' || + process.env.CI === 'true' || + process.stdin.isTTY !== true || + process.stdout.isTTY !== true + ); +} + +/** Ask a yes/no question on the terminal. Anything but y or yes is a no. */ +export async function confirmPrompt(question: string): Promise { + const rl = createInterface({ input: process.stdin, output: process.stdout }); + try { + const answer = await rl.question(`${question} (y/N) `); + return /^y(es)?$/i.test(answer.trim()); + } finally { + rl.close(); + } +} diff --git a/src/version.ts b/src/version.ts index c6f8f26..8f41652 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ -export const CLI_VERSION = '0.4.1'; +export const CLI_VERSION = '0.5.0'; export const CLI_USER_AGENT = `dm-cli/${CLI_VERSION}`; diff --git a/tests/commands/mailCampaigns.test.ts b/tests/commands/mailCampaigns.test.ts new file mode 100644 index 0000000..bfb2215 --- /dev/null +++ b/tests/commands/mailCampaigns.test.ts @@ -0,0 +1,216 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const { mockApiRequest, mockConfirm, mockNonInteractive } = vi.hoisted(() => ({ + mockApiRequest: vi.fn(), + mockConfirm: vi.fn(), + mockNonInteractive: vi.fn(() => false), +})); + +vi.mock('../../src/lib/client.js', () => ({ + apiRequest: mockApiRequest, + formatDate: () => 'Oct 14, 2026', +})); +vi.mock('../../src/lib/confirm.js', () => ({ + confirmPrompt: mockConfirm, + isNonInteractive: mockNonInteractive, +})); +vi.mock('../../src/lib/output.js', () => ({ + isQuiet: vi.fn(() => false), + parseRequestBody: vi.fn(), + printJson: vi.fn(), + printHeader: vi.fn(), + printTable: vi.fn(), + printKeyValue: vi.fn(), + printPagination: vi.fn(), + truncate: vi.fn((value: string) => value), + createSpinner: vi.fn(() => ({ + start() { + return this; + }, + stop: vi.fn(), + text: '', + })), +})); + +import { printJson, printKeyValue, printTable } from '../../src/lib/output.js'; +import { + mailCampaignsAddProperties, + mailCampaignsEnd, + mailCampaignsEstimateAdd, + mailCampaignsList, + parsePropertyIds, +} from '../../src/commands/mail.js'; + +const estimate = { + data: { + campaign_id: 'camp_1', + status: 'active', + property_count: 2, + pay_today_cents: 250, + pay_later_cents: 500, + total_cost_cents: 750, + pieces_per_property: 3, + wallet_available_cents: 10000, + auto_reload_enabled: true, + can_afford: true, + requires_auto_reload: false, + steps: [], + }, +}; + +const addResult = { + data: { + campaign_id: 'camp_1', + status: 'active', + added_count: 1, + skipped: [{ property_id: '102', reason: 'do_not_contact' }], + enqueued_count: 1, + scheduled_count: 2, + }, +}; + +let errorSpy: ReturnType; + +beforeEach(() => { + vi.clearAllMocks(); + mockNonInteractive.mockReturnValue(false); + process.exitCode = undefined; + errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'log').mockImplementation(() => {}); +}); + +afterEach(() => { + process.exitCode = undefined; + vi.restoreAllMocks(); +}); + +describe('dm mail campaigns', () => { + it('lists only campaigns that accept properties with the new status values', async () => { + mockApiRequest.mockResolvedValue({ + data: [], + pagination: { page: 1, per_page: 25, total: 0, has_more: false }, + }); + await mailCampaignsList({ status: 'ready', acceptingProperties: true, json: true }); + expect(mockApiRequest).toHaveBeenCalledWith('/mail/campaigns', { + query: { status: 'ready', accepting_properties: true }, + }); + }); + + it('shows the charge due today and adds properties only after confirmation', async () => { + mockApiRequest.mockResolvedValueOnce(estimate).mockResolvedValueOnce(addResult); + mockConfirm.mockResolvedValue(true); + + await mailCampaignsAddProperties('camp_1', { propertyIds: ['101,102'], recipient: 'owner' }); + + expect(mockApiRequest).toHaveBeenNthCalledWith(1, '/mail/campaigns/camp_1/properties/estimate', { + query: { count: 2 }, + }); + expect(printKeyValue).toHaveBeenCalledWith( + expect.objectContaining({ 'Charged Today': '$2.50', 'Charged Later': '$5.00' }) + ); + expect(mockConfirm).toHaveBeenCalledWith('Add 2 properties and charge $2.50 today?'); + expect(mockApiRequest).toHaveBeenNthCalledWith(2, '/mail/campaigns/camp_1/properties', { + method: 'POST', + body: { property_ids: ['101', '102'], recipient: 'owner' }, + }); + expect(printTable).toHaveBeenCalledWith( + [{ property: '102', reason: 'Marked Do Not Contact' }], + ['property', 'reason'] + ); + }); + + it('adds nothing when the person declines', async () => { + mockApiRequest.mockResolvedValueOnce(estimate); + mockConfirm.mockResolvedValue(false); + await mailCampaignsAddProperties('camp_1', { propertyIds: ['101'], recipient: 'resident' }); + expect(mockApiRequest).toHaveBeenCalledTimes(1); + }); + + it('returns only the estimate to agents that did not pass --yes', async () => { + mockNonInteractive.mockReturnValue(true); + mockApiRequest.mockResolvedValueOnce(estimate); + + await mailCampaignsAddProperties('camp_1', { + propertyIds: ['101', '102'], + recipient: 'owner', + json: true, + }); + + expect(mockApiRequest).toHaveBeenCalledTimes(1); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(printJson).toHaveBeenCalledWith( + expect.objectContaining({ added: false, confirmation_required: true, data: estimate.data }) + ); + expect(errorSpy.mock.calls.join(' ')).toContain('No properties were added'); + // Nothing was added, so scripts and agents see a failure, as end does. + expect(process.exitCode).toBe(1); + }); + + it('adds immediately with --yes', async () => { + mockApiRequest.mockResolvedValueOnce(addResult); + await mailCampaignsAddProperties('camp_1', { + propertyIds: ['101'], + recipient: 'owner', + yes: true, + json: true, + }); + expect(mockApiRequest).toHaveBeenCalledTimes(1); + expect(mockApiRequest).toHaveBeenCalledWith('/mail/campaigns/camp_1/properties', { + method: 'POST', + body: { property_ids: ['101'], recipient: 'owner' }, + }); + expect(printJson).toHaveBeenCalledWith(addResult); + }); + + it('rejects a missing recipient and bad property IDs before calling the API', async () => { + await mailCampaignsAddProperties('camp_1', { propertyIds: ['101'], yes: true }); + await mailCampaignsAddProperties('camp_1', { propertyIds: ['abc'], recipient: 'owner', yes: true }); + expect(mockApiRequest).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(() => parsePropertyIds(Array(5001).fill(0).map((_, i) => String(i + 1)))).toThrow( + 'at most 5000' + ); + expect(parsePropertyIds(['5 5', '6'])).toEqual(['5', '6']); + }); + + it('estimates adding a number of properties', async () => { + mockApiRequest.mockResolvedValueOnce(estimate); + await mailCampaignsEstimateAdd('camp_1', { count: '25', json: true }); + expect(mockApiRequest).toHaveBeenCalledWith('/mail/campaigns/camp_1/properties/estimate', { + query: { count: 25 }, + }); + await mailCampaignsEstimateAdd('camp_1', { count: '0' }); + expect(mockApiRequest).toHaveBeenCalledTimes(1); + expect(process.exitCode).toBe(1); + }); + + it('ends a campaign only after the person confirms', async () => { + mockConfirm.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + mockApiRequest.mockResolvedValue({ + data: { id: 'camp_1', status: 'ended' }, + stopped_mail_count: 4, + already_printed_count: 1, + }); + + await mailCampaignsEnd('camp_1', {}); + expect(mockApiRequest).not.toHaveBeenCalled(); + + await mailCampaignsEnd('camp_1', {}); + expect(mockApiRequest).toHaveBeenCalledWith('/mail/campaigns/camp_1/end', { + method: 'POST', + body: {}, + }); + }); + + it('refuses to end a campaign for an agent without --yes', async () => { + mockNonInteractive.mockReturnValue(true); + await mailCampaignsEnd('camp_1', { json: true }); + expect(mockApiRequest).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(errorSpy.mock.calls.join(' ')).toContain('permanent'); + + mockApiRequest.mockResolvedValue({ data: { id: 'camp_1', status: 'ended' } }); + await mailCampaignsEnd('camp_1', { json: true, yes: true }); + expect(mockApiRequest).toHaveBeenCalledTimes(1); + }); +}); diff --git a/tests/lib/client.test.ts b/tests/lib/client.test.ts index f207db0..eb8074d 100644 --- a/tests/lib/client.test.ts +++ b/tests/lib/client.test.ts @@ -44,3 +44,40 @@ it('requires login after stored credentials are removed even if the environment expect(process.exit).toHaveBeenCalledWith(1); expect(fetch).not.toHaveBeenCalled(); }); + +it('names each rejected field when the API returns a validation error', async () => { + readConfig.mockReturnValue({ apiKey: 'stored-account-key' }); + vi.stubGlobal( + 'fetch', + vi.fn().mockResolvedValue({ + ok: false, + status: 400, + json: async () => ({ + error: { + code: 'validation_error', + message: 'Validation failed', + details: { + issues: [ + { + path: 'status', + message: 'status must be one of draft, ready, active, paused, ended, completed, cancelled', + }, + ], + }, + }, + }), + }) + ); + const printed: string[] = []; + vi.spyOn(console, 'error').mockImplementation((line: unknown) => { + printed.push(String(line)); + }); + vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('exit'); + }); + + await expect(apiRequest('/mail/campaigns', { query: { status: 'finished' } })).rejects.toThrow('exit'); + expect(printed.join('\n')).toContain( + 'status: status must be one of draft, ready, active, paused, ended, completed, cancelled' + ); +});