import { useCallback, useEffect, useRef, useState } from 'react'; import type { CampaignSegment, CampaignAudiencePreview, SuppressionCounts } from '../../../services/CampaignService'; import { segmentsEqual } from './campaignSegment'; import type { RefusalReason } from './campaignRefusal'; import { readRefusal } from './campaignRefusal'; import { HttpError } from '../../../services/HttpService'; // // S4d Phase 2 slice 2 - the audience count is a PRECONDITION FOR SENDING, not a preview. // // POST /send and POST /schedule both run assertAcknowledgedAudience, which needs a server-stored // preview row for THIS EXACT segment - complete, fresh, untruncated - plus an acknowledgedCount. // Without it the send is refused. So this module owns "may we send yet?". // export type AudienceCountState = | { kind: 'idle' } | { kind: 'counting'; chunks: number } | { kind: 'counted'; segment: CampaignSegment; eligible: number; matched: number; byReason: SuppressionCounts; at: number } | { kind: 'failed'; reason: RefusalReason; message: string; mustNarrow: boolean; retryable: boolean }; export const IDLE: AudienceCountState = { kind: 'idle' }; /** * DELIBERATELY SHORTER THAN THE SERVER'S 24h `PREVIEW_TTL_SECONDS` - 23h. * * This is NOT an attempt to mirror the server's rule (that would be the duplication trap this slice * avoids in `segmentsEqual`). It exists so the client stops offering Send BEFORE the server would * start refusing. Erring short costs a re-count; erring long produces a live button and a confusing * refusal. */ export const PREVIEW_FRESHNESS_MS = 23 * 60 * 60 * 1000; /** * The walk advances ~100 candidates per call. This bounds the client loop so a paged walk cannot * present as an indefinite spinner - definition.ts names exactly that shape ("minutes of wall-clock * with NO error, presenting as a slow network rather than as a bug"). */ export const MAX_COUNT_CHUNKS = 200; /** * THE ONE PREDICATE IN THIS SLICE THAT MUST NOT BE WRONG. All three terms are load-bearing; see * the tests, each of which names what breaks when its term is removed. */ export function canSend( state: AudienceCountState, currentSegment: CampaignSegment, now: number, ): boolean { if (state.kind !== 'counted') return false; if (!segmentsEqual(state.segment, currentSegment)) return false; return now - state.at < PREVIEW_FRESHNESS_MS; } /** Send now additionally needs someone to send to; scheduling keeps `canSend`. */ export function canSendNow( state: AudienceCountState, currentSegment: CampaignSegment, now: number, ): boolean { return canSend(state, currentSegment, now) && state.kind === 'counted' && state.eligible > 0; } type PreviewFn = ( segment: CampaignSegment, cursor?: string | null, ) => Promise; const TOO_LARGE = 'This audience is too large to count here. Narrow the segment — by age range, ' + 'gender, location or a shorter visit window — and count it again.'; /** * Runs the chunked audience walk to completion. * * `eligible` IS A RUNNING PARTIAL UNTIL `done` - the server names it `matchedSoFar` in its own * audit line for exactly this reason. Only the final chunk's figure is an answer, so nothing * intermediate is exposed as a count; progress is reported as chunks completed. */ export function useAudienceCount(previewAudience: PreviewFn) { const [state, setState] = useState(IDLE); const generation = useRef(0); useEffect(() => () => { generation.current += 1; }, []); const reset = useCallback(() => { generation.current += 1; setState(IDLE); }, []); const count = useCallback(async (segment: CampaignSegment) => { const mine = ++generation.current; const publish = (next: AudienceCountState) => { if (mine === generation.current) setState(next); }; publish({ kind: 'counting', chunks: 0 }); let cursor: string | null = null; let restarted = false; for (let chunks = 0; chunks < MAX_COUNT_CHUNKS; chunks += 1) { if (mine !== generation.current) return; let result: CampaignAudiencePreview; try { result = await previewAudience(segment, cursor); } catch (error) { if (mine !== generation.current) return; // 410 Gone: the walk this cursor names can no longer be added to. Restart from the // beginning ONCE. A second 410 means restarting is not working, and looping on it // would be the indefinite-spinner failure this cap exists to prevent. if (error instanceof HttpError && error.status === 410 && !restarted) { restarted = true; cursor = null; publish({ kind: 'counting', chunks: chunks + 1 }); continue; } // Everything else STOPS, including 422 truncated - re-running an identical // segment truncates identically, so a retry cannot terminate. const refusal = readRefusal(error); publish({ kind: 'failed', reason: refusal.reason, message: refusal.message, mustNarrow: refusal.mustNarrow || (error instanceof HttpError && error.status === 422), retryable: refusal.retryable, }); return; } if (mine !== generation.current) return; if (result.done) { publish({ kind: 'counted', segment, eligible: result.eligible, matched: result.matched, byReason: result.suppressedByReason, at: Date.now(), }); return; } cursor = result.cursor; publish({ kind: 'counting', chunks: chunks + 1 }); } // The cap. Its remedy is the same as a truncated walk's, and its message must not read as a // server error - nothing failed, the audience is simply larger than we will count here. publish({ kind: 'failed', reason: 'preview-truncated', message: TOO_LARGE, mustNarrow: true, retryable: false, }); }, [previewAudience]); return { state, count, reset }; }