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
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/commands/campaign/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
144 changes: 144 additions & 0 deletions src/services/campaign-follow-ups.ts
Original file line number Diff line number Diff line change
@@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const REPORT_LABELS: Readonly<Record<(typeof REPORT_KEYS)[number], string>> = {
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<Record<string, string>> = {
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<string, unknown>)[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
}
13 changes: 13 additions & 0 deletions src/services/campaigns.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import {
followUpCell,
renderFollowUpAnalytics,
renderFollowUpConfiguration,
} from './campaign-follow-ups.js'
import {
type AddCampaignRecipientsResult,
type Campaign,
Expand Down Expand Up @@ -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')
}

Expand All @@ -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')
}

Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
Loading