Files
helix-engage/src/components/call-desk/enquiry-form.tsx
saridsa2 efe67dc28b feat(call-desk): lock patient name field behind explicit edit + confirm
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>
2026-04-07 13:54:22 +05:30

294 lines
14 KiB
TypeScript

import { useState, useEffect } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faUserPen } from '@fortawesome/pro-duotone-svg-icons';
import { Input } from '@/components/base/input/input';
import { Select } from '@/components/base/select/select';
import { TextArea } from '@/components/base/textarea/textarea';
import { Checkbox } from '@/components/base/checkbox/checkbox';
import { Button } from '@/components/base/buttons/button';
import { EditPatientConfirmModal } from '@/components/modals/edit-patient-confirm-modal';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
type EnquiryFormProps = {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
callerPhone?: string | null;
// Pre-populated caller name (from caller-resolution). When set, the
// patient-name field is locked behind the Edit-confirm modal to
// prevent accidental rename-on-save. When empty or null, the field
// starts unlocked because there's no existing name to protect.
leadName?: string | null;
leadId?: string | null;
patientId?: string | null;
agentName?: string | null;
onSaved?: () => void;
};
export const EnquiryForm = ({ isOpen, onOpenChange, callerPhone, leadName, leadId: propLeadId, patientId, agentName, onSaved }: EnquiryFormProps) => {
// Initial name captured at form open — used to detect whether the
// agent actually changed the name before committing any updatePatient /
// updateLead.contactName mutations. See also appointment-form.tsx.
const initialLeadName = (leadName ?? '').trim();
const [patientName, setPatientName] = useState(leadName ?? '');
const [isNameEditable, setIsNameEditable] = useState(initialLeadName.length === 0);
const [editConfirmOpen, setEditConfirmOpen] = useState(false);
const [source, setSource] = useState('Phone Inquiry');
const [queryAsked, setQueryAsked] = useState('');
const [isExisting, setIsExisting] = useState(false);
const [registeredPhone, setRegisteredPhone] = useState(callerPhone ?? '');
const [department, setDepartment] = useState<string | null>(null);
const [doctor, setDoctor] = useState<string | null>(null);
const [followUpNeeded, setFollowUpNeeded] = useState(false);
const [followUpDate, setFollowUpDate] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
// Fetch doctors for department/doctor dropdowns
const [doctors, setDoctors] = useState<Array<{ id: string; name: string; department: string }>>([]);
useEffect(() => {
if (!isOpen) return;
apiClient.graphql<{ doctors: { edges: Array<{ node: any }> } }>(
`{ doctors(first: 50) { edges { node {
id name fullName { firstName lastName } department
} } } }`,
).then(data => {
setDoctors(data.doctors.edges.map(e => ({
id: e.node.id,
name: e.node.fullName ? `Dr. ${e.node.fullName.firstName} ${e.node.fullName.lastName}`.trim() : e.node.name,
department: e.node.department ?? '',
})));
}).catch(() => {});
}, [isOpen]);
const departmentItems = [...new Set(doctors.map(d => d.department).filter(Boolean))]
.map(dept => ({ id: dept, label: dept.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) }));
const filteredDoctors = department ? doctors.filter(d => d.department === department) : doctors;
const doctorItems = filteredDoctors.map(d => ({ id: d.id, label: d.name }));
const handleSave = async () => {
if (!patientName.trim() || !queryAsked.trim()) {
setError('Please fill in required fields: patient name and query.');
return;
}
setIsSaving(true);
setError(null);
try {
// Use passed leadId or resolve from phone
let leadId: string | null = propLeadId ?? null;
if (!leadId && registeredPhone) {
const resolved = await apiClient.post<{ leadId: string; patientId: string }>('/api/caller/resolve', { phone: registeredPhone }, { silent: true });
leadId = resolved.leadId;
}
// Determine whether the agent actually renamed the patient.
// Only a non-empty, changed-from-initial name counts — empty
// strings or an unchanged name never trigger the rename
// chain, even if the field was unlocked.
const trimmedName = patientName.trim();
const nameChanged = isNameEditable && trimmedName.length > 0 && trimmedName !== initialLeadName;
const nameParts = {
firstName: trimmedName.split(' ')[0],
lastName: trimmedName.split(' ').slice(1).join(' ') || '',
};
if (leadId) {
// Update existing lead with enquiry details. Only touches
// contactName if the agent explicitly renamed — otherwise
// we leave the existing caller identity alone.
await apiClient.graphql(
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
{
id: leadId,
data: {
name: `Enquiry — ${trimmedName || 'Unknown caller'}`,
source: 'PHONE',
status: 'CONTACTED',
interestedService: queryAsked.substring(0, 100),
...(nameChanged ? { contactName: nameParts } : {}),
},
},
);
} else {
// No matched lead — create a fresh one. For net-new leads
// we always populate contactName from the typed value
// (there's no existing record to protect).
await apiClient.graphql(
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
{
data: {
name: `Enquiry — ${trimmedName || 'Unknown caller'}`,
contactName: nameParts,
contactPhone: registeredPhone ? { primaryPhoneNumber: registeredPhone } : undefined,
source: 'PHONE',
status: 'CONTACTED',
interestedService: queryAsked.substring(0, 100),
},
},
);
}
// Update linked patient's name ONLY if the agent explicitly
// renamed. Fixes the long-standing bug where typing a name
// into this form silently overwrote the existing patient
// record.
if (nameChanged && patientId) {
await apiClient.graphql(
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
{
id: patientId,
data: {
fullName: nameParts,
},
},
).catch((err: unknown) => console.warn('Failed to update patient name:', err));
}
// Post-save side-effects. If the agent actually renamed the
// patient, kick off AI summary regen + cache invalidation.
// Otherwise just invalidate the cache so the status update
// propagates.
if (nameChanged && leadId) {
apiClient.post(`/api/lead/${leadId}/enrich`, { phone: callerPhone ?? undefined }, { silent: true }).catch(() => {});
} else if (callerPhone) {
apiClient.post('/api/caller/invalidate', { phone: callerPhone }, { silent: true }).catch(() => {});
}
// Create follow-up if needed
if (followUpNeeded) {
if (!followUpDate) {
setError('Please select a follow-up date.');
setIsSaving(false);
return;
}
await apiClient.graphql(
`mutation($data: FollowUpCreateInput!) { createFollowUp(data: $data) { id } }`,
{
data: {
name: `Follow-up — ${patientName}`,
typeCustom: 'CALLBACK',
status: 'PENDING',
priority: 'NORMAL',
assignedAgent: agentName ?? undefined,
scheduledAt: new Date(`${followUpDate}T09:00:00`).toISOString(),
patientId: patientId ?? undefined,
},
},
{ silent: true },
);
}
notify.success('Enquiry Logged', 'Contact details and query captured');
onSaved?.();
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to save enquiry');
} finally {
setIsSaving(false);
}
};
if (!isOpen) return null;
return (
<div className="flex flex-col flex-1 min-h-0">
{/* Form fields — scrollable */}
<div className="flex-1 overflow-y-auto">
<div className="flex flex-col gap-3">
{/* Patient name — locked by default for existing callers,
unlocked for new callers with no prior name on record.
The Edit button opens a confirm modal before unlocking;
see EditPatientConfirmModal for the rationale. */}
<div className="flex items-end gap-2">
<div className="flex-1">
<Input
label="Patient Name"
placeholder="Full name"
value={patientName}
onChange={setPatientName}
isRequired
isDisabled={!isNameEditable}
/>
</div>
{!isNameEditable && initialLeadName.length > 0 && (
<Button
size="sm"
color="secondary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faUserPen} className={className} />
)}
onClick={() => setEditConfirmOpen(true)}
>
Edit
</Button>
)}
</div>
<Input label="Source / Referral" placeholder="How did they reach us?" value={source} onChange={setSource} isRequired />
<TextArea label="Query Asked" placeholder="What did the caller ask about?" value={queryAsked} onChange={setQueryAsked} rows={3} isRequired />
<Checkbox isSelected={isExisting} onChange={setIsExisting} label="Existing Patient" hint="Has visited the hospital before" />
{isExisting && (
<Input label="Registered Phone" placeholder="Phone number on file" value={registeredPhone} onChange={setRegisteredPhone} />
)}
<div className="border-t border-secondary" />
<div className="grid grid-cols-2 gap-3">
<Select label="Department" placeholder="Optional" items={departmentItems} selectedKey={department}
onSelectionChange={(key) => { setDepartment(key as string); setDoctor(null); }}>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
<Select label="Doctor" placeholder="Optional" items={doctorItems} selectedKey={doctor}
onSelectionChange={(key) => setDoctor(key as string)} isDisabled={!department}>
{(item) => <Select.Item id={item.id} label={item.label} />}
</Select>
</div>
<Checkbox isSelected={followUpNeeded} onChange={setFollowUpNeeded} label="Follow-up Needed" />
{followUpNeeded && (
<Input label="Follow-up Date" type="date" value={followUpDate} onChange={setFollowUpDate} isRequired />
)}
{error && (
<div className="rounded-lg bg-error-primary p-3 text-sm text-error-primary">{error}</div>
)}
</div>
</div>
{/* Footer — pinned */}
<div className="shrink-0 flex items-center justify-end gap-3 pt-4 border-t border-secondary">
<Button size="sm" color="secondary" onClick={() => onOpenChange(false)}>Cancel</Button>
<Button size="sm" color="primary" isLoading={isSaving} showTextWhileLoading onClick={handleSave}>
{isSaving ? 'Saving...' : 'Log Enquiry'}
</Button>
</div>
<EditPatientConfirmModal
isOpen={editConfirmOpen}
onOpenChange={setEditConfirmOpen}
onConfirm={() => {
setIsNameEditable(true);
setEditConfirmOpen(false);
}}
description={
<>
You&apos;re about to change the name on this patient&apos;s record. This will
update their profile across Helix Engage, including past appointments,
lead history, and AI summary. Only proceed if the current name is
actually wrong for all other cases, cancel and continue logging the
enquiry as-is.
</>
}
/>
</div>
);
};