import { useCallback, useEffect, useRef, useState } from 'react'; import { VISIT_ACCEPT_CLOSED, VISIT_TIME_PASSED, acceptIsUnfinished, answeredVisitStatus, visitIsOver, visitTimePhase, visitTitle, visitWhen } from '../../utils/visitInvitationWords'; import { useVisitClock } from '../../hooks/useVisitClock'; import { CalendarClock, Loader2 } from 'lucide-react'; import { useAppointmentServiceContext } from '../../services/AppointmentService.tsx'; import { useAuthServiceContext } from '../../services/AuthService.tsx'; import { useCapabilityServiceContext } from '../../services/CapabilityService.tsx'; import { HttpError } from '../../services/HttpService.tsx'; import { DismissibleAlert } from '../ui/dismissible-alert.tsx'; import { VisitInvitationPanel } from './VisitInvitationPanel.tsx'; import type { MyVisitInvitation } from '../../types/visitInvitation.ts'; import { relayServerFailure } from '../../utils/fetchFailureMessage'; import { onViewerChange, viewerEpoch } from '../../utils/writeThroughHold'; // - "visits you've been asked to join". // // THIS IS THE ENTRY POINT THAT MAKES ACCEPTANCE REACHABLE AT ALL. A released invitee is written onto // the appointment as a bare `Practitioner/{id}` participant, and their `PractitionerRole` - the key the // ORG-scoped appointment query joins on - is only added when they ACCEPT. So a CROSS-ORG invitee appears // on no calendar of theirs and in no queue: without this list they could not see the invitation they were // expected to answer, and could not answer it to become visible. That is the population collaboration // teams exist for, so the list is the feature, not a convenience. // // It renders nothing at all when there is nothing to answer - a permanently-empty section on every // clinician's overview would be noise for the majority who are never invited to anything. /** * Who can hold a visit invitation at all - THE FALLBACK RULE, no longer the primary one. * * A PATIENT never can - `visitInvitationEligibility.ts` removes the patient ref from the proposable set * by construction, since they are the subject of the visit rather than a guest at it. So on the patient * portal this must not fire a request that is guaranteed to 403; the route's `allowedRoles` excludes * PATIENTS precisely because granting them would be a dead role. * * Practitioners and related persons are the two invitee classes (`RosterClass` minus `patient`). * * WHY THIS IS NOT ENOUGH, and why walk named this component as the clearest instance of * finding B30: `resourceType` is IDENTITY, not CAPABILITY. Every staff human is a `Practitioner` * resource - HR, Marketing, Billing, an Admin - so all of them pass this set and fire * `GET /R4/my-visit-invitations`, while the route grants only ADMINS, PRACTITIONERS, RECEPTIONISTS and * RELATED_PERSONS. The denials the walk counted are this gate being asked a question it cannot answer. */ const CAN_HOLD_INVITATIONS = new Set(['Practitioner', 'RelatedPerson']); export const MyVisitInvitations = ({ className }: { className?: string }) => { const { fetchMyVisitInvitations } = useAppointmentServiceContext(); const { currentUser } = useAuthServiceContext(); const { mayCall } = useCapabilityServiceContext(); const [invitations, setInvitations] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [openId, setOpenId] = useState(null); // The invitation just answered from this list: once it leaves the pending rows, the person is told where it went. const [justAnswered, setJustAnswered] = useState(null); const [answersOpen, setAnswersOpen] = useState(false); // Whose list this is (account, acted-as subject, practice): a change retires what was shown and every read in // flight, so one viewer's answers or open panel never appear under another's, and an older read never lands late. const [viewer, setViewer] = useState(viewerEpoch); useEffect(() => onViewerChange(() => setViewer(viewerEpoch())), []); const reads = useRef(0); // The viewer epoch the rows were read for. Rows, the open panel and the confirmation show only while it is current, // from the first render after a change; epochs only grow, so A-B-A is a new owner. const [ownedBy, setOwnedBy] = useState(null); const viewerType = currentUser?.fhir?.resourceType; // - ask the session what it may call, and fall back to the identity rule only when there is // no answer to be had. // // THE `??` IS LOAD-BEARING AND IT IS NOT THE `??`-FALLBACK DEFECT FAMILY. `mayCall` returns // `undefined` for NOT KNOWN - never for "no" - and the fallback is the rule this component used // before the signal existed, not an invented value. That is what makes the two halves of this fix // safe to deploy in either order: until the server route is live, `mayCall` is `undefined` // everywhere and this component behaves exactly as it did yesterday. A version that treated // "unknown" as "denied" would hide every genuine invitation for the whole window between the two // merges - and an unanswered invitation going unnoticed is the one failure this list exists to // prevent. const eligible = mayCall('GET', '/my-visit-invitations') ?? Boolean(viewerType && CAN_HOLD_INVITATIONS.has(viewerType)); const load = useCallback(async () => { // Don't ask a question whose answer is structurally "no". This is also what lets the component be // mounted on a SHARED surface (the patient portal serves both the patient and their related // person) without a guard at every call site that could be got wrong independently. if (!eligible) { setLoading(false); return; } const read = ++reads.current; const forViewer = viewerEpoch(); try { setError(null); const loaded = await fetchMyVisitInvitations(); if (read === reads.current) { setInvitations(loaded); setOwnedBy(forViewer); } } catch (loadError) { if (read !== reads.current) return; setOwnedBy(forViewer); // A 401/403 here means "this caller cannot hold invitations", which is not an error worth // showing a human - it is the same non-answer as an empty list. Anything else IS surfaced, // because a silent failure would look identical to having no invitations, and the whole // point of this list is that an unanswered invitation must not go unnoticed. // `HttpError` (HttpService.tsx) carries the status precisely so callers can tell a genuine // "cannot" from a transient failure. Read that one field - no speculative `response.status` // fallback, because an untested alternative path is not defence, it is a second thing to be // wrong. const status = loadError instanceof HttpError ? loadError.status : undefined; if (status === 401 || status === 403) { setLoading(false); return; } setError(relayServerFailure(loadError, 'Could not load your visit invitations.')); } finally { if (read === reads.current) setLoading(false); } }, [eligible, fetchMyVisitInvitations]); useEffect(() => { setInvitations([]); setOpenId(null); setJustAnswered(null); setAnswersOpen(false); setLoading(true); void load(); // A list closed or replaced answers nothing later. return () => { reads.current += 1; }; }, [load, viewer]); // The list moves on at each visit's start and end while the page stays open: no reload needed. const now = useVisitClock(invitations); if (!eligible) return null; // What still needs the person: unanswered, a time that moved since they answered, or an accept whose hold is not // confirmed (no reservation reported counts). A finished answer belongs on the calendar it created, not on a to-do list. // A visit that is over (cancelled, no-show, entered in error, or already held) has nothing left to answer. const owned = ownedBy !== null && ownedBy === viewer && viewer === viewerEpoch(); // A visit whose scheduled time has passed is history, whatever its hold or answer: nothing on it can be finished. const ended = (invitation: MyVisitInvitation) => visitTimePhase(invitation, now) === 'ended'; const pending = !owned ? [] : invitations.filter((invitation) => !visitIsOver(invitation.appointmentStatus) && !ended(invitation) && (!invitation.response || invitation.timeChangedSinceAnswer || acceptIsUnfinished(invitation))); // What the person has already answered, read-only: the answer and its saved reason, as the server returned them, // and any visit whose time passed. Nothing here can be answered again; a time that moved since is a pending row // above, asking afresh. const answered = !owned ? [] : invitations.filter((invitation) => !pending.includes(invitation) && (Boolean(invitation.response) || (ended(invitation) && !visitIsOver(invitation.appointmentStatus)))); const confirmed = justAnswered ? answered.find((invitation) => invitation.appointmentId === justAnswered) : undefined; if (loading || !owned) { return (
); } if (error) { return ( setError(null)} className={className}> {error} ); } if (pending.length === 0 && answered.length === 0) return null; return (
{confirmed && (
Your answer was saved. It is under Your answers.
)}
    {pending.map((invitation) => (
  • {visitTitle(invitation)} {visitWhen(invitation) ?? 'Time to be confirmed'} {/* From its start no accept is taken; the row stays only for the decline it can still take. */} {visitTimePhase(invitation, now) === 'begun' && ( {VISIT_ACCEPT_CLOSED} )} {visitTimePhase(invitation, now) !== 'begun' && invitation.timeChangedSinceAnswer && ( The time changed. Please answer again. )} {visitTimePhase(invitation, now) !== 'begun' && invitation.response === 'accepted' && !invitation.timeChangedSinceAnswer && ( {invitation.reservation ? 'Accepted, but your time is not held yet.' : 'Accepted, but your time is not confirmed as held.'} )}
    {/* Reuses the panel rather than restating accept/decline: one implementation of "answer an invitation" means the two can never drift apart. */} {openId === invitation.appointmentId && (
    {/* Card 165 — this list holds its own copy of the same facts (the header count above), and the panel is where they get mutated. Without this the count kept naming an invitation the user had just removed, until a real page reload. `load` re-reads the list's own source; the panel re-reads the appointment's. */} { setJustAnswered(invitation.appointmentId); void load(); }} />
    )}
  • ))}
{answered.length > 0 && (
setAnswersOpen(event.currentTarget.open)} > Your answers ({answered.length})
    {answered.map((invitation) => (
  • {visitTitle(invitation)} {visitWhen(invitation) ?? 'Time to be confirmed'} {ended(invitation) && !visitIsOver(invitation.appointmentStatus) && ( {VISIT_TIME_PASSED} )} {answeredVisitStatus(invitation.appointmentStatus) && ( {answeredVisitStatus(invitation.appointmentStatus)} )} {/* The saved answer as it was: an accept is never called held here. */} {invitation.response === 'declined' ? 'You declined.' : invitation.response === 'accepted' ? 'You accepted.' : 'You did not answer.'} {invitation.response === 'declined' && invitation.declineReason && ( Your reason: “{invitation.declineReason}” )}
  • ))}
)}
); }; export default MyVisitInvitations;