Three related fixes:
1. Disposition for answered inbound calls
Previously the dispose endpoint sent the agent's choice to Ozonetel
but never wrote it back to the platform Call record. The webhook's
pre-disposition value ("General Enquiry" → INFO_PROVIDED) persisted.
Now: dispose endpoint finds the Call by UCID and updates disposition
to the agent's actual selection.
2. SLA timing wiring (assignedAt / answeredAt / responseTimeS)
patchCallTiming() existed but was never called. Now wired into
handleCallEvent:
- "Calling" event → writes assignedAt (ring start)
- "Answered" event → writes answeredAt + computes responseTimeS
(answeredAt - startedAt = caller wait time)
Uses patchCallTimingByUcid helper that looks up Call by UCID.
3. Backfill maint endpoint: POST /api/maint/backfill-call-disposition-timing
Walks calls for a given date, joins to CDR by UCID (both legs),
patches disposition (from CDR's mapped value, always overwrites),
timing fields (answeredAt, assignedAt, responseTimeS from CDR),
and CDR-specific durations (handlingTimeS, acwDurationS, holdDurationS).
Idempotent — safe to run multiple times.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Three changes:
1. Await Ozonetel logout in /auth/logout — prevents race condition when
agent re-logs in quickly via "Remember me". The fire-and-forget
logoutAgent() left a window where the next loginAgent() arrived
while Ozonetel was still processing the previous logout, leaving
the agent stuck in "Telephony Unavailable". (#559)
2. Use agentConfig.sipPassword (from Agent entity) instead of
OZONETEL_AGENT_PASSWORD env var for login/logout/force-ready.
The env var was a single shared credential that ignored per-agent
passwords. Removed hardcoded "Test123$" fallback. Force-ready
now looks up the Agent entity by ozonetelAgentId to get the
correct sipPassword + sipExtension.
3. Missed-calls worklist query now fetches campaign { id campaignName }
so the frontend Branch column can show the campaign name instead
of the raw DID phone number.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Unlock Agent / Force Ready shortcuts used to read the target agentId from localStorage helix_agent_config — supervisors don't have that set and got 400 'agentId required'.
- SessionService.listLockedSessions() — SCAN over agent:session:*
- POST /api/maint/session-status returns { locked, free } by joining
the platform Agent entities against Redis session locks
- orphan locks (Redis key with no matching Agent record) surface in
the Locked bucket so the operator can still clear stale lock state
Populates clinicId on historical Appointments that predate clinicId
persistence. Infers from DoctorVisitSlot records:
1. Pull all appointments with clinicId=NULL
2. Pre-load visit slots per unique doctorId (batched)
3. For each appointment: match slots by weekday + time-window
4. Single clinic → use it
5. Multiple candidates → pick lex-order first (deterministic; logged)
6. Last resort → any slot's clinic
No CDR or rate-limited calls — pure platform queries. Safe to re-run;
idempotent (only patches rows still missing clinicId).
POST /api/maint/backfill-appointment-clinics
Header: x-maint-otp: <OTP>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Audited all 23 sidecar create-mutation call sites; 7 were missing the
top-level data.name field that the platform uses as record title:
- caller-resolution.service.ts createPatient — full name from first/last
- maint.controller.ts createPatient (backfill-lead-patient-links) — same
- widget.service.ts createPatient (chat path + booking path) — full name
- widget.service.ts createAppointment — "<Patient> — <date>"
- worklist/missed-queue.service.ts createCall — "Missed — <phone>"
- rules-engine/actions/escalate.action.ts createPerformanceAlert —
"<agent>: <message> (<value>)"
- supervisor/agent-history.service.ts createAgentEvent / createAgentSession
Cosmetic only — the app fetches fullName/agentName for display, so
end users never saw "Untitled". Fixes platform-side admin browsing.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Inbound webhook rows store agentName as Ozonetel's display string
("Ganesh Bandi", "GlobalHealthX") which doesn't match either
ozonetelAgentId or platform Agent.name. Add ozonetelDisplayName as a
third index — populated on each platform Agent with the Full Name from
the Ozonetel admin UI.
After this + setting display names on Global + Ramaiah agents:
backfill matched 122/136 (~90%) on Global; remaining are calls handled
by agents that don't exist on this workspace.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Historical Calls pre-date UCID persistence, so the CDR-join enrichment
can't reach them. Fallback: parse agentName (may be "A -> B -> C"
transfer chain), take the final hop, resolve to Agent by ozonetelAgentId
(case-insensitive) or by Full Name. Preserve the full chain string in
transferredTo when it was actually chained.
No rate limit — pure platform queries, no CDR.
POST /api/maint/backfill-call-agents-by-name
Header: x-maint-otp: <OTP>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Ozonetel's webhook AgentName is a transfer-chain display string — same
display can collide (two agents both named "GlobalHealthX" with distinct
agent IDs), and chained like "RamaiahAdmin -> Ganesh Bandi -> GlobalHealthX".
Team Performance was bucketing every unique raw string as a separate
"agent", producing 7 rows for 3 real agents.
Fix — authoritative agent link via CDR AgentID (unique):
- New AgentLookupService (platform module): case-insensitive
ozonetelAgentId → Agent UUID cache, shared across webhook / dispose /
enrichment / backfill paths
- Webhook + outbound-dispose now persist UCID on Call so CDR can join
- Outbound dispose resolves agent relation at create time and overwrites
from CDR AgentID post-hoc (catches dial transfers)
- New CdrEnrichmentService: every 30 min fetches today + yesterday CDR,
patches Calls missing agentId / transferredTo / transferType by UCID
join. Well under Ozonetel's 2 req/min cap.
- Historical backfill maint endpoint: /api/maint/enrich-call-agents
with configurable day window (default 2, max 15). Rate-limited at 35s
between dates.
Call schema additions (synced on Global + Ramaiah): agent relation,
ucid, transferredTo, transferType. agentName remains for legacy/display.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Recomputes durationS on existing AgentEvent rows using the fixed
per-category pairing logic and re-runs the session rollup for every
affected date. Fixes the 0-second CALL_END durations written before the
slot-split fix.
Idempotent — only patches rows whose stored durationS differs from the
newly computed value. Safe to re-run.
POST /api/maint/backfill-agent-event-durations
Headers: x-maint-otp: <OTP>
Body: { "date": "YYYY-MM-DD" } (optional; defaults to today IST; use "all" to backfill every row)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
agent-state, dispose, dial, performance, force-ready, unlock-agent
all required agentId from the request body now. No silent fallback
to OZONETEL_AGENT_ID env var which caused cross-tenant operations
in multi-agent setups (Ramaiah operations hitting Global's agent).
OZONETEL_AGENT_ID removed from telephony env seed list. Hardcoded
fallbacks (agent3, Test123$, 521814) deleted.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Phase 1 of hospital onboarding & self-service plan
(docs/superpowers/plans/2026-04-06-hospital-onboarding-self-service.md).
Backend foundations to support the upcoming staff-portal Settings hub and
6-step setup wizard. No frontend in this phase.
New config services (mirroring ThemeService / WidgetConfigService):
- SetupStateService — tracks completion of 6 wizard steps; isWizardRequired()
drives the post-login redirect
- TelephonyConfigService — Ozonetel + Exotel + SIP, replaces 8 env vars,
seeds from env on first boot, masks secrets on GET,
'***masked***' sentinel on PUT means "keep existing"
- AiConfigService — provider, model, temperature, system prompt addendum;
API keys remain in env
New endpoints under /api/config:
- GET /api/config/setup-state returns state + wizardRequired flag
- PUT /api/config/setup-state/steps/:step mark step complete/incomplete
- POST /api/config/setup-state/dismiss dismiss wizard
- POST /api/config/setup-state/reset
- GET /api/config/telephony masked
- PUT /api/config/telephony
- POST /api/config/telephony/reset
- GET /api/config/ai
- PUT /api/config/ai
- POST /api/config/ai/reset
ConfigThemeModule is now @Global() so the new sidecar config services are
injectable from AuthModule, OzonetelAgentModule, MaintModule without creating
a circular dependency (ConfigThemeModule already imports AuthModule for
SessionService).
Migrated 11 env-var read sites to use the new services:
- ozonetel-agent.service: exotel API + ozonetel did/sipId via read-through getters
- ozonetel-agent.controller: defaultAgentId/Password/SipId via getters
- kookoo-ivr.controller: sipId/callerId via getters
- auth.controller: OZONETEL_AGENT_PASSWORD (login + logout)
- agent-config.service: sipDomain/wsPort/campaignName via getters
- maint.controller: forceReady + unlockAgent
- ai-provider: createAiModel and isAiConfigured refactored to pure factories
taking AiProviderOpts; no more ConfigService dependency
- widget-chat.service, recordings.service, ai-enrichment.service,
ai-chat.controller, ai-insight.consumer, call-assist.service: each builds
the AI model from AiConfigService.getConfig() + ConfigService API keys
Hot-reload guarantee: every consumer reads via a getter or builds per-call,
so admin updates take effect without sidecar restart. WidgetChatService
specifically rebuilds the model on each streamReply().
Bug fix bundled: dropped widget.json.hospitalName field (the original
duplicate that started this whole thread). WidgetConfigService now reads
brand.hospitalName from ThemeService at the 2 generateKey call sites.
Single source of truth for hospital name is workspace branding.
First-boot env seeding: TelephonyConfigService and AiConfigService both
copy their respective env vars into a fresh data/*.json on onModuleInit if
the file doesn't exist. Existing deployments auto-migrate without manual
intervention.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- SSE agent state stream: supervisor maintains state map from Ozonetel webhooks, streams via /api/supervisor/agent-state/stream
- Force-logout via SSE: distinct force-logout event type avoids conflict with normal login cycle
- Maint module (/api/maint): OTP-guarded endpoints for force-ready, unlock-agent, backfill-missed-calls, fix-timestamps
- Fix Ozonetel IST→UTC timestamp conversion: istToUtc() in webhook controller and missed-queue service
- Missed call lead lookup: ingestion queries leads by phone, stores leadId + leadName on Call entity
- Timestamp backfill endpoint: throttled at 700ms/mutation, idempotent (skips already-fixed records)
- Structured logging: full JSON payloads for agent/call webhooks, [DISPOSE] trace with agentId
- Fix dead code: agent-state endpoint auto-assign was after return statement
- Export SupervisorService for cross-module injection
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>