import type { CampaignSegment, ConditionCatalogueRow } from '../../../services/CampaignService'; import { selectedConditionRows } from './campaignConditions'; // // S4d Phase 2 slice 2 - the segment, as a PURE value. // // Every bound here mirrors the server's `validateSegment` (campaign-segment/definition.ts) so the // client refuses what the server would refuse. Where they disagree the SERVER is right. // export const GENDERS = ['male', 'female', 'other', 'unknown'] as const; // TWO DISTINCT VALUE SPACES. `cancelled` is legal below and ILLEGAL here: the relationship rollup // derives lastApptStatus only from QUALIFYING appointments, and a cancellation never qualifies - so // offering it would be a filter guaranteed to match nobody. definition.ts states this at length and // keeps the two allowlists separate for the same reason. Do not merge these. export const LAST_APPT_STATUSES = ['booked', 'arrived', 'checked-in', 'fulfilled', 'noshow'] as const; export const ANY_STATUS_WITHIN_DAYS_STATUSES = ['fulfilled', 'noshow', 'cancelled'] as const; export const MIN_AGE = 0; export const MAX_AGE = 130; export const MAX_DAYS = 3650; /** The form's own shape: strings, because that is what inputs hold. Never sent to the server. */ export interface SegmentDraft { ageMin: string; ageMax: string; gender: string; practitionerId: string; locationId: string; lastSeenBeforeDays: string; lastApptStatus: string; anyStatusWithinDaysStatus: string; anyStatusWithinDaysDays: string; excludeAlreadyBooked: boolean; conditionCodes: string[]; } export const EMPTY_DRAFT: SegmentDraft = { ageMin: '', ageMax: '', gender: '', practitionerId: '', locationId: '', lastSeenBeforeDays: '', lastApptStatus: '', anyStatusWithinDaysStatus: '', anyStatusWithinDaysDays: '', excludeAlreadyBooked: false, conditionCodes: [], }; const intOrNull = (raw: string): number | null => { if (raw.trim() === '') return null; const n = Number(raw); return Number.isInteger(n) ? n : null; }; /** * REBUILT FIELD BY FIELD, NEVER SPREAD - the same discipline the server's validateSegment states. * An unrecognised TOP-LEVEL key is a 400 that names the key, and a spread would send the draft's own * field names (`anyStatusWithinDaysDays`) as if they were axes. * * An UNSET axis is OMITTED, never sent empty: validateSegment tests `!== undefined`, so `gender: ''` * is a 400 rather than "no gender filter". */ export function buildSegment(draft: SegmentDraft): CampaignSegment { const segment: CampaignSegment = {}; const ageMin = intOrNull(draft.ageMin); if (ageMin !== null) segment.ageMin = ageMin; const ageMax = intOrNull(draft.ageMax); if (ageMax !== null) segment.ageMax = ageMax; if (draft.gender !== '') segment.gender = draft.gender; if (draft.practitionerId !== '') segment.practitionerId = draft.practitionerId; if (draft.locationId !== '') segment.locationId = draft.locationId; const lastSeen = intOrNull(draft.lastSeenBeforeDays); if (lastSeen !== null) segment.lastSeenBeforeDays = lastSeen; if (draft.lastApptStatus !== '') segment.lastApptStatus = draft.lastApptStatus; // BOTH halves or neither - the server rejects a partial { status } or { days }. const days = intOrNull(draft.anyStatusWithinDaysDays); if (draft.anyStatusWithinDaysStatus !== '' && days !== null) { segment.anyStatusWithinDays = { status: draft.anyStatusWithinDaysStatus, days }; } // Only when TRUE. `excludeAlreadyBooked: false` is a filter that does nothing, and sending it // would change the segmentHash for no behavioural reason. if (draft.excludeAlreadyBooked) segment.excludeAlreadyBooked = true; if (draft.conditionCodes.length > 0) segment.conditionCodes = [...draft.conditionCodes]; return segment; } /** * EXHAUSTIVE BY THE TYPE SYSTEM, NOT BY CARE. Adding a field to `CampaignSegment` without * classifying it here fails `tsc`, and because `SCALAR_AXES` is DERIVED from this record, a field * classified 'scalar' is automatically compared. A parallel hand-maintained list would let a new * axis be added to one and forgotten in the other - which is the drift this exists to prevent. * * Mirrors the server's `SEGMENT_KEYS: Record` (definition.ts) and * exists for the identical reason. * * `as const satisfies` rather than a type ANNOTATION, and the difference is load-bearing: an * annotation widens every value to `'scalar' | 'custom'`, which would make `CustomAxis` below * resolve to every axis. `satisfies` checks exhaustiveness while KEEPING the literal types - and it * does so without a separate canary constant, which in this file's history is how a TS6133 build * break got in (an unused `SEGMENT_KEYS` that vitest could not see). */ const AXIS_COMPARISON = { ageMin: 'scalar', ageMax: 'scalar', gender: 'scalar', practitionerId: 'scalar', locationId: 'scalar', lastSeenBeforeDays: 'scalar', lastApptStatus: 'scalar', excludeAlreadyBooked: 'scalar', // Compared by a registered comparator in CUSTOM_COMPARATORS - a `!==` on either of these would // compare object/array REFERENCES, so every re-render would report a changed audience. anyStatusWithinDays: 'custom', conditionCodes: 'custom', } as const satisfies Record; type AxisOfKind = { [K in keyof typeof AXIS_COMPARISON]: (typeof AXIS_COMPARISON)[K] extends Kind ? K : never; }[keyof typeof AXIS_COMPARISON]; const SCALAR_AXES = (Object.keys(AXIS_COMPARISON) as (keyof CampaignSegment)[]) .filter((key) => AXIS_COMPARISON[key] === 'scalar'); /** * CLASSIFYING AN AXIS `'custom'` NOW FORCES A COMPARATOR, NOT MERELY A CHOICE. * * Before this record existed, `AXIS_COMPARISON` made the author DECIDE scalar-vs-custom but nothing * checked that a `'custom'` axis was ever actually compared: a new one compiled clean, ran green, * and was silently ignored by `segmentsEqual`. That is not a cosmetic gap - an uncompared axis makes * two DIFFERENT audiences compare EQUAL, so `canSend` keeps offering Send against a count taken for * somebody else, and the practice mails the wrong list with nothing red on screen. * * `Record,...>` closes it: a new `'custom'` axis without an entry here is a * `tsc` error (TS2741), the same forcing function the scalar half already had. */ const CUSTOM_COMPARATORS: Record< AxisOfKind<'custom'>, (a: CampaignSegment, b: CampaignSegment) => boolean > = { anyStatusWithinDays: (a, b) => { const aw = a.anyStatusWithinDays; const bw = b.anyStatusWithinDays; if ((aw === undefined) !== (bw === undefined)) return false; if (aw !== undefined && bw !== undefined) { return aw.status === bw.status && aw.days === bw.days; } return true; }, conditionCodes: (a, b) => { const ac = a.conditionCodes ?? []; const bc = b.conditionCodes ?? []; if (ac.length !== bc.length) return false; return ac.every((code, i) => code === bc[i]); }, }; /** * THIS IS WHAT BINDS AN AUDIENCE COUNT TO THE SEGMENT IT WAS TAKEN FOR. * * It is deliberately NOT a client-side reimplementation of the server's `segmentHash`. A second * copy of a hashing rule in another repo, required to match byte-for-byte forever, is the shape * behind and the clinicianEnabled triplet - and when the two disagree the client * says "counted" while the server answers `not-previewed`, with nothing red anywhere. * * And NOT `JSON.stringify` equality: key order is not guaranteed, so that would report a change * where there is none. * * It compares a value we own, and it fails SAFE - a false "changed" costs one extra walk, never a * wrong send. */ export function segmentsEqual(a: CampaignSegment, b: CampaignSegment): boolean { for (const key of SCALAR_AXES) { if (a[key] !== b[key]) return false; } // Every registered custom comparator runs. Iterating the record rather than naming the two // axes inline is what makes the type-level guarantee above have a runtime effect. for (const compare of Object.values(CUSTOM_COMPARATORS)) { if (!compare(a, b)) return false; } return true; } const str = (n: number | undefined): string => (n === undefined ? '' : String(n)); /** * The inverse of `buildSegment`, for loading an existing campaign into the form. * * If these two ever stop being inverses, opening a draft and saving it unchanged REWRITES its * audience - silently, and in whichever direction the mismatch happens to fall. The round-trip test * is what holds them together. */ export function draftFromSegment(segment: CampaignSegment): SegmentDraft { return { ageMin: str(segment.ageMin), ageMax: str(segment.ageMax), gender: segment.gender ?? '', practitionerId: segment.practitionerId ?? '', locationId: segment.locationId ?? '', lastSeenBeforeDays: str(segment.lastSeenBeforeDays), lastApptStatus: segment.lastApptStatus ?? '', anyStatusWithinDaysStatus: segment.anyStatusWithinDays?.status ?? '', anyStatusWithinDaysDays: str(segment.anyStatusWithinDays?.days), excludeAlreadyBooked: segment.excludeAlreadyBooked === true, conditionCodes: [...(segment.conditionCodes ?? [])], }; } /** * Per-field problems with the draft, keyed by SegmentDraft field name. Empty object means valid. * * This is what makes this module's header claim TRUE. buildSegment deliberately does NOT drop * out-of-range values - a filter that silently vanishes is worse than one that is refused, because * the practice believes it narrowed the audience and it did not. * * Bounds mirror the server's validateSegment (campaign-segment/definition.ts). */ export function validateDraft(draft: SegmentDraft): Partial> { const errors: Partial> = {}; // ageMin validation if (draft.ageMin !== '') { const ageMinNum = intOrNull(draft.ageMin); if (ageMinNum === null || ageMinNum < MIN_AGE || ageMinNum > MAX_AGE) { errors.ageMin = 'Age must be a whole number between 0 and 130.'; } } // ageMax validation if (draft.ageMax !== '') { const ageMaxNum = intOrNull(draft.ageMax); if (ageMaxNum === null || ageMaxNum < MIN_AGE || ageMaxNum > MAX_AGE) { errors.ageMax = 'Age must be a whole number between 0 and 130.'; } } // ageMin > ageMax validation if (draft.ageMin !== '' && draft.ageMax !== '' && !errors.ageMin && !errors.ageMax) { const ageMinNum = intOrNull(draft.ageMin)!; const ageMaxNum = intOrNull(draft.ageMax)!; if (ageMinNum > ageMaxNum) { errors.ageMax = 'The minimum age must not be greater than the maximum age.'; } } // lastSeenBeforeDays validation if (draft.lastSeenBeforeDays !== '') { const daysNum = intOrNull(draft.lastSeenBeforeDays); if (daysNum === null || daysNum < 1 || daysNum > MAX_DAYS) { errors.lastSeenBeforeDays = 'Days must be a whole number between 1 and 3650.'; } } // anyStatusWithinDaysDays validation if (draft.anyStatusWithinDaysDays !== '') { const daysNum = intOrNull(draft.anyStatusWithinDaysDays); if (daysNum === null || daysNum < 1 || daysNum > MAX_DAYS) { errors.anyStatusWithinDaysDays = 'Days must be a whole number between 1 and 3650.'; } } return errors; } /** One human sentence for the segment. An empty segment is EVERYONE, and it says so. */ export function describeSegment(segment: CampaignSegment, conditionRows: readonly ConditionCatalogueRow[] = []): string { const parts: string[] = []; if (segment.ageMin !== undefined && segment.ageMax !== undefined) { parts.push(`age ${segment.ageMin}-${segment.ageMax}`); } else if (segment.ageMin !== undefined) { parts.push(`age ${segment.ageMin} and over`); } else if (segment.ageMax !== undefined) { parts.push(`age ${segment.ageMax} and under`); } if (segment.gender !== undefined) parts.push(segment.gender); if (segment.practitionerId !== undefined) parts.push('seen by a chosen practitioner'); if (segment.locationId !== undefined) parts.push('at a chosen location'); if (segment.lastSeenBeforeDays !== undefined) parts.push(`not seen in ${segment.lastSeenBeforeDays} days`); if (segment.lastApptStatus !== undefined) parts.push(`last appointment ${segment.lastApptStatus}`); if (segment.anyStatusWithinDays !== undefined) { parts.push(`${segment.anyStatusWithinDays.status} within ${segment.anyStatusWithinDays.days} days`); } if (segment.conditionCodes !== undefined && segment.conditionCodes.length > 0) { const named = selectedConditionRows(segment.conditionCodes, conditionRows); parts.push(named && named.length > 0 ? `diagnosed with ${named.map((row) => row.label).join(' or ')}` : `${segment.conditionCodes.length} condition code(s)`); } if (segment.excludeAlreadyBooked) parts.push('excluding anyone already booked'); if (parts.length === 0) return 'All patients who have opted in to marketing messages'; return parts.join(', '); }