Root cause: CDR webhook fires 5s after dispose, stores monitorUCID.
Frontend has agent-side UCID from SIP. They never matched → disposition
not persisted, agent call history empty.
Fix:
- Dispose endpoint now creates Call records for ALL answered calls
(inbound + outbound), not just outbound. Record gets agent-side UCID
+ correct disposition immediately.
- Webhook checks if Call record already exists (by agent UCID via
monitorUCID→agentUCID mapping). If found, enriches with recording
URL, agent chain name, CDR timing. If not found, creates as fallback.
- SupervisorService stores UCID mapping from real-time events (which
carry both UCIDs). Auto-expires after 10 minutes.
Verified: UCID-MAP logged, dispose creates record, webhook enriches
without duplicating. DB shows correct agent UCID + disposition + recording.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
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>
Inbound transferred calls arrive with AgentName like 'RamaiahAdmin -> GlobalHealthX'. The webhook was persisting the raw chain string and leaving agentId null; the CDR enrichment cron then silently skipped 100% of rows because the bulk CDR keys on caller-leg UCID while the webhook stores monitorUCID — the join never matched.
- missed-call-webhook: split chain on ' -> ', take final handler,
resolve via AgentLookupService (ozonetelAgentId + display name)
- cdr-enrichment: index CDR rows by both UCID and monitorUCID so
the cron actually patches historical rows
- enrichment also parses chain in CDR AgentName as a second fallback
- spec: add CallerResolutionService + AgentLookupService mocks
Ozonetel's summaryReport only tallies CLOSED login→logout pairs — an
agent who's still logged in reports 00:00:00, so the KPI card on My
Performance always showed 0s for the current session.
Our AgentSession rollup already caps open sessions at "now" when it
runs. Endpoint now:
1. Triggers an on-demand rollupSessions(targetDate) to refresh the
AgentSession row (no 15-min wait after login)
2. Reads AgentSession and renders in the HH:MM:SS shape the frontend
expects
3. Falls back to Ozonetel's summaryReport when AgentSession is
empty (brand-new agent, workspace missing AgentEvent entity)
Works transparently — same timeUtilization shape as before, frontend
unchanged.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The first boot hit 429 because two dates (today + yesterday) were
fetched back-to-back, and the dispose flow's fetchCdrByUCID shares the
same 2-req/min budget. 35s between dates keeps us clear of the cap.
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>
- New AgentHistoryService: persistAgentEvent pairs START/END for durationS, patchCallTiming updates Call SLA fields
- Supervisor service wires handleCallEvent (CALL_START on Answered, CALL_END on Disconnect) and handleAgentEvent (LOGIN/LOGOUT/PAUSE/RESUME/ACW_START/ACW_END/READY) via priorState-aware mapping
- setInterval-based nightly-ish rollup: every 15min aggregates AgentEvent into AgentSession per IST day (idempotent upsert by agentId+date)
- Ozonetel dispose flow extracts HandlingTime/WrapupDuration/HoldDuration from CDR, patches Call timing fields
- Field names match platform truncation: durationS, loginDurationS, busyTimeS, idleTimeS, pauseTimeS, wrapupTimeS, avgHandlingTimeS, handlingTimeS, acwDurationS, holdDurationS, responseTimeS, sessionDate → date
- Skips cleanly on workspaces where AgentEvent entity isn't synced
Known issue: pending-pair map has single slot per agent, so ACW_START overwrites pending CALL_START and CALL_END computes 0s duration. Fix in followup.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- AI chat book_appointment tool: accepts optional clinicId
- Widget booking: passes clinicId from request
- LiveKit agent: passes clinicId from doctor context if available
- Dispose endpoint: sets startedAt/endedAt on outbound call records
(computed from durationSec). Fixes null timestamps in call history.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Ozonetel returns raw base64 public key without PEM headers. Node's
crypto.publicEncrypt requires PEM format.
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>
#536: Performance endpoint now accepts agentId query param and filters
CDR to that agent only. Previously returned all agents' calls as one
agent's total. Fixed 'Unanswered' → 'NotAnswered' status filter.
#538: Team performance now includes per-agent call metrics (total,
inbound, outbound, answered, missed) from CDR data + teamTotals
aggregate. Previously only returned Ozonetel time breakdown without
any call counts.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Dispose endpoint creates Call entity for outbound calls (direction=OUTBOUND).
The webhook now skips outbound, so dispose is the only path for outbound records.
- MissedQueueService filters abandonCalls by own campaign (read from TelephonyConfigService).
Prevents cross-tenant ingestion from shared Ozonetel account.
- WorklistModule provides TelephonyConfigService directly (avoids circular dep with ConfigThemeModule).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Dispose endpoint now accepts agentId from body (same pattern as dial
fix). Fixes "Invalid Agent ID" when disposing as non-default agent.
Also fixed JS operator precedence bug in campaign name fallback
that produced "Inbound_" instead of "Inbound_918041763400".
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Defect 5: Worklist, missed-call-webhook, missed-queue, ai-chat, and
rules-engine all used legacy lowercase field names (callbackstatus,
callsourcenumber, missedcallcount, callbackattemptedat) from the old
VPS schema. Fixed to camelCase (callbackStatus, callSourceNumber,
missedCallCount, callbackAttemptedAt) matching the current SDK sync.
Defect 6: Dial endpoint used global defaults (OZONETEL_AGENT_ID env
var) instead of the logged-in agent's config. Now accepts agentId
and campaignName from the frontend request body. Falls back to
telephony config → DID-derived campaign name → explicit error.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When Ozonetel sends an ACW event, starts a 30-second timer. If no
/api/ozonetel/dispose call arrives within that window (frontend
crashed, tab closed, page refreshed), auto-disposes with "General
Enquiry" + autoRelease:true. Agent exits ACW automatically.
Timer is cancelled when:
- Frontend submits disposition normally (cancelAcwTimer in controller)
- Agent transitions to Ready or Offline
- Agent logs out
Wiring: OzonetelAgentModule now imports SupervisorModule (forwardRef
for circular dep), controller injects SupervisorService to cancel
the timer on successful dispose.
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>
- Token cache reduced from 55min to 10min (Ozonetel expires in ~15min)
- All API methods invalidate cached token on 401
- loginAgent forces logout + re-login when "already logged in"
to refresh SIP phone mapping
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Reduced token cache from 55min to 10min (Ozonetel expires in ~15min)
- All API methods invalidate cached token on 401 response
- Force-refresh token on CC agent login
- Removed unused withTokenRetry wrapper
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- MissedQueueService: polls Ozonetel abandonCalls every 30s, dedup by phone
- Auto-assigns oldest PENDING_CALLBACK call on agent Ready (dispose + state change)
- GET /api/worklist/missed-queue, PATCH /api/worklist/missed-queue/:id/status
- Worklist query updated with callback fields and FIFO ordering
- PlatformGraphqlService.query() made public for server-to-server ops
- forwardRef circular dependency resolution between WorklistModule and OzonetelAgentModule
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix Call record field names (recording, callerNumber, durationSec)
- Add POST /api/ozonetel/agent-ready using logout+login for Force Ready
- Add callerNumber to kookoo callback
- Better error logging with response body
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The toolbar uses TWO WebSocket connections:
1. CloudAgent WS (mdlConnection.php) for control events (newCall, busyAgent)
2. SIP WS (blr-pub-rtc4) for audio
tbManualDial requires browserSessionId + usId from the CloudAgent WS session.
Without these, CloudAgent doesn't route the SIP INVITE to our browser.
Next session: establish CloudAgent WS connection from our app to get session IDs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Kookoo <dial> only routes to PSTN numbers, not SIP extensions.
Tested: 523590, 0523590 — both result in not_answered.
The bridge from Kookoo to browser SIP requires either:
- Ozonetel enabling /ca_apis/ auth (CloudAgent API)
- Or a way to route Kookoo calls to internal SIP extensions
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
When customer answers outbound call, Kookoo hits /kookoo/ivr which
returns <dial record="true">523590</dial> to bridge the call to
the agent's SIP extension. Agent's browser rings, both connect.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Replace CloudAgent V3 tbManualDial with Kookoo outbound.php
- Simple HTTP GET with api_key — no auth issues
- Kookoo callback endpoint: POST /webhooks/kookoo/callback
- Creates Call record in platform
- Matches caller to Lead by phone
- Remove agent login requirement before dial
- Tested: call queued successfully, phone rang
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Update default campaign to Inbound_918041763265
- Add Dockerfile and .dockerignore for container deployment
- Sidecar image: 043728036361.dkr.ecr.ap-south-1.amazonaws.com/fortytwo-eap/helix-engage-sidecar:alpha
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>