Repository navigation
Show receipt-timed campaign follow-ups in CLI output #78
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
||
| 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 | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.