marmar-context / web / context / src / api / adapt.ts
adapt.ts
Raw
// 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 });