Worklist:
- SSE stream replaces 30s poll — EventSource on /api/supervisor/worklist/stream
triggers immediate fetchWorklist() on missed-call events
- Toast notification: 'Missed Call — {name} — needs callback'
- No polling fallback — SSE is the source of truth
Call History split by role:
- Agent: 'My Call History' — own calls only (matched by agent relation
or chain-parsed agentName), missed calls excluded (they belong on
the Call Desk queue), no Agent/Recording/SLA columns, phone clickable
via PhoneActionCell instead of separate Call button
- Supervisor: 'Call History' — all calls, Agent + Recording columns visible
Worklist panel:
- SOURCE/BRANCH column removed from display (data stays on row)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove New/My Leads/All Leads tabs — redundant now that contacts
are on a separate page; all leads shown as a flat list
- Remove row checkboxes (selectionMode="none") — bulk actions weren't
wired to any backend and confused QA
- Move Search + Columns + Export into the header row alongside the
title — cleaner single-row layout
- Remove BulkActionBar + AssignModal + WhatsAppSendModal + MarkSpamModal
imports and JSX — dead code without checkboxes
- LeadTable: new selectionMode prop (default "multiple" for back-compat)
- Same cleanup on Contacts page (no checkboxes)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Bug 556 triggered a broader audit of every date input in the app:
- appointment-form DatePicker now has minValue=today(getLocalTimeZone()) —
can't book or reschedule into the past (tightens bug 555 at the
input layer too; the past-slot filter in masterdata service still
handles the hour-granularity)
- clinic-form holiday date picker gets the same — can't observe a
holiday that already passed
Audit complete:
- enquiry-form follow-up date: already had min=today (bug 556 fix)
- appointment-form: fixed here
- clinic-form holidays: fixed here
- my-performance date filter: past valid (reports over history)
- campaign-edit start/end: past valid (historical campaigns)
Bug 558: Appointment edit view persisted in Patient 360 after Back to
Worklist. Closed as not-a-bug — the edit flow now lives inside the
unified Book Appt drawer, so the same button opens either path. Rename
makes the intent explicit:
- 'New Appt' when the caller has no upcoming appointments
- 'New / Reschedule Appt' when upcoming appointments exist (pills
inside the drawer let the agent pick which one to reschedule)
Pair to worklist.service.ts change. Unknown caller → appointment booked (creates Patient) → caller rings back → resolver links Lead↔Patient. But the frontend sometimes found the lead in the worklist cache mid-30s-poll and that row's patientId hadn't refreshed yet — so leadAppointments filter (keyed on lead.patientId) came up empty and the Book Appt pill for the prior appointment didn't render.
Now: when the worklist row is used, overlay the resolver's patientId if the cached row's is missing. Belt-and-braces with the sidecar fix.
- drop the header Back button (cosmetic; useNavigate + ArrowLeft icon
removed with it)
- Export CSV now downloads the currently-filtered list — respects tab,
search, campaign filter and active sort order. Headers: Phone /
First/Last Name / Email / Source / Status / Campaign / Assigned
Agent / First Contact / Last Contact / Created / Age (days).
- csv-utils: rowsToCsv + downloadCsv helpers. Values quoted, embedded
quotes escaped, leading =/+/-/@ prefixed with a single quote to
defeat CSV injection when opened in Excel. UTF-8 BOM on the blob
so Excel recognises non-ASCII names/addresses.
Maint shortcuts (Unlock Agent / Force Ready) used to read agentId from the CC-agent's localStorage config — supervisors had no such config and the endpoint 400'd. New flow: after OTP passes, modal calls /api/maint/session-status and renders a two-bucket picker (Locked selectable / Free informational 'Already free'). Orphan locks surface with an explicit label.
- use-maint-shortcuts: agentPickerEndpoint flag on forceReady + unlockAgent
- maint-otp-modal: two-phase — OTP gate, then picker, then submit; OTP
held in state across phases so the operator doesn't re-enter it
AI chat panel: supervisor context now shows supervisor-appropriate quick actions (Agent performance / Call summary / Campaign stats / Who needs attention?) that map 1:1 to the supervisor tool set on the sidecar. Agent flow keeps the theme-token quick actions (doctors/clinics/packages).
Ramaiah's product team owns their setup; end-user admins shouldn't see a dead-end Settings nav + Resume Setup banner. Flag is read from /api/config/ui-flags at app boot.
- use-ui-flags: module-scoped cache + useUiFlags hook + getUiFlags
helper for non-component callers
- main.tsx: /setup redirects when managed; RequireSelfServeSetup
guard blocks /settings/*
- resume-setup-banner: suppressed when managed
- login.tsx: skip first-run /setup redirect when managed
- settings.tsx: remove orphan popup-modal scaffolding left over
from an earlier 'contact product team' approach
- section-card: support onClick-or-href (kept for future use)
Two related fixes:
1. KPIs were capped at 100. The data-provider's entity queries were hardcoded to first: 100; on Global the supervisor dashboard showed 'Total Calls: 100' this week while the AI assistant (which paginates) reported 182. Converted each query to a cursor-aware builder, added a generic fetchAll(rootField, builder) that loops until hasNextPage=false (capped at 25 pages × 200 as a runaway guard). Page size bumped 100→200 to cut round-trips on active tenants.
2. Every 30s background poll flipped loading=true, flashing a 'Loading...' overlay across supervisor surfaces. hasLoadedRef guards the flag so only the initial fetch triggers the loading state.
QA flagged Team Dashboard vs Team Performance as repetitive. Retire Team Performance from the sidebar; move its unique surfaces (rich agent table, time breakdown, NPS/Conversion, Performance Alerts) into Team Dashboard below the existing KPI row.
- supervisor-rollup: new shared module — useSupervisorRollup hook +
RichAgentTable / TimeBreakdown / NpsConversion / PerformanceAlerts
- Time Breakdown rendered as a table (Agent / Active / Wrap / Idle
/ Break / Total + Team-average header row) — QA flagged the old
stacked-bar tiles as misleading because per-agent totals varied
wildly and width comparison was meaningless
- team-dashboard: tabs replaced with stacked sections; everything
scroll-visible so supervisors don't hunt across surfaces
- sidebar: remove 'Team Performance' entry (route kept for backup)
and drop the now-unused IconChartLine wiring
QA ask: leads should be the default view on campaign detail, not behind tab navigation, with campaign metrics (budget / funnel / source) kept visible alongside.
- drop the Overview/Leads tabs
- render LeadTable filtered to campaignLeads on the left
- Campaign Details card + Conversion Funnel + Source Breakdown
pinned on the right as a sticky sidebar
- hero: remove 'View Leads' button (was duplicate nav now)
- LeadActivitySlideout wired for row click-through
Edit mode prefilled clinic + department + doctorId, but the doctor
Select rendered blank because the doctor-list filter (doctors where
department === selectedDept) excluded the saved doctor. Root cause:
the Appointment.department string doesn't always match the doctor's
current department enum value.
Fix: doctorSelectItems now always includes the currently-selected
doctor as the first item, even when the department filter would
exclude them. Once the user changes department or doctor, the filter
behaves normally.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- leadAppointments now filters out past appointments — past dates
can't be rescheduled, so "14 Apr · Meena Patel" shouldn't appear in
the pill row today. Uses scheduledAt >= now.
- Click Edit pill → reschedule-confirm modal:
"Yes, reschedule" → form opens in edit mode (prefilled + editable)
"No, just view" → form opens read-only (prefilled + disabled)
- Prefill was broken — AppointmentForm's useState initializers only
run at mount, so switching pills didn't re-seed state. Added
key={editingApptId}-{apptMode} so the form fully remounts whenever
the selection or mode changes.
- Thread readOnly prop through every form control (patient name,
phone, age, gender, clinic, department, doctor, date, time slots,
chief complaint). In view mode all inputs are disabled and the
Update Appointment + Cancel Appointment buttons hide — only Close
remains.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Book Appt defect (QA-559): no visible path to edit an existing
appointment — the Upcoming section in the context panel collapses
automatically when the AI auto-summary fires, hiding the Edit action.
Fix: render appointment pills above the AppointmentForm drawer when
the returning patient has upcoming appointments:
[+ New] [Apr 24 · Dr. Harpreet Edit] [May 02 · Dr. Meena Edit]
- Click [+ New] (default): empty form, create mode
- Click Edit on a pill: form prefills with that appointment, edit mode
- Closing the drawer resets the selected pill
Separate defect: AI chat persisted after call ended — stale summary
from the previous call stayed visible on the worklist. ai-chat-panel
now wipes messages + resets the auto-fire guard when
callerContext.leadId transitions to null (call dropped/released, no
selected lead).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The appointment-edit form opened with clinic/time blank even for
well-formed appointments because the pipeline never carried clinicId
end-to-end. Four-layer audit + fix:
1. APPOINTMENTS_QUERY now fetches clinicId + clinic { id clinicName }
+ doctor.fullName (was only doctor.id).
2. transformAppointments populates real clinicId + clinicName from the
relation instead of faking clinicName=department.
3. Appointment type gets clinicId: string | null.
4. context-panel passes clinicId through to AppointmentForm's
existingAppointment prop; form initial-states clinic from it.
Also on edit: if the saved timeSlot isn't in the fresh slot list
(past-slot filter, schedule change, clinic mismatch) we inject it as
"HH:MM (current)" so the dropdown displays the existing value instead
of looking cleared.
Historical appointments with clinicId=null on the platform still fall
through to the auto-select-from-slot logic; a maint backfill for those
is a separate task.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Layout (P1-adjacent):
- context-panel switches to an edit-only layout when editingAppointment
is set. Previously AppointmentForm rendered inline BELOW the AI panel,
crushing the AI area into a ~2-line strip that made the returning-
patient summary + quick actions unusable. Edit view gets full height
with a "Back to context" button.
P2s:
- Remove Attempted sub-tab from Missed Calls worklist (Pending only).
- Add CALL_DROPPED disposition option + propagate through every
per-disposition Record<CallDisposition,...> map (incoming-call-card,
call-log, call-history, agent-detail, patient-360).
- Block SLA-gaming on unanswered calls: Book Appt / Enquiry / Transfer
buttons on active-call-card are disabled until the call reaches the
answered state (wasAnsweredRef). The disposition filter was already
in place; this closes the upstream entry.
- Data labels on performance charts: my-performance bar chart shows
value on top of each bar; donut shows {d}% slice labels; team-
performance day trend line shows per-point values.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Mute persists across calls: sip-manager's "ended/failed" branch now
resets the Recoil sipIsMutedAtom + sipIsOnHoldAtom (previously only
the SIP track was unmuted, leaving the UI icon + toggle logic in a
muted state that the next call inherited).
- Telephony-unavailable dial pad: call-desk.tsx dial-pad "Call" button
was missing an isRegistered check in its disabled prop, so it stayed
clickable when SIP was down. Button now shows "Telephony unavailable"
and is disabled.
- Past dates in Follow-up: enquiry-form's follow-up date input had no
min constraint. Switched to a raw <input type="date"> with min set
to today's ISO date.
- Returning-patient AI summary during call: ai-chat-panel now auto-fires
a "give me a quick summary of <caller>" request whenever the caller's
leadId changes (new incoming call). Clears prior chat state so each
caller starts fresh.
- Remove Type column in Patients page (Badge import also pruned).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Patient records created from the enquiry form now get a platform title
from the typed name. Cosmetic fix — frontend was already showing the
fullName, only platform admin browsing showed "Untitled".
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
usePerformanceAlerts now fetches /api/supervisor/performance-alerts
every 60s instead of computing client-side. Dismiss + dismiss-all hit
the sidecar so state survives reload. Toast fires when new alerts arrive.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1. notification-bell: drop the DEMO_ALERTS fallback (Riya Mehta etc.).
Empty state ("No active alerts") shows when the live computation
returns nothing — which is the truthful state until thresholds are
set on Agent records.
2. use-performance-alerts: bucket calls by c.agentId === agent.id when
the relation is set; fall back to legacy agentName matching only for
un-enriched rows. Fixes conversion% calc going to 0 after backfill.
3. agent-table: Link target uses agent.id (UUID or "legacy:NAME") so
the URL is a stable identifier instead of a display string.
4. agent-detail: parse the route param into UUID vs legacy:NAME, filter
calls by c.agentId or c.agentName accordingly, and resolve display
name via the platform Agents list.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The Agent Performance table on the team dashboard was bucketing by raw
call.agentName — the field that holds Ozonetel's transfer-chain string
("RamaiahAdmin -> GlobalHealthX") and collides for distinct AgentIDs
that share a Full Name. Result: 7 rows for 3 real agents.
Now buckets by call.agent.id when the CDR enrichment has populated it,
falls back to legacy agentName grouping otherwise. Calls without any
agent info are dropped from the agent rollup (instead of being
collapsed under "Unknown").
Pulls agent { id name ozonetelAgentId } + transferredTo + transferType
on CALLS_QUERY.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Prefer call.agent.id (set by CDR enrichment) over call.agentName string
matching. Falls back to the raw agentName only when the row hasn't been
enriched yet. Eliminates the "RamaiahAdmin -> GlobalHealthX" transfer-chain
rows and the display-name collisions (two distinct AgentIDs with the same
Full Name).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- appointment-form: normalize phone to +91XXXXXXXXXX before patient create
- appointment-form: use empty string not 'Unknown' for name fallback
- call-history: show formatted phone number instead of 'Unknown' when no lead
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Appointments page was using department for the Branch column. Now fetches
doctor.clinic.clinicName from the GraphQL query and displays that. Search
filter also updated.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Caller resolver now returns empty IDs for unrecognized numbers instead
of eagerly creating lead+patient records. Records are created when the
agent explicitly books an appointment or logs an enquiry — per PRD.
- caller-resolution.service.ts: return unresolved result, don't create
- call-desk.tsx: toast changed to 'No existing records found'
- appointment-form.tsx: create patient on save if none exists
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When a new caller's patient record is created by the caller resolver,
the name is empty. The agent types a name in the appointment form but
the patient was never updated (Bug #527 removed all patient updates).
Now updates patient name only when the initial name was empty — existing
patients with names are not affected.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Slots were fetched by doctor+date but not filtered by clinic. A doctor
visiting multiple branches showed all slots regardless of selected branch.
Now filters by clinicId when a clinic is selected.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The logout POST to /auth/logout was getting cancelled when the page
navigated to /login before the fetch completed. keepalive: true ensures
the request survives page unload so the Redis session lock is released.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Left panel: KPI cards + clickable call table (row selection highlights).
Right panel (380px): caller context (name, phone, source, AI summary,
appointments) + BargeControls component. Fetches lead data by phone match
on selection. Auto-clears when selected call ends. Removed disabled
Listen/Whisper/Barge buttons — replaced with integrated barge drawer.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
BargeControls component with 4 states: idle → connecting → connected → ended.
Connected state shows Listen/Whisper/Barge mode tabs (DTMF 4/5/6), live
duration counter, hang up button. Auto-cleanup on unmount. Mode changes
notify sidecar for agent SSE events.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Separate from agent sip-client.ts — different lifecycle (on-demand per
barge session, not persistent). Auto-answers incoming Ozonetel calls.
DTMF mode switching: 4=listen, 5=whisper, 6=barge. Event-driven with
registered/callConnected/callEnded events. Audio via hidden <audio> element.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Admin username + password inputs in the Ozonetel section for supervisor
barge/whisper/listen access. Follows existing masked password pattern.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- 13 Global Hospital smoke tests (CC Agent + Supervisor)
- Auto-unlock agent session in test setup via maint API
- agent-status-toggle sends agentId from localStorage (was missing)
- maint-otp-modal injects agentId from localStorage into all calls
- SIP manager logs agent identity on connect/disconnect/state changes
- seed-data.ts: added CC agent + marketing users, idempotent member
creation, cleanup phase before seeding
- .gitignore: exclude test-results/ and playwright-report/
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
#533: Remove redundant Call History top header (duplicate TopBar)
#531: Block logout during active call (confirm dialog + UCID check)
#529: Block outbound calls when agent is on Break/Training
#527: Remove updatePatient during appointment creation (was mutating
shared Patient entity, affecting all past appointments)
#547: SLA rules seeded via API (config issue, not code)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Appointment form clinic dropdown was hardcoded to 3 "Global Hospital"
branches. Replaced with a GraphQL query to { clinics } so each
workspace shows its own clinics. If no clinics are configured, the
dropdown is empty instead of showing wrong data.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
disconnectSip() now guards against disconnect when outboundPending
or outboundActive is true. Accepts force=true for intentional
disconnects (logout, page unload, component unmount). Prevents
React re-render cycles from killing the SIP WebSocket mid-dial,
which was causing the call to drop and disposition modal to not appear.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fixed useEffect dependency bug: callState in deps caused cleanup
(disconnectSip) to fire on every state transition, killing SIP
mid-dial. Now uses useRef for callState in beforeunload handler
with empty deps array — cleanup only fires on unmount.
- sendBeacon auto-dispose now includes agentId from agent config
- Disposition modal submit now includes agentId from agent config
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Defect 1: Setup wizard (/setup) now guarded by AdminSetupGuard —
CC agents and other non-admin roles are redirected to / instead of
seeing the setup wizard they can't complete.
Defect 3: Removed all references to Doctor.clinic (relation was
replaced by DoctorVisitSlot entity). Updated queries.ts,
appointments.tsx, transforms.ts, doctors.tsx, appointment-form.tsx.
Defect 6 (frontend side): Dial request now sends agentId and
campaignName from localStorage agent config so the sidecar dials
with the correct per-agent credentials, not global defaults.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Root cause: when an agent refreshes the page during or after a call,
the React state (UCID, callState, disposition modal) is wiped. The
SIP BYE event fires but no component exists to trigger the disposition
modal → no POST to /api/ozonetel/dispose → agent stuck in ACW.
Layer 1 (beforeunload warning):
Shows browser's native "Leave page?" dialog during active calls.
Agent can cancel and stay.
Layer 2 (sendBeacon auto-dispose):
UCID persisted to localStorage when call activates. On page unload,
navigator.sendBeacon fires /api/ozonetel/dispose with
CALLBACK_REQUESTED. Guaranteed delivery even during page death.
Cleared from localStorage when disposition modal submits normally.
Layer 3 lives in helix-engage-server (separate commit).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Setup wizard: 3-pane layout with right-side live previews, resume
banner, edit/copy icons on team step, AI prompt configuration
- Forms: employee-create replaces invite-member (no email invites),
clinic form with address/hours/payment, doctor form with visit slots
- Seed script: aligned to current SDK schema — doctors created as
workspace members (HelixEngage Manager role), visitingHours replaced
by doctorVisitSlot entity, clinics seeded, portalUserId linked
dynamically, SUB/ORIGIN/GQL configurable via env vars
- Pages: clinics + doctors CRUD updated for new schema, team settings
with temp password + role assignment
- New components: time-picker, day-selector, wizard-right-panes,
wizard-layout-context, resume-setup-banner
- Removed: invite-member-form (replaced by employee-create-form per
no-email-invites rule)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>