diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ca53696 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.md text eol=lf +*.mjs text eol=lf diff --git a/docs/commands.md b/docs/commands.md index 74a88ed..832e902 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -1358,6 +1358,12 @@ records that are not prospects. Bulk archive ends mail and keeps list membership it closes open opportunities unless `--no-cascade` is supplied. A positional `prospect_...` ID continues to use the single-prospect operation. +### Repeat forever + +Campaign step JSON accepts `repeat_forever: true` on the final step with `interval_days` of 21, 30, 45, 60, 90, 180, or 365. Earlier steps finish first. Read the cost estimate and enable auto-reload before sending; later pieces are charged when they go out. The CLI shows Forever for recurring steps; their bounded `repeat_count` is not a stopping limit. + +Use `dm mail settings update --body` with all three fields `default_single_send_interval_days`, `default_single_send_repeat_count`, and `default_single_send_repeat_forever` to save the workspace default for single postcards on web and mobile. This does not send mail or change existing campaigns. + ### International Owner Property search, count and export accept `international_owner` in the `--body` filters array. Use `true` for foreign mailing addresses, `false` to exclude them, or omit the filter for Any. The source is `MailingForeignAddressInd`: Canada (`C`) and other foreign addresses (`Y`) are international; null values and US territories are excluded. diff --git a/src/commands/mail.ts b/src/commands/mail.ts index e6f486d..515c30e 100644 --- a/src/commands/mail.ts +++ b/src/commands/mail.ts @@ -51,6 +51,7 @@ interface CampaignStep { delay_days: number; interval_days: number; repeat_count: number; + repeat_forever?: boolean; skip_if_responded: boolean; } @@ -101,6 +102,9 @@ interface CostEstimateResponse { price_per_card_cents: number; total_cost_cents: number; total_cost_dollars: number; + repeat_forever?: boolean; + repeat_interval_days?: number | null; + recurring_cost_cents?: number | null; pay_now_cost_cents: number; pay_later_cost_cents: number; wallet_balance_cents: number; @@ -373,7 +377,7 @@ export async function mailCampaignsGet(id: string, options: { json?: boolean }): name: s.step_name || '—', design: s.design_id ? truncate(s.design_id, 16) : '(prompt)', delay: `${s.delay_days}d`, - repeat: s.repeat_count, + repeat: s.repeat_forever ? `Forever, every ${s.interval_days}d` : s.repeat_count, })); printTable(stepRows, ['order', 'name', 'design', 'delay', 'repeat']); } @@ -601,7 +605,8 @@ export async function mailCampaignsCostEstimate( printKeyValue({ Recipients: e.recipient_count, 'Price Per Card': `$${(e.price_per_card_cents / 100).toFixed(2)}`, - 'Total Cost': `$${e.total_cost_dollars.toFixed(2)}`, + [e.repeat_forever ? 'Scheduled Cost' : 'Total Cost']: `$${e.total_cost_dollars.toFixed(2)}`, + ...(e.repeat_forever ? { Repeats: `Every ${e.repeat_interval_days} days until stopped`, 'Each Repeat': `$${((e.recurring_cost_cents ?? 0) / 100).toFixed(2)}` } : {}), 'Pay Now': `$${(e.pay_now_cost_cents / 100).toFixed(2)}`, 'Pay Later': `$${(e.pay_later_cost_cents / 100).toFixed(2)}`, 'Wallet Balance': `$${(e.wallet_balance_cents / 100).toFixed(2)}`, @@ -1445,6 +1450,9 @@ interface MailSettings { contact_frequency_limit_days: number | null; default_qr_code_url: string | null; default_signature_image_url: string | null; + default_single_send_interval_days?: number | null; + default_single_send_repeat_count?: number | null; + default_single_send_repeat_forever?: boolean | null; } interface MailSettingsResponse { @@ -1460,6 +1468,7 @@ function printMailSettings(s: MailSettings): void { : '— (1 day)', 'Default QR Code URL': s.default_qr_code_url ?? '—', 'Default Signature Image': s.default_signature_image_url ?? '—', + 'Default Postcard Schedule': s.default_single_send_repeat_forever ? `Repeat forever, every ${s.default_single_send_interval_days ?? 21} days` : s.default_single_send_interval_days === 0 ? 'Just once' : `${s.default_single_send_repeat_count ?? 6} sends, every ${s.default_single_send_interval_days ?? 21} days`, }); } diff --git a/tests/commands/mailCampaigns.test.ts b/tests/commands/mailCampaigns.test.ts index 1defa04..0332860 100644 --- a/tests/commands/mailCampaigns.test.ts +++ b/tests/commands/mailCampaigns.test.ts @@ -37,6 +37,7 @@ import { mailCampaignsAddProperties, mailCampaignsEnd, mailCampaignsArchive, + mailCampaignsCostEstimate, mailCampaignsEstimateAdd, mailCampaignsList, parsePropertyIds, @@ -185,6 +186,35 @@ describe('dm mail campaigns', () => { expect(process.exitCode).toBe(1); }); + const costEstimate = { + recipient_count: 10, + price_per_card_cents: 75, + total_cost_cents: 1500, + total_cost_dollars: 15, + pay_now_cost_cents: 750, + pay_later_cost_cents: 750, + wallet_balance_cents: 10000, + wallet_available_cents: 10000, + can_afford: true, + auto_reload_available: true, + }; + + it('shows the total cost of a finite campaign in dollars', async () => { + mockApiRequest.mockResolvedValueOnce({ data: costEstimate }); + await mailCampaignsCostEstimate('camp_1', {}); + expect(printKeyValue).toHaveBeenLastCalledWith(expect.objectContaining({ 'Total Cost': '$15.00' })); + }); + + it('shows the scheduled and repeat costs of a repeating campaign in dollars', async () => { + mockApiRequest.mockResolvedValueOnce({ + data: { ...costEstimate, repeat_forever: true, repeat_interval_days: 30, recurring_cost_cents: 750 }, + }); + await mailCampaignsCostEstimate('camp_1', {}); + expect(printKeyValue).toHaveBeenLastCalledWith( + expect.objectContaining({ 'Scheduled Cost': '$15.00', 'Each Repeat': '$7.50' }) + ); + }); + it('ends a campaign only after the person confirms', async () => { mockConfirm.mockResolvedValueOnce(false).mockResolvedValueOnce(true); mockApiRequest.mockResolvedValue({ diff --git a/tests/release-version-check.test.ts b/tests/release-version-check.test.ts index 27e805f..3239e55 100644 --- a/tests/release-version-check.test.ts +++ b/tests/release-version-check.test.ts @@ -66,7 +66,7 @@ describe('version check before staging and master', () => { writeFileSync(join(checkout, 'npm-alias/package.json'), JSON.stringify({ version, dependencies: { '@dealmachine/cli': version } })); writeFileSync(join(checkout, 'package-lock.json'), JSON.stringify({ version, packages: { '': { version } } })); writeFileSync(join(checkout, 'src/version.ts'), `export const CLI_VERSION = '${version}';\n`); - symlinkSync(join(root, 'scripts'), join(temp, 'linked-scripts')); + symlinkSync(join(root, 'scripts'), join(temp, 'linked-scripts'), process.platform === 'win32' ? 'junction' : 'dir'); const run = spawnSync(process.execPath, [join(temp, 'linked-scripts', 'check-release-version.mjs'), checkout], { encoding: 'utf8', env: { PATH: process.env.PATH, GITHUB_BASE_REF: 'staging' },