import type { Attachment, DocumentReference, Extension, Reference, Task } from 'fhir/r4'; import { ORGANIZATION_TAG_SYSTEM } from './carePlanAuthorship'; // A custom task that asks the patient for a FILE, and what answering one produces. // // A custom task is an instruction - "bring photo ID" - and it already collects one typed thing: // `measurementTask.ts` makes one ask for a NUMBER, write an Observation and close itself. This is that // shape with a different payload, and it is built decision for decision from that file rather than // invented beside it. // // THE DOCUMENT REFERENCE IS THE POINT RATHER THAN A SIDE EFFECT, and this is where it differs from the // questionnaire half of the same feature. A questionnaire answer is held by the response that produced // it, so writing a DocumentReference as well would file the same file twice. Here there is no response, // and a TASK IS NOT A RECORD ARTIFACT - tasks are voided with their appointment and are not what a // clinician reads a record through. So the DocumentReference is the only thing that puts the letter in // the record at all. // // This is ASKED-FOR, never collected. There is no surface here where a patient uploads whatever they // like, and it must not become one: a purpose-scoped request from a practice is the approved shape. export const AKASHIC_TASK_DOCUMENT_REQUEST_EXTENSION = 'https://akashichealthrecords.com/fhir/extensions/task-document-request'; /** The version of the request the sender opened, sent with a first answer; the server consumes it, never stores it. */ export const REQUEST_ANSWER_TASK_VERSION_SYSTEM = 'https://akashichealthrecords.com/fhir/identifier/request-answer-task-version'; /** What this task asks the patient to send, in the practice's own words. */ export interface TaskDocumentRequest { /** "Your referral letter" - shown to the patient as the ask. */ label: string; } /** * What this task asks for, or `undefined` when it asks for no file. * * A BLANK LABEL IS NOT A REQUEST, which is the same call `getTaskMeasurement` makes about a * half-configured measurement. A file control with nothing naming what to send asks the patient to guess * what the practice wants, and a document that arrives under a guess is worse than one that never came: * nobody can tell from the record whether it is the thing that was needed. */ /** * READ OFF THE TEMPLATE AND OFF THE TASK, which is why this takes the shape they share rather than a * Task. The request is AUTHORED on an ActivityDefinition and ANSWERED on the Task copied from it, and * both surfaces have to ask the same question of the same extension - a second reader for the template * is how the two would come to disagree about what counts as a request. */ export const getTaskDocumentRequest = ( task: { extension?: Extension[] } | undefined, ): TaskDocumentRequest | undefined => { const label = task?.extension?.find( (extension) => extension.url === AKASHIC_TASK_DOCUMENT_REQUEST_EXTENSION, )?.valueString?.trim(); if (!label) { return undefined; } return { label }; }; /** Whether the patient should be asked for a file. */ export const isDocumentRequestTask = (task: { extension?: Extension[] } | undefined): boolean => getTaskDocumentRequest(task) !== undefined; export interface DocumentRequestContext { /** When the patient sent it, as an ISO instant. */ now: string; /** The visit this task is bound to, when one is. */ appointmentReference?: Reference; /** The encounter the visit became, when there is one. */ encounterReference?: Reference; } /** * The letter, as the record holds it. * * `author` IS THE PATIENT, and that is a fact about the document rather than bookkeeping. A referral * letter the patient photographed and one the desk scanned are not the same kind of fact, and a * clinician reading the record has to be able to tell which is which - the same distinction the * measurement draws with `performer`. * * `context.related` CARRIES THE TASK THAT ASKED, which is what satisfies the rule that a document hangs * off the thing that produced it. Without it the letter is in the record with no account of why it is * there, and the request it answers is a task that will be voided with its appointment. * * THE APPOINTMENT GOES IN `related`, NOT IN `context.encounter`. The design names the appointment for * that slot, and `DocumentReference.context.encounter` is typed for an Encounter - an Appointment there * is off-spec and is the kind of thing a validating datastore refuses on write. `related` is * `Reference(Any)` and carries it without lying about what it is. `encounter` is still populated when * the caller genuinely has an Encounter. * * THE PRACTICE THAT ASKED IS THE CUSTODIAN, AND WITHOUT IT THE LETTER IS INVISIBLE. * * Measured in the product on 2026-09-13: the patient sent a photographed letter, the document was * written correctly, and the practitioner's Documents section did not list it. That section queries * `DocumentReference?custodian=&subject=`, and every document the practice's own UI * writes carries a custodian because the service that writes them sets one. This one did not, so it was * filed in the record and reachable from nowhere - the completed task says only that it is complete. * * TAKEN FROM THE TASK RATHER THAN FROM THE PORTAL THE PATIENT HAPPENS TO BE IN. The practice that asked * for the letter is the practice that keeps it, and a patient with two practices can be reading either * one's portal when they answer. The tag is the same one every resource carries and the care-plan * reader already reads. * * OMITTED WHEN THE TAG IS ABSENT rather than guessed. A wrong custodian files the letter in another * practice's record, which is worse than the defect this fixes. */ /** The practice that asked for the letter, or `undefined` when the tag is missing. */ export const requestingOrganizationId = (task: Task | undefined): string | undefined => task?.meta?.tag?.find((tag) => tag.system === ORGANIZATION_TAG_SYSTEM)?.code; export const buildRequestedDocument = ( task: Task, attachment: Attachment, context: DocumentRequestContext, ): DocumentReference => { const request = getTaskDocumentRequest(task); if (!request) { throw new Error('Only a task that asked for a file can file one'); } const subject = task.for; if (!subject?.reference) { throw new Error('A requested document needs the patient it belongs to'); } if (!attachment.url) { throw new Error('A requested document needs the stored file it points at'); } const custodianId = requestingOrganizationId(task); const related: Reference[] = [ { reference: `Task/${task.id}` }, ...(context.appointmentReference ? [context.appointmentReference] : []), ]; return { resourceType: 'DocumentReference', // NO ID. The server files the answer under the one id it reserves for this request, so a retry // lands on the same document rather than a second one. ...(task.meta?.versionId && { identifier: [{ system: REQUEST_ANSWER_TASK_VERSION_SYSTEM, value: task.meta.versionId }], }), status: 'current', // The practice's own words for what it asked for. There is no coding here on purpose: this is // whatever that practice calls the thing, and inventing a code for it would assert a meaning // nobody chose. type: { text: request.label }, subject, ...(custodianId && { custodian: { reference: `Organization/${custodianId}` } }), author: [subject], date: context.now, content: [{ attachment }], context: { related, ...(context.encounterReference && { encounter: [context.encounterReference] }), }, }; }; /** * The extension array to store, with the request set, replaced, or removed. * * PURE, AND HERE RATHER THAN IN THE AUTHORING DIALOG, for the reason `withTimezoneExtension` is: an * authoring form rebuilds a resource from a field list, so an extension the form does not carry is one * the next save deletes. Non-request extensions pass through untouched, which is what stops the next * person adding a task extension from re-living that. * * A blank label REMOVES the request rather than storing an empty one, so "asks for no file" is a single * state rather than two that read differently to `getTaskDocumentRequest`. */ export const withDocumentRequestExtension = ( existing: Task['extension'], label: string | undefined | null, ): NonNullable => { const others = (existing ?? []).filter( (extension) => extension.url !== AKASHIC_TASK_DOCUMENT_REQUEST_EXTENSION, ); const trimmed = typeof label === 'string' ? label.trim() : ''; return trimmed ? [...others, { url: AKASHIC_TASK_DOCUMENT_REQUEST_EXTENSION, valueString: trimmed }] : others; }; export const SAVED_PRIVATE = 'Saved to their record. This document is private in your view.'; export const ANSWER_ALREADY_SENT = 'A file was already saved for this request, so this one was not sent.'; export const SAVED_NOT_SETTLED = 'Your file was saved to their record. The request still needs checking.'; /** Heads a kept send whose request no longer names the file it asked for. */ export const KEPT_ANSWER_LABEL = 'Your file for this request'; export const SEND_UNCONFIRMED = 'We could not confirm your file was sent. Check the request before sending it again.'; export const NOTHING_SAVED_YET = 'No file was saved for this request yet. Choose the file to send.'; /** The code a request-answer refusal carries in its body, such as ANSWER_ALREADY_SAVED or NO_SAVED_ANSWER. */ export const answerRefusalCode = (error: unknown): string | undefined => { const code = (error as { body?: { code?: unknown } } | null | undefined)?.body?.code; return typeof code === 'string' ? code : undefined; }; /** Why a saved answer did not close its request: the server words for a refusal, otherwise that it needs checking. */ export const unsettledAnswerText = (error: unknown): string => { const status = (error as { status?: unknown } | null | undefined)?.status; const message = (error as { body?: { message?: unknown } } | null | undefined)?.body?.message; return typeof status === 'number' && status < 500 && typeof message === 'string' && message.trim() ? `Your file was saved to their record, but the request was not closed. ${message}` : SAVED_NOT_SETTLED; }; //** The refusal fields a request-answer response carries in its body. */ const refusalBody = (error: unknown): { code?: unknown; documentState?: unknown } | undefined => (error as { body?: { code?: unknown; documentState?: unknown } } | null | undefined)?.body; /** A refusal that says a file is already saved for this request, though the request was not closed. */ export const refusalSavedAnswer = (error: unknown): boolean => refusalBody(error)?.documentState === 'saved'; /** * A refusal the server gave before any file was sent for this request, so a send this tab made is known not to have * landed. A refusal that says a file is saved, or a lost or failed answer, proves nothing of the kind. */ export const refusedBeforeSending = (error: unknown): boolean => { const status = (error as { status?: unknown } | null | undefined)?.status; const body = refusalBody(error); if (body?.documentState === 'saved') return false; if (body?.code === 'FILE_NOT_READY') return true; return typeof status === 'number' && status >= 400 && status < 500 && status !== 408 && body?.code !== 'ANSWER_ALREADY_SAVED' && body?.code !== 'ANSWER_UNCLEAR'; }; /** The document the server filed for this request, readable to this viewer: it names this request and patient. */ export const isFiledAnswer = (body: unknown, task: Task): body is DocumentReference => { const document = body as DocumentReference | null | undefined; return document?.resourceType === 'DocumentReference' && typeof document.id === 'string' && Boolean(document.id) && Boolean(task.for?.reference) && document.subject?.reference === task.for?.reference && (document.context?.related ?? []).some((each) => each.reference === `Task/${task.id}`); }; /** The settled request, only when the answer is this request, for this patient and practice, stored completed. */ export const settledRequestOf = (body: unknown, task: Task): Task | undefined => { const answer = body as { state?: unknown; task?: Task } | null | undefined; const settled = answer?.task; if (answer?.state !== 'completed' || settled?.resourceType !== 'Task' || !task.id || settled.id !== task.id) return undefined; if (settled.status !== 'completed' || settled.for?.reference !== task.for?.reference) return undefined; if (settled.owner?.reference !== task.owner?.reference || requestingOrganizationId(settled) !== requestingOrganizationId(task)) return undefined; return settled; }; // A send whose answer the sender may not be able to find again, kept for this tab until its request is settled, so a // retry or a reload checks the request instead of sending another file. Keyed to who sends, as whom, at which practice, // for which patient and request; it holds no clinical content. export type PendingAnswer = 'saved' | 'unknown'; export interface AnswerScope { key: string; taskId: string } const PENDING_PREFIX = 'ahr-request-answer:'; const patientIdOf = (task: Task): string | undefined => { const reference = task.for?.reference; return reference?.startsWith('Patient/') && reference.length > 'Patient/'.length ? reference.slice('Patient/'.length) : undefined; }; /** Where a send for this request by this viewer is kept; undefined without a signed-in viewer, a patient or an id. */ export const answerScopeFor = (task: Task, viewer: string | null | undefined): AnswerScope | undefined => { const patientId = patientIdOf(task); if (!viewer || !patientId || !task.id) return undefined; return { key: `${PENDING_PREFIX}v2|${viewer}|${patientId}|${task.id}`, taskId: task.id }; }; /** The kept send as stored: its state and the send that kept it, so one send never clears another. */ export const pendingAnswerMark = (scope: AnswerScope | undefined): string | undefined => { if (!scope) return undefined; try { // The key the request alone once named is never adopted: whose send it was is unknown. sessionStorage.removeItem(PENDING_PREFIX + scope.taskId); return sessionStorage.getItem(scope.key) ?? undefined; } catch { return undefined; } }; export const pendingAnswerFor = (scope: AnswerScope | undefined): PendingAnswer | undefined => { const state = pendingAnswerMark(scope)?.split(':')[0]; return state === 'saved' || state === 'unknown' ? state : undefined; }; /** A new mark for one send. */ export const newSendMark = (): string => Date.now().toString(36) + Math.random().toString(36).slice(2, 10); export const keepPendingAnswer = (scope: AnswerScope | undefined, state: PendingAnswer, send: string): void => { if (!scope) return; try { sessionStorage.setItem(scope.key, state + ':' + send); } catch { /* the server still refuses a second answer */ } }; /** * Marks this send's kept hold saved, only while the hold is still this send's own unanswered one: a newer send's hold * is never replaced, and one already let go is never brought back. */ export const promotePendingAnswer = (scope: AnswerScope | undefined, send: string): void => { if (!scope) return; try { if (sessionStorage.getItem(scope.key) === 'unknown:' + send) sessionStorage.setItem(scope.key, 'saved:' + send); } catch { /* the server still refuses a second answer */ } }; /** Forgets the kept send only while it is still the one `mark` names; a newer send keeps its hold. */ export const forgetPendingAnswer = (scope: AnswerScope | undefined, mark: string | undefined): void => { if (!scope || mark === undefined) return; try { if (sessionStorage.getItem(scope.key) === mark) sessionStorage.removeItem(scope.key); } catch { /* nothing was kept */ } };