diff --git a/README.md b/README.md index 8d249c91..efbdc3c2 100644 --- a/README.md +++ b/README.md @@ -1730,6 +1730,34 @@ Requeue also accepts the header, but the API does not deduplicate it. Each invocation moves recipients eligible at that time, so a retry can move newly failed recipients rather than returning the first result. +#### Receipt-timed follow-ups (beta) + +Add `followUp` to the `campaign create` input to send each recipient one more +message from the number that sent the first one: + +```json +{ + "name": "Spring offer", + "followUp": { + "delaySeconds": 86400, + "expirySeconds": 604800, + "message": {"type": "text", "text": "Still interested?"} + } +} +``` + +The wait starts at the first delivery, and the first read restarts it until +sending starts. A follow-up not sent before `expirySeconds` (counted from the +first send) never sends; replies and opt-outs cancel it. The campaign must send +a single first message, and follow-up text cannot contain tracked links. A team +that is not enrolled receives `403 feature_unavailable`. There is no campaign +update command; change or remove a draft's follow-up with `polymorfa api`. + +`campaign view` shows the follow-up's message type, wait and expiry, never its +text. `campaign analytics` adds follow-up outcomes after the first-message +counts, and `campaign recipients list` adds a follow-up status column when a +recipient has one. `--json` returns the API response unchanged. + ### Campaign recipients Recipients are appended to a campaign that has not started sending, and read diff --git a/src/commands/campaign/create.ts b/src/commands/campaign/create.ts index be812749..7b31018e 100644 --- a/src/commands/campaign/create.ts +++ b/src/commands/campaign/create.ts @@ -6,7 +6,8 @@ import {idempotencyKey} from '../../input/idempotency.js' import {createCampaign, renderCampaign} from '../../services/campaigns.js' export default class CampaignCreate extends BaseCommand { - static override description = 'Create a campaign from one JSON object.' + static override description = + 'Create a campaign from one JSON object. Optional beta fields include followUp: one receipt-timed follow-up ({delaySeconds, expirySeconds, message: {type: "text", text} or {type: "template", templateId}}); teams that are not enrolled receive 403 feature_unavailable.' static override flags = { 'idempotency-key': Flags.string({ diff --git a/src/services/campaign-follow-ups.ts b/src/services/campaign-follow-ups.ts new file mode 100644 index 00000000..5426030c --- /dev/null +++ b/src/services/campaign-follow-ups.ts @@ -0,0 +1,144 @@ +import {terminalText} from '../developer/render.js' + +/* + * Receipt-timed campaign follow-ups (beta). + * + * The published SDK pin omits these fields, so the CLI reads them as unknown + * values and narrows them here. The shapes follow the API's CampaignFollowUp, + * CampaignFollowUpSummary and CampaignFollowUpReport schemas. A value that + * does not match is reported as unreadable instead of being printed partially. + */ + +const UNREADABLE = + 'Unreadable in this CLI version; use --json for the full response.' + +const REPORT_KEYS = [ + 'total', + 'waiting', + 'scheduled', + 'sending', + 'sent', + 'delivered', + 'read', + 'replied', + 'cancelled', + 'expired', + 'failed', + 'unknown', +] as const + +const REPORT_LABELS: Readonly> = { + cancelled: 'Follow-ups cancelled', + delivered: 'Follow-ups delivered', + expired: 'Follow-ups expired', + failed: 'Follow-ups failed', + read: 'Follow-ups read', + replied: 'Replies after follow-up', + scheduled: 'Follow-ups scheduled', + sending: 'Follow-ups sending', + sent: 'Follow-ups sent', + total: 'Recipients with a follow-up', + unknown: 'Follow-up outcome unknown', + waiting: 'Follow-ups waiting for a receipt', +} + +const STATUS_LABELS: Readonly> = { + cancelled: 'cancelled', + expired: 'expired', + failed: 'failed', + outcome_unknown: 'outcome unknown', + scheduled: 'scheduled', + sending: 'sending', + sent: 'sent', + waiting_receipt: 'waiting for a receipt', +} + +function field(value: object, key: string): unknown { + return (value as Record)[key] +} + +const isCount = (value: unknown): value is number => + typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 + +/** Exact duration in the largest whole units, for example `1 day 2 hours` or `1 minute 30 seconds`. */ +export function duration(seconds: number): string { + const parts: string[] = [] + let rest = seconds + for (const [size, name] of [ + [86_400, 'day'], + [3600, 'hour'], + [60, 'minute'], + [1, 'second'], + ] as const) { + const value = Math.floor(rest / size) + rest -= value * size + if (value > 0) parts.push(`${value} ${name}${value === 1 ? '' : 's'}`) + } + return parts.length > 0 ? parts.join(' ') : '0 seconds' +} + +// Dates the JavaScript Date range can represent. +const isTimestamp = (value: unknown): value is number => + typeof value === 'number' && + Number.isSafeInteger(value) && + Math.abs(value) <= 8.64e15 + +/** Human lines for `campaign view` and `campaign create`; empty without a follow-up. */ +export function renderFollowUpConfiguration(campaign: object): string[] { + const value = field(campaign, 'followUp') + if (value === undefined || value === null) return [] + if (typeof value !== 'object') return [`Follow-up\t${UNREADABLE}`] + const delay = field(value, 'delaySeconds') + const expiry = field(value, 'expirySeconds') + const message = field(value, 'message') + const type = + message && typeof message === 'object' ? field(message, 'type') : undefined + if ( + !isCount(delay) || + !isCount(expiry) || + (type !== 'text' && type !== 'template') + ) + return [`Follow-up\t${UNREADABLE}`] + return [ + `Follow-up\t${type === 'template' ? 'template' : 'text'} message`, + `Follow-up wait\t${duration(delay)} after first delivery or read`, + `Follow-up expiry\t${duration(expiry)} after the first send`, + ] +} + +/** Human lines for `campaign analytics`; empty when no recipient has a follow-up. */ +export function renderFollowUpAnalytics(analytics: object): string[] { + const value = field(analytics, 'followUps') + if (value === undefined || value === null) return [] + if (typeof value !== 'object') return ['', `Follow-ups\t${UNREADABLE}`] + const counts = REPORT_KEYS.map((key) => field(value, key)) + if (!counts.every(isCount)) return ['', `Follow-ups\t${UNREADABLE}`] + if (counts[0] === 0) return [] + const nextDueAt = field(value, 'nextDueAt') + if (nextDueAt !== null && !isTimestamp(nextDueAt)) + return ['', `Follow-ups\t${UNREADABLE}`] + return [ + '', + ...REPORT_KEYS.map( + (key, index) => `${REPORT_LABELS[key]}\t${counts[index]}`, + ), + `Next follow-up due\t${nextDueAt === null ? 'none' : new Date(nextDueAt).toISOString()}`, + ] +} + +/** One recipient's follow-up as a table cell: status, then reason. Empty without one. */ +export function followUpCell(recipient: object): string { + const value = field(recipient, 'followUp') + if (value === undefined || value === null) return '' + if (typeof value !== 'object') return 'unreadable' + const status = field(value, 'status') + if (typeof status !== 'string' || !Object.hasOwn(STATUS_LABELS, status)) + return 'unreadable' + const reason = field(value, 'reason') + if (reason !== null && reason !== undefined && typeof reason !== 'string') + return 'unreadable' + const label = STATUS_LABELS[status] ?? 'unreadable' + return typeof reason === 'string' && reason + ? `${label} (${terminalText(reason)})` + : label +} diff --git a/src/services/campaigns.ts b/src/services/campaigns.ts index 89a2b155..b00eeeff 100644 --- a/src/services/campaigns.ts +++ b/src/services/campaigns.ts @@ -1,3 +1,8 @@ +import { + followUpCell, + renderFollowUpAnalytics, + renderFollowUpConfiguration, +} from './campaign-follow-ups.js' import { type AddCampaignRecipientsResult, type Campaign, @@ -219,6 +224,7 @@ export function renderCampaign(campaign: Campaign): string { `Failed\t${campaign.failedCount}`, `Skipped\t${campaign.skippedCount}`, `Scheduled\t${formatCampaignTime(campaign.scheduledAt)}`, + ...renderFollowUpConfiguration(campaign), ].join('\n') } @@ -233,6 +239,7 @@ export function renderCampaignAnalytics(analytics: CampaignAnalytics): string { `Skipped\t${analytics.skippedCount}`, `Responded\t${analytics.respondedCount}`, `Response rate\t${analytics.responseRate}`, + ...renderFollowUpAnalytics(analytics), ].join('\n') } @@ -281,6 +288,11 @@ export function renderCampaignRecipients( result: CampaignRecipientPageResult, ): string { if (result.data.length === 0) return 'No recipients found.' + // The follow-up column appears only when a recipient has a follow-up, so + // campaigns without the beta keep their existing columns. + const followUps = result.data.some( + (recipient) => followUpCell(recipient) !== '', + ) const rows = result.data.map((recipient) => [ recipient.id, @@ -289,6 +301,7 @@ export function renderCampaignRecipients( String(recipient.attempts), recipient.lastError ?? '', formatCampaignTime(recipient.sentAt), + ...(followUps ? [followUpCell(recipient)] : []), ].join('\t'), ) if (result.page.hasMore && result.page.nextCursor !== null) { diff --git a/test/commands/campaign-follow-ups.test.ts b/test/commands/campaign-follow-ups.test.ts new file mode 100644 index 00000000..5fbb5c67 --- /dev/null +++ b/test/commands/campaign-follow-ups.test.ts @@ -0,0 +1,266 @@ +import {runCommand} from '@oclif/test' + +import { + duration, + followUpCell, + renderFollowUpAnalytics, +} from '../../src/services/campaign-follow-ups.js' +import {afterEach, describe, expect, test} from 'vitest' + +import { + startHttpSequenceServer, + startHttpServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' + +const report = { + cancelled: 1, + delivered: 3, + expired: 0, + failed: 0, + nextDueAt: 1_760_000_000_000, + read: 2, + replied: 1, + scheduled: 2, + sending: 0, + sent: 3, + total: 8, + unknown: 1, + waiting: 2, +} + +describe('campaign follow-up output (beta)', () => { + let server: TestHttpServer | undefined + + afterEach(async () => { + await server?.close() + server = undefined + }) + + test('shows follow-up timing on view and outcomes on analytics, apart from first-message counts', async () => { + server = await startHttpSequenceServer([ + { + body: { + data: { + ...campaign(), + followUp: { + delaySeconds: 86_400, + expirySeconds: 604_800, + message: {text: 'Still interested?', type: 'text'}, + }, + }, + success: true, + }, + }, + {body: {data: {...analytics(), followUps: report}, success: true}}, + ]) + const view = await apiKey(() => + runCommand('campaign view campaign_1 --project-slug project_1'), + ) + const result = await apiKey(() => + runCommand('campaign analytics campaign_1 --project-slug project_1'), + ) + expect(view.error).toBeUndefined() + expect(view.stdout).toContain('Follow-up\ttext message') + expect(view.stdout).toContain( + 'Follow-up wait\t1 day after first delivery or read', + ) + expect(view.stdout).toContain( + 'Follow-up expiry\t7 days after the first send', + ) + // The message body never reaches human output. + expect(view.stdout).not.toContain('Still interested?') + expect(result.stdout).toContain('Sent\t9') + expect(result.stdout).toContain('Recipients with a follow-up\t8') + expect(result.stdout).toContain('Replies after follow-up\t1') + expect(result.stdout).toContain('Follow-up outcome unknown\t1') + expect(result.stdout).toContain( + `Next follow-up due\t${new Date(1_760_000_000_000).toISOString()}`, + ) + }) + + test('keeps existing output for campaigns without a follow-up and reports unreadable shapes', async () => { + server = await startHttpSequenceServer([ + { + body: { + data: { + ...analytics(), + followUps: { + ...report, + cancelled: 0, + delivered: 0, + read: 0, + replied: 0, + scheduled: 0, + sent: 0, + total: 0, + unknown: 0, + waiting: 0, + nextDueAt: null, + }, + }, + success: true, + }, + }, + { + body: { + data: { + ...campaign(), + followUp: {delaySeconds: 'soon', message: {type: 'text'}}, + }, + success: true, + }, + }, + ]) + const result = await apiKey(() => + runCommand('campaign analytics campaign_1 --project-slug project_1'), + ) + const view = await apiKey(() => + runCommand('campaign view campaign_1 --project-slug project_1'), + ) + expect(result.stdout).not.toContain('follow-up') + expect(view.stdout).toContain( + 'Follow-up\tUnreadable in this CLI version; use --json for the full response.', + ) + }) + + test('adds a follow-up column to recipients only when a recipient has one', async () => { + const recipient = (id: string, followUp: unknown) => ({ + attempts: 1, + deliveredAt: null, + externalMessageId: null, + failedAt: null, + followUp, + id, + lastError: null, + phone: '+15550100101', + queuedAt: 1, + readAt: null, + respondedAt: null, + sentAt: null, + status: 'read', + variables: {}, + variantKey: null, + }) + server = await startHttpServer({ + body: { + data: [ + recipient('r1', { + anchorKind: 'read', + reason: null, + status: 'scheduled', + }), + recipient('r2', { + anchorKind: 'read', + reason: 'reply', + status: 'cancelled', + }), + recipient('r3', null), + ], + page: {hasMore: false, nextCursor: null}, + success: true, + }, + }) + const result = await apiKey(() => + runCommand( + 'campaign recipients list campaign_1 --project-slug project_1', + ), + ) + expect(result.error).toBeUndefined() + const lines = result.stdout.trim().split('\n') + expect(lines[0]?.split('\t').at(-1)).toBe('scheduled') + expect(lines[1]?.split('\t').at(-1)).toBe('cancelled (reply)') + expect(lines[2]).not.toMatch(/scheduled|cancelled|unreadable/) + }) + + async function apiKey(run: () => Promise): Promise { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + run, + ) + } +}) + +function campaign() { + return { + completedAt: null, + createdAt: 1_723_000_000_000, + deliveredCount: 8, + failedCount: 1, + id: 'campaign_1', + launchedAt: 1_723_000_100_000, + name: 'September launch', + readCount: 6, + recipientCount: 10, + recipientListId: 'recipients_1', + scheduledAt: null, + sentCount: 9, + skippedCount: 0, + status: 'running', + templateId: null, + updatedAt: 1_723_000_200_000, + } +} + +function analytics() { + return { + campaignId: 'campaign_1', + deliveredCount: 8, + failedCount: 1, + readCount: 6, + recipientCount: 10, + respondedCount: 2, + responseRate: 0.2, + sentCount: 9, + skippedCount: 0, + } +} + +describe('follow-up narrowing', () => { + test('renders exact durations without rounding', () => { + expect(duration(90)).toBe('1 minute 30 seconds') + expect(duration(86_400)).toBe('1 day') + expect(duration(93_600)).toBe('1 day 2 hours') + }) + + test('reports invalid next-due times and unknown statuses as unreadable', () => { + const counts = { + cancelled: 0, + delivered: 0, + expired: 0, + failed: 0, + read: 0, + replied: 0, + scheduled: 1, + sending: 0, + sent: 0, + total: 1, + unknown: 0, + waiting: 0, + } + for (const nextDueAt of [1e20, 'tomorrow', 1.5]) + expect( + renderFollowUpAnalytics({followUps: {...counts, nextDueAt}}), + ).toEqual([ + '', + 'Follow-ups\tUnreadable in this CLI version; use --json for the full response.', + ]) + expect(followUpCell({followUp: {status: 'constructor'}})).toBe('unreadable') + expect(followUpCell({followUp: {status: 'future_state'}})).toBe( + 'unreadable', + ) + expect(followUpCell({followUp: {reason: 42, status: 'sent'}})).toBe( + 'unreadable', + ) + expect(followUpCell({followUp: {reason: null, status: 'sent'}})).toBe( + 'sent', + ) + }) +})