import { campaignDispatchComplete, campaignDispatchSummary } from './campaignReport'; import type { Campaign, CampaignNotConfigured } from '../../../services/CampaignService'; // // S4d Phase 2 - how a campaign's state is DESCRIBED to a practice. // // Pure functions, deliberately in their own module so the wording is testable without rendering. // The wording is the product here: every string below answers "what do I do now?", because the // states a practice actually hits are the ones where the sender is refusing. // /** * THE REASON A CAMPAIGN CANNOT SEND, IN THE PRACTICE'S WORDS AND WITH THE FIX. * * Proven live on staging 2026-08-06: a campaign whose org has no verified sending domain goes to * `sending`, and the sweep then refuses it EVERY 15 MINUTES, logging `CAMPAIGN_NO_SENDING_IDENTITY` * where only an engineer would ever see it. From the product there was nothing at all - the campaign * simply looked busy for ever. * * These four are the server's own vocabulary (`CampaignNotConfiguredReason`). Do not invent a * fifth here: a reason the sender never emits is a promise the product cannot keep. */ export const NOT_CONFIGURED_COPY: Record = { 'no-sending-identity': { title: 'Your sending domain is not verified yet', fix: 'Verify your domain in Marketing email setup, then publish the DNS records it gives you. ' + 'Marketing email is sent as your practice, so it cannot go out until that is done.', }, 'no-postal-address': { title: 'Your practice needs a postal address', fix: 'Check your primary address or optional override in Marketing email setup. Marketing email must legally ' + 'carry a physical address (CAN-SPAM), so we will not send without one.', }, 'no-timezone': { title: 'Your practice has no time zone set', fix: 'Set your time zone in Organization settings. It decides the quiet hours we never send during.', }, 'unsubscribe-url': { title: 'Unsubscribe links are not configured', fix: 'This one is on us — please contact support. We will not send marketing email without a ' + 'working one-click unsubscribe.', }, }; const couldNotSend = (campaign: Campaign): string => campaign.counts.failed > 0 ? `, ${campaign.counts.failed} could not be sent` : ''; export type CampaignTone = 'neutral' | 'active' | 'good' | 'warn' | 'stopped'; export const CAMPAIGN_TONE_PILL: Record = { neutral: 'ak-pill ak-pill--mute', active: 'ak-pill ak-pill--info', good: 'ak-pill ak-pill--ok', warn: 'ak-pill ak-pill--warn', stopped: 'ak-pill ak-pill--mute', }; /** One line a practice can act on, for any campaign state. */ export function describeCampaign(campaign: Campaign): { label: string; detail: string; tone: CampaignTone } { // CHECKED BEFORE `status`, DELIBERATELY. A blocked campaign's status is `sending` - it looks // healthy and busy, and it will never send anything. This is the single most important line in // the file; without it the product repeats exactly the silence the smoke exposed. if (campaign.notConfigured && campaign.protocolVersion !== 2) { const copy = NOT_CONFIGURED_COPY[campaign.notConfigured]; return { label: 'Cannot send', detail: copy ? copy.title : 'This campaign cannot be sent yet.', tone: 'warn' }; } if (campaign.protocolVersion === 2 && campaign.status !== 'draft') { const summary = campaignDispatchSummary(campaign) + (campaign.notConfigured ? ` ${NOT_CONFIGURED_COPY[campaign.notConfigured]?.title ?? 'Current dispatch prerequisites are not ready.'}` : ''); if (campaign.status === 'cancelled') return { label: 'Cancelled', tone: 'stopped', detail: `New dispatch has stopped. Messages already in flight may still arrive. ${summary}` }; if (campaign.status === 'failed') return { label: 'Failed', tone: 'warn', detail: `${campaign.failureReason ?? 'Dispatch could not finish.'} ${summary}` }; if (campaign.status === 'sent') { const complete = campaignDispatchComplete(campaign); return { label: complete ? 'Dispatch complete' : 'Dispatch pending', tone: complete ? 'good' : 'warn', detail: `${summary} Delivery is reported separately.` }; } if (campaign.status === 'scheduled') return { label: 'Scheduled', tone: 'active', detail: `${campaign.scheduledFor ? `Starts ${new Date(campaign.scheduledFor).toLocaleString()}.` : 'Waiting to start.'} ${summary}` }; const legs = Object.values(campaign.execution?.legs ?? {}); const unknown = legs.some(leg => leg?.state === 'unknown') || Object.values(campaign.channelCounts ?? {}).some(counts => (counts?.unknown ?? 0) > 0); const blocked = !!campaign.notConfigured || legs.some(leg => leg?.state === 'blocked'); return { label: unknown ? 'Dispatch outcome unknown' : blocked ? 'Dispatch paused' : 'Dispatching', tone: unknown || blocked ? 'warn' : 'active', detail: summary }; } switch (campaign.status) { case 'draft': return { label: 'Draft', detail: 'Not scheduled yet.', tone: 'neutral' }; case 'scheduled': return { label: 'Scheduled', detail: campaign.scheduledFor ? `Starts ${new Date(campaign.scheduledFor).toLocaleString()}.` : 'Waiting to start.', tone: 'active', }; case 'sending': // Sending is BATCHED across sweeps, so "in progress" is the honest word - a practice // watching a large campaign will see this for hours, which is normal and not a fault. return { label: 'Sending', detail: `${campaign.counts.sent} sent so far` + couldNotSend(campaign) + '.', tone: 'active', }; case 'sent': // SENT IS NOT DELIVERED, and this line said it was. `counts.sent` is how many messages we // handed to SES; whether any of them arrived is a different measurement, made from delivery // events that most campaigns do not carry at all. The header read "1 delivered." directly // above the report panel saying "Delivery was not tracked for this campaign, so there is // nothing to report here" - one screen asserting a fact and disclaiming it in the same // breath, and the assertion was the half that was wrong. // // `failed` is a SEND failure and is genuinely known, so it keeps its clause, worded as what // it measures. Everything about delivery belongs to the report panel, which is built to // tell "never measured" from "measured zero" and must stay the only thing that speaks to it. return { label: 'Sent', detail: `${campaign.counts.sent} sent` + couldNotSend(campaign) + '.', tone: campaign.counts.sent === 0 && campaign.counts.failed > 0 ? 'warn' : 'good', }; case 'cancelled': return { label: 'Cancelled', detail: campaign.counts.sent > 0 || campaign.counts.failed > 0 ? `Stopped before it finished. ${campaign.counts.sent} sent before then` + couldNotSend(campaign) + '.' : 'Stopped before it finished.', tone: 'stopped', }; case 'failed': return { label: 'Failed', detail: campaign.failureReason ? campaign.failureReason : 'This campaign could not be sent.', tone: 'warn', }; default: // An unknown status must not render as blank - it is a deploy-skew symptom, and silence // about it reads as "nothing to see here". return { label: campaign.status, detail: '', tone: 'neutral' }; } } /** A campaign may only be edited before it has been handed to the sender. */ export const isEditable = (campaign: Campaign): boolean => campaign.status === 'draft'; /** Cancel is offered only where it does something - i.e. the sender still holds it. */ export const isCancellable = (campaign: Campaign): boolean => campaign.status === 'scheduled' || campaign.status === 'sending'; /** A blocked setting opens the existing Marketing setup door at its own section. */ export const campaignSetupDestination = (reason: CampaignNotConfigured | undefined): string | undefined => reason === 'no-postal-address' ? '/dashboard/campaigns/sending-domain#mailing-address' : reason === 'no-sending-identity' ? '/dashboard/campaigns/sending-domain#sending-domain' : undefined;