From b3afe28d2d92174c42de9df160eda84351900cd9 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 11 Oct 2026 21:18:02 +0300 Subject: [PATCH 1/3] Show receipt-timed campaign follow-ups in CLI output campaign view and create show a follow-up's message type, wait and expiry, never its text. campaign analytics adds follow-up outcomes after the first-message counts. campaign recipients list adds a follow-up status and reason column when a recipient has one. The published SDK pin omits these beta fields, so the CLI narrows them at runtime and reports an unreadable shape instead of printing it partially. API contract: polymorfa/polymorfa#503. --- README.md | 28 ++++++ src/commands/campaign/create.ts | 3 +- src/services/campaign-follow-ups.ts | 108 ++++++++++++++++++++++ src/services/campaigns.ts | 11 +++ test/commands/campaign-follow-ups.test.ts | 99 ++++++++++++++++++++ 5 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 src/services/campaign-follow-ups.ts create mode 100644 test/commands/campaign-follow-ups.test.ts diff --git a/README.md b/README.md index 8d249c91..9a2b5a81 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..1365d15c --- /dev/null +++ b/src/services/campaign-follow-ups.ts @@ -0,0 +1,108 @@ +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 + +function duration(seconds: number): string { + const unit = (value: number, name: string) => + `${value} ${name}${value === 1 ? '' : 's'}` + if (seconds % 86_400 === 0) return unit(seconds / 86_400, 'day') + if (seconds % 3600 === 0) return unit(seconds / 3600, 'hour') + return unit(Math.round(seconds / 60), 'minute') +} + +/** 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') + return [ + '', + ...REPORT_KEYS.map((key, index) => `${REPORT_LABELS[key]}\t${counts[index]}`), + `Next follow-up due\t${typeof nextDueAt === 'number' ? new Date(nextDueAt).toISOString() : 'none'}`, + ] +} + +/** 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') return 'unreadable' + const reason = field(value, 'reason') + const label = STATUS_LABELS[status] ?? terminalText(status) + return typeof reason === 'string' && reason + ? `${label} (${terminalText(reason)})` + : label +} diff --git a/src/services/campaigns.ts b/src/services/campaigns.ts index 89a2b155..1e9ba570 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,9 @@ 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 +299,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..d13b8ab8 --- /dev/null +++ b/test/commands/campaign-follow-ups.test.ts @@ -0,0 +1,99 @@ +import {runCommand} from '@oclif/test' +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, + } +} From 39b39c850ca191317c882682fb529873e6faca22 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 11 Oct 2026 21:21:24 +0300 Subject: [PATCH 2/3] Format follow-up output sources --- README.md | 2 +- src/services/campaign-follow-ups.ts | 24 ++- src/services/campaigns.ts | 4 +- test/commands/campaign-follow-ups.test.ts | 183 ++++++++++++++++++---- 4 files changed, 175 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index 9a2b5a81..efbdc3c2 100644 --- a/README.md +++ b/README.md @@ -1741,7 +1741,7 @@ message from the number that sent the first one: "followUp": { "delaySeconds": 86400, "expirySeconds": 604800, - "message": { "type": "text", "text": "Still interested?" } + "message": {"type": "text", "text": "Still interested?"} } } ``` diff --git a/src/services/campaign-follow-ups.ts b/src/services/campaign-follow-ups.ts index 1365d15c..3ee9653f 100644 --- a/src/services/campaign-follow-ups.ts +++ b/src/services/campaign-follow-ups.ts @@ -13,8 +13,18 @@ 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', + 'total', + 'waiting', + 'scheduled', + 'sending', + 'sent', + 'delivered', + 'read', + 'replied', + 'cancelled', + 'expired', + 'failed', + 'unknown', ] as const const REPORT_LABELS: Readonly> = { @@ -68,7 +78,11 @@ export function renderFollowUpConfiguration(campaign: object): string[] { const message = field(value, 'message') const type = message && typeof message === 'object' ? field(message, 'type') : undefined - if (!isCount(delay) || !isCount(expiry) || (type !== 'text' && type !== 'template')) + if ( + !isCount(delay) || + !isCount(expiry) || + (type !== 'text' && type !== 'template') + ) return [`Follow-up\t${UNREADABLE}`] return [ `Follow-up\t${type === 'template' ? 'template' : 'text'} message`, @@ -88,7 +102,9 @@ export function renderFollowUpAnalytics(analytics: object): string[] { const nextDueAt = field(value, 'nextDueAt') return [ '', - ...REPORT_KEYS.map((key, index) => `${REPORT_LABELS[key]}\t${counts[index]}`), + ...REPORT_KEYS.map( + (key, index) => `${REPORT_LABELS[key]}\t${counts[index]}`, + ), `Next follow-up due\t${typeof nextDueAt === 'number' ? new Date(nextDueAt).toISOString() : 'none'}`, ] } diff --git a/src/services/campaigns.ts b/src/services/campaigns.ts index 1e9ba570..b00eeeff 100644 --- a/src/services/campaigns.ts +++ b/src/services/campaigns.ts @@ -290,7 +290,9 @@ export function renderCampaignRecipients( 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 followUps = result.data.some( + (recipient) => followUpCell(recipient) !== '', + ) const rows = result.data.map((recipient) => [ recipient.id, diff --git a/test/commands/campaign-follow-ups.test.ts b/test/commands/campaign-follow-ups.test.ts index d13b8ab8..905f1526 100644 --- a/test/commands/campaign-follow-ups.test.ts +++ b/test/commands/campaign-follow-ups.test.ts @@ -9,8 +9,19 @@ import { } 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, + 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)', () => { @@ -23,48 +34,133 @@ describe('campaign follow-up output (beta)', () => { 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: { + ...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')) + 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') + 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()}`) + 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}}, + { + 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')) + 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.') + 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, + 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}, + 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')) + 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') @@ -74,26 +170,49 @@ describe('campaign follow-up output (beta)', () => { 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) + 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, + 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, + campaignId: 'campaign_1', + deliveredCount: 8, + failedCount: 1, + readCount: 6, + recipientCount: 10, + respondedCount: 2, + responseRate: 0.2, + sentCount: 9, + skippedCount: 0, } } From cc0836d81c23e532efa2e990adf0787348c7695c Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 11 Oct 2026 21:34:00 +0300 Subject: [PATCH 3/3] Narrow follow-up timestamps and statuses strictly; render exact durations --- src/services/campaign-follow-ups.ts | 38 +++++++++++++----- test/commands/campaign-follow-ups.test.ts | 48 +++++++++++++++++++++++ 2 files changed, 77 insertions(+), 9 deletions(-) diff --git a/src/services/campaign-follow-ups.ts b/src/services/campaign-follow-ups.ts index 3ee9653f..5426030c 100644 --- a/src/services/campaign-follow-ups.ts +++ b/src/services/campaign-follow-ups.ts @@ -60,14 +60,29 @@ function field(value: object, key: string): unknown { const isCount = (value: unknown): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 -function duration(seconds: number): string { - const unit = (value: number, name: string) => - `${value} ${name}${value === 1 ? '' : 's'}` - if (seconds % 86_400 === 0) return unit(seconds / 86_400, 'day') - if (seconds % 3600 === 0) return unit(seconds / 3600, 'hour') - return unit(Math.round(seconds / 60), 'minute') +/** 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') @@ -100,12 +115,14 @@ export function renderFollowUpAnalytics(analytics: object): string[] { 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${typeof nextDueAt === 'number' ? new Date(nextDueAt).toISOString() : 'none'}`, + `Next follow-up due\t${nextDueAt === null ? 'none' : new Date(nextDueAt).toISOString()}`, ] } @@ -115,9 +132,12 @@ export function followUpCell(recipient: object): string { if (value === undefined || value === null) return '' if (typeof value !== 'object') return 'unreadable' const status = field(value, 'status') - if (typeof status !== 'string') return 'unreadable' + if (typeof status !== 'string' || !Object.hasOwn(STATUS_LABELS, status)) + return 'unreadable' const reason = field(value, 'reason') - const label = STATUS_LABELS[status] ?? terminalText(status) + 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/test/commands/campaign-follow-ups.test.ts b/test/commands/campaign-follow-ups.test.ts index 905f1526..5fbb5c67 100644 --- a/test/commands/campaign-follow-ups.test.ts +++ b/test/commands/campaign-follow-ups.test.ts @@ -1,4 +1,10 @@ import {runCommand} from '@oclif/test' + +import { + duration, + followUpCell, + renderFollowUpAnalytics, +} from '../../src/services/campaign-follow-ups.js' import {afterEach, describe, expect, test} from 'vitest' import { @@ -216,3 +222,45 @@ function analytics() { 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', + ) + }) +})