Commit Graph

145 Commits

Author SHA1 Message Date
00303df95b fix(slots): hide past slots today even on cache hit
Previous flow cached the unfiltered slot list AND applied the "hide
past slots" filter — but only on the fresh-fetch path. A cache hit
returned the stored list untouched, so by lunchtime agents saw morning
slots that had already passed.

Refactored into a post-cache filterPastSlotsForToday() helper applied
on both cache-hit and fresh paths. Cache stores the full day's slots
(keyed by doctorId + dayOfWeek), so same-weekday reuse across weeks
stays correct.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:38:23 +05:30
34e053204f feat(leads): sidecar polling service for auto-assigning unassigned leads
Replaces the untrusted platform function path (SDK's lead-auto-assign
was written but never deployed to either workspace — all leads created
after seeding are orphan).

Polls every 60s:
  1. Fetch up to 100 unassigned leads (assignedAgent empty or null)
  2. Fetch platform Agents whose live SupervisorService state is
     ready/calling/in-call/acw (skip offline/break/training/unknown)
  3. Build open-lead count per agent (single paginated query)
  4. Assign each unassigned lead to the least-loaded active agent —
     writes agent.name into lead.assignedAgent to match the worklist
     filter (assignedAgent: { eq: agentName })

Catches every lead-creation path: CSV import, enquiry form,
missed-call webhook, widget, livekit. No platform changes needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 11:23:53 +05:30
98f5bc0347 fix(ai-chat): use correct Clinic schema in knowledge-base query
The Clinic entity has never had weekdayHours / saturdayHours /
sundayHours. Schema uses 7 booleans (openMonday..openSunday) + a single
opensAt/closesAt pair, and requiredDocuments is a RELATION
(ClinicRequiredDocumentConnection), not a scalar TEXT.

Query was failing silently since 2026-03-18 — AI chat knowledge base
was missing clinic info for a month.

Fix:
- Query the real fields: openMonday..openSunday, opensAt, closesAt
- Render "Open: Mon, Tue, ... HH:MM–HH:MM" + "Closed: Sat, Sun"
- Walk requiredDocuments.edges for documentType list instead of treating
  it as a string

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:31:00 +05:30
048545317d fix: set platform name on every entity create — patients/appts/calls/etc no longer "Untitled"
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>
2026-04-15 09:32:28 +05:30
8dcfa5a72f feat(performance-alerts): rules-engine-driven alerts, persisted as PerformanceAlert
Phase A+B of the alerts overhaul:

- New PerformanceFactsProvider exposes agent.idleMinutes (from
  AgentSession), agent.busyMinutes, agent.totalCallsToday,
  agent.bookedCallsToday, agent.conversionPercent
- Implement EscalateActionHandler (was a stub): persists a
  PerformanceAlert row, dedupes per agent+type+IST date so a 5-min
  cron can't spam, updates value if it changes
- New PerformanceConsumer: setInterval every 5 min, reads on_schedule
  rules referencing agent.* facts, evaluates per agent, dispatches
  escalate actions
- Two starter rules in hospital-starter.json: excessive-idle (>60min)
  and low-conversion (<15% with >10 calls today). NPS deferred — no
  source signal exists yet
- New PerformanceAlertsController: GET /api/supervisor/performance-alerts
  (active list), POST /:id/dismiss, POST /dismiss-all
- Rules engine now injects EscalateActionHandler via DI so the action
  has access to PlatformGraphqlService for persistence

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:02:02 +05:30
5b40f49b65 feat(agent-lookup): resolve by Ozonetel display name too
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>
2026-04-15 08:25:14 +05:30
fb616d47ee feat(maint): backfill-call-agents-by-name for historical Calls
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>
2026-04-15 07:58:06 +05:30
6fd17acf78 fix(cdr-enrichment): 35s sleep between date fetches — Ozonetel caps at 2/min
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>
2026-04-15 07:49:14 +05:30
846c5f4c9b feat(calls): consolidate agent identity via Ozonetel CDR
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>
2026-04-15 07:43:28 +05:30
9472f83cd8 feat(supervisor): team-performance reads AgentSession first, Ozonetel as fallback
Phase 3 — wire the dashboard to the new metrics path without touching
the frontend. getTeamPerformance now:

