// 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<T.ClinicProfile, "version">): 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<W.Record_, "id" | "title">[]): Record<string, string> {
return { [profileSourceId(patientId)]: PROFILE_TITLE, ...Object.fromEntries(records.map((r) => [r.id, r.title])) };
}
export function extractionFromWire(w: W.Extraction, titles: Record<string, string>): 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 });