import type { InvitationOutcome, InvitationReceipt } from "../../services/ClinicianInvitationService"; import { outcomeUnknown } from "../saveOperation"; import { settingsWriteHolder } from "../unresolvedSettingsWrite"; // What an invitation press is told, from the receipt the server gave for it. Saved and sent are said apart; queued // means the email was handed to the queue, never that it arrived; an answer that was lost is checked by its // operation, never repeated blind, and never read as "not saved". export type OutcomeTone = "success" | "warning" | "error"; /** An outcome as consumers pass it: from the service, or an older one that carried only the invitation. */ export type InvitationResult = Partial & { invitation?: InvitationOutcome["invitation"]; existing?: boolean }; /** The receipt, or what an answer with no receipt can conservatively say. */ export const receiptOf = (result: InvitationResult): InvitationReceipt => result.receipt ?? { operationId: "", save: result.invitation ? "saved" : "unconfirmed", ...(result.invitation && { invitationId: result.invitation.id }), standing: result.existing ? "reused" : "created", send: { state: result.existing || result.invitation?.delivery === "draft" ? "not-requested" : result.invitation?.delivery === "not-sent" ? "not-sent" : "unconfirmed" }, }; /** Whether this press handed an email to the queue: the only state that may be called sent. */ export const outcomeQueued = (result: InvitationResult): boolean => { const receipt = receiptOf(result); return receipt.save === "saved" && receipt.standing !== "reused" && receipt.send.state === "queued"; }; /** The words for an outcome, about the person named. */ export const invitationOutcomeNotice = (result: InvitationResult, name: string): { tone: OutcomeTone; text: string } => { const receipt = receiptOf(result); if (receipt.save !== "saved") { if (receipt.send.state === "queued") return { tone: "warning", text: `The invitation email for ${name} is queued to send, but its permanent record is not confirmed. Check this same invitation before doing anything else.` }; if (receipt.standing === "reused") return { tone: "warning", text: `This request names the existing invitation for ${name}, but its permanent record is not confirmed. Nothing new was sent. Check this same invitation before doing anything else.` }; return { tone: "warning", text: `We could not confirm the invitation for ${name} was saved. Check it before trying again.` }; } if (receipt.standing === "reused") return { tone: "success", text: `${name} already has an invitation waiting, so nothing new was sent.` }; if (receipt.standing === "resent") { switch (receipt.send.state) { case "queued": return { tone: "success", text: `A new link for ${name} is queued to send. The earlier link no longer works.` }; case "not-sent": return { tone: "warning", text: `The new link for ${name} was not sent. Check the invitation before sending it again.` }; default: return { tone: "warning", text: `We could not confirm the new link for ${name} was sent. The earlier link may no longer work, so check the invitation before sending another.` }; } } switch (receipt.send.state) { case "queued": return { tone: "success", text: `The invitation for ${name} is saved and its email is queued to send. They join your staff when they accept.` }; case "not-requested": return { tone: "success", text: `The invitation for ${name} is saved as a draft. Nothing was sent.` }; case "not-sent": return { tone: "warning", text: `The invitation for ${name} is saved, but its email was not sent. Open it in the staff list to send it.` }; default: return { tone: "warning", text: `The invitation for ${name} is saved, but we could not confirm its email was sent. Check it in the staff list before sending another link.` }; } }; /** An answer that may have landed: lost, timed out with a write out, or one that said nothing readable. */ export const invitationOutcomeUnknown = (error: unknown): boolean => (error as { name?: unknown } | null)?.name === "InvitationAnswerUnreadable" || outcomeUnknown(error); /** A fresh operation id for one create or resend: a UUID v4, made before anything is sent. */ export const newInvitationOperationId = (): string => crypto.randomUUID(); // The operation of a press whose answer is not known yet, kept for the viewer that made it (account, acted-for // subject and practice), what it was and the opening it was made from. Only the opaque id is kept. export interface HeldInvitationOperation { operationId: string; opening: string; receipt?: InvitationReceipt; } /** Retain learned target and send facts with the original operation, including through reload. */ export const retainInvitationReceipt = (holder: string, operationId: string, receipt: InvitationReceipt): void => { const fail = () => { const error = new Error("The original invitation could not be retained."); error.name = "InvitationAnswerUnreadable"; throw error; }; const held = heldInvitationOperation(holder); if (typeof held !== "object" || held.operationId !== operationId || receipt.operationId !== operationId) return fail(); const original = held.receipt; if ((original?.invitationId && receipt.invitationId !== original.invitationId) || (original?.send.attemptId && receipt.send.attemptId !== original.send.attemptId) || (original?.send.state === "queued" && receipt.send.state !== "queued")) return fail(); if (!holdInvitationOperation(holder, { ...held, receipt })) return fail(); const reread = heldInvitationOperation(holder); if (typeof reread !== "object" || JSON.stringify(reread.receipt) !== JSON.stringify(receipt)) return fail(); }; const STORAGE_KEY = "akashic.staffInvitationOperations"; // Undefined when the tab's record cannot be read: that is not "nothing held", so nothing is written over it. const readAll = (): Record | undefined => { try { const parsed = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? "{}") as unknown; return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record : undefined; } catch { return undefined; } }; /** Where an invitation press of this kind is held for the signed-in viewer, e.g. `create` or `resend:`. */ export const invitationOperationHolder = (action: string): string => settingsWriteHolder(`staff-invitation:${action}`); /** Keep the operation before it is sent. False when this tab cannot keep it, so the press must not be sent. */ export const holdInvitationOperation = (holder: string, held: HeldInvitationOperation): boolean => { try { // Refuse an unsupported candidate before it can overwrite readable custody. if (!isHeld(held)) return false; const all = readAll(); // A record, or an entry where this press would go, that cannot be read is not written over. if (!all || (holder in all && !isHeld(all[holder]))) return false; sessionStorage.setItem(STORAGE_KEY, JSON.stringify({ ...all, [holder]: held })); return readAll()?.[holder]?.operationId === held.operationId; } catch { return false; } }; const isHeld = (value: unknown): value is HeldInvitationOperation => { const held = value as HeldInvitationOperation | undefined; if (typeof held?.operationId !== "string" || !held.operationId || typeof held.opening !== "string") return false; const receipt = held.receipt; if (receipt === undefined) return true; if (!receipt || receipt.operationId !== held.operationId || !["saved", "unconfirmed"].includes(receipt.save) || !receipt.send || !["queued", "not-requested", "not-sent", "unconfirmed"].includes(receipt.send.state)) return false; const target = receipt.invitationId, attempt = receipt.send.attemptId; if (target !== undefined && (typeof target !== "string" || !target.trim())) return false; if (attempt !== undefined && (typeof attempt !== "string" || !attempt.trim())) return false; if (receipt.compatibility !== undefined) return receipt.compatibility === "legacy-not-sent" && receipt.save === "saved" && target !== undefined && ["created", "resent"].includes(receipt.standing ?? "") && receipt.send.state === "not-sent" && attempt === undefined; if (target === undefined) return receipt.save === "unconfirmed" && receipt.standing === undefined && attempt === undefined && ["not-requested", "unconfirmed"].includes(receipt.send.state); if (!receipt.standing || !["created", "reused", "resent"].includes(receipt.standing)) return false; if (receipt.standing === "reused" && receipt.send.state !== "not-requested") return false; if (receipt.send.state === "not-requested" && attempt !== undefined) return false; if (["queued", "not-sent"].includes(receipt.send.state) && attempt === undefined) return false; return true; }; /** * What is held here: an operation, nothing, or something that cannot be read - the whole record, or an entry that is * there but is not an operation. Unreadable holds every press: it may be a press whose answer is not known. */ export const heldInvitationOperation = (holder: string): HeldInvitationOperation | "unreadable" | undefined => { const all = readAll(); if (!all) return "unreadable"; if (!(holder in all)) return undefined; return isHeld(all[holder]) ? all[holder] : "unreadable"; }; /** The held operation's id, when one is held and readable. */ export const heldOperationId = (holder: string): string | undefined => { const held = heldInvitationOperation(holder); return typeof held === "object" ? held.operationId : undefined; }; export const heldInvitationNotice = (): string => { const held = heldInvitationOperation(invitationOperationHolder("create")); return typeof held === "object" && held.receipt?.invitationId ? invitationOutcomeNotice({ receipt: held.receipt }, "this person").text : "An earlier invitation from this tab was not confirmed. Check it before sending another."; }; /** Where one resend of an invitation is held: each its own, so a deliberate new link never writes over an earlier one. */ export const resendHolder = (invitationId: string, operationId: string): string => invitationOperationHolder(`resend:${invitationId}#${operationId}`); /** Every resend of this invitation still held for this viewer, or "unreadable" when the record cannot be read. */ export const heldResends = (invitationId: string): string[] | "unreadable" => { const all = readAll(); if (!all) return "unreadable"; const head = `staff-invitation:resend:${invitationId}#`; const viewer = invitationOperationHolder(`resend:${invitationId}#`).slice(head.length); const entries = Object.entries(all).filter(([key]) => key.startsWith(head) && key.endsWith(viewer)); // An entry that is not an operation may be a resend whose answer is not known: it is not left out. if (entries.some(([, held]) => !isHeld(held))) return "unreadable"; return entries.map(([, held]) => (held as HeldInvitationOperation).operationId); }; /** The same request, as a stable string, so a repeat can be told apart from an edited one. Kept in memory only. */ export const requestDigest = (request: unknown): string => { const stable = (value: unknown): string => (Array.isArray(value) ? `[${value.map(stable).join(",")}]` : value && typeof value === "object" ? `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stable((value as Record)[key])}`).join(",")}}` : JSON.stringify(value) ?? "null"); return stable(request); }; /** Let go of this operation only: a newer one held since is kept. */ export const releaseInvitationOperation = (holder: string, operationId: string): void => { try { const all = readAll(); if (!all || all[holder]?.operationId !== operationId) return; delete all[holder]; sessionStorage.setItem(STORAGE_KEY, JSON.stringify(all)); } catch { // Kept a little longer than needed: a later check only finds it already answered. } }; export const TRACKING_REFUSED = "This browser could not keep track of the invitation, so it was not sent. Allow this site to store data for the tab, then try again.";