import type { OwnerOperation, OwnerState } from '../../services/OrganizationOwnerService.tsx'; import { newSettingsOperationId, settingsWriteHolder } from '../../utils/unresolvedSettingsWrite'; import { referencesPractitioner } from './ownerReference'; import { AKASHIC_SUPPORT_ADDRESS } from '../../config/support'; // An owner action sent with no answer is held for the viewer that sent it (AuthService's key names the practice), // with what it asked for, until the practice's own owner record shows it done. Only an action that is refused // once it has landed may be sent again; one that tells somebody (a nomination, a hand-over) is offered again only // after a check that did not find it. // // An action that makes somebody the owner carries its operation. It is done only when the server says that operation // settled - designation alone is not - and one the server admitted but has not finished is held until the same // operation finishes it. export type OwnerAction = | { action: 'accept' | 'accept-transfer' | 'claim'; operation?: OwnerOperation } | { action: 'decline' } | { action: 'nominate'; practitionerId: string; operation?: OwnerOperation } | { action: 'transfer'; practitionerId: string }; export const ownerActionHolder = (): string => settingsWriteHolder('practice-owner'); const readOperation = (value: unknown): OwnerOperation | undefined => { const op = value as OwnerOperation | undefined; return typeof op?.id === 'string' && typeof op.fingerprint === 'string' ? { id: op.id, fingerprint: op.fingerprint } : undefined; }; export const readOwnerAction = (detail?: string): OwnerAction | undefined => { try { const held = JSON.parse(detail ?? '') as OwnerAction & { operation?: unknown }; const operation = readOperation(held.operation); if (held.action === 'nominate') { return typeof held.practitionerId === 'string' ? { action: 'nominate', practitionerId: held.practitionerId, ...(operation ? { operation } : {}) } : undefined; } if (held.action === 'transfer') return typeof held.practitionerId === 'string' ? { action: 'transfer', practitionerId: held.practitionerId } : undefined; if (held.action === 'decline') return { action: 'decline' }; return ['accept', 'accept-transfer', 'claim'].includes(held.action) ? { action: held.action as 'accept' | 'accept-transfer' | 'claim', ...(operation ? { operation } : {}) } : undefined; } catch { return undefined; } }; /** A new operation for an action that makes this viewer the owner, kept with the action until the server settles it. */ export const newOwnerOperation = (action: string, organizationId: string, practitionerId: string | undefined): OwnerOperation => ({ id: `owner:${newSettingsOperationId()}`, fingerprint: `${action}|${organizationId}|${practitionerId ?? ''}`, }); export type OperationSettlement = 'settled' | 'settling' | 'superseded' | undefined; /** Where this operation stands on the owner record; undefined when the record does not show it. */ export const operationSettlement = (state: OwnerState | undefined, operationId: string | undefined): OperationSettlement => { const transition = state?.transition; if (!transition || !operationId) return undefined; if (transition.operationId === operationId) return transition.state; return transition.yourEarlierOperations?.some((entry) => entry.operationId === operationId) ? 'superseded' : undefined; }; /** The operation an action carries, when it is one that makes somebody the owner. */ export const operationOf = (held: OwnerAction): OwnerOperation | undefined => held.action === 'accept' || held.action === 'accept-transfer' || held.action === 'claim' || held.action === 'nominate' ? held.operation : undefined; /** Whether the practice's owner record shows this action done, read by the viewer `me`. */ export const ownerStateShows = (held: OwnerAction, state: OwnerState, me: string | undefined): boolean => { // A read with no transition comes from a server that predates them, and is read as before. const operation = operationOf(held); if (operation && state.transition) { const settlement = operationSettlement(state, operation.id); return settlement === 'settled' || settlement === 'superseded'; } switch (held.action) { case 'accept': case 'accept-transfer': case 'claim': return state.status === 'accepted' && referencesPractitioner(state.ownerPractitionerId, me); case 'decline': return !(state.status === 'nominated' && referencesPractitioner(state.nomineePractitionerId, me)); case 'nominate': return referencesPractitioner(state.nomineePractitionerId, held.practitionerId) || (state.status === 'accepted' && referencesPractitioner(state.ownerPractitionerId, held.practitionerId)); case 'transfer': return referencesPractitioner(state.pendingTransferToPractitionerId, held.practitionerId) || (state.status === 'accepted' && referencesPractitioner(state.ownerPractitionerId, held.practitionerId)); } }; /** The route that finishes an action's admitted operation. */ export const resumeRouteOf = (held: OwnerAction): 'accept' | 'nominate' | 'transfer-accept' | 'claim-confirm' | undefined => { switch (held.action) { case 'accept': return 'accept'; case 'accept-transfer': return 'transfer-accept'; case 'claim': return 'claim-confirm'; case 'nominate': return 'nominate'; default: return undefined; } }; /** The viewer's own unfinished operation the owner read reports, as the action that finishes it after a reopen. */ export const unfinishedOwnerAction = (state: OwnerState | undefined, me: string | undefined): OwnerAction | undefined => { const transition = state?.transition; if (transition?.state !== 'settling' || !transition.operationId || transition.fingerprint === undefined) return undefined; const operation = { id: transition.operationId, fingerprint: transition.fingerprint }; switch (transition.route) { case 'accept': return { action: 'accept', operation }; case 'transfer-accept': return { action: 'accept-transfer', operation }; case 'claim-confirm': return { action: 'claim', operation }; case 'nominate': return me ? { action: 'nominate', practitionerId: me, operation } : undefined; default: return undefined; } }; /** Sent again only once a check has not found it, when it would tell somebody a second time. */ export const tellsSomebody = (held: OwnerAction): boolean => (held.action === 'nominate' && !held.operation) || held.action === 'transfer'; export const OWNER_ACTION_WORDS: Record = { accept: 'your acceptance of ownership was recorded', decline: 'your answer was recorded', 'accept-transfer': 'your acceptance of the hand-over was recorded', claim: 'you were recorded as the owner', nominate: 'the owner nomination was recorded', transfer: 'the hand-over request was recorded', }; export const unconfirmedOwnerAction = (held: OwnerAction): string => `We could not confirm whether ${OWNER_ACTION_WORDS[held.action]}. Check this practice's owner before doing anything else here.`; /** Recorded, with stages still owed; the same operation finishes it. */ export const OWNER_ACTION_SETTLING = 'You are recorded as the owner, but setting up your ownership is not finished yet. Finish it before doing anything else here.'; /** The action was done, and a later change of owner has happened since. */ export const OWNER_ACTION_SUPERSEDED = 'That was already done, and ownership of this practice has changed again since. Nothing more was changed.'; /** The owner record shows the viewer's own change of owner still unfinished, after a reopen. */ export const OWNER_UNFINISHED = 'Taking ownership of this practice was started but not finished. Finish it before doing anything else here.'; /** Ownership is recorded through the link in the owner email; only that link finishes it. */ export const OWNER_UNFINISHED_BY_LINK = 'Taking ownership of this practice was started from the owner email and is not finished. Open the newest owner email and press Accept again.'; // A confirmed change whose practice details could not be re-read afterwards is still the change it was. export const OWNER_SAVED_REFRESH_FAILED = 'Ownership is saved, but this page could not reload your practice details. Reload the page before changing other practice settings.'; /** The real support address, for an owner with no lawful self-service path back. */ export const OWNER_SUPPORT_ADDRESS = AKASHIC_SUPPORT_ADDRESS; /** * What a settled owner without administrator standing can lawfully do next: never a repeat acceptance or claim, * which the server refuses. `viewerIsOwner` picks whose words they are. */ export const ownerRecoveryWords = (state: OwnerState | undefined, viewerIsOwner: boolean): string | undefined => { if (state?.status !== 'accepted') return undefined; if (state.ownerStanding === 'unavailable') { return 'We could not check the owner\'s administrator access just now. Nothing is wrong with the ownership itself; check again later.'; } if (state.ownerStanding !== 'recovery-required') return undefined; if (state.ownerRecovery === 'support') { return viewerIsOwner ? `Your staff access to this practice has ended. Email Akashic support at ${OWNER_SUPPORT_ADDRESS} to restore it; they will confirm who you are through the practice first.` : `The owner's staff access to this practice has ended. An administrator can add them back as staff, or the owner can email Akashic support at ${OWNER_SUPPORT_ADDRESS}.`; } return viewerIsOwner ? 'Your administrator access to this practice has ended, but you are still its owner. Ask another administrator to restore it, or hand ownership to a current administrator.' : 'The owner\'s administrator access has ended. An administrator can restore it from the owner\'s staff record; ownership does not need to change.'; };