import type { Extension, Patient } from 'fhir/r4'; import { getLoginEmail } from '../utils/cognitoIdentity'; // Patient communication preferences (email/SMS/appointment-reminder consent), stored as a FHIR extension on the // Patient. Byte-identical URL + shape to the infra utils/communicationPreferences.ts. A channel is a sub-extension // (url = channel key) whose children carry enabled / disclaimerVersion / agreedAt / disabledReason / disabledAt. // Absent channel => OFF (opt-out default). All writes are immutable filter-then-append so no other Patient data // is ever lost. export const PATIENT_COMM_PREF_EXTENSION_URL = 'https://akashichealthrecords.com/fhir/StructureDefinition/patient-communication-preferences'; export type CommChannel = 'email' | 'sms' | 'appointmentReminders' | 'promotional'; export const COMM_CHANNELS: CommChannel[] = ['email', 'sms', 'appointmentReminders', 'promotional']; // Deliberately named for WHY the channel is off, not for the mechanism that detected it. An inbound // STOP and a send-time ConflictException (no inbound message at all) both resolve to this same reason. export type CommDisabledReason = 'carrierOptOut'; export type CarrierBlockScope = 'whole-channel' | 'sms-leg-only'; /** * HOW FAR A CARRIER OPT-OUT REACHES - and why it is NOT the same for every channel. * * MIRRORS THE SERVER. The authority is `carrierBlockScope` in the api's * utils/communicationPreferences.ts; the two must agree. This is one of the few pieces of LOGIC (not * just shape) duplicated across the repos - the server enforces it, and the client must render the * same model or it will describe a state the server does not hold. * * A carrier STOP is a fact about ONE handset and ONE origination number. For a channel that IS a * transport (`sms`), that disables the whole channel: no portal toggle can make it sendable, so the * checkbox is not rendered at all and only a texted START lifts it. * * `promotional` is a CATEGORY spanning email AND SMS. A carrier block on the promotional number says * nothing about email, so it suppresses only the SMS LEG and `enabled` remains the PATIENT'S: * * enabled=false -> opted out of marketing entirely * enabled=true + disabledReason=carrierOptOut -> email marketing fine, SMS leg blocked until START * * Rendering an sms-leg-only stamp as a whole-channel block is not merely cosmetic: it removes the * checkbox, which is the patient's ONLY control to turn marketing OFF - taken away at exactly the * moment they have signalled they want less contact, and replaced by "text START" from a handset they * may no longer own. That was. */ export function carrierBlockScope(channel: CommChannel): CarrierBlockScope { return channel === 'promotional' ? 'sms-leg-only' : 'whole-channel'; } export interface OriginalCommunicationConsent { id: string; versionId: string; disclaimerVersion: string; agreedAt: string; } export interface CommDecision { version: 1; id: string; patientId: string; channel: CommChannel; enabled: boolean; originalConsent?: OriginalCommunicationConsent; } export interface CarrierObservation { id: string; occurredAt: string; handset: string; originator: string; stream: 'transactional' | 'promotional'; } export interface CarrierBlock { stop: CarrierObservation; decisionId?: string; clearedBy?: { id: string; occurredAt: string }; } export interface CommPreferenceInput { enabled: boolean; disclaimerVersion?: string; agreedAt?: string; expectedDecisionId?: string | null; } export interface CommPreference { enabled: boolean; disclaimerVersion?: string; agreedAt?: string; disabledReason?: CommDisabledReason; disabledAt?: string; decision?: CommDecision; carrierBlocks?: CarrierBlock[]; carrierUnknown?: boolean; carrierConsentPending?: { id: string; decisionTime: string }; } // DRAFT disclaimer copy - final wording requires a compliance sign-off before prod. The stored // disclaimerVersion records exactly which wording the patient agreed to (the audit trail). NOTE: re-consent // enforcement on a version bump is NOT yet wired (deferred; see the design doc). When a version is bumped, a // future change must compare the stored version to the current one and re-prompt - until then, a bump does not // force re-consent on already-opted-in patients. export interface Disclaimer { version: string; text: string; } export const EMAIL_DISCLAIMER: Disclaimer = { version: 'email-v1', text: 'Email is not secure. Notifications may include information about your care and could be seen by anyone with ' + 'access to your inbox or email account. By enabling email notifications you accept this risk. You can turn ' + 'this off anytime in Settings.', }; export const SMS_DISCLAIMER: Disclaimer = { version: 'sms-v1', text: 'Text messages are not secure. Notifications may include information about your care and could be seen by ' + 'anyone with access to your phone. Message and data rates may apply. Reply STOP to opt out at any time. By ' + 'enabling text notifications you accept these terms. You can turn this off anytime in Settings.', }; // S4a - a CATEGORY, not a transport: gates whether marketing may reach the patient at all. // `email`/`sms` above still decide HOW. Default OFF, same as every other channel here. `sendLive: false` // on the CHANNELS row below is load-bearing, not decorative -- no send path exists yet (S4a ships able // to message nobody; the composer/sender is S4d), so this disclaimer's TCPA-flavoured STOP/unsubscribe // language describes the eventual mechanics, not something live today. export const PROMOTIONAL_DISCLAIMER: Disclaimer = { version: 'promotional-v1', text: 'Marketing messages are optional and separate from your appointment reminders. Turning this off ' + 'will never affect reminders or messages about your care. Message and data rates may apply to text ' + 'messages, and you can reply STOP to a marketing text or use the unsubscribe link in a marketing ' + 'email at any time. You can also turn this off anytime in Settings.', }; export interface CommChannelMeta { key: CommChannel; label: string; description: string; disclaimer: Disclaimer; // Whether a real send path exists today. Appointment reminders are sent by email by the reminder sweep, to // patients with this preference on; sms is captured now and delivered later. promotional has NO send path // at all yet (S4a is consent-capture only; the composer/sender is S4d) -- sendLive: false here is // load-bearing, and the description must never imply a message will arrive. sendLive: boolean; } export const CHANNELS: CommChannelMeta[] = [ { key: 'email', label: 'Email notifications', description: 'Get account and care notifications by email.', disclaimer: EMAIL_DISCLAIMER, sendLive: true }, { key: 'sms', label: 'Text (SMS) messages', description: 'Get messages by text. Delivery coming soon.', disclaimer: SMS_DISCLAIMER, sendLive: false }, { key: 'appointmentReminders', label: 'Appointment reminders', description: 'Get reminders by email before appointments.', disclaimer: EMAIL_DISCLAIMER, sendLive: true }, { key: 'promotional', label: 'Marketing and practice updates', description: 'Optional news and offers from your practice. Not yet being sent.', disclaimer: PROMOTIONAL_DISCLAIMER, sendLive: false }, ]; const findContainer = (patient: Patient): Extension | undefined => patient.extension?.find((e) => e.url === PATIENT_COMM_PREF_EXTENSION_URL); const boundedText = (value: unknown): value is string => typeof value === 'string' && value.length > 0 && value.length <= 256 && value.trim() === value; const validTime = (value: unknown): value is string => boundedText(value) && Number.isFinite(Date.parse(value)); const object = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); const originalConsent = (value: unknown): value is OriginalCommunicationConsent => object(value) && boundedText(value.id) && boundedText(value.versionId) && boundedText(value.disclaimerVersion) && validTime(value.agreedAt); const validDecision = (value: unknown, patientId: string | undefined, channel: CommChannel): value is CommDecision => object(value) && value.version === 1 && boundedText(value.id) && value.patientId === patientId && boundedText(value.patientId) && value.channel === channel && typeof value.enabled === 'boolean' && (value.originalConsent === undefined || originalConsent(value.originalConsent)); const observation = (value: unknown): value is CarrierObservation => object(value) && boundedText(value.id) && validTime(value.occurredAt) && boundedText(value.handset) && boundedText(value.originator) && /^\+1\d{10}$/.test(value.handset) && /^\+1\d{10}$/.test(value.originator) && (value.stream === 'transactional' || value.stream === 'promotional'); const validBlocks = (value: unknown): value is CarrierBlock[] => Array.isArray(value) && value.every((block: unknown) => object(block) && observation(block.stop) && (block.decisionId === undefined || boundedText(block.decisionId)) && (block.clearedBy === undefined || (object(block.clearedBy) && boundedText(block.clearedBy.id) && validTime(block.clearedBy.occurredAt) && Date.parse(block.clearedBy.occurredAt) >= Date.parse(block.stop.occurredAt)))); export function readCommPreference(value: unknown, patientId: string | undefined, channel: CommChannel): CommPreference | undefined { if (!object(value) || typeof value.enabled !== 'boolean') return undefined; if (value.disclaimerVersion !== undefined && !boundedText(value.disclaimerVersion)) return undefined; if (value.agreedAt !== undefined && !validTime(value.agreedAt)) return undefined; if (value.disabledReason !== undefined && value.disabledReason !== 'carrierOptOut') return undefined; if (value.disabledAt !== undefined && !validTime(value.disabledAt)) return undefined; const pref: CommPreference = { enabled: value.enabled }; if (typeof value.disclaimerVersion === 'string') pref.disclaimerVersion = value.disclaimerVersion; if (typeof value.agreedAt === 'string') pref.agreedAt = value.agreedAt; if (value.disabledReason === 'carrierOptOut') pref.disabledReason = value.disabledReason; if (typeof value.disabledAt === 'string') pref.disabledAt = value.disabledAt; if (validDecision(value.decision, patientId, channel)) pref.decision = value.decision; if (validBlocks(value.carrierBlocks)) pref.carrierBlocks = value.carrierBlocks; if (object(value.carrierConsentPending) && boundedText(value.carrierConsentPending.id) && validTime(value.carrierConsentPending.decisionTime)) { pref.carrierConsentPending = { id: value.carrierConsentPending.id, decisionTime: value.carrierConsentPending.decisionTime }; } else if (value.carrierConsentPending !== undefined) { pref.carrierUnknown = true; } if (value.carrierUnknown === true || (value.carrierBlocks !== undefined && !validBlocks(value.carrierBlocks)) || (value.carrierUnknown !== undefined && typeof value.carrierUnknown !== 'boolean') || (pref.disabledReason === 'carrierOptOut' && !pref.carrierBlocks?.length)) pref.carrierUnknown = true; return pref; } const parseJson = (value: string | undefined): unknown => { if (value === undefined) return undefined; try { return JSON.parse(value); } catch { return null; } }; const parseChannelExtension = (channelExt: Extension, patientId?: string, ambiguousContainer = false): CommPreference => { const children = channelExt.extension ?? []; const enabled = children.filter(c => c.url === 'enabled'); const ambiguous = ambiguousContainer || enabled.length !== 1 || typeof enabled[0]?.valueBoolean !== 'boolean' || ['disclaimerVersion', 'agreedAt', 'decision', 'carrierBlocks', 'carrierUnknown', 'carrierConsentPending', 'disabledReason', 'disabledAt'] .some(url => children.filter(c => c.url === url).length > 1) || children.some(c => c.url === 'disclaimerVersion' && typeof c.valueString !== 'string') || children.some(c => c.url === 'agreedAt' && !validTime(c.valueDateTime)); const jsonChild = (url: string): unknown => { const matching = children.filter(c => c.url === url); return matching.length === 0 ? undefined : matching.length === 1 ? parseJson(matching[0]?.valueString) ?? null : null; }; const unknown = children.filter(c => c.url === 'carrierUnknown'); const unknownValue = unknown[0]?.valueBoolean; const pref: CommPreference = { enabled: children.find((c) => c.url === 'enabled')?.valueBoolean ?? false }; const version = children.find((c) => c.url === 'disclaimerVersion')?.valueString; const agreedAt = children.find((c) => c.url === 'agreedAt')?.valueDateTime; const disabledReason = children.find((c) => c.url === 'disabledReason')?.valueString as CommDisabledReason | undefined; const disabledAt = children.find((c) => c.url === 'disabledAt')?.valueDateTime; if (version !== undefined) pref.disclaimerVersion = version; if (agreedAt !== undefined) pref.agreedAt = agreedAt; if (disabledReason !== undefined) pref.disabledReason = disabledReason; if (disabledAt !== undefined) pref.disabledAt = disabledAt; const parsed = readCommPreference({ ...pref, decision: jsonChild('decision'), carrierBlocks: jsonChild('carrierBlocks'), carrierUnknown: unknown.length === 0 ? undefined : typeof unknownValue === 'boolean' ? unknownValue : null, carrierConsentPending: jsonChild('carrierConsentPending'), }, patientId, channelExt.url as CommChannel) ?? { enabled: false, carrierUnknown: true }; if (ambiguous) { parsed.enabled = false; parsed.carrierUnknown = true; delete parsed.decision; } return parsed; }; export function getCommPreference(patient: Patient, channel: CommChannel): CommPreference | undefined { const containers = patient.extension?.filter(e => e.url === PATIENT_COMM_PREF_EXTENSION_URL) ?? []; const channels = containers.flatMap(e => e.extension ?? []).filter(e => e.url === channel); return channels[0] ? parseChannelExtension(channels[0], patient.id, containers.length !== 1 || channels.length !== 1) : undefined; } export function getAllCommPreferences(patient: Patient): Partial> { const result: Partial> = {}; for (const channel of COMM_CHANNELS) { const preference = getCommPreference(patient, channel); if (preference) result[channel] = preference; } return result; } /** * Whether the patient has given the CONSENTS that let a verified number actually be texted. * * VERIFICATION IS NECESSARY, NOT SUFFICIENT, and conflating the two is how UI copy ends up * promising something the system will not do. This mirrors PART of the conjunction in infra's * utils/smsChannel.ts::eligibleChannels - `appointmentReminders` gates EVERY channel, and `sms` gates * this one - but `isSmsDeliveryConsented(p) && getSmsVerification(p)?.verified` is NOT the full * server-side condition, and the client cannot make it so: `eligibleChannels` also requires * `smsEntitled` (the org's derived SMS add-on entitlement - lives on the org, not the patient, so it * is invisible here) and `toE164(number.digits)`. This pair can say "definitely not eligible" but * never "will be texted" - an org without the SMS add-on can satisfy both of these and still send * nothing. UI copy driven by this pair must not imply the entitlement half is also satisfied. * * Deliberately NOT a control anywhere in staff UI. R5 makes turning these on the patient's own * affirmative act; staff may witness a verification, never manufacture a consent. This exists so the * UI can TELL the truth about the gap, not close it. */ export function isSmsDeliveryConsented(patient: Patient): boolean { return getCommPreference(patient, 'sms')?.enabled === true && !hasCarrierRestriction(patient, 'sms') && getCommPreference(patient, 'appointmentReminders')?.enabled === true; } const buildChannelExtension = (channel: CommChannel, pref: CommPreference): Extension => { const children: Extension[] = [{ url: 'enabled', valueBoolean: pref.enabled }]; if (pref.disclaimerVersion !== undefined) children.push({ url: 'disclaimerVersion', valueString: pref.disclaimerVersion }); if (pref.agreedAt !== undefined) children.push({ url: 'agreedAt', valueDateTime: pref.agreedAt }); if (pref.disabledReason !== undefined) children.push({ url: 'disabledReason', valueString: pref.disabledReason }); if (pref.disabledAt !== undefined) children.push({ url: 'disabledAt', valueDateTime: pref.disabledAt }); if (pref.decision !== undefined) children.push({ url: 'decision', valueString: JSON.stringify(pref.decision) }); if (pref.carrierBlocks !== undefined) children.push({ url: 'carrierBlocks', valueString: JSON.stringify(pref.carrierBlocks) }); if (pref.carrierUnknown !== undefined) children.push({ url: 'carrierUnknown', valueBoolean: pref.carrierUnknown }); if (pref.carrierConsentPending !== undefined) children.push({ url: 'carrierConsentPending', valueString: JSON.stringify(pref.carrierConsentPending) }); return { url: channel, extension: children }; }; export function setCommPreference(patient: Patient, channel: CommChannel, pref: CommPreference): Patient { const container = findContainer(patient); const otherChannels = (container?.extension ?? []).filter((e) => e.url !== channel); const nextContainer: Extension = { url: PATIENT_COMM_PREF_EXTENSION_URL, extension: [...otherChannels, buildChannelExtension(channel, pref)], }; const otherExtensions = (patient.extension ?? []).filter((e) => e.url !== PATIENT_COMM_PREF_EXTENSION_URL); return { ...patient, extension: [...otherExtensions, nextContainer] }; } // Which mobile number receives SMS reminders. Byte-identical URL + shape to the infra // utils/smsChannel.ts, which re-resolves these digits against the patient's current telecom at send time // (system: 'phone' AND use: 'mobile', matched on digits only) rather than trusting a stored copy. export const PATIENT_SMS_NUMBER_EXTENSION_URL = 'https://akashichealthrecords.com/fhir/StructureDefinition/patient-sms-number'; const digitsOnly = (value: string | undefined) => (value ?? '').replace(/\D/g, ''); /** Mobile phone numbers already on the patient, normalised to digits. */ export function listMobileNumbers(patient: Patient): string[] { return (patient.telecom ?? []) .filter((t) => t.system === 'phone' && t.use === 'mobile' && t.value) .map((t) => digitsOnly(t.value)) .filter((d) => d.length > 0); } /** * Select the number that receives SMS. * * Always writes `verified: false`. Verification attaches to the NUMBER, not the patient, so * switching numbers must never inherit the previous number's trust. */ export function setSmsNumber( patient: Patient, value: string, options: { addToProfile?: boolean } = {}, ): Patient { const digits = digitsOnly(value); const others = (patient.extension ?? []).filter( (e) => e.url !== PATIENT_SMS_NUMBER_EXTENSION_URL, ); // Dedup guard matches on 'mobile' specifically. A number already stored under some OTHER use // (e.g. home/work) does not satisfy this -- append a mobile-tagged entry alongside it rather than // silently promoting/mutating the existing entry: the patient just asserted "this number is also // my mobile", and FHIR permits the same number under multiple `use` values. Reclassifying their // existing home/work entry would be presuming intent they didn't express. let telecom = patient.telecom ?? []; if (options.addToProfile && !telecom.some((t) => t.system === 'phone' && t.use === 'mobile' && digitsOnly(t.value) === digits)) { telecom = [...telecom, { system: 'phone', value: digits, use: 'mobile' }]; } return { ...patient, telecom, extension: [...others, { url: PATIENT_SMS_NUMBER_EXTENSION_URL, extension: [ { url: 'digits', valueString: digits }, { url: 'verified', valueBoolean: false }, ], }], }; } // --------------------------------------------------------------------------- // S2b - reading + optimistically reflecting the server-owned verification state. // --------------------------------------------------------------------------- // `verified` / `verifiedAt` are written ONLY by the server (POST /R4/patient-sms-verification/verify, // via a system token straight to HealthLake - see infra utils/pending-sms-verification/verifyFlow.ts // and utils/smsChannel.ts::setSmsVerified, which this mirrors read-for-read). The plugin never PUTs // these fields itself; `setSmsVerifiedLocally` below exists only to keep the cached currentUser.fhir // in sync with a write the server already made, exactly like `setCommPreference` does for channel // toggles above. export interface SmsVerificationState { digits: string; verified: boolean; verifiedAt?: string; } /** The patient's currently selected SMS number and whether it has been verified. */ export function getSmsVerification(patient: Patient): SmsVerificationState | undefined { const ext = patient.extension?.find((e) => e.url === PATIENT_SMS_NUMBER_EXTENSION_URL); const digits = ext?.extension?.find((c) => c.url === 'digits')?.valueString; if (!digits) return undefined; const verified = ext?.extension?.find((c) => c.url === 'verified')?.valueBoolean === true; const verifiedAt = ext?.extension?.find((c) => c.url === 'verifiedAt')?.valueDateTime; return verified ? { digits, verified, verifiedAt } : { digits, verified }; } // --------------------------------------------------------------------------- // - the patient's VERIFIED CONTACT EMAIL. // --------------------------------------------------------------------------- // The email twin of the patient-sms-number block above, and a deliberate MIRROR of it rather than a // second contact model. Byte-identical URL + shape to the infra utils/emailChannel.ts, which // re-resolves the stored address against the patient's current telecom (`system: 'email'`) at send // time instead of trusting a stored copy - the same self-healing rule resolveSmsNumber follows. // // THIS IS NOT THE LOGIN EMAIL. `PatientLoginEmail` is a Cognito-bound login-email CHANGE flow that // sends its code to the OLD address on purpose. This is a CONTACT address on the Patient resource: a // different fact about a different thing, with its own OTP doors ( part B). // // `verified` / `verifiedAt` are written ONLY by the server (POST /R4/{patient,guardian}-email- // verification/verify, via a system token straight to HealthLake - infra // utils/pending-email-verification/verifyFlow.ts). handlers/Patient/put.ts re-pins BOTH out of any // client body, so a PUT from here can never mint the trust; `setEmailVerifiedLocally` below exists // only to keep the cached currentUser.fhir in step with a write the server already made. export const PATIENT_EMAIL_EXTENSION_URL = 'https://akashichealthrecords.com/fhir/StructureDefinition/patient-email'; /** * THE SAME NORMALISATION THE SERVER APPLIES - infra utils/contactDistinctness.ts::normaliseEmail, * which `resolveEmailAddress` AND handlers/Patient/put.ts's verified-pin both go through. A second, * differently-behaved normaliser on this side is not cosmetic: the pin keeps a real verification only * while the incoming address still normalises to the stored one, so a client that trimmed or cased * differently would silently DROP a genuine verification on an innocuous re-save of the same address. */ export const normaliseEmail = (value: string | undefined): string => (value ?? '').trim().toLowerCase(); /** * Email addresses already on the patient, normalised. * * NO `use` FILTER, and its absence is the deliberate email counterpart of `listMobileNumbers`' * `use: 'mobile'`. That filter exists because a shared home LANDLINE is not a personal device; * `ContactPoint.use` on an email is 'home'/'work'/absent and carries no such meaning, so requiring one * would reject the commonest shape (a bare `{ system: 'email' }` entry) for no safety gained. Both * infra's `resolveEmailAddress` and `contactDistinctness.ts::isTakeoverChannel` take the same position. */ export function listEmailAddresses(patient: Patient): string[] { const seen = new Set(); for (const contact of patient.telecom ?? []) { if (contact.system !== 'email') continue; const address = normaliseEmail(contact.value); if (address) seen.add(address); } return [...seen]; } /** * The addresses the notices picker offers: the sign-in email first, then the record's own. A fresh * account's sign-in email is not on telecom yet; saving any choice adds it there. The caller passes the * signed-in account's email on its own record; otherwise the tag counts only when it is an address. */ export function listEmailChoices(patient: Patient, signInEmail: string | undefined = getLoginEmail(patient)): string[] { const signIn = normaliseEmail(signInEmail); return [...new Set([...(signIn ? [signIn] : []), ...listEmailAddresses(patient)])]; } /** * Select the address that receives contact email. * * Always writes `verified: false`, exactly as `setSmsNumber` does. Verification attaches to the * ADDRESS, not the patient, so switching inboxes must never inherit the previous one's trust - and * the server enforces the same rule from its side (put.ts's pin survives only while the incoming * address still resolves to the stored one). * * `addToProfile` is not optional in practice for a NEW address: the server's `resolveEmailAddress` * refuses an address that is not also on `telecom` as `system: 'email'`, so an extension written * without the telecom entry produces a selection the request door answers "No verifiable contact * email is selected" for - with the address plainly on screen. */ export function setContactEmail( patient: Patient, value: string, options: { addToProfile?: boolean } = {}, ): Patient { const address = normaliseEmail(value); const others = (patient.extension ?? []).filter((e) => e.url !== PATIENT_EMAIL_EXTENSION_URL); // Dedup on the NORMALISED value only - there is no `use` to disambiguate on (see // listEmailAddresses), so 'Ada@Example.com' already on file is the same channel as 'ada@example.com' // and appending a second entry would leave the profile with two rows for one inbox. let telecom = patient.telecom ?? []; if (options.addToProfile && !telecom.some((t) => t.system === 'email' && normaliseEmail(t.value) === address)) { telecom = [...telecom, { system: 'email', value: address }]; } return { ...patient, telecom, extension: [...others, { url: PATIENT_EMAIL_EXTENSION_URL, extension: [ { url: 'address', valueString: address }, { url: 'verified', valueBoolean: false }, ], }], }; } export interface EmailVerificationState { address: string; verified: boolean; verifiedAt?: string; } /** * The patient's currently selected contact email and whether it has been verified. * * `verified` AND `verifiedAt` ARE ONE FACT (infra emailChannel.ts's R4 note). An unverified address * carries NO verification date out of here even when a stale one is sitting in the stored extension - * a timestamp beside a false flag is worse than no timestamp, because a reader believes it. The server * writes that rule; this is the read half, and both are needed because every screen renders from here. * * Deliberately does NOT re-resolve against telecom, matching `getSmsVerification`. The server's * `resolveEmailAddress` does (and returns null for an address no longer on the profile); this reader * answers the narrower question "what is stored", so a page can show an address whose telecom entry * was removed rather than silently forgetting the patient ever chose one. */ export function getEmailVerification(patient: Patient): EmailVerificationState | undefined { const ext = patient.extension?.find((e) => e.url === PATIENT_EMAIL_EXTENSION_URL); const address = normaliseEmail(ext?.extension?.find((c) => c.url === 'address')?.valueString); if (!address) return undefined; const verified = ext?.extension?.find((c) => c.url === 'verified')?.valueBoolean === true; const verifiedAt = ext?.extension?.find((c) => c.url === 'verifiedAt')?.valueDateTime; return verified ? { address, verified, verifiedAt } : { address, verified }; } /** * Stamp `verified: true` + `verifiedAt` onto the patient-email extension, LOCALLY - the server has * already made this write (this function never calls it). Mirrors infra's `setEmailVerified`, * restricted to the extension carrying `address`; refuses to stamp an address that is not the one * currently stored, the same guard the server enforces at the point of the real write (there it * throws; here it returns the Patient untouched, because a cache mirror has no caller to throw at). * * R5 - touches the patient-email extension and NOTHING else. Verification proves control of a * mailbox, never consent to be mailed; never fold a communication-preference write in here. */ export function setEmailVerifiedLocally(patient: Patient, address: string, verifiedAt: string): Patient { const requested = normaliseEmail(address); let stamped = false; return { ...patient, extension: (patient.extension ?? []).map((e) => { if (e.url !== PATIENT_EMAIL_EXTENSION_URL || stamped) return e; const storedAddress = normaliseEmail(e.extension?.find((c) => c.url === 'address')?.valueString); if (!storedAddress || storedAddress !== requested) return e; stamped = true; return { ...e, extension: [ // Filter BOTH before re-emitting, so a stale verifiedAt can never survive beside a // new one - the same rebuild infra's rebuildEmailChildren performs. ...(e.extension ?? []).filter((c) => c.url !== 'verified' && c.url !== 'verifiedAt'), { url: 'verified', valueBoolean: true }, { url: 'verifiedAt', valueDateTime: verifiedAt }, ], }; }), }; } /** * Stamp `verified: true` + `verifiedAt` onto the patient-sms-number extension, LOCALLY - the server * has already made this write (this function never calls it). Mirrors infra's `setSmsVerified` byte * for byte, restricted to the extension carrying `digits`; refuses to stamp a number that is not the * one currently stored, the same guard the server enforces at the point of the real write. * * R5 - touches the sms-number extension and NOTHING else. Never fold in a communication-preference * write here: verification proves control of the handset, not consent to be texted. */ export function setSmsVerifiedLocally(patient: Patient, digits: string, verifiedAt: string): Patient { const requested = digitsOnly(digits); let stamped = false; return { ...patient, extension: (patient.extension ?? []).map((e) => { if (e.url !== PATIENT_SMS_NUMBER_EXTENSION_URL || stamped) return e; const storedDigits = e.extension?.find((c) => c.url === 'digits')?.valueString; if (storedDigits !== requested) return e; stamped = true; return { ...e, extension: [ ...(e.extension ?? []).filter((c) => c.url !== 'verified' && c.url !== 'verifiedAt'), { url: 'verified', valueBoolean: true }, { url: 'verifiedAt', valueDateTime: verifiedAt }, ], }; }), }; } export function hasCarrierRestriction(patient: Patient, channel: CommChannel): boolean { const pref = getCommPreference(patient, channel); if (pref?.carrierUnknown || pref?.carrierConsentPending) return true; const active = pref?.carrierBlocks?.filter((block) => !block.clearedBy) ?? []; if (!active.length) return false; const stored = getSmsVerification(patient)?.digits; const selected = stored && listMobileNumbers(patient).includes(stored) ? stored : undefined; if (!selected) return true; const e164 = selected.length === 10 ? `1${selected}` : selected; return active.some((block) => block.stop.handset.replace(/\D/g, '') === e164); }