124 Commits

Author SHA1 Message Date
a837c95d8c fix: contact form creates Lead only, not Patient
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Widget contact + chat-start were creating Patient records for new
visitors. Patient should only be created during appointment booking.
Added createPatient flag to findOrCreateLeadByPhone — defaults to
false, only bookAppointment passes true.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 16:41:11 +05:30
ac76ef5487 feat: add telephonyEnabled to ui-flags endpoint
TELEPHONY_ENABLED env var (default true). When set to false, frontend
hides call center nav and shows CRM-only mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 16:38:18 +05:30
99954c1ff2 fix: widget build outputs to ./dist instead of sidecar public/
Prevents accidental overwrites of the working widget.js. Use
'npm run deploy' to explicitly build + copy to sidecar public/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 14:26:03 +05:30
4b84792619 fix: instant widget lead assignment + SSE notification
Widget leads were invisible to agents for up to 90s (60s auto-assign
poll + 30s worklist poll). Now triggers immediate auto-assign after
lead creation and emits SSE worklistUpdate so agents see new widget
leads and appointments instantly.

Also excluded packages/ from tsconfig build to prevent widget source
compilation errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 13:08:59 +05:30
9890559ec1 fix: append IST offset (+05:30) to bare datetime in appointment booking
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Widget and WhatsApp flows send scheduledAt without timezone offset,
causing platform to interpret as UTC (10:00 shows as 3:30 PM IST).
Server now appends +05:30 if no timezone indicator present. Also fixed
in WhatsApp slot ID generation and widget source.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 11:14:24 +05:30
9cb4d1c122 docs: website widget operations guide + archive widget source
- Comprehensive docs: embed snippet, key management, API endpoints,
  chat/booking/contact flows, lead dedup, reCAPTCHA, branding, deploy
  checklist, troubleshooting
- Widget Preact source archived in packages/widget-src/ (was only on
  local machine, not tracked in any repo)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 06:39:23 +05:30
014b27cf90 fix: restore full widget.js with chat-start flow from aa41a2a
The rebuild from packages/helix-engage-widget/ produced an older version
without chat-start/leadId support. Restored the working version from
commit aa41a2a which has the complete chat flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 06:21:17 +05:30
826ced1e62 feat: include widget.js in Docker image for embed script serving
Added COPY public ./public to Dockerfile so the Preact embed widget
is served via NestJS static assets at /widget.js.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 05:51:30 +05:30
bbea12185d feat: Claude skill for generating WhatsApp flow JSON definitions
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Skill documents the full flow schema (Groups, Blocks, Edges, Variables),
all available tools, WhatsApp constraints, system variables, and
deployment steps. Enables generating new flows from natural language
descriptions — e.g., "create a prescription refill flow".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 21:31:00 +05:30
f1c026cf7a fix(flow): serialize per-phone execution to prevent concurrent flows
Two messages arriving close together could start two parallel flow
executions for the same phone. The second would create a new session
while the first was mid-AI-block, causing duplicate greetings and
race conditions. Per-phone async lock ensures sequential execution.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 20:48:55 +05:30
d819888351 feat: appointment QR code — generated and sent via WhatsApp after booking
- QrService: generates QR PNG from appointment data, cached in-memory
- GET /api/messaging/qr/:appointmentId serves the image (Gupshup needs URL)
- sendImage added to MessagingProvider + GupshupProvider
- send_appointment_qr tool registered in ToolRegistry
- Flow JSON updated: QR sent after booking confirmation
- Variable interpolation now supports dot notation ({{result.field}})
- SIDECAR_PUBLIC_URL env var for the QR image URL

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 20:23:06 +05:30
300fff25c1 feat(flow): handle 'Choose Another Date' with AI date parsing
Added g4t (tomorrow), g4c (custom date) groups. Custom date asks
patient to type a date, AI block parses it to YYYY-MM-DD. Three
condition branches now: tomorrow, day_after, other.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 19:32:31 +05:30
9ee087b898 fix: extract datetime from slot selection ID before booking
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
scheduledAt was passed as raw "slot:{id}:{datetime}" — platform rejected
it. Added extract_datetime expression to pull the ISO datetime from the
third segment. Updated flow JSON with SetVariableBlock after slot input.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 19:05:47 +05:30
963cf28d23 fix: link patientId to appointment in WhatsApp booking
Appointments created via WhatsApp had null patientId — lookup_appointments
couldn't find them. Now resolves patient before booking and includes
patientId in the createAppointment mutation. Fixed in both flow tool
registry and legacy messaging service.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 18:56:39 +05:30
903e82b536 fix: include default-flows JSON in nest build assets
nest build only compiles TS — JSON files need explicit asset copy
in nest-cli.json compilerOptions.assets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 18:48:22 +05:30
2e0527e1d8 feat: config-driven flow runtime engine for WhatsApp conversations
Groups + Blocks execution model adapted from Typebot:
- FlowExecutionService: walks through groups/blocks, pauses at InputBlocks
- FlowSessionService: Redis-backed session state (24h TTL)
- FlowStoreService: loads flow definitions from data/flows/ JSON files
- FlowVariableService: {{variable}} interpolation + expressions
- ToolRegistry: registered tool handlers (departments, doctors, slots, booking)
- Default appointment-booking.json flow seeded on first run

MessagingService delegates to flow engine when published flows exist,
falls back to hardcoded AI chat otherwise (backward compatible).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 18:27:29 +05:30
4549241b78 docs: flow runtime design spec — config-driven WhatsApp conversation engine
Groups + Blocks model adapted from Typebot. Execution loop pauses at
InputBlocks, resumes on next message. Tool registry bridges existing
tools. Session state in Redis with 24h TTL.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 18:18:17 +05:30
6a3834a7eb feat(messaging): conflict check before booking appointment
Check for duplicate patient+doctor+date and max 3 slots per time before
creating the appointment. Returns actionable message if conflict found.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:46:27 +05:30
6847f5de95 fix(messaging): appointment field is 'status' not 'appointmentStatus'
Matched the working agent tool mutation — platform expects 'status'
for AppointmentCreateInput, not 'appointmentStatus'.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:44:56 +05:30
d857a0b270 fix(messaging): truncate WhatsApp list section titles to 24 char limit
WhatsApp list messages have strict limits: section title max 24 chars,
row title max 24 chars. Long doctor names + dates in section titles
caused Gupshup to silently drop the list options.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:37:30 +05:30
214cc60917 fix(messaging): teach AI to parse selection_id format for tool dispatch
AI wasn't calling send_slot_list after doctor selection because it didn't
know how to extract doctorId from "doc:{uuid}:{name}" format. Updated
system prompt with explicit selection_id parsing instructions for each
step of the booking flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:31:20 +05:30
c4c437abd6 fix(messaging): parse postbackText from Gupshup list_reply, pass selection ID to AI
Gupshup list_reply has empty id field — postbackText carries our ID.
Fixed ?? to || fallback. Also inject selection_id into user message so
AI can extract doctorId from "doc:{uuid}:{name}" format.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:25:35 +05:30
b1922809d0 fix(messaging): generate hourly slots from visitSlots day-of-week data
Was reading non-existent availableSlots field. Now reads raw visitSlots,
matches target date's day-of-week, and generates hourly time slots from
startTime to endTime. Doctors with 08:00-20:00 get 10 hourly options.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:19:09 +05:30
8aae95e8cc fix(messaging): directive prompt — force interactive lists, add hospital name
AI was asking "which department?" in text instead of sending the
interactive list. Updated system prompt to be prescriptive: IMMEDIATELY
call send_department_list, never ask in text. Also injects hospital name
from theme config.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 15:09:19 +05:30
2c947517af feat: WhatsApp AI assistant — provider-agnostic messaging with Gupshup
Provider-agnostic WhatsApp integration for AI-driven appointment booking:

- MessagingProvider interface (sendText, sendButtons, sendList, parseInbound)
- GupshupProvider implementation (Gupshup WhatsApp API)
- MessagingService — AI orchestration with tools (department/doctor/slot
  lists via interactive WhatsApp messages, appointment booking, caller
  resolution + context injection)
- Redis conversation history (24h TTL, matches WhatsApp session window)
- Webhook controller at POST /api/messaging/webhook

Swappable to Ozonetel or Meta Cloud API by implementing MessagingProvider
and switching MESSAGING_PROVIDER env var.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 14:45:26 +05:30
Kartik Datrika
473183869a Merge branch 'hardening/apr-week2' of https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server into hardening/apr-week2 2026-04-20 14:40:31 +05:30
3bb4315925 fix: persist LOGIN events for session rollup — fixes zero dashboard metrics
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
History event persistence was gated behind if(mapped), but login returns
null for state (UI waits for release). LOGIN events were never written
to AgentEvent table → rollup computed 0 for loginDuration → idle/pause/
wrap all zero. Moved history persistence outside the state mapping gate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 10:30:46 +05:30
350fcdd926 fix: persist LOGIN events for session rollup — fixes zero dashboard metrics
History event persistence was gated behind if(mapped), but login returns
null for state (UI waits for release). LOGIN events were never written
to AgentEvent table → rollup computed 0 for loginDuration → idle/pause/
wrap all zero. Moved history persistence outside the state mapping gate.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 10:29:44 +05:30
7402590969 fix: always include Health Packages section in KB — empty state says "not configured"
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
When zero active packages exist, the KB silently omitted the section.
AI then responded "I couldn't find that" for package queries. Now always
includes the section header with explicit "not configured" guidance.

Also removed contradicting rule 8 (bullet points) — conflicts with the
structured output format that requires plain text sentences.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 17:50:36 +05:30
3f22166ac0 fix: dispose creates inbound Call record, webhook enriches — eliminates UCID mismatch + timing race
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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>
2026-04-18 08:31:07 +05:30
8c8b1e78b0 feat: caller context cache invalidation endpoint
- CallerContextService: added invalidateCache(leadId) method
- CallerResolutionController: POST /api/caller/invalidate-context
  endpoint — frontend calls after appointment mutations to bust
  stale AI context cache

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 05:29:56 +05:30
77b3e917db fix: fetch Lead first to resolve patientId before appointments query
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
The build() method previously fetched Lead and Appointments in parallel.
When the input patientId was empty (outbound dial, first-time linkage),
the appointments query was skipped even though the Lead record in the DB
had a valid patientId. Now fetches Lead first, reads its patientId, then
fetches appointments/calls/activities in parallel with the correct ID.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 16:23:48 +05:30
68ba3e135d fix: remove example from schema description — AI was copying it verbatim
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 11:58:59 +05:30
e1babb30e5 fix: AI message formatting — plain text sentences, no markdown/data dump
Schema description reinforced: brief 2-3 sentence natural language only.
Prompt template updated with example output and explicit ban on markdown
headers, bold, bullet lists, and raw field labels in the message field.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 11:46:44 +05:30
ae360a183d feat: enforce structured JSON output via AI SDK Output.object
- ai-response-schema.ts: Zod schema for { message, suggestions[] }
- ai-chat.controller.ts: Output.object({ schema }) on streamText
  forces the LLM to return valid JSON matching the schema instead
  of free-form prose. Supervisor mode excluded (uses tools, not schema).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 11:40:25 +05:30
e03b1e6235 feat: structured JSON output + suggestion rules in AI system prompt
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 11:11:13 +05:30
2d18110786 feat: suggestion rules engine + caller context evaluation
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 11:09:47 +05:30
a576552f8a feat: pre-fetched caller context replaces tool-based patient lookups
- CallerContextService: fetches lead profile, appointments, call history,
  activities in parallel. Caches in Redis (5 min TTL). Renders as
  human-readable KB section — no UUIDs exposed to the LLM.
- Caller resolution controller: prewarms context cache on resolve
  (fire-and-forget) so the AI stream has a cache hit.
- AI chat stream: injects caller context into system prompt KB instead
  of raw Lead ID. LLM answers patient questions from context, no tool
  calls needed for current caller data.
- Eliminates UUID hallucination: LLM never sees leadId or patientId,
  can't pass wrong ID to wrong tool parameter.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 09:56:18 +05:30
b11f4ea336 feat: log backfill endpoint for desktop log panel
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
- LogStreamService: ring buffer (500 entries) + getRecentLogs() method
- SupervisorController: GET /api/supervisor/logs/recent returns buffered
  log entries so the desktop log panel shows history on tab open, not
  just live stream

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 08:51:55 +05:30
96ae867288 feat: server log streaming via SSE for desktop log panel
- LogStreamService: singleton that extends ConsoleLogger, captures all
  NestJS log output into an RxJS Subject while preserving stdout
- main.ts: uses LogStreamService.instance as app logger
- supervisor.controller.ts: new @Sse('logs/stream') endpoint pipes
  log entries (timestamp, level, context, message) to connected clients

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 08:22:11 +05:30
9a016a2ed0 feat: real-time active call SSE — hold/unhold status for supervisor live monitor
- SupervisorService: added activeCallSubject (RxJS Subject), emits on all
  activeCalls Map mutations (Answered, Calling, Disconnect, Hold, Unhold)
- SupervisorController: new @Sse('active-calls/stream') endpoint
- OzonetelAgentController: callControl HOLD/UNHOLD updates activeCalls Map
  status via supervisor.updateCallStatus()

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-17 05:45:14 +05:30
9cf0f69dde feat: SSE push for worklist updates — instant missed-call notifications
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
New worklist SSE stream replaces the 30s frontend poll. When the
missed-call webhook creates a Call record, it emits a worklist-updated
event via the supervisor's worklistSubject. All connected agents
receive the event immediately.

- supervisor.service.ts: worklistSubject + emitWorklistUpdate()
- supervisor.controller.ts: @Sse('worklist/stream') broadcast endpoint
- missed-call-webhook.controller.ts: emits after createCall() with
  callerPhone + callerName for toast notification
- worklist.module.ts: imports SupervisorModule (forwardRef)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 18:32:57 +05:30
a6f4c51ca9 fix: disposition for answered inbound calls + SLA timing wiring + backfill
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>
2026-04-16 18:02:49 +05:30
2d8308bed8 fix: remove hardcoded Inbound_918041763265 campaign fallback
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
The default campaign name was hardcoded to 'Inbound_918041763265'.
After the Ozonetel campaigns were renamed (Inbound_918041763265 →
Global, Inbound_918041763400 → Ramaiah), agent login/dial would
break because the old name doesn't exist on Ozonetel anymore.

Campaign name now comes exclusively from the Agent entity's
campaignName field (per-agent) or the OZONETEL_CAMPAIGN_NAME env
var (per-workspace). No hardcoded fallback.
2026-04-16 17:33:35 +05:30
2666a10f48 fix: await Ozonetel logout + per-agent sipPassword + campaign name on missed calls
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>
2026-04-16 16:54:08 +05:30
a00668c517 feat(ai): UUID-safe agent tools + lookup_lead_activities + tool logging
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Bug 553 (partial) — AI Panel 'Patient History' returned 'not in system'
even though the caller had 7 calls + an appointment. The model was
hallucinating instead of chaining lookup tools.

UUID safety: LLMs drop hyphens / swap chars on 36-char ids once the
context wears thin. To keep the model off the UUID path for 'this
caller' questions:
 - lookup_appointments, lookup_call_history, lookup_lead_activities
   now accept their id arguments OPTIONALLY
 - when omitted, the sidecar resolves leadId from ctx and patientId
   from the lead record (cached per-request)
 - new lookup_lead_activities tool rounds out the patient-history
   trio (call history + activity log + appointments)

System prompt (ccAgentHelper) tightened:
 - chain call history + activities + appointments for history questions
 - call lookup tools with NO arguments when using the current caller
 - don't re-type UUIDs seen in CURRENT CONTEXT
 - say 'feature not set up yet' when KB section is empty (packages,
   etc.) instead of 'I couldn't find that'

All agent tools now emit structured [AI-TOOL] trace lines with full
UUIDs printed — tail sidecar logs to see which tool the model chose,
whether the model passed an id or used the context fallback, and how
many records came back. If the model ever hallucinates a UUID, the
resolved= field on the log line will echo it and count=0 will flag
the miss immediately.
2026-04-16 05:39:08 +05:30
a1413aae40 fix(supervisor): sweep stale activeCalls before returning to Live Monitor
Bug 560: Live Call Monitor showed ghost calls with runaway timers when
the agent wasn't on a call. Cause — activeCalls Map only added on
'Answered' and deleted on 'Disconnect'; a missed Disconnect (sidecar
restart, Ozonetel subscription hiccup, network blip) left the entry
lingering forever.

getActiveCalls() now sweeps stale entries before returning:
 - drop if startTime is older than 30 minutes
 - drop if the mapped agent is currently ready / offline / paused
   (agent can't be on a call in any of those states)

Each sweep logs the reason so we can track how often this fires.
2026-04-16 05:38:52 +05:30
6adb3985cb feat(config): ui-flags endpoint driven by HELIX_SETUP_MANAGED
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Per-tenant flag that hides self-serve setup surfaces when the product team owns onboarding for a workspace. Set HELIX_SETUP_MANAGED=true on the sidecar env for that tenant; the frontend reads this endpoint at boot, hides the Settings nav + Setup banner, and blocks /settings/* routes. Setup-state APIs stay live so ops can still drive the wizard remotely.
2026-04-15 18:55:25 +05:30
67c41f4783 feat(maint): session-status endpoint for agent picker
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
2026-04-15 18:55:18 +05:30
d459d6469a fix(worklist): include patientId in assigned-leads query
Scenario: unknown caller books appointment (creates Patient), calls again, caller resolver links Lead↔Patient. On the second call the frontend found the lead in the worklist cache but it lacked patientId — so Book Appt pills couldn't find the prior appointment. The resolver had the right patientId; the worklist didn't.

Adding patientId to the GraphQL selection so the cached row carries it end-to-end.
2026-04-15 18:55:08 +05:30
60d2329dd8 fix(call-attribution): resolve Ozonetel chain AgentNames to agent.id
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
2026-04-15 18:55:00 +05:30
f375e7736c fix(my-performance): LOGIN TIME uses AgentSession rollup, not Ozonetel summary
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>
2026-04-15 12:25:23 +05:30
96977e84a1 feat(maint): backfill-appointment-clinics endpoint
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
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>
2026-04-15 12:01:08 +05:30
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
1d1f27607f feat: caller cache invalidation endpoint + worklist auth fix
- POST /api/caller/invalidate — clears Redis cache for a phone number
- WorklistController: resolves agent name from login cache (avoids currentUser query)
- AuthController: caches agent name in Redis during login (keyed by token suffix)
- WorklistModule: imports AuthModule (forwardRef for circular dep)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 12:14:56 +05:30
d0df6618b5 chore: track caller resolution module
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 17:13:35 +05:30
5e3ccbd040 feat: transcription fix + SLA write-back + real-time supervisor events
- Deepgram: multichannel=true + language=multi (captures both speakers, multilingual)
- LLM speaker identification (agent vs customer from conversational cues)
- Removed summarize=v2 (incompatible with multilingual)
- SLA computation on call creation (lead.createdAt → call.startedAt elapsed %)
- WebSocket: supervisor room + call:created broadcast for real-time updates
- Maint: clear-analysis-cache endpoint + scanKeys/deleteCache on SessionService
- AI chat: rules-engine context routing with dedicated system prompt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 16:59:23 +05:30
b8556cf440 feat: rules engine — json-rules-engine integration with worklist scoring
- Self-contained NestJS module: types, storage (Redis+JSON), fact providers, action handlers
- PriorityConfig CRUD (slider values for task weights, campaign weights, source weights)
- Score action handler with SLA multiplier + campaign multiplier formula
- Worklist consumer: scores and ranks items before returning
- Hospital starter template (7 rules)
- REST API: /api/rules/* (CRUD, priority-config, evaluate, templates)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 16:59:10 +05:30
7b59543d36 feat: streaming AI chat endpoint with tool calling
- POST /api/ai/stream: streamText with tools, streams response via toTextStreamResponse
- Tools: lookup_patient, lookup_appointments, lookup_doctor (same as existing chat endpoint)
- Uses stopWhen(stepCountIs(5)) for multi-step tool execution
- Streams response body to Express response manually (NestJS + AI SDK v6 compatibility)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:27:24 +05:30
3e2e7372cc feat: event bus with Redpanda + AI insight consumer
- EventBusService: Kafka/Redpanda pub/sub with kafkajs, graceful fallback when broker unavailable
- Topics: call.completed, call.missed, agent.state
- AiInsightConsumer: on call.completed, fetches lead activity → OpenAI generates summary + suggested action → updates Lead entity on platform
- Disposition endpoint emits call.completed event after Ozonetel dispose
- EventsModule registered as @Global for cross-module injection
- Redpanda container added to VPS docker-compose
- Docker image rebuilt for linux/amd64 with kafkajs dependency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 09:38:02 +05:30
3c06a01e7b feat: LiveKit AI answering agent (Gemini 2.5 Flash native audio)
- Hospital receptionist agent "Helix" with Gemini realtime speech-to-speech
- Tools wired to platform: lookupDoctor, bookAppointment, collectLeadInfo, transferToAgent
- Loads hospital context (doctors, departments) from platform GraphQL on startup
- Connects to LiveKit Cloud, joins rooms when participants connect
- Silero VAD for voice activity detection
- @livekit/agents + @livekit/agents-plugin-google + @livekit/agents-plugin-silero

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 20:30:41 +05:30
fcc7c90e84 feat: recording analysis module with Deepgram + AI insights + Redis cache
- RecordingsModule: POST /api/recordings/analyze
- Deepgram pre-recorded API: diarize, summarize, topics, sentiment, utterances
- AI insights via OpenAI generateObject: call outcome, coaching, compliance, satisfaction
- Redis cache: 7-day TTL per callId, check before hitting Deepgram/OpenAI
- Generic getCache/setCache added to SessionService for cross-module use

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 09:20:15 +05:30
eb4000961f feat: SSE agent state, maint module, timestamp fix, missed call lead lookup
- 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>
2026-03-24 22:04:31 +05:30
d3331e56c0 fix: Ozonetel token 10min cache + invalidate on 401 + force re-login on already logged in
- 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>
2026-03-24 18:49:26 +05:30
fd08a5d5db fix: Ozonetel token — 10min cache, invalidate on 401, refresh on login
- 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>
2026-03-24 15:22:19 +05:30
2e4f97ff1a feat: supervisor module — team performance + active calls endpoints
- SupervisorService: aggregates Ozonetel agent summary across all agents,
  tracks active calls from real-time events
- GET /api/supervisor/team-performance — per-agent time breakdown + thresholds
- GET /api/supervisor/active-calls — current active call map
- POST /api/supervisor/call-event — Ozonetel event webhook
- POST /api/supervisor/agent-event — Ozonetel agent event webhook

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:53:49 +05:30
a35a7d70bf feat: session lock stores IP + timestamp for debugging
- SessionService stores JSON { memberId, ip, lockedAt } instead of plain memberId
- Auth controller extracts client IP from x-forwarded-for header
- Lockout error message includes IP of blocking device

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 13:21:13 +05:30
77c5335955 fix: strict duplicate login lockout — one device per agent
Block any login attempt when a session exists, regardless of user identity.
Same user on second device is blocked until logout or TTL expiry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:44:56 +05:30
e4a24feedb feat: multi-agent SIP with Redis session lockout
- SessionService: Redis-backed session lock/unlock/refresh with 1hr TTL
- AgentConfigService: queries Agent entity, caches per-member config
- Auth login: resolves agent config, locks Redis session, returns SIP credentials
- Auth logout: unlocks Redis session, Ozonetel logout, clears cache
- Auth heartbeat: refreshes Redis TTL every 5 minutes
- Added ioredis dependency

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 21:24:32 +05:30
4b5edc4e55 fix: appointmentStatus→status, missed call visibility, webhook callbackstatus, KB logging
- Renamed appointmentStatus to status in search + call-assist queries
- Missed calls worklist: removed agentName filter (shared FIFO queue)
- Webhook sets callbackstatus: PENDING_CALLBACK on missed calls
- AI chat: added KB content logging for debugging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:42:38 +05:30
0b98d490f0 fix: use HH:MM:SS format for Ozonetel abandonCalls time params
Ozonetel API expects time-of-day format (HH:MM:SS), not full datetime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 12:50:57 +05:30
30a4cda178 feat: add token refresh endpoint for auto-renewal
POST /auth/refresh exchanges refresh token for new access token
via platform's renewToken mutation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 11:53:02 +05:30
feedec0588 docs: add team onboarding README with architecture and troubleshooting guide
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 09:47:15 +05:30
cec2526d37 feat: Phase 2 — missed call queue ingestion, auto-assignment, endpoints
- 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>
2026-03-23 09:17:33 +05:30
4963a698d9 feat: agent state endpoint + search module
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 14:21:40 +05:30
189 changed files with 29241 additions and 1810 deletions

View File

@@ -0,0 +1,203 @@
# Generate WhatsApp Flow
Generate a config-driven WhatsApp conversation flow JSON for the Helix Engage flow runtime engine.
## When to use
When the user asks to create a new WhatsApp flow, chatbot flow, or conversation automation — e.g., "create a WhatsApp flow for prescription refills", "build a feedback collection flow", "add a lab report flow".
## Flow Runtime Architecture
The flow engine reads JSON flow definitions from `src/messaging/flow/default-flows/` and executes them at runtime. Each flow is a graph of **Groups** (containers) containing **Blocks** (steps), connected by **Edges**.
### Execution Model
```
Inbound WhatsApp message → match flow by trigger → create/resume session
→ walk forward through Groups → Blocks:
MessageBlock → send text/buttons/list to patient
InputBlock → PAUSE, wait for next message
ConditionBlock → evaluate variable, follow matching edge
SetVariableBlock → assign/transform variable
ToolCallBlock → call registered tool
AIBlock → generate LLM response
JumpBlock → jump to another group
→ End of group → follow outgoing edge → next group
→ No more edges → flow complete, session cleared
```
Session state stored in Redis with 24h TTL. Per-phone execution lock prevents concurrent flows.
### Flow JSON Schema
```typescript
type Flow = {
id: string; // "flow-{kebab-name}"
name: string; // Human-readable name
description: string; // Admin-facing description
trigger: FlowTrigger; // What starts this flow
groups: Group[]; // Ordered containers of blocks
edges: Edge[]; // Connections between blocks/groups
variables: VariableDefinition[];// Flow-scoped variables
version: number; // Start at 1
status: 'draft' | 'published'; // Only published flows execute
};
type FlowTrigger =
| { type: 'message'; conditions?: { keywords?: string[]; regex?: string } }
| { type: 'default' }; // Catch-all when no other flow matches
type Group = {
id: string; // "g1", "g2", etc.
title: string; // "Greeting", "Department Selection"
blocks: Block[]; // Executed in order
};
type Edge = {
id: string; // "e1", "e2", etc.
from: { blockId: string; conditionId?: string };
to: { groupId: string; blockId?: string };
};
type VariableDefinition = {
id: string; // "v1", "v2", etc.
name: string; // "selectedDepartment"
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
defaultValue?: any;
};
```
### Block Types
```typescript
// Send text, buttons, or list to patient
type MessageBlock = {
id: string; type: 'message';
content:
| { format: 'text'; text: string } // Supports {{variables}}
| { format: 'buttons'; text: string; buttons: { id: string; title: string }[] } // Max 3 buttons, title max 20 chars
| { format: 'list'; text: string; buttonText: string; sections: { title: string; rows: { id: string; title: string; description?: string }[] }[] }; // Section title max 24 chars, row title max 24 chars, max 10 rows total
};
// Wait for patient reply — PAUSES execution
type InputBlock = {
id: string; type: 'input';
inputType: 'text' | 'interactive_reply' | 'any';
variableId: string; // Store reply in this variable
validation?: { regex?: string; errorMessage?: string };
};
// Branch based on variable value
type ConditionBlock = {
id: string; type: 'condition';
conditions: {
id: string; // "c1" — used in edge.from.conditionId
variableId: string;
operator: 'equals' | 'contains' | 'exists' | 'not_exists' | 'gt' | 'lt' | 'starts_with';
value?: string; // Supports {{variables}}
}[];
};
// Assign or transform a variable
type SetVariableBlock = {
id: string; type: 'set_variable';
variableId: string;
value: string;
expression?: 'extract_id' | 'extract_datetime' | 'date_tomorrow' | 'date_day_after';
// extract_id: "doc:{uuid}:{name}" → uuid (second segment)
// extract_datetime: "slot:{id}:{datetime}" → datetime (third+ segments, rejoined with :)
// date_tomorrow/date_day_after: computes date string YYYY-MM-DD
};
// Execute a registered tool
type ToolCallBlock = {
id: string; type: 'tool_call';
toolName: string; // Must be a registered tool (see below)
inputs: Record<string, string>; // Values support {{variables}} and {{var.field}} dot notation
outputVariableId?: string;
};
// Generate dynamic LLM response
type AIBlock = {
id: string; type: 'ai';
prompt: string; // Supports {{variables}}
outputVariableId?: string;
sendToPatient: boolean; // true = send as WhatsApp message
};
// Jump to another group
type JumpBlock = {
id: string; type: 'jump';
targetGroupId: string;
};
```
### Available Tools (ToolRegistry)
| Tool Name | Description | Inputs | Output |
|---|---|---|---|
| `resolve_caller` | Phone → Lead + Patient | phone? (defaults to current) | { leadId, patientId, isNew, phone } |
| `send_department_list` | Interactive department list | (none) | { sent, departments[] } |
| `send_doctor_list` | Interactive doctor list | department | { sent, count } |
| `send_slot_list` | Time slots for doctor+date | doctorId, doctorName, date | { sent, slots } |
| `send_confirm_buttons` | Confirm/Cancel buttons | summary | { sent } |
| `book_appointment` | Book with conflict check | patientName, phoneNumber, department, doctorName, scheduledAt, reason | { booked, appointmentId, reference } |
| `lookup_appointments` | Check existing appointments | (none — uses current caller) | { appointments[] } |
| `send_appointment_qr` | Generate and send QR code | appointmentId, reference, patientName, doctorName, department, scheduledAt | { sent, qrUrl } |
### System Variables (auto-injected)
| Variable | Description |
|---|---|
| `_initialMessage` | The first message the patient sent |
| `_senderName` | WhatsApp profile name |
| `_phone` | Phone number (E.164 without +) |
| `_callerName` | Resolved patient name from platform |
| `_leadId` | Lead ID if exists |
| `_patientId` | Patient ID if exists |
| `_isNew` | true if no prior records |
### Variable Interpolation
- `{{variableName}}` — simple substitution
- `{{result.fieldName}}` — dot notation for object fields (e.g., `{{bookingResult.appointmentId}}`)
- Interactive reply IDs stored in `variableId`, display titles in `variableId_title`
### WhatsApp Constraints
- Button title: max 20 characters
- List section title: max 24 characters
- List row title: max 24 characters
- List row description: max 72 characters
- Max 3 buttons per message
- Max 10 list rows total across all sections
- No markdown in text messages (plain text only)
- Interactive messages only work within 24h session window
## How to Generate
1. **Ask the user** what the flow should do — purpose, steps, what data to collect
2. **Design the groups** — each logical phase is a group (Greeting, Selection, Confirmation, etc.)
3. **Define variables** — what data flows through the conversation
4. **Build blocks** — MessageBlocks for output, InputBlocks to pause for reply, ConditionBlocks for branching, ToolCallBlocks for platform operations, AIBlocks for dynamic responses
5. **Wire edges** — connect groups via edges, condition edges for branching
6. **Write the JSON** to `src/messaging/flow/default-flows/{flow-name}.json`
7. **Register new tools** if needed in `src/messaging/flow/tool-registry.ts`
## Reference
See `src/messaging/flow/default-flows/appointment-booking.json` for a complete working example with:
- AI greeting
- Intent routing (book / check / question)
- Interactive lists (departments, doctors, slots)
- Date selection with custom date AI parsing
- Confirmation buttons
- Booking with conflict check
- QR code generation
## Deployment
After creating the flow JSON:
1. `npm run build` — verifies the JSON is copied to dist (via nest-cli.json assets)
2. Deploy to EC2 — the flow store auto-seeds on first run if `data/flows/` is empty
3. If updating an existing flow: `docker exec sidecar cp /app/dist/.../flow.json /app/data/flows/flow-id.json && docker compose restart sidecar`

View File

@@ -1,3 +1,14 @@
# Build artifacts and host-installed deps — the multi-stage Dockerfile
# rebuilds these inside the container for the target platform, so the
# host copies must NOT leak in (would clobber linux/amd64 binaries
# with darwin/arm64 ones).
dist
node_modules
# Secrets and local state
.env
.env.local
.git
src
# Local data dirs (Redis cache file, setup-state, etc.)
data

5
.gitignore vendored
View File

@@ -37,3 +37,8 @@ lerna-debug.log*
# Environment
.env
# Widget config — instance-specific, auto-generated on first boot.
# Each environment mints its own HMAC-signed site key.
data/widget.json
data/widget-backups/

24
.woodpecker.yml Normal file
View File

@@ -0,0 +1,24 @@
# Woodpecker CI pipeline for Helix Engage Server (sidecar)
when:
- event: [push, manual]
steps:
unit-tests:
image: node:20
commands:
- npm ci
- npm test -- --ci --forceExit
notify-teams:
image: curlimages/curl
environment:
TEAMS_WEBHOOK:
from_secret: teams_webhook
commands:
- >
curl -s -X POST "$TEAMS_WEBHOOK"
-H "Content-Type:application/json"
-d '{"type":"message","attachments":[{"contentType":"application/vnd.microsoft.card.adaptive","content":{"type":"AdaptiveCard","version":"1.4","body":[{"type":"TextBlock","size":"Medium","weight":"Bolder","text":"Helix Engage Server — Build #'"$CI_PIPELINE_NUMBER"'"},{"type":"TextBlock","text":"Branch: '"$CI_COMMIT_BRANCH"'","wrap":true},{"type":"TextBlock","text":"'"$(echo $CI_COMMIT_MESSAGE | head -c 80)"'","wrap":true}],"actions":[{"type":"Action.OpenUrl","title":"View Pipeline","url":"https://operations.healix360.net/repos/2/pipeline/'"$CI_PIPELINE_NUMBER"'"}]}}]}'
when:
- status: [success, failure]

View File

@@ -1,7 +1,61 @@
# syntax=docker/dockerfile:1.7
#
# Multi-stage build for the helix-engage sidecar.
#
# Why multi-stage instead of "build on host, COPY dist + node_modules"?
# The host (developer Mac, CI runner) is rarely the same architecture
# as the target (linux/amd64 EC2 / VPS). Copying a host-built
# node_modules brings darwin-arm64 native bindings (sharp, livekit,
# fsevents, etc.) into the runtime image, which crash on first import.
# This Dockerfile rebuilds inside the target-platform container so
# native bindings are downloaded/compiled for the right arch.
#
# The build stage runs `npm ci` + `nest build`, then `npm prune` to
# strip dev deps. The runtime stage carries forward only `dist/`,
# the pruned `node_modules/`, and `package.json`.
# --- Builder stage ----------------------------------------------------------
FROM node:22-slim AS builder
WORKDIR /app
# Build deps for any native modules whose prebuilt binaries miss the
# target arch. Kept minimal — node:22-slim already ships most of what's
# needed for the deps in this project, but python/make/g++ are the
# canonical "I might need to gyp-rebuild" trio.
RUN apt-get update \
&& apt-get install -y --no-install-recommends \
python3 \
make \
g++ \
&& rm -rf /var/lib/apt/lists/*
# Lockfile-only install first so this layer caches when only source
# changes — much faster repeat builds.
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund --loglevel=verbose
# Source + build config
COPY tsconfig.json tsconfig.build.json nest-cli.json ./
COPY src ./src
RUN npm run build
# Strip dev dependencies so the runtime image stays small.
RUN npm prune --omit=dev
# --- Runtime stage ----------------------------------------------------------
FROM node:22-slim
WORKDIR /app
COPY dist ./dist
COPY node_modules ./node_modules
COPY package.json ./
# Bring across only what the runtime needs. Source, dev deps, build
# tooling all stay in the builder stage and get discarded.
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./
# Widget embed script (pre-built, served via NestJS static assets)
COPY public ./public
EXPOSE 4100
CMD ["node", "dist/main.js"]

264
README.md
View File

@@ -1,98 +1,210 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
# Helix Engage Server — Sidecar Backend
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
NestJS sidecar that bridges Ozonetel telephony APIs with the FortyTwo platform. Handles agent auth, call control, disposition, missed call queue, worklist aggregation, AI enrichment, and live call assist.
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
<p align="center">
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
**Owner: Karthik**
## Description
## Architecture
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
## Project setup
```bash
$ npm install
```
┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
│ helix-engage │ │ helix-engage-server │ │ FortyTwo Platform │
│ React frontend │────▶│ (this repo) │────▶│ GraphQL API │
│ │ │ Port 4100 │ │ Port 4000 │
└─────────────────────┘ └──────────────────────┘ └─────────────────────┘
│ Ozonetel CloudAgent APIs
┌──────────────┐
│ Ozonetel │
│ in1-ccaas-api│
└──────────────┘
```
## Compile and run the project
This server has **no database**. All persistent data flows to/from the FortyTwo platform via GraphQL. Ozonetel is the telephony provider (CloudAgent APIs).
**Three repos:**
| Repo | Purpose | Owner |
|------|---------|-------|
| `helix-engage` | React frontend | Mouli |
| `helix-engage-server` (this) | NestJS sidecar | Karthik |
| `helix-engage-app` | FortyTwo SDK app — entity schemas | Shared |
## Getting Started
```bash
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
npm install
npm run start:dev # http://localhost:4100 (watch mode)
npm run build # Production build
npm run start:prod # Run production build
```
## Run tests
### Environment Variables
```bash
# unit tests
$ npm run test
| Variable | Purpose | Default |
|----------|---------|---------|
| `PORT` | Server port | `4100` |
| `CORS_ORIGIN` | Allowed frontend origin | `http://localhost:5173` |
| `PLATFORM_GRAPHQL_URL` | FortyTwo GraphQL endpoint | `http://localhost:4000/graphql` |
| `PLATFORM_API_KEY` | FortyTwo API key (server-to-server) | — |
| `EXOTEL_API_KEY` | Ozonetel API key | — |
| `EXOTEL_API_TOKEN` | Ozonetel API token | — |
| `EXOTEL_ACCOUNT_SID` | Ozonetel account SID | — |
| `OZONETEL_AGENT_ID` | Default agent ID | `agent3` |
| `OZONETEL_AGENT_PASSWORD` | Default agent password | — |
| `OZONETEL_SIP_ID` | Default SIP extension | `521814` |
| `OZONETEL_DID` | Inbound DID number | `918041763265` |
| `OZONETEL_CAMPAIGN_NAME` | Default campaign | `Inbound_918041763265` |
| `MISSED_QUEUE_POLL_INTERVAL_MS` | Missed call ingestion interval | `30000` |
| `OPENAI_API_KEY` | For AI enrichment / call assist | — |
| `ANTHROPIC_API_KEY` | Alternative AI provider | — |
| `DEEPGRAM_API_KEY` | Live transcription (STT) | — |
# e2e tests
$ npm run test:e2e
## Module Structure
# test coverage
$ npm run test:cov
```
src/
├── ozonetel/ # ⚡ Ozonetel telephony — WHERE MOST WORK HAPPENS
│ ├── ozonetel-agent.controller.ts # REST endpoints for agent operations
│ ├── ozonetel-agent.service.ts # Ozonetel API wrapper (token, CDR, abandon calls)
│ ├── ozonetel-agent.module.ts # Module wiring
│ └── kookoo-ivr.controller.ts # IVR callback handler (XML responses)
├── worklist/ # Agent task queue + missed call queue
│ ├── worklist.controller.ts # GET /api/worklist, missed queue endpoints
│ ├── worklist.service.ts # Aggregates leads + missed calls + follow-ups
│ ├── missed-queue.service.ts # Ingestion, dedup, auto-assignment
│ ├── missed-call-webhook.controller.ts # Webhook receiver
│ └── kookoo-callback.controller.ts # Kookoo webhook
├── call-events/ # Real-time call event processing
│ ├── call-events.service.ts # Incoming call handling, AI enrichment, disposition logging
│ ├── call-events.gateway.ts # WebSocket push to frontend (Socket.IO)
│ └── call-lookup.controller.ts # Reverse phone lookup + AI enrichment
├── platform/ # FortyTwo platform GraphQL client
│ ├── platform-graphql.service.ts # query() for server-to-server, queryWithAuth() for user JWT
│ ├── platform.types.ts # Lead, Call, Activity types
│ └── platform.module.ts
├── search/ # Cross-entity search
│ └── search.controller.ts # GET /api/search — leads + patients + appointments
├── call-assist/ # Live call assistance
│ └── (Socket.IO namespace /call-assist, Deepgram STT, AI suggestions)
├── ai/ # AI enrichment (lead summaries, suggested actions)
├── auth/ # User auth proxy
├── graphql-proxy/ # GraphQL passthrough to platform
├── health/ # Health check endpoint
├── config/
│ └── configuration.ts # All env var loading
├── app.module.ts # Root module — imports all feature modules
└── main.ts # NestJS bootstrap (port 4100, CORS)
```
## API Endpoints
### Ozonetel Agent (`/api/ozonetel/`)
| Method | Path | Purpose |
|--------|------|---------|
| POST | `/agent-login` | Agent login to Ozonetel |
| POST | `/agent-logout` | Agent logout |
| POST | `/agent-state` | Change state (Ready/Pause) + auto-assign missed call on Ready |
| POST | `/agent-ready` | Force ready (logout + login) |
| POST | `/dispose` | Submit call disposition + update missed call status + auto-assign next |
| POST | `/dial` | Manual outbound dial |
| POST | `/call-control` | CONFERENCE, HOLD, UNHOLD, MUTE, UNMUTE, KICK_CALL |
| POST | `/recording` | Pause/unpause recording |
| GET | `/missed-calls` | Raw Ozonetel abandon calls |
| GET | `/call-history?date=` | CDR for a date |
| GET | `/performance?date=` | Aggregated agent metrics |
### Worklist (`/api/worklist/`)
| Method | Path | Purpose |
|--------|------|---------|
| GET | `/` | Agent's worklist (missed calls + follow-ups + leads) |
| GET | `/missed-queue` | Missed calls grouped by callback status |
| PATCH | `/missed-queue/:id/status` | Update callback status on a missed call |
### Other
| Method | Path | Purpose |
|--------|------|---------|
| GET | `/api/search?q=` | Cross-entity search (leads, patients, appointments) |
| POST | `/api/call/lookup` | Reverse phone lookup + AI enrichment |
| GET | `/api/health` | Health check |
| POST | `/graphql` | GraphQL proxy to platform |
## Troubleshooting Guide — Where to Look
### "Agent can't log in to Ozonetel"
**File:** `src/ozonetel/ozonetel-agent.controller.ts``agentLogin()`
**Service:** `src/ozonetel/ozonetel-agent.service.ts``loginAgent()`
Uses HTTP Basic auth to Ozonetel's `AgentAuthenticationV2` endpoint. "Already logged in" responses have `status: "error"` but are not real errors. Check `OZONETEL_AGENT_ID` and `OZONETEL_AGENT_PASSWORD` env vars.
### "Disposition failing / ACW not releasing"
**File:** `src/ozonetel/ozonetel-agent.controller.ts``dispose()`
**Service:** `src/ozonetel/ozonetel-agent.service.ts``setDisposition()`
All dispositions currently map to `'General Enquiry'` (campaign limitation). Uses `autoRelease: 'true'` to end ACW. If agent stays in ACW, the Ozonetel campaign's wrapup time (8s) may not have elapsed.
### "Missed calls not being ingested"
**File:** `src/worklist/missed-queue.service.ts``ingest()`
Runs on a 30s interval (`onModuleInit`). Polls Ozonetel `abandonCalls` API for the last 5 minutes. Look for log lines with `[MissedQueueService]`. Common issues: Ozonetel token expired (55-min cache), platform API key missing, phone number format mismatch.
### "Auto-assignment not working"
**File:** `src/worklist/missed-queue.service.ts``assignNext()`
Triggered from two places: `dispose()` and `agent-state()` in `ozonetel-agent.controller.ts`. Queries platform for oldest `PENDING_CALLBACK` call with empty `agentName`. Uses a mutex to prevent race conditions. If no calls are assigned, check that `callbackstatus` field exists on the Call entity (custom field, all-lowercase in GraphQL).
### "Worklist returning empty"
**File:** `src/worklist/worklist.service.ts`
Three parallel queries: `getMissedCalls()`, `getPendingFollowUps()`, `getAssignedLeads()`. All filter by `agentName`. If the agent name from the JWT doesn't match what's stored in lead/call records, results will be empty. Check `resolveAgentName()` in `worklist.controller.ts`.
### "Call events / webhooks not arriving"
**File:** `src/call-events/call-events.service.ts`
Ozonetel sends webhooks to the sidecar. Check that the webhook URL is configured in the Ozonetel dashboard and that the sidecar is reachable from the internet (Caddy reverse proxy on the VPS).
### "AI enrichment / call assist broken"
**Files:** `src/ai/`, `src/call-assist/`
Live transcription uses Deepgram Nova STT via raw WebSocket. AI suggestions use OpenAI gpt-4o-mini. Check `DEEPGRAM_API_KEY` and `OPENAI_API_KEY` env vars. The call-assist gateway uses Socket.IO namespace `/call-assist`.
### "Search not finding records"
**File:** `src/search/search.controller.ts`
Runs three parallel GraphQL queries (leads, patients, appointments), filters client-side. Requires minimum 2 characters. Uses the user's JWT (passed from frontend auth header).
## Key Technical Patterns
### Two Auth Models
1. **User JWT passthrough**`platform.queryWithAuth(query, vars, authHeader)` — for user-facing endpoints (worklist, search). The frontend sends its JWT and the sidecar forwards it.
2. **Server API key**`platform.query(query, vars)` — for server-to-server operations (missed call ingestion, auto-assignment). Uses `PLATFORM_API_KEY`.
### Ozonetel Token Caching
`ozonetel-agent.service.ts``getToken()` caches the bearer token for 55 minutes (tokens expire at 60 min). All CloudAgent API calls use this cached token.
### Custom Field Naming
Fields added via the FortyTwo admin portal use **all-lowercase** GraphQL names:
- `callbackstatus` (not `callbackStatus`)
- `callsourcenumber` (not `callSourceNumber`)
- `missedcallcount` (not `missedCallCount`)
- `callbackattemptedat` (not `callbackAttemptedAt`)
App-defined (managed) fields keep camelCase: `callStatus`, `agentName`, etc.
### Error Handling Pattern
Ozonetel endpoints return `{ status: 'error', message }` instead of throwing — this prevents UI from blocking on telephony failures. The frontend catches errors silently on disposition and recording.
## Deployment
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
npm run build
# Then tar + scp + docker cp + restart (see deploy script in project docs)
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
The sidecar runs inside a Docker container (`fortytwo-staging-sidecar-1`) on the staging VPS.
## Resources
## Git Workflow
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
- `dev` — active development
- `master` — stable baseline

View File

@@ -0,0 +1,50 @@
{
"brand": {
"name": "Helix Engage",
"hospitalName": "Global Hospital",
"logo": "/helix-logo.png",
"favicon": "/favicon.ico"
},
"colors": {
"brand": {
"25": "rgb(239 246 255)",
"50": "rgb(219 234 254)",
"100": "rgb(191 219 254)",
"200": "rgb(147 197 253)",
"300": "rgb(96 165 250)",
"400": "rgb(59 130 246)",
"500": "rgb(37 99 235)",
"600": "rgb(29 78 216)",
"700": "rgb(30 64 175)",
"800": "rgb(30 58 138)",
"900": "rgb(23 37 84)",
"950": "rgb(15 23 42)"
}
},
"typography": {
"body": "'Satoshi', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif",
"display": "'General Sans', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif"
},
"login": {
"title": "Sign in to Helix Engage",
"subtitle": "Global Hospital",
"showGoogleSignIn": true,
"showForgotPassword": true,
"poweredBy": {
"label": "Powered by F0rty2.ai",
"url": "https://f0rty2.ai"
}
},
"sidebar": {
"title": "Helix Engage",
"subtitle": "Global Hospital · {role}"
},
"ai": {
"quickActions": [
{ "label": "Doctor availability", "prompt": "What doctors are available and what are their visiting hours?" },
{ "label": "Clinic timings", "prompt": "What are the clinic locations and timings?" },
{ "label": "Patient history", "prompt": "Can you summarize this patient's history?" },
{ "label": "Treatment packages", "prompt": "What treatment packages are available?" }
]
}
}

View File

@@ -0,0 +1,62 @@
{
"brand": {
"name": "Test",
"hospitalName": "Global Hospital",
"logo": "/helix-logo.png",
"favicon": "/favicon.ico"
},
"colors": {
"brand": {
"25": "rgb(239 246 255)",
"50": "rgb(219 234 254)",
"100": "rgb(191 219 254)",
"200": "rgb(147 197 253)",
"300": "rgb(96 165 250)",
"400": "rgb(59 130 246)",
"500": "rgb(37 99 235)",
"600": "rgb(29 78 216)",
"700": "rgb(30 64 175)",
"800": "rgb(30 58 138)",
"900": "rgb(23 37 84)",
"950": "rgb(15 23 42)"
}
},
"typography": {
"body": "'Satoshi', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif",
"display": "'General Sans', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif"
},
"login": {
"title": "Sign in to Helix Engage",
"subtitle": "Global Hospital",
"showGoogleSignIn": true,
"showForgotPassword": true,
"poweredBy": {
"label": "Powered by F0rty2.ai",
"url": "https://f0rty2.ai"
}
},
"sidebar": {
"title": "Helix Engage",
"subtitle": "Global Hospital · {role}"
},
"ai": {
"quickActions": [
{
"label": "Doctor availability",
"prompt": "What doctors are available and what are their visiting hours?"
},
{
"label": "Clinic timings",
"prompt": "What are the clinic locations and timings?"
},
{
"label": "Patient history",
"prompt": "Can you summarize this patient's history?"
},
{
"label": "Treatment packages",
"prompt": "What treatment packages are available?"
}
]
}
}

View File

@@ -0,0 +1,62 @@
{
"brand": {
"name": "Helix Engage",
"hospitalName": "Global Hospital",
"logo": "/helix-logo.png",
"favicon": "/favicon.ico"
},
"colors": {
"brand": {
"25": "rgb(239 246 255)",
"50": "rgb(219 234 254)",
"100": "rgb(191 219 254)",
"200": "rgb(147 197 253)",
"300": "rgb(96 165 250)",
"400": "rgb(59 130 246)",
"500": "rgb(37 99 235)",
"600": "rgb(29 78 216)",
"700": "rgb(30 64 175)",
"800": "rgb(30 58 138)",
"900": "rgb(23 37 84)",
"950": "rgb(15 23 42)"
}
},
"typography": {
"body": "'Satoshi', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif",
"display": "'General Sans', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif"
},
"login": {
"title": "Sign in to Helix Engage",
"subtitle": "Global Hospital",
"showGoogleSignIn": true,
"showForgotPassword": true,
"poweredBy": {
"label": "Powered by F0rty2.ai",
"url": "https://f0rty2.ai"
}
},
"sidebar": {
"title": "Helix Engage",
"subtitle": "Global Hospital · {role}"
},
"ai": {
"quickActions": [
{
"label": "Doctor availability",
"prompt": "What doctors are available and what are their visiting hours?"
},
{
"label": "Clinic timings",
"prompt": "What are the clinic locations and timings?"
},
{
"label": "Patient history",
"prompt": "Can you summarize this patient's history?"
},
{
"label": "Treatment packages",
"prompt": "What treatment packages are available?"
}
]
}
}

View File

@@ -0,0 +1,62 @@
{
"brand": {
"name": "Helix Engage",
"hospitalName": "Global Hospital",
"logo": "/helix-logo.png",
"favicon": "/favicon.ico"
},
"colors": {
"brand": {
"25": "rgb(250 245 255)",
"50": "rgb(245 235 255)",
"100": "rgb(235 215 254)",
"200": "rgb(214 187 251)",
"300": "rgb(182 146 246)",
"400": "rgb(158 119 237)",
"500": "rgb(127 86 217)",
"600": "rgb(105 65 198)",
"700": "rgb(83 56 158)",
"800": "rgb(66 48 125)",
"900": "rgb(53 40 100)",
"950": "rgb(44 28 95)"
}
},
"typography": {
"body": "'Satoshi', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif",
"display": "'General Sans', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif"
},
"login": {
"title": "Sign in to Ramaiah",
"subtitle": "Ramaiah Hospital",
"showGoogleSignIn": true,
"showForgotPassword": true,
"poweredBy": {
"label": "Powered by F0rty2.ai",
"url": "https://f0rty2.ai"
}
},
"sidebar": {
"title": "Ramaiah",
"subtitle": "Ramaiah Hospital · {role}"
},
"ai": {
"quickActions": [
{
"label": "Doctor availability",
"prompt": "What doctors are available and what are their visiting hours?"
},
{
"label": "Clinic timings",
"prompt": "What are the clinic locations and timings?"
},
{
"label": "Patient history",
"prompt": "Can you summarize this patient's history?"
},
{
"label": "Treatment packages",
"prompt": "What treatment packages are available?"
}
]
}
}

View File

@@ -0,0 +1,62 @@
{
"brand": {
"name": "Helix Engage",
"hospitalName": "Global Hospital",
"logo": "/helix-logo.png",
"favicon": "/favicon.ico"
},
"colors": {
"brand": {
"25": "rgb(248 250 252)",
"50": "rgb(241 245 249)",
"100": "rgb(226 232 240)",
"200": "rgb(203 213 225)",
"300": "rgb(148 163 184)",
"400": "rgb(100 116 139)",
"500": "rgb(71 85 105)",
"600": "rgb(47 64 89)",
"700": "rgb(37 49 72)",
"800": "rgb(30 41 59)",
"900": "rgb(15 23 42)",
"950": "rgb(2 6 23)"
}
},
"typography": {
"body": "'Plus Jakarta Sans', 'Inter', sans-serif",
"display": "'Plus Jakarta Sans', 'Inter', sans-serif"
},
"login": {
"title": "Sign in to Ramaiah",
"subtitle": "Ramaiah Hospital",
"showGoogleSignIn": true,
"showForgotPassword": true,
"poweredBy": {
"label": "Powered by F0rty2.ai",
"url": "https://f0rty2.ai"
}
},
"sidebar": {
"title": "Ramaiah",
"subtitle": "Ramaiah Hospital · {role}"
},
"ai": {
"quickActions": [
{
"label": "Doctor availability",
"prompt": "What doctors are available and what are their visiting hours?"
},
{
"label": "Clinic timings",
"prompt": "What are the clinic locations and timings?"
},
{
"label": "Patient history",
"prompt": "Can you summarize this patient's history?"
},
{
"label": "Treatment packages",
"prompt": "What treatment packages are available?"
}
]
}
}

View File

@@ -0,0 +1,62 @@
{
"brand": {
"name": "Helix Engage",
"hospitalName": "Global Hospital",
"logo": "/helix-logo.png",
"favicon": "/favicon.ico"
},
"colors": {
"brand": {
"25": "rgb(248 250 252)",
"50": "rgb(241 245 249)",
"100": "rgb(226 232 240)",
"200": "rgb(203 213 225)",
"300": "rgb(148 163 184)",
"400": "rgb(100 116 139)",
"500": "rgb(71 85 105)",
"600": "rgb(47 64 89)",
"700": "rgb(37 49 72)",
"800": "rgb(30 41 59)",
"900": "rgb(15 23 42)",
"950": "rgb(2 6 23)"
}
},
"typography": {
"body": "'Plus Jakarta Sans', 'Inter', sans-serif",
"display": "'Plus Jakarta Sans', 'Inter', sans-serif"
},
"login": {
"title": "Sign in to Ramaiah",
"subtitle": "Ramaiah Hospital",
"showGoogleSignIn": false,
"showForgotPassword": true,
"poweredBy": {
"label": "Powered by F0rty2.ai",
"url": "https://f0rty2.ai"
}
},
"sidebar": {
"title": "Ramaiah",
"subtitle": "Ramaiah Hospital · {role}"
},
"ai": {
"quickActions": [
{
"label": "Doctor availability",
"prompt": "What doctors are available and what are their visiting hours?"
},
{
"label": "Clinic timings",
"prompt": "What are the clinic locations and timings?"
},
{
"label": "Patient history",
"prompt": "Can you summarize this patient's history?"
},
{
"label": "Treatment packages",
"prompt": "What treatment packages are available?"
}
]
}
}

View File

@@ -0,0 +1,62 @@
{
"brand": {
"name": "Helix Engage",
"hospitalName": "Global Hospital",
"logo": "/helix-logo.png",
"favicon": "/favicon.ico"
},
"colors": {
"brand": {
"25": "rgb(240 253 250)",
"50": "rgb(204 251 241)",
"100": "rgb(153 246 228)",
"200": "rgb(94 234 212)",
"300": "rgb(45 212 191)",
"400": "rgb(20 184 166)",
"500": "rgb(13 148 136)",
"600": "rgb(15 118 110)",
"700": "rgb(17 94 89)",
"800": "rgb(19 78 74)",
"900": "rgb(17 63 61)",
"950": "rgb(4 47 46)"
}
},
"typography": {
"body": "'Plus Jakarta Sans', 'Inter', sans-serif",
"display": "'Plus Jakarta Sans', 'Inter', sans-serif"
},
"login": {
"title": "Sign in to Ramaiah",
"subtitle": "Ramaiah Hospital",
"showGoogleSignIn": false,
"showForgotPassword": true,
"poweredBy": {
"label": "Powered by F0rty2.ai",
"url": "https://f0rty2.ai"
}
},
"sidebar": {
"title": "Ramaiah",
"subtitle": "Ramaiah Hospital · {role}"
},
"ai": {
"quickActions": [
{
"label": "Doctor availability",
"prompt": "What doctors are available and what are their visiting hours?"
},
{
"label": "Clinic timings",
"prompt": "What are the clinic locations and timings?"
},
{
"label": "Patient history",
"prompt": "Can you summarize this patient's history?"
},
{
"label": "Treatment packages",
"prompt": "What treatment packages are available?"
}
]
}
}

View File

@@ -0,0 +1,62 @@
{
"brand": {
"name": "Helix Engage",
"hospitalName": "Global Hospital",
"logo": "/helix-logo.png",
"favicon": "/favicon.ico"
},
"colors": {
"brand": {
"25": "rgb(240 253 250)",
"50": "rgb(204 251 241)",
"100": "rgb(153 246 228)",
"200": "rgb(94 234 212)",
"300": "rgb(45 212 191)",
"400": "rgb(20 184 166)",
"500": "rgb(13 148 136)",
"600": "rgb(15 118 110)",
"700": "rgb(17 94 89)",
"800": "rgb(19 78 74)",
"900": "rgb(17 63 61)",
"950": "rgb(4 47 46)"
}
},
"typography": {
"body": "'Satoshi', 'Inter', -apple-system, sans-serif",
"display": "'Satoshi', 'Inter', -apple-system, sans-serif"
},
"login": {
"title": "Sign in to Ramaiah",
"subtitle": "Ramaiah Hospital",
"showGoogleSignIn": false,
"showForgotPassword": true,
"poweredBy": {
"label": "Powered by F0rty2.ai",
"url": "https://f0rty2.ai"
}
},
"sidebar": {
"title": "Ramaiah",
"subtitle": "Ramaiah Hospital · {role}"
},
"ai": {
"quickActions": [
{
"label": "Doctor availability",
"prompt": "What doctors are available and what are their visiting hours?"
},
{
"label": "Clinic timings",
"prompt": "What are the clinic locations and timings?"
},
{
"label": "Patient history",
"prompt": "Can you summarize this patient's history?"
},
{
"label": "Treatment packages",
"prompt": "What treatment packages are available?"
}
]
}
}

View File

@@ -0,0 +1,64 @@
{
"brand": {
"name": "Helix Engage",
"hospitalName": "Global Hospital",
"logo": "/helix-logo.png",
"favicon": "/favicon.ico"
},
"colors": {
"brand": {
"25": "rgb(249 252 243)",
"50": "rgb(244 249 231)",
"100": "rgb(235 244 210)",
"200": "rgb(224 247 161)",
"300": "rgb(206 243 104)",
"400": "rgb(195 255 31)",
"500": "rgb(172 235 0)",
"600": "rgb(142 194 0)",
"700": "rgb(116 158 0)",
"800": "rgb(97 133 0)",
"900": "rgb(75 102 0)",
"950": "rgb(49 66 0)"
}
},
"typography": {
"body": "'Satoshi', 'Inter', -apple-system, sans-serif",
"display": "'Satoshi', 'Inter', -apple-system, sans-serif"
},
"login": {
"title": "Sign in to Ramaiah",
"subtitle": "Ramaiah Hospital",
"showGoogleSignIn": false,
"showForgotPassword": true,
"poweredBy": {
"label": "Powered by F0rty2.ai",
"url": "https://f0rty2.ai"
}
},
"sidebar": {
"title": "Ramaiah",
"subtitle": "Ramaiah Hospital · {role}"
},
"ai": {
"quickActions": [
{
"label": "Doctor availability",
"prompt": "What doctors are available and what are their visiting hours?"
},
{
"label": "Clinic timings",
"prompt": "What are the clinic locations and timings?"
},
{
"label": "Patient history",
"prompt": "Can you summarize this patient's history?"
},
{
"label": "Treatment packages",
"prompt": "What treatment packages are available?"
}
]
},
"version": 1,
"updatedAt": "2026-04-02T10:19:29.559Z"
}

64
data/theme.json Normal file
View File

@@ -0,0 +1,64 @@
{
"brand": {
"name": "Helix Engage",
"hospitalName": "Global Hospital",
"logo": "/helix-logo.png",
"favicon": "/favicon.ico"
},
"colors": {
"brand": {
"25": "rgb(250 245 255)",
"50": "rgb(245 235 255)",
"100": "rgb(235 215 254)",
"200": "rgb(214 187 251)",
"300": "rgb(182 146 246)",
"400": "rgb(158 119 237)",
"500": "rgb(127 86 217)",
"600": "rgb(105 65 198)",
"700": "rgb(83 56 158)",
"800": "rgb(66 48 125)",
"900": "rgb(53 40 100)",
"950": "rgb(44 28 95)"
}
},
"typography": {
"body": "'Satoshi', 'Inter', -apple-system, sans-serif",
"display": "'Satoshi', 'Inter', -apple-system, sans-serif"
},
"login": {
"title": "Sign in to Ramaiah",
"subtitle": "Ramaiah Hospital",
"showGoogleSignIn": false,
"showForgotPassword": true,
"poweredBy": {
"label": "Powered by F0rty2.ai",
"url": "https://f0rty2.ai"
}
},
"sidebar": {
"title": "Ramaiah",
"subtitle": "Ramaiah Hospital · {role}"
},
"ai": {
"quickActions": [
{
"label": "Doctor availability",
"prompt": "What doctors are available and what are their visiting hours?"
},
{
"label": "Clinic timings",
"prompt": "What are the clinic locations and timings?"
},
{
"label": "Patient history",
"prompt": "Can you summarize this patient's history?"
},
{
"label": "Treatment packages",
"prompt": "What treatment packages are available?"
}
]
},
"version": 2,
"updatedAt": "2026-04-02T10:19:35.284Z"
}

View File

@@ -0,0 +1,901 @@
# WhatsApp AI Assistant — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Provider-agnostic WhatsApp AI assistant that handles inbound patient messages — answers questions from KB, books appointments via interactive buttons, and creates/updates leads automatically.
**Architecture:** A `MessagingModule` with a provider interface (Gupshup first, swappable to Ozonetel/Meta later). Inbound webhook → caller resolution → AI conversation with tools (reuses existing `book_appointment`, `lookup_doctor`, etc.) → outbound replies via provider. Conversation history stored in Redis with 24h TTL. Interactive WhatsApp buttons/lists for structured selection steps.
**Tech Stack:** NestJS, Vercel AI SDK (`generateText` with tools), Redis, Gupshup WhatsApp API (`POST https://api.gupshup.io/wa/api/v1/msg`)
---
## File Structure
```
src/messaging/
├── messaging.module.ts — NestJS module, wires everything
├── messaging.controller.ts — POST /api/messaging/webhook (inbound)
├── messaging.service.ts — Conversation orchestration (resolve caller, build prompt, call AI, send reply)
├── messaging-conversation.service.ts — Redis conversation history (store/load/clear, 24h TTL)
├── providers/
│ ├── messaging-provider.interface.ts — Provider contract (sendText, sendList, sendButtons, parseInbound)
│ └── gupshup.provider.ts — Gupshup implementation
└── types.ts — NormalizedMessage, ConversationEntry, InteractiveButton, ListSection
```
**Modified files:**
- `src/config/configuration.ts` — add `messaging` config block
- `src/app.module.ts` — import MessagingModule
---
### Task 1: Types and Provider Interface
**Files:**
- Create: `src/messaging/types.ts`
- Create: `src/messaging/providers/messaging-provider.interface.ts`
- [ ] **Step 1: Create types**
```typescript
// src/messaging/types.ts
export type NormalizedMessage = {
phone: string; // E.164 without +, e.g. "919949879837"
name: string; // sender name from WhatsApp profile
text: string; // message text (or button reply title)
type: 'text' | 'interactive_reply' | 'location' | 'image' | 'unknown';
interactiveReply?: { // populated when user taps a button or list item
id: string; // button/row ID set by us
title: string; // display text
};
rawPayload: any; // original provider payload for debugging
};
export type ConversationEntry = {
role: 'user' | 'assistant';
content: string;
timestamp: number;
};
export type InteractiveButton = {
id: string;
title: string; // max 20 chars for WhatsApp
};
export type ListSection = {
title: string;
rows: { id: string; title: string; description?: string }[];
};
```
- [ ] **Step 2: Create provider interface**
```typescript
// src/messaging/providers/messaging-provider.interface.ts
import { NormalizedMessage, InteractiveButton, ListSection } from '../types';
export interface MessagingProvider {
/** Parse raw webhook payload into normalized message */
parseInbound(body: any): NormalizedMessage | null;
/** Send a plain text message */
sendText(to: string, text: string): Promise<void>;
/** Send interactive buttons (max 3 for WhatsApp) */
sendButtons(to: string, body: string, buttons: InteractiveButton[]): Promise<void>;
/** Send interactive list (max 10 rows total across sections) */
sendList(to: string, body: string, buttonText: string, sections: ListSection[]): Promise<void>;
/** Validate that inbound webhook is authentic */
validateWebhook(body: any): boolean;
}
```
- [ ] **Step 3: Commit**
```bash
git add src/messaging/types.ts src/messaging/providers/messaging-provider.interface.ts
git commit -m "feat(messaging): types and provider interface"
```
---
### Task 2: Gupshup Provider
**Files:**
- Create: `src/messaging/providers/gupshup.provider.ts`
- [ ] **Step 1: Implement Gupshup provider**
```typescript
// src/messaging/providers/gupshup.provider.ts
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MessagingProvider } from './messaging-provider.interface';
import { NormalizedMessage, InteractiveButton, ListSection } from '../types';
@Injectable()
export class GupshupProvider implements MessagingProvider {
private readonly logger = new Logger(GupshupProvider.name);
private readonly apiKey: string;
private readonly appId: string;
private readonly sourceNumber: string;
private readonly apiUrl = 'https://api.gupshup.io/wa/api/v1/msg';
constructor(private config: ConfigService) {
this.apiKey = config.get<string>('messaging.gupshup.apiKey') ?? '';
this.appId = config.get<string>('messaging.gupshup.appId') ?? '';
this.sourceNumber = config.get<string>('messaging.gupshup.sourceNumber') ?? '';
if (this.apiKey) {
this.logger.log(`Gupshup provider configured: appId=${this.appId} source=${this.sourceNumber}`);
} else {
this.logger.warn('Gupshup provider not configured — missing API key');
}
}
validateWebhook(body: any): boolean {
// Gupshup doesn't sign webhooks — validate by app name match
return body?.app === this.appId || !this.appId;
}
parseInbound(body: any): NormalizedMessage | null {
// Gupshup sends: { app, timestamp, version, type, payload }
if (body?.type !== 'message') return null;
const payload = body.payload;
if (!payload?.sender?.phone) return null;
const phone = payload.sender.phone.replace(/\D/g, '');
const name = payload.sender.name ?? '';
const msgType = payload.type;
// Text message
if (msgType === 'text') {
return {
phone, name,
text: payload.payload?.text ?? payload.text ?? '',
type: 'text',
rawPayload: body,
};
}
// Interactive reply (button tap or list selection)
if (msgType === 'button_reply' || msgType === 'list_reply') {
return {
phone, name,
text: payload.payload?.title ?? '',
type: 'interactive_reply',
interactiveReply: {
id: payload.payload?.id ?? '',
title: payload.payload?.title ?? '',
},
rawPayload: body,
};
}
// Location
if (msgType === 'location') {
return {
phone, name,
text: `Location: ${payload.payload?.latitude}, ${payload.payload?.longitude}`,
type: 'location',
rawPayload: body,
};
}
// Image/document/audio — acknowledge but treat as text
if (['image', 'audio', 'video', 'document', 'sticker'].includes(msgType)) {
return {
phone, name,
text: `[Sent ${msgType}]`,
type: 'image',
rawPayload: body,
};
}
this.logger.warn(`[GUPSHUP] Unknown message type: ${msgType}`);
return { phone, name, text: '', type: 'unknown', rawPayload: body };
}
async sendText(to: string, text: string): Promise<void> {
await this.send(to, JSON.stringify({ type: 'text', text }));
}
async sendButtons(to: string, body: string, buttons: InteractiveButton[]): Promise<void> {
const message = {
type: 'quick_reply',
content: { type: 'text', text: body },
options: buttons.map(b => ({ type: 'text', title: b.title, postbackText: b.id })),
};
await this.send(to, JSON.stringify(message));
}
async sendList(to: string, body: string, buttonText: string, sections: ListSection[]): Promise<void> {
const message = {
type: 'list',
title: buttonText,
body: body,
globalButtons: [{ type: 'text', title: buttonText }],
items: sections.map(s => ({
title: s.title,
options: s.rows.map(r => ({
type: 'text',
title: r.title,
description: r.description ?? '',
postbackText: r.id,
})),
})),
};
await this.send(to, JSON.stringify(message));
}
private async send(to: string, message: string): Promise<void> {
const params = new URLSearchParams();
params.append('channel', 'whatsapp');
params.append('source', this.sourceNumber);
params.append('destination', to);
params.append('message', message);
params.append('src.name', this.appId);
this.logger.log(`[GUPSHUP] Sending to ${to}: ${message.substring(0, 100)}...`);
const resp = await fetch(this.apiUrl, {
method: 'POST',
headers: {
'apikey': this.apiKey,
'Content-Type': 'application/x-www-form-urlencoded',
},
body: params.toString(),
});
const result = await resp.json().catch(() => resp.text());
if (!resp.ok) {
this.logger.error(`[GUPSHUP] Send failed (${resp.status}): ${JSON.stringify(result)}`);
throw new Error(`Gupshup send failed: ${resp.status}`);
}
this.logger.log(`[GUPSHUP] Sent: ${JSON.stringify(result)}`);
}
}
```
- [ ] **Step 2: Commit**
```bash
git add src/messaging/providers/gupshup.provider.ts
git commit -m "feat(messaging): gupshup provider implementation"
```
---
### Task 3: Conversation History Service
**Files:**
- Create: `src/messaging/messaging-conversation.service.ts`
- [ ] **Step 1: Implement Redis-backed conversation store**
```typescript
// src/messaging/messaging-conversation.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
import { ConversationEntry } from './types';
@Injectable()
export class MessagingConversationService {
private readonly logger = new Logger(MessagingConversationService.name);
private readonly redis: Redis;
private readonly ttlSec = 24 * 60 * 60; // 24 hours — matches WhatsApp session window
private readonly maxHistory = 20; // keep last 20 message pairs
constructor(config: ConfigService) {
const redisUrl = config.get<string>('redis.url') ?? 'redis://localhost:6379';
this.redis = new Redis(redisUrl);
}
private key(phone: string): string {
return `wa:conv:${phone}`;
}
async getHistory(phone: string): Promise<ConversationEntry[]> {
const raw = await this.redis.get(this.key(phone));
if (!raw) return [];
try {
return JSON.parse(raw);
} catch {
return [];
}
}
async addMessages(phone: string, entries: ConversationEntry[]): Promise<void> {
const existing = await this.getHistory(phone);
const updated = [...existing, ...entries].slice(-this.maxHistory);
await this.redis.setex(this.key(phone), this.ttlSec, JSON.stringify(updated));
}
async clear(phone: string): Promise<void> {
await this.redis.del(this.key(phone));
}
}
```
- [ ] **Step 2: Commit**
```bash
git add src/messaging/messaging-conversation.service.ts
git commit -m "feat(messaging): redis conversation history service"
```
---
### Task 4: Messaging Service (Conversation Orchestration)
**Files:**
- Create: `src/messaging/messaging.service.ts`
This is the core — resolves the caller, builds AI context, runs the AI with tools, sends the reply back.
- [ ] **Step 1: Create messaging service**
```typescript
// src/messaging/messaging.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { generateText, tool } from 'ai';
import { z } from 'zod';
import { MessagingProvider } from './providers/messaging-provider.interface';
import { MessagingConversationService } from './messaging-conversation.service';
import { CallerResolutionService } from '../caller/caller-resolution.service';
import { CallerContextService } from '../caller/caller-context.service';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { createAiModel } from '../ai/ai-provider';
import { AiConfigService } from '../config/ai-config.service';
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils';
import type { NormalizedMessage, InteractiveButton, ListSection } from './types';
import type { LanguageModel } from 'ai';
@Injectable()
export class MessagingService {
private readonly logger = new Logger(MessagingService.name);
private readonly aiModel: LanguageModel | null;
private readonly auth: string; // server-to-server API key auth
constructor(
private config: ConfigService,
private provider: MessagingProvider,
private conversation: MessagingConversationService,
private caller: CallerResolutionService,
private callerContext: CallerContextService,
private platform: PlatformGraphqlService,
private aiConfig: AiConfigService,
) {
const cfg = aiConfig.getConfig();
this.aiModel = createAiModel({
provider: cfg.provider,
model: cfg.model,
anthropicApiKey: config.get<string>('ai.anthropicApiKey'),
openaiApiKey: config.get<string>('ai.openaiApiKey'),
});
// WhatsApp AI uses server-to-server auth (no user JWT)
const apiKey = config.get<string>('platform.apiKey') ?? '';
this.auth = apiKey ? `Bearer ${apiKey}` : '';
}
async handleInbound(message: NormalizedMessage): Promise<void> {
const { phone, name, text } = message;
this.logger.log(`[WA] Inbound from ${phone} (${name}): ${text.substring(0, 100)}`);
if (!this.aiModel) {
await this.provider.sendText(phone, 'Our assistant is temporarily unavailable. Please call us directly.');
return;
}
// 1. Resolve caller
const resolved = await this.caller.resolve(phone, this.auth).catch(err => {
this.logger.error(`[WA] Caller resolution failed: ${err.message}`);
return null;
});
// 2. Build context
let callerContextPrompt = '';
if (resolved && !resolved.isNew && resolved.leadId) {
const ctx = await this.callerContext.getOrBuild(resolved.leadId, resolved.patientId ?? '', this.auth).catch(() => null);
if (ctx) {
callerContextPrompt = this.callerContext.renderForPrompt(ctx);
}
}
// 3. Load conversation history
const history = await this.conversation.getHistory(phone);
const messages = [
...history.map(h => ({ role: h.role as 'user' | 'assistant', content: h.content })),
{ role: 'user' as const, content: text },
];
// 4. Build system prompt
const systemPrompt = this.buildSystemPrompt(callerContextPrompt, name, phone, resolved?.isNew ?? true);
// 5. Build tools — provider is injected so tools can send interactive messages
const tools = this.buildTools(phone);
// 6. Run AI
try {
const result = await generateText({
model: this.aiModel,
system: systemPrompt,
messages,
tools,
maxSteps: 5,
});
const reply = result.text?.trim();
if (reply) {
await this.provider.sendText(phone, reply);
}
// 7. Persist conversation
await this.conversation.addMessages(phone, [
{ role: 'user', content: text, timestamp: Date.now() },
...(reply ? [{ role: 'assistant' as const, content: reply, timestamp: Date.now() }] : []),
]);
} catch (err: any) {
this.logger.error(`[WA] AI error: ${err.message}`);
await this.provider.sendText(phone, 'Sorry, I encountered an error. Please try again or call us directly.');
}
}
private buildSystemPrompt(callerContext: string, name: string, phone: string, isNew: boolean): string {
return `You are a friendly WhatsApp assistant for a hospital. You help patients with:
- Answering questions about departments, doctors, timings, fees
- Booking appointments
- Checking existing appointments
RULES:
- Be concise — WhatsApp messages should be short (2-3 sentences max per message).
- No markdown formatting (no **, ##, bullets). Plain text only.
- When booking an appointment, collect: department, doctor preference, preferred date/time, reason for visit.
- Use the send_department_list tool to show available departments as a WhatsApp list.
- Use the send_doctor_list tool to show available doctors as a WhatsApp list.
- Use the send_slot_list tool to show available time slots as a WhatsApp list.
- Use the send_confirm_buttons tool to let the patient confirm or cancel before booking.
- After booking, send a confirmation with doctor name, date, time, and reference number.
- If the patient asks something you can't help with, suggest they call the hospital directly.
- Always be warm and professional. Use the patient's name when known.
- Reply in the same language the patient uses. Button/list labels stay in English.
CURRENT PATIENT:
Name: ${name || 'Unknown'}
Phone: ${phone}
${isNew ? 'New patient — no prior records.' : ''}
${callerContext ? `\n${callerContext}` : ''}`;
}
private buildTools(phone: string) {
const provider = this.provider;
const platform = this.platform;
const auth = this.auth;
const logger = this.logger;
return {
lookup_appointments: tool({
description: 'Look up existing appointments for the current patient.',
parameters: z.object({
patientId: z.string().optional().describe('Patient ID — omit to use current caller context'),
}),
execute: async ({ patientId }) => {
// Resolve patient from phone if not provided
let pid = patientId;
if (!pid) {
const resolved = await this.caller.resolve(phone, auth).catch(() => null);
pid = resolved?.patientId;
}
if (!pid) return { appointments: [], message: 'No patient record found.' };
const data = await platform.query<any>(
`{ appointments(first: 10, filter: { patientId: { eq: "${pid}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id scheduledAt appointmentStatus doctorName department reasonForVisit
} } } }`,
);
return { appointments: data.appointments.edges.map((e: any) => e.node) };
},
}),
send_department_list: tool({
description: 'Send an interactive WhatsApp list of available departments for the patient to choose from. Call this when the patient wants to book but hasn\'t specified a department.',
parameters: z.object({}),
execute: async () => {
const data = await platform.query<any>(
`{ doctors(first: 50) { edges { node { department } } } }`,
);
const departments = [...new Set(
data.doctors.edges.map((e: any) => e.node.department).filter(Boolean),
)] as string[];
if (!departments.length) return { sent: false, message: 'No departments available.' };
const sections: ListSection[] = [{
title: 'Departments',
rows: departments.slice(0, 10).map(d => ({
id: `dept:${d}`,
title: d.substring(0, 24),
})),
}];
await provider.sendList(phone, 'Which department would you like to visit?', 'View Departments', sections);
return { sent: true, departments };
},
}),
send_doctor_list: tool({
description: 'Send an interactive WhatsApp list of doctors in a specific department. Call this after the patient selects a department.',
parameters: z.object({
department: z.string().describe('Department name'),
}),
execute: async ({ department }) => {
const data = await platform.query<any>(
`{ doctors(first: 50) { edges { node {
id fullName { firstName lastName }
department specialty
consultationFeeNew { amountMicros currencyCode }
${DOCTOR_VISIT_SLOTS_FRAGMENT}
} } } }`,
);
const allDocs = normalizeDoctors(data.doctors.edges.map((e: any) => e.node));
const deptDocs = allDocs.filter((d: any) =>
d.department?.toLowerCase() === department.toLowerCase(),
);
if (!deptDocs.length) return { sent: false, message: `No doctors found in ${department}.` };
const sections: ListSection[] = [{
title: department,
rows: deptDocs.slice(0, 10).map((d: any) => {
const name = `Dr. ${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''}`.trim();
const fee = d.consultationFeeNew?.amountMicros
? `${(d.consultationFeeNew.amountMicros / 1000000).toFixed(0)}`
: '';
return {
id: `doc:${d.id}:${name}`,
title: name.substring(0, 24),
description: fee ? `${d.specialty ?? department}${fee}` : (d.specialty ?? department),
};
}),
}];
await provider.sendList(phone, `Doctors in ${department}:`, 'View Doctors', sections);
return { sent: true, count: deptDocs.length };
},
}),
send_slot_list: tool({
description: 'Send available time slots for a doctor as a WhatsApp list. Call this after the patient selects a doctor.',
parameters: z.object({
doctorId: z.string().describe('Doctor ID from the doctor list selection'),
doctorName: z.string().describe('Doctor name for display'),
date: z.string().optional().describe('Date in YYYY-MM-DD format. Defaults to tomorrow.'),
}),
execute: async ({ doctorId, doctorName, date }) => {
const targetDate = date ?? new Date(Date.now() + 86400000).toISOString().split('T')[0];
const data = await platform.query<any>(
`{ doctors(first: 50) { edges { node {
id fullName { firstName lastName }
${DOCTOR_VISIT_SLOTS_FRAGMENT}
} } } }`,
);
const allDocs = normalizeDoctors(data.doctors.edges.map((e: any) => e.node));
const doctor = allDocs.find((d: any) => d.id === doctorId);
const slots = doctor?.availableSlots ?? [];
if (!slots.length) {
return { sent: false, message: `No slots available for Dr. ${doctorName} on ${targetDate}.` };
}
const sections: ListSection[] = [{
title: `${doctorName}${targetDate}`,
rows: slots.slice(0, 10).map((s: any, i: number) => ({
id: `slot:${doctorId}:${targetDate}T${s.time}:00`,
title: s.time,
description: s.clinic ?? '',
})),
}];
await provider.sendList(phone, `Available slots for ${doctorName}:`, 'View Slots', sections);
return { sent: true, slots: slots.length };
},
}),
send_confirm_buttons: tool({
description: 'Send confirmation buttons before booking the appointment. Call this after all details are collected.',
parameters: z.object({
summary: z.string().describe('Appointment summary text to show the patient'),
}),
execute: async ({ summary }) => {
const buttons: InteractiveButton[] = [
{ id: 'confirm_booking', title: 'Confirm' },
{ id: 'cancel_booking', title: 'Cancel' },
];
await provider.sendButtons(phone, summary, buttons);
return { sent: true };
},
}),
book_appointment: tool({
description: 'Book the appointment after patient confirms. Only call this AFTER the patient taps the Confirm button.',
parameters: z.object({
patientName: z.string().describe('Patient name'),
phoneNumber: z.string().describe('Patient phone number'),
department: z.string().describe('Department'),
doctorName: z.string().describe('Doctor name'),
scheduledAt: z.string().describe('ISO datetime for the appointment'),
reason: z.string().describe('Reason for visit'),
}),
execute: async ({ patientName, phoneNumber, department, doctorName, scheduledAt, reason }) => {
logger.log(`[WA-BOOK] Booking: ${patientName}${doctorName} @ ${scheduledAt}`);
try {
// Ensure lead exists
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
const resolved = await this.caller.resolve(cleanPhone, auth).catch(() => null);
if (resolved?.isNew) {
// Create patient + lead
const firstName = patientName.split(' ')[0];
const lastName = patientName.split(' ').slice(1).join(' ') || '';
try {
const p = await platform.query<any>(
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
{ data: { fullName: { firstName, lastName }, phones: { primaryPhoneNumber: `+91${cleanPhone}` }, patientType: 'NEW' } },
);
const patientId = p?.createPatient?.id;
await platform.query<any>(
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
{ data: { name: `WhatsApp — ${patientName}`, contactName: { firstName, lastName }, contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` }, source: 'WHATSAPP', status: 'NEW', interestedService: department, ...(patientId ? { patientId } : {}) } },
);
} catch (err: any) {
logger.warn(`[WA-BOOK] Lead/patient creation failed: ${err.message}`);
}
}
const result = await platform.query<any>(
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
{ data: { name: `WhatsApp Booking — ${patientName} (${department})`, scheduledAt, appointmentStatus: 'SCHEDULED', doctorName, department, reasonForVisit: reason } },
);
const id = result?.createAppointment?.id;
if (id) {
return { booked: true, appointmentId: id, message: `Appointment booked! Reference: ${id.substring(0, 8)}` };
}
return { booked: false, message: 'Booking failed. Please try again.' };
} catch (err: any) {
logger.error(`[WA-BOOK] Failed: ${err.message}`);
return { booked: false, message: 'Booking failed. Please call us directly.' };
}
},
}),
};
}
}
```
- [ ] **Step 2: Commit**
```bash
git add src/messaging/messaging.service.ts
git commit -m "feat(messaging): conversation orchestration service with AI tools"
```
---
### Task 5: Webhook Controller
**Files:**
- Create: `src/messaging/messaging.controller.ts`
- [ ] **Step 1: Create the webhook controller**
```typescript
// src/messaging/messaging.controller.ts
import { Controller, Post, Body, Logger } from '@nestjs/common';
import { MessagingProvider } from './providers/messaging-provider.interface';
import { MessagingService } from './messaging.service';
@Controller('api/messaging')
export class MessagingController {
private readonly logger = new Logger(MessagingController.name);
constructor(
private readonly provider: MessagingProvider,
private readonly messaging: MessagingService,
) {}
@Post('webhook')
async webhook(@Body() body: any) {
this.logger.log(`[WA-WEBHOOK] Received: ${JSON.stringify(body).substring(0, 300)}`);
// Validate webhook source
if (!this.provider.validateWebhook(body)) {
this.logger.warn('[WA-WEBHOOK] Validation failed — ignoring');
return { status: 'ignored', reason: 'validation failed' };
}
// Parse inbound message
const message = this.provider.parseInbound(body);
if (!message) {
this.logger.log('[WA-WEBHOOK] Non-message event — skipped');
return { status: 'ok', type: body?.type ?? 'unknown' };
}
// Handle asynchronously — don't block the webhook response
this.messaging.handleInbound(message).catch(err => {
this.logger.error(`[WA-WEBHOOK] handleInbound failed: ${err.message}`);
});
return { status: 'ok' };
}
}
```
- [ ] **Step 2: Commit**
```bash
git add src/messaging/messaging.controller.ts
git commit -m "feat(messaging): webhook controller"
```
---
### Task 6: Module Wiring and Configuration
**Files:**
- Create: `src/messaging/messaging.module.ts`
- Modify: `src/config/configuration.ts`
- Modify: `src/app.module.ts`
- [ ] **Step 1: Add messaging config**
Add to `src/config/configuration.ts`, after the `ai` block:
```typescript
messaging: {
provider: process.env.MESSAGING_PROVIDER ?? 'gupshup',
gupshup: {
apiKey: process.env.GUPSHUP_API_KEY ?? '',
appId: process.env.GUPSHUP_APP_ID ?? '',
sourceNumber: process.env.GUPSHUP_SOURCE_NUMBER ?? '',
},
},
```
- [ ] **Step 2: Create module**
```typescript
// src/messaging/messaging.module.ts
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { PlatformModule } from '../platform/platform.module';
import { CallerResolutionModule } from '../caller/caller-resolution.module';
import { MessagingController } from './messaging.controller';
import { MessagingService } from './messaging.service';
import { MessagingConversationService } from './messaging-conversation.service';
import { GupshupProvider } from './providers/gupshup.provider';
import { MessagingProvider } from './providers/messaging-provider.interface';
@Module({
imports: [ConfigModule, PlatformModule, CallerResolutionModule],
controllers: [MessagingController],
providers: [
MessagingService,
MessagingConversationService,
{
provide: MessagingProvider,
useFactory: (config: ConfigService) => {
const provider = config.get<string>('messaging.provider');
// Future: switch on provider to return OzonetelProvider, MetaProvider, etc.
return new GupshupProvider(config);
},
inject: [ConfigService],
},
],
})
export class MessagingModule {}
```
- [ ] **Step 3: Register in app.module.ts**
Add import at the top:
```typescript
import { MessagingModule } from './messaging/messaging.module';
```
Add `MessagingModule` to the `imports` array.
- [ ] **Step 4: Commit**
```bash
git add src/messaging/messaging.module.ts src/config/configuration.ts src/app.module.ts
git commit -m "feat(messaging): module wiring and configuration"
```
---
### Task 7: Environment Variables and Deployment
**Files:**
- Modify: Ramaiah sidecar env on EC2
- [ ] **Step 1: Add env vars to Ramaiah sidecar**
SSH into EC2 and add to the sidecar-ramaiah environment in docker-compose:
```bash
SSHPASS='SasiSuman@2007' sshpass -P "Enter passphrase" -e \
ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194
cd /opt/fortytwo
# Edit docker-compose.yml — add to sidecar-ramaiah environment:
# MESSAGING_PROVIDER=gupshup
# GUPSHUP_API_KEY=sk_c6dd2ff65d4f4e2d967cf7bbc2f620ed
# GUPSHUP_APP_ID=f6196887-ed08-4c4e-9049-e4e4ec59b254
# GUPSHUP_SOURCE_NUMBER=<the WhatsApp Business number registered with Gupshup>
```
- [ ] **Step 2: Configure Gupshup webhook**
In the Gupshup dashboard, set the callback URL to:
```
https://ramaiah.engage.healix360.net/api/messaging/webhook
```
- [ ] **Step 3: Build, push, and deploy sidecar**
```bash
cd helix-engage-server
aws ecr get-login-password --region ap-south-1 | docker login --username AWS --password-stdin 043728036361.dkr.ecr.ap-south-1.amazonaws.com
docker buildx build --platform linux/amd64 -t 043728036361.dkr.ecr.ap-south-1.amazonaws.com/fortytwo-eap/helix-engage-sidecar:alpha --push .
```
On EC2:
```bash
cd /opt/fortytwo && sudo docker compose pull sidecar-ramaiah && sudo docker compose up -d sidecar-ramaiah
```
- [ ] **Step 4: Test end-to-end**
Send a WhatsApp message to the Gupshup-registered number. Verify:
1. Webhook received (check sidecar logs)
2. AI response sent back
3. Department list renders as interactive WhatsApp list
4. Doctor selection works
5. Slot selection works
6. Confirm/cancel buttons render
7. Appointment appears in platform
- [ ] **Step 5: Commit env docs**
```bash
git add docs/plans/2026-04-20-whatsapp-ai-assistant.md
git commit -m "docs: whatsapp AI assistant implementation plan"
```
---
## Missing: Source Number
The `GUPSHUP_SOURCE_NUMBER` env var needs the WhatsApp Business number registered with Gupshup. This is the number patients will message. Check the Gupshup dashboard under App Settings → WhatsApp Number.
## Provider Swap (Future)
To add Ozonetel or Meta Cloud API:
1. Create `src/messaging/providers/ozonetel.provider.ts` implementing `MessagingProvider`
2. Add config block in `configuration.ts`
3. Update the `useFactory` in `messaging.module.ts` to switch on `config.get('messaging.provider')`
4. Set `MESSAGING_PROVIDER=ozonetel` in env
No other files change — the controller, service, and conversation store are provider-agnostic.

View File

@@ -0,0 +1,270 @@
# WhatsApp Flow Runtime — Design Spec
## Goal
Config-driven conversation engine that reads flow definitions (JSON) and executes them at runtime. Replaces the hardcoded system prompt + tools in `messaging.service.ts`. Hospital admins define flows via API/file — no code changes needed.
## Architecture
```
Inbound WhatsApp message
→ MessagingController (existing)
→ FlowExecutionService (NEW — replaces MessagingService AI logic)
→ Load/create FlowSession from Redis
→ Match flow by trigger (or resume existing session)
→ Walk forward through Groups → Blocks
→ Pause at InputBlock, resume on next message
→ Send messages via MessagingProvider (existing)
→ Call tools via ToolRegistry (NEW)
→ Reply sent to patient
```
## Flow Definition Schema
```typescript
type Flow = {
id: string;
name: string;
description: string;
trigger: FlowTrigger;
groups: Group[];
edges: Edge[];
variables: VariableDefinition[];
version: number;
status: 'draft' | 'published';
};
type FlowTrigger =
| { type: 'message'; conditions?: { keywords?: string[]; regex?: string } }
| { type: 'default' };
type VariableDefinition = {
id: string;
name: string;
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
defaultValue?: any;
};
```
## Groups and Edges
```typescript
type Group = {
id: string;
title: string;
blocks: Block[];
};
type Edge = {
id: string;
from: { blockId: string; conditionId?: string };
to: { groupId: string; blockId?: string };
};
```
## Block Types
```typescript
type Block =
| MessageBlock
| InputBlock
| ConditionBlock
| SetVariableBlock
| ToolCallBlock
| AIBlock
| JumpBlock;
// Send text/list/buttons to patient
type MessageBlock = {
id: string;
type: 'message';
content:
| { format: 'text'; text: string }
| { format: 'buttons'; text: string; buttons: { id: string; title: string }[] }
| { format: 'list'; text: string; buttonText: string; sections: { title: string; rows: { id: string; title: string; description?: string }[] }[] };
};
// Wait for patient reply
type InputBlock = {
id: string;
type: 'input';
inputType: 'text' | 'interactive_reply' | 'any';
variableId: string;
validation?: { regex?: string; errorMessage?: string };
};
// Branch based on variable value
type ConditionBlock = {
id: string;
type: 'condition';
conditions: {
id: string;
variableId: string;
operator: 'equals' | 'contains' | 'exists' | 'not_exists' | 'gt' | 'lt' | 'starts_with';
value?: string;
}[];
};
// Assign/transform a variable
type SetVariableBlock = {
id: string;
type: 'set_variable';
variableId: string;
value: string;
expression?: 'extract_id';
};
// Execute a registered tool
type ToolCallBlock = {
id: string;
type: 'tool_call';
toolName: string;
inputs: Record<string, string>; // values support {{variables}}
outputVariableId?: string;
};
// Generate dynamic LLM response
type AIBlock = {
id: string;
type: 'ai';
prompt: string; // supports {{variables}}
outputVariableId?: string;
sendToPatient: boolean;
};
// Jump to another group
type JumpBlock = {
id: string;
type: 'jump';
targetGroupId: string;
};
```
## Session State (Redis)
```typescript
type FlowSession = {
flowId: string;
currentGroupId: string;
currentBlockIndex: number;
variables: Record<string, any>;
history: ConversationEntry[];
startedAt: number;
lastActiveAt: number;
};
```
Key: `wa:flow:{phone}`, TTL: 24 hours (WhatsApp session window).
## Execution Loop
```
On inbound message:
1. Load session from Redis (or create new → match flow by trigger)
2. If paused at InputBlock → store reply in variable, advance
3. Walk forward:
- MessageBlock → send via provider, advance
- InputBlock → save session, STOP (wait for next message)
- ConditionBlock → evaluate, follow matching edge (or fall through)
- SetVariableBlock → assign value, advance
- ToolCallBlock → execute tool, store result, advance
- AIBlock → call LLM, optionally send, advance
- JumpBlock → jump to target group
- End of group → follow outgoing edge to next group
4. If no more blocks/edges → flow complete, clear session
```
## Tool Registry
Existing tools from messaging.service.ts become registered tools:
| Tool Name | Description | Inputs | Output |
|---|---|---|---|
| resolve_caller | Phone → Lead + Patient | phone | { leadId, patientId, isNew, name } |
| send_department_list | Send interactive department list | (none — reads from platform) | { departments[] } |
| send_doctor_list | Send interactive doctor list | department | { doctors[] } |
| send_slot_list | Send time slots for doctor+date | doctorId, doctorName, date | { slots[] } |
| send_confirm_buttons | Send confirm/cancel buttons | summary | { sent: true } |
| book_appointment | Book appointment (with conflict check) | patientName, phoneNumber, department, doctorName, scheduledAt, reason | { booked, appointmentId } |
| lookup_appointments | Check existing appointments | patientId? | { appointments[] } |
| create_lead | Create lead + patient | name, phoneNumber, interest | { leadId } |
## Example Flow: Appointment Booking
```
Group: "Greeting" (g1)
→ AIBlock: greet using patient name + context
→ MessageBlock: buttons ["Book Appointment", "Check Appointment", "Ask a Question"]
→ InputBlock: store in {{intent}}
Edges: g1 → ConditionBlock routes to g2 (book) / g7 (check) / g8 (question)
Group: "Department Selection" (g2)
→ ToolCallBlock: send_department_list
→ InputBlock: store in {{selectedDepartment}}
Edge: g2 → g3
Group: "Doctor Selection" (g3)
→ ToolCallBlock: send_doctor_list, input: department={{selectedDepartment}}
→ InputBlock: store in {{selectedDoctor}}
→ SetVariableBlock: extract doctorId from {{selectedDoctor}}
Edge: g3 → g4
Group: "Date Selection" (g4)
→ MessageBlock: "When would you like to visit?"
→ MessageBlock: buttons ["Tomorrow", "Day After", "Choose Date"]
→ InputBlock: store in {{dateChoice}}
→ ConditionBlock: tomorrow → SetVariable, day_after → SetVariable, else → AI parse
Edge: g4 → g5
Group: "Slot Selection" (g5)
→ ToolCallBlock: send_slot_list, inputs: doctorId={{doctorId}}, date={{selectedDate}}
→ InputBlock: store in {{selectedSlot}}
Edge: g5 → g6
Group: "Confirmation" (g6)
→ MessageBlock: buttons ["Confirm", "Cancel"], summary text
→ InputBlock: store in {{confirmation}}
→ ConditionBlock: confirm → g7, cancel → g8
Edges: confirm → "Booking" group, cancel → "Cancelled" group
Group: "Booking" (g7)
→ ToolCallBlock: book_appointment with all collected variables
→ MessageBlock: confirmation with reference number
Group: "Cancelled" (g8)
→ MessageBlock: "No problem! Let me know if you need anything else."
```
## File Structure (Implementation)
```
src/messaging/
├── flow/
│ ├── flow-types.ts — All types above
│ ├── flow-execution.service.ts — Main execution loop
│ ├── flow-session.service.ts — Redis session CRUD
│ ├── flow-store.service.ts — Load/save flow definitions (file/Redis)
│ ├── flow-variable.service.ts — Variable interpolation + expressions
│ ├── tool-registry.ts — Tool name → handler mapping
│ └── default-flows/
│ └── appointment-booking.json — Seeded default flow
├── providers/ (existing, unchanged)
├── messaging.module.ts — Wire new services
├── messaging.controller.ts — Unchanged (webhook still here)
├── messaging.service.ts — Delegates to FlowExecutionService
└── types.ts — Existing types (unchanged)
```
## Migration Path
1. Build FlowExecutionService alongside existing MessagingService
2. Seed default appointment-booking.json (equivalent to current hardcoded flow)
3. MessagingService checks: if flow config exists → delegate to FlowExecutionService, else → current AI behavior (backward compatible)
4. Once validated, remove hardcoded AI flow from MessagingService
## Not in Scope
- Visual builder UI (future, maybe never)
- Flow versioning/rollback (v2)
- Flow analytics/metrics (v2)
- Multi-flow routing (v2 — for now, one active flow per trigger type)

239
docs/website-widget.md Normal file
View File

@@ -0,0 +1,239 @@
# Website Chat Widget — Operations Guide
## Overview
A floating chat/booking/contact widget that hospitals embed on their website via a single `<script>` tag. Visitors can:
- **Chat** with an AI assistant (powered by OpenAI/Anthropic)
- **Book** appointments (department → doctor → date → slot)
- **Contact** the hospital (name, phone, interest — creates a lead in the CRM)
All interactions create or update leads in Helix Engage, so CC agents see the full visitor journey when they call back.
---
## Embed Snippet
Add this to any page on the hospital website (before `</body>`):
```html
<script src="https://ramaiah.engage.healix360.net/widget.js"
data-key="956018d178194fb9.313657fbc8a912b9cf8c93b9a51dfb209022fcd9910bd5abc7aa16dfaacf98a3">
</script>
```
| Parameter | Description |
|---|---|
| `src` | The sidecar URL + `/widget.js` |
| `data-key` | Site key — generated and rotatable from the admin portal |
The widget renders in a **shadow DOM** — its styles don't leak into or get affected by the host website's CSS.
---
## Admin Configuration
### Settings Page
URL: `https://ramaiah.engage.healix360.net/settings/widget` (login as admin/supervisor)
| Setting | Description |
|---|---|
| **Enabled** | Master kill switch — when off, widget.js no-ops |
| **Site Key** | Read-only HMAC-signed key. Copy-to-clipboard for the embed snippet |
| **Site ID** | Read-only identifier |
| **Rotate Key** | Generates a new key, invalidates the old embed snippet |
| **Hosting URL** | Public base URL for widget.js. Leave blank to use same origin as sidecar |
| **Allowed Origins** | Whitelist of domains allowed to embed. Empty = any origin (test mode) |
| **Show on Login Page** | Toggle to display widget on the Helix Engage login page |
### Configuration API
```bash
# Read config (public — no auth)
curl https://ramaiah.engage.healix360.net/api/config/widget
# Read full config (admin)
curl https://ramaiah.engage.healix360.net/api/config/widget/admin
# Update config
curl -X PUT https://ramaiah.engage.healix360.net/api/config/widget \
-H "Content-Type: application/json" \
-d '{"enabled": true, "allowedOrigins": ["https://ramaiahmedical.com"]}'
# Rotate site key (invalidates old embeds)
curl -X POST https://ramaiah.engage.healix360.net/api/config/widget/rotate-key
```
---
## Widget Key Security
- Key format: `{siteId}.{hmacSignature}`
- HMAC computed as: `sha256(siteId, secret=WIDGET_SECRET env var)`
- Validated with `timingSafeEqual()` to prevent timing attacks
- Origin checked against `allowedOrigins` whitelist
- Empty whitelist = test mode (any origin allowed)
### For Production
Before going live on a real hospital website:
1. Set `allowedOrigins` to the hospital's domain(s): `["https://ramaiahmedical.com", "https://www.ramaiahmedical.com"]`
2. Ensure `WIDGET_SECRET` env var is set in the sidecar (auto-generated on first run if missing)
---
## Widget API Endpoints
All endpoints require `WidgetKeyGuard` (key as query param `?key=...` or header `X-Widget-Key`).
| Endpoint | Method | Purpose |
|---|---|---|
| `/api/widget/init` | GET | Returns brand name, logo, colors, reCAPTCHA key |
| `/api/widget/doctors` | GET | All doctors with departments, specialties, fees, visit slots |
| `/api/widget/slots?doctorId=X&date=YYYY-MM-DD` | GET | Available time slots for a doctor on a date |
| `/api/widget/book` | POST | Book appointment (requires captcha token) |
| `/api/widget/lead` | POST | Create lead from contact form (requires captcha token) |
| `/api/widget/chat-start` | POST | Start chat session — body: `{name, phone}`, returns `{leadId}` |
| `/api/widget/chat` | POST | Stream AI reply — body: `{leadId, messages[], branch?}`, returns SSE stream |
---
## How the Chat Flow Works
1. Visitor opens widget → Chat tab shows name + phone form
2. Visitor enters name + phone → `POST /api/widget/chat-start` → returns `leadId`
- Creates or finds existing lead by phone (deduplication)
3. Visitor types a message → `POST /api/widget/chat` with `leadId` + message history
- AI streams a reply via Server-Sent Events
- AI has tools: branch selection, doctor search, slot lookup, booking suggestions
4. After conversation ends, transcript is saved to lead activity timeline
5. CC agent sees the WhatsApp/chat history when calling the patient back
---
## How the Booking Flow Works
1. Visitor opens widget → Book tab
2. Selects department → fetches doctor list
3. Selects doctor → fetches available dates/slots
4. Enters patient name, phone, chief complaint
5. `POST /api/widget/book` with captcha token
6. Creates patient + lead + appointment in the platform
7. Shows confirmation with reference number
---
## How Lead Capture Works
1. Visitor opens widget → Contact tab
2. Enters name, phone, interest, optional message
3. `POST /api/widget/lead` with captcha token
4. Creates lead with source "Website Widget"
5. Shows success confirmation
---
## Lead Deduplication
All three flows (chat, book, contact) use `CallerResolutionService` to find existing leads by phone number. If a visitor chats, then books, then contacts — all within 24 hours — they create ONE lead, not three. Activities are appended to the same lead.
---
## reCAPTCHA Protection
- Booking and lead endpoints use reCAPTCHA v3 (invisible — no user friction)
- Chat endpoints do NOT use reCAPTCHA (session already verified by name+phone gate)
- reCAPTCHA site key returned by `/api/widget/init`
- Server validates tokens via Google reCAPTCHA API
- Captcha can be bypassed for webhooks using `captchaToken: "webhook-bypass"`
---
## Branding & Theming
The widget pulls branding from the sidecar theme config:
- **Hospital name** — displayed in the widget header
- **Logo** — shown in the header
- **Brand color** — applied to buttons, links, active states
- **Location** — shown under the hospital name
Configure via: `https://ramaiah.engage.healix360.net/settings` → Theme section
---
## Widget Source Code
| Location | Description |
|---|---|
| `packages/widget-src/` | Preact source (chat.tsx, booking.tsx, contact.tsx, api.ts, etc.) |
| `public/widget.js` | Compiled IIFE bundle (served by NestJS static assets) |
| `src/widget/` | Backend API (controller, service, chat service, key guard) |
| `src/config/widget-keys.service.ts` | Key generation + HMAC validation |
| `src/config/widget-config.service.ts` | Config file management (data/widget.json) |
### Rebuilding widget.js
```bash
cd packages/widget-src
npm install
npm run build
# Output goes to ../../public/widget.js (configured in vite.config.ts)
```
After rebuilding:
1. Commit `public/widget.js`
2. Build Docker image and deploy sidecar
3. Widget auto-updates on next page load (1h cache)
---
## Deployment Checklist
### First-time setup on a new tenant:
1. **Sidecar serves widget.js** — verify `https://{tenant}.engage.healix360.net/widget.js` returns JS, not HTML
2. **Caddy routing**`/widget.js` must route to sidecar, not frontend. Add to the `@api path` matcher in Caddyfile:
```
@api path /api/* /widget.js /graphql /auth/* ...
```
3. **Widget config exists** — `GET /api/config/widget` should return `{enabled: true, key: "..."}`
4. **Generate key if needed** — `POST /api/widget/keys/generate`
5. **Set allowed origins** (for production) — `PUT /api/config/widget` with `allowedOrigins: ["https://hospital.com"]`
6. **Test embed** — paste the `<script>` tag into a test HTML page and verify the widget appears
7. **Verify AI** — start a chat, confirm AI responds (requires `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` in sidecar env)
### Current deployment (Ramaiah):
| Item | Value |
|---|---|
| Widget URL | `https://ramaiah.engage.healix360.net/widget.js` |
| Site Key | `956018d178194fb9.313657fbc8a912b9cf8c93b9a51dfb209022fcd9910bd5abc7aa16dfaacf98a3` |
| Site ID | `956018d178194fb9` |
| Allowed Origins | Empty (test mode — any origin) |
| Login Page Widget | Enabled |
| reCAPTCHA | Configured (key returned by `/api/widget/init`) |
---
## Troubleshooting
### Widget doesn't appear
1. Check browser console for errors
2. Verify `VITE_API_URL` in the frontend build points to the sidecar URL (not `localhost`)
3. Verify `/api/config/widget` returns `enabled: true` and `embed.loginPage: true`
4. Verify `/widget.js` returns actual JavaScript (not HTML from the frontend catch-all)
### "leadId required" error
The chat requires a `chat-start` call first (name + phone → leadId). If the widget skips this step, it's using an old `widget.js`. Clear browser cache or deploy the correct version from commit `aa41a2a`.
### Chat returns "AI not configured"
Missing `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` in sidecar environment. Check with:
```bash
docker exec sidecar-ramaiah env | grep -i 'OPENAI\|ANTHROPIC\|AI_PROVIDER'
```
### CORS errors
The widget key guard validates the request origin against `allowedOrigins`. If empty, any origin is allowed. If set, the host website's domain must be in the list.
### Widget shows on login page but not on hospital website
The login page injection code is in `helix-engage/src/pages/login.tsx`. For external hospital websites, the embed snippet must be manually added to their HTML. There's no automatic injection for third-party sites.

View File

@@ -3,6 +3,9 @@
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
"deleteOutDir": true,
"assets": [
{ "include": "messaging/flow/default-flows/*.json", "watchAssets": true }
]
}
}

6072
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,17 +23,26 @@
"@ai-sdk/anthropic": "^3.0.58",
"@ai-sdk/openai": "^3.0.41",
"@deepgram/sdk": "^5.0.0",
"@livekit/agents": "^1.2.1",
"@livekit/agents-plugin-google": "^1.2.1",
"@livekit/agents-plugin-silero": "^1.2.1",
"@nestjs/common": "^11.0.1",
"@nestjs/config": "^4.0.3",
"@nestjs/core": "^11.0.1",
"@nestjs/platform-express": "^11.0.1",
"@nestjs/platform-socket.io": "^11.1.17",
"@nestjs/websockets": "^11.1.17",
"@types/qrcode": "^1.5.6",
"ai": "^6.0.116",
"axios": "^1.13.6",
"ioredis": "^5.10.1",
"json-rules-engine": "^6.6.0",
"kafkajs": "^2.2.4",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"socket.io": "^4.8.3"
"socket.io": "^4.8.3",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/eslintrc": "^3.2.0",

View File

@@ -0,0 +1,18 @@
{
"name": "helix-engage-widget",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"preact": "^10.25.0"
},
"devDependencies": {
"@preact/preset-vite": "^2.9.0",
"typescript": "^5.7.0",
"vite": "^6.0.0"
}
}

View File

@@ -0,0 +1,57 @@
import type { WidgetConfig, Doctor, TimeSlot } from './types';
let baseUrl = '';
let widgetKey = '';
export const initApi = (url: string, key: string) => {
baseUrl = url;
widgetKey = key;
};
const headers = () => ({
'Content-Type': 'application/json',
'X-Widget-Key': widgetKey,
});
export const fetchInit = async (): Promise<WidgetConfig> => {
const res = await fetch(`${baseUrl}/api/widget/init?key=${widgetKey}`);
if (!res.ok) throw new Error('Widget init failed');
return res.json();
};
export const fetchDoctors = async (): Promise<Doctor[]> => {
const res = await fetch(`${baseUrl}/api/widget/doctors?key=${widgetKey}`);
if (!res.ok) throw new Error('Failed to load doctors');
return res.json();
};
export const fetchSlots = async (doctorId: string, date: string): Promise<TimeSlot[]> => {
const res = await fetch(`${baseUrl}/api/widget/slots?key=${widgetKey}&doctorId=${doctorId}&date=${date}`);
if (!res.ok) throw new Error('Failed to load slots');
return res.json();
};
export const submitBooking = async (data: any): Promise<{ appointmentId: string; reference: string }> => {
const res = await fetch(`${baseUrl}/api/widget/book?key=${widgetKey}`, {
method: 'POST', headers: headers(), body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Booking failed');
return res.json();
};
export const submitLead = async (data: any): Promise<{ leadId: string }> => {
const res = await fetch(`${baseUrl}/api/widget/lead?key=${widgetKey}`, {
method: 'POST', headers: headers(), body: JSON.stringify(data),
});
if (!res.ok) throw new Error('Submission failed');
return res.json();
};
export const streamChat = async (messages: any[], captchaToken?: string): Promise<ReadableStream> => {
const res = await fetch(`${baseUrl}/api/widget/chat?key=${widgetKey}`, {
method: 'POST', headers: headers(),
body: JSON.stringify({ messages, captchaToken }),
});
if (!res.ok || !res.body) throw new Error('Chat failed');
return res.body;
};

View File

@@ -0,0 +1,199 @@
import { useState, useEffect } from 'preact/hooks';
import { fetchDoctors, fetchSlots, submitBooking } from './api';
import type { Doctor, TimeSlot } from './types';
type Step = 'department' | 'doctor' | 'datetime' | 'details' | 'success';
export const Booking = () => {
const [step, setStep] = useState<Step>('department');
const [doctors, setDoctors] = useState<Doctor[]>([]);
const [departments, setDepartments] = useState<string[]>([]);
const [selectedDept, setSelectedDept] = useState('');
const [selectedDoctor, setSelectedDoctor] = useState<Doctor | null>(null);
const [selectedDate, setSelectedDate] = useState('');
const [slots, setSlots] = useState<TimeSlot[]>([]);
const [selectedSlot, setSelectedSlot] = useState('');
const [name, setName] = useState('');
const [phone, setPhone] = useState('');
const [complaint, setComplaint] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [reference, setReference] = useState('');
useEffect(() => {
fetchDoctors().then(docs => {
setDoctors(docs);
setDepartments([...new Set(docs.map(d => d.department).filter(Boolean))]);
}).catch(() => setError('Failed to load doctors'));
}, []);
const filteredDoctors = selectedDept ? doctors.filter(d => d.department === selectedDept) : [];
const handleDoctorSelect = (doc: Doctor) => {
setSelectedDoctor(doc);
setSelectedDate(new Date().toISOString().split('T')[0]);
setStep('datetime');
};
useEffect(() => {
if (selectedDoctor && selectedDate) {
fetchSlots(selectedDoctor.id, selectedDate).then(setSlots).catch(() => {});
}
}, [selectedDoctor, selectedDate]);
const handleBook = async () => {
if (!selectedDoctor || !selectedSlot || !name || !phone) return;
setLoading(true);
setError('');
try {
const scheduledAt = `${selectedDate}T${selectedSlot}:00`;
const result = await submitBooking({
departmentId: selectedDept,
doctorId: selectedDoctor.id,
scheduledAt,
patientName: name,
patientPhone: phone,
chiefComplaint: complaint,
captchaToken: 'dev-bypass',
});
setReference(result.reference);
setStep('success');
} catch {
setError('Booking failed. Please try again.');
} finally {
setLoading(false);
}
};
const stepIndex = { department: 0, doctor: 1, datetime: 2, details: 3, success: 4 };
const currentStep = stepIndex[step];
return (
<div>
{step !== 'success' && (
<div class="widget-steps">
{[0, 1, 2, 3].map(i => (
<div key={i} class={`widget-step ${i < currentStep ? 'done' : i === currentStep ? 'active' : ''}`} />
))}
</div>
)}
{error && <div style={{ color: '#dc2626', fontSize: '12px', marginBottom: '8px' }}>{error}</div>}
{step === 'department' && (
<div>
<div class="widget-label" style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600 }}>Select Department</div>
{departments.map(dept => (
<button
key={dept}
class="widget-btn widget-btn-secondary"
style={{ marginBottom: '6px', textAlign: 'left' }}
onClick={() => { setSelectedDept(dept); setStep('doctor'); }}
>
{dept.replace(/_/g, ' ')}
</button>
))}
</div>
)}
{step === 'doctor' && (
<div>
<div class="widget-label" style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600 }}>
Select Doctor {selectedDept.replace(/_/g, ' ')}
</div>
{filteredDoctors.map(doc => (
<button
key={doc.id}
class="widget-btn widget-btn-secondary"
style={{ marginBottom: '6px', textAlign: 'left' }}
onClick={() => handleDoctorSelect(doc)}
>
<div style={{ fontWeight: 600 }}>{doc.name}</div>
<div style={{ fontSize: '11px', color: '#6b7280' }}>
{doc.visitingHours ?? ''} {doc.clinic?.clinicName ? `${doc.clinic.clinicName}` : ''}
</div>
</button>
))}
<button class="widget-btn widget-btn-secondary" style={{ marginTop: '8px' }} onClick={() => setStep('department')}>
Back
</button>
</div>
)}
{step === 'datetime' && (
<div>
<div class="widget-label" style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600 }}>
{selectedDoctor?.name} Pick Date & Time
</div>
<div class="widget-field">
<label class="widget-label">Date</label>
<input
class="widget-input"
type="date"
value={selectedDate}
min={new Date().toISOString().split('T')[0]}
onInput={(e: any) => { setSelectedDate(e.target.value); setSelectedSlot(''); }}
/>
</div>
{slots.length > 0 && (
<div>
<label class="widget-label">Available Slots</label>
<div class="widget-slots">
{slots.map(s => (
<button
key={s.time}
class={`widget-slot ${s.time === selectedSlot ? 'selected' : ''} ${!s.available ? 'unavailable' : ''}`}
onClick={() => s.available && setSelectedSlot(s.time)}
disabled={!s.available}
>
{s.time}
</button>
))}
</div>
</div>
)}
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
<button class="widget-btn widget-btn-secondary" style={{ flex: 1 }} onClick={() => setStep('doctor')}> Back</button>
<button class="widget-btn" style={{ flex: 1 }} disabled={!selectedSlot} onClick={() => setStep('details')}>Next </button>
</div>
</div>
)}
{step === 'details' && (
<div>
<div class="widget-label" style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600 }}>Your Details</div>
<div class="widget-field">
<label class="widget-label">Full Name *</label>
<input class="widget-input" placeholder="Your name" value={name} onInput={(e: any) => setName(e.target.value)} />
</div>
<div class="widget-field">
<label class="widget-label">Phone Number *</label>
<input class="widget-input" placeholder="+91 9876543210" value={phone} onInput={(e: any) => setPhone(e.target.value)} />
</div>
<div class="widget-field">
<label class="widget-label">Chief Complaint</label>
<textarea class="widget-input widget-textarea" placeholder="Describe your concern..." value={complaint} onInput={(e: any) => setComplaint(e.target.value)} />
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button class="widget-btn widget-btn-secondary" style={{ flex: 1 }} onClick={() => setStep('datetime')}> Back</button>
<button class="widget-btn" style={{ flex: 1 }} disabled={!name || !phone || loading} onClick={handleBook}>
{loading ? 'Booking...' : 'Book Appointment'}
</button>
</div>
</div>
)}
{step === 'success' && (
<div class="widget-success">
<div class="widget-success-icon"></div>
<div class="widget-success-title">Appointment Booked!</div>
<div class="widget-success-text">
Reference: <strong>{reference}</strong><br />
{selectedDoctor?.name} {selectedDate} at {selectedSlot}<br /><br />
We'll send a confirmation SMS to your phone.
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,94 @@
import { useState, useRef, useEffect } from 'preact/hooks';
import { streamChat } from './api';
import type { ChatMessage } from './types';
const QUICK_ACTIONS = [
'Doctor availability',
'Clinic timings',
'Book appointment',
'Health packages',
];
export const Chat = () => {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [messages]);
const sendMessage = async (text: string) => {
if (!text.trim() || loading) return;
const userMsg: ChatMessage = { role: 'user', content: text.trim() };
const updated = [...messages, userMsg];
setMessages(updated);
setInput('');
setLoading(true);
try {
const stream = await streamChat(updated);
const reader = stream.getReader();
const decoder = new TextDecoder();
let assistantText = '';
setMessages([...updated, { role: 'assistant', content: '' }]);
while (true) {
const { done, value } = await reader.read();
if (done) break;
assistantText += decoder.decode(value, { stream: true });
setMessages([...updated, { role: 'assistant', content: assistantText }]);
}
} catch {
setMessages([...updated, { role: 'assistant', content: 'Sorry, I encountered an error. Please try again.' }]);
} finally {
setLoading(false);
}
};
return (
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<div class="chat-messages" ref={scrollRef}>
{messages.length === 0 && (
<div style={{ textAlign: 'center', padding: '20px 0' }}>
<div style={{ fontSize: '24px', marginBottom: '8px' }}>👋</div>
<div style={{ fontSize: '14px', fontWeight: 600, color: '#1f2937', marginBottom: '4px' }}>
How can we help you?
</div>
<div style={{ fontSize: '12px', color: '#6b7280', marginBottom: '16px' }}>
Ask about doctors, clinics, packages, or book an appointment.
</div>
<div class="quick-actions">
{QUICK_ACTIONS.map(q => (
<button key={q} class="quick-action" onClick={() => sendMessage(q)}>{q}</button>
))}
</div>
</div>
)}
{messages.map((msg, i) => (
<div key={i} class={`chat-msg ${msg.role}`}>
<div class="chat-bubble">{msg.content || '...'}</div>
</div>
))}
</div>
<div class="chat-input-row">
<input
class="widget-input chat-input"
placeholder="Type a message..."
value={input}
onInput={(e: any) => setInput(e.target.value)}
onKeyDown={(e: any) => e.key === 'Enter' && sendMessage(input)}
disabled={loading}
/>
<button class="chat-send" onClick={() => sendMessage(input)} disabled={loading || !input.trim()}>
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,85 @@
import { useState } from 'preact/hooks';
import { submitLead } from './api';
export const Contact = () => {
const [name, setName] = useState('');
const [phone, setPhone] = useState('');
const [interest, setInterest] = useState('');
const [message, setMessage] = useState('');
const [loading, setLoading] = useState(false);
const [success, setSuccess] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async () => {
if (!name.trim() || !phone.trim()) return;
setLoading(true);
setError('');
try {
await submitLead({
name: name.trim(),
phone: phone.trim(),
interest: interest.trim() || undefined,
message: message.trim() || undefined,
captchaToken: 'dev-bypass',
});
setSuccess(true);
} catch {
setError('Submission failed. Please try again.');
} finally {
setLoading(false);
}
};
if (success) {
return (
<div class="widget-success">
<div class="widget-success-icon">🙏</div>
<div class="widget-success-title">Thank you!</div>
<div class="widget-success-text">
An agent will call you shortly on {phone}.<br />
We typically respond within 30 minutes during business hours.
</div>
</div>
);
}
return (
<div>
<div style={{ fontSize: '13px', fontWeight: 600, color: '#1f2937', marginBottom: '12px' }}>
Get in touch
</div>
<div style={{ fontSize: '12px', color: '#6b7280', marginBottom: '16px' }}>
Leave your details and we'll call you back.
</div>
{error && <div style={{ color: '#dc2626', fontSize: '12px', marginBottom: '8px' }}>{error}</div>}
<div class="widget-field">
<label class="widget-label">Full Name *</label>
<input class="widget-input" placeholder="Your name" value={name} onInput={(e: any) => setName(e.target.value)} />
</div>
<div class="widget-field">
<label class="widget-label">Phone Number *</label>
<input class="widget-input" placeholder="+91 9876543210" value={phone} onInput={(e: any) => setPhone(e.target.value)} />
</div>
<div class="widget-field">
<label class="widget-label">Interested In</label>
<select class="widget-select" value={interest} onChange={(e: any) => setInterest(e.target.value)}>
<option value="">Select (optional)</option>
<option value="Consultation">General Consultation</option>
<option value="Health Checkup">Health Checkup</option>
<option value="Surgery">Surgery</option>
<option value="Second Opinion">Second Opinion</option>
<option value="Other">Other</option>
</select>
</div>
<div class="widget-field">
<label class="widget-label">Message</label>
<textarea class="widget-input widget-textarea" placeholder="How can we help? (optional)" value={message} onInput={(e: any) => setMessage(e.target.value)} />
</div>
<button class="widget-btn" disabled={!name.trim() || !phone.trim() || loading} onClick={handleSubmit}>
{loading ? 'Sending...' : 'Send Message'}
</button>
</div>
);
};

View File

@@ -0,0 +1,27 @@
// FontAwesome Pro 6.7.2 Duotone SVGs — bundled as inline strings
// License: https://fontawesome.com/license (Commercial License)
export const icons = {
chat: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M0 64C0 28.7 28.7 0 64 0L448 0c35.3 0 64 28.7 64 64l0 288c0 35.3-28.7 64-64 64l-138.7 0L185.6 508.8c-4.8 3.6-11.3 4.2-16.8 1.5s-8.8-8.2-8.8-14.3l0-80-96 0c-35.3 0-64-28.7-64-64L0 64zM96 208a32 32 0 1 0 64 0 32 32 0 1 0 -64 0zm128 0a32 32 0 1 0 64 0 32 32 0 1 0 -64 0zm128 0a32 32 0 1 0 64 0 32 32 0 1 0 -64 0z"/><path class="fa-primary" d="M96 208a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm128 0a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm160-32a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"/></svg>`,
calendar: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M0 192l448 0 0 272c0 26.5-21.5 48-48 48L48 512c-26.5 0-48-21.5-48-48L0 192zM119 319c-9.4 9.4-9.4 24.6 0 33.9l64 64c4.7 4.7 10.8 7 17 7s12.3-2.3 17-7L329 305c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-95 95-47-47c-9.4-9.4-24.6-9.4-33.9 0z"/><path class="fa-primary" d="M128 0C110.3 0 96 14.3 96 32l0 32L48 64C21.5 64 0 85.5 0 112l0 80 448 0 0-80c0-26.5-21.5-48-48-48l-48 0 0-32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 32L160 64l0-32c0-17.7-14.3-32-32-32zM329 305c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-95 95-47-47c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l64 64c9.4 9.4 24.6 9.4 33.9 0L329 305z"/></svg>`,
phone: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M0 64C0 311.4 200.6 512 448 512c18 0 33.8-12.1 38.6-29.5l24-88c1-3.5 1.4-7 1.4-10.5c0-15.8-9.4-30.6-24.6-36.9l-96-40c-16.3-6.8-35.2-2.1-46.3 11.6L304.7 368C234.3 334.7 177.3 277.7 144 207.3L193.3 167c13.7-11.2 18.4-30 11.6-46.3l-40-96C158.6 9.4 143.8 0 128 0c-3.5 0-7 .5-10.5 1.4l-88 24C12.1 30.2 0 46 0 64z"/><path class="fa-primary" d="M295 217c-9.4-9.4-9.4-24.6 0-33.9l135-135L384 48c-13.3 0-24-10.7-24-24s10.7-24 24-24L488 0c13.3 0 24 10.7 24 24l0 104c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-46.1L329 217c-9.4 9.4-24.6 9.4-33.9 0z"/></svg>`,
send: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M1.4 72.3c0 6.1 1.4 12.4 4.7 18.6l70 134.6c63.3 7.9 126.6 15.8 190 23.7c3.4 .4 6 3.3 6 6.7s-2.6 6.3-6 6.7l-190 23.7L6.1 421.1c-14.6 28.1 7.3 58.6 35.2 58.6c5.3 0 10.8-1.1 16.3-3.5L492.9 285.3c11.6-5.1 19.1-16.6 19.1-29.3s-7.5-24.2-19.1-29.3L57.6 35.8C29.5 23.5 1.4 45.6 1.4 72.3z"/><path class="fa-primary" d="M76.1 286.5l190-23.7c3.4-.4 6-3.3 6-6.7s-2.6-6.3-6-6.7l-190-23.7 8.2 15.7c4.8 9.3 4.8 20.3 0 29.5l-8.2 15.7z"/></svg>`,
close: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6z"/></svg>`,
check: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M0 256a256 256 0 1 0 512 0A256 256 0 1 0 0 256zm136 0c0-6.1 2.3-12.3 7-17c9.4-9.4 24.6-9.4 33.9 0l47 47c37-37 74-74 111-111c4.7-4.7 10.8-7 17-7s12.3 2.3 17 7c2.3 2.3 4.1 5 5.3 7.9c.6 1.5 1 2.9 1.3 4.4c.2 1.1 .3 2.2 .3 2.2c.1 1.2 .1 1.2 .1 2.5c-.1 1.5-.1 1.9-.1 2.3c-.1 .7-.2 1.5-.3 2.2c-.3 1.5-.7 3-1.3 4.4c-1.2 2.9-2.9 5.6-5.3 7.9c-42.7 42.7-85.3 85.3-128 128c-4.7 4.7-10.8 7-17 7s-12.3-2.3-17-7c-21.3-21.3-42.7-42.7-64-64c-4.7-4.7-7-10.8-7-17z"/><path class="fa-primary" d="M369 175c9.4 9.4 9.4 24.6 0 33.9L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0z"/></svg>`,
sparkles: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M320 96c0 4.8 3 9.1 7.5 10.8L384 128l21.2 56.5c1.7 4.5 6 7.5 10.8 7.5s9.1-3 10.8-7.5L448 128l56.5-21.2c4.5-1.7 7.5-6 7.5-10.8s-3-9.1-7.5-10.8L448 64 426.8 7.5C425.1 3 420.8 0 416 0s-9.1 3-10.8 7.5L384 64 327.5 85.2c-4.5 1.7-7.5 6-7.5 10.8zm0 320c0 4.8 3 9.1 7.5 10.8L384 448l21.2 56.5c1.7 4.5 6 7.5 10.8 7.5s9.1-3 10.8-7.5L448 448l56.5-21.2c4.5-1.7 7.5-6 7.5-10.8s-3-9.1-7.5-10.8L448 384l-21.2-56.5c-1.7-4.5-6-7.5-10.8-7.5s-9.1 3-10.8 7.5L384 384l-56.5 21.2c-4.5 1.7-7.5 6-7.5 10.8z"/><path class="fa-primary" d="M205.1 73.3c-2.6-5.7-8.3-9.3-14.5-9.3s-11.9 3.6-14.5 9.3L123.4 187.4 9.3 240C3.6 242.6 0 248.3 0 254.6s3.6 11.9 9.3 14.5l114.1 52.7L176 435.8c2.6 5.7 8.3 9.3 14.5 9.3s11.9-3.6 14.5-9.3l52.7-114.1 114.1-52.7c5.7-2.6 9.3-8.3 9.3-14.5s-3.6-11.9-9.3-14.5L257.8 187.4 205.1 73.3z"/></svg>`,
};
// Render an icon as an HTML string with given size and color
export const icon = (name: keyof typeof icons, size = 16, color = 'currentColor'): string => {
const svg = icons[name];
return svg
.replace('<svg', `<svg width="${size}" height="${size}" style="fill:${color};vertical-align:middle;"`)
.replace(/\.fa-primary/g, '.p')
.replace(/\.fa-secondary\{opacity:\.4\}/g, `.s{opacity:.4;fill:${color}}`);
};

View File

@@ -0,0 +1,40 @@
import { render } from 'preact';
import { initApi, fetchInit } from './api';
import { Widget } from './widget';
import type { WidgetConfig } from './types';
const init = async () => {
const script = document.querySelector('script[data-key]') as HTMLScriptElement | null;
if (!script) { console.error('[HelixWidget] Missing data-key attribute'); return; }
const key = script.getAttribute('data-key') ?? '';
const baseUrl = script.src.replace(/\/widget\.js.*$/, '');
initApi(baseUrl, key);
let config: WidgetConfig;
try {
config = await fetchInit();
} catch (err) {
console.error('[HelixWidget] Init failed:', err);
return;
}
// Create shadow DOM host
const host = document.createElement('div');
host.id = 'helix-widget-host';
host.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:999999;font-family:-apple-system,sans-serif;';
document.body.appendChild(host);
const shadow = host.attachShadow({ mode: 'open' });
const mountPoint = document.createElement('div');
shadow.appendChild(mountPoint);
render(<Widget config={config} shadow={shadow} />, mountPoint);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}

View File

@@ -0,0 +1,138 @@
import type { WidgetConfig } from './types';
export const getStyles = (config: WidgetConfig) => `
:host { all: initial; font-family: -apple-system, 'Segoe UI', Roboto, sans-serif; }
* { margin: 0; padding: 0; box-sizing: border-box; }
.widget-bubble {
width: 56px; height: 56px; border-radius: 50%;
background: ${config.colors.primary}; color: #fff;
display: flex; align-items: center; justify-content: center;
cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
transition: transform 0.2s; border: none; outline: none;
}
.widget-bubble:hover { transform: scale(1.08); }
.widget-bubble img { width: 28px; height: 28px; border-radius: 6px; }
.widget-bubble svg { width: 24px; height: 24px; fill: currentColor; }
.widget-panel {
width: 380px; height: 520px; border-radius: 16px;
background: #fff; box-shadow: 0 8px 32px rgba(0,0,0,0.12);
display: flex; flex-direction: column; overflow: hidden;
border: 1px solid #e5e7eb; position: absolute; bottom: 68px; right: 0;
animation: slideUp 0.25s ease-out;
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(12px); }
to { opacity: 1; transform: translateY(0); }
}
.widget-header {
display: flex; align-items: center; gap: 10px;
padding: 14px 16px; background: ${config.colors.primary}; color: #fff;
}
.widget-header img { width: 32px; height: 32px; border-radius: 8px; }
.widget-header-text { flex: 1; }
.widget-header-name { font-size: 14px; font-weight: 600; }
.widget-header-sub { font-size: 11px; opacity: 0.8; }
.widget-close {
background: none; border: none; color: #fff; cursor: pointer;
font-size: 18px; padding: 4px; opacity: 0.8;
}
.widget-close:hover { opacity: 1; }
.widget-tabs {
display: flex; border-bottom: 1px solid #e5e7eb; background: #fafafa;
}
.widget-tab {
flex: 1; padding: 10px 0; text-align: center; font-size: 12px;
font-weight: 500; cursor: pointer; border: none; background: none;
color: #6b7280; border-bottom: 2px solid transparent;
transition: all 0.15s;
}
.widget-tab.active {
color: ${config.colors.primary}; border-bottom-color: ${config.colors.primary};
font-weight: 600;
}
.widget-body { flex: 1; overflow-y: auto; padding: 16px; }
.widget-input {
width: 100%; padding: 10px 12px; border: 1px solid #d1d5db;
border-radius: 8px; font-size: 13px; outline: none;
transition: border-color 0.15s;
}
.widget-input:focus { border-color: ${config.colors.primary}; }
.widget-textarea { resize: vertical; min-height: 60px; font-family: inherit; }
.widget-select {
width: 100%; padding: 10px 12px; border: 1px solid #d1d5db;
border-radius: 8px; font-size: 13px; background: #fff; outline: none;
}
.widget-label { font-size: 12px; font-weight: 500; color: #374151; margin-bottom: 4px; display: block; }
.widget-field { margin-bottom: 12px; }
.widget-btn {
width: 100%; padding: 10px 16px; border: none; border-radius: 8px;
font-size: 13px; font-weight: 600; cursor: pointer;
transition: opacity 0.15s; color: #fff; background: ${config.colors.primary};
}
.widget-btn:hover { opacity: 0.9; }
.widget-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.widget-btn-secondary { background: #f3f4f6; color: #374151; }
.widget-slots {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin: 8px 0;
}
.widget-slot {
padding: 8px; text-align: center; font-size: 12px; border-radius: 6px;
border: 1px solid #e5e7eb; cursor: pointer; background: #fff;
transition: all 0.15s;
}
.widget-slot:hover { border-color: ${config.colors.primary}; }
.widget-slot.selected { background: ${config.colors.primary}; color: #fff; border-color: ${config.colors.primary}; }
.widget-slot.unavailable { opacity: 0.4; cursor: not-allowed; text-decoration: line-through; }
.widget-success {
text-align: center; padding: 24px 16px;
}
.widget-success-icon { font-size: 40px; margin-bottom: 12px; }
.widget-success-title { font-size: 16px; font-weight: 600; color: #059669; margin-bottom: 8px; }
.widget-success-text { font-size: 13px; color: #6b7280; }
.chat-messages { flex: 1; overflow-y: auto; padding: 12px 0; }
.chat-msg { margin-bottom: 10px; display: flex; }
.chat-msg.user { justify-content: flex-end; }
.chat-bubble {
max-width: 80%; padding: 10px 14px; border-radius: 12px;
font-size: 13px; line-height: 1.5;
}
.chat-msg.user .chat-bubble { background: ${config.colors.primary}; color: #fff; border-bottom-right-radius: 4px; }
.chat-msg.assistant .chat-bubble { background: #f3f4f6; color: #1f2937; border-bottom-left-radius: 4px; }
.chat-input-row { display: flex; gap: 8px; padding-top: 8px; border-top: 1px solid #e5e7eb; }
.chat-input { flex: 1; }
.chat-send {
width: 36px; height: 36px; border-radius: 8px;
background: ${config.colors.primary}; color: #fff;
border: none; cursor: pointer; display: flex;
align-items: center; justify-content: center; font-size: 16px;
}
.chat-send:disabled { opacity: 0.5; }
.quick-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
.quick-action {
padding: 6px 12px; border-radius: 16px; font-size: 11px;
border: 1px solid ${config.colors.primary}; color: ${config.colors.primary};
background: ${config.colors.primaryLight}; cursor: pointer;
transition: all 0.15s;
}
.quick-action:hover { background: ${config.colors.primary}; color: #fff; }
.widget-steps { display: flex; gap: 4px; margin-bottom: 16px; }
.widget-step {
flex: 1; height: 3px; border-radius: 2px; background: #e5e7eb;
}
.widget-step.active { background: ${config.colors.primary}; }
.widget-step.done { background: #059669; }
`;

View File

@@ -0,0 +1,26 @@
export type WidgetConfig = {
brand: { name: string; logo: string };
colors: { primary: string; primaryLight: string; text: string; textLight: string };
captchaSiteKey: string;
};
export type Doctor = {
id: string;
name: string;
fullName: { firstName: string; lastName: string };
department: string;
specialty: string;
visitingHours: string;
consultationFeeNew: { amountMicros: number; currencyCode: string } | null;
clinic: { clinicName: string } | null;
};
export type TimeSlot = {
time: string;
available: boolean;
};
export type ChatMessage = {
role: 'user' | 'assistant';
content: string;
};

View File

@@ -0,0 +1,78 @@
import { useState, useEffect } from 'preact/hooks';
import type { WidgetConfig } from './types';
import { getStyles } from './styles';
import { icon } from './icons';
import { Chat } from './chat';
import { Booking } from './booking';
import { Contact } from './contact';
type Tab = 'chat' | 'book' | 'contact';
type WidgetProps = {
config: WidgetConfig;
shadow: ShadowRoot;
};
export const Widget = ({ config, shadow }: WidgetProps) => {
const [open, setOpen] = useState(false);
const [tab, setTab] = useState<Tab>('chat');
// Inject styles into shadow DOM
useEffect(() => {
const style = document.createElement('style');
style.textContent = getStyles(config);
shadow.appendChild(style);
return () => { shadow.removeChild(style); };
}, [config, shadow]);
return (
<div>
{/* Floating bubble */}
{!open && (
<button class="widget-bubble" onClick={() => setOpen(true)}>
{config.brand.logo ? (
<img src={config.brand.logo} alt={config.brand.name} />
) : (
<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z"/></svg>
)}
</button>
)}
{/* Panel */}
{open && (
<div class="widget-panel">
{/* Header */}
<div class="widget-header">
{config.brand.logo && <img src={config.brand.logo} alt="" />}
<div class="widget-header-text">
<div class="widget-header-name">{config.brand.name}</div>
<div class="widget-header-sub">We're here to help</div>
</div>
<button class="widget-close" onClick={() => setOpen(false)}>✕</button>
{/* Icons bundled from FontAwesome Pro SVGs — static, not user input */}
</div>
{/* Tabs */}
<div class="widget-tabs">
<button class={`widget-tab ${tab === 'chat' ? 'active' : ''}`} onClick={() => setTab('chat')}>
<span innerHTML={icon('chat', 14)} /> Chat
</button>
<button class={`widget-tab ${tab === 'book' ? 'active' : ''}`} onClick={() => setTab('book')}>
<span innerHTML={icon('calendar', 14)} /> Book
</button>
<button class={`widget-tab ${tab === 'contact' ? 'active' : ''}`} onClick={() => setTab('contact')}>
<span innerHTML={icon('phone', 14)} /> Contact
</button>
</div>
{/* Body */}
<div class="widget-body">
{tab === 'chat' && <Chat />}
{tab === 'book' && <Booking />}
{tab === 'contact' && <Contact />}
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Global Hospital — Widget Test</title>
<style>
body { font-family: -apple-system, sans-serif; max-width: 800px; margin: 0 auto; padding: 40px; color: #1f2937; }
h1 { font-size: 28px; margin-bottom: 8px; }
p { color: #6b7280; line-height: 1.6; }
.hero { background: #f0f9ff; border-radius: 12px; padding: 40px; margin: 40px 0; }
.hero h2 { color: #1e40af; }
</style>
</head>
<body>
<h1>🏥 Global Hospital, Bangalore</h1>
<p>Welcome to Global Hospital — Bangalore's leading multi-specialty healthcare provider.</p>
<div class="hero">
<h2>Book Your Appointment Online</h2>
<p>Click the chat bubble in the bottom-right corner to talk to our AI assistant, book an appointment, or request a callback.</p>
</div>
<h3>Our Departments</h3>
<ul>
<li>Cardiology</li>
<li>Orthopedics</li>
<li>Gynecology</li>
<li>ENT</li>
<li>General Medicine</li>
</ul>
<p style="margin-top: 40px; font-size: 12px; color: #9ca3af;">
This is a test page for the Helix Engage website widget.
The widget loads from the sidecar and renders in a shadow DOM.
</p>
<!-- Replace SITE_KEY with the generated key -->
<script src="http://localhost:4100/widget.js" data-key="8197d39c9ad946ef.31e3b1f492a7380f77ea0c90d2f86d5d4b1ac8f70fd01423ac3d85b87d9aa511"></script>
</body>
</html>

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "preact",
"strict": true,
"esModuleInterop": true,
"outDir": "dist",
"skipLibCheck": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,22 @@
import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';
export default defineConfig({
plugins: [preact()],
build: {
lib: {
entry: 'src/main.tsx',
name: 'HelixWidget',
fileName: () => 'widget.js',
formats: ['iife'],
},
outDir: './dist',
emptyOutDir: false,
minify: 'esbuild',
rollupOptions: {
output: {
inlineDynamicImports: true,
},
},
},
});

41
public/test.html Normal file
View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Global Hospital — Widget Test</title>
<style>
body { font-family: -apple-system, sans-serif; max-width: 800px; margin: 0 auto; padding: 40px; color: #1f2937; }
h1 { font-size: 28px; margin-bottom: 8px; }
p { color: #6b7280; line-height: 1.6; }
.hero { background: #f0f9ff; border-radius: 12px; padding: 40px; margin: 40px 0; }
.hero h2 { color: #1e40af; }
</style>
</head>
<body>
<h1>🏥 Global Hospital, Bangalore</h1>
<p>Welcome to Global Hospital — Bangalore's leading multi-specialty healthcare provider.</p>
<div class="hero">
<h2>Book Your Appointment Online</h2>
<p>Click the chat bubble in the bottom-right corner to talk to our AI assistant, book an appointment, or request a callback.</p>
</div>
<h3>Our Departments</h3>
<ul>
<li>Cardiology</li>
<li>Orthopedics</li>
<li>Gynecology</li>
<li>ENT</li>
<li>General Medicine</li>
</ul>
<p style="margin-top: 40px; font-size: 12px; color: #9ca3af;">
This is a test page for the Helix Engage website widget.
The widget loads from the sidecar and renders in a shadow DOM.
</p>
<!-- Replace SITE_KEY with the generated key -->
<script src="http://localhost:4100/widget.js" data-key="8197d39c9ad946ef.31e3b1f492a7380f77ea0c90d2f86d5d4b1ac8f70fd01423ac3d85b87d9aa511"></script>
</body>
</html>

463
public/widget.js Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,225 @@
/**
* Ozonetel API fixtures — accurate to the official docs (2026-04-10).
*
* These represent the EXACT shapes Ozonetel sends/returns. Used by
* unit tests to mock Ozonetel API responses and replay webhook payloads
* without a live Ozonetel account.
*
* Source: https://docs.ozonetel.com/reference
*/
// ─── Webhook "URL to Push" payloads ──────────────────────────────
// Ozonetel POSTs these to our /webhooks/ozonetel/missed-call endpoint.
// Field names match the CDR detail record (PascalCase).
export const WEBHOOK_INBOUND_ANSWERED = {
CallerID: '9949879837',
Status: 'Answered',
Type: 'InBound',
StartTime: '2026-04-09 14:30:00',
EndTime: '2026-04-09 14:34:00',
CallDuration: '00:04:00',
AgentName: 'global',
AudioFile: 'https://s3.ap-southeast-1.amazonaws.com/recordings.kookoo.in/global_healthx/20260409_143000.mp3',
monitorUCID: '31712345678901234',
Disposition: 'General Enquiry',
HangupBy: 'CustomerHangup',
DID: '918041763400',
CampaignName: 'Inbound_918041763400',
};
export const WEBHOOK_INBOUND_MISSED = {
CallerID: '6309248884',
Status: 'NotAnswered',
Type: 'InBound',
StartTime: '2026-04-09 15:00:00',
EndTime: '2026-04-09 15:00:30',
CallDuration: '00:00:00',
AgentName: '',
AudioFile: '',
monitorUCID: '31712345678905678',
Disposition: '',
HangupBy: 'CustomerHangup',
DID: '918041763400',
CampaignName: 'Inbound_918041763400',
};
export const WEBHOOK_OUTBOUND_ANSWERED = {
CallerID: '',
Status: 'Answered',
Type: 'OutBound',
StartTime: '2026-04-09 16:00:00',
EndTime: '2026-04-09 16:03:00',
CallDuration: '00:03:00',
AgentName: 'global',
AudioFile: 'https://s3.ap-southeast-1.amazonaws.com/recordings.kookoo.in/global_healthx/20260409_160000.mp3',
monitorUCID: '31712345678909999',
Disposition: 'Appointment Booked',
HangupBy: 'AgentHangup',
DID: '918041763400',
CampaignName: 'Inbound_918041763400',
};
export const WEBHOOK_OUTBOUND_NO_ANSWER = {
CallerID: '',
Status: 'NotAnswered',
Type: 'OutBound',
StartTime: '2026-04-09 16:10:00',
EndTime: '2026-04-09 16:10:45',
CallDuration: '00:00:00',
AgentName: 'global',
AudioFile: '',
monitorUCID: '31712345678908888',
Disposition: '',
HangupBy: 'Timeout',
DID: '918041763400',
CampaignName: 'Inbound_918041763400',
};
// ─── Agent Authentication ────────────────────────────────────────
// POST /CAServices/AgentAuthenticationV2/index.php
export const AGENT_AUTH_LOGIN_SUCCESS = {
status: 'success',
message: 'Agent global logged in successfully',
};
export const AGENT_AUTH_LOGIN_ALREADY = {
status: 'error',
message: 'Agent has already logged in',
};
export const AGENT_AUTH_LOGOUT_SUCCESS = {
status: 'success',
message: 'Agent global logged out successfully',
};
export const AGENT_AUTH_INVALID = {
status: 'error',
message: 'Invalid Authentication',
};
// ─── Set Disposition ─────────────────────────────────────────────
// POST /ca_apis/DispositionAPIV2 (action=Set)
export const DISPOSITION_SET_DURING_CALL = {
status: 'Success',
message: 'Disposition Queued Successfully',
};
export const DISPOSITION_SET_AFTER_CALL = {
details: 'Disposition saved successfully',
status: 'Success',
};
export const DISPOSITION_SET_UPDATE = {
status: 'Success',
message: 'Disposition Updated Successfully',
};
export const DISPOSITION_INVALID_UCID = {
status: 'Fail',
message: 'Invalid ucid',
};
export const DISPOSITION_INVALID_AGENT = {
status: 'Fail',
message: 'Invalid Agent ID',
};
// ─── CDR Detail Record ──────────────────────────────────────────
// GET /ca_reports/fetchCDRDetails
export const CDR_DETAIL_RECORD = {
AgentDialStatus: 'answered',
AgentID: 'global',
AgentName: 'global',
CallAudio: 'https://s3.ap-southeast-1.amazonaws.com/recordings.kookoo.in/global_healthx/20260409_143000.mp3',
CallDate: '2026-04-09',
CallID: 31733467784618213,
CallerConfAudioFile: '',
CallerID: '9949879837',
CampaignName: 'Inbound_918041763400',
Comments: '',
ConferenceDuration: '00:00:00',
CustomerDialStatus: 'answered',
CustomerRingTime: '00:00:05',
DID: '918041763400',
DialOutName: '',
DialStatus: 'answered',
DialedNumber: '523590',
Disposition: 'General Enquiry',
Duration: '00:04:00',
DynamicDID: '',
E164: '+919949879837',
EndTime: '14:34:00',
Event: 'AgentDial',
HandlingTime: '00:04:05',
HangupBy: 'CustomerHangup',
HoldDuration: '00:00:00',
Location: 'Bangalore',
PickupTime: '14:30:05',
Rating: 0,
RatingComments: '',
Skill: 'General',
StartTime: '14:30:00',
Status: 'Answered',
TalkTime: '00:04:00',
TimeToAnswer: '00:00:05',
TransferType: '',
TransferredTo: '',
Type: 'InBound',
UCID: 31712345678901234,
UUI: '',
WrapUpEndTime: '14:34:10',
WrapUpStartTime: '14:34:00',
WrapupDuration: '00:00:10',
};
export const CDR_RESPONSE_SUCCESS = {
status: 'success',
message: 'success',
details: [CDR_DETAIL_RECORD],
};
export const CDR_RESPONSE_EMPTY = {
status: 'success',
message: 'success',
details: [],
};
// ─── Abandon / Missed Calls ─────────────────────────────────────
// GET /ca_apis/abandonCalls
export const ABANDON_CALL_RECORD = {
monitorUCID: 31712345678905678,
type: 'InBound',
status: 'NotAnswered',
campaign: 'Inbound_918041763400',
callerID: '6309248884',
did: '918041763400',
skillID: '',
skill: '',
agentID: 'global',
agent: 'global',
hangupBy: 'CustomerHangup',
callTime: '2026-04-09 15:00:33',
};
export const ABANDON_RESPONSE_SUCCESS = {
status: 'success',
message: [ABANDON_CALL_RECORD],
};
export const ABANDON_RESPONSE_EMPTY = {
status: 'success',
message: [],
};
// ─── Get Disposition List ────────────────────────────────────────
// POST /ca_apis/DispositionAPIV2 (action=get)
export const DISPOSITION_LIST_SUCCESS = {
status: 'Success',
details: 'General Enquiry, Appointment Booked, Follow Up, Not Interested, Wrong Number, ',
};

View File

@@ -1,10 +1,16 @@
import { Controller, Post, Body, Headers, HttpException, Logger } from '@nestjs/common';
import { Controller, Post, Body, Headers, Req, Res, HttpException, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { generateText, tool, stepCountIs } from 'ai';
import type { Request, Response } from 'express';
import { generateText, streamText, Output, tool, stepCountIs } from 'ai';
import type { LanguageModel } from 'ai';
import { aiResponseSchema } from './ai-response-schema';
import { z } from 'zod';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { CallerResolutionService } from '../caller/caller-resolution.service';
import { CallerContextService } from '../caller/caller-context.service';
import { createAiModel, isAiConfigured } from './ai-provider';
import { AiConfigService } from '../config/ai-config.service';
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils';
type ChatRequest = {
message: string;
@@ -22,14 +28,21 @@ export class AiChatController {
constructor(
private config: ConfigService,
private platform: PlatformGraphqlService,
private aiConfig: AiConfigService,
private caller: CallerResolutionService,
private callerContext: CallerContextService,
) {
this.aiModel = createAiModel(config);
const cfg = aiConfig.getConfig();
this.aiModel = createAiModel({
provider: cfg.provider,
model: cfg.model,
anthropicApiKey: config.get<string>('ai.anthropicApiKey'),
openaiApiKey: config.get<string>('ai.openaiApiKey'),
});
if (!this.aiModel) {
this.logger.warn('AI not configured — chat uses fallback');
} else {
const provider = config.get<string>('ai.provider') ?? 'openai';
const model = config.get<string>('ai.model') ?? 'gpt-4o-mini';
this.logger.log(`AI configured: ${provider}/${model}`);
this.logger.log(`AI configured: ${cfg.provider}/${cfg.model}`);
}
}
@@ -61,9 +74,587 @@ export class AiChatController {
}
}
@Post('stream')
async stream(@Req() req: Request, @Res() res: Response, @Headers('authorization') auth: string) {
if (!auth) throw new HttpException('Authorization required', 401);
const body = req.body;
const messages = body.messages ?? [];
if (!messages.length) throw new HttpException('messages required', 400);
if (!this.aiModel) {
res.status(500).json({ error: 'AI not configured' });
return;
}
const ctx = body.context;
let systemPrompt: string;
// Rules engine context — use rules-specific system prompt
if (ctx?.type === 'rules-engine') {
systemPrompt = this.buildRulesSystemPrompt(ctx.currentConfig);
} else if (ctx?.type === 'supervisor') {
systemPrompt = this.buildSupervisorSystemPrompt();
} else {
const kb = await this.buildKnowledgeBase(auth);
systemPrompt = this.buildSystemPrompt(kb);
// Inject pre-fetched caller context (appointments, call history,
// activities, AI summary) so the LLM can answer from the KB
// without tool calls. No UUIDs exposed — only human-readable data.
if (ctx?.leadId) {
const callerCtx = await this.callerContext.getOrBuild(ctx.leadId, '', auth);
if (callerCtx) {
systemPrompt += `\n\n${this.callerContext.renderForPrompt(callerCtx)}`;
if (callerCtx.suggestionTriggers?.length) {
systemPrompt += this.callerContext.renderSuggestionsForPrompt(callerCtx.suggestionTriggers);
}
}
} else if (ctx?.callerPhone) {
systemPrompt += `\n\nCURRENT CONTEXT:\nCaller phone: ${ctx.callerPhone}\nNew caller — no prior records.`;
}
}
const platformService = this.platform;
const isSupervisor = ctx?.type === 'supervisor';
// Supervisor tools — agent performance, campaign stats, team metrics
const supervisorTools = {
get_agent_performance: tool({
description: 'Get performance metrics for all agents or a specific agent. Returns call counts, conversion rates, idle time, NPS scores.',
inputSchema: z.object({
agentName: z.string().optional().describe('Agent name to look up. Leave empty for all agents.'),
}),
execute: async ({ agentName }) => {
const [callsData, leadsData, agentsData, followUpsData] = await Promise.all([
platformService.queryWithAuth<any>(
`{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { id direction callStatus agentName startedAt durationSec disposition } } } }`,
undefined, auth,
),
platformService.queryWithAuth<any>(
`{ leads(first: 200) { edges { node { id assignedAgent status } } } }`,
undefined, auth,
),
platformService.queryWithAuth<any>(
// Field names are label-derived camelCase on the
// current platform schema. The legacy lowercase
// names (ozonetelagentid etc.) only still exist on
// staging workspaces that were synced from an
// older SDK. See agent-config.service.ts for the
// canonical explanation.
`{ agents(first: 20) { edges { node { id name ozonetelAgentId npsScore maxIdleMinutes minNpsThreshold minConversion } } } }`,
undefined, auth,
),
platformService.queryWithAuth<any>(
`{ followUps(first: 100) { edges { node { id assignedAgent status } } } }`,
undefined, auth,
),
]);
const calls = callsData.calls.edges.map((e: any) => e.node);
const leads = leadsData.leads.edges.map((e: any) => e.node);
const agents = agentsData.agents.edges.map((e: any) => e.node);
const followUps = followUpsData.followUps.edges.map((e: any) => e.node);
const agentMetrics = agents
.filter((a: any) => !agentName || a.name.toLowerCase().includes(agentName.toLowerCase()))
.map((agent: any) => {
const agentCalls = calls.filter((c: any) => c.agentName === agent.name || c.agentName === agent.ozonetelAgentId);
const totalCalls = agentCalls.length;
const missed = agentCalls.filter((c: any) => c.callStatus === 'MISSED').length;
const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
const apptBooked = agentCalls.filter((c: any) => c.disposition === 'APPOINTMENT_BOOKED').length;
const agentLeads = leads.filter((l: any) => l.assignedAgent === agent.name);
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name);
const pendingFollowUps = agentFollowUps.filter((f: any) => f.status === 'PENDING' || f.status === 'OVERDUE').length;
const conversionRate = totalCalls > 0 ? Math.round((apptBooked / totalCalls) * 100) : 0;
return {
name: agent.name,
totalCalls,
completed,
missed,
appointmentsBooked: apptBooked,
conversionRate: `${conversionRate}%`,
assignedLeads: agentLeads.length,
pendingFollowUps,
npsScore: agent.npsScore,
maxIdleMinutes: agent.maxIdleMinutes,
minNpsThreshold: agent.minNpsThreshold,
minConversionPercent: agent.minConversion,
belowNpsThreshold: agent.minNpsThreshold && (agent.npsScore ?? 100) < agent.minNpsThreshold,
belowConversionThreshold: agent.minConversion && conversionRate < agent.minConversion,
};
});
return { agents: agentMetrics, totalAgents: agentMetrics.length };
},
}),
get_campaign_stats: tool({
description: 'Get campaign performance stats — lead counts, conversion rates, sources.',
inputSchema: z.object({}),
execute: async () => {
const [campaignsData, leadsData] = await Promise.all([
platformService.queryWithAuth<any>(
`{ campaigns(first: 20) { edges { node { id campaignName campaignStatus platform leadCount convertedCount budget { amountMicros } } } } }`,
undefined, auth,
),
platformService.queryWithAuth<any>(
`{ leads(first: 200) { edges { node { id campaignId status } } } }`,
undefined, auth,
),
]);
const campaigns = campaignsData.campaigns.edges.map((e: any) => e.node);
const leads = leadsData.leads.edges.map((e: any) => e.node);
return {
campaigns: campaigns.map((c: any) => {
const campaignLeads = leads.filter((l: any) => l.campaignId === c.id);
const converted = campaignLeads.filter((l: any) => l.status === 'CONVERTED' || l.status === 'APPOINTMENT_SET').length;
return {
name: c.campaignName,
status: c.campaignStatus,
platform: c.platform,
totalLeads: campaignLeads.length,
converted,
conversionRate: campaignLeads.length > 0 ? `${Math.round((converted / campaignLeads.length) * 100)}%` : '0%',
budget: c.budget ? `${c.budget.amountMicros / 1_000_000}` : null,
};
}),
};
},
}),
get_call_summary: tool({
description: 'Get aggregate call statistics — total calls, inbound/outbound split, missed call rate, average duration, disposition breakdown.',
inputSchema: z.object({
period: z.string().optional().describe('Period: "today", "week", "month". Defaults to "week".'),
}),
execute: async ({ period }) => {
const data = await platformService.queryWithAuth<any>(
`{ calls(first: 500, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { id direction callStatus agentName startedAt durationSec disposition } } } }`,
undefined, auth,
);
const allCalls = data.calls.edges.map((e: any) => e.node);
// Filter by period
const now = new Date();
const start = new Date(now);
if (period === 'today') start.setHours(0, 0, 0, 0);
else if (period === 'month') start.setDate(start.getDate() - 30);
else start.setDate(start.getDate() - 7); // default week
const calls = allCalls.filter((c: any) => c.startedAt && new Date(c.startedAt) >= start);
const total = calls.length;
const inbound = calls.filter((c: any) => c.direction === 'INBOUND').length;
const outbound = total - inbound;
const missed = calls.filter((c: any) => c.callStatus === 'MISSED').length;
const completed = calls.filter((c: any) => c.callStatus === 'COMPLETED').length;
const totalDuration = calls.reduce((sum: number, c: any) => sum + (c.durationSec ?? 0), 0);
const avgDuration = completed > 0 ? Math.round(totalDuration / completed) : 0;
const dispositions: Record<string, number> = {};
for (const c of calls) {
if (c.disposition) dispositions[c.disposition] = (dispositions[c.disposition] ?? 0) + 1;
}
return {
period: period ?? 'week',
total,
inbound,
outbound,
missed,
completed,
missedRate: total > 0 ? `${Math.round((missed / total) * 100)}%` : '0%',
avgDurationSeconds: avgDuration,
dispositions,
};
},
}),
get_sla_breaches: tool({
description: 'Get calls/leads that have breached their SLA — items that were not handled within the expected timeframe.',
inputSchema: z.object({}),
execute: async () => {
const data = await platformService.queryWithAuth<any>(
`{ calls(first: 100, filter: { callStatus: { eq: MISSED }, callbackStatus: { eq: PENDING_CALLBACK } }) { edges { node { id callerNumber { primaryPhoneNumber } startedAt agentName sla } } } }`,
undefined, auth,
);
const breached = data.calls.edges
.map((e: any) => e.node)
.filter((c: any) => (c.sla ?? 0) > 100);
return {
breachedCount: breached.length,
items: breached.map((c: any) => ({
id: c.id,
phone: c.callerNumber?.primaryPhoneNumber ?? 'Unknown',
slaPercent: c.sla,
missedAt: c.startedAt,
agent: c.agentName,
})),
};
},
}),
};
// Agent tools — patient lookup, appointments, doctors
//
// UUID safety: LLMs hallucinate 36-char identifiers once the context
// starts wearing thin (dropped hyphens, swapped chars). To keep the
// model off the UUID path for "this caller" questions, the tools
// below accept their id arguments OPTIONALLY — when omitted we fall
// back to the leadId carried on the call context, and resolve
// patientId from it server-side. The model is instructed (see
// ccAgentHelper prompt) to omit the id entirely when asking about
// the current caller, so it never has to echo the UUID back.
//
// Every tool below logs a one-line structured trace via `toolLog`:
// [AI-TOOL] <name> args=<...> resolved=<...> result=<...>
// This lets us see which tool the model chose, whether it passed
// the UUID through or used the context fallback, and what came
// back. Tail sidecar logs while testing and you'll see the full
// orchestration trail for each chat turn.
const logger = this.logger;
const toolLog = (name: string, args: Record<string, unknown>, outcome: Record<string, unknown>) => {
// Print full values — UUIDs in particular are kept intact so we
// can diff the model's argument against the platform record when
// hunting hallucinated ids. Grep with `AI-TOOL` to pull the
// orchestration trail for a given chat turn.
const argStr = Object.entries(args).map(([k, v]) => `${k}=${v ?? '∅'}`).join(' ');
const outStr = Object.entries(outcome).map(([k, v]) => `${k}=${v ?? '∅'}`).join(' ');
logger.log(`[AI-TOOL] ${name} ${argStr}${outStr}`);
};
let cachedPatientId: string | undefined;
const resolveLeadId = (arg?: string): string | undefined => arg || ctx?.leadId || undefined;
const resolvePatientId = async (arg?: string): Promise<string | undefined> => {
if (arg) return arg;
if (cachedPatientId) return cachedPatientId;
const lid = ctx?.leadId;
if (!lid) return undefined;
try {
const data = await platformService.queryWithAuth<any>(
`{ lead(filter: { id: { eq: "${lid}" } }) { id patientId } }`,
undefined, auth,
);
cachedPatientId = data?.lead?.patientId ?? undefined;
logger.log(`[AI-TOOL] resolvePatientId lead=${lid} patientId=${cachedPatientId ?? '∅'}`);
return cachedPatientId;
} catch (err: any) {
logger.warn(`[AI-TOOL] resolvePatientId failed: ${err?.message ?? err}`);
return undefined;
}
};
const agentTools = {
lookup_patient: tool({
description: 'Search for a patient/lead by phone number or name. Returns their profile, lead status, AI summary.',
inputSchema: z.object({
phone: z.string().optional().describe('Phone number to search'),
name: z.string().optional().describe('Patient/lead name to search'),
}),
execute: async ({ phone, name }) => {
const data = await platformService.queryWithAuth<any>(
`{ leads(first: 50) { edges { node {
id name contactName { firstName lastName }
contactPhone { primaryPhoneNumber }
source status interestedService
contactAttempts lastContacted
aiSummary aiSuggestedAction patientId
} } } }`,
undefined, auth,
);
const leads = data.leads.edges.map((e: any) => e.node);
const phoneClean = (phone ?? '').replace(/\D/g, '');
const nameClean = (name ?? '').toLowerCase();
const matched = leads.filter((l: any) => {
if (phoneClean) {
const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, '');
if (lp.endsWith(phoneClean) || phoneClean.endsWith(lp)) return true;
}
if (nameClean) {
const fn = `${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase();
if (fn.includes(nameClean)) return true;
}
return false;
});
toolLog('lookup_patient', { phone, name }, { scanned: leads.length, matched: matched.length });
if (!matched.length) return { found: false, message: 'No patient/lead found.' };
return { found: true, count: matched.length, leads: matched };
},
}),
lookup_appointments: tool({
description: 'Get appointments for a patient. Omit patientId to use the current caller — do NOT re-type a UUID you saw in context; just call with no arguments.',
inputSchema: z.object({
patientId: z.string().optional().describe('Patient ID (omit when asking about the current caller)'),
}),
execute: async ({ patientId }) => {
const resolved = await resolvePatientId(patientId);
if (!resolved) {
toolLog('lookup_appointments', { patientId }, { resolved: null, result: 'no-context' });
return { appointments: [], message: 'No patient context — ask the agent which patient.' };
}
const data = await platformService.queryWithAuth<any>(
`{ appointments(first: 20, filter: { patientId: { eq: "${resolved}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id scheduledAt status doctorName department reasonForVisit
} } } }`,
undefined, auth,
);
const appointments = data.appointments.edges.map((e: any) => e.node);
toolLog('lookup_appointments', { patientId }, { resolved, count: appointments.length });
return { appointments };
},
}),
lookup_doctor: tool({
description: 'Get doctor details — schedule, clinic, fees, specialty.',
inputSchema: z.object({
doctorName: z.string().describe('Doctor name'),
}),
execute: async ({ doctorName }) => {
const data = await platformService.queryWithAuth<any>(
`{ doctors(first: 10) { edges { node {
id fullName { firstName lastName }
department specialty
consultationFeeNew { amountMicros currencyCode }
${DOCTOR_VISIT_SLOTS_FRAGMENT}
} } } }`,
undefined, auth,
);
const doctors = normalizeDoctors(data.doctors.edges.map((e: any) => e.node));
// Strip "Dr." prefix and search flexibly
const search = doctorName.toLowerCase().replace(/^dr\.?\s*/i, '').trim();
const searchWords = search.split(/\s+/);
const matched = doctors.filter((d: any) => {
const fn = (d.fullName?.firstName ?? '').toLowerCase();
const ln = (d.fullName?.lastName ?? '').toLowerCase();
const full = `${fn} ${ln}`;
return searchWords.some(w => w.length > 1 && (fn.includes(w) || ln.includes(w) || full.includes(w)));
});
toolLog('lookup_doctor', { doctorName }, { scanned: doctors.length, matched: matched.length });
if (!matched.length) return { found: false, message: `No doctor matching "${doctorName}". Available: ${doctors.map((d: any) => `Dr. ${d.fullName?.lastName ?? d.fullName?.firstName}`).join(', ')}` };
return { found: true, doctors: matched };
},
}),
book_appointment: tool({
description: 'Book an appointment for a patient. Collect patient name, phone, department, doctor, clinic/branch, preferred date/time, and reason before calling this.',
inputSchema: z.object({
patientName: z.string().describe('Full name of the patient'),
phoneNumber: z.string().describe('Patient phone number'),
department: z.string().describe('Department for the appointment'),
doctorName: z.string().describe('Doctor name'),
clinicId: z.string().optional().describe('Clinic/branch ID — get from lookup_doctor results'),
scheduledAt: z.string().describe('Date and time in ISO format (e.g. 2026-04-01T10:00:00)'),
reason: z.string().describe('Reason for visit'),
}),
execute: async ({ patientName, phoneNumber, department, doctorName, clinicId, scheduledAt, reason }) => {
toolLog('book_appointment', { patientName, phoneNumber, department, doctorName, clinicId, scheduledAt }, { reason: reason?.slice(0, 40) });
try {
const result = await platformService.queryWithAuth<any>(
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
{
data: {
name: `AI Booking — ${patientName} (${department})`,
scheduledAt,
status: 'SCHEDULED',
doctorName,
department,
reasonForVisit: reason,
...(clinicId ? { clinicId } : {}),
},
},
auth,
);
const id = result?.createAppointment?.id;
if (id) {
toolLog('book_appointment', { doctorName }, { booked: true, appointmentId: id });
return { booked: true, appointmentId: id, message: `Appointment booked for ${patientName} with ${doctorName} on ${scheduledAt}. Reference: ${id.substring(0, 8)}` };
}
toolLog('book_appointment', { doctorName }, { booked: false });
return { booked: false, message: 'Appointment creation failed.' };
} catch (err: any) {
logger.error(`[AI-TOOL] book_appointment failed: ${err.message}`);
return { booked: false, message: `Failed to book: ${err.message}` };
}
},
}),
create_lead: tool({
description: 'Create a new lead/enquiry for a caller who is not an existing patient. Collect name, phone, and interest.',
inputSchema: z.object({
name: z.string().describe('Caller name'),
phoneNumber: z.string().describe('Phone number'),
interest: z.string().describe('What they are enquiring about'),
}),
execute: async ({ name, phoneNumber, interest }) => {
toolLog('create_lead', { name, phoneNumber, interest: interest?.slice(0, 40) }, {});
try {
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
const resolved = await this.caller.resolve(cleanPhone, auth);
const firstName = name.split(' ')[0];
const lastName = name.split(' ').slice(1).join(' ') || '';
if (resolved.isNew) {
// Net-new caller — create Patient + Lead with
// the AI-collected name from the conversation.
let patientId: string | undefined;
try {
const p = await platformService.queryWithAuth<any>(
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
{
data: {
fullName: { firstName, lastName },
phones: { primaryPhoneNumber: `+91${cleanPhone}` },
patientType: 'NEW',
},
},
auth,
);
patientId = p?.createPatient?.id;
} catch (err: any) {
logger.warn(`[AI-TOOL] create_lead patient create failed: ${err.message}`);
}
const created = await platformService.queryWithAuth<any>(
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
{
data: {
name: `AI Enquiry — ${name}`,
contactName: { firstName, lastName },
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
source: 'PHONE',
status: 'NEW',
interestedService: interest,
...(patientId ? { patientId } : {}),
},
},
auth,
);
const id = created?.createLead?.id;
if (id) {
toolLog('create_lead', { name }, { created: true, isNew: true, leadId: id });
return { created: true, leadId: id, message: `Lead created for ${name}. Our team will follow up on ${phoneNumber}.` };
}
toolLog('create_lead', { name }, { created: false });
return { created: false, message: 'Lead creation failed.' };
}
// Existing record — update with AI-collected name.
await platformService.queryWithAuth<any>(
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
{
id: resolved.leadId,
data: {
name: `AI Enquiry — ${name}`,
contactName: { firstName, lastName },
source: 'PHONE',
status: 'NEW',
interestedService: interest,
},
},
auth,
);
if (resolved.patientId) {
await platformService.queryWithAuth<any>(
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
{ id: resolved.patientId, data: { fullName: { firstName, lastName } } },
auth,
).catch(() => {});
}
toolLog('create_lead', { name }, { created: true, isNew: false, leadId: resolved.leadId });
return { created: true, leadId: resolved.leadId, message: `Lead updated for ${name}. Our team will follow up on ${phoneNumber}.` };
} catch (err: any) {
logger.error(`[AI-TOOL] create_lead failed: ${err.message}`);
return { created: false, message: `Failed: ${err.message}` };
}
},
}),
lookup_call_history: tool({
description: 'Get call log for a lead — all inbound/outbound calls with dispositions and durations. Omit leadId to use the current caller — do NOT re-type a UUID you saw in context; just call with no arguments.',
inputSchema: z.object({
leadId: z.string().optional().describe('Lead ID (omit when asking about the current caller)'),
}),
execute: async ({ leadId }) => {
const resolved = resolveLeadId(leadId);
if (!resolved) {
toolLog('lookup_call_history', { leadId }, { resolved: null, result: 'no-context' });
return { calls: [], message: 'No lead context — ask the agent which caller.' };
}
const data = await platformService.queryWithAuth<any>(
`{ calls(first: 20, filter: { leadId: { eq: "${resolved}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
id direction callStatus agentName startedAt durationSec disposition
} } } }`,
undefined, auth,
);
const calls = data.calls.edges.map((e: any) => e.node);
toolLog('lookup_call_history', { leadId }, { resolved, count: calls.length });
return { calls };
},
}),
lookup_lead_activities: tool({
description: 'Get activity log entries for a lead — notes, status changes, enquiries. Omit leadId to use the current caller — do NOT re-type a UUID you saw in context.',
inputSchema: z.object({
leadId: z.string().optional().describe('Lead ID (omit when asking about the current caller)'),
}),
execute: async ({ leadId }) => {
const resolved = resolveLeadId(leadId);
if (!resolved) {
toolLog('lookup_lead_activities', { leadId }, { resolved: null, result: 'no-context' });
return { activities: [], message: 'No lead context — ask the agent which caller.' };
}
const data = await platformService.queryWithAuth<any>(
`{ leadActivities(first: 20, filter: { leadId: { eq: "${resolved}" } }, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node {
id activityType summary occurredAt performedBy channel outcome
} } } }`,
undefined, auth,
);
const activities = data.leadActivities.edges.map((e: any) => e.node);
toolLog('lookup_lead_activities', { leadId }, { resolved, count: activities.length });
return { activities };
},
}),
};
const result = streamText({
model: this.aiModel,
system: systemPrompt,
messages,
stopWhen: stepCountIs(5),
tools: isSupervisor ? supervisorTools : agentTools,
...(isSupervisor ? {} : { output: Output.object({ schema: aiResponseSchema }) }),
});
const response = result.toTextStreamResponse();
res.status(response.status);
response.headers.forEach((value, key) => res.setHeader(key, value));
if (response.body) {
const reader = response.body.getReader();
const pump = async () => {
while (true) {
const { done, value } = await reader.read();
if (done) { res.end(); break; }
res.write(value);
}
};
pump().catch(() => res.end());
} else {
res.end();
}
}
private async buildKnowledgeBase(auth: string): Promise<string> {
const now = Date.now();
if (this.knowledgeBase && now - this.kbLoadedAt < this.kbTtlMs) {
this.logger.log(`KB cache hit (${this.knowledgeBase.length} chars, age ${Math.round((now - this.kbLoadedAt) / 1000)}s)`);
return this.knowledgeBase;
}
@@ -75,33 +666,48 @@ export class AiChatController {
`{ clinics(first: 20) { edges { node {
id name clinicName
addressCustom { addressStreet1 addressCity addressState addressPostcode }
weekdayHours saturdayHours sundayHours
openMonday openTuesday openWednesday openThursday openFriday openSaturday openSunday
opensAt closesAt
status walkInAllowed onlineBooking
cancellationWindowHours arriveEarlyMin requiredDocuments
cancellationWindowHours arriveEarlyMin
acceptsCash acceptsCard acceptsUpi
requiredDocuments { edges { node { documentType notes } } }
} } } }`,
undefined, auth,
);
const clinics = clinicData.clinics.edges.map((e: any) => e.node);
if (clinics.length) {
sections.push('## Clinics');
sections.push('## CLINICS & TIMINGS');
const dayFlags: Array<[string, string]> = [
['Mon', 'openMonday'], ['Tue', 'openTuesday'], ['Wed', 'openWednesday'],
['Thu', 'openThursday'], ['Fri', 'openFriday'],
['Sat', 'openSaturday'], ['Sun', 'openSunday'],
];
for (const c of clinics) {
const name = c.clinicName ?? c.name;
const addr = c.addressCustom
? [c.addressCustom.addressStreet1, c.addressCustom.addressCity].filter(Boolean).join(', ')
: '';
const hours = [
c.weekdayHours ? `MonFri ${c.weekdayHours}` : '',
c.saturdayHours ? `Sat ${c.saturdayHours}` : '',
c.sundayHours ? `Sun ${c.sundayHours}` : 'Sun closed',
].filter(Boolean).join(', ');
sections.push(`- ${c.clinicName ?? c.name}: ${addr}. ${hours}.`);
sections.push(`### ${name}`);
if (addr) sections.push(` Address: ${addr}`);
const openDays = dayFlags.filter(([, flag]) => c[flag]).map(([label]) => label);
if (openDays.length) {
const hours = c.opensAt && c.closesAt ? ` ${c.opensAt}${c.closesAt}` : '';
sections.push(` Open: ${openDays.join(', ')}${hours}`);
}
const closedDays = dayFlags.filter(([, flag]) => !c[flag]).map(([label]) => label);
if (closedDays.length) {
sections.push(` Closed: ${closedDays.join(', ')}`);
}
if (c.walkInAllowed) sections.push(` Walk-ins: Accepted`);
}
const rulesClinic = clinics[0];
const rules: string[] = [];
if (rulesClinic.cancellationWindowHours) rules.push(`Free cancellation up to ${rulesClinic.cancellationWindowHours}h before`);
if (rulesClinic.arriveEarlyMin) rules.push(`Arrive ${rulesClinic.arriveEarlyMin}min early`);
if (rulesClinic.requiredDocuments) rules.push(`First-time patients bring ${rulesClinic.requiredDocuments}`);
const docs = rulesClinic.requiredDocuments?.edges?.map((e: any) => e.node?.documentType).filter(Boolean) ?? [];
if (docs.length) rules.push(`First-time patients bring: ${docs.join(', ')}`);
if (rulesClinic.walkInAllowed) rules.push('Walk-ins accepted');
if (rulesClinic.onlineBooking) rules.push('Online booking available');
if (rules.length) {
@@ -120,6 +726,39 @@ export class AiChatController {
}
} catch (err) {
this.logger.warn(`Failed to fetch clinics: ${err}`);
sections.push('## CLINICS\nFailed to load clinic data.');
}
// Add doctors to KB
try {
const docData = await this.platform.queryWithAuth<any>(
`{ doctors(first: 20) { edges { node {
id fullName { firstName lastName } department specialty
consultationFeeNew { amountMicros currencyCode }
${DOCTOR_VISIT_SLOTS_FRAGMENT}
} } } }`,
undefined, auth,
);
const doctors = normalizeDoctors(docData.doctors.edges.map((e: any) => e.node));
if (doctors.length) {
sections.push('\n## DOCTORS');
for (const d of doctors) {
const name = `Dr. ${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''}`.trim();
const fee = d.consultationFeeNew ? `${d.consultationFeeNew.amountMicros / 1_000_000}` : '';
// List ALL clinics this doctor visits in the KB so
// the AI can answer questions like "where can I see
// Dr. X" without needing a follow-up tool call.
const clinics = d.clinics.map((c) => c.clinicName).join(', ');
sections.push(`### ${name}`);
sections.push(` Department: ${d.department ?? 'N/A'}`);
sections.push(` Specialty: ${d.specialty ?? 'N/A'}`);
if (d.visitingHours) sections.push(` Hours: ${d.visitingHours}`);
if (fee) sections.push(` Consultation fee: ${fee}`);
if (clinics) sections.push(` Clinics: ${clinics}`);
}
}
} catch (err) {
this.logger.warn(`Failed to fetch doctors for KB: ${err}`);
}
try {
@@ -134,8 +773,8 @@ export class AiChatController {
undefined, auth,
);
const packages = pkgData.healthPackages.edges.map((e: any) => e.node);
if (packages.length) {
sections.push('\n## Health Packages');
if (packages.length) {
for (const p of packages) {
const price = p.price ? `${p.price.amountMicros / 1_000_000}` : '';
const disc = p.discountedPrice?.amountMicros ? ` (discounted: ₹${p.discountedPrice.amountMicros / 1_000_000})` : '';
@@ -152,9 +791,12 @@ export class AiChatController {
sections.push(` Includes: ${p.inclusions}`);
}
}
} else {
sections.push('No packages available.');
}
} catch (err) {
this.logger.warn(`Failed to fetch health packages: ${err}`);
sections.push('\n## Health Packages\nFailed to load package data.');
}
try {
@@ -175,6 +817,7 @@ export class AiChatController {
}
} catch (err) {
this.logger.warn(`Failed to fetch insurance partners: ${err}`);
sections.push('\n## Insurance Partners\nFailed to load insurance data.');
}
this.knowledgeBase = sections.join('\n') || 'No hospital information available yet.';
@@ -183,26 +826,76 @@ export class AiChatController {
return this.knowledgeBase;
}
private buildSupervisorSystemPrompt(): string {
return this.aiConfig.renderPrompt('supervisorChat', {
hospitalName: this.getHospitalName(),
});
}
// Best-effort hospital name lookup for the AI prompts. Falls back
// to a generic label so prompt rendering never throws.
private getHospitalName(): string {
return process.env.HOSPITAL_NAME ?? 'the hospital';
}
private buildRulesSystemPrompt(currentConfig: any): string {
const configJson = JSON.stringify(currentConfig, null, 2);
return `You are an AI assistant helping a hospital supervisor configure the Rules Engine for their call center worklist.
## YOUR ROLE
You help the supervisor understand and optimize priority scoring rules. You explain concepts clearly and make specific recommendations based on their current configuration.
## SCORING FORMULA
finalScore = baseWeight × slaMultiplier × campaignMultiplier
- **Base Weight** (0-10): Each task type (missed calls, follow-ups, campaign leads, 2nd/3rd attempts) has a configurable weight. Higher weight = higher priority in the worklist.
- **SLA Multiplier**: Time-based urgency curve. Formula: elapsed^1.6 (accelerates as SLA deadline approaches). Past SLA breach: 1.0 + (excess × 0.5). This means items get increasingly urgent as they approach their SLA deadline.
- **Campaign Multiplier**: (campaignWeight/10) × (sourceWeight/10). IVF(9) × WhatsApp(9) = 0.81. Health(7) × Instagram(5) = 0.35.
- **SLA Thresholds**: Each task type has an SLA in minutes. Missed calls default to 12h (720min), follow-ups to 1d (1440min), campaign leads to 2d (2880min).
## SLA STATUS COLORS
- Green (low): < 50% SLA elapsed
- Amber (medium): 50-80% SLA elapsed
- Red (high): 80-100% SLA elapsed
- Dark red pulsing (critical): > 100% SLA elapsed (breached)
## PRIORITY RULES vs AUTOMATION RULES
- **Priority Rules** (what the supervisor is configuring now): Determine worklist order. Computed in real-time at request time. No permanent data changes.
- **Automation Rules** (coming soon): Trigger durable actions — assign leads to agents, escalate SLA breaches to supervisors, update lead status automatically. These write back to entity fields and need a draft/publish workflow.
## BEST PRACTICES FOR HOSPITAL CALL CENTERS
- Missed calls should have the highest weight (8-10) — these are patients who tried to reach you
- Follow-ups should be high (7-9) — you committed to calling them back
- Campaign leads vary by campaign value (5-8)
- SLA for missed calls: 4-12 hours (shorter = more responsive)
- SLA for follow-ups: 12-24 hours
- High-value campaigns (IVF, cancer screening): weight 8-9
- General campaigns (health checkup): weight 5-7
- WhatsApp/Phone leads convert better than social media → weight them higher
## CURRENT CONFIGURATION
${configJson}
## RULES
1. Be concise — under 100 words unless asked for detail
2. When recommending changes, be specific: "Set missed call weight to 9, SLA to 8 hours"
3. Explain WHY a change matters: "This ensures IVF patients get called within 4 hours"
4. Reference the scoring formula when explaining scores
5. If asked about automation rules, explain the concept and say it's coming soon`;
}
private buildSystemPrompt(kb: string): string {
return `You are an AI assistant for call center agents at a hospital.
You help agents answer questions about patients, doctors, appointments, and hospital services during live calls.
RULES:
1. For patient-specific questions, you MUST use lookup tools. NEVER guess patient data.
2. For doctor schedule/fees, use the lookup_doctor tool to get real data from the system.
3. For hospital info (clinics, packages, insurance), use the knowledge base below.
4. If a tool returns no data, say "I couldn't find that in our system."
5. Be concise — agents are on live calls. Under 100 words unless asked for detail.
6. NEVER give medical advice, diagnosis, or treatment recommendations.
7. NEVER share sensitive hospital data (revenue, salaries, internal policies).
8. Format with bullet points for easy scanning.
${kb}`;
return this.aiConfig.renderPrompt('ccAgentHelper', {
hospitalName: this.getHospitalName(),
knowledgeBase: kb,
});
}
private async chatWithTools(userMessage: string, auth: string) {
const kb = await this.buildKnowledgeBase(auth);
this.logger.log(`KB content preview: ${kb.substring(0, 300)}...`);
const systemPrompt = this.buildSystemPrompt(kb);
this.logger.log(`System prompt length: ${systemPrompt.length} chars, user message: "${userMessage.substring(0, 100)}"`);
const platformService = this.platform;
const { text, steps } = await generateText({
@@ -309,16 +1002,15 @@ ${kb}`;
`{ doctors(first: 10) { edges { node {
id name fullName { firstName lastName }
department specialty qualifications yearsOfExperience
visitingHours
consultationFeeNew { amountMicros currencyCode }
consultationFeeFollowUp { amountMicros currencyCode }
active registrationNumber
clinic { id name clinicName }
${DOCTOR_VISIT_SLOTS_FRAGMENT}
} } } }`,
undefined, auth,
);
const doctors = data.doctors.edges.map((e: any) => e.node);
const doctors = normalizeDoctors(data.doctors.edges.map((e: any) => e.node));
const search = doctorName.toLowerCase();
const matched = doctors.filter((d: any) => {
const full = `${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''} ${d.name ?? ''}`.toLowerCase();
@@ -331,7 +1023,13 @@ ${kb}`;
found: true,
doctors: matched.map((d: any) => ({
...d,
clinicName: d.clinic?.clinicName ?? d.clinic?.name ?? 'N/A',
// Multi-clinic doctors show as
// "Koramangala / Indiranagar" so the
// model has the full picture without
// a follow-up tool call.
clinicName: d.clinics.length > 0
? d.clinics.map((c: { clinicName: string }) => c.clinicName).join(' / ')
: 'N/A',
feeNewFormatted: d.consultationFeeNew ? `${d.consultationFeeNew.amountMicros / 1_000_000}` : 'N/A',
feeFollowUpFormatted: d.consultationFeeFollowUp ? `${d.consultationFeeFollowUp.amountMicros / 1_000_000}` : 'N/A',
})),
@@ -355,13 +1053,13 @@ ${kb}`;
try {
const doctors = await this.platform.queryWithAuth<any>(
`{ doctors(first: 10) { edges { node {
name fullName { firstName lastName } department specialty visitingHours
id name fullName { firstName lastName } department specialty
consultationFeeNew { amountMicros currencyCode }
clinic { name clinicName }
${DOCTOR_VISIT_SLOTS_FRAGMENT}
} } } }`,
undefined, auth,
);
const docs = doctors.doctors.edges.map((e: any) => e.node);
const docs = normalizeDoctors(doctors.doctors.edges.map((e: any) => e.node));
const l = msg.toLowerCase();
const matchedDoc = docs.find((d: any) => {
@@ -371,7 +1069,7 @@ ${kb}`;
if (matchedDoc) {
const fee = matchedDoc.consultationFeeNew ? `${matchedDoc.consultationFeeNew.amountMicros / 1_000_000}` : '';
const clinic = matchedDoc.clinic?.clinicName ?? '';
return `Dr. ${matchedDoc.fullName?.lastName ?? matchedDoc.name} (${matchedDoc.department ?? matchedDoc.specialty}): ${matchedDoc.visitingHours ?? 'hours not set'}${clinic ? ` at ${clinic}` : ''}${fee ? `. Fee: ${fee}` : ''}.`;
return `Dr. ${matchedDoc.fullName?.lastName ?? matchedDoc.name} (${matchedDoc.department ?? matchedDoc.specialty}): ${matchedDoc.visitingHours || 'hours not set'}${clinic ? ` at ${clinic}` : ''}${fee ? `. Fee: ${fee}` : ''}.`;
}
if (l.includes('doctor') || l.includes('available')) {

View File

@@ -4,6 +4,7 @@ import { generateObject } from 'ai';
import type { LanguageModel } from 'ai';
import { z } from 'zod';
import { createAiModel } from './ai-provider';
import { AiConfigService } from '../config/ai-config.service';
type LeadContext = {
firstName?: string;
@@ -32,8 +33,17 @@ export class AiEnrichmentService {
private readonly logger = new Logger(AiEnrichmentService.name);
private readonly aiModel: LanguageModel | null;
constructor(private config: ConfigService) {
this.aiModel = createAiModel(config);
constructor(
private config: ConfigService,
private aiConfig: AiConfigService,
) {
const cfg = aiConfig.getConfig();
this.aiModel = createAiModel({
provider: cfg.provider,
model: cfg.model,
anthropicApiKey: config.get<string>('ai.anthropicApiKey'),
openaiApiKey: config.get<string>('ai.openaiApiKey'),
});
if (!this.aiModel) {
this.logger.warn('AI not configured — enrichment uses fallback');
}
@@ -56,19 +66,15 @@ export class AiEnrichmentService {
const { object } = await generateObject({
model: this.aiModel!,
schema: enrichmentSchema,
prompt: `You are an AI assistant for a hospital call center.
An inbound call is coming in from a lead. Summarize their history and suggest what the call center agent should do.
Lead details:
- Name: ${lead.firstName ?? 'Unknown'} ${lead.lastName ?? ''}
- Source: ${lead.leadSource ?? 'Unknown'}
- Interested in: ${lead.interestedService ?? 'Unknown'}
- Current status: ${lead.leadStatus ?? 'Unknown'}
- Lead age: ${daysSince} days
- Contact attempts: ${lead.contactAttempts ?? 0}
Recent activity:
${activitiesText}`,
prompt: this.aiConfig.renderPrompt('leadEnrichment', {
leadName: `${lead.firstName ?? 'Unknown'} ${lead.lastName ?? ''}`.trim(),
leadSource: lead.leadSource ?? 'Unknown',
interestedService: lead.interestedService ?? 'Unknown',
leadStatus: lead.leadStatus ?? 'Unknown',
daysSince,
contactAttempts: lead.contactAttempts ?? 0,
activities: activitiesText,
}),
});
this.logger.log(`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`);

View File

@@ -1,26 +1,30 @@
import { ConfigService } from '@nestjs/config';
import { anthropic } from '@ai-sdk/anthropic';
import { openai } from '@ai-sdk/openai';
import type { LanguageModel } from 'ai';
export function createAiModel(config: ConfigService): LanguageModel | null {
const provider = config.get<string>('ai.provider') ?? 'openai';
const model = config.get<string>('ai.model') ?? 'gpt-4o-mini';
// Pure factory — no DI. Caller passes provider/model (admin-editable, from
// AiConfigService) and the API key (env-driven, ops-owned). Decoupling means
// the model can be re-built per request without re-instantiating the caller
// service, so admin updates to provider/model take effect immediately.
if (provider === 'anthropic') {
const apiKey = config.get<string>('ai.anthropicApiKey');
if (!apiKey) return null;
return anthropic(model);
export type AiProviderOpts = {
provider: string;
model: string;
anthropicApiKey?: string;
openaiApiKey?: string;
};
export function createAiModel(opts: AiProviderOpts): LanguageModel | null {
if (opts.provider === 'anthropic') {
if (!opts.anthropicApiKey) return null;
return anthropic(opts.model);
}
// Default to openai
const apiKey = config.get<string>('ai.openaiApiKey');
if (!apiKey) return null;
return openai(model);
if (!opts.openaiApiKey) return null;
return openai(opts.model);
}
export function isAiConfigured(config: ConfigService): boolean {
const provider = config.get<string>('ai.provider') ?? 'openai';
if (provider === 'anthropic') return !!config.get<string>('ai.anthropicApiKey');
return !!config.get<string>('ai.openaiApiKey');
export function isAiConfigured(opts: AiProviderOpts): boolean {
if (opts.provider === 'anthropic') return !!opts.anthropicApiKey;
return !!opts.openaiApiKey;
}

View File

@@ -0,0 +1,14 @@
import { z } from 'zod';
export const aiResponseSchema = z.object({
message: z.string().describe('Brief 2-3 sentence summary in plain conversational sentences. NEVER include suggestions, bullet lists, markdown, headers, or field labels here — those belong in the suggestions array only.'),
suggestions: z.array(z.object({
id: z.string().describe('Unique suggestion ID like s1, s2'),
type: z.enum(['upsell', 'crosssell', 'retention', 'operational']),
title: z.string().describe('Short title for the suggestion pill'),
script: z.string().describe('2-3 sentence script the agent can read aloud to the caller'),
priority: z.enum(['high', 'medium', 'low']),
})).describe('0-4 contextual suggestions based on business rules. Include on first response, update on subsequent.'),
});
export type AiResponse = z.infer<typeof aiResponseSchema>;

View File

@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { Module, forwardRef } from '@nestjs/common';
import { PlatformModule } from '../platform/platform.module';
import { AiEnrichmentService } from './ai-enrichment.service';
import { AiChatController } from './ai-chat.controller';
import { CallerResolutionModule } from '../caller/caller-resolution.module';
@Module({
imports: [PlatformModule],
imports: [PlatformModule, forwardRef(() => CallerResolutionModule)],
controllers: [AiChatController],
providers: [AiEnrichmentService],
exports: [AiEnrichmentService],

View File

@@ -11,6 +11,20 @@ import { GraphqlProxyModule } from './graphql-proxy/graphql-proxy.module';
import { HealthModule } from './health/health.module';
import { WorklistModule } from './worklist/worklist.module';
import { CallAssistModule } from './call-assist/call-assist.module';
import { SearchModule } from './search/search.module';
import { SupervisorModule } from './supervisor/supervisor.module';
import { MaintModule } from './maint/maint.module';
import { RecordingsModule } from './recordings/recordings.module';
import { EventsModule } from './events/events.module';
import { CallerResolutionModule } from './caller/caller-resolution.module';
import { RulesEngineModule } from './rules-engine/rules-engine.module';
import { ConfigThemeModule } from './config/config-theme.module';
import { WidgetModule } from './widget/widget.module';
import { TeamModule } from './team/team.module';
import { MasterdataModule } from './masterdata/masterdata.module';
import { LeadsModule } from './leads/leads.module';
import { MessagingModule } from './messaging/messaging.module';
import { TelephonyRegistrationService } from './telephony-registration.service';
@Module({
imports: [
@@ -28,6 +42,20 @@ import { CallAssistModule } from './call-assist/call-assist.module';
HealthModule,
WorklistModule,
CallAssistModule,
SearchModule,
SupervisorModule,
MaintModule,
RecordingsModule,
EventsModule,
CallerResolutionModule,
RulesEngineModule,
ConfigThemeModule,
WidgetModule,
TeamModule,
MasterdataModule,
LeadsModule,
MessagingModule,
],
providers: [TelephonyRegistrationService],
})
export class AppModule {}

View File

@@ -0,0 +1,86 @@
import { Injectable, Logger } from '@nestjs/common';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { TelephonyConfigService } from '../config/telephony-config.service';
export type AgentConfig = {
id: string;
ozonetelAgentId: string;
sipExtension: string;
sipPassword: string;
campaignName: string;
sipUri: string;
sipWsServer: string;
};
@Injectable()
export class AgentConfigService {
private readonly logger = new Logger(AgentConfigService.name);
private readonly cache = new Map<string, AgentConfig>();
constructor(
private platform: PlatformGraphqlService,
private telephony: TelephonyConfigService,
) {}
private get sipDomain(): string {
return this.telephony.getConfig().sip.domain || 'blr-pub-rtc4.ozonetel.com';
}
private get sipWsPort(): string {
return this.telephony.getConfig().sip.wsPort || '444';
}
private get defaultCampaignName(): string {
// No hardcoded fallback — each Agent entity's own campaignName
// field is the source of truth. Env var is the per-workspace
// default; if neither is set, the Ozonetel login will use
// whatever the agent's entity specifies.
return this.telephony.getConfig().ozonetel.campaignName || '';
}
async getByMemberId(memberId: string): Promise<AgentConfig | null> {
const cached = this.cache.get(memberId);
if (cached) return cached;
try {
// Note: platform GraphQL field names are derived from the SDK
// `label`, not `name` — so the filter/column is
// `workspaceMemberId` and the SIP fields are camelCase. The
// legacy staging workspace was synced from an older SDK that
// exposed `wsmemberId`/`ozonetelagentid`/etc., but any fresh
// sync (and all new hospitals going forward) uses these
// label-derived names. Re-sync staging if it drifts.
const data = await this.platform.query<any>(
`{ agents(first: 1, filter: { workspaceMemberId: { eq: "${memberId}" } }) { edges { node {
id ozonetelAgentId sipExtension sipPassword campaignName
} } } }`,
);
const node = data?.agents?.edges?.[0]?.node;
if (!node || !node.ozonetelAgentId || !node.sipExtension) return null;
const agentConfig: AgentConfig = {
id: node.id,
ozonetelAgentId: node.ozonetelAgentId,
sipExtension: node.sipExtension,
sipPassword: node.sipPassword ?? node.sipExtension,
campaignName: node.campaignName ?? this.defaultCampaignName,
sipUri: `sip:${node.sipExtension}@${this.sipDomain}`,
sipWsServer: `wss://${this.sipDomain}:${this.sipWsPort}`,
};
this.cache.set(memberId, agentConfig);
this.logger.log(`Loaded agent config for member ${memberId}: ${agentConfig.ozonetelAgentId} / ${agentConfig.sipExtension}`);
return agentConfig;
} catch (err) {
this.logger.warn(`Failed to fetch agent config: ${err}`);
return null;
}
}
getFromCache(memberId: string): AgentConfig | null {
return this.cache.get(memberId) ?? null;
}
clearCache(memberId: string): void {
this.cache.delete(memberId);
}
}

View File

@@ -1,7 +1,11 @@
import { Controller, Post, Body, Logger, HttpException } from '@nestjs/common';
import { Controller, Post, Body, Headers, Req, Logger, HttpException } from '@nestjs/common';
import type { Request } from 'express';
import { ConfigService } from '@nestjs/config';
import axios from 'axios';
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
import { SessionService } from './session.service';
import { AgentConfigService } from './agent-config.service';
import { TelephonyConfigService } from '../config/telephony-config.service';
@Controller('auth')
export class AuthController {
@@ -13,6 +17,9 @@ export class AuthController {
constructor(
private config: ConfigService,
private ozonetelAgent: OzonetelAgentService,
private sessionService: SessionService,
private agentConfigService: AgentConfigService,
private telephony: TelephonyConfigService,
) {
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
this.workspaceSubdomain = process.env.PLATFORM_WORKSPACE_SUBDOMAIN ?? 'fortytwo-dev';
@@ -20,7 +27,7 @@ export class AuthController {
}
@Post('login')
async login(@Body() body: { email: string; password: string }) {
async login(@Body() body: { email: string; password: string }, @Req() req: Request) {
this.logger.log(`Login attempt for ${body.email}`);
try {
@@ -100,31 +107,68 @@ export class AuthController {
// Determine app role from platform roles
let appRole = 'executive'; // default
if (roleLabels.includes('HelixEngage Manager')) {
if (roleLabels.includes('HelixEngage Manager') || roleLabels.includes('HelixEngage Supervisor')) {
appRole = 'admin';
} else if (roleLabels.includes('HelixEngage User')) {
// Distinguish CC agent from executive by email convention or config
// For now, emails containing 'cc' map to cc-agent
const email = workspaceMember?.userEmail ?? body.email;
appRole = email.includes('cc') ? 'cc-agent' : 'executive';
}
this.logger.log(`User ${body.email} logged in with role: ${appRole} (platform roles: ${roleLabels.join(', ')})`);
// Auto-login Ozonetel agent for CC agents (fire and forget)
if (appRole === 'cc-agent') {
const ozAgentId = process.env.OZONETEL_AGENT_ID ?? 'agent3';
const ozAgentPassword = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
const ozSipId = process.env.OZONETEL_SIP_ID ?? '521814';
// Check if user has an Agent entity with SIP config — applies to ALL roles
let agentConfigResponse: any = undefined;
const memberId = workspaceMember?.id;
if (memberId) {
const agentConfig = await this.agentConfigService.getByMemberId(memberId);
if (agentConfig) {
// Agent entity found — set up SIP + Ozonetel
const clientIp = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ?? req.ip ?? 'unknown';
const existingSession = await this.sessionService.getSession(agentConfig.ozonetelAgentId);
if (existingSession) {
this.logger.warn(`Duplicate login blocked for ${body.email} — session held by IP ${existingSession.ip} since ${existingSession.lockedAt}`);
throw new HttpException(`You are already logged in from another device (${existingSession.ip}). Please log out there first.`, 409);
}
await this.sessionService.lockSession(agentConfig.ozonetelAgentId, memberId, clientIp);
this.ozonetelAgent.refreshToken().catch(err => {
this.logger.warn(`Ozonetel token refresh on login failed: ${err.message}`);
});
this.ozonetelAgent.loginAgent({
agentId: ozAgentId,
password: ozAgentPassword,
phoneNumber: ozSipId,
agentId: agentConfig.ozonetelAgentId,
password: agentConfig.sipPassword,
phoneNumber: agentConfig.sipExtension,
mode: 'blended',
}).catch(err => {
this.logger.warn(`Ozonetel agent login failed (non-blocking): ${err.message}`);
});
agentConfigResponse = {
ozonetelAgentId: agentConfig.ozonetelAgentId,
sipExtension: agentConfig.sipExtension,
sipPassword: agentConfig.sipPassword,
sipUri: agentConfig.sipUri,
sipWsServer: agentConfig.sipWsServer,
campaignName: agentConfig.campaignName,
};
this.logger.log(`Agent ${body.email} → Ozonetel ${agentConfig.ozonetelAgentId} / SIP ${agentConfig.sipExtension}`);
} else if (appRole === 'cc-agent') {
// CC agent role but no Agent entity — block login
throw new HttpException('Agent account not configured. Contact administrator.', 403);
} else {
this.logger.log(`User ${body.email} has no Agent entity — SIP disabled`);
}
}
// Cache agent name for worklist resolution (avoids re-querying currentUser with user JWT)
const agentFullName = `${workspaceMember?.name?.firstName ?? ''} ${workspaceMember?.name?.lastName ?? ''}`.trim();
if (agentFullName) {
await this.sessionService.setCache(`agent:name:${accessToken.slice(-16)}`, agentFullName, 86400);
}
return {
@@ -139,6 +183,7 @@ export class AuthController {
role: appRole,
platformRoles: roleLabels,
},
...(agentConfigResponse ? { agentConfig: agentConfigResponse } : {}),
};
} catch (error) {
if (error instanceof HttpException) throw error;
@@ -146,4 +191,103 @@ export class AuthController {
throw new HttpException('Authentication service unavailable', 503);
}
}
@Post('refresh')
async refresh(@Body() body: { refreshToken: string }) {
if (!body.refreshToken) {
throw new HttpException('refreshToken required', 400);
}
this.logger.log('Token refresh request');
try {
const res = await axios.post(this.graphqlUrl, {
query: `mutation RefreshToken($token: String!) {
renewToken(appToken: $token) {
tokens {
accessOrWorkspaceAgnosticToken { token expiresAt }
refreshToken { token }
}
}
}`,
variables: { token: body.refreshToken },
}, {
headers: { 'Content-Type': 'application/json' },
});
if (res.data.errors) {
this.logger.warn(`Token refresh failed: ${res.data.errors[0]?.message}`);
throw new HttpException('Token refresh failed', 401);
}
const tokens = res.data.data.renewToken.tokens;
return {
accessToken: tokens.accessOrWorkspaceAgnosticToken.token,
refreshToken: tokens.refreshToken.token,
};
} catch (error) {
if (error instanceof HttpException) throw error;
this.logger.error(`Token refresh failed: ${error}`);
throw new HttpException('Token refresh failed', 401);
}
}
@Post('logout')
async logout(@Headers('authorization') auth: string) {
if (!auth) return { status: 'ok' };
try {
const profileRes = await axios.post(this.graphqlUrl, {
query: '{ currentUser { workspaceMember { id } } }',
}, { headers: { 'Content-Type': 'application/json', Authorization: auth } });
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
if (!memberId) return { status: 'ok' };
const agentConfig = this.agentConfigService.getFromCache(memberId);
if (agentConfig) {
await this.sessionService.unlockSession(agentConfig.ozonetelAgentId);
this.logger.log(`Session unlocked for ${agentConfig.ozonetelAgentId}`);
// Await the Ozonetel logout so it completes before the
// HTTP response returns. Without this, a fast re-login
// (e.g. "remember me" auto-fill) races the logout and
// the agent lands in "Telephony Unavailable" because
// Ozonetel receives login while still processing logout.
await this.ozonetelAgent.logoutAgent({
agentId: agentConfig.ozonetelAgentId,
password: agentConfig.sipPassword,
}).catch(err => this.logger.warn(`Ozonetel logout failed: ${err.message}`));
this.agentConfigService.clearCache(memberId);
}
return { status: 'ok' };
} catch (err) {
this.logger.warn(`Logout cleanup failed: ${err}`);
return { status: 'ok' };
}
}
@Post('heartbeat')
async heartbeat(@Headers('authorization') auth: string) {
if (!auth) return { status: 'ok' };
try {
const profileRes = await axios.post(this.graphqlUrl, {
query: '{ currentUser { workspaceMember { id } } }',
}, { headers: { 'Content-Type': 'application/json', Authorization: auth } });
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
const agentConfig = memberId ? this.agentConfigService.getFromCache(memberId) : null;
if (agentConfig) {
await this.sessionService.refreshSession(agentConfig.ozonetelAgentId);
}
return { status: 'ok' };
} catch {
return { status: 'ok' };
}
}
}

View File

@@ -1,9 +1,14 @@
import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
import { PlatformModule } from '../platform/platform.module';
import { SessionService } from './session.service';
import { AgentConfigService } from './agent-config.service';
@Module({
imports: [OzonetelAgentModule],
imports: [OzonetelAgentModule, PlatformModule],
controllers: [AuthController],
providers: [SessionService, AgentConfigService],
exports: [SessionService, AgentConfigService],
})
export class AuthModule {}

105
src/auth/session.service.ts Normal file
View File

@@ -0,0 +1,105 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
const SESSION_TTL = 3600; // 1 hour
@Injectable()
export class SessionService {
private readonly logger = new Logger(SessionService.name);
private readonly redis: Redis;
// Redis client is constructed eagerly (not in onModuleInit) so
// other services can call cache methods from THEIR onModuleInit
// hooks. NestJS instantiates all providers before running any
// onModuleInit callback, so the client is guaranteed ready even
// when an earlier-firing module's init path touches the cache
// (e.g. WidgetConfigService → WidgetKeysService → setCachePersistent).
constructor(private config: ConfigService) {
const url = this.config.get<string>('redis.url', 'redis://localhost:6379');
this.redis = new Redis(url, { lazyConnect: false });
this.redis.on('connect', () => this.logger.log('Redis connected'));
this.redis.on('error', (err) => this.logger.error(`Redis error: ${err.message}`));
}
private key(agentId: string): string {
return `agent:session:${agentId}`;
}
async lockSession(agentId: string, memberId: string, ip?: string): Promise<void> {
const value = JSON.stringify({ memberId, ip: ip ?? 'unknown', lockedAt: new Date().toISOString() });
await this.redis.set(this.key(agentId), value, 'EX', SESSION_TTL);
}
async getSession(agentId: string): Promise<{ memberId: string; ip: string; lockedAt: string } | null> {
const raw = await this.redis.get(this.key(agentId));
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
// Legacy format — just memberId string
return { memberId: raw, ip: 'unknown', lockedAt: 'unknown' };
}
}
async isSessionLocked(agentId: string): Promise<string | null> {
const session = await this.getSession(agentId);
return session ? session.memberId : null;
}
async refreshSession(agentId: string): Promise<void> {
await this.redis.expire(this.key(agentId), SESSION_TTL);
}
async unlockSession(agentId: string): Promise<void> {
await this.redis.del(this.key(agentId));
}
// Enumerate every active session lock so the maint UI can show which
// agentIds are currently held (and by whom) vs free. Uses SCAN, not
// KEYS, to avoid blocking Redis on workspaces with many keys.
async listLockedSessions(): Promise<Array<{ agentId: string; memberId: string; ip: string; lockedAt: string }>> {
const out: Array<{ agentId: string; memberId: string; ip: string; lockedAt: string }> = [];
const stream = this.redis.scanStream({ match: 'agent:session:*', count: 100 });
const keys: string[] = [];
await new Promise<void>((resolve, reject) => {
stream.on('data', (chunk: string[]) => keys.push(...chunk));
stream.on('end', resolve);
stream.on('error', reject);
});
for (const key of keys) {
const agentId = key.slice('agent:session:'.length);
const session = await this.getSession(agentId);
if (session) out.push({ agentId, ...session });
}
return out;
}
// Generic cache operations for any module
async getCache(key: string): Promise<string | null> {
return this.redis.get(key);
}
async setCache(key: string, value: string, ttlSeconds: number): Promise<void> {
await this.redis.set(key, value, 'EX', ttlSeconds);
}
async setCachePersistent(key: string, value: string): Promise<void> {
await this.redis.set(key, value);
}
async deleteCache(key: string): Promise<void> {
await this.redis.del(key);
}
async scanKeys(pattern: string): Promise<string[]> {
const keys: string[] = [];
let cursor = '0';
do {
const [next, batch] = await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
cursor = next;
keys.push(...batch);
} while (cursor !== '0');
return keys;
}
}

View File

@@ -4,6 +4,8 @@ import { generateText } from 'ai';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { createAiModel } from '../ai/ai-provider';
import type { LanguageModel } from 'ai';
import { AiConfigService } from '../config/ai-config.service';
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils';
@Injectable()
export class CallAssistService {
@@ -14,8 +16,15 @@ export class CallAssistService {
constructor(
private config: ConfigService,
private platform: PlatformGraphqlService,
private aiConfig: AiConfigService,
) {
this.aiModel = createAiModel(config);
const cfg = aiConfig.getConfig();
this.aiModel = createAiModel({
provider: cfg.provider,
model: cfg.model,
anthropicApiKey: config.get<string>('ai.anthropicApiKey'),
openaiApiKey: config.get<string>('ai.openaiApiKey'),
});
this.platformApiKey = config.get<string>('platform.apiKey') ?? '';
}
@@ -52,7 +61,7 @@ export class CallAssistService {
const apptResult = await this.platform.queryWithAuth<any>(
`{ appointments(first: 10, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id scheduledAt appointmentStatus doctorName department reasonForVisit patientId
id scheduledAt status doctorName department reasonForVisit patientId
} } } }`,
undefined, authHeader,
);
@@ -63,7 +72,7 @@ export class CallAssistService {
parts.push('\nPAST APPOINTMENTS:');
for (const a of appts) {
const date = a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString('en-IN') : '?';
parts.push(`- ${date}: ${a.doctorName ?? '?'} (${a.department ?? '?'}) — ${a.appointmentStatus}`);
parts.push(`- ${date}: ${a.doctorName ?? '?'} (${a.department ?? '?'}) — ${a.status}`);
}
}
} else if (callerPhone) {
@@ -73,16 +82,24 @@ export class CallAssistService {
const docResult = await this.platform.queryWithAuth<any>(
`{ doctors(first: 20) { edges { node {
fullName { firstName lastName } department specialty clinic { clinicName }
id fullName { firstName lastName } department specialty
${DOCTOR_VISIT_SLOTS_FRAGMENT}
} } } }`,
undefined, authHeader,
);
const docs = docResult.doctors.edges.map((e: any) => e.node);
const docs = normalizeDoctors(docResult.doctors.edges.map((e: any) => e.node));
if (docs.length > 0) {
parts.push('\nAVAILABLE DOCTORS:');
for (const d of docs) {
const name = d.fullName ? `Dr. ${d.fullName.firstName} ${d.fullName.lastName}`.trim() : 'Unknown';
parts.push(`- ${name}${d.department ?? '?'}${d.clinic?.clinicName ?? '?'}`);
// Show all clinics the doctor visits, joined with
// " / " — call assist context is read by the AI
// whisperer so multi-clinic doctors don't get
// truncated to their first location.
const clinicLabel = d.clinics.length > 0
? d.clinics.map((c) => c.clinicName).join(' / ')
: '?';
parts.push(`- ${name}${d.department ?? '?'}${clinicLabel}`);
}
}
@@ -99,18 +116,10 @@ export class CallAssistService {
try {
const { text } = await generateText({
model: this.aiModel,
system: `You are a real-time call assistant for Global Hospital Bangalore.
You listen to the customer's words and provide brief, actionable suggestions for the CC agent.
${context}
RULES:
- Keep suggestions under 2 sentences
- Focus on actionable next steps the agent should take NOW
- If customer mentions a doctor or department, suggest available slots
- If customer wants to cancel or reschedule, note relevant appointment details
- If customer sounds upset, suggest empathetic response
- Do NOT repeat what the agent already knows`,
system: this.aiConfig.renderPrompt('callAssist', {
hospitalName: process.env.HOSPITAL_NAME ?? 'the hospital',
context,
}),
prompt: `Conversation transcript so far:\n${transcript}\n\nProvide a brief suggestion for the agent based on what was just said.`,
maxOutputTokens: 150,
});

View File

@@ -35,6 +35,20 @@ export class CallEventsGateway {
this.server.to(room).emit('call:incoming', event);
}
// Broadcast to supervisors when a new call record is created
broadcastCallCreated(callData: any) {
this.logger.log('Broadcasting call:created to supervisor room');
this.server.to('supervisor').emit('call:created', callData);
}
// Supervisor registers to receive real-time updates
@SubscribeMessage('supervisor:register')
handleSupervisorRegister(@ConnectedSocket() client: Socket) {
client.join('supervisor');
this.logger.log(`Supervisor registered (socket: ${client.id})`);
client.emit('supervisor:registered', { room: 'supervisor' });
}
// Agent registers when they open the Call Desk page
@SubscribeMessage('agent:register')
handleAgentRegister(

View File

@@ -1,13 +1,18 @@
import { Module } from '@nestjs/common';
import { PlatformModule } from '../platform/platform.module';
import { AiModule } from '../ai/ai.module';
import { CallerResolutionModule } from '../caller/caller-resolution.module';
import { CallEventsService } from './call-events.service';
import { CallEventsGateway } from './call-events.gateway';
import { CallLookupController } from './call-lookup.controller';
import { LeadEnrichController } from './lead-enrich.controller';
@Module({
imports: [PlatformModule, AiModule],
controllers: [CallLookupController],
// CallerResolutionModule is imported so LeadEnrichController can
// inject CallerResolutionService to invalidate the Redis caller
// cache after a forced re-enrichment.
imports: [PlatformModule, AiModule, CallerResolutionModule],
controllers: [CallLookupController, LeadEnrichController],
providers: [CallEventsService, CallEventsGateway],
exports: [CallEventsService, CallEventsGateway],
})

View File

@@ -167,7 +167,24 @@ export class CallEventsService {
`Processing disposition: ${payload.disposition} for call ${payload.callSid}`,
);
// 1. Create Call record in platform
// 1. Compute SLA % if lead is linked
let sla: number | undefined;
if (payload.leadId && payload.startedAt) {
try {
const lead = await this.platform.findLeadById(payload.leadId);
if (lead?.createdAt) {
const leadCreated = new Date(lead.createdAt).getTime();
const callStarted = new Date(payload.startedAt).getTime();
const elapsedMin = Math.max(0, (callStarted - leadCreated) / 60000);
const slaThresholdMin = 1440; // Default 24h; missed calls use 720 but this is a completed call
sla = Math.round((elapsedMin / slaThresholdMin) * 100);
}
} catch {
// SLA computation is best-effort
}
}
// 2. Create Call record in platform
try {
await this.platform.createCall({
callDirection: 'INBOUND',
@@ -187,8 +204,11 @@ export class CallEventsService {
disposition: payload.disposition,
callNotes: payload.notes || undefined,
leadId: payload.leadId || undefined,
sla,
});
this.logger.log(`Call record created for ${payload.callSid}`);
this.logger.log(`Call record created for ${payload.callSid} (SLA: ${sla ?? 'N/A'}%)`);
// Notify supervisors in real-time
this.gateway.broadcastCallCreated({ callSid: payload.callSid, agentName: payload.agentName, disposition: payload.disposition });
} catch (error) {
this.logger.error(`Failed to create call record: ${error}`);
}

View File

@@ -0,0 +1,114 @@
import { Body, Controller, Headers, HttpException, Logger, Param, Post } from '@nestjs/common';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { AiEnrichmentService } from '../ai/ai-enrichment.service';
import { CallerResolutionService } from '../caller/caller-resolution.service';
// POST /api/lead/:id/enrich
//
// Force re-generation of a lead's AI summary + suggested action. Used by
// the call-desk appointment/enquiry forms when the agent explicitly edits
// the caller's name — the previously-generated summary was built against
// the stale identity, so we discard it and run the enrichment prompt
// again with the corrected name.
//
// Optional body: `{ phone?: string }` — when provided, also invalidates
// the Redis caller-resolution cache for that phone so the NEXT incoming
// call from the same number picks up fresh data from the platform
// instead of the stale cached entry.
//
// This is distinct from the cache-miss enrichment path in
// call-lookup.controller.ts `POST /api/call/lookup` which only runs
// enrichment when `lead.aiSummary` is null. That path is fine for
// first-time lookups; this one is for explicit "the old summary is
// wrong, regenerate it" triggers.
@Controller('api/lead')
export class LeadEnrichController {
private readonly logger = new Logger(LeadEnrichController.name);
constructor(
private readonly platform: PlatformGraphqlService,
private readonly ai: AiEnrichmentService,
private readonly callerResolution: CallerResolutionService,
) {}
@Post(':id/enrich')
async enrichLead(
@Param('id') leadId: string,
@Body() body: { phone?: string },
@Headers('authorization') authHeader: string,
) {
if (!authHeader) throw new HttpException('Authorization required', 401);
if (!leadId) throw new HttpException('leadId required', 400);
this.logger.log(`Force-enriching lead ${leadId}`);
// 1. Fetch fresh lead from platform (with the staging-aligned
// field names — see findLeadByIdWithToken comment).
let lead: any;
try {
lead = await this.platform.findLeadByIdWithToken(leadId, authHeader);
} catch (err) {
this.logger.error(`[LEAD-ENRICH] Lead fetch failed for ${leadId}: ${err}`);
throw new HttpException(`Lead fetch failed: ${(err as Error).message}`, 500);
}
if (!lead) {
throw new HttpException(`Lead not found: ${leadId}`, 404);
}
// 2. Fetch recent activities so the prompt has conversation context.
let activities: any[] = [];
try {
activities = await this.platform.getLeadActivitiesWithToken(leadId, authHeader, 5);
} catch (err) {
// Non-fatal — enrichment just has less context.
this.logger.warn(`[LEAD-ENRICH] Activity fetch failed: ${err}`);
}
// 3. Run enrichment. LeadContext uses the legacy `leadStatus`/
// `leadSource` internal names even though the platform now
// exposes them as `status`/`source` — we just map across.
const enrichment = await this.ai.enrichLead({
firstName: lead.contactName?.firstName ?? undefined,
lastName: lead.contactName?.lastName ?? undefined,
leadSource: lead.source ?? undefined,
interestedService: lead.interestedService ?? undefined,
leadStatus: lead.status ?? undefined,
contactAttempts: lead.contactAttempts ?? undefined,
createdAt: lead.createdAt,
activities: activities.map((a) => ({
activityType: a.activityType ?? '',
summary: a.summary ?? '',
})),
});
// 4. Persist the new summary back to the lead.
try {
await this.platform.updateLeadWithToken(
leadId,
{
aiSummary: enrichment.aiSummary,
aiSuggestedAction: enrichment.aiSuggestedAction,
},
authHeader,
);
} catch (err) {
this.logger.error(`[LEAD-ENRICH] Failed to persist enrichment for ${leadId}: ${err}`);
throw new HttpException(
`Failed to persist enrichment: ${(err as Error).message}`,
500,
);
}
// Caller resolution no longer caches — every resolve() hits the
// platform fresh via an indexed phone filter. No invalidation
// needed after enrichment.
this.logger.log(`[LEAD-ENRICH] Lead ${leadId} enriched successfully`);
return {
leadId,
aiSummary: enrichment.aiSummary,
aiSuggestedAction: enrichment.aiSuggestedAction,
};
}
}

View File

@@ -0,0 +1,249 @@
import { Injectable, Logger } from '@nestjs/common';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { SessionService } from '../auth/session.service';
import { evaluateSuggestionRules, type SuggestionTrigger } from '../rules-engine/suggestion-rules';
export type CallerContext = {
leadId: string;
patientId: string;
name: string;
phone: string;
isNew: boolean;
// Lead profile
leadSource: string | null;
leadStatus: string | null;
interestedService: string | null;
aiSummary: string | null;
contactAttempts: number;
lastContacted: string | null;
utmCampaign: string | null;
// Appointments
appointments: Array<{
scheduledAt: string;
status: string;
doctorName: string;
department: string;
reasonForVisit: string | null;
}>;
// Recent call history
calls: Array<{
startedAt: string;
direction: string;
duration: number | null;
disposition: string | null;
agentName: string | null;
}>;
// Lead activities
activities: Array<{
activityType: string;
summary: string | null;
occurredAt: string;
outcome: string | null;
}>;
// Rule-driven suggestion triggers
suggestionTriggers: SuggestionTrigger[];
};
const CACHE_KEY_PREFIX = 'caller:context:';
const CACHE_TTL = 300; // 5 minutes — covers the call duration
@Injectable()
export class CallerContextService {
private readonly logger = new Logger(CallerContextService.name);
constructor(
private readonly platform: PlatformGraphqlService,
private readonly session: SessionService,
) {}
async getOrBuild(leadId: string, patientId: string, auth: string): Promise<CallerContext | null> {
if (!leadId) return null;
// Check cache first
const cacheKey = `${CACHE_KEY_PREFIX}${leadId}`;
try {
const cached = await this.session.getCache(cacheKey);
if (cached) {
this.logger.log(`[CALLER-CTX] Cache hit for ${leadId}`);
return JSON.parse(cached);
}
} catch {}
// Build fresh
this.logger.log(`[CALLER-CTX] Building context for lead=${leadId} patient=${patientId}`);
const ctx = await this.build(leadId, patientId, auth);
if (ctx) {
this.session.setCache(cacheKey, JSON.stringify(ctx), CACHE_TTL).catch(() => {});
}
return ctx;
}
async invalidateCache(leadId: string): Promise<void> {
if (!leadId) return;
const cacheKey = `${CACHE_KEY_PREFIX}${leadId}`;
await this.session.deleteCache(cacheKey).catch(() => {});
this.logger.log(`[CALLER-CTX] Cache invalidated for ${leadId}`);
}
// Fire-and-forget pre-warm — called from caller resolution
// so the cache is hot when the AI stream fires seconds later.
prewarm(leadId: string, patientId: string, auth: string): void {
if (!leadId) return;
this.getOrBuild(leadId, patientId, auth).catch(err => {
this.logger.warn(`[CALLER-CTX] Prewarm failed: ${err.message}`);
});
}
private async build(leadId: string, patientId: string, auth: string): Promise<CallerContext | null> {
try {
// Step 1: Fetch lead first to get the authoritative patientId
const leadData = await this.platform.queryWithAuth<any>(
`{ lead(filter: { id: { eq: "${leadId}" } }) {
id contactName { firstName lastName }
contactPhone { primaryPhoneNumber }
source status interestedService
aiSummary contactAttempts lastContacted
utmCampaign patientId
} }`,
undefined, auth,
);
const lead = leadData?.lead;
if (!lead) return null;
// Use Lead's patientId as authoritative source — the input
// param may be empty if caller resolution just linked them.
const resolvedPatientId = patientId || lead.patientId || '';
this.logger.log(`[CALLER-CTX] Resolved patientId=${resolvedPatientId} (input=${patientId}, lead=${lead.patientId ?? '∅'})`);
const firstName = lead.contactName?.firstName ?? '';
const lastName = lead.contactName?.lastName ?? '';
// Step 2: Fetch appointments, calls, activities in parallel
// using the resolved patientId from the Lead record.
const [appointmentsData, callsData, activitiesData] = await Promise.all([
resolvedPatientId ? this.platform.queryWithAuth<any>(
`{ appointments(first: 10, filter: { patientId: { eq: "${resolvedPatientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
scheduledAt status doctorName department reasonForVisit
} } } }`,
undefined, auth,
) : Promise.resolve(null),
this.platform.queryWithAuth<any>(
`{ calls(first: 10, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
startedAt direction durationSec disposition agentName
} } } }`,
undefined, auth,
),
this.platform.queryWithAuth<any>(
`{ leadActivities(first: 10, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node {
activityType summary occurredAt outcome
} } } }`,
undefined, auth,
),
]);
const appointments = (appointmentsData?.appointments?.edges ?? []).map((e: any) => e.node);
const calls = (callsData?.calls?.edges ?? []).map((e: any) => ({
startedAt: e.node.startedAt,
direction: e.node.direction,
duration: e.node.durationSec,
disposition: e.node.disposition,
agentName: e.node.agentName,
}));
const suggestionTriggers = evaluateSuggestionRules({
isNew: false,
interestedService: lead.interestedService ?? null,
leadStatus: lead.status ?? null,
contactAttempts: lead.contactAttempts ?? 0,
appointments,
calls: calls.map((c: any) => ({ direction: c.direction, disposition: c.disposition, startedAt: c.startedAt })),
utmCampaign: lead.utmCampaign ?? null,
leadSource: lead.source ?? null,
});
return {
leadId,
patientId: resolvedPatientId,
name: `${firstName} ${lastName}`.trim() || 'Unknown',
phone: lead.contactPhone?.primaryPhoneNumber ?? '',
isNew: false,
leadSource: lead.source ?? null,
leadStatus: lead.status ?? null,
interestedService: lead.interestedService ?? null,
aiSummary: lead.aiSummary ?? null,
contactAttempts: lead.contactAttempts ?? 0,
lastContacted: lead.lastContacted ?? null,
utmCampaign: lead.utmCampaign ?? null,
appointments,
calls,
activities: (activitiesData?.leadActivities?.edges ?? []).map((e: any) => e.node),
suggestionTriggers,
};
} catch (err: any) {
this.logger.warn(`[CALLER-CTX] Build failed: ${err.message}`);
return null;
}
}
renderSuggestionsForPrompt(triggers: SuggestionTrigger[]): string {
if (triggers.length === 0) return '';
const lines = [
'',
'SUGGESTION RULES (from business configuration):',
'Based on this caller\'s profile, the following suggestions should be offered.',
'Generate a natural, conversational script for each that the agent can read aloud.',
'Return them in the `suggestions` array of your JSON response.',
'',
];
triggers.forEach((t, i) => {
lines.push(`${i + 1}. [${t.type}/${t.priority}] ${t.title}${t.reason}`);
});
return lines.join('\n');
}
renderForPrompt(ctx: CallerContext): string {
const lines: string[] = [];
lines.push(`## CURRENT CALLER: ${ctx.name}`);
lines.push(`Phone: ${ctx.phone}`);
if (ctx.leadSource) lines.push(`Source: ${ctx.leadSource}`);
if (ctx.leadStatus) lines.push(`Status: ${ctx.leadStatus}`);
if (ctx.interestedService) lines.push(`Interested in: ${ctx.interestedService}`);
if (ctx.utmCampaign) lines.push(`Campaign: ${ctx.utmCampaign}`);
if (ctx.contactAttempts > 0) lines.push(`Contact attempts: ${ctx.contactAttempts}`);
if (ctx.lastContacted) lines.push(`Last contacted: ${ctx.lastContacted}`);
if (ctx.aiSummary) {
lines.push(`\nAI Summary: ${ctx.aiSummary}`);
}
if (ctx.appointments.length > 0) {
lines.push(`\n### Appointments (${ctx.appointments.length})`);
for (const a of ctx.appointments) {
const date = a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }) : '?';
lines.push(`- ${date} | ${a.doctorName ?? '?'} (${a.department ?? '?'}) | ${a.status}${a.reasonForVisit ? ` | ${a.reasonForVisit}` : ''}`);
}
} else {
lines.push('\nNo appointments on record.');
}
if (ctx.calls.length > 0) {
lines.push(`\n### Call History (last ${ctx.calls.length})`);
for (const c of ctx.calls) {
const date = c.startedAt ? new Date(c.startedAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }) : '?';
const dur = c.duration ? `${Math.floor(c.duration / 60)}m${c.duration % 60}s` : '?';
lines.push(`- ${date} | ${c.direction ?? '?'} | ${dur} | ${c.disposition ?? 'No disposition'}${c.agentName ? ` | Agent: ${c.agentName}` : ''}`);
}
}
if (ctx.activities.length > 0) {
lines.push(`\n### Recent Activity (last ${ctx.activities.length})`);
for (const a of ctx.activities) {
const date = a.occurredAt ? new Date(a.occurredAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }) : '?';
lines.push(`- ${date} | ${a.activityType}${a.summary ? `: ${a.summary}` : ''}${a.outcome ? `${a.outcome}` : ''}`);
}
}
return lines.join('\n');
}
}

View File

@@ -0,0 +1,45 @@
import { Controller, Post, Body, Headers, HttpException, HttpStatus, Logger } from '@nestjs/common';
import { CallerResolutionService } from './caller-resolution.service';
import { CallerContextService } from './caller-context.service';
@Controller('api/caller')
export class CallerResolutionController {
private readonly logger = new Logger(CallerResolutionController.name);
constructor(
private readonly resolution: CallerResolutionService,
private readonly callerContext: CallerContextService,
) {}
@Post('resolve')
async resolve(
@Body('phone') phone: string,
@Headers('authorization') auth: string,
) {
if (!phone) {
throw new HttpException('phone is required', HttpStatus.BAD_REQUEST);
}
if (!auth) {
throw new HttpException('Authorization header required', HttpStatus.UNAUTHORIZED);
}
this.logger.log(`[RESOLVE] Resolving caller: ${phone}`);
const result = await this.resolution.resolve(phone, auth);
// Pre-warm caller context cache so the AI chat has it ready
if (result.leadId) {
this.callerContext.prewarm(result.leadId, result.patientId, auth);
}
return result;
}
@Post('invalidate-context')
async invalidateContext(@Body('leadId') leadId: string) {
if (!leadId) {
throw new HttpException('leadId is required', HttpStatus.BAD_REQUEST);
}
await this.callerContext.invalidateCache(leadId);
return { status: 'ok' };
}
}

View File

@@ -0,0 +1,14 @@
import { Module, forwardRef } from '@nestjs/common';
import { PlatformModule } from '../platform/platform.module';
import { AuthModule } from '../auth/auth.module';
import { CallerResolutionController } from './caller-resolution.controller';
import { CallerResolutionService } from './caller-resolution.service';
import { CallerContextService } from './caller-context.service';
@Module({
imports: [PlatformModule, forwardRef(() => AuthModule)],
controllers: [CallerResolutionController],
providers: [CallerResolutionService, CallerContextService],
exports: [CallerResolutionService, CallerContextService],
})
export class CallerResolutionModule {}

View File

@@ -0,0 +1,217 @@
import { Injectable, Logger } from '@nestjs/common';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
export type ResolvedCaller = {
leadId: string;
patientId: string;
firstName: string;
lastName: string;
phone: string;
isNew: boolean; // true if no Lead/Patient exists for this phone
};
@Injectable()
export class CallerResolutionService {
private readonly logger = new Logger(CallerResolutionService.name);
constructor(
private readonly platform: PlatformGraphqlService,
) {}
// Resolve a caller by phone number via indexed platform queries. No
// cache — every call hits the DB fresh. Cache was previously used to
// compensate for client-side `leads(first: 200)` scans, but we now
// filter by phone directly which is O(log n) with the DB index.
// Cost: ~2 fast queries per resolve; eventual-consistency window = 0.
async resolve(phone: string, auth: string): Promise<ResolvedCaller> {
const normalized = phone.replace(/\D/g, '').slice(-10);
if (normalized.length < 10) {
throw new Error(`Invalid phone number: ${phone}`);
}
// Lookup lead + patient by phone, in parallel.
const [lead, patient] = await Promise.all([
this.findLeadByPhone(normalized, auth),
this.findPatientByPhone(normalized, auth),
]);
let result: ResolvedCaller;
if (lead && patient) {
// Both exist — link them if not already linked
if (!lead.patientId) {
await this.linkLeadToPatient(lead.id, patient.id, auth);
this.logger.log(`[RESOLVE] Linked existing lead ${lead.id} → patient ${patient.id}`);
}
// PRD: "Returning patient (Y/N) will be taken care of by the system"
// Patient is recognized on a subsequent contact → mark as RETURNING
if (patient.patientType === 'NEW') {
this.upgradeToReturning(patient.id, auth);
}
result = {
leadId: lead.id,
patientId: patient.id,
firstName: lead.firstName || patient.firstName,
lastName: lead.lastName || patient.lastName,
phone: normalized,
isNew: false,
};
} else if (lead && !patient) {
// Lead exists, no patient — create patient
const newPatient = await this.createPatient(lead.firstName, lead.lastName, normalized, auth);
await this.linkLeadToPatient(lead.id, newPatient.id, auth);
this.logger.log(`[RESOLVE] Created patient ${newPatient.id} for existing lead ${lead.id}`);
result = {
leadId: lead.id,
patientId: newPatient.id,
firstName: lead.firstName,
lastName: lead.lastName,
phone: normalized,
isNew: false,
};
} else if (!lead && patient) {
// Patient exists, no lead — create lead
const newLead = await this.createLead(patient.firstName, patient.lastName, normalized, patient.id, auth);
this.logger.log(`[RESOLVE] Created lead ${newLead.id} for existing patient ${patient.id}`);
if (patient.patientType === 'NEW') {
this.upgradeToReturning(patient.id, auth);
}
result = {
leadId: newLead.id,
patientId: patient.id,
firstName: patient.firstName,
lastName: patient.lastName,
phone: normalized,
isNew: false,
};
} else {
// Neither exists — return empty IDs with isNew=true. Caller
// code is responsible for creating records with the real name
// they've collected (enquiry form, appointment form, widget,
// AI tools). This avoids the "Unknown" placeholder cascade:
// no Lead/Patient is ever written unless we have a real name
// to attach to it. Missed-call / poller paths that have no
// name persist the Call record with leadName=phone as the
// honest snapshot.
this.logger.log(`[RESOLVE] No existing records for ${normalized} — returning isNew=true`);
result = {
leadId: '',
patientId: '',
firstName: '',
lastName: '',
phone: normalized,
isNew: true,
};
}
return result;
}
// Indexed lookup — platform filters by phone server-side. Matches on
// the last 10 digits regardless of stored format (+919XXXX / 91XXXX /
// XXXX / +91-XXXX), via the `like: "%XXXXXXXXXX"` predicate.
private async findLeadByPhone(phone10: string, auth: string): Promise<{ id: string; firstName: string; lastName: string; patientId: string | null } | null> {
try {
const data = await this.platform.queryWithAuth<{ leads: { edges: { node: any }[] } }>(
`{ leads(first: 1, filter: { contactPhone: { primaryPhoneNumber: { like: "%${phone10}" } } }) { edges { node {
id
contactName { firstName lastName }
patientId
} } } }`,
undefined,
auth,
);
const match = data.leads.edges[0]?.node;
if (!match) return null;
return {
id: match.id,
firstName: match.contactName?.firstName ?? '',
lastName: match.contactName?.lastName ?? '',
patientId: match.patientId || null,
};
} catch (err: any) {
this.logger.warn(`[RESOLVE] Lead lookup failed: ${err.message}`);
return null;
}
}
private async findPatientByPhone(phone10: string, auth: string): Promise<{ id: string; firstName: string; lastName: string; patientType: string | null } | null> {
try {
const data = await this.platform.queryWithAuth<{ patients: { edges: { node: any }[] } }>(
`{ patients(first: 1, filter: { phones: { primaryPhoneNumber: { like: "%${phone10}" } } }) { edges { node {
id
fullName { firstName lastName }
patientType
} } } }`,
undefined,
auth,
);
const match = data.patients.edges[0]?.node;
if (!match) return null;
return {
id: match.id,
firstName: match.fullName?.firstName ?? '',
lastName: match.fullName?.lastName ?? '',
patientType: match.patientType ?? null,
};
} catch (err: any) {
this.logger.warn(`[RESOLVE] Patient lookup failed: ${err.message}`);
return null;
}
}
private async createPatient(firstName: string, lastName: string, phone: string, auth: string): Promise<{ id: string }> {
const data = await this.platform.queryWithAuth<{ createPatient: { id: string } }>(
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
{
data: {
name: `${firstName || 'Unknown'} ${lastName || ''}`.trim(),
fullName: { firstName: firstName || 'Unknown', lastName: lastName || '' },
phones: { primaryPhoneNumber: `+91${phone}` },
patientType: 'NEW',
},
},
auth,
);
return data.createPatient;
}
private async createLead(firstName: string, lastName: string, phone: string, patientId: string, auth: string): Promise<{ id: string }> {
const data = await this.platform.queryWithAuth<{ createLead: { id: string } }>(
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
{
data: {
name: `${firstName} ${lastName}`.trim() || 'Unknown Caller',
contactName: { firstName: firstName || 'Unknown', lastName: lastName || '' },
contactPhone: { primaryPhoneNumber: `+91${phone}` },
source: 'PHONE',
status: 'NEW',
patientId,
},
},
auth,
);
return data.createLead;
}
private upgradeToReturning(patientId: string, auth: string): void {
// Fire-and-forget — don't block caller resolution
this.platform.queryWithAuth<any>(
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
{ id: patientId, data: { patientType: 'RETURNING' } },
auth,
).then(() => {
this.logger.log(`[RESOLVE] Upgraded patient ${patientId} to RETURNING`);
}).catch(err => {
this.logger.warn(`[RESOLVE] Failed to upgrade patient type: ${err.message}`);
});
}
private async linkLeadToPatient(leadId: string, patientId: string, auth: string): Promise<void> {
await this.platform.queryWithAuth<any>(
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
{ id: leadId, data: { patientId } },
auth,
);
}
}

View File

@@ -0,0 +1,213 @@
/**
* Caller Resolution Service — unit tests
*
* QA coverage: TC-IB-05 (lead creation from enquiry),
* TC-IB-06 (new patient registration), TC-IB-07/08 (AI context)
*
* Tests the phone→lead+patient resolution logic:
* - Existing patient + existing lead → returns both, links if needed
* - Existing lead, no patient → creates patient, links
* - Existing patient, no lead → creates lead, links
* - New caller (neither exists) → creates both
* - Phone normalization (strips +91, non-digits)
* - Cache hit/miss behavior
*/
import { Test } from '@nestjs/testing';
import { CallerResolutionService, ResolvedCaller } from './caller-resolution.service';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { SessionService } from '../auth/session.service';
describe('CallerResolutionService', () => {
let service: CallerResolutionService;
let platform: jest.Mocked<PlatformGraphqlService>;
let cache: jest.Mocked<SessionService>;
const AUTH = 'Bearer test-token';
const existingLead = {
id: 'lead-001',
contactName: { firstName: 'Priya', lastName: 'Sharma' },
contactPhone: { primaryPhoneNumber: '+919949879837' },
patientId: 'patient-001',
};
const existingPatient = {
id: 'patient-001',
fullName: { firstName: 'Priya', lastName: 'Sharma' },
phones: { primaryPhoneNumber: '+919949879837' },
};
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
CallerResolutionService,
{
provide: PlatformGraphqlService,
useValue: {
queryWithAuth: jest.fn(),
},
},
{
provide: SessionService,
useValue: {
getCache: jest.fn().mockResolvedValue(null), // no cache by default
setCache: jest.fn().mockResolvedValue(undefined),
},
},
],
}).compile();
service = module.get(CallerResolutionService);
platform = module.get(PlatformGraphqlService);
cache = module.get(SessionService);
});
// ── TC-IB-05: Existing lead + existing patient ───────────────
it('TC-IB-05: should return existing lead+patient when both found by phone', async () => {
platform.queryWithAuth
// leads query
.mockResolvedValueOnce({ leads: { edges: [{ node: existingLead }] } })
// patients query
.mockResolvedValueOnce({ patients: { edges: [{ node: existingPatient }] } });
const result = await service.resolve('9949879837', AUTH);
expect(result.leadId).toBe('lead-001');
expect(result.patientId).toBe('patient-001');
expect(result.isNew).toBe(false);
expect(result.firstName).toBe('Priya');
});
// ── TC-IB-06: New caller → creates both lead + patient ──────
it('TC-IB-06: should create both lead+patient for unknown caller', async () => {
platform.queryWithAuth
// leads query → empty
.mockResolvedValueOnce({ leads: { edges: [] } })
// patients query → empty
.mockResolvedValueOnce({ patients: { edges: [] } })
// createPatient
.mockResolvedValueOnce({ createPatient: { id: 'new-patient-001' } })
// createLead
.mockResolvedValueOnce({ createLead: { id: 'new-lead-001' } });
const result = await service.resolve('6309248884', AUTH);
expect(result.leadId).toBe('new-lead-001');
expect(result.patientId).toBe('new-patient-001');
expect(result.isNew).toBe(true);
});
// ── Lead exists, no patient → creates patient ────────────────
it('should create patient when lead exists without patient match', async () => {
const leadNoPatient = { ...existingLead, patientId: null };
platform.queryWithAuth
.mockResolvedValueOnce({ leads: { edges: [{ node: leadNoPatient }] } })
.mockResolvedValueOnce({ patients: { edges: [] } })
// createPatient
.mockResolvedValueOnce({ createPatient: { id: 'new-patient-002' } })
// linkLeadToPatient (updateLead)
.mockResolvedValueOnce({ updateLead: { id: 'lead-001' } });
const result = await service.resolve('9949879837', AUTH);
expect(result.patientId).toBe('new-patient-002');
expect(result.leadId).toBe('lead-001');
expect(result.isNew).toBe(false);
});
// ── Patient exists, no lead → creates lead ───────────────────
it('should create lead when patient exists without lead match', async () => {
platform.queryWithAuth
.mockResolvedValueOnce({ leads: { edges: [] } })
.mockResolvedValueOnce({ patients: { edges: [{ node: existingPatient }] } })
// createLead
.mockResolvedValueOnce({ createLead: { id: 'new-lead-002' } });
const result = await service.resolve('9949879837', AUTH);
expect(result.leadId).toBe('new-lead-002');
expect(result.patientId).toBe('patient-001');
expect(result.isNew).toBe(false);
});
// ── Phone normalization ──────────────────────────────────────
it('should normalize phone: strip +91 prefix and non-digits', async () => {
platform.queryWithAuth
.mockResolvedValueOnce({ leads: { edges: [] } })
.mockResolvedValueOnce({ patients: { edges: [] } })
.mockResolvedValueOnce({ createPatient: { id: 'p' } })
.mockResolvedValueOnce({ createLead: { id: 'l' } });
const result = await service.resolve('+91-994-987-9837', AUTH);
expect(result.phone).toBe('9949879837');
});
it('should reject invalid short phone numbers', async () => {
await expect(service.resolve('12345', AUTH)).rejects.toThrow('Invalid phone');
});
// ── Cache hit ────────────────────────────────────────────────
it('should return cached result without hitting platform', async () => {
const cached: ResolvedCaller = {
leadId: 'cached-lead',
patientId: 'cached-patient',
firstName: 'Cache',
lastName: 'Hit',
phone: '9949879837',
isNew: false,
};
cache.getCache.mockResolvedValueOnce(JSON.stringify(cached));
const result = await service.resolve('9949879837', AUTH);
expect(result).toEqual(cached);
expect(platform.queryWithAuth).not.toHaveBeenCalled();
});
// ── Cache write ──────────────────────────────────────────────
it('should cache result after successful resolve', async () => {
platform.queryWithAuth
.mockResolvedValueOnce({ leads: { edges: [{ node: existingLead }] } })
.mockResolvedValueOnce({ patients: { edges: [{ node: existingPatient }] } });
await service.resolve('9949879837', AUTH);
expect(cache.setCache).toHaveBeenCalledWith(
'caller:9949879837',
expect.any(String),
3600,
);
});
// ── Links unlinked lead to patient ───────────────────────────
it('should link lead to patient when both exist but are unlinked', async () => {
const unlinkedLead = { ...existingLead, patientId: null };
platform.queryWithAuth
.mockResolvedValueOnce({ leads: { edges: [{ node: unlinkedLead }] } })
.mockResolvedValueOnce({ patients: { edges: [{ node: existingPatient }] } })
// updateLead to link
.mockResolvedValueOnce({ updateLead: { id: 'lead-001' } });
const result = await service.resolve('9949879837', AUTH);
expect(result.leadId).toBe('lead-001');
expect(result.patientId).toBe('patient-001');
// Verify the link mutation was called
const linkCall = platform.queryWithAuth.mock.calls.find(
c => typeof c[0] === 'string' && c[0].includes('updateLead'),
);
expect(linkCall).toBeDefined();
});
});

View File

@@ -0,0 +1,49 @@
import { Body, Controller, Get, Logger, Param, Post, Put } from '@nestjs/common';
import { AiConfigService } from './ai-config.service';
import type { AiActorKey, AiConfig } from './ai.defaults';
// Mounted under /api/config alongside theme/widget/telephony/setup-state.
//
// GET /api/config/ai — full config (no secrets here, all safe to return)
// PUT /api/config/ai — admin update (provider/model/temperature)
// POST /api/config/ai/reset — reset entire config to defaults
// PUT /api/config/ai/prompts/:actor — update one persona's system prompt template
// POST /api/config/ai/prompts/:actor/reset — restore one persona to its default
@Controller('api/config')
export class AiConfigController {
private readonly logger = new Logger(AiConfigController.name);
constructor(private readonly ai: AiConfigService) {}
@Get('ai')
getAi() {
return this.ai.getConfig();
}
@Put('ai')
updateAi(@Body() body: Partial<AiConfig>) {
this.logger.log('AI config update request');
return this.ai.updateConfig(body);
}
@Post('ai/reset')
resetAi() {
this.logger.log('AI config reset request');
return this.ai.resetConfig();
}
@Put('ai/prompts/:actor')
updatePrompt(
@Param('actor') actor: AiActorKey,
@Body() body: { template: string; editedBy?: string },
) {
this.logger.log(`AI prompt update for actor '${actor}'`);
return this.ai.updatePrompt(actor, body.template, body.editedBy ?? null);
}
@Post('ai/prompts/:actor/reset')
resetPrompt(@Param('actor') actor: AiActorKey) {
this.logger.log(`AI prompt reset for actor '${actor}'`);
return this.ai.resetPrompt(actor);
}
}

View File

@@ -0,0 +1,218 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { dirname, join } from 'path';
import {
AI_ACTOR_KEYS,
AI_ENV_SEEDS,
DEFAULT_AI_CONFIG,
DEFAULT_AI_PROMPTS,
type AiActorKey,
type AiConfig,
type AiPromptConfig,
type AiProvider,
} from './ai.defaults';
const CONFIG_PATH = join(process.cwd(), 'data', 'ai.json');
const BACKUP_DIR = join(process.cwd(), 'data', 'ai-backups');
// File-backed AI config — provider, model, temperature, and per-actor
// system prompt templates. API keys stay in env. Mirrors
// TelephonyConfigService.
@Injectable()
export class AiConfigService implements OnModuleInit {
private readonly logger = new Logger(AiConfigService.name);
private cached: AiConfig | null = null;
onModuleInit() {
this.ensureReady();
}
getConfig(): AiConfig {
if (this.cached) return this.cached;
return this.load();
}
updateConfig(updates: Partial<AiConfig>): AiConfig {
const current = this.getConfig();
const merged: AiConfig = {
...current,
...updates,
// Clamp temperature to a sane range so an admin typo can't break
// the model — most providers reject < 0 or > 2.
temperature:
updates.temperature !== undefined
? Math.max(0, Math.min(2, updates.temperature))
: current.temperature,
version: (current.version ?? 0) + 1,
updatedAt: new Date().toISOString(),
};
this.backup();
this.writeFile(merged);
this.cached = merged;
this.logger.log(`AI config updated to v${merged.version}`);
return merged;
}
resetConfig(): AiConfig {
this.backup();
const fresh = JSON.parse(JSON.stringify(DEFAULT_AI_CONFIG)) as AiConfig;
this.writeFile(fresh);
this.cached = fresh;
this.logger.log('AI config reset to defaults');
return fresh;
}
// Update a single actor's prompt template, preserving the audit
// trail. Used by the wizard's edit slideout. Validates the actor
// key so a typo from a hand-crafted PUT can't write garbage.
updatePrompt(actor: AiActorKey, template: string, editedBy: string | null): AiConfig {
if (!AI_ACTOR_KEYS.includes(actor)) {
throw new Error(`Unknown AI actor: ${actor}`);
}
const current = this.getConfig();
const existing = current.prompts[actor] ?? DEFAULT_AI_PROMPTS[actor];
const updatedPrompt: AiPromptConfig = {
...existing,
template,
lastEditedAt: new Date().toISOString(),
lastEditedBy: editedBy,
};
const merged: AiConfig = {
...current,
prompts: { ...current.prompts, [actor]: updatedPrompt },
version: (current.version ?? 0) + 1,
updatedAt: new Date().toISOString(),
};
this.backup();
this.writeFile(merged);
this.cached = merged;
this.logger.log(`AI prompt for actor '${actor}' updated to v${merged.version}`);
return merged;
}
// Restore a single actor's prompt back to the SDK-shipped default.
// Clears the audit fields so it looks "fresh" in the UI.
resetPrompt(actor: AiActorKey): AiConfig {
if (!AI_ACTOR_KEYS.includes(actor)) {
throw new Error(`Unknown AI actor: ${actor}`);
}
const current = this.getConfig();
const fresh: AiPromptConfig = {
...DEFAULT_AI_PROMPTS[actor],
lastEditedAt: null,
lastEditedBy: null,
};
const merged: AiConfig = {
...current,
prompts: { ...current.prompts, [actor]: fresh },
version: (current.version ?? 0) + 1,
updatedAt: new Date().toISOString(),
};
this.backup();
this.writeFile(merged);
this.cached = merged;
this.logger.log(`AI prompt for actor '${actor}' reset to default`);
return merged;
}
// Render a prompt with `{{variable}}` substitution. Variables not
// present in `vars` are left as-is so a missing fill is loud
// (the AI sees `{{leadName}}` literally) rather than silently
// dropping the placeholder. Falls back to DEFAULT_AI_PROMPTS if
// the actor key is missing from the loaded config (handles old
// ai.json files that predate this refactor).
renderPrompt(actor: AiActorKey, vars: Record<string, string | number | null | undefined>): string {
const cfg = this.getConfig();
const prompt = cfg.prompts?.[actor] ?? DEFAULT_AI_PROMPTS[actor];
const template = prompt.template;
return template.replace(/\{\{(\w+)\}\}/g, (match, key) => {
const value = vars[key];
if (value === undefined || value === null) return match;
return String(value);
});
}
private ensureReady(): AiConfig {
if (existsSync(CONFIG_PATH)) {
return this.load();
}
const seeded: AiConfig = JSON.parse(JSON.stringify(DEFAULT_AI_CONFIG)) as AiConfig;
let appliedCount = 0;
for (const seed of AI_ENV_SEEDS) {
const value = process.env[seed.env];
if (value === undefined || value === '') continue;
(seeded as any)[seed.field] = value;
appliedCount += 1;
}
seeded.version = 1;
seeded.updatedAt = new Date().toISOString();
this.writeFile(seeded);
this.cached = seeded;
this.logger.log(
`AI config seeded from env (${appliedCount} env var${appliedCount === 1 ? '' : 's'} applied)`,
);
return seeded;
}
private load(): AiConfig {
try {
const raw = readFileSync(CONFIG_PATH, 'utf8');
const parsed = JSON.parse(raw);
// Merge incoming prompts against defaults so old ai.json
// files (written before the prompts refactor) get topped
// up with the new actor entries instead of crashing on
// first read. Per-actor merging keeps any admin edits
// intact while filling in missing actors.
const mergedPrompts: Record<AiActorKey, AiPromptConfig> = { ...DEFAULT_AI_PROMPTS };
if (parsed.prompts && typeof parsed.prompts === 'object') {
for (const key of AI_ACTOR_KEYS) {
const incoming = parsed.prompts[key];
if (incoming && typeof incoming === 'object') {
mergedPrompts[key] = {
...DEFAULT_AI_PROMPTS[key],
...incoming,
// Always pull `defaultTemplate` from the
// shipped defaults — never trust the
// file's copy, since the SDK baseline can
// change between releases and we want
// "reset to default" to always reset to
// the latest baseline.
defaultTemplate: DEFAULT_AI_PROMPTS[key].defaultTemplate,
};
}
}
}
const merged: AiConfig = {
...DEFAULT_AI_CONFIG,
...parsed,
provider: (parsed.provider ?? DEFAULT_AI_CONFIG.provider) as AiProvider,
prompts: mergedPrompts,
};
this.cached = merged;
this.logger.log('AI config loaded from file');
return merged;
} catch (err) {
this.logger.warn(`Failed to load AI config, using defaults: ${err}`);
const fresh = JSON.parse(JSON.stringify(DEFAULT_AI_CONFIG)) as AiConfig;
this.cached = fresh;
return fresh;
}
}
private writeFile(cfg: AiConfig) {
const dir = dirname(CONFIG_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf8');
}
private backup() {
try {
if (!existsSync(CONFIG_PATH)) return;
if (!existsSync(BACKUP_DIR)) mkdirSync(BACKUP_DIR, { recursive: true });
const ts = new Date().toISOString().replace(/[:.]/g, '-');
copyFileSync(CONFIG_PATH, join(BACKUP_DIR, `ai-${ts}.json`));
} catch (err) {
this.logger.warn(`AI config backup failed: ${err}`);
}
}
}

305
src/config/ai.defaults.ts Normal file
View File

@@ -0,0 +1,305 @@
// Admin-editable AI assistant config. Holds the user-facing knobs (provider,
// model, temperature) AND a per-actor system prompt template map. API keys
// themselves stay in env vars because they are true secrets and rotation is
// an ops event.
//
// Each "actor" is a distinct AI persona used by the sidecar — widget chat,
// CC agent helper, supervisor, lead enrichment, etc. Pulling these out of
// hardcoded service files lets the hospital admin tune tone, boundaries,
// and instructions per persona without a sidecar redeploy. The 7 actors
// listed below cover every customer-facing AI surface in Helix Engage as
// of 2026-04-08; internal/dev-only prompts (rules engine config helper,
// recording speaker-channel identification) stay hardcoded since they are
// not customer-tunable.
//
// Templating: each actor's prompt is a string with `{{variable}}` placeholders
// that the calling service fills in via AiConfigService.renderPrompt(actor,
// vars). The variable shape per actor is documented in the `variables` field
// so the wizard UI can show admins what they can reference.
export type AiProvider = 'openai' | 'anthropic';
// Stable keys for each configurable persona. Adding a new actor:
// 1. add a key here
// 2. add a default entry in DEFAULT_AI_PROMPTS below
// 3. add the corresponding renderPrompt call in the consuming service
export const AI_ACTOR_KEYS = [
'widgetChat',
'ccAgentHelper',
'supervisorChat',
'leadEnrichment',
'callInsight',
'callAssist',
'recordingAnalysis',
] as const;
export type AiActorKey = (typeof AI_ACTOR_KEYS)[number];
export type AiPromptConfig = {
// Human-readable name shown in the wizard UI.
label: string;
// One-line description of when this persona is invoked.
description: string;
// Variables the template can reference, with a one-line hint each.
// Surfaced in the edit slideout so admins know what `{{var}}` they
// can use without reading code.
variables: Array<{ key: string; description: string }>;
// The current template (may be admin-edited).
template: string;
// The original baseline so we can offer a "reset to default" button.
defaultTemplate: string;
// Audit fields — when this prompt was last edited and by whom.
// null on the default-supplied entries.
lastEditedAt: string | null;
lastEditedBy: string | null;
};
export type AiConfig = {
provider: AiProvider;
model: string;
// 0..2, controls randomness. Default 0.7 matches the existing hardcoded
// values used in WidgetChatService and AI tools.
temperature: number;
// Per-actor system prompt templates. Keyed by AiActorKey so callers can
// do `config.prompts.widgetChat.template` and missing keys are caught
// at compile time.
prompts: Record<AiActorKey, AiPromptConfig>;
version?: number;
updatedAt?: string;
};
// ---------------------------------------------------------------------------
// Default templates — extracted verbatim from the hardcoded versions in:
// - widget-chat.service.ts → widgetChat
// - ai-chat.controller.ts → ccAgentHelper, supervisorChat
// - ai-enrichment.service.ts → leadEnrichment
// - ai-insight.consumer.ts → callInsight
// - call-assist.service.ts → callAssist
// - recordings.service.ts → recordingAnalysis
// ---------------------------------------------------------------------------
const WIDGET_CHAT_DEFAULT = `You are a helpful, concise assistant for {{hospitalName}}.
You are chatting with a website visitor named {{userName}}.
{{branchContext}}
TOOL USAGE RULES (STRICT):
- When the user asks about departments, call list_departments and DO NOT also list departments in prose.
- When they ask about clinic timings, visiting hours, or "when is X open", call show_clinic_timings.
- When they ask about doctors in a department, call show_doctors and DO NOT also list doctors in prose.
- When they ask about a specific doctor's availability or want to book with them, call show_doctor_slots.
- When the conversation is trending toward booking, call suggest_booking.
- After calling a tool, DO NOT restate its contents in prose. At most write a single short sentence
(under 15 words) framing the widget, or no text at all. The widget already shows the data.
- If you are about to write a bulleted or numbered list of departments, doctors, hours, or slots,
STOP and call the appropriate tool instead.
- NEVER use markdown formatting (no **bold**, no *italic*, no bullet syntax). Plain text only in
non-tool replies.
- NEVER invent or mention specific dates in prose or tool inputs. The server owns "today".
If the visitor asks about a future date, tell them to use the Book tab's date picker.
OTHER RULES:
- Answer other questions (directions, general info) concisely in prose.
- If you do not know something, say so and suggest they call the hospital.
- Never quote prices. No medical advice. For clinical questions, defer to a doctor.
{{knowledgeBase}}`;
const CC_AGENT_HELPER_DEFAULT = `You are an AI assistant for call center agents at {{hospitalName}}.
You help agents answer questions about patients, doctors, appointments, clinics, and hospital services during live calls.
IMPORTANT — ANSWER FROM KNOWLEDGE BASE FIRST:
The knowledge base below contains REAL clinic locations, timings, doctor details, health packages, and insurance partners.
When asked about clinic timings, locations, doctor availability, packages, or insurance — ALWAYS check the knowledge base FIRST before saying you don't know.
RULES:
1. For patient-specific questions (history, appointments, calls), use the lookup tools. NEVER guess patient data. NEVER say a patient doesn't exist without calling a tool first.
2. When CURRENT CONTEXT lists a Lead ID, the lookup tools already know which caller to pull. Call them with NO arguments — do not re-type the Lead ID or Patient ID as a tool argument:
- lookup_call_history() → calls for this caller
- lookup_lead_activities() → activity log for this caller
- lookup_appointments() → appointments for this caller
Pass IDs explicitly only when the agent is asking about a different, specific patient — and even then, prefer name/phone via lookup_patient.
3. For "summarize this patient's history" or similar, chain multiple lookups (call history + lead activities + appointments) and stitch the answer from what came back. If all three return empty, say so honestly — otherwise report what you found.
4. For doctor details beyond what's in the KB, use the lookup_doctor tool.
5. For clinic info, timings, packages, insurance → answer directly from the knowledge base below. If the knowledge base is empty for that section (e.g. no packages configured), say the feature isn't set up yet instead of "I couldn't find that".
6. Be concise — agents are on live calls. Under 100 words unless asked for detail.
7. NEVER give medical advice, diagnosis, or treatment recommendations.
RESPONSE FORMAT (STRICT):
You MUST respond with valid JSON in this exact format — no markdown fences, no extra text, just raw JSON:
{"message": "your response text here", "suggestions": [{"id": "s1", "type": "upsell", "title": "short title", "script": "2-3 sentence script the agent reads aloud", "priority": "high"}]}
Response format rules:
- "message" MUST be plain text sentences only. NEVER use markdown headers (###), bold (**), bullet lists (-), or field labels (Phone:, Status:). Write natural conversational sentences like you are briefing a colleague. Do NOT repeat suggestions in the message — they belong only in the suggestions array.
- "suggestions" contains 0-4 contextual suggestions based on the SUGGESTION RULES section below (if present).
- Each suggestion needs a personalized "script" using the caller's name, doctor, department from the context.
- type must be one of: upsell, crosssell, retention, operational
- priority must be one of: high, medium, low
- On the first response (patient summary), always include suggestions from the rules.
- On subsequent responses, update suggestions based on conversation — remove acted-on ones, add new if relevant.
- If no suggestion rules are provided, return an empty suggestions array.
- Do NOT repeat raw data fields in the message. The summary card already shows name, phone, appointments. Keep the message to insight and context the card doesn't show.
KNOWLEDGE BASE (this is real data from our system):
{{knowledgeBase}}`;
const SUPERVISOR_CHAT_DEFAULT = `You are an AI assistant for supervisors at {{hospitalName}}'s call center (Helix Engage).
You help supervisors monitor team performance, identify issues, and make data-driven decisions.
## YOUR CAPABILITIES
You have access to tools that query real-time data:
- **Agent performance**: call counts, conversion rates, NPS scores, idle time, pending follow-ups
- **Campaign stats**: lead counts, conversion rates per campaign, platform breakdown
- **Call summary**: total calls, inbound/outbound split, missed call rate, disposition breakdown
- **SLA breaches**: missed calls that haven't been called back within the SLA threshold
## RULES
1. ALWAYS use tools to fetch data before answering. NEVER guess or fabricate performance numbers.
2. Be specific — include actual numbers from the tool response, not vague qualifiers.
3. When comparing agents, use their configured thresholds (minConversionPercent, minNpsThreshold, maxIdleMinutes) and team averages. Let the data determine who is underperforming — do not assume.
4. Be concise — supervisors want quick answers. Use bullet points.
5. When recommending actions, ground them in the data returned by tools.
6. If asked about trends, use the call summary tool with different periods.
7. Do not use any agent name in a negative context unless the data explicitly supports it.`;
const LEAD_ENRICHMENT_DEFAULT = `You are an AI assistant for a hospital call center.
An inbound call is coming in from a lead. Summarize their history and suggest what the call center agent should do.
Lead details:
- Name: {{leadName}}
- Source: {{leadSource}}
- Interested in: {{interestedService}}
- Current status: {{leadStatus}}
- Lead age: {{daysSince}} days
- Contact attempts: {{contactAttempts}}
Recent activity:
{{activities}}`;
const CALL_INSIGHT_DEFAULT = `You are a CRM assistant for {{hospitalName}}.
Generate a brief, actionable insight about this lead based on their interaction history.
Be specific — reference actual dates, dispositions, and patterns.
If the lead has booked appointments, mention upcoming ones.
If they keep calling about the same thing, note the pattern.`;
const CALL_ASSIST_DEFAULT = `You are a real-time call assistant for {{hospitalName}}.
You listen to the customer's words and provide brief, actionable suggestions for the CC agent.
{{context}}
RULES:
- Keep suggestions under 2 sentences
- Focus on actionable next steps the agent should take NOW
- If customer mentions a doctor or department, suggest available slots
- If customer wants to cancel or reschedule, note relevant appointment details
- If customer sounds upset, suggest empathetic response
- Do NOT repeat what the agent already knows`;
const RECORDING_ANALYSIS_DEFAULT = `You are a call quality analyst for {{hospitalName}}.
Analyze the following call recording transcript and provide structured insights.
Be specific, brief, and actionable. Focus on healthcare context.
{{summaryBlock}}
{{topicsBlock}}`;
// Helper that builds an AiPromptConfig with the same template for both
// `template` and `defaultTemplate` — what every actor starts with on a
// fresh boot.
const promptDefault = (
label: string,
description: string,
variables: Array<{ key: string; description: string }>,
template: string,
): AiPromptConfig => ({
label,
description,
variables,
template,
defaultTemplate: template,
lastEditedAt: null,
lastEditedBy: null,
});
export const DEFAULT_AI_PROMPTS: Record<AiActorKey, AiPromptConfig> = {
widgetChat: promptDefault(
'Website widget chat',
'Patient-facing bot embedded on the hospital website. Handles general questions, finds doctors, suggests appointments.',
[
{ key: 'hospitalName', description: 'Branded hospital display name from theme.json' },
{ key: 'userName', description: 'Visitor first name (or "there" if unknown)' },
{ key: 'branchContext', description: 'Pre-rendered branch-selection instructions block' },
{ key: 'knowledgeBase', description: 'Pre-rendered list of departments + doctors + clinics' },
],
WIDGET_CHAT_DEFAULT,
),
ccAgentHelper: promptDefault(
'CC agent helper',
'In-call assistant the CC agent uses to look up patient history, doctor details, and clinic info while on a call.',
[
{ key: 'hospitalName', description: 'Branded hospital display name' },
{ key: 'knowledgeBase', description: 'Pre-rendered hospital knowledge base (clinics, doctors, packages)' },
],
CC_AGENT_HELPER_DEFAULT,
),
supervisorChat: promptDefault(
'Supervisor assistant',
'AI tools the supervisor uses to query agent performance, campaign stats, and SLA breaches.',
[
{ key: 'hospitalName', description: 'Branded hospital display name' },
],
SUPERVISOR_CHAT_DEFAULT,
),
leadEnrichment: promptDefault(
'Lead enrichment',
'Generates an AI summary + suggested action for a new inbound lead before the agent picks up.',
[
{ key: 'leadName', description: 'Lead first + last name' },
{ key: 'leadSource', description: 'Source channel (WHATSAPP, GOOGLE_ADS, etc.)' },
{ key: 'interestedService', description: 'What the lead enquired about' },
{ key: 'leadStatus', description: 'Current lead status' },
{ key: 'daysSince', description: 'Days since the lead was created' },
{ key: 'contactAttempts', description: 'Prior contact attempts count' },
{ key: 'activities', description: 'Pre-rendered recent activity summary' },
],
LEAD_ENRICHMENT_DEFAULT,
),
callInsight: promptDefault(
'Post-call insight',
'After each call, generates a 2-3 sentence summary + a single suggested next action for the lead record.',
[
{ key: 'hospitalName', description: 'Branded hospital display name' },
],
CALL_INSIGHT_DEFAULT,
),
callAssist: promptDefault(
'Live call whisper',
'Real-time suggestions whispered to the CC agent during a call, based on the running transcript.',
[
{ key: 'hospitalName', description: 'Branded hospital display name' },
{ key: 'context', description: 'Pre-rendered call context (current lead, recent activities, available doctors)' },
],
CALL_ASSIST_DEFAULT,
),
recordingAnalysis: promptDefault(
'Call recording analysis',
'Analyses post-call recording transcripts to extract key topics, action items, coaching notes, and compliance flags.',
[
{ key: 'hospitalName', description: 'Branded hospital display name' },
{ key: 'summaryBlock', description: 'Optional pre-rendered "Call summary: ..." line (empty when none)' },
{ key: 'topicsBlock', description: 'Optional pre-rendered "Detected topics: ..." line (empty when none)' },
],
RECORDING_ANALYSIS_DEFAULT,
),
};
export const DEFAULT_AI_CONFIG: AiConfig = {
provider: 'openai',
model: 'gpt-4o-mini',
temperature: 0.7,
prompts: DEFAULT_AI_PROMPTS,
};
// Field-by-field mapping from the legacy env vars used by ai-provider.ts
// (AI_PROVIDER + AI_MODEL). API keys are NOT seeded — they remain in env.
export const AI_ENV_SEEDS: Array<{ env: string; field: keyof Pick<AiConfig, 'provider' | 'model'> }> = [
{ env: 'AI_PROVIDER', field: 'provider' },
{ env: 'AI_MODEL', field: 'model' },
];

View File

@@ -0,0 +1,54 @@
import { Global, Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { PlatformModule } from '../platform/platform.module';
import { ThemeController } from './theme.controller';
import { ThemeService } from './theme.service';
import { WidgetKeysService } from './widget-keys.service';
import { WidgetConfigService } from './widget-config.service';
import { WidgetConfigController } from './widget-config.controller';
import { SetupStateService } from './setup-state.service';
import { SetupStateController } from './setup-state.controller';
import { TelephonyConfigService } from './telephony-config.service';
import { TelephonyConfigController } from './telephony-config.controller';
import { AiConfigService } from './ai-config.service';
import { AiConfigController } from './ai-config.controller';
// Central config module — owns everything in data/*.json that's editable
// from the admin portal. Today: theme, widget, setup-state, telephony, ai.
//
// Marked @Global() so the 3 new sidecar config services (setup-state, telephony,
// ai) are injectable from any module without explicit import wiring. Without this,
// AuthModule + OzonetelAgentModule + MaintModule would all need to import
// ConfigThemeModule, which would create a circular dependency with AuthModule
// (ConfigThemeModule already imports AuthModule for SessionService).
//
// AuthModule is imported because WidgetKeysService depends on SessionService
// (Redis-backed cache for widget site key storage).
@Global()
@Module({
imports: [AuthModule, PlatformModule],
controllers: [
ThemeController,
WidgetConfigController,
SetupStateController,
TelephonyConfigController,
AiConfigController,
],
providers: [
ThemeService,
WidgetKeysService,
WidgetConfigService,
SetupStateService,
TelephonyConfigService,
AiConfigService,
],
exports: [
ThemeService,
WidgetKeysService,
WidgetConfigService,
SetupStateService,
TelephonyConfigService,
AiConfigService,
],
})
export class ConfigThemeModule {}

View File

@@ -12,10 +12,39 @@ export default () => ({
subdomain: process.env.EXOTEL_SUBDOMAIN ?? 'api.exotel.com',
webhookSecret: process.env.EXOTEL_WEBHOOK_SECRET ?? '',
},
redis: {
url: process.env.REDIS_URL ?? 'redis://localhost:6379',
},
sip: {
domain: process.env.SIP_DOMAIN ?? 'blr-pub-rtc4.ozonetel.com',
wsPort: process.env.SIP_WS_PORT ?? '444',
},
missedQueue: {
pollIntervalMs: parseInt(process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000', 10),
},
worklist: {
// Per-page fetch size from the platform GraphQL endpoint. Tuned to
// balance response size vs. page count. Platform's Relay pagination
// typically caps at 100200 per page.
pageSize: parseInt(process.env.WORKLIST_PAGE_SIZE ?? '50', 10),
// Hard ceiling on pages fetched per poll. Safety valve against
// unbounded cost when a tenant has thousands of pending callbacks.
// maxPages * pageSize = effective worklist size.
maxPages: parseInt(process.env.WORKLIST_MAX_PAGES ?? '10', 10),
},
ai: {
provider: process.env.AI_PROVIDER ?? 'openai',
anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? '',
openaiApiKey: process.env.OPENAI_API_KEY ?? '',
model: process.env.AI_MODEL ?? 'gpt-4o-mini',
},
sidecarUrl: process.env.SIDECAR_PUBLIC_URL ?? '',
messaging: {
provider: process.env.MESSAGING_PROVIDER ?? 'gupshup',
gupshup: {
apiKey: process.env.GUPSHUP_API_KEY ?? '',
appId: process.env.GUPSHUP_APP_ID ?? '',
sourceNumber: process.env.GUPSHUP_SOURCE_NUMBER ?? '',
},
},
});

View File

@@ -0,0 +1,73 @@
import { Body, Controller, Get, Logger, Param, Post, Put } from '@nestjs/common';
import { SetupStateService } from './setup-state.service';
import type { SetupStepName } from './setup-state.defaults';
// Public endpoint family for the onboarding wizard. Mounted under /api/config
// alongside theme/widget. No auth guard yet — matches existing convention with
// ThemeController. To be tightened when the staff portal admin auth is in place.
//
// GET /api/config/setup-state full state + isWizardRequired
// PUT /api/config/setup-state/steps/:step { completed: bool, completedBy?: string }
// POST /api/config/setup-state/dismiss dismiss the wizard for this workspace
// POST /api/config/setup-state/reset reset all steps to incomplete (admin)
@Controller('api/config')
export class SetupStateController {
private readonly logger = new Logger(SetupStateController.name);
constructor(private readonly setupState: SetupStateService) {}
@Get('setup-state')
async getState() {
// Use the checked variant so the platform workspace probe runs
// before we serialize. Catches workspace changes (DB resets,
// re-onboards) on the very first frontend GET.
const state = await this.setupState.getStateChecked();
return {
...state,
wizardRequired: this.setupState.isWizardRequired(),
};
}
@Put('setup-state/steps/:step')
updateStep(
@Param('step') step: SetupStepName,
@Body() body: { completed: boolean; completedBy?: string },
) {
const updated = body.completed
? this.setupState.markStepCompleted(step, body.completedBy ?? null)
: this.setupState.markStepIncomplete(step);
// Mirror GET shape — include `wizardRequired` so the frontend
// doesn't see a state object missing the field and re-render
// into an inconsistent shape.
return { ...updated, wizardRequired: this.setupState.isWizardRequired() };
}
@Post('setup-state/dismiss')
dismiss() {
const updated = this.setupState.dismissWizard();
return { ...updated, wizardRequired: this.setupState.isWizardRequired() };
}
@Post('setup-state/reset')
reset() {
const updated = this.setupState.resetState();
return { ...updated, wizardRequired: this.setupState.isWizardRequired() };
}
// UI-level flags the frontend reads at app boot to tailor which admin
// surfaces are available. Driven by sidecar env vars so each workspace
// can be configured independently without touching the frontend build.
//
// setupManaged=true means "the product team handles setup for this
// workspace" — hide the Settings nav, routes, and the resume-setup
// banner. The wizard + setup-state APIs stay functional for ops use
// (a support engineer can still PUT /steps/:step or hit the routes
// directly); only the end-user admin UI is hidden.
@Get('ui-flags')
uiFlags() {
return {
setupManaged: process.env.HELIX_SETUP_MANAGED === 'true',
telephonyEnabled: process.env.TELEPHONY_ENABLED !== 'false', // default true
};
}
}

View File

@@ -0,0 +1,60 @@
// Tracks completion of the 6 onboarding setup steps the hospital admin walks
// through after first login. Drives the wizard auto-show on /setup and the
// completion badges on the Settings hub.
export type SetupStepName =
| 'identity'
| 'clinics'
| 'doctors'
| 'team'
| 'telephony'
| 'ai';
export type SetupStepStatus = {
completed: boolean;
completedAt: string | null;
completedBy: string | null;
};
export type SetupState = {
version?: number;
updatedAt?: string;
// When true the wizard never auto-shows even if some steps are incomplete.
// Settings hub still shows the per-section badges.
wizardDismissed: boolean;
steps: Record<SetupStepName, SetupStepStatus>;
// The platform workspace this state belongs to. The sidecar's API key
// is scoped to exactly one workspace, so on every load we compare the
// file's workspaceId against the live currentWorkspace.id and reset
// the file if they differ. Stops setup-state from leaking across DB
// resets and re-onboards.
workspaceId?: string | null;
};
const emptyStep = (): SetupStepStatus => ({
completed: false,
completedAt: null,
completedBy: null,
});
export const SETUP_STEP_NAMES: readonly SetupStepName[] = [
'identity',
'clinics',
'doctors',
'team',
'telephony',
'ai',
] as const;
export const DEFAULT_SETUP_STATE: SetupState = {
wizardDismissed: false,
workspaceId: null,
steps: {
identity: emptyStep(),
clinics: emptyStep(),
doctors: emptyStep(),
team: emptyStep(),
telephony: emptyStep(),
ai: emptyStep(),
},
};

View File

@@ -0,0 +1,220 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { dirname, join } from 'path';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import {
DEFAULT_SETUP_STATE,
SETUP_STEP_NAMES,
type SetupState,
type SetupStepName,
} from './setup-state.defaults';
const SETUP_STATE_PATH = join(process.cwd(), 'data', 'setup-state.json');
// File-backed store for the onboarding wizard's progress. Mirrors the
// pattern of ThemeService and WidgetConfigService — load on init, cache in
// memory, write on every change. No backups (the data is small and easily
// recreated by the wizard if it ever gets corrupted).
//
// Workspace scoping: the sidecar's API key is scoped to exactly one
// workspace, so on first access we compare the file's stored workspaceId
// against the live currentWorkspace.id from the platform. If they differ
// (DB reset, re-onboard, sidecar pointed at a new workspace), the file is
// reset before any reads return. This guarantees a fresh wizard for a
// fresh workspace without manual file deletion.
@Injectable()
export class SetupStateService implements OnModuleInit {
private readonly logger = new Logger(SetupStateService.name);
private cached: SetupState | null = null;
// Memoize the platform's currentWorkspace.id lookup so we don't hit
// the platform on every getState() call. Set once per process boot
// (or after a successful reset).
private liveWorkspaceId: string | null = null;
private workspaceCheckPromise: Promise<void> | null = null;
constructor(private platform: PlatformGraphqlService) {}
onModuleInit() {
this.load();
// Fire-and-forget the workspace probe so the file gets aligned
// before the frontend's first GET. Errors are logged but
// non-fatal — if the platform is down at boot, the legacy
// unscoped behaviour kicks in until the first reachable probe.
this.ensureWorkspaceMatch().catch((err) =>
this.logger.warn(`Initial workspace probe failed: ${err}`),
);
}
getState(): SetupState {
if (this.cached) return this.cached;
return this.load();
}
// Awaits a workspace check before returning state. The controller
// calls this so the GET response always reflects the current
// workspace, not yesterday's.
async getStateChecked(): Promise<SetupState> {
await this.ensureWorkspaceMatch();
return this.getState();
}
private async ensureWorkspaceMatch(): Promise<void> {
// Single-flight: if a check is already running, await it.
if (this.workspaceCheckPromise) return this.workspaceCheckPromise;
if (this.liveWorkspaceId) {
// Already validated this process. Trust the cache.
return;
}
this.workspaceCheckPromise = (async () => {
try {
const data = await this.platform.query<{
currentWorkspace: { id: string };
}>(`{ currentWorkspace { id } }`);
const liveId = data?.currentWorkspace?.id ?? null;
if (!liveId) {
this.logger.warn(
'currentWorkspace.id was empty — cannot scope setup-state',
);
return;
}
this.liveWorkspaceId = liveId;
const current = this.getState();
if (current.workspaceId && current.workspaceId !== liveId) {
this.logger.log(
`Workspace changed (${current.workspaceId}${liveId}) — resetting setup-state`,
);
this.resetState();
}
if (!current.workspaceId) {
// First boot after the workspaceId field was added
// (or first boot ever). Stamp the file so future
// boots can detect drift.
const stamped: SetupState = {
...this.getState(),
workspaceId: liveId,
};
this.writeFile(stamped);
this.cached = stamped;
}
} finally {
this.workspaceCheckPromise = null;
}
})();
return this.workspaceCheckPromise;
}
// Returns true if any required step is incomplete and the wizard hasn't
// been explicitly dismissed. Used by the frontend post-login redirect.
isWizardRequired(): boolean {
const s = this.getState();
if (s.wizardDismissed) return false;
return SETUP_STEP_NAMES.some(name => !s.steps[name].completed);
}
markStepCompleted(step: SetupStepName, completedBy: string | null = null): SetupState {
const current = this.getState();
if (!current.steps[step]) {
throw new Error(`Unknown setup step: ${step}`);
}
const updated: SetupState = {
...current,
steps: {
...current.steps,
[step]: {
completed: true,
completedAt: new Date().toISOString(),
completedBy,
},
},
version: (current.version ?? 0) + 1,
updatedAt: new Date().toISOString(),
};
this.writeFile(updated);
this.cached = updated;
this.logger.log(`Setup step '${step}' marked completed`);
return updated;
}
markStepIncomplete(step: SetupStepName): SetupState {
const current = this.getState();
if (!current.steps[step]) {
throw new Error(`Unknown setup step: ${step}`);
}
const updated: SetupState = {
...current,
steps: {
...current.steps,
[step]: { completed: false, completedAt: null, completedBy: null },
},
version: (current.version ?? 0) + 1,
updatedAt: new Date().toISOString(),
};
this.writeFile(updated);
this.cached = updated;
this.logger.log(`Setup step '${step}' marked incomplete`);
return updated;
}
dismissWizard(): SetupState {
const current = this.getState();
const updated: SetupState = {
...current,
wizardDismissed: true,
version: (current.version ?? 0) + 1,
updatedAt: new Date().toISOString(),
};
this.writeFile(updated);
this.cached = updated;
this.logger.log('Setup wizard dismissed');
return updated;
}
resetState(): SetupState {
// Preserve the live workspaceId on reset so the file remains
// scoped — otherwise the next workspace check would think the
// file is unscoped and re-stamp it, which is fine but creates
// an extra write.
const fresh: SetupState = {
...DEFAULT_SETUP_STATE,
workspaceId: this.liveWorkspaceId ?? null,
};
this.writeFile(fresh);
this.cached = fresh;
this.logger.log('Setup state reset to defaults');
return this.cached;
}
private load(): SetupState {
try {
if (existsSync(SETUP_STATE_PATH)) {
const raw = readFileSync(SETUP_STATE_PATH, 'utf8');
const parsed = JSON.parse(raw);
// Defensive merge: if a new step name is added later, the old
// file won't have it. Fill missing steps with the empty default.
const merged: SetupState = {
...DEFAULT_SETUP_STATE,
...parsed,
steps: {
...DEFAULT_SETUP_STATE.steps,
...(parsed.steps ?? {}),
},
};
this.cached = merged;
this.logger.log('Setup state loaded from file');
return merged;
}
} catch (err) {
this.logger.warn(`Failed to load setup state: ${err}`);
}
const fresh: SetupState = JSON.parse(JSON.stringify(DEFAULT_SETUP_STATE));
this.cached = fresh;
this.logger.log('Using default setup state (no file yet)');
return fresh;
}
private writeFile(state: SetupState) {
const dir = dirname(SETUP_STATE_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(SETUP_STATE_PATH, JSON.stringify(state, null, 2), 'utf8');
}
}

View File

@@ -0,0 +1,32 @@
import { Body, Controller, Get, Logger, Post, Put } from '@nestjs/common';
import { TelephonyConfigService } from './telephony-config.service';
import type { TelephonyConfig } from './telephony.defaults';
// Mounted under /api/config alongside theme/widget/setup-state.
//
// GET /api/config/telephony — masked (secrets returned as '***masked***')
// PUT /api/config/telephony — admin update; '***masked***' is treated as "no change"
// POST /api/config/telephony/reset — reset to defaults (admin)
@Controller('api/config')
export class TelephonyConfigController {
private readonly logger = new Logger(TelephonyConfigController.name);
constructor(private readonly telephony: TelephonyConfigService) {}
@Get('telephony')
getTelephony() {
return this.telephony.getMaskedConfig();
}
@Put('telephony')
updateTelephony(@Body() body: Partial<TelephonyConfig>) {
this.logger.log('Telephony config update request');
return this.telephony.updateConfig(body);
}
@Post('telephony/reset')
resetTelephony() {
this.logger.log('Telephony config reset request');
return this.telephony.resetConfig();
}
}

View File

@@ -0,0 +1,164 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
import { dirname, join } from 'path';
import {
DEFAULT_TELEPHONY_CONFIG,
TELEPHONY_ENV_SEEDS,
type TelephonyConfig,
} from './telephony.defaults';
const CONFIG_PATH = join(process.cwd(), 'data', 'telephony.json');
const BACKUP_DIR = join(process.cwd(), 'data', 'telephony-backups');
// File-backed telephony config. Replaces eight env vars (OZONETEL_*, SIP_*,
// EXOTEL_*). On first boot we copy whatever those env vars hold into the
// config file so existing deployments don't break — after that, the env vars
// are no longer read by anything.
//
// Mirrors WidgetConfigService and ThemeService — load on init, in-memory
// cache, file backups on every change.
@Injectable()
export class TelephonyConfigService implements OnModuleInit {
private readonly logger = new Logger(TelephonyConfigService.name);
private cached: TelephonyConfig | null = null;
onModuleInit() {
this.ensureReady();
}
getConfig(): TelephonyConfig {
if (this.cached) return this.cached;
return this.load();
}
// Public-facing subset for the GET endpoint — masks the Exotel API token
// so it can't be exfiltrated by an unauthenticated reader. The admin UI
// gets the full config via getConfig() through the controller's PUT path
// (the new value is supplied client-side, the old value is never displayed).
getMaskedConfig() {
const c = this.getConfig();
return {
...c,
exotel: {
...c.exotel,
apiToken: c.exotel.apiToken ? '***masked***' : '',
},
ozonetel: {
...c.ozonetel,
agentPassword: c.ozonetel.agentPassword ? '***masked***' : '',
adminPassword: c.ozonetel.adminPassword ? '***masked***' : '',
},
};
}
updateConfig(updates: Partial<TelephonyConfig>): TelephonyConfig {
const current = this.getConfig();
// Deep-ish merge — each top-level group merges its own keys.
const merged: TelephonyConfig = {
ozonetel: { ...current.ozonetel, ...(updates.ozonetel ?? {}) },
sip: { ...current.sip, ...(updates.sip ?? {}) },
exotel: { ...current.exotel, ...(updates.exotel ?? {}) },
version: (current.version ?? 0) + 1,
updatedAt: new Date().toISOString(),
};
// Strip the masked sentinel — admin UI sends back '***masked***' for
// unchanged secret fields. We treat that as "keep the existing value".
if (merged.exotel.apiToken === '***masked***') {
merged.exotel.apiToken = current.exotel.apiToken;
}
if (merged.ozonetel.agentPassword === '***masked***') {
merged.ozonetel.agentPassword = current.ozonetel.agentPassword;
}
if (merged.ozonetel.adminPassword === '***masked***') {
merged.ozonetel.adminPassword = current.ozonetel.adminPassword;
}
this.backup();
this.writeFile(merged);
this.cached = merged;
this.logger.log(`Telephony config updated to v${merged.version}`);
return merged;
}
resetConfig(): TelephonyConfig {
this.backup();
const fresh = JSON.parse(JSON.stringify(DEFAULT_TELEPHONY_CONFIG)) as TelephonyConfig;
this.writeFile(fresh);
this.cached = fresh;
this.logger.log('Telephony config reset to defaults');
return fresh;
}
// First-boot bootstrap: if no telephony.json exists yet, seed it from the
// legacy env vars. After this runs once the env vars are dead code.
private ensureReady(): TelephonyConfig {
if (existsSync(CONFIG_PATH)) {
return this.load();
}
const seeded: TelephonyConfig = JSON.parse(
JSON.stringify(DEFAULT_TELEPHONY_CONFIG),
) as TelephonyConfig;
let appliedCount = 0;
for (const seed of TELEPHONY_ENV_SEEDS) {
const value = process.env[seed.env];
if (value === undefined || value === '') continue;
this.setNested(seeded, seed.path, value);
appliedCount += 1;
}
seeded.version = 1;
seeded.updatedAt = new Date().toISOString();
this.writeFile(seeded);
this.cached = seeded;
this.logger.log(
`Telephony config seeded from env (${appliedCount} env var${appliedCount === 1 ? '' : 's'} applied)`,
);
return seeded;
}
private load(): TelephonyConfig {
try {
const raw = readFileSync(CONFIG_PATH, 'utf8');
const parsed = JSON.parse(raw);
const merged: TelephonyConfig = {
ozonetel: { ...DEFAULT_TELEPHONY_CONFIG.ozonetel, ...(parsed.ozonetel ?? {}) },
sip: { ...DEFAULT_TELEPHONY_CONFIG.sip, ...(parsed.sip ?? {}) },
exotel: { ...DEFAULT_TELEPHONY_CONFIG.exotel, ...(parsed.exotel ?? {}) },
version: parsed.version,
updatedAt: parsed.updatedAt,
};
this.cached = merged;
this.logger.log('Telephony config loaded from file');
return merged;
} catch (err) {
this.logger.warn(`Failed to load telephony config, using defaults: ${err}`);
const fresh = JSON.parse(JSON.stringify(DEFAULT_TELEPHONY_CONFIG)) as TelephonyConfig;
this.cached = fresh;
return fresh;
}
}
private setNested(obj: any, path: string[], value: string) {
let cursor = obj;
for (let i = 0; i < path.length - 1; i++) {
if (!cursor[path[i]]) cursor[path[i]] = {};
cursor = cursor[path[i]];
}
cursor[path[path.length - 1]] = value;
}
private writeFile(cfg: TelephonyConfig) {
const dir = dirname(CONFIG_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf8');
}
private backup() {
try {
if (!existsSync(CONFIG_PATH)) return;
if (!existsSync(BACKUP_DIR)) mkdirSync(BACKUP_DIR, { recursive: true });
const ts = new Date().toISOString().replace(/[:.]/g, '-');
copyFileSync(CONFIG_PATH, join(BACKUP_DIR, `telephony-${ts}.json`));
} catch (err) {
this.logger.warn(`Telephony backup failed: ${err}`);
}
}
}

View File

@@ -0,0 +1,86 @@
// Admin-editable telephony config. Holds Ozonetel cloud-call-center settings,
// the Ozonetel SIP gateway info, and the Exotel REST API credentials.
//
// All of these used to live in env vars (OZONETEL_*, SIP_*, EXOTEL_*).
// On first boot, TelephonyConfigService seeds this file from those env vars
// so existing deployments keep working without manual migration. After that,
// admins edit via the staff portal "Telephony" settings page and the env vars
// are no longer read.
//
// SECRETS — note: EXOTEL_WEBHOOK_SECRET stays in env (true secret used for
// inbound webhook HMAC verification). EXOTEL_API_TOKEN is stored here because
// the admin must be able to rotate it from the UI. The GET endpoint masks it.
export type TelephonyConfig = {
ozonetel: {
// Default test agent — used by maintenance and provisioning flows.
agentId: string;
agentPassword: string;
// Default DID (the hospital's published number).
did: string;
// Default SIP extension that maps to a softphone session.
sipId: string;
// Default outbound campaign name on Ozonetel CloudAgent.
campaignName: string;
// Ozonetel portal admin credentials — used by supervisor barge/whisper/listen.
// These are the login credentials for the Ozonetel admin dashboard
// (api.cloudagent.ozonetel.com/auth/login), NOT an agent ID.
adminUsername: string;
adminPassword: string;
};
// Ozonetel WebRTC gateway used by the staff portal softphone.
sip: {
domain: string;
wsPort: string;
};
// Exotel REST API credentials for inbound number management + SMS.
exotel: {
apiKey: string;
apiToken: string;
accountSid: string;
subdomain: string;
};
version?: number;
updatedAt?: string;
};
export const DEFAULT_TELEPHONY_CONFIG: TelephonyConfig = {
ozonetel: {
agentId: '',
agentPassword: '',
did: '',
sipId: '',
campaignName: '',
adminUsername: '',
adminPassword: '',
},
sip: {
domain: 'blr-pub-rtc4.ozonetel.com',
wsPort: '444',
},
exotel: {
apiKey: '',
apiToken: '',
accountSid: '',
subdomain: 'api.exotel.com',
},
};
// Field-by-field mapping from legacy env var names to config paths. Used by
// the first-boot seeder. Keep in sync with the migration target sites.
export const TELEPHONY_ENV_SEEDS: Array<{ env: string; path: string[] }> = [
// OZONETEL_AGENT_ID removed — agentId is per-user on the Agent entity,
// not a sidecar-level config. All endpoints require agentId from caller.
{ env: 'OZONETEL_AGENT_PASSWORD', path: ['ozonetel', 'agentPassword'] },
{ env: 'OZONETEL_ADMIN_USERNAME', path: ['ozonetel', 'adminUsername'] },
{ env: 'OZONETEL_ADMIN_PASSWORD', path: ['ozonetel', 'adminPassword'] },
{ env: 'OZONETEL_DID', path: ['ozonetel', 'did'] },
{ env: 'OZONETEL_SIP_ID', path: ['ozonetel', 'sipId'] },
{ env: 'OZONETEL_CAMPAIGN_NAME', path: ['ozonetel', 'campaignName'] },
{ env: 'SIP_DOMAIN', path: ['sip', 'domain'] },
{ env: 'SIP_WS_PORT', path: ['sip', 'wsPort'] },
{ env: 'EXOTEL_API_KEY', path: ['exotel', 'apiKey'] },
{ env: 'EXOTEL_API_TOKEN', path: ['exotel', 'apiToken'] },
{ env: 'EXOTEL_ACCOUNT_SID', path: ['exotel', 'accountSid'] },
{ env: 'EXOTEL_SUBDOMAIN', path: ['exotel', 'subdomain'] },
];

View File

@@ -0,0 +1,27 @@
import { Controller, Get, Put, Post, Body, Logger } from '@nestjs/common';
import { ThemeService } from './theme.service';
import type { ThemeConfig } from './theme.defaults';
@Controller('api/config')
export class ThemeController {
private readonly logger = new Logger(ThemeController.name);
constructor(private readonly theme: ThemeService) {}
@Get('theme')
getTheme() {
return this.theme.getTheme();
}
@Put('theme')
updateTheme(@Body() body: Partial<ThemeConfig>) {
this.logger.log('Theme update request');
return this.theme.updateTheme(body);
}
@Post('theme/reset')
resetTheme() {
this.logger.log('Theme reset request');
return this.theme.resetTheme();
}
}

View File

@@ -0,0 +1,79 @@
export type ThemeConfig = {
version?: number;
updatedAt?: string;
brand: {
name: string;
hospitalName: string;
logo: string;
favicon: string;
};
colors: {
brand: Record<string, string>;
};
typography: {
body: string;
display: string;
};
login: {
title: string;
subtitle: string;
showGoogleSignIn: boolean;
showForgotPassword: boolean;
poweredBy: { label: string; url: string };
};
sidebar: {
title: string;
subtitle: string;
};
ai: {
quickActions: Array<{ label: string; prompt: string }>;
};
};
export const DEFAULT_THEME: ThemeConfig = {
brand: {
name: 'Helix Engage',
hospitalName: 'Global Hospital',
logo: '/helix-logo.png',
favicon: '/favicon.ico',
},
colors: {
brand: {
'25': 'rgb(239 246 255)',
'50': 'rgb(219 234 254)',
'100': 'rgb(191 219 254)',
'200': 'rgb(147 197 253)',
'300': 'rgb(96 165 250)',
'400': 'rgb(59 130 246)',
'500': 'rgb(37 99 235)',
'600': 'rgb(29 78 216)',
'700': 'rgb(30 64 175)',
'800': 'rgb(30 58 138)',
'900': 'rgb(23 37 84)',
'950': 'rgb(15 23 42)',
},
},
typography: {
body: "'Satoshi', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif",
display: "'General Sans', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif",
},
login: {
title: 'Sign in to Helix Engage',
subtitle: 'Global Hospital',
showGoogleSignIn: true,
showForgotPassword: true,
poweredBy: { label: 'Powered by F0rty2.ai', url: 'https://f0rty2.ai' },
},
sidebar: {
title: 'Helix Engage',
subtitle: 'Global Hospital \u00b7 {role}',
},
ai: {
quickActions: [
{ label: 'Doctor availability', prompt: 'What doctors are available and what are their visiting hours?' },
{ label: 'Clinic timings', prompt: 'What are the clinic locations and timings?' },
{ label: 'Patient history', prompt: "Can you summarize this patient's history?" },
{ label: 'Treatment packages', prompt: 'What treatment packages are available?' },
],
},
};

View File

@@ -0,0 +1,98 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'fs';
import { join, dirname } from 'path';
import { DEFAULT_THEME, type ThemeConfig } from './theme.defaults';
const THEME_PATH = join(process.cwd(), 'data', 'theme.json');
const BACKUP_DIR = join(process.cwd(), 'data', 'theme-backups');
@Injectable()
export class ThemeService implements OnModuleInit {
private readonly logger = new Logger(ThemeService.name);
private cached: ThemeConfig | null = null;
onModuleInit() {
this.load();
}
getTheme(): ThemeConfig {
if (this.cached) return this.cached;
return this.load();
}
updateTheme(updates: Partial<ThemeConfig>): ThemeConfig {
const current = this.getTheme();
const merged: ThemeConfig = {
brand: { ...current.brand, ...updates.brand },
colors: {
brand: { ...current.colors.brand, ...updates.colors?.brand },
},
typography: { ...current.typography, ...updates.typography },
login: { ...current.login, ...updates.login, poweredBy: { ...current.login.poweredBy, ...updates.login?.poweredBy } },
sidebar: { ...current.sidebar, ...updates.sidebar },
ai: {
quickActions: updates.ai?.quickActions ?? current.ai.quickActions,
},
};
merged.version = (current.version ?? 0) + 1;
merged.updatedAt = new Date().toISOString();
this.backup();
const dir = dirname(THEME_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(THEME_PATH, JSON.stringify(merged, null, 2), 'utf8');
this.cached = merged;
this.logger.log(`Theme updated to v${merged.version}`);
return merged;
}
resetTheme(): ThemeConfig {
this.backup();
const dir = dirname(THEME_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(THEME_PATH, JSON.stringify(DEFAULT_THEME, null, 2), 'utf8');
this.cached = DEFAULT_THEME;
this.logger.log('Theme reset to defaults');
return DEFAULT_THEME;
}
private load(): ThemeConfig {
try {
if (existsSync(THEME_PATH)) {
const raw = readFileSync(THEME_PATH, 'utf8');
const parsed = JSON.parse(raw);
this.cached = {
brand: { ...DEFAULT_THEME.brand, ...parsed.brand },
colors: { brand: { ...DEFAULT_THEME.colors.brand, ...parsed.colors?.brand } },
typography: { ...DEFAULT_THEME.typography, ...parsed.typography },
login: { ...DEFAULT_THEME.login, ...parsed.login, poweredBy: { ...DEFAULT_THEME.login.poweredBy, ...parsed.login?.poweredBy } },
sidebar: { ...DEFAULT_THEME.sidebar, ...parsed.sidebar },
ai: { quickActions: parsed.ai?.quickActions ?? DEFAULT_THEME.ai.quickActions },
};
this.logger.log('Theme loaded from file');
return this.cached;
}
} catch (err) {
this.logger.warn(`Failed to load theme: ${err}`);
}
this.cached = DEFAULT_THEME;
this.logger.log('Using default theme');
return DEFAULT_THEME;
}
private backup() {
try {
if (!existsSync(THEME_PATH)) return;
if (!existsSync(BACKUP_DIR)) mkdirSync(BACKUP_DIR, { recursive: true });
const ts = new Date().toISOString().replace(/[:.]/g, '-');
copyFileSync(THEME_PATH, join(BACKUP_DIR, `theme-${ts}.json`));
} catch (err) {
this.logger.warn(`Backup failed: ${err}`);
}
}
}

View File

@@ -0,0 +1,50 @@
import { Controller, Get, Put, Post, Body, Logger } from '@nestjs/common';
import { WidgetConfigService } from './widget-config.service';
import type { WidgetConfig } from './widget.defaults';
// Mounted under /api/config (same prefix as ThemeController).
//
// GET /api/config/widget — public subset, called by the embed
// page to decide whether & how to load
// widget.js
// GET /api/config/widget/admin — full config incl. origins + metadata
// PUT /api/config/widget — admin update (merge patch)
// POST /api/config/widget/rotate-key — rotate the HMAC site key
// POST /api/config/widget/reset — reset to defaults (regenerates key)
//
// TODO: protect the admin endpoints with the admin guard once the settings UI
// ships. Matches the current ThemeController convention (also currently open).
@Controller('api/config')
export class WidgetConfigController {
private readonly logger = new Logger(WidgetConfigController.name);
constructor(private readonly widgetConfig: WidgetConfigService) {}
@Get('widget')
getPublicWidget() {
return this.widgetConfig.getPublicConfig();
}
@Get('widget/admin')
getAdminWidget() {
return this.widgetConfig.getConfig();
}
@Put('widget')
async updateWidget(@Body() body: Partial<WidgetConfig>) {
this.logger.log('Widget config update request');
return this.widgetConfig.updateConfig(body);
}
@Post('widget/rotate-key')
async rotateKey() {
this.logger.log('Widget key rotation request');
return this.widgetConfig.rotateKey();
}
@Post('widget/reset')
async resetWidget() {
this.logger.log('Widget config reset request');
return this.widgetConfig.resetConfig();
}
}

View File

@@ -0,0 +1,202 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'fs';
import { join, dirname } from 'path';
import { DEFAULT_WIDGET_CONFIG, type WidgetConfig } from './widget.defaults';
import { WidgetKeysService } from './widget-keys.service';
import { ThemeService } from './theme.service';
const CONFIG_PATH = join(process.cwd(), 'data', 'widget.json');
const BACKUP_DIR = join(process.cwd(), 'data', 'widget-backups');
// File-backed store for admin-editable widget configuration. Mirrors ThemeService:
// - onModuleInit() → load from disk → ensure key exists (generate + persist)
// - getConfig() → in-memory cached lookup
// - updateConfig() → merge patch + backup + write + bump version
// - rotateKey() → revoke old siteId in Redis + generate new + persist
//
// Also guarantees the key stays valid across Redis flushes: if the file has a
// key but Redis doesn't know about its siteId, we silently re-register it on
// boot so POST /api/widget/* requests keep authenticating.
@Injectable()
export class WidgetConfigService implements OnModuleInit {
private readonly logger = new Logger(WidgetConfigService.name);
private cached: WidgetConfig | null = null;
constructor(
private readonly widgetKeys: WidgetKeysService,
private readonly theme: ThemeService,
) {}
// Hospital name comes from the theme — single source of truth. The widget
// key's Redis label is just bookkeeping; pulling it from theme means
// renaming the hospital via /branding-settings flows through to the next
// key rotation automatically.
private get hospitalName(): string {
return this.theme.getTheme().brand.hospitalName;
}
async onModuleInit() {
await this.ensureReady();
}
getConfig(): WidgetConfig {
if (this.cached) return this.cached;
return this.load();
}
// Public-facing subset served from GET /api/config/widget. Only the fields
// the embed bootstrap code needs — no origins, no hospital label, no
// version metadata.
getPublicConfig() {
const c = this.getConfig();
return {
enabled: c.enabled,
key: c.key,
url: c.url,
embed: c.embed,
};
}
async updateConfig(updates: Partial<WidgetConfig>): Promise<WidgetConfig> {
const current = this.getConfig();
const merged: WidgetConfig = {
...current,
...updates,
embed: { ...current.embed, ...updates.embed },
allowedOrigins: updates.allowedOrigins ?? current.allowedOrigins,
version: (current.version ?? 0) + 1,
updatedAt: new Date().toISOString(),
};
this.backup();
this.writeFile(merged);
this.cached = merged;
this.logger.log(`Widget config updated to v${merged.version}`);
return merged;
}
// Revoke the current siteId in Redis, mint a new key with the current
// theme.brand.hospitalName + allowedOrigins, persist both the Redis entry
// and the config file. Used by admins to invalidate a leaked or stale key.
async rotateKey(): Promise<WidgetConfig> {
const current = this.getConfig();
if (current.siteId) {
await this.widgetKeys.revokeKey(current.siteId).catch(err => {
this.logger.warn(`Revoke of old siteId ${current.siteId} failed: ${err}`);
});
}
const { key, siteKey } = this.widgetKeys.generateKey(
this.hospitalName,
current.allowedOrigins,
);
await this.widgetKeys.saveKey(siteKey);
const updated: WidgetConfig = {
...current,
key,
siteId: siteKey.siteId,
version: (current.version ?? 0) + 1,
updatedAt: new Date().toISOString(),
};
this.backup();
this.writeFile(updated);
this.cached = updated;
this.logger.log(`Widget key rotated: new siteId=${siteKey.siteId}`);
return updated;
}
async resetConfig(): Promise<WidgetConfig> {
this.backup();
this.writeFile(DEFAULT_WIDGET_CONFIG);
this.cached = { ...DEFAULT_WIDGET_CONFIG };
this.logger.log('Widget config reset to defaults');
return this.ensureReady();
}
private async ensureReady(): Promise<WidgetConfig> {
let cfg = this.load();
// First boot or missing key → generate + persist.
const needsKey = !cfg.key || !cfg.siteId;
if (needsKey) {
this.logger.log('No widget key in config — generating a fresh one');
const { key, siteKey } = this.widgetKeys.generateKey(
this.hospitalName,
cfg.allowedOrigins,
);
await this.widgetKeys.saveKey(siteKey);
cfg = {
...cfg,
key,
siteId: siteKey.siteId,
// Allow WIDGET_PUBLIC_URL env var to seed the url field on
// first boot, so dev/staging don't start with a blank URL.
url: cfg.url || process.env.WIDGET_PUBLIC_URL || '',
version: (cfg.version ?? 0) + 1,
updatedAt: new Date().toISOString(),
};
this.writeFile(cfg);
this.cached = cfg;
this.logger.log(`Widget key generated: siteId=${siteKey.siteId}`);
return cfg;
}
// Key exists on disk but may be missing from Redis (e.g., Redis
// flushed or sidecar migrated to new Redis). Re-register so requests
// validate correctly. This is silent — callers don't care.
const validated = await this.widgetKeys.validateKey(cfg.key).catch(() => null);
if (!validated) {
this.logger.warn(
`Widget key in config not found in Redis — re-registering siteId=${cfg.siteId}`,
);
await this.widgetKeys.saveKey({
siteId: cfg.siteId,
hospitalName: this.hospitalName,
allowedOrigins: cfg.allowedOrigins,
active: true,
createdAt: cfg.updatedAt ?? new Date().toISOString(),
});
}
return cfg;
}
private load(): WidgetConfig {
try {
if (existsSync(CONFIG_PATH)) {
const raw = readFileSync(CONFIG_PATH, 'utf8');
const parsed = JSON.parse(raw);
const merged: WidgetConfig = {
...DEFAULT_WIDGET_CONFIG,
...parsed,
embed: { ...DEFAULT_WIDGET_CONFIG.embed, ...parsed.embed },
allowedOrigins: parsed.allowedOrigins ?? DEFAULT_WIDGET_CONFIG.allowedOrigins,
};
this.cached = merged;
this.logger.log('Widget config loaded from file');
return merged;
}
} catch (err) {
this.logger.warn(`Failed to load widget config: ${err}`);
}
const fallback: WidgetConfig = { ...DEFAULT_WIDGET_CONFIG };
this.cached = fallback;
this.logger.log('Using default widget config');
return fallback;
}
private writeFile(cfg: WidgetConfig) {
const dir = dirname(CONFIG_PATH);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2), 'utf8');
}
private backup() {
try {
if (!existsSync(CONFIG_PATH)) return;
if (!existsSync(BACKUP_DIR)) mkdirSync(BACKUP_DIR, { recursive: true });
const ts = new Date().toISOString().replace(/[:.]/g, '-');
copyFileSync(CONFIG_PATH, join(BACKUP_DIR, `widget-${ts}.json`));
} catch (err) {
this.logger.warn(`Widget config backup failed: ${err}`);
}
}
}

View File

@@ -0,0 +1,94 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { createHmac, timingSafeEqual, randomUUID } from 'crypto';
import { SessionService } from '../auth/session.service';
import type { WidgetSiteKey } from '../widget/widget.types';
const KEY_PREFIX = 'widget:keys:';
@Injectable()
export class WidgetKeysService {
private readonly logger = new Logger(WidgetKeysService.name);
private readonly secret: string;
constructor(
private config: ConfigService,
private session: SessionService,
) {
this.secret = process.env.WIDGET_SECRET ?? config.get<string>('WIDGET_SECRET') ?? 'helix-widget-default-secret';
}
generateKey(hospitalName: string, allowedOrigins: string[]): { key: string; siteKey: WidgetSiteKey } {
const siteId = randomUUID().replace(/-/g, '').substring(0, 16);
const signature = this.sign(siteId);
const key = `${siteId}.${signature}`;
const siteKey: WidgetSiteKey = {
siteId,
hospitalName,
allowedOrigins,
active: true,
createdAt: new Date().toISOString(),
};
return { key, siteKey };
}
async saveKey(siteKey: WidgetSiteKey): Promise<void> {
await this.session.setCachePersistent(`${KEY_PREFIX}${siteKey.siteId}`, JSON.stringify(siteKey));
this.logger.log(`Widget key saved: ${siteKey.siteId} (${siteKey.hospitalName})`);
}
async validateKey(rawKey: string): Promise<WidgetSiteKey | null> {
const dotIndex = rawKey.indexOf('.');
if (dotIndex === -1) return null;
const siteId = rawKey.substring(0, dotIndex);
const signature = rawKey.substring(dotIndex + 1);
const expected = this.sign(siteId);
try {
if (!timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'))) return null;
} catch {
return null;
}
const data = await this.session.getCache(`${KEY_PREFIX}${siteId}`);
if (!data) return null;
const siteKey: WidgetSiteKey = JSON.parse(data);
if (!siteKey.active) return null;
return siteKey;
}
validateOrigin(siteKey: WidgetSiteKey, origin: string | undefined): boolean {
if (!origin) return true; // Allow no-origin for dev/testing
if (siteKey.allowedOrigins.length === 0) return true;
return siteKey.allowedOrigins.some(allowed => origin.startsWith(allowed));
}
async listKeys(): Promise<WidgetSiteKey[]> {
const keys = await this.session.scanKeys(`${KEY_PREFIX}*`);
const results: WidgetSiteKey[] = [];
for (const key of keys) {
const data = await this.session.getCache(key);
if (data) results.push(JSON.parse(data));
}
return results;
}
async revokeKey(siteId: string): Promise<boolean> {
const data = await this.session.getCache(`${KEY_PREFIX}${siteId}`);
if (!data) return false;
const siteKey: WidgetSiteKey = JSON.parse(data);
siteKey.active = false;
await this.session.setCachePersistent(`${KEY_PREFIX}${siteId}`, JSON.stringify(siteKey));
this.logger.log(`Widget key revoked: ${siteId}`);
return true;
}
private sign(data: string): string {
return createHmac('sha256', this.secret).update(data).digest('hex');
}
}

View File

@@ -0,0 +1,46 @@
// Shape of the website-widget configuration, stored in data/widget.json.
// Mirrors the theme config pattern — file-backed, versioned, admin-editable.
export type WidgetConfig = {
// Master feature flag. When false, the widget does not render anywhere.
enabled: boolean;
// HMAC-signed site key the embed script passes as data-key. Auto-generated
// on first boot if empty. Rotate via POST /api/config/widget/rotate-key.
key: string;
// Stable site identifier derived from the key. Used for Redis lookup and
// revocation. Populated alongside `key`.
siteId: string;
// Public base URL where widget.js is hosted. Typically the sidecar host.
// If empty, the embed page falls back to its own VITE_API_URL at fetch time.
url: string;
// Origin allowlist. Empty array means any origin is accepted (test mode).
// Set tight values in production: ['https://hospital.com'].
allowedOrigins: string[];
// Embed toggles — where the widget should render. Kept as an object so we
// can add other surfaces (public landing page, portal, etc.) without a
// breaking schema change.
embed: {
// Show on the staff login page. Useful for testing without a public
// landing page; turn off in production.
loginPage: boolean;
};
// Bookkeeping — incremented on every update, like the theme config.
version?: number;
updatedAt?: string;
};
export const DEFAULT_WIDGET_CONFIG: WidgetConfig = {
enabled: true,
key: '',
siteId: '',
url: '',
allowedOrigins: [],
embed: {
loginPage: true,
},
};

View File

@@ -0,0 +1,125 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { generateObject } from 'ai';
import { z } from 'zod';
import { EventBusService } from '../event-bus.service';
import { Topics } from '../event-types';
import type { CallCompletedEvent } from '../event-types';
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
import { createAiModel } from '../../ai/ai-provider';
import type { LanguageModel } from 'ai';
import { AiConfigService } from '../../config/ai-config.service';
@Injectable()
export class AiInsightConsumer implements OnModuleInit {
private readonly logger = new Logger(AiInsightConsumer.name);
private readonly aiModel: LanguageModel | null;
constructor(
private eventBus: EventBusService,
private platform: PlatformGraphqlService,
private config: ConfigService,
private aiConfig: AiConfigService,
) {
const cfg = aiConfig.getConfig();
this.aiModel = createAiModel({
provider: cfg.provider,
model: cfg.model,
anthropicApiKey: config.get<string>('ai.anthropicApiKey'),
openaiApiKey: config.get<string>('ai.openaiApiKey'),
});
}
onModuleInit() {
this.eventBus.on(Topics.CALL_COMPLETED, (event: CallCompletedEvent) => this.handleCallCompleted(event));
}
private async handleCallCompleted(event: CallCompletedEvent): Promise<void> {
if (!event.leadId) {
this.logger.debug('[AI-INSIGHT] No leadId — skipping');
return;
}
if (!this.aiModel) {
this.logger.debug('[AI-INSIGHT] No AI model configured — skipping');
return;
}
this.logger.log(`[AI-INSIGHT] Generating insight for lead ${event.leadId}`);
try {
// Fetch lead + all activities
const data = await this.platform.query<any>(
`{ leads(filter: { id: { eq: "${event.leadId}" } }) { edges { node {
id name contactName { firstName lastName }
status source interestedService
contactAttempts lastContacted
} } } }`,
);
const lead = data?.leads?.edges?.[0]?.node;
if (!lead) return;
const activityData = await this.platform.query<any>(
`{ leadActivities(first: 20, filter: { leadId: { eq: "${event.leadId}" } }, orderBy: [{ occurredAt: DescNullsLast }]) {
edges { node { activityType summary occurredAt channel durationSec outcome } }
} }`,
);
const activities = activityData?.leadActivities?.edges?.map((e: any) => e.node) ?? [];
const leadName = lead.contactName
? `${lead.contactName.firstName ?? ''} ${lead.contactName.lastName ?? ''}`.trim()
: lead.name ?? 'Unknown';
// Build context
const activitySummary = activities.map((a: any) =>
`${a.activityType}: ${a.summary} (${a.occurredAt ?? 'unknown date'})`,
).join('\n');
// Generate insight
const { object } = await generateObject({
model: this.aiModel,
schema: z.object({
summary: z.string().describe('2-3 sentence summary of this lead based on all their interactions'),
suggestedAction: z.string().describe('One clear next action for the agent'),
}),
system: this.aiConfig.renderPrompt('callInsight', {
hospitalName: process.env.HOSPITAL_NAME ?? 'the hospital',
}),
prompt: `Lead: ${leadName}
Status: ${lead.status ?? 'Unknown'}
Source: ${lead.source ?? 'Unknown'}
Interested in: ${lead.interestedService ?? 'Not specified'}
Contact attempts: ${lead.contactAttempts ?? 0}
Last contacted: ${lead.lastContacted ?? 'Never'}
Recent activity (newest first):
${activitySummary || 'No activity recorded'}
Latest call:
- Direction: ${event.direction}
- Duration: ${event.durationSec}s
- Disposition: ${event.disposition}
- Notes: ${event.notes ?? 'None'}`,
maxOutputTokens: 200,
});
// Update lead with new AI insight
await this.platform.query<any>(
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
{
id: event.leadId,
data: {
aiSummary: object.summary,
aiSuggestedAction: object.suggestedAction,
lastContacted: new Date().toISOString(),
contactAttempts: (lead.contactAttempts ?? 0) + 1,
},
},
);
this.logger.log(`[AI-INSIGHT] Updated lead ${event.leadId}: "${object.summary.substring(0, 60)}..."`);
} catch (err: any) {
this.logger.error(`[AI-INSIGHT] Failed for lead ${event.leadId}: ${err.message}`);
}
}
}

View File

@@ -0,0 +1,114 @@
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { Kafka, Producer, Consumer, EachMessagePayload } from 'kafkajs';
import type { EventPayload } from './event-types';
type EventHandler = (payload: any) => Promise<void>;
@Injectable()
export class EventBusService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(EventBusService.name);
private kafka: Kafka;
private producer: Producer;
private consumer: Consumer;
private handlers = new Map<string, EventHandler[]>();
private connected = false;
constructor() {
const brokers = (process.env.KAFKA_BROKERS ?? 'localhost:9092').split(',');
this.kafka = new Kafka({
clientId: 'helix-engage-sidecar',
brokers,
retry: { retries: 5, initialRetryTime: 1000 },
logLevel: 1, // ERROR only
});
this.producer = this.kafka.producer();
this.consumer = this.kafka.consumer({ groupId: 'helix-engage-workers' });
}
async onModuleInit() {
try {
await this.producer.connect();
await this.consumer.connect();
this.connected = true;
this.logger.log('Event bus connected (Kafka/Redpanda)');
// Subscribe to all topics we have handlers for
// Handlers are registered by consumer modules during their onModuleInit
// We start consuming after a short delay to let all handlers register
setTimeout(() => this.startConsuming(), 2000);
} catch (err: any) {
this.logger.warn(`Event bus not available (${err.message}) — running without events`);
this.connected = false;
}
}
async onModuleDestroy() {
if (this.connected) {
await this.consumer.disconnect().catch(() => {});
await this.producer.disconnect().catch(() => {});
}
}
async emit(topic: string, payload: EventPayload): Promise<void> {
if (!this.connected) {
this.logger.debug(`[EVENT] Skipped (not connected): ${topic}`);
return;
}
try {
await this.producer.send({
topic,
messages: [{ value: JSON.stringify(payload), timestamp: Date.now().toString() }],
});
this.logger.log(`[EVENT] Emitted: ${topic}`);
} catch (err: any) {
this.logger.error(`[EVENT] Failed to emit ${topic}: ${err.message}`);
}
}
on(topic: string, handler: EventHandler): void {
const existing = this.handlers.get(topic) ?? [];
existing.push(handler);
this.handlers.set(topic, existing);
this.logger.log(`[EVENT] Handler registered for: ${topic}`);
}
private async startConsuming(): Promise<void> {
if (!this.connected) return;
const topics = Array.from(this.handlers.keys());
if (topics.length === 0) {
this.logger.log('[EVENT] No handlers registered — skipping consumer');
return;
}
try {
for (const topic of topics) {
await this.consumer.subscribe({ topic, fromBeginning: false });
}
await this.consumer.run({
eachMessage: async (payload: EachMessagePayload) => {
const { topic, message } = payload;
const handlers = this.handlers.get(topic) ?? [];
if (handlers.length === 0 || !message.value) return;
try {
const data = JSON.parse(message.value.toString());
for (const handler of handlers) {
await handler(data).catch(err =>
this.logger.error(`[EVENT] Handler error on ${topic}: ${err.message}`),
);
}
} catch (err: any) {
this.logger.error(`[EVENT] Parse error on ${topic}: ${err.message}`);
}
},
});
this.logger.log(`[EVENT] Consuming: ${topics.join(', ')}`);
} catch (err: any) {
this.logger.error(`[EVENT] Consumer failed: ${err.message}`);
}
}
}

36
src/events/event-types.ts Normal file
View File

@@ -0,0 +1,36 @@
// Event topic names
export const Topics = {
CALL_COMPLETED: 'call.completed',
CALL_MISSED: 'call.missed',
AGENT_STATE: 'agent.state',
} as const;
// Event payloads
export type CallCompletedEvent = {
callId: string | null;
ucid: string;
agentId: string;
callerPhone: string;
direction: string;
durationSec: number;
disposition: string;
leadId: string | null;
notes: string | null;
timestamp: string;
};
export type CallMissedEvent = {
callId: string | null;
callerPhone: string;
leadId: string | null;
leadName: string | null;
timestamp: string;
};
export type AgentStateEvent = {
agentId: string;
state: string;
timestamp: string;
};
export type EventPayload = CallCompletedEvent | CallMissedEvent | AgentStateEvent;

View File

@@ -0,0 +1,12 @@
import { Module, Global } from '@nestjs/common';
import { PlatformModule } from '../platform/platform.module';
import { EventBusService } from './event-bus.service';
import { AiInsightConsumer } from './consumers/ai-insight.consumer';
@Global()
@Module({
imports: [PlatformModule],
providers: [EventBusService, AiInsightConsumer],
exports: [EventBusService],
})
export class EventsModule {}

View File

@@ -0,0 +1,182 @@
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { SupervisorService } from '../supervisor/supervisor.service';
const TICK_INTERVAL_MS = 60 * 1000; // 60s
const KICKOFF_DELAY_MS = 45_000; // let sidecar boot settle
const MAX_LEADS_PER_TICK = 100; // guard against runaway batches
const ACTIVE_STATES = new Set(['ready', 'calling', 'in-call', 'acw']);
// Excluded: 'offline' (agent logged out), 'break' / 'training' (explicitly away).
// ACW is included — the agent is still handling work and will return to Ready soon.
/**
* Polls for unassigned leads every 60s and assigns them least-loaded across
* active agents.
*
* Why polling instead of platform functions or Redpanda events:
* - The platform's lead.created hook isn't wired to the sidecar (no bridge)
* - The SDK's lead-auto-assign.function.ts is written but hasn't been
* deployed/published to either workspace
* - Polling catches EVERY lead creation path (CSV import, enquiry form,
* missed-call webhook, widget, livekit) with no per-path instrumentation
*
* Assignment strategy:
* - Count each active agent's OPEN leads (status in NEW/CONTACTED/QUALIFIED)
* - Pick the agent with the lowest count — ties broken by platform ordering
* - Write agent.name (display name) to lead.assignedAgent (worklist filter matches on this)
*
* Edge cases:
* - No active agents → skip tick; next run retries
* - agentName empty → skip agent
* - Mutation errors → log, continue with next lead
*/
@Injectable()
export class LeadAutoAssignService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(LeadAutoAssignService.name);
private timer: NodeJS.Timeout | null = null;
private running = false;
constructor(
private readonly platform: PlatformGraphqlService,
private readonly supervisor: SupervisorService,
) {}
onModuleInit() {
setTimeout(() => {
this.runOnce().catch((err) => this.logger.warn(`[AUTO-ASSIGN] Kickoff failed: ${err?.message ?? err}`));
}, KICKOFF_DELAY_MS);
this.timer = setInterval(() => {
this.runOnce().catch((err) => this.logger.warn(`[AUTO-ASSIGN] Tick failed: ${err?.message ?? err}`));
}, TICK_INTERVAL_MS);
}
onModuleDestroy() {
if (this.timer) clearInterval(this.timer);
}
async runOnce(): Promise<{ assigned: number; skipped: number; noAgents: boolean }> {
// Guard against concurrent runs (prev tick hasn't finished).
if (this.running) return { assigned: 0, skipped: 0, noAgents: false };
this.running = true;
try {
const unassigned = await this.fetchUnassignedLeads();
if (unassigned.length === 0) return { assigned: 0, skipped: 0, noAgents: false };
const active = await this.fetchActiveAgents();
if (active.length === 0) {
this.logger.debug(`[AUTO-ASSIGN] ${unassigned.length} leads waiting — no active agents`);
return { assigned: 0, skipped: unassigned.length, noAgents: true };
}
// Seed current-load map: lead count per agent across their OPEN leads.
// Fetch once per tick (not per lead) — the map is updated locally as we assign.
const loadByAgent = await this.fetchOpenLeadCounts(active.map((a) => a.name));
let assigned = 0;
let skipped = 0;
for (const lead of unassigned) {
// Pick the least-loaded active agent.
const target = [...active].sort(
(a, b) => (loadByAgent.get(a.name) ?? 0) - (loadByAgent.get(b.name) ?? 0),
)[0];
if (!target?.name) { skipped++; continue; }
try {
await this.platform.query<any>(
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
{ id: lead.id, data: { assignedAgent: target.name } },
);
assigned++;
loadByAgent.set(target.name, (loadByAgent.get(target.name) ?? 0) + 1);
await new Promise((r) => setTimeout(r, 40)); // gentle pacing
} catch (err: any) {
this.logger.warn(`[AUTO-ASSIGN] updateLead failed for ${lead.id}: ${err?.message ?? err}`);
skipped++;
}
}
if (assigned > 0 || skipped > 0) {
const loadSummary = active.map((a) => `${a.name}=${loadByAgent.get(a.name) ?? 0}`).join(', ');
this.logger.log(`[AUTO-ASSIGN] Pass complete — assigned=${assigned} skipped=${skipped} load=[${loadSummary}]`);
}
return { assigned, skipped, noAgents: false };
} finally {
this.running = false;
}
}
private async fetchUnassignedLeads(): Promise<Array<{ id: string; campaignId: string | null }>> {
try {
const data: any = await this.platform.query<any>(
`{ leads(first: ${MAX_LEADS_PER_TICK}, filter: {
or: [
{ assignedAgent: { eq: "" } },
{ assignedAgent: { is: NULL } }
]
}, orderBy: [{ createdAt: AscNullsLast }]) {
edges { node { id campaignId } }
} }`,
);
return (data?.leads?.edges ?? []).map((e: any) => e.node);
} catch (err: any) {
this.logger.warn(`[AUTO-ASSIGN] fetch unassigned failed: ${err?.message ?? err}`);
return [];
}
}
private async fetchActiveAgents(): Promise<Array<{ id: string; name: string; ozonetelAgentId: string }>> {
try {
const data: any = await this.platform.query<any>(
`{ agents(first: 100) { edges { node { id name ozonetelAgentId } } } }`,
);
const all: Array<{ id: string; name: string; ozonetelAgentId: string }> =
(data?.agents?.edges ?? []).map((e: any) => e.node);
// Filter to agents whose in-memory state (from Ozonetel webhooks) is active.
// If state is unknown (never seen a state event), treat as offline.
return all.filter((a) => {
if (!a.name || !a.ozonetelAgentId) return false;
const entry = this.supervisor.getAgentState(a.ozonetelAgentId);
return entry ? ACTIVE_STATES.has(entry.state) : false;
});
} catch (err: any) {
this.logger.warn(`[AUTO-ASSIGN] fetch agents failed: ${err?.message ?? err}`);
return [];
}
}
private async fetchOpenLeadCounts(agentNames: string[]): Promise<Map<string, number>> {
const map = new Map<string, number>();
for (const name of agentNames) map.set(name, 0);
if (agentNames.length === 0) return map;
// Single aggregated query — pull ALL open leads with assignedAgent set,
// count by agent locally. Avoids N+1 over agents.
try {
let after: string | null = null;
for (let page = 0; page < 20; page++) {
const cursor: string = after ? `, after: "${after}"` : '';
const data: any = await this.platform.query<any>(
`{ leads(first: 200${cursor}, filter: {
status: { in: [NEW, CONTACTED, QUALIFIED] }
}) {
edges { node { assignedAgent } }
pageInfo { hasNextPage endCursor }
} }`,
);
const edges = data?.leads?.edges ?? [];
for (const e of edges) {
const name = e.node.assignedAgent;
if (name && map.has(name)) map.set(name, (map.get(name) ?? 0) + 1);
}
const info: { hasNextPage?: boolean; endCursor?: string } = data?.leads?.pageInfo ?? {};
if (!info.hasNextPage) break;
after = info.endCursor ?? null;
}
} catch (err: any) {
this.logger.warn(`[AUTO-ASSIGN] fetch open-lead counts failed: ${err?.message ?? err}`);
}
return map;
}
}

11
src/leads/leads.module.ts Normal file
View File

@@ -0,0 +1,11 @@
import { Module, forwardRef } from '@nestjs/common';
import { PlatformModule } from '../platform/platform.module';
import { SupervisorModule } from '../supervisor/supervisor.module';
import { LeadAutoAssignService } from './lead-auto-assign.service';
@Module({
imports: [PlatformModule, forwardRef(() => SupervisorModule)],
providers: [LeadAutoAssignService],
exports: [LeadAutoAssignService],
})
export class LeadsModule {}

358
src/livekit-agent/agent.ts Normal file
View File

@@ -0,0 +1,358 @@
import { WorkerOptions, defineAgent, llm, voice, VAD } from '@livekit/agents';
import * as google from '@livekit/agents-plugin-google';
import * as silero from '@livekit/agents-plugin-silero';
import { z } from 'zod';
// Platform GraphQL helper
const SIDECAR_URL = process.env.SIDECAR_URL ?? 'http://localhost:4100';
const PLATFORM_API_KEY = process.env.PLATFORM_API_KEY ?? '';
async function gql<T = any>(query: string, variables?: Record<string, unknown>): Promise<T | null> {
if (!PLATFORM_API_KEY) return null;
try {
const res = await fetch(`${SIDECAR_URL}/graphql`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${PLATFORM_API_KEY}` },
body: JSON.stringify({ query, variables }),
});
const data = await res.json();
if (data.errors) {
console.error('[AGENT-GQL] Error:', data.errors[0]?.message);
return null;
}
return data.data;
} catch (err) {
console.error('[AGENT-GQL] Failed:', err);
return null;
}
}
// Resolve a phone to a {leadId, patientId} pair via the sidecar's
// caller-resolution endpoint. Always returns populated IDs (creates
// placeholder lead+patient when none exist).
async function resolveCaller(phone: string): Promise<{ leadId: string; patientId: string; firstName: string; lastName: string; isNew: boolean } | null> {
try {
const res = await fetch(`${SIDECAR_URL}/api/caller/resolve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phone }),
});
if (!res.ok) {
console.error('[AGENT-RESOLVE] Failed:', res.status, await res.text().catch(() => ''));
return null;
}
return await res.json();
} catch (err) {
console.error('[AGENT-RESOLVE] Failed:', err);
return null;
}
}
// Hospital context — loaded on startup
let hospitalContext = {
doctors: [] as Array<{ name: string; department: string; specialty: string; id: string }>,
departments: [] as string[],
};
async function loadHospitalContext() {
const data = await gql(`{ doctors(first: 20) { edges { node { id fullName { firstName lastName } department specialty } } } }`);
if (data?.doctors?.edges) {
hospitalContext.doctors = data.doctors.edges.map((e: any) => ({
id: e.node.id,
name: `Dr. ${e.node.fullName?.firstName ?? ''} ${e.node.fullName?.lastName ?? ''}`.trim(),
department: e.node.department ?? '',
specialty: e.node.specialty ?? '',
}));
hospitalContext.departments = [...new Set(hospitalContext.doctors.map(d => d.department))] as string[];
console.log(`[LIVEKIT-AGENT] Loaded ${hospitalContext.doctors.length} doctors, ${hospitalContext.departments.length} departments`);
} else {
// Fallback
hospitalContext.doctors = [
{ id: '', name: 'Dr. Arun Sharma', department: 'Cardiology', specialty: 'Interventional Cardiology' },
{ id: '', name: 'Dr. Rajesh Kumar', department: 'Orthopedics', specialty: 'Joint Replacement' },
{ id: '', name: 'Dr. Meena Patel', department: 'Gynecology', specialty: 'Reproductive Medicine' },
{ id: '', name: 'Dr. Lakshmi Reddy', department: 'General Medicine', specialty: 'Internal Medicine' },
{ id: '', name: 'Dr. Harpreet Singh', department: 'ENT', specialty: 'Head & Neck Surgery' },
];
hospitalContext.departments = ['Cardiology', 'Orthopedics', 'Gynecology', 'General Medicine', 'ENT'];
console.log('[LIVEKIT-AGENT] Using fallback doctor list');
}
}
// ─── Tools ────────────────────────────────────────────────────────────
const lookupDoctor = llm.tool({
description: 'Look up available doctors by department or specialty. Call this when the patient asks about a specific department or type of doctor.',
parameters: z.object({
department: z.string().nullable().describe('Department name like Cardiology, Orthopedics, ENT'),
specialty: z.string().nullable().describe('Specialty or condition like joint pain, heart, ear'),
}),
execute: async ({ department, specialty }) => {
let results = hospitalContext.doctors;
if (department) {
results = results.filter(d => d.department.toLowerCase().includes(department.toLowerCase()));
}
if (specialty) {
results = results.filter(d =>
d.specialty.toLowerCase().includes(specialty.toLowerCase()) ||
d.department.toLowerCase().includes(specialty.toLowerCase()),
);
}
if (results.length === 0) return 'No matching doctors found. Available departments: ' + hospitalContext.departments.join(', ');
return results.map(d => `${d.name}${d.department} (${d.specialty})`).join('\n');
},
});
const bookAppointment = llm.tool({
description: 'Book an appointment for the caller. You MUST collect patient name, phone number, department, preferred date/time, and reason before calling this.',
parameters: z.object({
patientName: z.string().describe('Full name of the patient'),
phoneNumber: z.string().describe('Patient phone number with country code'),
department: z.string().describe('Department for the appointment'),
doctorName: z.string().nullable().describe('Preferred doctor name if specified'),
preferredDate: z.string().describe('Date in YYYY-MM-DD format or natural language'),
preferredTime: z.string().describe('Time slot like 10:00 AM, morning, afternoon'),
reason: z.string().describe('Reason for visit'),
}),
execute: async ({ patientName, phoneNumber, department, doctorName, preferredDate, preferredTime, reason }) => {
console.log(`[LIVEKIT-AGENT] Booking: ${patientName} | ${phoneNumber} | ${department} | ${doctorName ?? 'any'} | ${preferredDate} ${preferredTime}`);
// Parse date — try ISO format first, fallback to tomorrow
let scheduledAt: string;
try {
const parsed = new Date(preferredDate);
if (!isNaN(parsed.getTime())) {
// Map time to hour
const timeMap: Record<string, string> = { morning: '10:00', afternoon: '14:00', evening: '17:00' };
const timeStr = timeMap[preferredTime.toLowerCase()] ?? preferredTime.replace(/\s*(AM|PM)/i, (_, p) => '');
scheduledAt = new Date(`${parsed.toISOString().split('T')[0]}T${timeStr}:00`).toISOString();
} else {
scheduledAt = new Date(Date.now() + 86400000).toISOString(); // tomorrow
}
} catch {
scheduledAt = new Date(Date.now() + 86400000).toISOString();
}
// Find matching doctor
const doctor = doctorName
? hospitalContext.doctors.find(d => d.name.toLowerCase().includes(doctorName.toLowerCase()))
: hospitalContext.doctors.find(d => d.department.toLowerCase().includes(department.toLowerCase()));
// Create appointment on platform
const result = await gql(
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
{
data: {
name: `AI Booking — ${patientName} (${department})`,
scheduledAt,
status: 'SCHEDULED',
doctorName: doctor?.name ?? doctorName ?? 'To be assigned',
department,
reasonForVisit: reason,
...((doctor as any)?.clinicId ? { clinicId: (doctor as any).clinicId } : {}),
},
},
);
// Resolve caller — if isNew, create Lead + Patient with the
// AI-collected name; otherwise update the existing record.
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
const resolved = await resolveCaller(cleanPhone);
const fn = patientName.split(' ')[0];
const ln = patientName.split(' ').slice(1).join(' ') || '';
if (resolved?.isNew) {
const p = await gql(
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
{ data: { fullName: { firstName: fn, lastName: ln }, phones: { primaryPhoneNumber: `+91${cleanPhone}` }, patientType: 'NEW' } },
);
const newPatientId = p?.createPatient?.id;
await gql(
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
{
data: {
name: `AI — ${patientName}`,
contactName: { firstName: fn, lastName: ln },
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
source: 'PHONE',
status: 'APPOINTMENT_SET',
interestedService: department,
...(newPatientId ? { patientId: newPatientId } : {}),
},
},
);
} else if (resolved?.leadId) {
await gql(
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
{
id: resolved.leadId,
data: {
name: `AI — ${patientName}`,
contactName: { firstName: fn, lastName: ln },
source: 'PHONE',
status: 'APPOINTMENT_SET',
interestedService: department,
},
},
);
if (resolved.patientId) {
await gql(
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
{ id: resolved.patientId, data: { fullName: { firstName: fn, lastName: ln } } },
);
}
}
const refNum = `GH-${Date.now().toString().slice(-6)}`;
if (result?.createAppointment?.id) {
console.log(`[LIVEKIT-AGENT] Appointment created: ${result.createAppointment.id}`);
return `Appointment booked successfully! Reference number ${refNum}. ${patientName} is scheduled for ${department} on ${preferredDate} at ${preferredTime} with ${doctor?.name ?? 'an available doctor'}. A confirmation SMS will be sent to ${phoneNumber}.`;
}
return `I have noted the appointment request. Reference number ${refNum}. Our team will confirm the booking and send an SMS to ${phoneNumber}.`;
},
});
const collectLeadInfo = llm.tool({
description: 'Save the caller as a lead/enquiry when they are interested but not ready to book. Collect their name and phone number.',
parameters: z.object({
name: z.string().describe('Caller name'),
phoneNumber: z.string().describe('Caller phone number'),
interest: z.string().describe('What they are interested in or enquiring about'),
}),
execute: async ({ name, phoneNumber, interest }) => {
console.log(`[LIVEKIT-AGENT] Lead: ${name} | ${phoneNumber} | ${interest}`);
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
const resolved = await resolveCaller(cleanPhone);
const fn = name.split(' ')[0];
const ln = name.split(' ').slice(1).join(' ') || '';
if (resolved?.isNew) {
// Net-new caller — create Patient + Lead with the AI-collected name.
const p = await gql(
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
{ data: { fullName: { firstName: fn, lastName: ln }, phones: { primaryPhoneNumber: `+91${cleanPhone}` }, patientType: 'NEW' } },
);
const newPatientId = p?.createPatient?.id;
const created = await gql(
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
{
data: {
name: `AI Enquiry — ${name}`,
contactName: { firstName: fn, lastName: ln },
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
source: 'PHONE',
status: 'NEW',
interestedService: interest,
...(newPatientId ? { patientId: newPatientId } : {}),
},
},
);
console.log(`[LIVEKIT-AGENT] Lead created: ${created?.createLead?.id ?? 'none'} (patient ${newPatientId ?? 'none'})`);
} else if (resolved?.leadId) {
await gql(
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
{
id: resolved.leadId,
data: {
name: `AI Enquiry — ${name}`,
contactName: { firstName: fn, lastName: ln },
source: 'PHONE',
status: 'NEW',
interestedService: interest,
},
},
);
if (resolved.patientId) {
await gql(
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
{ id: resolved.patientId, data: { fullName: { firstName: fn, lastName: ln } } },
);
}
console.log(`[LIVEKIT-AGENT] Lead updated: ${resolved.leadId} (patient ${resolved.patientId})`);
}
return `Thank you ${name}. I have noted your enquiry about ${interest}. One of our team members will call you back on ${phoneNumber} shortly.`;
},
});
const transferToAgent = llm.tool({
description: 'Transfer the call to a human agent. Use this when the caller explicitly asks to speak with a person, or when the query is too complex.',
parameters: z.object({
reason: z.string().describe('Why the caller needs a human agent'),
}),
execute: async ({ reason }) => {
console.log(`[LIVEKIT-AGENT] Transfer requested: ${reason}`);
// TODO: When SIP is connected, trigger Ozonetel transfer via sidecar API
return 'I am transferring you to one of our agents now. Please hold for a moment. If no agent is available, someone will call you back within 15 minutes.';
},
});
// ─── Agent ────────────────────────────────────────────────────────────
const hospitalAgent = new voice.Agent({
instructions: `You are the AI receptionist for Global Hospital, Bangalore. Your name is Helix.
PERSONALITY:
- Warm, professional, and empathetic
- Speak clearly and at a moderate pace
- Use simple language — many callers may not be fluent in English
- Be concise — this is a phone call, not a chat
- Respond in the same language the caller uses (English, Hindi, Kannada)
CAPABILITIES:
- Answer questions about hospital departments, doctors, and specialties
- Book appointments — collect: name, phone, department, preferred date/time, reason
- Take messages and create enquiries for callback
- Transfer to a human agent when needed
HOSPITAL INFO:
- Global Hospital, Bangalore
- Open Monday to Saturday, 8 AM to 8 PM
- Emergency services available 24/7
- Departments: ${hospitalContext.departments.join(', ') || 'Cardiology, Orthopedics, Gynecology, General Medicine, ENT'}
RULES:
- Greet: "Hello, thank you for calling Global Hospital. This is Helix, how may I help you today?"
- If caller asks about pricing, say you will have the team call back with details
- Never give medical advice — always recommend consulting a doctor
- If the caller is in an emergency, tell them to visit the ER immediately or call 108
- Always confirm all details before booking an appointment
- End calls politely: "Thank you for calling Global Hospital. Have a good day!"
- If you cannot understand the caller, politely ask them to repeat`,
llm: new google.beta.realtime.RealtimeModel({
model: 'gemini-2.5-flash-native-audio-latest',
voice: 'Aoede',
temperature: 0.7,
}),
tools: { lookupDoctor, bookAppointment, collectLeadInfo, transferToAgent },
});
// ─── Entry Point ──────────────────────────────────────────────────────
export default defineAgent({
prewarm: async (proc) => {
proc.userData.vad = await silero.VAD.load();
await loadHospitalContext();
},
entry: async (ctx) => {
await ctx.connect();
console.log(`[LIVEKIT-AGENT] Connected to room: ${ctx.room.name}`);
const session = new voice.AgentSession({
vad: ctx.proc.userData.vad as VAD,
});
await session.start({ agent: hospitalAgent, room: ctx.room });
console.log('[LIVEKIT-AGENT] Voice session started');
// Gemini Realtime handles greeting via instructions — no separate say() needed
},
});
// CLI runner
if (require.main === module) {
const options = new WorkerOptions({
agent: __filename,
});
const { cli } = require('@livekit/agents');
cli.runApp(options);
}

View File

@@ -0,0 +1,61 @@
import { ConsoleLogger } from '@nestjs/common';
import { Subject } from 'rxjs';
export type LogEntry = {
timestamp: string;
level: 'log' | 'error' | 'warn' | 'debug' | 'verbose';
context: string;
message: string;
};
// Singleton — created once in main.ts, accessed by the SSE controller
// via LogStreamService.instance. NestJS DI isn't available at bootstrap
// time (the logger is created before the container), so we use a static
// instance instead of @Injectable().
export class LogStreamService extends ConsoleLogger {
static readonly instance = new LogStreamService();
readonly logSubject = new Subject<LogEntry>();
private readonly buffer: LogEntry[] = [];
private static readonly MAX_BUFFER = 500;
getRecentLogs(limit = 200): LogEntry[] {
return this.buffer.slice(-limit);
}
private emit(level: LogEntry['level'], message: unknown, context?: string) {
const entry: LogEntry = {
timestamp: new Date().toISOString(),
level,
context: context ?? this.context ?? '',
message: typeof message === 'string' ? message : JSON.stringify(message),
};
this.buffer.push(entry);
if (this.buffer.length > LogStreamService.MAX_BUFFER) this.buffer.shift();
this.logSubject.next(entry);
}
log(message: unknown, context?: string) {
super.log(message, context);
this.emit('log', message, context);
}
error(message: unknown, stack?: string, context?: string) {
super.error(message, stack, context);
this.emit('error', message, context);
}
warn(message: unknown, context?: string) {
super.warn(message, context);
this.emit('warn', message, context);
}
debug(message: unknown, context?: string) {
super.debug(message, context);
this.emit('debug', message, context);
}
verbose(message: unknown, context?: string) {
super.verbose(message, context);
this.emit('verbose', message, context);
}
}

View File

@@ -1,9 +1,13 @@
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import { AppModule } from './app.module';
import { ConfigService } from '@nestjs/config';
import { LogStreamService } from './logging/log-stream.service';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const logger = LogStreamService.instance;
const app = await NestFactory.create<NestExpressApplication>(AppModule, { logger });
const config = app.get(ConfigService);
app.enableCors({
@@ -11,6 +15,17 @@ async function bootstrap() {
credentials: true,
});
// Serve widget.js and other static files from /public
// In dev mode __dirname = src/, in prod __dirname = dist/ — resolve from process.cwd()
app.useStaticAssets(join(process.cwd(), 'public'), {
setHeaders: (res, path) => {
if (path.endsWith('.js')) {
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('Access-Control-Allow-Origin', '*');
}
},
});
const port = config.get('port');
await app.listen(port);
console.log(`Helix Engage Server running on port ${port}`);

File diff suppressed because it is too large Load Diff

20
src/maint/maint.guard.ts Normal file
View File

@@ -0,0 +1,20 @@
import { CanActivate, ExecutionContext, Injectable, HttpException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class MaintGuard implements CanActivate {
private readonly otp: string;
constructor(private config: ConfigService) {
this.otp = process.env.MAINT_OTP ?? '400168';
}
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest();
const provided = request.headers['x-maint-otp'] ?? request.body?.otp;
if (!provided || provided !== this.otp) {
throw new HttpException('Invalid maintenance OTP', 403);
}
return true;
}
}

13
src/maint/maint.module.ts Normal file
View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { PlatformModule } from '../platform/platform.module';
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
import { AuthModule } from '../auth/auth.module';
import { SupervisorModule } from '../supervisor/supervisor.module';
import { CallerResolutionModule } from '../caller/caller-resolution.module';
import { MaintController } from './maint.controller';
@Module({
imports: [PlatformModule, OzonetelAgentModule, AuthModule, SupervisorModule, CallerResolutionModule],
controllers: [MaintController],
})
export class MaintModule {}

View File

@@ -0,0 +1,45 @@
import { Controller, Get, Query, Logger } from '@nestjs/common';
import { MasterdataService } from './masterdata.service';
@Controller('api/masterdata')
export class MasterdataController {
private readonly logger = new Logger(MasterdataController.name);
constructor(private masterdata: MasterdataService) {}
@Get('departments')
async departments() {
return this.masterdata.getDepartments();
}
@Get('doctors')
async doctors() {
return this.masterdata.getDoctors();
}
@Get('clinics')
async clinics() {
return this.masterdata.getClinics();
}
// Available time slots for a doctor on a given date.
// Computed from DoctorVisitSlot entities (doctor × clinic × dayOfWeek).
// Returns 30-min slots within the doctor's visiting window for that day.
//
// GET /api/masterdata/slots?doctorId=xxx&date=2026-04-15
@Get('slots')
async slots(
@Query('doctorId') doctorId: string,
@Query('date') date: string,
) {
if (!doctorId || !date) return [];
return this.masterdata.getAvailableSlots(doctorId, date);
}
// Force cache refresh (admin use)
@Get('refresh')
async refresh() {
await this.masterdata.invalidateAll();
return { refreshed: true };
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { PlatformModule } from '../platform/platform.module';
import { AuthModule } from '../auth/auth.module';
import { MasterdataController } from './masterdata.controller';
import { MasterdataService } from './masterdata.service';
@Module({
imports: [PlatformModule, AuthModule],
controllers: [MasterdataController],
providers: [MasterdataService],
exports: [MasterdataService],
})
export class MasterdataModule {}

View File

@@ -0,0 +1,213 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { SessionService } from '../auth/session.service';
// Master data: cached lookups for departments, doctors, clinics.
// Fetched from the platform on first request, cached in Redis with TTL.
// Frontend dropdowns use these instead of direct GraphQL queries.
const CACHE_TTL = 300; // 5 minutes
const KEY_DEPARTMENTS = 'masterdata:departments';
const KEY_DOCTORS = 'masterdata:doctors';
const KEY_CLINICS = 'masterdata:clinics';
@Injectable()
export class MasterdataService implements OnModuleInit {
private readonly logger = new Logger(MasterdataService.name);
private readonly apiKey: string;
constructor(
private config: ConfigService,
private platform: PlatformGraphqlService,
private cache: SessionService,
) {
this.apiKey = this.config.get<string>('platform.apiKey') ?? process.env.PLATFORM_API_KEY ?? '';
}
async onModuleInit() {
// Warm cache on startup
try {
await this.getDepartments();
await this.getDoctors();
await this.getClinics();
this.logger.log('Master data cache warmed');
} catch (err: any) {
this.logger.warn(`Cache warm failed: ${err.message}`);
}
}
async getDepartments(): Promise<string[]> {
const cached = await this.cache.getCache(KEY_DEPARTMENTS);
if (cached) return JSON.parse(cached);
const auth = `Bearer ${this.apiKey}`;
const data = await this.platform.queryWithAuth<any>(
`{ doctors(first: 500) { edges { node { department } } } }`,
undefined, auth,
);
const departments = Array.from(new Set(
data.doctors.edges
.map((e: any) => e.node.department)
.filter((d: string) => d && d.trim()),
)).sort() as string[];
await this.cache.setCache(KEY_DEPARTMENTS, JSON.stringify(departments), CACHE_TTL);
this.logger.log(`Cached ${departments.length} departments`);
return departments;
}
async getDoctors(): Promise<Array<{ id: string; name: string; department: string; qualifications: string }>> {
const cached = await this.cache.getCache(KEY_DOCTORS);
if (cached) return JSON.parse(cached);
const auth = `Bearer ${this.apiKey}`;
const data = await this.platform.queryWithAuth<any>(
`{ doctors(first: 500) { edges { node {
id name department qualifications specialty active
fullName { firstName lastName }
} } } }`,
undefined, auth,
);
const doctors = data.doctors.edges
.map((e: any) => ({
id: e.node.id,
name: e.node.name ?? `${e.node.fullName?.firstName ?? ''} ${e.node.fullName?.lastName ?? ''}`.trim(),
department: e.node.department ?? '',
qualifications: e.node.qualifications ?? '',
specialty: e.node.specialty ?? '',
active: e.node.active ?? true,
}))
.filter((d: any) => d.active !== false);
await this.cache.setCache(KEY_DOCTORS, JSON.stringify(doctors), CACHE_TTL);
this.logger.log(`Cached ${doctors.length} doctors`);
return doctors;
}
async getClinics(): Promise<Array<{ id: string; name: string; phone: string; address: string; opensAt: string; closesAt: string }>> {
const cached = await this.cache.getCache(KEY_CLINICS);
if (cached) return JSON.parse(cached);
const auth = `Bearer ${this.apiKey}`;
const data = await this.platform.queryWithAuth<any>(
`{ clinics(first: 50) { edges { node {
id clinicName status opensAt closesAt
phone { primaryPhoneNumber }
addressCustom { addressCity addressState }
} } } }`,
undefined, auth,
);
const clinics = data.clinics.edges
.filter((e: any) => e.node.status !== 'INACTIVE')
.map((e: any) => ({
id: e.node.id,
name: e.node.clinicName ?? '',
phone: e.node.phone?.primaryPhoneNumber ?? '',
opensAt: e.node.opensAt ?? '08:00',
closesAt: e.node.closesAt ?? '20:00',
address: [e.node.addressCustom?.addressCity, e.node.addressCustom?.addressState].filter(Boolean).join(', '),
}));
await this.cache.setCache(KEY_CLINICS, JSON.stringify(clinics), CACHE_TTL);
this.logger.log(`Cached ${clinics.length} clinics`);
return clinics;
}
// Available time slots for a doctor on a given date.
// Reads DoctorVisitSlot entities for the matching dayOfWeek,
// then generates 30-min slots within each visiting window.
async getAvailableSlots(doctorId: string, date: string): Promise<Array<{ time: string; label: string; clinicId: string; clinicName: string }>> {
const dayOfWeek = new Date(date).toLocaleDateString('en-US', { weekday: 'long' }).toUpperCase();
const cacheKey = `masterdata:slots:${doctorId}:${dayOfWeek}`;
// Cache stores the UNFILTERED full-day slot list (keyed by dayOfWeek,
// so it's reusable across dates that fall on the same weekday). The
// "hide past slots on today" filter is applied AFTER cache read so it
// stays correct as real-time advances without cache churn.
const cached = await this.cache.getCache(cacheKey);
if (cached) return this.filterPastSlotsForToday(JSON.parse(cached), date);
const auth = `Bearer ${this.apiKey}`;
const data = await this.platform.queryWithAuth<any>(
`{ doctorVisitSlots(first: 100, filter: { doctorId: { eq: "${doctorId}" }, dayOfWeek: { eq: ${dayOfWeek} } }) {
edges { node { id startTime endTime clinic { id clinicName } } }
} }`,
undefined, auth,
);
const slots: Array<{ time: string; label: string; clinicId: string; clinicName: string }> = [];
for (const edge of data.doctorVisitSlots?.edges ?? []) {
const node = edge.node;
const clinicId = node.clinic?.id ?? '';
const clinicName = node.clinic?.clinicName ?? '';
const startTime = node.startTime ?? '09:00';
const endTime = node.endTime ?? '17:00';
// Generate 30-min slots within visiting window
const [startH, startM] = startTime.split(':').map(Number);
const [endH, endM] = endTime.split(':').map(Number);
let h = startH, m = startM ?? 0;
const endMin = endH * 60 + (endM ?? 0);
while (h * 60 + m < endMin) {
const hh = h.toString().padStart(2, '0');
const mm = m.toString().padStart(2, '0');
const ampm = h < 12 ? 'AM' : 'PM';
const displayH = h === 0 ? 12 : h > 12 ? h - 12 : h;
slots.push({
time: `${hh}:${mm}`,
label: `${displayH}:${mm.toString().padStart(2, '0')} ${ampm}${clinicName}`,
clinicId,
clinicName,
});
m += 30;
if (m >= 60) { h++; m = 0; }
}
}
// Sort by time
slots.sort((a, b) => a.time.localeCompare(b.time));
// Cache the full UNFILTERED list so reuse across dates (same dayOfWeek)
// doesn't mis-serve filtered data from an earlier date.
await this.cache.setCache(cacheKey, JSON.stringify(slots), CACHE_TTL);
this.logger.log(`Generated ${slots.length} slots for doctor ${doctorId} on ${dayOfWeek}`);
return this.filterPastSlotsForToday(slots, date);
}
// When the requested date is today (IST), hide slots whose time has
// already passed (30-min buffer so we don't offer the impossible-to-keep
// "in 5 minutes" slot). Applies to both cache-hit and fresh fetch paths.
private filterPastSlotsForToday(
slots: Array<{ time: string; label: string; clinicId: string; clinicName: string }>,
date: string,
): Array<{ time: string; label: string; clinicId: string; clinicName: string }> {
const todayIst = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Kolkata' });
if (date !== todayIst) return slots;
const nowHHMM = new Date().toLocaleTimeString('en-GB', {
timeZone: 'Asia/Kolkata', hour: '2-digit', minute: '2-digit',
});
const [nowH, nowM] = nowHHMM.split(':').map(Number);
const cutoff = nowH * 60 + nowM + 30; // 30-min buffer
const filtered = slots.filter((s) => {
const [h, m] = s.time.split(':').map(Number);
return h * 60 + m >= cutoff;
});
this.logger.log(`[SLOTS] Today filter: ${slots.length}${filtered.length} (now=${nowHHMM} IST, cutoff=${Math.floor(cutoff / 60)}:${String(cutoff % 60).padStart(2, '0')})`);
return filtered;
}
async invalidateAll(): Promise<void> {
await this.cache.setCache(KEY_DEPARTMENTS, '', 1);
await this.cache.setCache(KEY_DOCTORS, '', 1);
await this.cache.setCache(KEY_CLINICS, '', 1);
this.logger.log('Master data cache invalidated');
}
}

View File

@@ -0,0 +1,377 @@
{
"id": "flow-appointment-booking",
"name": "Appointment Booking",
"description": "AI-driven appointment booking via WhatsApp with interactive department, doctor, date, and slot selection.",
"trigger": { "type": "default" },
"version": 1,
"status": "published",
"variables": [
{ "id": "v1", "name": "intent", "type": "string" },
{ "id": "v2", "name": "selectedDepartment", "type": "string" },
{ "id": "v3", "name": "selectedDepartmentTitle", "type": "string" },
{ "id": "v4", "name": "selectedDoctor", "type": "string" },
{ "id": "v5", "name": "selectedDoctorTitle", "type": "string" },
{ "id": "v6", "name": "doctorId", "type": "string" },
{ "id": "v7", "name": "dateChoice", "type": "string" },
{ "id": "v8", "name": "selectedDate", "type": "string" },
{ "id": "v9", "name": "selectedSlot", "type": "string" },
{ "id": "v10", "name": "confirmation", "type": "string" },
{ "id": "v11", "name": "bookingResult", "type": "object" },
{ "id": "v12", "name": "deptListResult", "type": "object" },
{ "id": "v13", "name": "docListResult", "type": "object" },
{ "id": "v14", "name": "slotListResult", "type": "object" },
{ "id": "v15", "name": "aiGreeting", "type": "string" },
{ "id": "v16", "name": "reason", "type": "string" },
{ "id": "v17", "name": "scheduledDateTime", "type": "string" }
],
"groups": [
{
"id": "g1",
"title": "Greeting",
"blocks": [
{
"id": "b1",
"type": "ai",
"prompt": "Greet the patient {{_senderName}} warmly in 1-2 sentences. They messaged: \"{{_initialMessage}}\". You are a WhatsApp assistant for Ramaiah Hospital. Be concise, no markdown.",
"outputVariableId": "aiGreeting",
"sendToPatient": true
},
{
"id": "b2",
"type": "message",
"content": {
"format": "buttons",
"text": "How can I help you today?",
"buttons": [
{ "id": "intent:book", "title": "Book Appointment" },
{ "id": "intent:check", "title": "Check Appointment" },
{ "id": "intent:question", "title": "Ask a Question" }
]
}
},
{
"id": "b3",
"type": "input",
"inputType": "any",
"variableId": "intent"
},
{
"id": "b4",
"type": "condition",
"conditions": [
{ "id": "c1", "variableId": "intent", "operator": "contains", "value": "book" },
{ "id": "c2", "variableId": "intent", "operator": "contains", "value": "check" }
]
}
]
},
{
"id": "g2",
"title": "Department Selection",
"blocks": [
{
"id": "b5",
"type": "tool_call",
"toolName": "send_department_list",
"inputs": {},
"outputVariableId": "deptListResult"
},
{
"id": "b6",
"type": "input",
"inputType": "any",
"variableId": "selectedDepartment"
},
{
"id": "b7",
"type": "set_variable",
"variableId": "selectedDepartmentTitle",
"value": "selectedDepartment",
"expression": "extract_id"
}
]
},
{
"id": "g3",
"title": "Doctor Selection",
"blocks": [
{
"id": "b8",
"type": "tool_call",
"toolName": "send_doctor_list",
"inputs": { "department": "{{selectedDepartmentTitle}}" },
"outputVariableId": "docListResult"
},
{
"id": "b9",
"type": "input",
"inputType": "any",
"variableId": "selectedDoctor"
},
{
"id": "b10",
"type": "set_variable",
"variableId": "doctorId",
"value": "selectedDoctor",
"expression": "extract_id"
}
]
},
{
"id": "g4",
"title": "Date Selection",
"blocks": [
{
"id": "b11",
"type": "message",
"content": {
"format": "buttons",
"text": "When would you like to visit?",
"buttons": [
{ "id": "date:tomorrow", "title": "Tomorrow" },
{ "id": "date:day_after", "title": "Day After Tomorrow" },
{ "id": "date:other", "title": "Choose Another Date" }
]
}
},
{
"id": "b12",
"type": "input",
"inputType": "any",
"variableId": "dateChoice"
},
{
"id": "b13",
"type": "condition",
"conditions": [
{ "id": "c3", "variableId": "dateChoice", "operator": "contains", "value": "tomorrow" },
{ "id": "c4", "variableId": "dateChoice", "operator": "contains", "value": "day_after" },
{ "id": "c7", "variableId": "dateChoice", "operator": "contains", "value": "other" }
]
}
]
},
{
"id": "g4t",
"title": "Date - Tomorrow",
"blocks": [
{
"id": "b14",
"type": "set_variable",
"variableId": "selectedDate",
"value": "",
"expression": "date_tomorrow"
}
]
},
{
"id": "g4a",
"title": "Date - Day After",
"blocks": [
{
"id": "b15",
"type": "set_variable",
"variableId": "selectedDate",
"value": "",
"expression": "date_day_after"
}
]
},
{
"id": "g4c",
"title": "Date - Custom",
"blocks": [
{
"id": "b15a",
"type": "message",
"content": {
"format": "text",
"text": "Please type your preferred date (e.g., April 25 or 25/04/2026)."
}
},
{
"id": "b15b",
"type": "input",
"inputType": "text",
"variableId": "customDateText"
},
{
"id": "b15c",
"type": "ai",
"prompt": "The patient typed this date: \"{{customDateText}}\". Convert it to YYYY-MM-DD format. The current year is 2026. Reply with ONLY the date in YYYY-MM-DD format, nothing else.",
"outputVariableId": "selectedDate",
"sendToPatient": false
}
]
},
{
"id": "g5",
"title": "Slot Selection",
"blocks": [
{
"id": "b16",
"type": "tool_call",
"toolName": "send_slot_list",
"inputs": {
"doctorId": "{{doctorId}}",
"doctorName": "{{selectedDoctor_title}}",
"date": "{{selectedDate}}"
},
"outputVariableId": "slotListResult"
},
{
"id": "b17",
"type": "input",
"inputType": "any",
"variableId": "selectedSlot"
},
{
"id": "b17a",
"type": "set_variable",
"variableId": "scheduledDateTime",
"value": "selectedSlot",
"expression": "extract_datetime"
}
]
},
{
"id": "g6",
"title": "Reason",
"blocks": [
{
"id": "b18",
"type": "message",
"content": {
"format": "text",
"text": "What is the reason for your visit? (e.g., General Consultation, Follow-up, etc.)"
}
},
{
"id": "b19",
"type": "input",
"inputType": "text",
"variableId": "reason"
}
]
},
{
"id": "g7",
"title": "Confirmation",
"blocks": [
{
"id": "b20",
"type": "tool_call",
"toolName": "send_confirm_buttons",
"inputs": {
"summary": "Appointment Summary:\nDoctor: {{selectedDoctor_title}}\nDate: {{selectedDate}}\nTime: {{selectedSlot_title}}\nReason: {{reason}}\n\nShall I confirm this booking?"
}
},
{
"id": "b21",
"type": "input",
"inputType": "any",
"variableId": "confirmation"
},
{
"id": "b22",
"type": "condition",
"conditions": [
{ "id": "c5", "variableId": "confirmation", "operator": "contains", "value": "confirm" },
{ "id": "c6", "variableId": "confirmation", "operator": "contains", "value": "cancel" }
]
}
]
},
{
"id": "g8",
"title": "Booking",
"blocks": [
{
"id": "b23",
"type": "tool_call",
"toolName": "book_appointment",
"inputs": {
"patientName": "{{_senderName}}",
"phoneNumber": "{{_phone}}",
"department": "{{selectedDepartmentTitle}}",
"doctorName": "{{selectedDoctor_title}}",
"scheduledAt": "{{scheduledDateTime}}",
"reason": "{{reason}}"
},
"outputVariableId": "bookingResult"
},
{
"id": "b24",
"type": "message",
"content": {
"format": "text",
"text": "Your appointment is confirmed!\n\nDoctor: {{selectedDoctor_title}}\nDate: {{selectedDate}}\nTime: {{selectedSlot_title}}\nReason: {{reason}}\n\nThank you for choosing Ramaiah Hospital. See you soon!"
}
},
{
"id": "b24a",
"type": "tool_call",
"toolName": "send_appointment_qr",
"inputs": {
"appointmentId": "{{bookingResult.appointmentId}}",
"reference": "{{bookingResult.reference}}",
"patientName": "{{_senderName}}",
"doctorName": "{{selectedDoctor_title}}",
"department": "{{selectedDepartmentTitle}}",
"scheduledAt": "{{scheduledDateTime}}"
}
}
]
},
{
"id": "g9",
"title": "Cancelled",
"blocks": [
{
"id": "b25",
"type": "message",
"content": {
"format": "text",
"text": "No problem! Your booking has been cancelled. Feel free to message us again whenever you'd like to book an appointment."
}
}
]
},
{
"id": "g10",
"title": "Check Appointments",
"blocks": [
{
"id": "b26",
"type": "tool_call",
"toolName": "lookup_appointments",
"inputs": {},
"outputVariableId": "existingAppts"
},
{
"id": "b27",
"type": "ai",
"prompt": "The patient {{_senderName}} asked to check their appointments. Here are their appointments: {{existingAppts}}. Summarize them in a friendly WhatsApp message. If no appointments, say they have none and offer to book one. Be concise, no markdown.",
"outputVariableId": "apptSummary",
"sendToPatient": true
}
]
}
],
"edges": [
{ "id": "e1", "from": { "blockId": "b4", "conditionId": "c1" }, "to": { "groupId": "g2" } },
{ "id": "e2", "from": { "blockId": "b4", "conditionId": "c2" }, "to": { "groupId": "g10" } },
{ "id": "e3", "from": { "blockId": "b7" }, "to": { "groupId": "g3" } },
{ "id": "e4", "from": { "blockId": "b10" }, "to": { "groupId": "g4" } },
{ "id": "e5", "from": { "blockId": "b13", "conditionId": "c3" }, "to": { "groupId": "g4t" } },
{ "id": "e6", "from": { "blockId": "b13", "conditionId": "c4" }, "to": { "groupId": "g4a" } },
{ "id": "e6a", "from": { "blockId": "b13", "conditionId": "c7" }, "to": { "groupId": "g4c" } },
{ "id": "e7", "from": { "blockId": "b14" }, "to": { "groupId": "g5" } },
{ "id": "e8", "from": { "blockId": "b15" }, "to": { "groupId": "g5" } },
{ "id": "e8a", "from": { "blockId": "b15c" }, "to": { "groupId": "g5" } },
{ "id": "e9", "from": { "blockId": "b17a" }, "to": { "groupId": "g6" } },
{ "id": "e10", "from": { "blockId": "b19" }, "to": { "groupId": "g7" } },
{ "id": "e11", "from": { "blockId": "b22", "conditionId": "c5" }, "to": { "groupId": "g8" } },
{ "id": "e12", "from": { "blockId": "b22", "conditionId": "c6" }, "to": { "groupId": "g9" } }
]
}

View File

@@ -0,0 +1,344 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { generateText, stepCountIs } from 'ai';
import { createAiModel } from '../../ai/ai-provider';
import { AiConfigService } from '../../config/ai-config.service';
import { CallerResolutionService } from '../../caller/caller-resolution.service';
import { CallerContextService } from '../../caller/caller-context.service';
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
import { MessagingProvider } from '../providers/messaging-provider.interface';
import { FlowSessionService } from './flow-session.service';
import { FlowStoreService } from './flow-store.service';
import { FlowVariableService } from './flow-variable.service';
import { ToolRegistry } from './tool-registry';
import type { Flow, FlowSession, Group, Block, ConditionBlock, ToolContext } from './flow-types';
import type { NormalizedMessage } from '../types';
import type { LanguageModel } from 'ai';
@Injectable()
export class FlowExecutionService {
private readonly logger = new Logger(FlowExecutionService.name);
private readonly aiModel: LanguageModel | null;
private readonly auth: string;
constructor(
private config: ConfigService,
private provider: MessagingProvider,
private sessions: FlowSessionService,
private store: FlowStoreService,
private variables: FlowVariableService,
private tools: ToolRegistry,
private caller: CallerResolutionService,
private callerContext: CallerContextService,
private platform: PlatformGraphqlService,
private aiConfig: AiConfigService,
) {
const cfg = aiConfig.getConfig();
this.aiModel = createAiModel({
provider: cfg.provider,
model: cfg.model,
anthropicApiKey: config.get<string>('ai.anthropicApiKey'),
openaiApiKey: config.get<string>('ai.openaiApiKey'),
});
const apiKey = config.get<string>('platform.apiKey') ?? '';
this.auth = apiKey ? `Bearer ${apiKey}` : '';
}
// Per-phone lock to prevent concurrent flow executions
private readonly locks = new Map<string, Promise<void>>();
async handleMessage(message: NormalizedMessage): Promise<void> {
const { phone } = message;
// Serialize executions per phone — prevent two concurrent flows
const existing = this.locks.get(phone);
const execute = async () => {
if (existing) await existing.catch(() => {});
await this._handleMessage(message);
};
const promise = execute();
this.locks.set(phone, promise);
await promise.finally(() => {
if (this.locks.get(phone) === promise) this.locks.delete(phone);
});
}
private async _handleMessage(message: NormalizedMessage): Promise<void> {
const { phone } = message;
// 1. Load existing session or start new flow
let session = await this.sessions.load(phone);
let flow: Flow | null = null;
if (session) {
flow = this.store.getById(session.flowId);
if (!flow) {
this.logger.warn(`[FLOW] Flow ${session.flowId} not found — clearing session`);
await this.sessions.clear(phone);
session = null;
}
}
if (!session) {
flow = this.store.matchFlow(message.text);
if (!flow) {
this.logger.log(`[FLOW] No matching flow for: ${message.text.substring(0, 50)}`);
await this.provider.sendText(phone, 'Sorry, I didn\'t understand. Please try again.');
return;
}
// Initialize session
const firstGroup = flow.groups[0];
if (!firstGroup) {
this.logger.error(`[FLOW] Flow ${flow.id} has no groups`);
return;
}
session = {
flowId: flow.id,
currentGroupId: firstGroup.id,
currentBlockIndex: 0,
variables: this.initializeVariables(flow, message),
startedAt: Date.now(),
lastActiveAt: Date.now(),
};
// Resolve caller and inject context variables
const resolved = await this.caller.resolve(phone, this.auth).catch(() => null);
if (resolved) {
session.variables['_callerName'] = `${resolved.firstName} ${resolved.lastName}`.trim();
session.variables['_leadId'] = resolved.leadId;
session.variables['_patientId'] = resolved.patientId;
session.variables['_isNew'] = resolved.isNew;
session.variables['_phone'] = phone;
}
this.logger.log(`[FLOW] Started flow "${flow.name}" for ${phone}`);
}
// 2. If paused at an InputBlock, process the reply
const currentGroup = flow!.groups.find(g => g.id === session!.currentGroupId);
if (currentGroup) {
const currentBlock = currentGroup.blocks[session!.currentBlockIndex];
if (currentBlock?.type === 'input') {
const value = message.interactiveReply?.id ?? message.text;
session!.variables[currentBlock.variableId] = value;
// Also store the display title for interactive replies
if (message.interactiveReply?.title) {
session!.variables[currentBlock.variableId + '_title'] = message.interactiveReply.title;
}
this.logger.log(`[FLOW] Input received: ${currentBlock.variableId}=${value}`);
session!.currentBlockIndex++;
}
}
// 3. Walk forward
await this.walkForward(phone, session!, flow!);
}
private async walkForward(phone: string, session: FlowSession, flow: Flow): Promise<void> {
let iterations = 0;
const maxIterations = 50; // safety valve
while (iterations++ < maxIterations) {
const group = flow.groups.find(g => g.id === session.currentGroupId);
if (!group) {
this.logger.log(`[FLOW] Group ${session.currentGroupId} not found — flow complete`);
await this.sessions.clear(phone);
return;
}
// End of group — follow outgoing edge
if (session.currentBlockIndex >= group.blocks.length) {
const edge = this.findGroupEdge(flow, group);
if (!edge) {
this.logger.log(`[FLOW] No outgoing edge from group "${group.title}" — flow complete`);
await this.sessions.clear(phone);
return;
}
session.currentGroupId = edge.to.groupId;
session.currentBlockIndex = 0;
continue;
}
const block = group.blocks[session.currentBlockIndex];
this.logger.log(`[FLOW] Executing block ${block.id} (${block.type}) in group "${group.title}"`);
const shouldStop = await this.executeBlock(block, phone, session, flow);
if (shouldStop) {
await this.sessions.save(phone, session);
return;
}
}
this.logger.error(`[FLOW] Max iterations reached for ${phone} — possible infinite loop`);
await this.sessions.clear(phone);
}
// Returns true if execution should pause (InputBlock)
private async executeBlock(block: Block, phone: string, session: FlowSession, flow: Flow): Promise<boolean> {
const ctx: ToolContext = {
phone,
session,
provider: this.provider,
platform: this.platform,
auth: this.auth,
};
switch (block.type) {
case 'message': {
const content = block.content;
if (content.format === 'text') {
const text = this.variables.interpolate(content.text, session.variables);
await this.provider.sendText(phone, text);
} else if (content.format === 'buttons') {
const text = this.variables.interpolate(content.text, session.variables);
await this.provider.sendButtons(phone, text, content.buttons);
} else if (content.format === 'list') {
const text = this.variables.interpolate(content.text, session.variables);
await this.provider.sendList(phone, text, content.buttonText, content.sections);
}
session.currentBlockIndex++;
return false;
}
case 'input': {
// Pause — wait for next message
this.logger.log(`[FLOW] Waiting for input → ${block.variableId}`);
return true;
}
case 'condition': {
const matched = this.evaluateConditions(block, session);
if (matched) {
const edge = flow.edges.find(e =>
e.from.blockId === block.id && e.from.conditionId === matched.id,
);
if (edge) {
session.currentGroupId = edge.to.groupId;
session.currentBlockIndex = 0;
return false;
}
}
// No match — fall through to next block
session.currentBlockIndex++;
return false;
}
case 'set_variable': {
if (block.expression) {
const rawValue = session.variables[block.value] ?? block.value;
session.variables[block.variableId] = this.variables.evaluateExpression(
block.expression, String(rawValue), session.variables,
);
} else {
session.variables[block.variableId] = this.variables.interpolate(block.value, session.variables);
}
this.logger.log(`[FLOW] Set ${block.variableId}=${session.variables[block.variableId]}`);
session.currentBlockIndex++;
return false;
}
case 'tool_call': {
const inputs = this.variables.interpolateObject(block.inputs, session.variables);
const result = await this.tools.execute(block.toolName, inputs, ctx);
if (block.outputVariableId) {
session.variables[block.outputVariableId] = result;
}
session.currentBlockIndex++;
return false;
}
case 'ai': {
if (!this.aiModel) {
session.currentBlockIndex++;
return false;
}
const prompt = this.variables.interpolate(block.prompt, session.variables);
try {
const result = await generateText({
model: this.aiModel,
prompt,
stopWhen: stepCountIs(1),
});
const text = result.text?.trim() ?? '';
if (block.outputVariableId) {
session.variables[block.outputVariableId] = text;
}
if (block.sendToPatient && text) {
await this.provider.sendText(phone, text);
}
} catch (err: any) {
this.logger.error(`[FLOW] AI block failed: ${err.message}`);
}
session.currentBlockIndex++;
return false;
}
case 'jump': {
session.currentGroupId = block.targetGroupId;
session.currentBlockIndex = 0;
return false;
}
default:
this.logger.warn(`[FLOW] Unknown block type: ${(block as any).type}`);
session.currentBlockIndex++;
return false;
}
}
private evaluateConditions(block: ConditionBlock, session: FlowSession) {
for (const cond of block.conditions) {
const value = session.variables[cond.variableId];
const target = cond.value ? this.variables.interpolate(cond.value, session.variables) : undefined;
let match = false;
switch (cond.operator) {
case 'equals': match = String(value) === target; break;
case 'contains': match = String(value ?? '').toLowerCase().includes((target ?? '').toLowerCase()); break;
case 'exists': match = value !== undefined && value !== null && value !== ''; break;
case 'not_exists': match = value === undefined || value === null || value === ''; break;
case 'starts_with': match = String(value ?? '').startsWith(target ?? ''); break;
case 'gt': match = Number(value) > Number(target); break;
case 'lt': match = Number(value) < Number(target); break;
}
if (match) return cond;
}
return null;
}
private findGroupEdge(flow: Flow, group: Group) {
// Find edge from the last block in the group (default outgoing)
const lastBlock = group.blocks[group.blocks.length - 1];
if (lastBlock) {
const edge = flow.edges.find(e => e.from.blockId === lastBlock.id && !e.from.conditionId);
if (edge) return edge;
}
// Fallback: any edge from any block in this group without conditionId
for (const block of group.blocks) {
const edge = flow.edges.find(e => e.from.blockId === block.id && !e.from.conditionId);
if (edge) return edge;
}
return null;
}
private initializeVariables(flow: Flow, message: NormalizedMessage): Record<string, any> {
const vars: Record<string, any> = {};
for (const v of flow.variables) {
vars[v.name] = v.defaultValue ?? null;
}
// Inject message context
vars['_initialMessage'] = message.text;
vars['_senderName'] = message.name;
return vars;
}
// Check if flow engine has any published flows
hasFlows(): boolean {
return this.store.getAll().some(f => f.status === 'published');
}
}

View File

@@ -0,0 +1,39 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
import type { FlowSession } from './flow-types';
@Injectable()
export class FlowSessionService {
private readonly logger = new Logger(FlowSessionService.name);
private readonly redis: Redis;
private readonly ttlSec = 24 * 60 * 60; // 24h
constructor(config: ConfigService) {
const redisUrl = config.get<string>('redis.url') ?? 'redis://localhost:6379';
this.redis = new Redis(redisUrl);
}
private key(phone: string): string {
return `wa:flow:${phone}`;
}
async load(phone: string): Promise<FlowSession | null> {
const raw = await this.redis.get(this.key(phone));
if (!raw) return null;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
async save(phone: string, session: FlowSession): Promise<void> {
session.lastActiveAt = Date.now();
await this.redis.setex(this.key(phone), this.ttlSec, JSON.stringify(session));
}
async clear(phone: string): Promise<void> {
await this.redis.del(this.key(phone));
}
}

View File

@@ -0,0 +1,102 @@
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { existsSync, readFileSync, writeFileSync, readdirSync } from 'fs';
import { join } from 'path';
import type { Flow } from './flow-types';
const FLOWS_DIR = join(process.cwd(), 'data', 'flows');
const DEFAULTS_DIR = join(__dirname, 'default-flows');
@Injectable()
export class FlowStoreService implements OnModuleInit {
private readonly logger = new Logger(FlowStoreService.name);
private flows: Map<string, Flow> = new Map();
onModuleInit() {
this.ensureDirectory();
this.seedDefaults();
this.loadAll();
}
private ensureDirectory() {
const { mkdirSync } = require('fs');
if (!existsSync(FLOWS_DIR)) {
mkdirSync(FLOWS_DIR, { recursive: true });
}
}
private seedDefaults() {
// Copy default flows if data/flows/ is empty
if (!existsSync(DEFAULTS_DIR)) return;
const existing = readdirSync(FLOWS_DIR).filter(f => f.endsWith('.json'));
if (existing.length > 0) return;
const defaults = readdirSync(DEFAULTS_DIR).filter(f => f.endsWith('.json'));
for (const file of defaults) {
const src = join(DEFAULTS_DIR, file);
const dest = join(FLOWS_DIR, file);
const content = readFileSync(src, 'utf-8');
writeFileSync(dest, content);
this.logger.log(`[FLOW-STORE] Seeded default flow: ${file}`);
}
}
private loadAll() {
this.flows.clear();
const files = readdirSync(FLOWS_DIR).filter(f => f.endsWith('.json'));
for (const file of files) {
try {
const raw = readFileSync(join(FLOWS_DIR, file), 'utf-8');
const flow: Flow = JSON.parse(raw);
this.flows.set(flow.id, flow);
this.logger.log(`[FLOW-STORE] Loaded flow: ${flow.name} (${flow.id}) status=${flow.status}`);
} catch (err: any) {
this.logger.error(`[FLOW-STORE] Failed to load ${file}: ${err.message}`);
}
}
this.logger.log(`[FLOW-STORE] ${this.flows.size} flow(s) loaded`);
}
getById(id: string): Flow | null {
return this.flows.get(id) ?? null;
}
// Match inbound message to a published flow by trigger
matchFlow(messageText: string): Flow | null {
let defaultFlow: Flow | null = null;
for (const flow of this.flows.values()) {
if (flow.status !== 'published') continue;
if (flow.trigger.type === 'default') {
defaultFlow = flow;
continue;
}
if (flow.trigger.type === 'message' && flow.trigger.conditions) {
const { keywords, regex } = flow.trigger.conditions;
const lower = messageText.toLowerCase();
if (keywords?.some(k => lower.includes(k.toLowerCase()))) {
return flow;
}
if (regex && new RegExp(regex, 'i').test(messageText)) {
return flow;
}
}
}
return defaultFlow;
}
// CRUD for admin API (future)
getAll(): Flow[] {
return Array.from(this.flows.values());
}
save(flow: Flow): void {
this.flows.set(flow.id, flow);
const file = join(FLOWS_DIR, `${flow.id}.json`);
writeFileSync(file, JSON.stringify(flow, null, 2));
this.logger.log(`[FLOW-STORE] Saved flow: ${flow.name} (${flow.id})`);
}
}

View File

@@ -0,0 +1,133 @@
// ── Flow Definition ──
export type Flow = {
id: string;
name: string;
description: string;
trigger: FlowTrigger;
groups: Group[];
edges: Edge[];
variables: VariableDefinition[];
version: number;
status: 'draft' | 'published';
};
export type FlowTrigger =
| { type: 'message'; conditions?: { keywords?: string[]; regex?: string } }
| { type: 'default' };
export type VariableDefinition = {
id: string;
name: string;
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
defaultValue?: any;
};
// ── Groups & Edges ──
export type Group = {
id: string;
title: string;
blocks: Block[];
};
export type Edge = {
id: string;
from: { blockId: string; conditionId?: string };
to: { groupId: string; blockId?: string };
};
// ── Blocks ──
export type Block =
| MessageBlock
| InputBlock
| ConditionBlock
| SetVariableBlock
| ToolCallBlock
| AIBlock
| JumpBlock;
export type MessageBlock = {
id: string;
type: 'message';
content:
| { format: 'text'; text: string }
| { format: 'buttons'; text: string; buttons: { id: string; title: string }[] }
| { format: 'list'; text: string; buttonText: string; sections: { title: string; rows: { id: string; title: string; description?: string }[] }[] };
};
export type InputBlock = {
id: string;
type: 'input';
inputType: 'text' | 'interactive_reply' | 'any';
variableId: string;
validation?: { regex?: string; errorMessage?: string };
};
export type ConditionBlock = {
id: string;
type: 'condition';
conditions: {
id: string;
variableId: string;
operator: 'equals' | 'contains' | 'exists' | 'not_exists' | 'gt' | 'lt' | 'starts_with';
value?: string;
}[];
};
export type SetVariableBlock = {
id: string;
type: 'set_variable';
variableId: string;
value: string;
expression?: 'extract_id' | 'extract_datetime' | 'date_tomorrow' | 'date_day_after';
};
export type ToolCallBlock = {
id: string;
type: 'tool_call';
toolName: string;
inputs: Record<string, string>;
outputVariableId?: string;
};
export type AIBlock = {
id: string;
type: 'ai';
prompt: string;
outputVariableId?: string;
sendToPatient: boolean;
};
export type JumpBlock = {
id: string;
type: 'jump';
targetGroupId: string;
};
// ── Session State ──
export type FlowSession = {
flowId: string;
currentGroupId: string;
currentBlockIndex: number;
variables: Record<string, any>;
startedAt: number;
lastActiveAt: number;
};
// ── Tool Registry ──
export type ToolHandler = (
inputs: Record<string, any>,
context: ToolContext,
) => Promise<any>;
export type ToolContext = {
phone: string;
session: FlowSession;
provider: import('../providers/messaging-provider.interface').MessagingProvider;
platform: import('../../platform/platform-graphql.service').PlatformGraphqlService;
auth: string;
};

View File

@@ -0,0 +1,56 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class FlowVariableService {
// Replace {{variableName}} with values from session variables
interpolate(template: string, variables: Record<string, any>): string {
return template.replace(/\{\{([\w.]+)\}\}/g, (match, path) => {
// Support dot notation: {{bookingResult.appointmentId}}
const parts = path.split('.');
let value: any = variables;
for (const part of parts) {
value = value?.[part];
if (value === undefined) return match;
}
if (value === null) return match;
if (typeof value === 'object') return JSON.stringify(value);
return String(value);
});
}
// Interpolate all string values in an object
interpolateObject(obj: Record<string, string>, variables: Record<string, any>): Record<string, any> {
const result: Record<string, any> = {};
for (const [key, value] of Object.entries(obj)) {
result[key] = this.interpolate(value, variables);
}
return result;
}
// Execute expressions for SetVariableBlock
evaluateExpression(expression: string, value: string, variables: Record<string, any>): any {
switch (expression) {
case 'extract_id': {
// Extract second segment: "doc:{uuid}:{name}" → uuid, "dept:{name}" → name
const parts = value.split(':');
return parts.length >= 2 ? parts[1] : value;
}
case 'extract_datetime': {
// Extract datetime from "slot:{doctorId}:{datetime}" → "2026-04-21T14:00:00"
const parts = value.split(':');
// Rejoin from index 2 onwards (datetime contains colons: 2026-04-21T14:00:00)
return parts.length >= 3 ? parts.slice(2).join(':') : value;
}
case 'date_tomorrow': {
const d = new Date(Date.now() + 86400000);
return d.toISOString().split('T')[0];
}
case 'date_day_after': {
const d = new Date(Date.now() + 2 * 86400000);
return d.toISOString().split('T')[0];
}
default:
return this.interpolate(value, variables);
}
}
}

View File

@@ -0,0 +1,244 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
import { CallerResolutionService } from '../../caller/caller-resolution.service';
import { QrService } from '../qr.service';
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../../shared/doctor-utils';
import type { ToolHandler, ToolContext } from './flow-types';
import type { ListSection, InteractiveButton } from '../types';
@Injectable()
export class ToolRegistry {
private readonly logger = new Logger(ToolRegistry.name);
private readonly tools: Map<string, ToolHandler> = new Map();
private readonly sidecarUrl: string;
constructor(
private platform: PlatformGraphqlService,
private caller: CallerResolutionService,
private qr: QrService,
private config: ConfigService,
) {
this.sidecarUrl = config.get<string>('sidecarUrl') ?? '';
this.registerDefaults();
}
register(name: string, handler: ToolHandler) {
this.tools.set(name, handler);
}
async execute(name: string, inputs: Record<string, any>, context: ToolContext): Promise<any> {
const handler = this.tools.get(name);
if (!handler) {
this.logger.error(`[TOOL] Unknown tool: ${name}`);
return { error: `Unknown tool: ${name}` };
}
this.logger.log(`[TOOL] ${name} inputs=${JSON.stringify(inputs).substring(0, 200)}`);
const result = await handler(inputs, context);
this.logger.log(`[TOOL] ${name} result=${JSON.stringify(result).substring(0, 200)}`);
return result;
}
private registerDefaults() {
this.register('resolve_caller', async (inputs, ctx) => {
const phone = inputs.phone ?? ctx.phone;
const resolved = await this.caller.resolve(phone, ctx.auth).catch(() => null);
return resolved ?? { isNew: true, leadId: '', patientId: '', phone };
});
this.register('send_department_list', async (_inputs, ctx) => {
const data = await this.platform.query<any>(
`{ doctors(first: 50) { edges { node { department } } } }`,
);
const departments = [...new Set(
data.doctors.edges.map((e: any) => e.node.department).filter(Boolean),
)] as string[];
if (!departments.length) return { sent: false, message: 'No departments available.' };
const sections: ListSection[] = [{
title: 'Departments',
rows: departments.slice(0, 10).map(d => ({
id: `dept:${d}`,
title: d.substring(0, 24),
})),
}];
await ctx.provider.sendList(ctx.phone, 'Which department would you like to visit?', 'View Departments', sections);
return { sent: true, departments };
});
this.register('send_doctor_list', async (inputs, ctx) => {
const department = inputs.department;
const data = await this.platform.query<any>(
`{ doctors(first: 50) { edges { node {
id fullName { firstName lastName }
department specialty
consultationFeeNew { amountMicros currencyCode }
${DOCTOR_VISIT_SLOTS_FRAGMENT}
} } } }`,
);
const allDocs = normalizeDoctors(data.doctors.edges.map((e: any) => e.node));
const deptDocs = allDocs.filter((d: any) =>
d.department?.toLowerCase() === department.toLowerCase(),
);
if (!deptDocs.length) return { sent: false, message: `No doctors found in ${department}.` };
const sections: ListSection[] = [{
title: department.substring(0, 24),
rows: deptDocs.slice(0, 10).map((d: any) => {
const docName = `Dr. ${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''}`.trim();
const fee = d.consultationFeeNew?.amountMicros
? `${(d.consultationFeeNew.amountMicros / 1000000).toFixed(0)}`
: '';
return {
id: `doc:${d.id}:${docName}`,
title: docName.substring(0, 24),
description: fee ? `${d.specialty ?? department}${fee}` : (d.specialty ?? department),
};
}),
}];
await ctx.provider.sendList(ctx.phone, `Doctors in ${department}:`, 'View Doctors', sections);
return { sent: true, count: deptDocs.length };
});
this.register('send_slot_list', async (inputs, ctx) => {
const { doctorId, doctorName, date } = inputs;
const targetDate = date ?? new Date(Date.now() + 86400000).toISOString().split('T')[0];
const dayNames = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'];
const targetDay = dayNames[new Date(targetDate + 'T00:00:00+05:30').getDay()];
const data = await this.platform.query<any>(
`{ doctors(first: 50) { edges { node {
id fullName { firstName lastName }
${DOCTOR_VISIT_SLOTS_FRAGMENT}
} } } }`,
);
const rawDocs = data.doctors.edges.map((e: any) => e.node);
const doctor = rawDocs.find((d: any) => d.id === doctorId);
if (!doctor) return { sent: false, message: 'Doctor not found.' };
const rawSlots = doctor.visitSlots?.edges?.map((e: any) => e.node) ?? [];
const daySlots = rawSlots.filter((s: any) => s.dayOfWeek === targetDay);
if (!daySlots.length) {
const dayLabel = targetDay.charAt(0) + targetDay.slice(1).toLowerCase();
return { sent: false, message: `${doctorName} is not available on ${dayLabel} (${targetDate}).` };
}
const timeSlots: { time: string; clinic: string }[] = [];
for (const ds of daySlots) {
const startHour = parseInt(ds.startTime?.split(':')[0] ?? '9', 10);
const endHour = parseInt(ds.endTime?.split(':')[0] ?? '17', 10);
const clinicName = ds.clinic?.clinicName ?? '';
for (let h = startHour; h < endHour && timeSlots.length < 10; h++) {
timeSlots.push({ time: `${String(h).padStart(2, '0')}:00`, clinic: clinicName });
}
}
if (!timeSlots.length) return { sent: false, message: `No slots for ${doctorName} on ${targetDate}.` };
const sections: ListSection[] = [{
title: targetDate,
rows: timeSlots.map(s => ({
id: `slot:${doctorId}:${targetDate}T${s.time}:00+05:30`,
title: s.time,
description: s.clinic || undefined,
})),
}];
await ctx.provider.sendList(ctx.phone, `Available slots for ${doctorName}:`, 'View Slots', sections);
return { sent: true, slots: timeSlots.length };
});
this.register('send_confirm_buttons', async (inputs, ctx) => {
const buttons: InteractiveButton[] = [
{ id: 'confirm_booking', title: 'Confirm' },
{ id: 'cancel_booking', title: 'Cancel' },
];
await ctx.provider.sendButtons(ctx.phone, inputs.summary, buttons);
return { sent: true };
});
this.register('book_appointment', async (inputs, ctx) => {
const { patientName, phoneNumber, department, doctorName, scheduledAt, reason } = inputs;
const cleanPhone = (phoneNumber ?? ctx.phone).replace(/[^0-9]/g, '').slice(-10);
// Conflict check
const bookingDate = scheduledAt.split('T')[0];
const existingAppts = await this.platform.query<any>(
`{ appointments(first: 50, filter: { doctorName: { eq: "${doctorName}" } }, orderBy: [{ scheduledAt: AscNullsLast }]) { edges { node { id scheduledAt status patientName } } } }`,
).catch(() => ({ appointments: { edges: [] } }));
const conflicts = existingAppts.appointments.edges
.map((e: any) => e.node)
.filter((a: any) => a.status === 'SCHEDULED' && a.scheduledAt?.startsWith(bookingDate));
const slotConflicts = conflicts.filter((a: any) => a.scheduledAt === scheduledAt);
if (slotConflicts.length >= 3) {
return { booked: false, message: `${doctorName} is fully booked at this time.` };
}
// Resolve caller — creates lead/patient if new
const resolved = await this.caller.resolve(cleanPhone, ctx.auth).catch(() => null);
let patientId = resolved?.patientId;
if (resolved?.isNew && patientName) {
const firstName = patientName.split(' ')[0];
const lastName = patientName.split(' ').slice(1).join(' ') || '';
try {
const p = await this.platform.query<any>(
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
{ data: { fullName: { firstName, lastName }, phones: { primaryPhoneNumber: `+91${cleanPhone}` }, patientType: 'NEW' } },
);
patientId = p?.createPatient?.id;
await this.platform.query<any>(
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
{ data: { name: `WhatsApp — ${patientName}`, contactName: { firstName, lastName }, contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` }, source: 'WHATSAPP', status: 'NEW', interestedService: department, ...(patientId ? { patientId } : {}) } },
);
} catch {}
}
// Book — include patientId so appointment is linked to patient record
const result = await this.platform.query<any>(
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
{ data: { name: `WhatsApp Booking — ${patientName} (${department})`, scheduledAt: scheduledAt.includes('+') || scheduledAt.includes('Z') ? scheduledAt : `${scheduledAt}+05:30`, status: 'SCHEDULED', doctorName, department, reasonForVisit: reason ?? 'General Consultation', ...(patientId ? { patientId } : {}) } },
);
const id = result?.createAppointment?.id;
if (id) {
return { booked: true, appointmentId: id, reference: id.substring(0, 8) };
}
return { booked: false, message: 'Booking failed.' };
});
this.register('lookup_appointments', async (inputs, ctx) => {
const resolved = await this.caller.resolve(ctx.phone, ctx.auth).catch(() => null);
if (!resolved?.patientId) return { appointments: [], message: 'No patient record found.' };
const data = await this.platform.query<any>(
`{ appointments(first: 10, filter: { patientId: { eq: "${resolved.patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
id scheduledAt status doctorName department reasonForVisit
} } } }`,
);
return { appointments: data.appointments.edges.map((e: any) => e.node) };
});
this.register('send_appointment_qr', async (inputs, ctx) => {
const { appointmentId, reference, patientName, doctorName, department, scheduledAt } = inputs;
if (!appointmentId) return { sent: false, message: 'No appointment ID.' };
await this.qr.generate(appointmentId, {
reference: reference ?? appointmentId.substring(0, 8),
patientName: patientName ?? '',
doctorName: doctorName ?? '',
department: department ?? '',
scheduledAt: scheduledAt ?? '',
});
const qrUrl = `${this.sidecarUrl}/api/messaging/qr/${appointmentId}`;
await ctx.provider.sendImage(ctx.phone, qrUrl, `Your appointment QR code — show this at the hospital reception desk.`);
this.logger.log(`[TOOL] send_appointment_qr: sent QR for ${reference ?? appointmentId}`);
return { sent: true, qrUrl };
});
}
}

View File

@@ -0,0 +1,41 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import Redis from 'ioredis';
import { ConversationEntry } from './types';
@Injectable()
export class MessagingConversationService {
private readonly logger = new Logger(MessagingConversationService.name);
private readonly redis: Redis;
private readonly ttlSec = 24 * 60 * 60; // 24h — matches WhatsApp session window
private readonly maxHistory = 20;
constructor(config: ConfigService) {
const redisUrl = config.get<string>('redis.url') ?? 'redis://localhost:6379';
this.redis = new Redis(redisUrl);
}
private key(phone: string): string {
return `wa:conv:${phone}`;
}
async getHistory(phone: string): Promise<ConversationEntry[]> {
const raw = await this.redis.get(this.key(phone));
if (!raw) return [];
try {
return JSON.parse(raw);
} catch {
return [];
}
}
async addMessages(phone: string, entries: ConversationEntry[]): Promise<void> {
const existing = await this.getHistory(phone);
const updated = [...existing, ...entries].slice(-this.maxHistory);
await this.redis.setex(this.key(phone), this.ttlSec, JSON.stringify(updated));
}
async clear(phone: string): Promise<void> {
await this.redis.del(this.key(phone));
}
}

View File

@@ -0,0 +1,52 @@
import { Controller, Post, Get, Body, Param, Res, Logger } from '@nestjs/common';
import type { Response } from 'express';
import { MessagingProvider } from './providers/messaging-provider.interface';
import { MessagingService } from './messaging.service';
import { QrService } from './qr.service';
@Controller('api/messaging')
export class MessagingController {
private readonly logger = new Logger(MessagingController.name);
constructor(
private readonly provider: MessagingProvider,
private readonly messaging: MessagingService,
private readonly qr: QrService,
) {}
@Post('webhook')
async webhook(@Body() body: any) {
this.logger.log(`[WA-WEBHOOK] Received: ${JSON.stringify(body).substring(0, 500)}`);
if (!this.provider.validateWebhook(body)) {
this.logger.warn('[WA-WEBHOOK] Validation failed — ignoring');
return { status: 'ignored', reason: 'validation failed' };
}
const message = this.provider.parseInbound(body);
if (!message) {
this.logger.log('[WA-WEBHOOK] Non-message event — skipped');
return { status: 'ok', type: body?.type ?? 'unknown' };
}
// Handle async — don't block webhook response
this.messaging.handleInbound(message).catch(err => {
this.logger.error(`[WA-WEBHOOK] handleInbound failed: ${err.message}`);
});
return { status: 'ok' };
}
// Serve QR code images — Gupshup needs a public URL to send images
@Get('qr/:appointmentId')
async serveQr(@Param('appointmentId') appointmentId: string, @Res() res: Response) {
const png = this.qr.get(appointmentId);
if (!png) {
res.status(404).json({ error: 'QR code not found or expired' });
return;
}
res.set('Content-Type', 'image/png');
res.set('Cache-Control', 'public, max-age=86400');
res.send(png);
}
}

Some files were not shown because too many files have changed in this diff Show More