marmar-context / web / context / src / logic / access.ts
access.ts
Raw
import type { AccessRequestInput, HeardFrom, InvitePreview, LicensingBody, Profession } from "../api/types";

export const TERMS_VERSION = "clinician-terms-2026-10";

export const countries = [
  { code: "NG", name: "Nigeria", open: true },
  { code: "RW", name: "Rwanda", open: false },
  { code: "QA", name: "Qatar", open: false },
  { code: "GH", name: "Ghana", open: false },
  { code: "KE", name: "Kenya", open: false },
  { code: "GB", name: "United Kingdom", open: false },
  { code: "OTHER", name: "Another country", open: false },
] as const;

export const heardFromLabels: Record<HeardFrom, string> = {
  film: "The MARMAR Context film",
  patient_invite: "A patient's invite",
  colleague: "A colleague",
  other: "Somewhere else",
};

export type AccessForm = {
  fullName: string;
  profession: Profession | "";
  licenceNumber: string;
  country: string;
  clinicName: string;
  clinicPhone: string;
  workEmail: string;
  mobile: string;
  heardFrom: HeardFrom | "";
  heardFromOther: string;
  acceptedTerms: boolean;
};
export type AccessErrors = Partial<Record<keyof AccessForm, string>>;

export const emptyAccessForm = (): AccessForm => ({
  fullName: "",
  profession: "",
  licenceNumber: "",
  country: "NG",
  clinicName: "",
  clinicPhone: "",
  workEmail: "",
  mobile: "",
  heardFrom: "",
  heardFromOther: "",
  acceptedTerms: false,
});

/** ACC-4: a request from a patient's invite starts with what the invite already knows. */
export function prefillFromInvite(form: AccessForm, invite: InvitePreview): AccessForm {
  const name = invite.doctorName.replace(/^(dr\.?|doctor|pharm\.?)\s+/i, "").trim();
  return {
    ...form,
    fullName: form.fullName || name,
    clinicName: form.clinicName || invite.clinicName || "",
    workEmail: form.workEmail || invite.doctorEmail || "",
    profession: form.profession || (/^(dr\.?|doctor)\s/i.test(invite.doctorName) ? "doctor" : ""),
    heardFrom: "patient_invite",
  };
}

export const licensingBodyFor = (profession: Profession): LicensingBody => (profession === "doctor" ? "MDCN" : "PCN");

const email = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
const phone = (value: string) => {
  const digits = value.replace(/[\s().-]/g, "");
  return /^\+?\d{7,15}$/.test(digits);
};
/** A number Context may text: with its country code (+234... or 00234...), as the backend stores it for alerts. */
export const isInternationalNumber = (value: string) => /^(\+|00)[1-9]\d{6,14}$/.test(value.replace(/[\s().-]/g, ""));
/** MDCN folio numbers such as MDCN/R/123456, or the bare folio digits. */
const mdcn = /^(MDCN\/[A-Z]{1,3}\/\d{3,7}|\d{3,7})$/i;
/** PCN registration numbers: letters, digits and slashes or dashes. */
const pcn = /^[A-Z0-9][A-Z0-9/-]{2,19}$/i;

/** ACC-3 validation. Each message says what to fix. */
export function validateAccess(form: AccessForm): AccessErrors {
  const errors: AccessErrors = {};
  const name = form.fullName.trim();
  if (name.split(/\s+/).length < 2 || name.length > 120) errors.fullName = "Enter your full name as it appears on your licence.";
  if (!form.profession) errors.profession = "Choose doctor or pharmacist.";
  const licence = form.licenceNumber.trim();
  if (!licence) errors.licenceNumber = "Enter your licence number.";
  else if (form.profession === "doctor" && !mdcn.test(licence))
    errors.licenceNumber = "Enter your MDCN folio number, for example MDCN/R/123456.";
  else if (form.profession === "pharmacist" && !pcn.test(licence))
    errors.licenceNumber = "Enter your PCN registration number, letters and digits only.";
  if (!form.country) errors.country = "Choose the country you practise in.";
  if (form.clinicName.trim().length < 2) errors.clinicName = "Enter your clinic or facility name.";
  if (!phone(form.clinicPhone)) errors.clinicPhone = "Enter the clinic phone with its country code, for example +234 1 234 5678.";
  if (!email.test(form.workEmail.trim())) errors.workEmail = "Enter your work email, for example name@clinic.ng.";
  if (!isInternationalNumber(form.mobile)) errors.mobile = "Enter your mobile number with its country code, for example +234 803 123 4567.";
  if (!form.heardFrom) errors.heardFrom = "Tell us how you heard about Context.";
  else if (form.heardFrom === "other" && !form.heardFromOther.trim()) errors.heardFromOther = "Tell us where you heard about it.";
  if (!form.acceptedTerms) errors.acceptedTerms = "Accept the clinician terms to send your request.";
  return errors;
}

export function toAccessRequest(form: AccessForm, inviteToken: string | null): AccessRequestInput {
  const profession = form.profession as Profession;
  return {
    fullName: form.fullName.trim(),
    profession,
    licensingBody: licensingBodyFor(profession),
    licenceNumber: form.licenceNumber.trim().toUpperCase(),
    country: form.country,
    clinicName: form.clinicName.trim(),
    clinicPhone: form.clinicPhone.trim(),
    workEmail: form.workEmail.trim().toLowerCase(),
    mobile: form.mobile.trim(),
    heardFrom: form.heardFrom as HeardFrom,
    heardFromOther: form.heardFrom === "other" ? form.heardFromOther.trim() : null,
    acceptedTermsVersion: TERMS_VERSION,
    inviteToken,
  };
}

/** ACC-7: lock after the chosen idle time; the session itself ends after 12 hours. */
export const shouldLock = (lastActivity: number, now: number, idleMinutes: number) =>
  now - lastActivity >= idleMinutes * 60_000;
export const sessionEnded = (expiresAt: string, now: number) => now >= new Date(expiresAt).getTime();