1. Fetches AgentSession rows for the given IST date (keyed by agent UUID)
2. For each agent: uses AgentSession data rendered as HH:MM:SS if a row
   exists, otherwise falls back to Ozonetel summaryReport
3. Returns timeBreakdownSource so the frontend can optionally show which
   source was used

Frontend continues to parse the existing HH:MM:SS shape via parseTime()
— no UI change needed. Historical dates without AgentSession rows still
render via Ozonetel.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 07:14:16 +05:30
6de1989536 feat(maint): backfill-agent-event-durations endpoint
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>
2026-04-15 07:05:54 +05:30
2acba59963 fix(supervisor): separate pending slots per event category to pair CALL/ACW correctly
CALL and ACW overlap: an agent enters ACW before the CALL_END webhook
arrives. With a single shared pending slot, ACW_START would clobber the
pending CALL_START and CALL_END would compute 0-second duration against
the ACW_START timestamp. Verified in production data — 4/4 CALL_END rows
on Global had durationS=0.

Fix: one slot per category (pause/call/acw). Each END reads and clears
its own slot. READY and LOGOUT defensively flush all slots to avoid
leaking state across sessions.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 06:53:23 +05:30
4eb8cb80b2 feat(supervisor): Phase 2 metrics ingest — AgentEvent/AgentSession rollup
- 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>
2026-04-15 06:49:15 +05:30
fbe782b5ac fix+feat: morning QA fixes, worklist pagination, misc sidecar improvements
- caller-resolution: drop cache, use indexed phone filter (lead.contactPhone.primaryPhoneNumber.like)
- worklist: externalize page size (WORKLIST_PAGE_SIZE × WORKLIST_MAX_PAGES), paginate getMissedCalls/getAssignedLeads/getPendingFollowUps
- maint: unlock-agent, force-ready, backfill-caller-resolution, clear-analysis-cache, fix-timestamps
- ozonetel agent.service: force logout+re-login on "already logged in"
- ai chat: context expansion
- livekit-agent: updates
- widget: session handling
- masterdata: clinic list cache

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 06:49:02 +05:30
b6b597fdda fix: clinicId on all appointment paths + startedAt on call records
- 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>
2026-04-13 14:52:30 +05:30
a4ff052fef fix: stop auto-creating Unknown leads on caller resolve
Return empty IDs for unrecognized numbers instead of creating lead+patient.
Per PRD: 'System will not identify the patient — no summary shown.'
Records are created when agent books appointment or logs enquiry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:23:22 +05:30
5969441868 fix: map Ozonetel 'pause' webhook action to break state
Ozonetel sends action: 'pause' via webhook when agent is paused, but
mapOzonetelAction only handled 'AUX'. The 'pause' action fell through
to default (null), so the break SSE event was never emitted. The agent
UI stayed on 'Ready' while Ozonetel had the agent PAUSED.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:52:52 +05:30
01348123e6 fix: map HelixEngage Supervisor platform role to admin app role
Supervisor users were getting 'executive' role because only 'HelixEngage
Manager' was mapped to admin. This broke admin route access after the
RequireAdmin guard was added.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 06:47:01 +05:30
d97d73dd1a fix: wrap raw base64 public key with PEM headers for Node crypto
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>
2026-04-12 21:09:00 +05:30
7b178f9dc7 fix: remove ConfigThemeModule import — it's @Global, no import needed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 20:59:36 +05:30
3d790e51dc fix: circular dependency — forwardRef ConfigThemeModule in SupervisorModule
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 17:03:47 +05:30
1c3e42ad7c fix: non-null assertion on cachedToken return
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:50:10 +05:30
ea60787da0 feat(sidecar): supervisor barge endpoints — initiate, mode switch, end
Endpoints:
- GET  /api/supervisor/barge/sip-credentials — fetch SIP number from pool
- POST /api/supervisor/barge — initiate barge via Ozonetel apiId 63
- POST /api/supervisor/barge/mode — update mode (listen/whisper/barge)
- POST /api/supervisor/barge/end — cleanup session + Redis

