mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
Fixes the long-standing bug where the Appointment and Enquiry forms
silently overwrote existing patients' names with whatever happened to
be in the form's patient-name input. Before this change, an agent who
accidentally typed over the pre-filled name (or deliberately typed a
different name while booking on behalf of a relative) would rename
the patient across the entire workspace on save. The corruption
cascaded into past appointments, lead history, the AI summary, and
the Redis caller-resolution cache. This was the root cause of the
"Priya Sharma shows as Satya Sharma" incident on staging.
Root cause: appointment-form.tsx:249-278 and enquiry-form.tsx:107-117
fired updatePatient + updateLead.contactName unconditionally on every
save. Nothing distinguished "stub patient with no name yet" from
"existing patient whose name just needs this appointment booked".
Fix — lock-by-default with explicit unlock:
- src/components/modals/edit-patient-confirm-modal.tsx (new):
generic reusable confirmation modal for any destructive edit to a
patient's record. Accepts title/description/confirmLabel with
sensible defaults so the call-desk forms can pass a name-specific
description, and any future page that needs a "are you sure you
want to change this patient field?" confirm can reuse it without
building its own modal. Styled to match the sign-out confirmation
in sidebar.tsx — warning circle, primary-destructive confirm button.
- src/components/call-desk/appointment-form.tsx:
- New state: isNameEditable (default false when leadName is
non-empty; true for first-time callers with no prior name to
protect) + editConfirmOpen.
- Name input renders disabled + shows an Edit button next to it
when locked.
- Edit button opens EditPatientConfirmModal. Confirm unlocks the
field for the rest of the form session.
- Save logic gates updatePatient / updateLead.contactName behind
`isNameEditable && trimmedName.length > 0 && trimmedName !==
initialLeadName`. Empty / same-as-initial values never trigger
the rename chain, even if the field was unlocked.
- On a real rename, fires POST /api/lead/:id/enrich to regenerate
the AI summary against the corrected identity (phone passed in
the body so the sidecar also invalidates the caller-resolution
cache). Non-rename saves just invalidate the cache via the
existing /api/caller/invalidate endpoint so status +
lastContacted updates propagate.
- Bundled fix: renamed `leadStatus: 'APPOINTMENT_SET'` →
`status: 'APPOINTMENT_SET'` and `lastContactedAt` →
`lastContacted` in the updateLead payload. The old field names
are rejected by the staging platform schema and were causing the
"Query failed: Field leadStatus is not defined by type
LeadUpdateInput" toast on every appointment save.
- src/components/call-desk/enquiry-form.tsx:
- Same lock + Edit + modal pattern as the appointment form.
- Added leadName prop (the form previously didn't receive one).
- Gated updatePatient behind the nameChanged check.
- Gated lead.contactName in updateLead behind the same check.
- Hooks the enrich endpoint on rename; cache invalidate otherwise.
- Status + interestedService + source still update on every save
(those are genuinely about this enquiry, not identity).
- src/components/call-desk/active-call-card.tsx: passes
leadName={fullName || null} to EnquiryForm so the form can
pre-populate + lock by default.
Behavior summary:
- New caller, no prior name: field unlocked, agent types, save runs
the full chain (correct — this IS the name).
- Existing caller, agent leaves name alone: field locked, Save
creates appointment/enquiry + updates lead status/lastContacted +
invalidates cache. Zero risk of patient/lead rename.
- Existing caller, agent clicks Edit, confirms modal, changes name,
Save: full rename chain runs — updatePatient + updateLead +
/api/lead/:id/enrich + cache invalidate. The only code path that
can mutate a linked patient's name, and it requires two explicit
clicks.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
85 lines
3.8 KiB
TypeScript
85 lines
3.8 KiB
TypeScript
import type { ReactNode } from 'react';
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
import { faUserPen } from '@fortawesome/pro-duotone-svg-icons';
|
|
import { ModalOverlay, Modal, Dialog } from '@/components/application/modals/modal';
|
|
import { Button } from '@/components/base/buttons/button';
|
|
|
|
// Generic confirmation modal shown before any destructive edit to a
|
|
// patient's record. Used by the call-desk forms (appointment, enquiry)
|
|
// to gate the patient-name rename flow, but intentionally non-specific:
|
|
// any page that needs a "are you sure you want to change this patient
|
|
// field?" confirm should reuse this modal instead of building its own.
|
|
//
|
|
// The lock-by-default + explicit-confirm gate is deliberately heavy
|
|
// because patient edits cascade workspace-wide — they hit past
|
|
// appointments, lead history, AI summaries, and the Redis
|
|
// caller-resolution cache. The default path should always be "don't
|
|
// touch the record"; the only way to actually commit a change is
|
|
// clicking an Edit button, reading this prompt, and confirming.
|
|
//
|
|
// Styling matches the sign-out confirmation in sidebar.tsx — same
|
|
// warning circle, same button layout — so the weight of the action
|
|
// reads immediately.
|
|
|
|
type EditPatientConfirmModalProps = {
|
|
isOpen: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onConfirm: () => void;
|
|
/** Modal heading. Defaults to "Edit patient details?". */
|
|
title?: string;
|
|
/** Body copy explaining the consequences of the edit. Accepts any
|
|
* ReactNode so callers can inline markup / inline the specific
|
|
* field being edited. A sensible generic default is provided. */
|
|
description?: ReactNode;
|
|
/** Confirm-button label. Defaults to "Yes, edit details". */
|
|
confirmLabel?: string;
|
|
};
|
|
|
|
const DEFAULT_TITLE = 'Edit patient details?';
|
|
|
|
const DEFAULT_DESCRIPTION = (
|
|
<>
|
|
You're about to change a detail on this patient's record. The update will cascade
|
|
across Helix Engage — past appointments, lead history, and the AI summary all reflect
|
|
the new value. Only proceed if the current data is actually wrong; for all other
|
|
cases, cancel and continue with the current record.
|
|
</>
|
|
);
|
|
|
|
const DEFAULT_CONFIRM_LABEL = 'Yes, edit details';
|
|
|
|
export const EditPatientConfirmModal = ({
|
|
isOpen,
|
|
onOpenChange,
|
|
onConfirm,
|
|
title = DEFAULT_TITLE,
|
|
description = DEFAULT_DESCRIPTION,
|
|
confirmLabel = DEFAULT_CONFIRM_LABEL,
|
|
}: EditPatientConfirmModalProps) => (
|
|
<ModalOverlay isOpen={isOpen} onOpenChange={onOpenChange} isDismissable>
|
|
<Modal className="max-w-md">
|
|
<Dialog>
|
|
<div className="rounded-xl bg-primary p-6 shadow-xl">
|
|
<div className="flex flex-col items-center text-center gap-4">
|
|
<div className="flex size-12 items-center justify-center rounded-full bg-warning-secondary">
|
|
<FontAwesomeIcon icon={faUserPen} className="size-5 text-fg-warning-primary" />
|
|
</div>
|
|
<div>
|
|
<h3 className="text-lg font-semibold text-primary">{title}</h3>
|
|
<p className="mt-1 text-sm text-tertiary">{description}</p>
|
|
</div>
|
|
<div className="flex w-full gap-3">
|
|
<Button size="md" color="secondary" className="flex-1" onClick={() => onOpenChange(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button size="md" color="primary-destructive" className="flex-1" onClick={onConfirm}>
|
|
{confirmLabel}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
</Modal>
|
|
</ModalOverlay>
|
|
);
|