// From the backend's JSON (wire.ts) to Context's view types (types.ts), and back for request // bodies. Pure functions, so they are tested against JSON shaped like the backend's serializers. import type { CoverageReview } from "../coverage"; import { countries } from "../logic/access"; import type * as T from "./types"; import type * as W from "./wire"; export const ATTESTATION_VERSION = "2026-10-30"; export const TERMS_VERSION = "clinician-terms-2026-10"; export const DPA_VERSION = "context-dpa-2026-10"; export const OPEN_STATUSES: T.CheckInStatus[] = ["sent", "ringing", "missed", "later", "in_progress", "ended_early"]; /** The backend counts days 1 to 7 from Monday; Context counts 0 to 6 from Sunday. */ export const toViewDay = (day: number) => day % 7; export const toWireDay = (day: number) => (day === 0 ? 7 : day); export const viewHours = (hours: W.WeeklyHours | null | undefined): T.WeeklyHours => (hours ?? []).map((h) => ({ ...h, day: toViewDay(h.day) })); export const wireHours = (hours: T.WeeklyHours): W.WeeklyHours => hours.map((h) => ({ ...h, day: toWireDay(h.day) })); const idle = (minutes: number): T.IdleMinutes => (minutes === 15 || minutes === 30 || minutes === 60 ? minutes : 15); /** "Dr Emeka Okafor" with title "Dr": first name Emeka, last name Okafor. */ export function splitName(fullName: string, title: string | null): { firstName: string; lastName: string } { let rest = fullName.trim(); if (title && rest.toLowerCase().startsWith(`${title.toLowerCase()} `)) rest = rest.slice(title.length).trim(); const words = rest.split(/\s+/); return { firstName: words[0] ?? "", lastName: words.slice(1).join(" ") }; } // ---------------------------------------------------------------- session and profile export function sessionFromMe(me: W.Me, now = new Date()): T.ClinicianSession { const c = me.clinician; return { email: c.email, clinicianId: c.id, verification: c.status === "verified" ? (c.lapsed ? "lapsed" : "verified") : c.status, termsAccepted: !!c.termsAcceptedAt, stepUp: !me.secondFactor.totp ? "enrol" : me.session.stepUpDone && !me.session.locked ? "done" : "required", methods: me.secondFactor.totp ? ["totp"] : [], idleLockMinutes: idle(me.session.idleLockMinutes), lastActiveAt: now.toISOString(), expiresAt: me.session.endsAt, features: me.features, }; } export function profileFromWire(c: W.Clinician, prefs: { browser: boolean }): T.ClinicianProfile { const { firstName, lastName } = splitName(c.fullName, c.title); return { id: c.id, email: c.email, title: c.title ?? "", fullName: c.fullName, firstName, lastName, spokenName: c.displayName, pronoun: c.pronoun, profession: c.profession, licensingBody: c.licenceBody as T.LicensingBody, licenceNumber: c.licenceNumber, licenceExpiresAt: c.verificationExpiresAt, verifiedAt: c.verifiedAt, photoUrl: null, mobilePhone: c.mobilePhone, idleLockMinutes: idle(c.idleLockMinutes), notifications: { browser: prefs.browser, email: true }, termsAcceptedAt: c.termsAcceptedAt, }; } /** The fields the backend stores; notifications stay in this browser. Null when nothing goes to the server. */ export function meChange(update: T.ClinicianProfileUpdate): W.MeChange | null { const change: W.MeChange = {}; if (update.title !== undefined) change.title = update.title.trim() || null; if (update.spokenName !== undefined) change.displayName = update.spokenName.trim(); if (update.pronoun !== undefined) change.pronoun = update.pronoun; if (update.mobilePhone !== undefined) change.mobilePhone = update.mobilePhone?.trim() || null; if (update.idleLockMinutes !== undefined) change.idleLockMinutes = update.idleLockMinutes; return Object.keys(change).length ? change : null; } export function clinicFromWire(c: W.Clinic): T.ClinicProfile { return { id: c.id, role: c.role, version: c.version, name: c.name, address: c.addressLine ?? "", phone: c.phone ?? "", country: c.countryCode, timeZone: c.timezone, openingHours: viewHours(c.openingHours), alertWatchHours: viewHours(c.alertWatchHours), backupContact: c.backupAlertName || c.backupAlertPhone || c.backupAlertEmail ? { name: c.backupAlertName ?? "", phone: c.backupAlertPhone ?? "", email: c.backupAlertEmail ?? "" } : null, emergencyNumbers: c.emergencyNumbers.map((n) => ({ number: n.number, label: n.label, testedAt: n.testDialledAt, source: n.source })), }; } /** The registry's numbers are never sent back: sending them would make them the clinic's own. */ export function clinicChange(update: T.ClinicProfileUpdate, current: Pick): W.ClinicChange { const change: W.ClinicChange = { expectedVersion: update.version ?? current.version }; if (update.name !== undefined) change.name = update.name.trim(); if (update.address !== undefined) change.addressLine = update.address.trim() || null; if (update.phone !== undefined) change.phone = update.phone.trim() || null; if (update.timeZone !== undefined) change.timezone = update.timeZone; if (update.openingHours) change.openingHours = wireHours(update.openingHours); if (update.alertWatchHours) change.alertWatchHours = wireHours(update.alertWatchHours); if (update.backupContact !== undefined) { change.backupAlertName = update.backupContact?.name.trim() || null; change.backupAlertPhone = update.backupContact?.phone.trim() || null; change.backupAlertEmail = update.backupContact?.email.trim().toLowerCase() || null; } if (update.emergencyNumbers && update.emergencyNumbers.length && update.emergencyNumbers.every((n) => n.source === "clinic")) change.emergencyNumbers = update.emergencyNumbers.map((n) => ({ number: n.number, label: n.label, testDialledAt: n.testedAt })); return change; } // ---------------------------------------------------------------- access and invitations export function accessBody(input: T.AccessRequestInput): W.AccessRequestBody { return { fullName: input.fullName, profession: input.profession, licenceBody: input.licensingBody, licenceNumber: input.licenceNumber, // "Another country" has no ISO code; ZZ is the code reserved for an unknown one. countryCode: /^[A-Z]{2}$/.test(input.country) ? input.country : "ZZ", facilityName: input.clinicName, facilityPhone: input.clinicPhone, workEmail: input.workEmail, mobilePhone: input.mobile, heardFrom: input.heardFrom, ...(input.heardFromOther ? { heardFromDetail: input.heardFromOther } : {}), acceptTerms: true, termsVersion: input.acceptedTermsVersion, ...(input.inviteToken ? { inviteCode: input.inviteToken } : {}), }; } export const receiptFromWire = (w: W.AccessRequestReceived, input: T.AccessRequestInput): T.AccessRequestReceipt => ({ status: "received", message: w.message, priority: !!input.inviteToken, countryOpen: countries.some((c) => c.code === input.country && c.open), }); export const invitePreviewFromWire = (w: W.InvitePreview): T.InvitePreview => ({ ...w, patientPronoun: null }); export const invitationFromWire = (w: W.Invitation): T.Invitation => ({ ...w, patientPronoun: null }); // ---------------------------------------------------------------- patients const viewSex = (sex: W.PatientItem["sex"]) => (sex === "female" || sex === "male" ? sex : null); export function summaryFromWire(item: W.PatientItem): T.PatientSummary { return { id: item.id, kind: item.kind, name: item.name, age: item.age, pronoun: null, sharing: item.sharing ? { ...item.sharing, sharedBy: item.sharing.sharedBy ?? [], withheldSome: item.sharing.withheldSome ?? false } : null, medicines: item.medicines, doses: item.doses, lastCheckIn: item.lastCheckIn, attention: item.attention, lastActivityAt: item.lastActivityAt, }; } const NO_LIMITS: T.CheckInLimits = { usedToday: 0, usedThisWeek: 0, maxPerDay: 1, maxPerWeek: 3, nextPossibleAt: null }; /** What a connected patient's page also reads: the shared tile, the check-in plan and this clinician's check-ins. */ export type PatientExtras = { tile?: W.SharedSummary | null; plan?: W.PlanTemplate | null; checkIns?: W.CheckInList | null }; export function detailFromWire(d: W.PatientDetail, extras: PatientExtras = {}): T.PatientDetail { const base = summaryFromWire(d); const { tile, plan, checkIns } = extras; const window = plan?.window; const ownAccount = d.hasOwnAccount || (d.kind === "connected" && !!plan && plan.eligibility.code !== "no_own_account"); const active = checkIns?.checkIns.find((c) => OPEN_STATUSES.includes(c.status)); return { ...base, sharing: tile ? { ...tile.sharing } : base.sharing, medicines: tile ? tile.medicines : base.medicines, doses: tile ? tile.doses : base.doses, lastCheckIn: tile ? tile.lastCheckIn : base.lastCheckIn, pronoun: d.pronoun, firstName: d.firstName, dateOfBirth: d.dateOfBirthIso, birthYear: d.birthYear, sex: viewSex(d.sex), phone: d.phone ?? tile?.phone ?? null, preferredTitle: d.preferredTitle, language: "en", hasOwnAccount: ownAccount, identityOnFile: d.identityOnFile, timeZone: window?.patientTimeZone ?? d.timeZone, quietHours: window ? window.quietHours ?? { start: "00:00", end: "00:00" } : { start: "22:00", end: "07:00" }, quietHoursKnown: !!window && window.patientTimeZone !== null, blockedTimes: (window?.blockedTimes ?? []).map((b) => ({ day: b.day === null ? null : toViewDay(b.day), start: b.start, end: b.end, label: b.label ?? "" })), checkInLimits: plan?.limits ?? NO_LIMITS, activeCheckInId: d.activeCheckInId ?? active?.id ?? null, lastCheckId: d.lastCheckId, newInformation: d.newInformation, }; } export function newPatientBody(input: T.NewPatientInput): W.NewPatientBody { return { displayName: input.name.trim(), ...(input.dateOfBirth ? { dateOfBirth: input.dateOfBirth } : input.age !== null ? { ageYears: input.age } : {}), ...(input.sex ? { sex: input.sex } : {}), attestation: { careRelationship: true, patientAgreement: true, version: ATTESTATION_VERSION }, }; } // ---------------------------------------------------------------- records, reading, the check export const recordFromWire = (r: W.Record_): T.ContextRecord => ({ ...r, fileName: null }); export const profileSourceId = (patientId: string) => `profile-${patientId}`; export const PROFILE_TITLE = "Patient profile"; /** Titles by record id, for "Clinic note ยท line 4": the backend's reading carries ids only. */ export function titlesOf(patientId: string, records: Pick[]): Record { return { [profileSourceId(patientId)]: PROFILE_TITLE, ...Object.fromEntries(records.map((r) => [r.id, r.title])) }; } export function extractionFromWire(w: W.Extraction, titles: Record): T.Extraction { const coverageReview: CoverageReview = Object.fromEntries( Object.entries(w.coverageReview ?? {}).map(([id, v]) => [id, { fingerprint: v.fingerprint, choice: v.choice, reason: v.reason ?? "", confirmedBy: v.confirmedBy }]), ); return { ...w, records: w.records.map((r) => ({ recordId: r.recordId, title: titles[r.recordId] ?? "Record", status: r.status, error: r.error })), titles, recordsNotReady: w.recordsNotReady ?? [], decisions: w.decisions ?? {}, coverageReview, confirmedHardToRead: w.confirmedHardToRead ?? [], }; } export function decisionsBody(input: T.DecisionsInput): W.DecisionsBody { return { revision: input.revision, decisions: input.decisions, coverageReview: Object.fromEntries( Object.entries(input.coverageReview).map(([id, v]) => [id, { ...(v.choice ? { choice: v.choice } : {}), ...(v.reason.trim() ? { reason: v.reason.trim() } : {}), fingerprint: v.fingerprint }]), ), acknowledgedOriginals: input.acknowledgedOriginals, // Only included facts may be confirmed one by one; the backend refuses any other. confirmedHardToRead: input.confirmedHardToRead.filter((id) => input.decisions[id] === "include"), }; } export const checkFromWire = (w: W.CheckRun): T.CheckRun => ({ ...w, summary: w.summary ?? null });