SupervisorService extended with barge session tracking (in-memory Map).
Mode changes emit SSE events to agent: supervisor-whisper, supervisor-barge,
supervisor-left. Listen mode is silent (no event to agent).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:06:57 +05:30
c23792496b feat(sidecar): Ozonetel admin auth service — RSA login, JWT cache
- Node crypto RSA encryption (not jsencrypt — server-side)
- Pre-login public key fetch, encrypted login, JWT caching
- Auto-refresh before token expiry (decoded from JWT payload)
- Auth headers: Bearer token + userId + userName + isSuperAdmin
- Registered in SupervisorModule with ConfigThemeModule import

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:05:24 +05:30
27a3fbcfed feat(config): add Ozonetel admin credentials to TelephonyConfig
- adminUsername + adminPassword in ozonetel section
- Masked in GET response, sentinel-stripped on update
- Env seeds: OZONETEL_ADMIN_USERNAME, OZONETEL_ADMIN_PASSWORD
- Used by supervisor barge/whisper/listen endpoints

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 16:03:51 +05:30
0f5bd7d61a ci: fix Teams notification — use Adaptive Card with curl
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2026-04-11 15:37:20 +05:30
f1313f0e2f ci: use Teams notification plugin
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-11 15:34:30 +05:30
44f1ec36e1 ci: add Woodpecker pipeline — unit tests + Teams notification
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
2026-04-11 15:29:49 +05:30
4bd08a9b02 fix: remove defaultAgentId fallback — require agentId from caller
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>
2026-04-11 12:10:31 +05:30
0248c4cad1 fix: #536 #538 performance metrics — filter CDR by agentId, add team call counts
#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>
2026-04-10 19:33:59 +05:30
be505b8d1f fix: #540 ignore call events for offline agents in live monitor
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 19:27:09 +05:30
dbefa9675a feat: master data endpoint — cached departments, doctors, clinics
Redis-cached (5min TTL) lookups via /api/masterdata/departments,
/api/masterdata/doctors, /api/masterdata/clinics. Warms cache on
startup. Frontend dropdowns use these instead of hardcoded lists.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 17:31:01 +05:30
9dc02e107a fix: E.164 phone format for outbound call records (+91 prefix)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:27:50 +05:30
c807cf737f fix: outbound call records via dispose + campaign-filtered polling
- 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>
2026-04-10 16:20:45 +05:30
96d0c32000 fix: skip outbound calls in webhook + filter abandon polls by campaign
Webhook controller now skips outbound calls (type=Manual/OutBound).
An unanswered outbound dial is NOT a missed inbound call — it was
being incorrectly created as MISSED with PENDING_CALLBACK status.

MissedQueueService now filters the Ozonetel abandonCalls API response
by campaign name (read from TelephonyConfigService). Prevents
cross-tenant ingestion when multiple sidecars share the same
Ozonetel account.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 16:09:17 +05:30
9665500b63 fix: dispose uses per-agent ID + campaign fallback operator precedence
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>
2026-04-10 15:49:10 +05:30
9f5935e417 feat: telephony dispatcher registration — sidecar self-registers on boot
Adds TelephonyRegistrationService that:
1. On startup: queries platform for agent list, registers with the
   telephony dispatcher at TELEPHONY_DISPATCHER_URL
2. Every 30s: sends heartbeat to keep registration alive (90s TTL)
3. On shutdown: deregisters (best-effort, TTL cleans up anyway)
4. On heartbeat failure: auto re-registers

Env vars:
  TELEPHONY_DISPATCHER_URL — where to register (outbound to dispatcher)
  TELEPHONY_CALLBACK_URL — where events come back (inbound to sidecar)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 15:08:30 +05:30
898ff65951 fix: camelCase field names + dial uses per-agent config
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>
2026-04-10 14:29:19 +05:30
7717536622 fix: server-side ACW auto-dispose (Layer 3) — 30s timeout safety net
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>
2026-04-10 12:29:41 +05:30
33dc8b5669 merge: feature/omnichannel-widget → master
26 commits bringing the full omnichannel call-center stack:

