// Pure helpers behind the related-person authority-grant client service (F1 plan, Task 8) - kept free of // environment/HttpService imports so they're importable under the plain-node vitest environment (the same // split as chargeItemDefinitionSearch.ts / ChargeItemDefinitionService.tsx). // // Byte-identical to the server contract (Task 3) - do not add/rename/reorder tiers without a matching // server-side change. import type { DesignationAttestationRequest } from './relatedPersonDesignation'; export type AuthorityTier = 'assist-only' | 'scheduling-forms' | 'full-proxy'; export const TIER_LABELS: Record = { 'assist-only': 'Assist only', 'scheduling-forms': 'Scheduling & forms', 'full-proxy': 'Full proxy', }; export const TIER_DESCRIPTIONS: Record = { 'assist-only': 'View-only — can see information but cannot act on the patient\'s behalf.', 'scheduling-forms': 'Can book/cancel appointments, complete questionnaires, upload intake documents, and enter coverage — no clinical or profile changes.', 'full-proxy': 'Full patient parity — can act on the patient\'s behalf across the record.', }; export interface AuthorityGrant { relatedPersonId: string; tier: AuthorityTier; allowMessaging: boolean; // - FINANCIAL disclosure. Orthogonal to the tier: a full-proxy grant implies NEITHER, and an // assist-only grant may carry both. Default OFF - a pre-028 grant simply lacks them and reads as false, // which is the whole migration. `allowBillingPay` DEPENDS on `allowBillingView` (you cannot pay an // invoice you may not see); the server rejects pay-without-view with a 400 and the UI keeps the pay // toggle disabled until view is on. allowBillingView: boolean; allowBillingPay: boolean; /** * Present ONLY on a desk designation - a receptionist recording what the patient asked for. * Absent is the normal case and means the patient granted this themselves in their own portal. */ attestation?: DesignationAttestationRequest; /** * The decision generation this grant is at, 0 for a link never decided. A change is sent based on it. * Absent only from a server that predates it. */ generation?: number; /** * Only a `current` grant carries its tier and flags. `none` is a managed link never given a grant: the * assist-only floor, at generation 0. `pending` is a decision still being written and `unavailable` one that * could not be read (or a link this list does not hold); neither is shown as what the person may do, and * neither is a base to change from. `ended` is a guardianship past the child's majority: permanent, with no * tier, and never a base to change from either. Absent means current, from a server that predates it. */ state?: AuthorityGrantState; /** Why a link has ended; only with `state: 'ended'`. */ reason?: 'guardianship-ended'; } export type AuthorityGrantState = 'current' | 'none' | 'pending' | 'unavailable' | 'ended'; /** A grant in force, with its tier: what may be shown and changed from. */ export const isCurrentGrant = (grant: AuthorityGrant): boolean => !grant.state || grant.state === 'current'; /** A decision that is known: a grant in force, or a link known to have none. Pending, unreadable and ended are not. */ export const isSettledGrant = (grant: AuthorityGrant): boolean => isCurrentGrant(grant) || grant.state === 'none'; /** A guardianship that has ended: it grants nothing, and no read will make it current again. */ export const isEndedGrant = (grant: AuthorityGrant): boolean => grant.state === 'ended'; /** Whether the list says this link has ended. */ export const endedFor = (grants: readonly AuthorityGrant[] | null | undefined, relatedPersonId: string | undefined): boolean => Boolean(grants?.some((grant) => grant.relatedPersonId === relatedPersonId && isEndedGrant(grant))); export interface AuthorityGrantsResponse { grants: AuthorityGrant[]; /** * True when no search behind the list was cut short by paging. It does not say the index has caught up, so a * known link missing from the list is unknown either way. Absent: a server that predates it. */ complete?: boolean; } //: the pay view dependency as a pure function, so the UI and the request body can never // disagree about it. Pay is only ever true alongside view. The server enforces the same rule (400) and the // Consent builder normalizes it a third time - three layers, because a stored pay-without-view grant would // be a permission nobody intended. export const normalizeBillingGrant = ( allowBillingView: boolean, allowBillingPay: boolean, ): { allowBillingView: boolean; allowBillingPay: boolean } => ({ allowBillingView, allowBillingPay: allowBillingView && allowBillingPay, }); // Pure request-body builder - the unit-testable seam, kept free of hook/HTTP concerns. //: the billing flags default to FALSE so every pre-028 call site (the invite flow's // applyRelatedPersonAuthority, which grants a tier at first touch) keeps compiling AND keeps granting no // financial access - a new link must never start out able to see the patient's money. export const buildAuthorityRequest = ( relatedPersonId: string, tier: AuthorityTier, allowMessaging: boolean, allowBillingView = false, allowBillingPay = false, /** * Present ONLY when a receptionist is recording a designation the patient made at the desk. * * Absent is the normal case and means the patient granted this themselves. It carries the * statement version and nothing else - `staff-attested` is not a value this client may claim, * and the witness is not a field it may fill, because the server takes both from the * authenticated caller. A body that could name its own witness could forge one. */ attestation?: DesignationAttestationRequest, ): AuthorityGrant => ({ relatedPersonId, tier, allowMessaging, ...normalizeBillingGrant(allowBillingView, allowBillingPay), ...(attestation ? { attestation } : {}), }); /** * ONE DECISION ABOUT A GRANT, AS THE SERVER KNOWS IT: a stable id for the change, and the decision generation * the person was looking at when they made it. Both travel on every write, and both stay the same across the * retries of a save that was never confirmed, so the server can apply it once and refuse it once it is stale. * A new decision (another opening, another person, other choices) is a new intent read from a fresh grant; * it is never an old one rebased onto whatever is newest. */ export interface AuthorityIntent { requestId: string; basedOnGeneration: number; } const newRequestId = (): string => globalThis.crypto?.randomUUID?.() ?? `${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 14)}`; export const newAuthorityIntent = (basedOnGeneration: number): AuthorityIntent => ({ requestId: newRequestId(), basedOnGeneration, }); /** The write body: the grant, and the intent it is one attempt of. */ export const buildAuthorityChange = ( grant: AuthorityGrant, intent: AuthorityIntent, ): AuthorityGrant & AuthorityIntent => ({ ...grant, requestId: intent.requestId, basedOnGeneration: intent.basedOnGeneration, }); /** * What a failed write means for its intent. Read from the server's `code`, not the status alone: a 409 is also * how an ordinary rule answers ("full access for one person at a time"), and that one is a refusal. * * - `unconfirmed`: 503 NOT_CONFIRMED. It may have been applied; retry the SAME intent, and never say it was * saved or that nothing changed. * - `superseded`: 409 AUTHORITY_CHANGED or REQUEST_REUSED, 428 REFRESH_REQUIRED. Another decision came first * or this one is out of date; nothing more is sent for it, and what is in force is read again. * - `unanswered`: no HTTP answer at all. The intent is kept, so a retry cannot apply it twice. * - `refused`: any other answer. The server said no before changing anything. */ export type AuthoritySaveOutcome = 'unconfirmed' | 'superseded' | 'unanswered' | 'refused'; const SUPERSEDED_CODES = new Set(['AUTHORITY_CHANGED', 'REQUEST_REUSED', 'REFRESH_REQUIRED']); export const authoritySaveOutcome = (error: unknown): AuthoritySaveOutcome => { const { status, body } = (error ?? {}) as { status?: unknown; body?: unknown }; const code = (body as { code?: unknown } | null | undefined)?.code; if (typeof status !== 'number') return 'unanswered'; if (status === 503 || code === 'NOT_CONFIRMED') return 'unconfirmed'; if (status === 428 || (typeof code === 'string' && SUPERSEDED_CODES.has(code))) return 'superseded'; return 'refused'; }; /** * The tier a list row says for a link. What the server holds wins: its grant in force; the assist-only floor for a * link it says has none; nothing at all for a decision pending or unreadable, and nothing for a link missing from a * list that reports `complete` (unknown, not untouched). Only a server that predates those states leaves room for * what this session's invite wrote. */ export const tierShownFor = ( grants: readonly AuthorityGrant[] | null | undefined, listReportsStates: boolean, relatedPersonId: string | undefined, written: AuthorityTier | undefined, ): AuthorityTier | undefined => { const row = grants?.find((grant) => grant.relatedPersonId === relatedPersonId); if (row) return isCurrentGrant(row) ? row.tier : row.state === 'none' ? 'assist-only' : undefined; return listReportsStates ? undefined : written; };