import { useCallback, createContext, useContext, ReactNode } from 'react'; import { environment } from '../environments/environment.ts'; import { useHttpService } from './HttpService.tsx'; import { useOptionalAuthServiceContext } from './AuthService.tsx'; import type { SaveTransport } from '../utils/saveOperation'; // // Card 077 S4d Phase 2 — THE CLIENT HALF. The server platform (entity, audience resolution, batched // fan-out, quiet hours, per-send claims, the scheduled sweep) has been merged and deployed for days // and is UNREACHABLE from the product: until this service existed a practice could not create a // campaign at all. // // ON `fhirBaseApi`, NOT `apiUrl`. Every campaign route is declared in 02-fhir-base.yaml, so the // api-service gateway would 404 SILENTLY - the wrong-base-url failure has no error to read. // const API_URL = environment.fhirBaseApi; /** The axes a practice can segment on. Mirrors the server's SegmentDefinition. */ export interface CampaignSegment { ageMin?: number; ageMax?: number; gender?: string; practitionerId?: string; locationId?: string; lastSeenBeforeDays?: number; lastApptStatus?: string; anyStatusWithinDays?: { status: string; days: number }; conditionCodes?: string[]; excludeAlreadyBooked?: boolean; } export type CampaignStatus = 'draft' | 'scheduled' | 'sending' | 'sent' | 'cancelled' | 'failed'; /** * Why a campaign cannot send. Server-side vocabulary - do NOT invent client-side variants; a * practice must be told the same reason the sender used. */ export type CampaignNotConfigured = | 'no-timezone' | 'unsubscribe-url' | 'no-postal-address' | 'no-sending-identity'; /** Card 077 S4e-2 — the practice-authored call to action. ONE per campaign; the target is always * the portal login page (no destination picker). */ export type CampaignCtaStyle = 'text' | 'button'; export interface CampaignCta { label: string; style: CampaignCtaStyle; } /** * Card 077 S4e-1 — how many of `sent` actually carried a campaign tag, i.e. the denominator the * delivery figures are honest against. Absent means the campaign predates delivery tracking. */ export interface CampaignCounts { matched: number; suppressed: number; sent: number; failed: number; trackedSends?: number; } /** * The delivery counters, which live on their OWN DynamoDB item (spec §2.3) because the campaign row * and the SES event handler are two writers with different write semantics. * * The campaign carries `delivery?: CampaignDelivery | null`. **`null` means no delivery row * exists - the campaign was never tracked; an absent key means this response never carried the * field at all.** Final fix wave, CRITICAL 2: only `GET /{id}` (get.ts) attaches `delivery` - * `send`/`schedule`/`cancel`/`list`/`post`/`put` all return a bare `campaignResponse(...)`, so a * campaign object built from any of those genuinely has no `delivery` key. `undefined` and `null` * both honestly mean "no measurement on this payload", and **neither is zero** - it must never be * defaulted to `{}` or to zeroes anywhere in the client. */ export interface CampaignDelivery { delivered?: number; bounced?: number; complained?: number; unsubscribed?: number; /** Card 077 S4e-2 */ clicked?: number; booked?: number; } export type CampaignChannel = 'email' | 'sms'; export type CampaignLegState = 'pending' | 'running' | 'blocked' | 'unknown' | 'dispatch-complete'; export interface CampaignCapabilities { protocolVersion: 2; implementedChannels: CampaignChannel[]; readiness: Partial>; } export interface CampaignChannelCounts { accepted?: number; failedBeforeAcceptance?: number; suppressed?: number; unknown?: number; owed?: number; trackedSends?: number; } export interface CampaignChannelDelivery { accepted?: number; delivered?: number; failed?: number; pending?: number; unknown?: number; bounced?: number; complained?: number; unsubscribed?: number; } export interface CampaignCommand { requestId: string; recordVersion: string; previewId: string; channels: CampaignChannel[]; } export type CampaignTransport = Partial; export interface Campaign { campaignId: string; protocolVersion?: 2; recordVersion?: string; capabilities?: CampaignCapabilities; execution?: { runId?: string; requestId?: string; legacyResumeRequired?: boolean; matchedPeople?: number; continuation?: boolean; legacyCounts?: CampaignCounts; legs?: Partial>; }; channelCounts?: Partial>; channelDelivery?: Partial>; name: string; createdBy: string; createdAt: string; channels: string[]; segment: CampaignSegment; subject: string; body: string; /** * The text message, written separately from `body`. Non-null exactly when `channels` includes * 'sms'; the server refuses an sms channel that arrives without one rather than reusing `body`. */ smsBody: string | null; /** Card 077 S4e-2 — null until the practice adds a login-link CTA. */ cta: CampaignCta | null; status: CampaignStatus; failureReason: string | null; scheduledFor: string | null; acknowledgedCount: number | null; counts: CampaignCounts; delivery?: CampaignDelivery | null; lastSweptAt: string | null; lastBatchAt: string | null; notConfigured?: CampaignNotConfigured; } /** * The audience preview. COUNTS ONLY - there is no recipient list here and there must never be * one: aggregate counts are not PHI, a per-recipient list IS (Spencer, 2026-08-02). */ export interface CampaignAudiencePreview { previewId?: string; channelSetHash?: string; matched: number; eligible: number; matchedSuppressed: boolean; conditionFilterApplied: boolean; truncated: boolean; suppressed: { alreadyBooked: number | null; total: number | null }; suppressedByReason: SuppressionCounts; cursor: string | null; done: boolean; } // The diagnoses a campaign may target, published by the server with the campaign list. A row // selects all of its codes; a segment holding anything else cannot be edited here. export interface ConditionCatalogueRow { id: string; label: string; codes: string[]; } export interface ConditionCatalogue { version: string; rows: ConditionCatalogueRow[]; } // null is a clinical count the server withheld under its small-audience floor. export interface SuppressionCounts { 'no-relationship': number | null; 'not-opted-in': number | null; suppressed: number | null; unreachable: number | null; 'not-entitled': number | null; } export const useCampaignService = () => { const { get, post, put } = useHttpService(); const auth = useOptionalAuthServiceContext(); const options = (transport?: CampaignTransport, version?: string) => ({ ...transport, organizationId: auth?.wpOrganization?.current?.id, headers: { 'X-Campaign-Protocol': '2', ...(version ? { 'If-Match': version } : {}) }, }); const listCampaigns = useCallback( async (transport?: CampaignTransport) => get<{ campaigns: Campaign[]; conditionCatalogue?: ConditionCatalogue; capabilities?: CampaignCapabilities }>(`${API_URL}/campaign`, options(transport)), [get], ); const getCampaign = useCallback( async (campaignId: string, transport?: CampaignTransport) => get(`${API_URL}/campaign/${campaignId}`, options(transport)), [get], ); const createCampaign = useCallback( async (draft: { name: string; channels: string[]; subject: string; body: string; smsBody: string | null; segment: CampaignSegment; cta: { label: string; style: CampaignCtaStyle } | null; }, transport?: CampaignTransport) => post(`${API_URL}/campaign`, draft, options(transport)), [post], ); const updateCampaign = useCallback( async (campaignId: string, draft: { name: string; channels: string[]; subject: string; body: string; smsBody: string | null; segment: CampaignSegment; cta: { label: string; style: CampaignCtaStyle } | null; }, transport?: CampaignTransport, recordVersion?: string) => put(`${API_URL}/campaign/${campaignId}`, draft, options(transport, recordVersion)), [put], ); /** * THE SEGMENT IS SENT SPREAD, NOT NESTED - and that asymmetry with create/update is REAL, not * a mistake here. `POST /R4/campaign` takes `{ segment: {...} }`; this endpoint takes the axes as * TOP-LEVEL fields and answers 400 to a `segment` key. * * DO NOT "TIDY" THIS BY SENDING `{ segment }`. The preview's strictness is load-bearing: its * validator used to DROP unknown keys, so a nested body rebuilt to the EMPTY segment and the * endpoint returned THE WHOLE PRACTICE'S COUNT - confidently, successfully, and larger. That was * caught on live staging. Spreading here is what keeps the count honest. * Carded to make the two endpoints agree; until then this comment is the reason. */ const previewAudience = useCallback( async (segment: CampaignSegment, cursor?: string | null, channels?: CampaignChannel[], transport?: CampaignTransport) => post(`${API_URL}/campaign-audience-preview`, { ...segment, // TOP-LEVEL, ALONGSIDE the axes - `validateSegment` lists `cursor` in // ENDPOINT_TRANSPORT_KEYS and ignores it, and the handler reads it off the raw body. // It must NEVER reach the object sent to create/update: inside the hashed segment // every chunk gets a different segmentHash, so each call starts a fresh walk and the // count never converges - with no error at all. ...(cursor ? { cursor } : {}), ...(channels ? { channels } : {}), }, options(transport)), [post], ); /** * Hand the campaign to the scheduled sender. * * A 200 HERE IS NOT "IT SENT". The response can carry `notConfigured`, meaning the campaign is * now `sending` but the sender will refuse every tick until a human fixes the cause (no verified * domain, no postal address, no timezone, no unsubscribe url). Callers MUST surface that - * proven live on staging, where a campaign sat in `sending` re-refusing silently every 15 * minutes with nothing visible in the product. * * `acknowledgedCount` IS REQUIRED. `assertAcknowledgedAudience` runs on this route and answers * `400 not-acknowledged` without it for any audience at or above SMALL_N_FLOOR (11). It must be * the `eligible` figure from a COMPLETED audience count for this campaign's exact segment. */ const sendCampaign = useCallback( async (campaignId: string, acknowledgedCount: number, command?: CampaignCommand, transport?: CampaignTransport) => post(`${API_URL}/campaign/${campaignId}/send`, { acknowledgedCount, ...command }, options(transport, command?.recordVersion)), [post], ); /** Same guardrail as `sendCampaign` - see its note. */ const scheduleCampaign = useCallback( async (campaignId: string, scheduledFor: string, acknowledgedCount: number, command?: CampaignCommand, transport?: CampaignTransport) => post(`${API_URL}/campaign/${campaignId}/schedule`, { scheduledFor, acknowledgedCount, ...command }, options(transport, command?.recordVersion)), [post], ); const cancelCampaign = useCallback( async (campaignId: string, transport?: CampaignTransport, recordVersion?: string) => post(`${API_URL}/campaign/${campaignId}/cancel`, {}, options(transport, recordVersion)), [post], ); const resumeCampaign = useCallback( async (campaignId: string, acknowledgedCount: number, command: CampaignCommand, transport?: CampaignTransport) => post(`${API_URL}/campaign/${campaignId}/resume`, { acknowledgedCount, ...command, channels: ['sms'], continuation: true }, options(transport, command.recordVersion)), [post], ); return { resumeCampaign, listCampaigns, getCampaign, createCampaign, updateCampaign, previewAudience, sendCampaign, scheduleCampaign, cancelCampaign, }; }; const CampaignServiceContext = createContext | undefined>(undefined); export const CampaignServiceProvider: React.FC<{ children: ReactNode }> = ({ children }) => { const campaignService = useCampaignService(); return ( {children} ); }; export const useCampaignServiceContext = () => { const context = useContext(CampaignServiceContext); if (context === undefined) { throw new Error('useCampaignServiceContext must be used within a CampaignServiceProvider'); } return context; };