Core features:
- Team module (in-place employee creation, temp passwords, role assignment)
- Multi-stage Dockerfile (fixes cross-arch native module crashes)
- Doctor visit slot entity support (shared fragment + normalizer)
- AI config CRUD (admin-editable prompts, workspace-scoped setup state)
- Widget chat with generative UI, captcha gate, lead dedup
- Call assist, supervisor, recordings services updated for new schema
- Session service with workspace-scoped Redis key prefixing

Infrastructure:
- Dockerfile rewritten as multi-stage builder → runtime
- package-lock.json regenerated (Verdaccio → public npmjs.org)
- .dockerignore hardened

Tests (48 passing):
- Ozonetel agent service (auth, dial, dispose, state, token cache)
- Missed call webhook (parsing, IST→UTC, duration, CallerID)
- Missed queue (abandon polling, PENDING_CALLBACK, dedup)
- Caller resolution (4-path phone→lead+patient, caching)
- Team service (5-step creation, SIP linking, validation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:36:11 +05:30
ab65823c2e test: unit tests for Ozonetel integration, caller resolution, team, missed calls
48 tests across 5 new spec files, all passing in <1s:

- ozonetel-agent.service.spec: agent auth (login/logout/retry),
  manual dial, set disposition, change state, token caching (10 tests)
- missed-call-webhook.spec: webhook payload parsing, IST→UTC
  conversion, duration parsing, CallerID handling, JSON-wrapped
  body (9 tests)
- missed-queue.spec: abandon call polling, PENDING_CALLBACK status,
  UCID dedup, phone normalization, istToUtc utility (8 tests)
- caller-resolution.spec: phone→lead+patient resolution (4 paths:
  both exist, lead only, patient only, neither), caching, phone
  normalization, link-if-unlinked (9 tests)
- team.spec: 5-step member creation flow, SIP seat linking,
  validation, temp password Redis cache, email normalization,
  workspace context caching (8 tests)

Fixtures: ozonetel-payloads.ts with accurate Ozonetel API shapes
from official docs — webhook payloads, CDR records, abandon calls,
disposition responses, auth responses.

QA coverage: TC-MC-01/02/03, TC-IB-05/06/07, backs TC-IB/OB-01→06
via the Ozonetel service layer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 09:32:40 +05:30
695f119c2b feat: team module, multi-stage Dockerfile, doctor utils, AI config overhaul
- Team module: POST /api/team/members (in-place employee creation with
  temp password + Redis cache), PUT /api/team/members/:id, GET temp
  password endpoint. Uses signUpInWorkspace — no email invites.
- Dockerfile: rewritten as multi-stage build (builder + runtime) so
  native modules compile for target arch. Fixes darwin→linux crash.
- .dockerignore: exclude dist, node_modules, .env, .git, data/
- package-lock.json: regenerated against public npmjs.org (was
  pointing at localhost:4873 Verdaccio — broke docker builds)
- Doctor utils: shared DOCTOR_VISIT_SLOTS_FRAGMENT + normalizeDoctors
  helper for visit-slot-aware queries across 6 consumers
- AI config: full admin CRUD (GET/PUT/POST reset), workspace-scoped
  setup-state with workspace ID isolation, AI prompt defaults overhaul
- Agent config: camelCase field fix for SDK-synced workspaces
- Session service: workspace-scoped Redis key prefixing for setup state
- Recordings/supervisor/widget services: updated to use doctor-utils
  shared fragments instead of inline visitingHours queries

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-10 08:37:58 +05:30
eacfce6970 feat: POST /api/lead/:id/enrich for on-demand AI summary regen
Adds a new sidecar endpoint that forces regeneration of a lead's
aiSummary + aiSuggestedAction. Triggered by the call-desk Appointment
and Enquiry forms when an agent explicitly edits the caller's name —
the previous summary was built against stale identity and needs to be
refreshed from the corrected record.

Scope:

- src/call-events/lead-enrich.controller.ts (new): POST
  /api/lead/:id/enrich. Fetches the lead fresh via
  findLeadByIdWithToken, runs AiEnrichmentService.enrichLead() with
  recent activities for context, persists the new summary via
  updateLeadWithToken, and optionally invalidates the Redis
  caller-resolution cache for the phone (if provided in the request
  body) so the next incoming call does a fresh platform lookup.

- src/platform/platform-graphql.service.ts:
  - Added findLeadByIdWithToken. Selects staging-aligned field names
    (status/source/lastContacted) rather than the older
    leadStatus/leadSource/lastContactedAt names — otherwise the query
    is rejected by the deployed schema. Includes a fallback query
    shape in case a future platform version exposes `lead(id)`
    directly instead of `leads(filter: ...)`.
  - Fixed updateLeadWithToken response fragment to drop the broken
    `leadStatus` field selection. Every call to this method was
    failing against staging because the fragment asked for a field
    the schema no longer has.

- src/call-events/call-events.module.ts: registered
  LeadEnrichController and imported CallerResolutionModule so the
  new controller can inject CallerResolutionService for Redis cache
  invalidation.

The other field-rename issues in platform-graphql.service.ts
(findLeadByPhone/findLeadByPhoneWithToken/updateLead still select
leadStatus+leadSource and will keep failing against staging) are
deliberately untouched here — separate follow-up hotfix to keep this
commit focused on the enrich flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:53:46 +05:30
619e9ab405 feat(onboarding/phase-1): admin-editable telephony, ai, and setup-state config
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>
2026-04-07 07:02:07 +05:30
e6c8d950ea feat: widget config via admin-editable data/widget.json
Mirrors the existing theme config pattern so website widget settings can be
edited from the admin portal instead of baked into frontend env vars. Fixes
the current symptom where the staging widget is silently disabled because
VITE_WIDGET_KEY is missing from .env.production.

Backend (sidecar):
- src/config/widget.defaults.ts — WidgetConfig type + defaults
  (enabled, key, siteId, url, allowedOrigins, hospitalName,
  embed.loginPage, version, updatedAt)
- src/config/widget-config.service.ts — file-backed load / update /
  rotate-key / reset with backups, mirroring ThemeService. On module init:
    * first boot → auto-generates an HMAC-signed site key via
      WidgetKeysService, persists both to data/widget.json and to Redis
    * subsequent boots → re-registers the key in Redis if missing (handles
      Redis flushes so validateKey() keeps working without admin action)
- src/config/widget-config.controller.ts — new endpoints under /api/config:
    GET  /api/config/widget            public subset {enabled, key, url, embed}
    GET  /api/config/widget/admin      full config for the settings UI
    PUT  /api/config/widget            admin update (partial merge)
    POST /api/config/widget/rotate-key revoke old siteId + mint a new key
    POST /api/config/widget/reset      reset to defaults + regenerate
- Move src/widget/widget-keys.service.ts → src/config/widget-keys.service.ts
  (it's a config-layer concern now, not widget-layer). config-theme.module
  becomes the owner, imports AuthModule for SessionService, and exports
  WidgetKeysService + WidgetConfigService alongside ThemeService.
- widget.module stops providing WidgetKeysService (it imports ConfigThemeModule
  already, so the guard + controller still get it via DI).
- .gitignore data/widget.json + data/widget-backups/ so each environment
  auto-generates its own instance-specific key instead of sharing one via git.

TODO (flagged, out of scope for this pass):
- Protect admin endpoints with an auth guard when settings UI ships.
- Set WIDGET_SECRET env var in staging (currently falls back to the
  hardcoded default in widget-keys.service.ts).
- Admin portal settings page for editing widget config (mirror branding-settings).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 17:33:25 +05:30
aa41a2abb7 feat: widget chat with generative UI, branch selection, captcha gate, lead dedup
- Streaming AI chat via Vercel AI SDK v6 UI message stream — tool-based
  generative UI (pick_branch, list_departments, show_clinic_timings,
  show_doctors, show_doctor_slots, suggest_booking). Typing indicator,
  markdown suppressed, text parts hidden when widgets are rendered.
- Centralized Preact store (store.tsx) for visitor, leadId, captchaToken,
  bookingPrefill, doctors roster, branches, selectedBranch — replaces prop
  drilling across chat/book/contact tabs.
- Cloudflare Turnstile captcha gate rendered via light-DOM portal so it
  renders correctly inside the shadow DOM (Turnstile CSS doesn't cross
  shadow boundaries).
- Lead dedup helper (findOrCreateLeadByPhone, 24h phone window) shared
  across chat-start / book / contact so one visitor == one lead. Booking
  upgrades existing lead status NEW → APPOINTMENT_SET via updateLeadStatus.
- Pre-chat name+phone form captures the visitor; chat transcript logged
  to leadActivity records after each stream.
- Booking wizard gains a branch step 0 (skipped for single-branch
  hospitals); departments + doctors filtered by selectedBranch. Chat slot
  picks prefill the booking details step and lock the branch.
- Window-level captcha gate, modal maximize mode, header badge showing
  selected branch, widget font inherits from host page (fix :host { all:
  initial } override).
- 23 FA Pro 7.1 duotone icons bundled — medical departments, nav, actions,
  hospital/location-dot for branch context.
- main.ts: resolve public/ from process.cwd() so widget.js serves in both
  dev and prod. tsconfig: exclude widget-src/public/data from server tsc.
- captcha.guard: switch from reCAPTCHA v3 to Cloudflare Turnstile verify.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 16:04:46 +05:30
517b2661b0 chore: move widget source into sidecar repo (widget-src/)
Widget builds from widget-src/ → public/widget.js
Vite outDir updated to ../public
.gitignore excludes node_modules

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 06:59:54 +05:30
76fa6f51de feat: website widget + omnichannel lead webhooks
Widget (embeddable):
- Preact + Vite library mode → 35KB IIFE bundle served from sidecar
- Shadow DOM for CSS isolation, themed from sidecar theme API
- AI chatbot (streaming), appointment booking (4-step wizard), lead capture form
- FontAwesome Pro duotone SVGs bundled as inline strings
- HMAC-signed site keys (Redis storage, origin validation)
- Captcha guard (Cloudflare Turnstile ready)

Sidecar endpoints:
- GET/PUT/DELETE /api/widget/keys/* — site key management
- GET /api/widget/init — theme + config (key-gated)
- GET /api/widget/doctors, /slots — doctor list + availability
- POST /api/widget/book — appointment booking (captcha-gated)
- POST /api/widget/lead — lead capture (captcha-gated)

Omnichannel webhooks:
- POST /api/webhook/facebook — Meta Lead Ads (verification + lead ingestion)
- POST /api/webhook/google — Google Ads lead form extension
- POST /api/webhook/whatsapp — Ozonetel WhatsApp callback (receiver ready)
- POST /api/webhook/sms — Ozonetel SMS callback (receiver ready)

Infrastructure:
- SessionService.setCachePersistent() for non-expiring Redis keys
- Static file serving from /public (widget.js)
- WidgetModule registered in AppModule

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 06:49:02 +05:30
8cc1bdc812 feat: theme config service — REST API with versioning + backup
- ThemeService: read/write/validate theme.json, auto-backup on save
- ThemeController: GET/PUT/POST /api/config/theme (public GET, versioned PUT)
- ThemeConfig type with version + updatedAt fields
- Default theme: Global Hospital blue scale
- ConfigThemeModule registered in AppModule

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 15:50:51 +05:30
f231f6fd73 feat: supervisor AI — 4 tools + dedicated system prompt
- get_agent_performance: call counts, conversion, NPS, threshold breaches
- get_campaign_stats: lead counts, conversion per campaign
- get_call_summary: aggregate stats by period with disposition breakdown
- get_sla_breaches: missed calls past SLA threshold
- Supervisor system prompt: unbiased, data-grounded, threshold-based
- Context routing: supervisor/rules-engine/agent tool sets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 13:05:32 +05:30