import type { AuthorityTier } from '../services/relatedPersonAuthority'; import type { AgreementAnswerEligibility } from '../services/OrgAuthorizationService'; /** * WHERE A SWITCHED-IN HELPER STANDS ON THEIR OWN LIST, and the three answers are not two. * * - The tier on their row. * - `'none'` once their list has ANSWERED without the link: it was revoked or removed. That is a refusal, * and it never widens what is offered. Seen in a walk: an assist-only helper's open document showed * Edit, Delete and Add for two seconds after the patient revoked them, because an absent row read as an * unknown tier and unknown fails open. * - `undefined` while unknown: no answer yet, or the read failed. That is what fails open, below. */ export type ProxyStanding = AuthorityTier | 'none' | undefined; export const proxyStanding = ( proxies: ReadonlyArray<{ relatedPersonId: string; careTier?: AuthorityTier }>, relatedPersonId: string, listAnswered: boolean | undefined, ): ProxyStanding => { const row = proxies.find((proxy) => proxy.relatedPersonId === relatedPersonId); if (row) return row.careTier; return listAnswered ? 'none' : undefined; }; /** * MAY THIS PROXY FILL IN A FORM FOR THE PERSON THEY HELP? * * The tiers are cumulative and only the bottom one is view-only, so this is one comparison rather than * a list of capabilities - but it is written down because the screen that asks it must agree with the * server, and the server's answer is a 403 that arrives after the patient has typed every answer. * * SEEN IN A WALK: an assist-only related person opened a questionnaire for their partner, answered * it, pressed Submit, and only then learned they could not. The tier was knowable before the form * rendered - it is on the patient's own Family & caregivers screen - so the work was thrown away for * want of one read. * * THIS IS A COURTESY, NEVER A GATE. The server is the authorization boundary and refuses this write * on its own; if this returned the wrong answer the record would still be safe. That is also why the * caller fails OPEN when the grant cannot be read: a proxy who may act must not be locked out of a form * by a read that failed, and one who may not is refused by the server either way. */ export const mayCompleteFormsAsProxy = (tier: ProxyStanding): boolean => tier === undefined || (tier !== 'assist-only' && tier !== 'none'); // Allergies, medications and attesting to none: full access only, as the server's matrix has it. // Fails OPEN on an unknown tier for the reason given on `mayCompleteFormsAsProxy`. export const mayAddToRecordAsProxy = (tier: ProxyStanding): boolean => tier === undefined || tier === 'full-proxy'; // The practice agreements: the server lets only the patient or a full-access helper answer them. Unlike the // form rules above this fails closed, since an answer offered and then refused tells a guardian nothing. export type AgreementAnswering = 'may' | 'checking' | 'failed' | 'may-not'; export const agreementAnswering = (tier: ProxyStanding, readFailed: boolean): AgreementAnswering => readFailed ? 'failed' : tier === 'full-proxy' ? 'may' : tier === undefined ? 'checking' : 'may-not'; export const agreementLimitation = (answering: AgreementAnswering, tier: ProxyStanding, subjectName: string): string | null => { if (answering === 'may') return null; if (answering === 'checking') return `Checking whether your access allows answering these agreements for ${subjectName}…`; if (answering === 'failed') return `We could not check whether your access allows answering these agreements for ${subjectName}. Please try again later.`; return tier === 'scheduling-forms' ? `Your current access allows appointments and forms. It does not allow answering these agreements for ${subjectName}. Contact the practice for help.` : `Your current access does not allow answering these agreements for ${subjectName}. Contact the practice for help.`; }; /** * What to tell somebody the form is not theirs to fill in, in the words their own portal uses. * * NAMES WHAT IS MISSING AND WHO CAN CHANGE IT, which is the difference between this and the sentence * the server's refusal produces. "You do not have access to do this" is true and leaves the reader with * nowhere to go; the patient is the only person who can raise a tier, and they do it in one place. */ export const describeProxyCannotCompleteForms = (subjectName: string | undefined): string => { const who = subjectName?.trim() ? `${subjectName.trim()}'s` : 'this'; return `You can see ${who} information but cannot fill in forms for them. ` + 'They can change that in their own portal, under Family & caregivers.'; }; /** * What a record page says instead of Add, in the same words as the form refusal above. The allergy and * medication lists gate on `mayAddToRecordAsProxy`. */ export const describeProxyCannotAddToRecord = (subjectName: string | undefined): string => { const who = subjectName?.trim() ? `${subjectName.trim()}'s` : 'this'; return `You can see ${who} record but cannot add to it. ` + 'They can change that in their own portal, under Family & caregivers.'; }; /** * What the contact-preferences section says to a helper who may not change it. The permission matrix * (roles/patient/patient_proxy_permission_matrix_mvp.md section 3): "assist-only writes nothing at all". */ export const describeProxyCannotChangePreferences = (subjectName: string | undefined): string => { const who = subjectName?.trim() ? subjectName.trim() : 'this person'; return `You can see how Akashic contacts ${who} but cannot change it. ` + 'They can change that in their own portal, under Family & caregivers.'; }; /** * MAY THIS PROXY BOOK FOR THE PERSON THEY HELP? The same tier as forms: the permission matrix gives * appointments to scheduling-and-forms and above, and assist-only writes nothing. Fails OPEN on an unknown * tier for the reason given on `mayCompleteFormsAsProxy`; the server is the boundary. */ export const mayBookAsProxy = (tier: ProxyStanding): boolean => mayCompleteFormsAsProxy(tier); /** What booking says to a helper who may not book, before they fill anything in. */ export const describeProxyCannotBook = (subjectName: string | undefined): string => { const who = subjectName?.trim() ? subjectName.trim() : 'this person'; return `You can see ${who}'s appointments but cannot book for them. ` + 'They can change that in their own portal, under Family & caregivers.'; }; /** * MAY THIS PROXY ADD, EDIT OR REMOVE THE PEOPLE WHO CAN ACCESS THE PERSON THEY HELP? * * Full access only - the permission matrix's own row, "Related persons: Full access only". A helper below * that must not be able to grant somebody else access they do not hold themselves. Fails OPEN on an * unknown tier for the reason given on `mayCompleteFormsAsProxy`; the server is the boundary. */ export const mayManageRelatedPersonsAsProxy = (tier: ProxyStanding): boolean => tier === undefined || tier === 'full-proxy'; /** What Family & caregivers says to a helper who may not change who can access the patient. */ export const describeProxyCannotManageRelatedPersons = (subjectName: string | undefined): string => { const who = subjectName?.trim() ? `${subjectName.trim()}'s` : 'this'; return `You can see who can access ${who} account but cannot change it. ` + 'They can change that in their own portal, under Family & caregivers.'; }; /** * MAY THIS PROXY CHANGE THE PROFILE OF THE PERSON THEY HELP? * * Only a full proxy may. The profile is the person's own identity record; the tier below full is forms * and scheduling, and the bottom one is view-only. The server refuses a profile change below full proxy. * * A courtesy, never a gate, and it fails OPEN on an unknown tier for the reason given on * `mayCompleteFormsAsProxy` above. */ export const mayEditProfileAsProxy = (tier: ProxyStanding): boolean => tier === undefined || tier === 'full-proxy'; /** What the profile says instead of offering edits, in the same words as the form refusal above. */ export const describeProxyCannotEditProfile = (subjectName: string | undefined): string => { const who = subjectName?.trim() ? `${subjectName.trim()}'s` : 'this'; return `You can see ${who} profile but cannot change it. ` + 'They can change that in their own portal, under Family & caregivers.'; }; /** * Who may answer is the server's decision, and the tier is never enough on its own: a child's agreements are answered * by an established guardian, never by the child, and a guardianship ends at 18. The status read carries that decision * as answerEligibility; an answer is offered only when it said eligible. A read that did not say is not eligible. */ export const answerEligibilityOf = (read: { answerEligibility?: AgreementAnswerEligibility }): AgreementAnswerEligibility => read.answerEligibility ?? { status: 'unavailable' }; export const answeringWithEligibility = ( byTier: AgreementAnswering, eligibility: AgreementAnswerEligibility | undefined, ): AgreementAnswering => { if (byTier !== 'may') return byTier; if (!eligibility) return 'checking'; return eligibility.status === 'eligible' ? 'may' : eligibility.status === 'refused' ? 'may-not' : 'failed'; }; const ELLIPSIS = String.fromCharCode(8230); const REFUSED_BECAUSE: Record string> = { 'guardian-answers': () => 'A parent or guardian answers these agreements for anyone under 18.', 'not-guardian': (subject) => "Only a parent or guardian answers these agreements for " + subject + ". Contact the practice for help.", 'guardianship-ended': (subject) => "A guardianship ends at 18, so it cannot answer these agreements for " + subject + ". Contact the practice for help.", 'age-unknown': (subject, self) => "A date of birth is needed before " + (self ? "your" : subject + "'s") + " agreements can be answered. Contact the practice to add it.", 'not-full-proxy': (subject) => "Your current access does not allow answering these agreements for " + subject + ". Contact the practice for help.", }; /** What is said instead of Agree once the tier allows it but the server's decision does not, or is not known. */ export const eligibilityLimitation = ( answering: AgreementAnswering, eligibility: AgreementAnswerEligibility | undefined, subjectName: string, self: boolean, ): string | null => { const forWhom = self ? "" : " for " + subjectName; if (answering === 'may') return null; if (answering === 'checking') return "Checking whether you can answer these agreements" + forWhom + ELLIPSIS; if (answering === 'failed') return "We could not check whether you can answer these agreements" + forWhom + ". Please try again later."; const because = eligibility?.status === 'refused' && eligibility.code ? REFUSED_BECAUSE[eligibility.code] : undefined; return because ? because(subjectName, self) : "These agreements cannot be answered from this account" + forWhom + ". Contact the practice for help."; };