import { useCallback } from 'react'; import { environment } from '../environments/environment.ts'; import { useHttpService } from './HttpService.tsx'; import { awaitWritten, holdWrite, noteWrite, startWrite } from '../utils/writeThroughHold'; import type { QuestionnaireResponse } from 'fhir/r4'; const API_URL = environment.fhirBaseApi; // --------------------------------------------------------------------------- // Wire-format types - mirror the `form-submissions` server contract exactly // (aws/sam/services/api/src/form-submissions/*.ts is authoritative). // --------------------------------------------------------------------------- export type FormSubmissionStatus = 'new' | 'in_progress' | 'resolved' | 'converted'; export const FORM_SUBMISSION_STATUSES: FormSubmissionStatus[] = [ 'new', 'in_progress', 'resolved', 'converted', ]; export const FORM_SUBMISSION_STATUS_LABELS: Record = { new: 'New', in_progress: 'In progress', resolved: 'Resolved', converted: 'Converted', }; export interface FormSubmissionAnswer { linkId: string; value: any; } export interface SubmitterContact { name?: string; email?: string; phone?: string; } export interface FormSubmission { submissionId: string; orgId: string; formHandle: string; /** The form's name as the server resolved it; absent when it has none. */ formTitle?: string; questionnaireId: string; answers: FormSubmissionAnswer[]; submitterContact: SubmitterContact | null; message: string | null; sourceDomain: string; status: FormSubmissionStatus; resolutionNote?: string | null; assignedTo?: string | null; // - every reply already sent, so the next person can see what was said. An outbound message // with no trace is how two people answer the same person differently. replies?: FormSubmissionReply[]; /** * - the QUARANTINED objects a stranger attached, by server-chosen key. * * Keys, never URLs, and that is the containment rather than an oversight. The file came from an * anonymous, unscanned source, so there is no link anywhere until somebody authenticated asks for one * - which is what `openSubmissionAttachment` below is. * * Absent rather than empty when nothing was attached; the server omits the field. */ attachmentKeys?: string[]; /** The name each file was sent with, by key: a stranger's display text, cleaned by the server. */ attachmentNames?: Record; /** Set once the submission is attached: whose record its answers went to. */ promotedPatientId?: string; createdAt: string; updatedAt: string; } interface FetchSubmissionsOptions { status?: FormSubmissionStatus; formHandle?: string; limit?: number; nextToken?: string; } interface FetchSubmissionsResult { items: FormSubmission[]; nextToken?: string; } /** One reply already sent on a submission, newest last. Written by the server, never by the client. */ export interface FormSubmissionReply { body: string; sentAt: string; sentBy: string; to: string; } export interface SendReplyResult { sentAt: string; sentBy: string; to: string; } export interface AttachSubmissionResult { questionnaireResponseId: string; promotedPatientId: string; status: FormSubmissionStatus; } interface UpdateSubmissionInput { status?: FormSubmissionStatus; resolutionNote?: string; assignedTo?: string; } // --------------------------------------------------------------------------- // Service // --------------------------------------------------------------------------- export const useFormSubmissionsService = () => { const { get, patch, post } = useHttpService(); // GET /form-submissions?status=&formHandle=&limit=&nextToken= - org-scoped server-side via // the auth token; newest-first. All query params are optional. const fetchSubmissions = useCallback( async (opts?: FetchSubmissionsOptions): Promise => { const params = new URLSearchParams(); if (opts?.status) params.append('status', opts.status); if (opts?.formHandle) params.append('formHandle', opts.formHandle); if (opts?.limit !== undefined) params.append('limit', String(opts.limit)); if (opts?.nextToken) params.append('nextToken', opts.nextToken); const qs = params.toString(); return get(`${API_URL}/form-submissions${qs ? `?${qs}` : ''}`); }, [get], ); // GET /form-submissions/{id} - 404 if the submission isn't in the caller's org. const getSubmission = useCallback( async (id: string): Promise => { return get(`${API_URL}/form-submissions/${id}`); }, [get], ); // PATCH /form-submissions/{id} - { status?, resolutionNote?, assignedTo? } -> updated record. const updateSubmission = useCallback( async (id: string, update: UpdateSubmissionInput): Promise => { return patch(`${API_URL}/form-submissions/${id}`, update); }, [patch], ); /** * ATTACH A SUBMISSION TO A PATIENT'S RECORD -, and the step the form is collected for. * * IT SENDS ONE THING: WHICH PATIENT. The answers, the questionnaire and the date all come from the * stored submission, server-side. Do not "helpfully" post the answers along with it - the server * ignores them by design, and a client that sends them teaches the next reader that they matter *. * A 409 means it was already attached, and the body names the patient it went to. That is a real * answer, not an error to swallow: two receptionists can open the same submission. */ const attachSubmissionToPatient = useCallback( async (id: string, patientId: string): Promise => { const startedIn = startWrite(); const attached = await post(`${API_URL}/form-submissions/${id}/convert`, { patientId }); noteWrite('QuestionnaireResponse'); // The attach answers with ids only. A read by id sees the new answer at once, where the search lags; // holding it lists it until the search catches up. The attach stands even when that read fails; the // answer's id is then kept, and the next list read asks for it again rather than serving a lagging search. try { const answer = await get(`${environment.fhirClinicalApi}/QuestionnaireResponse/${attached.questionnaireResponseId}`); holdWrite(answer, Date.now(), startedIn); } catch (error) { awaitWritten('QuestionnaireResponse', attached.questionnaireResponseId, Date.now(), startedIn, patientId); console.error('The attached answer could not be read back yet; the next list read asks for it again.', error); } return attached; }, [get, post], ); /** * REPLY TO WHOEVER WROTE IN -. * * Spencer, 2026-08-18: *"remember this is public facing form submissions for things like 'contact * us' forms and stuff. not actual clinical care stuff."* The reply goes to the address they typed, * about what they wrote in about. * **The exposure is not the channel - it is a staff member typing something clinical into it**, so * the control is the disclaimer beside the box in `FormSubmissionReply`. There is no address * verification and no consent gate here, and adding one would answer a question that was ruled out. * * A **409** means the submission carried no email address - a real answer, not a failure. The * receptionist's next move is a phone call. */ const replyToSubmission = useCallback( async (id: string, replyBody: string): Promise => { return post(`${API_URL}/form-submissions/${id}/reply`, { body: replyBody }); }, [post], ); /** * - open a QUARANTINED file a stranger attached to a public form. * * The prefix it lives under is a quarantine: the file arrived from an anonymous, unauthenticated * visitor and has not been scanned, and nothing in the clinical record references it until somebody at * the practice triages the submission. This call IS that decision - an authenticated person choosing * to look - which is why there is no URL on the record and one has to be asked for. * * THE SERVER CHECKS THE KEY AGAINST THE SUBMISSION, so naming a file that is not on this one answers * exactly like a submission that does not exist. Do not treat a 404 here as "the file is missing" * in the copy: it is equally "that is not your file", and the two are indistinguishable on purpose. * * The URL it returns is short-lived (five minutes) and needs no credentials - so it is opened, not * stored, and never put anywhere it would outlive the person who asked for it. */ const openSubmissionAttachment = useCallback( async (id: string, key: string): Promise<{ url: string; expiresIn: number }> => { return post<{ url: string; expiresIn: number }>( `${API_URL}/form-submissions/${id}/attachment`, { key }, ); }, [post], ); return { fetchSubmissions, getSubmission, updateSubmission, attachSubmissionToPatient, replyToSubmission, openSubmissionAttachment, }; };