mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
Compare commits
9 Commits
bbea12185d
...
dev-kartik
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
603ec7c612 | ||
|
|
05eb7a326e | ||
|
|
bb20f5102a | ||
|
|
09c7930b52 | ||
|
|
e912b982df | ||
|
|
c80dddee0f | ||
|
|
bb46549a4d | ||
|
|
33ec8f5db8 | ||
|
|
a1157ab4c1 |
@@ -1,203 +0,0 @@
|
||||
# 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`
|
||||
32
.claudeignore
Normal file
32
.claudeignore
Normal file
@@ -0,0 +1,32 @@
|
||||
# Build outputs
|
||||
dist/
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Lock files
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
|
||||
# Test coverage output
|
||||
coverage/
|
||||
|
||||
# Generated type declarations
|
||||
**/*.d.ts
|
||||
*.tsbuildinfo
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# E2E test fixtures (keep unit tests)
|
||||
test/
|
||||
|
||||
# Environment secrets — never read
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -1,14 +1,3 @@
|
||||
# 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
|
||||
|
||||
# Local data dirs (Redis cache file, setup-state, etc.)
|
||||
data
|
||||
src
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Server
|
||||
PORT=4100
|
||||
CORS_ORIGIN=http://localhost:5173
|
||||
CORS_ORIGINS=http://localhost:5173,http://localhost:8000
|
||||
|
||||
# Fortytwo Platform
|
||||
PLATFORM_GRAPHQL_URL=http://localhost:4000/graphql
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -37,8 +37,3 @@ 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/
|
||||
|
||||
1
.husky/pre-commit
Normal file
1
.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
@@ -1,24 +0,0 @@
|
||||
# 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]
|
||||
57
Dockerfile
57
Dockerfile
@@ -1,58 +1,7 @@
|
||||
# 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
|
||||
|
||||
# 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 ./
|
||||
|
||||
COPY dist ./dist
|
||||
COPY node_modules ./node_modules
|
||||
COPY package.json ./
|
||||
EXPOSE 4100
|
||||
CMD ["node", "dist/main.js"]
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
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.
|
||||
|
||||
**Owner: Karthik**
|
||||
**Owner: Kartik**
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -27,7 +27,7 @@ This server has **no database**. All persistent data flows to/from the FortyTwo
|
||||
| Repo | Purpose | Owner |
|
||||
|------|---------|-------|
|
||||
| `helix-engage` | React frontend | Mouli |
|
||||
| `helix-engage-server` (this) | NestJS sidecar | Karthik |
|
||||
| `helix-engage-server` (this) | NestJS sidecar | Kartik |
|
||||
| `helix-engage-app` | FortyTwo SDK app — entity schemas | Shared |
|
||||
|
||||
## Getting Started
|
||||
|
||||
@@ -1,901 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,270 +0,0 @@
|
||||
# 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)
|
||||
@@ -29,6 +29,16 @@ export default tseslint.config(
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'@typescript-eslint/no-floating-promises': 'warn',
|
||||
'@typescript-eslint/no-unsafe-argument': 'warn',
|
||||
'@typescript-eslint/no-unsafe-assignment': 'warn',
|
||||
'@typescript-eslint/no-unsafe-call': 'warn',
|
||||
'@typescript-eslint/no-unsafe-member-access': 'warn',
|
||||
'@typescript-eslint/no-unsafe-return': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
'@typescript-eslint/no-base-to-string': 'warn',
|
||||
'@typescript-eslint/no-misused-promises': 'warn',
|
||||
'@typescript-eslint/require-await': 'warn',
|
||||
'@typescript-eslint/no-redundant-type-constituents': 'warn',
|
||||
'no-empty': 'warn',
|
||||
"prettier/prettier": ["error", { endOfLine: "auto" }],
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"assets": [
|
||||
{ "include": "messaging/flow/default-flows/*.json", "watchAssets": true }
|
||||
]
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
|
||||
3405
package-lock.json
generated
3405
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
18
package.json
18
package.json
@@ -17,7 +17,8 @@
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.58",
|
||||
@@ -31,14 +32,13 @@
|
||||
"@nestjs/core": "^11.0.1",
|
||||
"@nestjs/platform-express": "^11.0.1",
|
||||
"@nestjs/platform-socket.io": "^11.1.17",
|
||||
"@nestjs/swagger": "^11.2.6",
|
||||
"@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",
|
||||
@@ -58,7 +58,9 @@
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-prettier": "^5.2.2",
|
||||
"globals": "^16.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^30.0.0",
|
||||
"lint-staged": "^16.4.0",
|
||||
"prettier": "^3.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.0.0",
|
||||
@@ -70,6 +72,16 @@
|
||||
"typescript": "^5.7.3",
|
||||
"typescript-eslint": "^8.20.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"src/**/*.ts": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
],
|
||||
"test/**/*.ts": [
|
||||
"eslint --fix",
|
||||
"prettier --write"
|
||||
]
|
||||
},
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
|
||||
6909
pnpm-lock.yaml
generated
Normal file
6909
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,41 +0,0 @@
|
||||
<!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
463
public/widget.js
File diff suppressed because one or more lines are too long
@@ -1,225 +0,0 @@
|
||||
/**
|
||||
* 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, ',
|
||||
};
|
||||
@@ -1,16 +1,11 @@
|
||||
import { Controller, Post, Body, Headers, Req, Res, HttpException, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { Request, Response } from 'express';
|
||||
import { generateText, streamText, Output, tool, stepCountIs } from 'ai';
|
||||
import { generateText, streamText, 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;
|
||||
@@ -28,21 +23,14 @@ export class AiChatController {
|
||||
constructor(
|
||||
private config: ConfigService,
|
||||
private platform: PlatformGraphqlService,
|
||||
private aiConfig: AiConfigService,
|
||||
private caller: CallerResolutionService,
|
||||
private callerContext: CallerContextService,
|
||||
) {
|
||||
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.aiModel = createAiModel(config);
|
||||
if (!this.aiModel) {
|
||||
this.logger.warn('AI not configured — chat uses fallback');
|
||||
} else {
|
||||
this.logger.log(`AI configured: ${cfg.provider}/${cfg.model}`);
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,20 +87,16 @@ export class AiChatController {
|
||||
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);
|
||||
// Inject caller context so the AI knows who is selected
|
||||
if (ctx) {
|
||||
const parts: string[] = [];
|
||||
if (ctx.leadName) parts.push(`Currently viewing/talking to: ${ctx.leadName}`);
|
||||
if (ctx.callerPhone) parts.push(`Phone: ${ctx.callerPhone}`);
|
||||
if (ctx.leadId) parts.push(`Lead ID: ${ctx.leadId}`);
|
||||
if (parts.length) {
|
||||
systemPrompt += `\n\nCURRENT CONTEXT:\n${parts.join('\n')}\nUse this context to answer questions about "this patient" or "this caller" without asking for their name.`;
|
||||
}
|
||||
}
|
||||
} else if (ctx?.callerPhone) {
|
||||
systemPrompt += `\n\nCURRENT CONTEXT:\nCaller phone: ${ctx.callerPhone}\nNew caller — no prior records.`;
|
||||
}
|
||||
}
|
||||
|
||||
const platformService = this.platform;
|
||||
@@ -136,13 +120,7 @@ export class AiChatController {
|
||||
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 } } } }`,
|
||||
`{ agents(first: 20) { edges { node { id name ozonetelagentid npsscore maxidleminutes minnpsthreshold minconversionpercent } } } }`,
|
||||
undefined, auth,
|
||||
),
|
||||
platformService.queryWithAuth<any>(
|
||||
@@ -159,7 +137,7 @@ export class AiChatController {
|
||||
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 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;
|
||||
@@ -178,12 +156,12 @@ export class AiChatController {
|
||||
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,
|
||||
npsScore: agent.npsscore,
|
||||
maxIdleMinutes: agent.maxidleminutes,
|
||||
minNpsThreshold: agent.minnpsthreshold,
|
||||
minConversionPercent: agent.minconversionpercent,
|
||||
belowNpsThreshold: agent.minnpsthreshold && (agent.npsscore ?? 100) < agent.minnpsthreshold,
|
||||
belowConversionThreshold: agent.minconversionpercent && conversionRate < agent.minconversionpercent,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -280,7 +258,7 @@ export class AiChatController {
|
||||
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 } } } }`,
|
||||
`{ 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
|
||||
@@ -302,54 +280,6 @@ export class AiChatController {
|
||||
};
|
||||
|
||||
// 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.',
|
||||
@@ -384,32 +314,24 @@ export class AiChatController {
|
||||
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.',
|
||||
description: 'Get appointments for a patient. Returns doctor, department, date, status.',
|
||||
inputSchema: z.object({
|
||||
patientId: z.string().optional().describe('Patient ID (omit when asking about the current caller)'),
|
||||
patientId: z.string().describe('Patient ID'),
|
||||
}),
|
||||
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 {
|
||||
`{ appointments(first: 20, filter: { patientId: { eq: "${patientId}" } }, 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 };
|
||||
return { appointments: data.appointments.edges.map((e: any) => e.node) };
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -422,13 +344,13 @@ export class AiChatController {
|
||||
const data = await platformService.queryWithAuth<any>(
|
||||
`{ doctors(first: 10) { edges { node {
|
||||
id fullName { firstName lastName }
|
||||
department specialty
|
||||
department specialty visitingHours
|
||||
consultationFeeNew { amountMicros currencyCode }
|
||||
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||
clinic { clinicName }
|
||||
} } } }`,
|
||||
undefined, auth,
|
||||
);
|
||||
const doctors = normalizeDoctors(data.doctors.edges.map((e: any) => e.node));
|
||||
const doctors = 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+/);
|
||||
@@ -438,25 +360,24 @@ export class AiChatController {
|
||||
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 });
|
||||
this.logger.log(`[TOOL] lookup_doctor: search="${doctorName}" → ${matched.length} results`);
|
||||
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.',
|
||||
description: 'Book an appointment for a patient. Collect patient name, phone, department, doctor, 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) });
|
||||
execute: async ({ patientName, phoneNumber, department, doctorName, scheduledAt, reason }) => {
|
||||
this.logger.log(`[TOOL] book_appointment: ${patientName} | ${phoneNumber} | ${department} | ${doctorName} | ${scheduledAt}`);
|
||||
try {
|
||||
const result = await platformService.queryWithAuth<any>(
|
||||
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
||||
@@ -468,20 +389,17 @@ export class AiChatController {
|
||||
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}`);
|
||||
this.logger.error(`[TOOL] book_appointment failed: ${err.message}`);
|
||||
return { booked: false, message: `Failed to book: ${err.message}` };
|
||||
}
|
||||
},
|
||||
@@ -495,131 +413,51 @@ export class AiChatController {
|
||||
interest: z.string().describe('What they are enquiring about'),
|
||||
}),
|
||||
execute: async ({ name, phoneNumber, interest }) => {
|
||||
toolLog('create_lead', { name, phoneNumber, interest: interest?.slice(0, 40) }, {});
|
||||
this.logger.log(`[TOOL] create_lead: ${name} | ${phoneNumber} | ${interest}`);
|
||||
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>(
|
||||
const result = await platformService.queryWithAuth<any>(
|
||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
name: `AI Enquiry — ${name}`,
|
||||
contactName: { firstName, lastName },
|
||||
contactName: {
|
||||
firstName: name.split(' ')[0],
|
||||
lastName: name.split(' ').slice(1).join(' ') || '',
|
||||
},
|
||||
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
||||
source: 'PHONE',
|
||||
status: 'NEW',
|
||||
interestedService: interest,
|
||||
...(patientId ? { patientId } : {}),
|
||||
},
|
||||
},
|
||||
auth,
|
||||
);
|
||||
const id = created?.createLead?.id;
|
||||
const id = result?.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}`);
|
||||
this.logger.error(`[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.',
|
||||
description: 'Get call log for a lead — all inbound/outbound calls with dispositions and durations.',
|
||||
inputSchema: z.object({
|
||||
leadId: z.string().optional().describe('Lead ID (omit when asking about the current caller)'),
|
||||
leadId: z.string().describe('Lead ID'),
|
||||
}),
|
||||
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 {
|
||||
`{ calls(first: 20, filter: { leadId: { eq: "${leadId}" } }, 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 };
|
||||
return { calls: data.calls.edges.map((e: any) => e.node) };
|
||||
},
|
||||
}),
|
||||
};
|
||||
@@ -630,7 +468,6 @@ export class AiChatController {
|
||||
messages,
|
||||
stopWhen: stepCountIs(5),
|
||||
tools: isSupervisor ? supervisorTools : agentTools,
|
||||
...(isSupervisor ? {} : { output: Output.object({ schema: aiResponseSchema }) }),
|
||||
});
|
||||
|
||||
const response = result.toTextStreamResponse();
|
||||
@@ -666,23 +503,16 @@ export class AiChatController {
|
||||
`{ clinics(first: 20) { edges { node {
|
||||
id name clinicName
|
||||
addressCustom { addressStreet1 addressCity addressState addressPostcode }
|
||||
openMonday openTuesday openWednesday openThursday openFriday openSaturday openSunday
|
||||
opensAt closesAt
|
||||
weekdayHours saturdayHours sundayHours
|
||||
status walkInAllowed onlineBooking
|
||||
cancellationWindowHours arriveEarlyMin
|
||||
cancellationWindowHours arriveEarlyMin requiredDocuments
|
||||
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 & 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
|
||||
@@ -690,15 +520,9 @@ export class AiChatController {
|
||||
: '';
|
||||
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.weekdayHours) sections.push(` Mon–Fri: ${c.weekdayHours}`);
|
||||
if (c.saturdayHours) sections.push(` Saturday: ${c.saturdayHours}`);
|
||||
sections.push(` Sunday: ${c.sundayHours ?? 'Closed'}`);
|
||||
if (c.walkInAllowed) sections.push(` Walk-ins: Accepted`);
|
||||
}
|
||||
|
||||
@@ -706,8 +530,7 @@ export class AiChatController {
|
||||
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`);
|
||||
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.requiredDocuments) rules.push(`First-time patients bring ${rulesClinic.requiredDocuments}`);
|
||||
if (rulesClinic.walkInAllowed) rules.push('Walk-ins accepted');
|
||||
if (rulesClinic.onlineBooking) rules.push('Online booking available');
|
||||
if (rules.length) {
|
||||
@@ -733,28 +556,25 @@ export class AiChatController {
|
||||
try {
|
||||
const docData = await this.platform.queryWithAuth<any>(
|
||||
`{ doctors(first: 20) { edges { node {
|
||||
id fullName { firstName lastName } department specialty
|
||||
fullName { firstName lastName } department specialty visitingHours
|
||||
consultationFeeNew { amountMicros currencyCode }
|
||||
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||
clinic { clinicName }
|
||||
} } } }`,
|
||||
undefined, auth,
|
||||
);
|
||||
const doctors = normalizeDoctors(docData.doctors.edges.map((e: any) => e.node));
|
||||
const doctors = 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(', ');
|
||||
const clinic = d.clinic?.clinicName ?? '';
|
||||
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}`);
|
||||
if (clinic) sections.push(` Clinic: ${clinic}`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -773,8 +593,8 @@ export class AiChatController {
|
||||
undefined, auth,
|
||||
);
|
||||
const packages = pkgData.healthPackages.edges.map((e: any) => e.node);
|
||||
sections.push('\n## Health Packages');
|
||||
if (packages.length) {
|
||||
sections.push('\n## Health Packages');
|
||||
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})` : '';
|
||||
@@ -791,8 +611,6 @@ 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}`);
|
||||
@@ -827,15 +645,24 @@ export class AiChatController {
|
||||
}
|
||||
|
||||
private buildSupervisorSystemPrompt(): string {
|
||||
return this.aiConfig.renderPrompt('supervisorChat', {
|
||||
hospitalName: this.getHospitalName(),
|
||||
});
|
||||
}
|
||||
return `You are an AI assistant for supervisors at Global Hospital's call center (Helix Engage).
|
||||
You help supervisors monitor team performance, identify issues, and make data-driven decisions.
|
||||
|
||||
// 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';
|
||||
## 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.`;
|
||||
}
|
||||
|
||||
private buildRulesSystemPrompt(currentConfig: any): string {
|
||||
@@ -885,10 +712,25 @@ ${configJson}
|
||||
}
|
||||
|
||||
private buildSystemPrompt(kb: string): string {
|
||||
return this.aiConfig.renderPrompt('ccAgentHelper', {
|
||||
hospitalName: this.getHospitalName(),
|
||||
knowledgeBase: kb,
|
||||
});
|
||||
return `You are an AI assistant for call center agents at Global Hospital, Bangalore.
|
||||
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.
|
||||
Example: "What are the Koramangala timings?" → Look for "Koramangala" in the Clinics section below.
|
||||
|
||||
RULES:
|
||||
1. For patient-specific questions (history, appointments, calls), use the lookup tools. NEVER guess patient data.
|
||||
2. For doctor details beyond what's in the KB, use the lookup_doctor tool.
|
||||
3. For clinic info, timings, packages, insurance → answer directly from the knowledge base below.
|
||||
4. If you truly cannot find the answer in the KB or via tools, 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. Format with bullet points for easy scanning.
|
||||
|
||||
KNOWLEDGE BASE (this is real data from our system):
|
||||
${kb}`;
|
||||
}
|
||||
|
||||
private async chatWithTools(userMessage: string, auth: string) {
|
||||
@@ -1002,15 +844,16 @@ ${configJson}
|
||||
`{ doctors(first: 10) { edges { node {
|
||||
id name fullName { firstName lastName }
|
||||
department specialty qualifications yearsOfExperience
|
||||
visitingHours
|
||||
consultationFeeNew { amountMicros currencyCode }
|
||||
consultationFeeFollowUp { amountMicros currencyCode }
|
||||
active registrationNumber
|
||||
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||
clinic { id name clinicName }
|
||||
} } } }`,
|
||||
undefined, auth,
|
||||
);
|
||||
|
||||
const doctors = normalizeDoctors(data.doctors.edges.map((e: any) => e.node));
|
||||
const doctors = 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();
|
||||
@@ -1023,13 +866,7 @@ ${configJson}
|
||||
found: true,
|
||||
doctors: matched.map((d: any) => ({
|
||||
...d,
|
||||
// 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',
|
||||
clinicName: d.clinic?.clinicName ?? d.clinic?.name ?? 'N/A',
|
||||
feeNewFormatted: d.consultationFeeNew ? `₹${d.consultationFeeNew.amountMicros / 1_000_000}` : 'N/A',
|
||||
feeFollowUpFormatted: d.consultationFeeFollowUp ? `₹${d.consultationFeeFollowUp.amountMicros / 1_000_000}` : 'N/A',
|
||||
})),
|
||||
@@ -1053,13 +890,13 @@ ${configJson}
|
||||
try {
|
||||
const doctors = await this.platform.queryWithAuth<any>(
|
||||
`{ doctors(first: 10) { edges { node {
|
||||
id name fullName { firstName lastName } department specialty
|
||||
name fullName { firstName lastName } department specialty visitingHours
|
||||
consultationFeeNew { amountMicros currencyCode }
|
||||
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||
clinic { name clinicName }
|
||||
} } } }`,
|
||||
undefined, auth,
|
||||
);
|
||||
const docs = normalizeDoctors(doctors.doctors.edges.map((e: any) => e.node));
|
||||
const docs = doctors.doctors.edges.map((e: any) => e.node);
|
||||
const l = msg.toLowerCase();
|
||||
|
||||
const matchedDoc = docs.find((d: any) => {
|
||||
@@ -1069,7 +906,7 @@ ${configJson}
|
||||
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')) {
|
||||
|
||||
@@ -4,7 +4,6 @@ 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;
|
||||
@@ -24,8 +23,12 @@ type EnrichmentResult = {
|
||||
};
|
||||
|
||||
const enrichmentSchema = z.object({
|
||||
aiSummary: z.string().describe('1-2 sentence summary of who this lead is and their history'),
|
||||
aiSuggestedAction: z.string().describe('5-10 word suggested action for the agent'),
|
||||
aiSummary: z
|
||||
.string()
|
||||
.describe('1-2 sentence summary of who this lead is and their history'),
|
||||
aiSuggestedAction: z
|
||||
.string()
|
||||
.describe('5-10 word suggested action for the agent'),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
@@ -33,17 +36,8 @@ export class AiEnrichmentService {
|
||||
private readonly logger = new Logger(AiEnrichmentService.name);
|
||||
private readonly aiModel: LanguageModel | null;
|
||||
|
||||
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'),
|
||||
});
|
||||
constructor(private config: ConfigService) {
|
||||
this.aiModel = createAiModel(config);
|
||||
if (!this.aiModel) {
|
||||
this.logger.warn('AI not configured — enrichment uses fallback');
|
||||
}
|
||||
@@ -56,28 +50,39 @@ export class AiEnrichmentService {
|
||||
|
||||
try {
|
||||
const daysSince = lead.createdAt
|
||||
? Math.floor((Date.now() - new Date(lead.createdAt).getTime()) / (1000 * 60 * 60 * 24))
|
||||
? Math.floor(
|
||||
(Date.now() - new Date(lead.createdAt).getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
)
|
||||
: 0;
|
||||
|
||||
const activitiesText = lead.activities?.length
|
||||
? lead.activities.map(a => `- ${a.activityType}: ${a.summary}`).join('\n')
|
||||
? lead.activities
|
||||
.map((a) => `- ${a.activityType}: ${a.summary}`)
|
||||
.join('\n')
|
||||
: 'No previous interactions';
|
||||
|
||||
const { object } = await generateObject({
|
||||
model: this.aiModel!,
|
||||
model: this.aiModel,
|
||||
schema: enrichmentSchema,
|
||||
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,
|
||||
}),
|
||||
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}`,
|
||||
});
|
||||
|
||||
this.logger.log(`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`);
|
||||
this.logger.log(
|
||||
`AI enrichment generated for lead ${lead.firstName} ${lead.lastName}`,
|
||||
);
|
||||
return object;
|
||||
} catch (error) {
|
||||
this.logger.error(`AI enrichment failed: ${error}`);
|
||||
@@ -87,12 +92,16 @@ export class AiEnrichmentService {
|
||||
|
||||
private fallbackEnrichment(lead: LeadContext): EnrichmentResult {
|
||||
const daysSince = lead.createdAt
|
||||
? Math.floor((Date.now() - new Date(lead.createdAt).getTime()) / (1000 * 60 * 60 * 24))
|
||||
? Math.floor(
|
||||
(Date.now() - new Date(lead.createdAt).getTime()) /
|
||||
(1000 * 60 * 60 * 24),
|
||||
)
|
||||
: 0;
|
||||
|
||||
const attempts = lead.contactAttempts ?? 0;
|
||||
const service = lead.interestedService ?? 'general inquiry';
|
||||
const source = lead.leadSource?.replace(/_/g, ' ').toLowerCase() ?? 'unknown source';
|
||||
const source =
|
||||
lead.leadSource?.replace(/_/g, ' ').toLowerCase() ?? 'unknown source';
|
||||
|
||||
let summary: string;
|
||||
let action: string;
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { anthropic } from '@ai-sdk/anthropic';
|
||||
import { openai } from '@ai-sdk/openai';
|
||||
import type { LanguageModel } from 'ai';
|
||||
|
||||
// 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.
|
||||
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';
|
||||
|
||||
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);
|
||||
if (provider === 'anthropic') {
|
||||
const apiKey = config.get<string>('ai.anthropicApiKey');
|
||||
if (!apiKey) return null;
|
||||
return anthropic(model);
|
||||
}
|
||||
|
||||
// Default to openai
|
||||
if (!opts.openaiApiKey) return null;
|
||||
return openai(opts.model);
|
||||
const apiKey = config.get<string>('ai.openaiApiKey');
|
||||
if (!apiKey) return null;
|
||||
return openai(model);
|
||||
}
|
||||
|
||||
export function isAiConfigured(opts: AiProviderOpts): boolean {
|
||||
if (opts.provider === 'anthropic') return !!opts.anthropicApiKey;
|
||||
return !!opts.openaiApiKey;
|
||||
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');
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
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>;
|
||||
@@ -1,11 +1,10 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { Module } 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, forwardRef(() => CallerResolutionModule)],
|
||||
imports: [PlatformModule],
|
||||
controllers: [AiChatController],
|
||||
providers: [AiEnrichmentService],
|
||||
exports: [AiEnrichmentService],
|
||||
|
||||
@@ -19,12 +19,6 @@ 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: [
|
||||
@@ -50,12 +44,6 @@ import { TelephonyRegistrationService } from './telephony-registration.service';
|
||||
CallerResolutionModule,
|
||||
RulesEngineModule,
|
||||
ConfigThemeModule,
|
||||
WidgetModule,
|
||||
TeamModule,
|
||||
MasterdataModule,
|
||||
LeadsModule,
|
||||
MessagingModule,
|
||||
],
|
||||
providers: [TelephonyRegistrationService],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||
|
||||
export type AgentConfig = {
|
||||
id: string;
|
||||
@@ -16,24 +16,18 @@ export type AgentConfig = {
|
||||
export class AgentConfigService {
|
||||
private readonly logger = new Logger(AgentConfigService.name);
|
||||
private readonly cache = new Map<string, AgentConfig>();
|
||||
private readonly sipDomain: string;
|
||||
private readonly sipWsPort: string;
|
||||
|
||||
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 || '';
|
||||
private config: ConfigService,
|
||||
) {
|
||||
this.sipDomain = config.get<string>(
|
||||
'sip.domain',
|
||||
'blr-pub-rtc4.ozonetel.com',
|
||||
);
|
||||
this.sipWsPort = config.get<string>('sip.wsPort', '444');
|
||||
}
|
||||
|
||||
async getByMemberId(memberId: string): Promise<AgentConfig | null> {
|
||||
@@ -41,34 +35,32 @@ export class AgentConfigService {
|
||||
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
|
||||
`{ agents(first: 1, filter: { wsmemberId: { eq: "${memberId}" } }) { edges { node {
|
||||
id ozonetelagentid sipextension sippassword campaignname
|
||||
} } } }`,
|
||||
);
|
||||
|
||||
const node = data?.agents?.edges?.[0]?.node;
|
||||
if (!node || !node.ozonetelAgentId || !node.sipExtension) return null;
|
||||
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}`,
|
||||
ozonetelAgentId: node.ozonetelagentid,
|
||||
sipExtension: node.sipextension,
|
||||
sipPassword: node.sippassword ?? node.sipextension,
|
||||
campaignName:
|
||||
node.campaignname ??
|
||||
process.env.OZONETEL_CAMPAIGN_NAME ??
|
||||
'Inbound_918041763265',
|
||||
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}`);
|
||||
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}`);
|
||||
|
||||
@@ -5,7 +5,6 @@ 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 {
|
||||
@@ -19,7 +18,6 @@ export class AuthController {
|
||||
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';
|
||||
@@ -107,9 +105,11 @@ export class AuthController {
|
||||
|
||||
// Determine app role from platform roles
|
||||
let appRole = 'executive'; // default
|
||||
if (roleLabels.includes('HelixEngage Manager') || roleLabels.includes('HelixEngage Supervisor')) {
|
||||
if (roleLabels.includes('HelixEngage Manager')) {
|
||||
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';
|
||||
}
|
||||
@@ -138,9 +138,10 @@ export class AuthController {
|
||||
this.logger.warn(`Ozonetel token refresh on login failed: ${err.message}`);
|
||||
});
|
||||
|
||||
const ozAgentPassword = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
|
||||
this.ozonetelAgent.loginAgent({
|
||||
agentId: agentConfig.ozonetelAgentId,
|
||||
password: agentConfig.sipPassword,
|
||||
password: ozAgentPassword,
|
||||
phoneNumber: agentConfig.sipExtension,
|
||||
mode: 'blended',
|
||||
}).catch(err => {
|
||||
@@ -249,14 +250,9 @@ export class AuthController {
|
||||
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({
|
||||
this.ozonetelAgent.logoutAgent({
|
||||
agentId: agentConfig.ozonetelAgentId,
|
||||
password: agentConfig.sipPassword,
|
||||
password: process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$',
|
||||
}).catch(err => this.logger.warn(`Ozonetel logout failed: ${err.message}`));
|
||||
|
||||
this.agentConfigService.clearCache(memberId);
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
const SESSION_TTL = 3600; // 1 hour
|
||||
|
||||
@Injectable()
|
||||
export class SessionService {
|
||||
export class SessionService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SessionService.name);
|
||||
private readonly redis: Redis;
|
||||
private 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) {
|
||||
constructor(private config: ConfigService) {}
|
||||
|
||||
onModuleInit() {
|
||||
const url = this.config.get<string>('redis.url', 'redis://localhost:6379');
|
||||
this.redis = new Redis(url, { lazyConnect: false });
|
||||
this.redis = new Redis(url);
|
||||
this.redis.on('connect', () => this.logger.log('Redis connected'));
|
||||
this.redis.on('error', (err) => this.logger.error(`Redis error: ${err.message}`));
|
||||
}
|
||||
@@ -55,26 +51,6 @@ export class SessionService {
|
||||
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);
|
||||
@@ -84,10 +60,6 @@ export class SessionService {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -33,15 +33,20 @@ export class CallAssistGateway implements OnGatewayDisconnect {
|
||||
@SubscribeMessage('call-assist:start')
|
||||
async handleStart(
|
||||
@ConnectedSocket() client: Socket,
|
||||
@MessageBody() data: { ucid: string; leadId?: string; callerPhone?: string },
|
||||
@MessageBody()
|
||||
data: { ucid: string; leadId?: string; callerPhone?: string },
|
||||
) {
|
||||
this.logger.log(`Call assist start: ucid=${data.ucid} lead=${data.leadId ?? 'none'}`);
|
||||
this.logger.log(
|
||||
`Call assist start: ucid=${data.ucid} lead=${data.leadId ?? 'none'}`,
|
||||
);
|
||||
|
||||
const context = await this.callAssist.loadCallContext(
|
||||
data.leadId ?? null,
|
||||
data.callerPhone ?? null,
|
||||
);
|
||||
client.emit('call-assist:context', { context: context.substring(0, 200) + '...' });
|
||||
client.emit('call-assist:context', {
|
||||
context: context.substring(0, 200) + '...',
|
||||
});
|
||||
|
||||
const session: SessionState = {
|
||||
deepgramWs: null,
|
||||
@@ -87,13 +92,18 @@ export class CallAssistGateway implements OnGatewayDisconnect {
|
||||
session.deepgramWs = dgWs;
|
||||
} else {
|
||||
this.logger.warn('DEEPGRAM_API_KEY not set — transcription disabled');
|
||||
client.emit('call-assist:error', { message: 'Transcription not configured' });
|
||||
client.emit('call-assist:error', {
|
||||
message: 'Transcription not configured',
|
||||
});
|
||||
}
|
||||
|
||||
// AI suggestion every 10 seconds
|
||||
session.suggestionTimer = setInterval(async () => {
|
||||
if (!session.transcript.trim()) return;
|
||||
const suggestion = await this.callAssist.getSuggestion(session.transcript, session.context);
|
||||
const suggestion = await this.callAssist.getSuggestion(
|
||||
session.transcript,
|
||||
session.context,
|
||||
);
|
||||
if (suggestion) {
|
||||
client.emit('call-assist:suggestion', { text: suggestion });
|
||||
}
|
||||
@@ -128,7 +138,9 @@ export class CallAssistGateway implements OnGatewayDisconnect {
|
||||
if (session) {
|
||||
if (session.suggestionTimer) clearInterval(session.suggestionTimer);
|
||||
if (session.deepgramWs) {
|
||||
try { session.deepgramWs.close(); } catch {}
|
||||
try {
|
||||
session.deepgramWs.close();
|
||||
} catch {}
|
||||
}
|
||||
this.sessions.delete(clientId);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ 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 {
|
||||
@@ -16,20 +14,18 @@ export class CallAssistService {
|
||||
constructor(
|
||||
private config: ConfigService,
|
||||
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'),
|
||||
});
|
||||
this.aiModel = createAiModel(config);
|
||||
this.platformApiKey = config.get<string>('platform.apiKey') ?? '';
|
||||
}
|
||||
|
||||
async loadCallContext(leadId: string | null, callerPhone: string | null): Promise<string> {
|
||||
const authHeader = this.platformApiKey ? `Bearer ${this.platformApiKey}` : '';
|
||||
async loadCallContext(
|
||||
leadId: string | null,
|
||||
callerPhone: string | null,
|
||||
): Promise<string> {
|
||||
const authHeader = this.platformApiKey
|
||||
? `Bearer ${this.platformApiKey}`
|
||||
: '';
|
||||
if (!authHeader) return 'No platform context available.';
|
||||
|
||||
try {
|
||||
@@ -44,7 +40,8 @@ export class CallAssistService {
|
||||
lastContacted contactAttempts
|
||||
aiSummary aiSuggestedAction
|
||||
} } } }`,
|
||||
undefined, authHeader,
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
const lead = leadResult.leads.edges[0]?.node;
|
||||
if (lead) {
|
||||
@@ -52,9 +49,13 @@ export class CallAssistService {
|
||||
? `${lead.contactName.firstName} ${lead.contactName.lastName}`.trim()
|
||||
: lead.name;
|
||||
parts.push(`CALLER: ${name}`);
|
||||
parts.push(`Phone: ${lead.contactPhone?.primaryPhoneNumber ?? callerPhone}`);
|
||||
parts.push(
|
||||
`Phone: ${lead.contactPhone?.primaryPhoneNumber ?? callerPhone}`,
|
||||
);
|
||||
parts.push(`Source: ${lead.source ?? 'Unknown'}`);
|
||||
parts.push(`Interested in: ${lead.interestedService ?? 'Not specified'}`);
|
||||
parts.push(
|
||||
`Interested in: ${lead.interestedService ?? 'Not specified'}`,
|
||||
);
|
||||
parts.push(`Contact attempts: ${lead.contactAttempts ?? 0}`);
|
||||
if (lead.aiSummary) parts.push(`AI Summary: ${lead.aiSummary}`);
|
||||
}
|
||||
@@ -63,7 +64,8 @@ export class CallAssistService {
|
||||
`{ appointments(first: 10, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||
id scheduledAt status doctorName department reasonForVisit patientId
|
||||
} } } }`,
|
||||
undefined, authHeader,
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
const appts = apptResult.appointments.edges
|
||||
.map((e: any) => e.node)
|
||||
@@ -71,8 +73,12 @@ export class CallAssistService {
|
||||
if (appts.length > 0) {
|
||||
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.status}`);
|
||||
const date = a.scheduledAt
|
||||
? new Date(a.scheduledAt).toLocaleDateString('en-IN')
|
||||
: '?';
|
||||
parts.push(
|
||||
`- ${date}: ${a.doctorName ?? '?'} (${a.department ?? '?'}) — ${a.status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (callerPhone) {
|
||||
@@ -82,24 +88,21 @@ export class CallAssistService {
|
||||
|
||||
const docResult = await this.platform.queryWithAuth<any>(
|
||||
`{ doctors(first: 20) { edges { node {
|
||||
id fullName { firstName lastName } department specialty
|
||||
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||
fullName { firstName lastName } department specialty clinic { clinicName }
|
||||
} } } }`,
|
||||
undefined, authHeader,
|
||||
undefined,
|
||||
authHeader,
|
||||
);
|
||||
const docs = normalizeDoctors(docResult.doctors.edges.map((e: any) => e.node));
|
||||
const docs = 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';
|
||||
// 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}`);
|
||||
const name = d.fullName
|
||||
? `Dr. ${d.fullName.firstName} ${d.fullName.lastName}`.trim()
|
||||
: 'Unknown';
|
||||
parts.push(
|
||||
`- ${name} — ${d.department ?? '?'} — ${d.clinic?.clinicName ?? '?'}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,10 +119,18 @@ export class CallAssistService {
|
||||
try {
|
||||
const { text } = await generateText({
|
||||
model: this.aiModel,
|
||||
system: this.aiConfig.renderPrompt('callAssist', {
|
||||
hospitalName: process.env.HOSPITAL_NAME ?? 'the hospital',
|
||||
context,
|
||||
}),
|
||||
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`,
|
||||
prompt: `Conversation transcript so far:\n${transcript}\n\nProvide a brief suggestion for the agent based on what was just said.`,
|
||||
maxOutputTokens: 150,
|
||||
});
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
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({
|
||||
// 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],
|
||||
imports: [PlatformModule, AiModule],
|
||||
controllers: [CallLookupController],
|
||||
providers: [CallEventsService, CallEventsGateway],
|
||||
exports: [CallEventsService, CallEventsGateway],
|
||||
})
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Controller, Post, Body, Logger, Headers, HttpException } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Body,
|
||||
Logger,
|
||||
Headers,
|
||||
HttpException,
|
||||
} from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { AiEnrichmentService } from '../ai/ai-enrichment.service';
|
||||
|
||||
@@ -33,11 +40,17 @@ export class CallLookupController {
|
||||
}
|
||||
|
||||
if (lead) {
|
||||
this.logger.log(`Matched lead: ${lead.id} — ${lead.contactName?.firstName} ${lead.contactName?.lastName}`);
|
||||
this.logger.log(
|
||||
`Matched lead: ${lead.id} — ${lead.contactName?.firstName} ${lead.contactName?.lastName}`,
|
||||
);
|
||||
|
||||
// Get recent activities
|
||||
try {
|
||||
activities = await this.platform.getLeadActivitiesWithToken(lead.id, authHeader, 5);
|
||||
activities = await this.platform.getLeadActivitiesWithToken(
|
||||
lead.id,
|
||||
authHeader,
|
||||
5,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Activity fetch failed: ${err}`);
|
||||
}
|
||||
@@ -64,10 +77,14 @@ export class CallLookupController {
|
||||
|
||||
// Persist AI enrichment back to platform
|
||||
try {
|
||||
await this.platform.updateLeadWithToken(lead.id, {
|
||||
await this.platform.updateLeadWithToken(
|
||||
lead.id,
|
||||
{
|
||||
aiSummary: enrichment.aiSummary,
|
||||
aiSuggestedAction: enrichment.aiSuggestedAction,
|
||||
}, authHeader);
|
||||
},
|
||||
authHeader,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to persist AI enrichment: ${err}`);
|
||||
}
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,249 +0,0 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
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,
|
||||
) {}
|
||||
constructor(private readonly resolution: CallerResolutionService) {}
|
||||
|
||||
@Post('resolve')
|
||||
async resolve(
|
||||
@@ -25,21 +21,16 @@ export class CallerResolutionController {
|
||||
|
||||
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);
|
||||
@Post('invalidate')
|
||||
async invalidate(@Body('phone') phone: string) {
|
||||
if (!phone) {
|
||||
throw new HttpException('phone is required', HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
await this.callerContext.invalidateCache(leadId);
|
||||
this.logger.log(`[RESOLVE] Invalidating cache for: ${phone}`);
|
||||
await this.resolution.invalidate(phone);
|
||||
return { status: 'ok' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { Module } 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)],
|
||||
imports: [PlatformModule, AuthModule],
|
||||
controllers: [CallerResolutionController],
|
||||
providers: [CallerResolutionService, CallerContextService],
|
||||
exports: [CallerResolutionService, CallerContextService],
|
||||
providers: [CallerResolutionService],
|
||||
exports: [CallerResolutionService],
|
||||
})
|
||||
export class CallerResolutionModule {}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { SessionService } from '../auth/session.service';
|
||||
|
||||
const CACHE_TTL = 3600; // 1 hour
|
||||
const CACHE_PREFIX = 'caller:';
|
||||
|
||||
export type ResolvedCaller = {
|
||||
leadId: string;
|
||||
@@ -7,7 +11,7 @@ export type ResolvedCaller = {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
phone: string;
|
||||
isNew: boolean; // true if no Lead/Patient exists for this phone
|
||||
isNew: boolean; // true if we just created the lead+patient pair
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
@@ -16,24 +20,28 @@ export class CallerResolutionService {
|
||||
|
||||
constructor(
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly cache: SessionService,
|
||||
) {}
|
||||
|
||||
// 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.
|
||||
// Resolve a caller by phone number. Always returns a paired lead + patient.
|
||||
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),
|
||||
]);
|
||||
// 1. Check cache
|
||||
const cached = await this.cache.getCache(`${CACHE_PREFIX}${normalized}`);
|
||||
if (cached) {
|
||||
this.logger.log(`[RESOLVE] Cache hit for ${normalized}`);
|
||||
return JSON.parse(cached);
|
||||
}
|
||||
|
||||
// 2. Look up lead by phone
|
||||
const lead = await this.findLeadByPhone(normalized, auth);
|
||||
|
||||
// 3. Look up patient by phone
|
||||
const patient = await this.findPatientByPhone(normalized, auth);
|
||||
|
||||
let result: ResolvedCaller;
|
||||
|
||||
@@ -43,11 +51,6 @@ export class CallerResolutionService {
|
||||
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,
|
||||
@@ -73,9 +76,6 @@ export class CallerResolutionService {
|
||||
// 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,
|
||||
@@ -85,18 +85,13 @@ export class CallerResolutionService {
|
||||
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`);
|
||||
// Neither exists — create both
|
||||
const newPatient = await this.createPatient('', '', normalized, auth);
|
||||
const newLead = await this.createLead('', '', normalized, newPatient.id, auth);
|
||||
this.logger.log(`[RESOLVE] Created new lead ${newLead.id} + patient ${newPatient.id} for ${normalized}`);
|
||||
result = {
|
||||
leadId: '',
|
||||
patientId: '',
|
||||
leadId: newLead.id,
|
||||
patientId: newPatient.id,
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
phone: normalized,
|
||||
@@ -104,30 +99,43 @@ export class CallerResolutionService {
|
||||
};
|
||||
}
|
||||
|
||||
// 4. Cache the result
|
||||
await this.cache.setCache(`${CACHE_PREFIX}${normalized}`, JSON.stringify(result), CACHE_TTL);
|
||||
|
||||
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.
|
||||
// Invalidate cache for a phone number (call after updates)
|
||||
async invalidate(phone: string): Promise<void> {
|
||||
const normalized = phone.replace(/\D/g, '').slice(-10);
|
||||
await this.cache.setCache(`${CACHE_PREFIX}${normalized}`, '', 1); // expire immediately
|
||||
}
|
||||
|
||||
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 {
|
||||
`{ leads(first: 200) { edges { node {
|
||||
id
|
||||
contactName { firstName lastName }
|
||||
contactPhone { primaryPhoneNumber }
|
||||
patientId
|
||||
} } } }`,
|
||||
undefined,
|
||||
auth,
|
||||
);
|
||||
const match = data.leads.edges[0]?.node;
|
||||
|
||||
const match = data.leads.edges.find(e => {
|
||||
const num = (e.node.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, '').slice(-10);
|
||||
return num.length >= 10 && num === phone10;
|
||||
});
|
||||
|
||||
if (!match) return null;
|
||||
|
||||
return {
|
||||
id: match.id,
|
||||
firstName: match.contactName?.firstName ?? '',
|
||||
lastName: match.contactName?.lastName ?? '',
|
||||
patientId: match.patientId || null,
|
||||
id: match.node.id,
|
||||
firstName: match.node.contactName?.firstName ?? '',
|
||||
lastName: match.node.contactName?.lastName ?? '',
|
||||
patientId: match.node.patientId || null,
|
||||
};
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[RESOLVE] Lead lookup failed: ${err.message}`);
|
||||
@@ -135,24 +143,29 @@ export class CallerResolutionService {
|
||||
}
|
||||
}
|
||||
|
||||
private async findPatientByPhone(phone10: string, auth: string): Promise<{ id: string; firstName: string; lastName: string; patientType: string | null } | null> {
|
||||
private async findPatientByPhone(phone10: string, auth: string): Promise<{ id: string; firstName: string; lastName: string } | null> {
|
||||
try {
|
||||
const data = await this.platform.queryWithAuth<{ patients: { edges: { node: any }[] } }>(
|
||||
`{ patients(first: 1, filter: { phones: { primaryPhoneNumber: { like: "%${phone10}" } } }) { edges { node {
|
||||
`{ patients(first: 200) { edges { node {
|
||||
id
|
||||
fullName { firstName lastName }
|
||||
patientType
|
||||
phones { primaryPhoneNumber }
|
||||
} } } }`,
|
||||
undefined,
|
||||
auth,
|
||||
);
|
||||
const match = data.patients.edges[0]?.node;
|
||||
|
||||
const match = data.patients.edges.find(e => {
|
||||
const num = (e.node.phones?.primaryPhoneNumber ?? '').replace(/\D/g, '').slice(-10);
|
||||
return num.length >= 10 && num === phone10;
|
||||
});
|
||||
|
||||
if (!match) return null;
|
||||
|
||||
return {
|
||||
id: match.id,
|
||||
firstName: match.fullName?.firstName ?? '',
|
||||
lastName: match.fullName?.lastName ?? '',
|
||||
patientType: match.patientType ?? null,
|
||||
id: match.node.id,
|
||||
firstName: match.node.fullName?.firstName ?? '',
|
||||
lastName: match.node.fullName?.lastName ?? '',
|
||||
};
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[RESOLVE] Patient lookup failed: ${err.message}`);
|
||||
@@ -165,7 +178,6 @@ export class CallerResolutionService {
|
||||
`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',
|
||||
@@ -194,19 +206,6 @@ export class CallerResolutionService {
|
||||
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 } }`,
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,305 +0,0 @@
|
||||
// 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' },
|
||||
];
|
||||
@@ -1,54 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { PlatformModule } from '../platform/platform.module';
|
||||
import { Module } from '@nestjs/common';
|
||||
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,
|
||||
],
|
||||
controllers: [ThemeController],
|
||||
providers: [ThemeService],
|
||||
exports: [ThemeService],
|
||||
})
|
||||
export class ConfigThemeModule {}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
export default () => ({
|
||||
port: parseInt(process.env.PORT ?? '4100', 10),
|
||||
corsOrigin: process.env.CORS_ORIGIN ?? 'http://localhost:5173',
|
||||
corsOrigins: (process.env.CORS_ORIGINS ?? 'http://localhost:5173')
|
||||
.split(',')
|
||||
.map((origin) => origin.trim())
|
||||
.filter((origin) => origin.length > 0),
|
||||
platform: {
|
||||
graphqlUrl: process.env.PLATFORM_GRAPHQL_URL ?? 'http://localhost:4000/graphql',
|
||||
graphqlUrl:
|
||||
process.env.PLATFORM_GRAPHQL_URL ?? 'http://localhost:4000/graphql',
|
||||
apiKey: process.env.PLATFORM_API_KEY ?? '',
|
||||
},
|
||||
exotel: {
|
||||
@@ -20,17 +24,10 @@ export default () => ({
|
||||
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 100–200 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),
|
||||
pollIntervalMs: parseInt(
|
||||
process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000',
|
||||
10,
|
||||
),
|
||||
},
|
||||
ai: {
|
||||
provider: process.env.AI_PROVIDER ?? 'openai',
|
||||
@@ -38,13 +35,4 @@ export default () => ({
|
||||
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 ?? '',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
// 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(),
|
||||
},
|
||||
};
|
||||
@@ -1,220 +0,0 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
// 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'] },
|
||||
];
|
||||
@@ -1,50 +0,0 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
// 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,
|
||||
},
|
||||
};
|
||||
9
src/embed/embed.module.ts
Normal file
9
src/embed/embed.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PlatformModule } from '../platform/platform.module';
|
||||
import { LeadEmbedController } from './lead-embed.controller';
|
||||
|
||||
@Module({
|
||||
imports: [PlatformModule],
|
||||
controllers: [LeadEmbedController],
|
||||
})
|
||||
export class EmbedModule {}
|
||||
193
src/embed/lead-embed.controller.ts
Normal file
193
src/embed/lead-embed.controller.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { Controller, Post, Body, Logger, HttpException } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Controller('embed/leads')
|
||||
export class LeadEmbedController {
|
||||
private readonly logger = new Logger(LeadEmbedController.name);
|
||||
private readonly apiKey: string;
|
||||
|
||||
constructor(
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly config: ConfigService,
|
||||
) {
|
||||
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||
}
|
||||
|
||||
@Post('create')
|
||||
async handleLeadCreation(@Body() body: Record<string, any>) {
|
||||
console.log('Lead creation from embed received:', body);
|
||||
this.logger.log(
|
||||
`Lead creation from embed received: ${JSON.stringify(body)}`,
|
||||
);
|
||||
|
||||
const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : '';
|
||||
if (!authHeader) {
|
||||
this.logger.warn('No PLATFORM_API_KEY configured — cannot create lead');
|
||||
throw new HttpException('Server configuration error', 500);
|
||||
}
|
||||
|
||||
try {
|
||||
const leadData = this.mapIncomingDataToLead(body);
|
||||
|
||||
if (!leadData.contactPhone && !leadData.contactEmail) {
|
||||
throw new HttpException(
|
||||
'Either contact phone or email is required',
|
||||
400,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await this.platform.queryWithAuth<any>(
|
||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||
{ data: leadData },
|
||||
authHeader,
|
||||
);
|
||||
|
||||
const leadId = result.createLead.id;
|
||||
this.logger.log(`Lead created successfully: ${leadId}`);
|
||||
|
||||
if (body.notes || body.type) {
|
||||
await this.createInitialActivity(leadId, body, authHeader);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
leadId,
|
||||
message: 'Lead created successfully',
|
||||
};
|
||||
} catch (error: any) {
|
||||
const responseData = error?.response?.data
|
||||
? JSON.stringify(error.response.data)
|
||||
: '';
|
||||
this.logger.error(
|
||||
`Lead creation failed: ${error.message} ${responseData}`,
|
||||
);
|
||||
|
||||
if (error instanceof HttpException) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
throw new HttpException(
|
||||
error.message || 'Lead creation failed',
|
||||
error.response?.status || 500,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private mapIncomingDataToLead(
|
||||
body: Record<string, any>,
|
||||
): Record<string, any> {
|
||||
const leadData: Record<string, any> = {};
|
||||
|
||||
const contactName = body.contact_name || body.contactName || 'Unknown';
|
||||
const nameParts = contactName.split(' ');
|
||||
const firstName = nameParts[0] || 'Unknown';
|
||||
const lastName = nameParts.slice(1).join(' ');
|
||||
|
||||
leadData.name = contactName;
|
||||
leadData.contactName = {
|
||||
firstName,
|
||||
lastName: lastName || undefined,
|
||||
};
|
||||
|
||||
if (body.contact_phone || body.contactPhone) {
|
||||
const phone = body.contact_phone || body.contactPhone;
|
||||
const cleanPhone = phone.replace(/\D/g, '');
|
||||
leadData.contactPhone = {
|
||||
primaryPhoneNumber: cleanPhone.startsWith('91')
|
||||
? `+${cleanPhone}`
|
||||
: `+91${cleanPhone}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (body.contact_email || body.contactEmail) {
|
||||
leadData.contactEmail = {
|
||||
primaryEmail: body.contact_email || body.contactEmail,
|
||||
};
|
||||
}
|
||||
|
||||
leadData.source = body.source || 'WEBSITE';
|
||||
leadData.status = body.lead_status || body.status || 'NEW';
|
||||
|
||||
const interestedService = this.mapInterestedService(body);
|
||||
if (interestedService) {
|
||||
leadData.interestedService = interestedService;
|
||||
}
|
||||
|
||||
if (body.assigned_agent || body.assignedAgent) {
|
||||
leadData.assignedAgent = body.assigned_agent || body.assignedAgent;
|
||||
}
|
||||
|
||||
if (body.campaign_id || body.campaignId) {
|
||||
leadData.campaignId = body.campaign_id || body.campaignId;
|
||||
}
|
||||
|
||||
return leadData;
|
||||
}
|
||||
|
||||
private mapInterestedService(body: Record<string, any>): string | null {
|
||||
const type = body.type || body.interested_service || body.interestedService;
|
||||
|
||||
if (!type) {
|
||||
return body.department || null;
|
||||
}
|
||||
|
||||
const serviceMap: Record<string, string> = {
|
||||
consultation: 'Appointment',
|
||||
follow_up: 'Appointment',
|
||||
procedure: 'Appointment',
|
||||
emergency: 'Appointment',
|
||||
general_enquiry: 'General Enquiry',
|
||||
general: 'General Enquiry',
|
||||
};
|
||||
|
||||
return serviceMap[type.toLowerCase()] || type;
|
||||
}
|
||||
|
||||
private async createInitialActivity(
|
||||
leadId: string,
|
||||
body: Record<string, any>,
|
||||
authHeader: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const activityType =
|
||||
body.type === 'consultation' || body.type === 'appointment'
|
||||
? 'APPOINTMENT_BOOKED'
|
||||
: 'CALL_RECEIVED';
|
||||
|
||||
let summary = 'Lead submitted via web form';
|
||||
if (body.type) {
|
||||
summary = `${body.type.replace(/_/g, ' ')} requested`;
|
||||
}
|
||||
if (body.department) {
|
||||
summary += ` - ${body.department}`;
|
||||
}
|
||||
if (body.title) {
|
||||
summary += ` (from ${body.title})`;
|
||||
}
|
||||
|
||||
await this.platform.queryWithAuth<any>(
|
||||
`mutation($data: LeadActivityCreateInput!) { createLeadActivity(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
name: summary.substring(0, 80),
|
||||
activityType,
|
||||
summary,
|
||||
occurredAt: new Date().toISOString(),
|
||||
performedBy: 'System',
|
||||
channel: 'PHONE',
|
||||
leadId,
|
||||
},
|
||||
},
|
||||
authHeader,
|
||||
);
|
||||
|
||||
this.logger.log(`Initial activity created for lead ${leadId}`);
|
||||
} catch (error: any) {
|
||||
const errorDetails = error?.response?.data
|
||||
? JSON.stringify(error.response.data)
|
||||
: error.message;
|
||||
this.logger.error(`Failed to create initial activity: ${errorDetails}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ 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 {
|
||||
@@ -19,15 +18,8 @@ export class AiInsightConsumer implements OnModuleInit {
|
||||
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'),
|
||||
});
|
||||
this.aiModel = createAiModel(config);
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
@@ -82,9 +74,11 @@ export class AiInsightConsumer implements OnModuleInit {
|
||||
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',
|
||||
}),
|
||||
system: `You are a CRM assistant for Global Hospital Bangalore.
|
||||
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.`,
|
||||
prompt: `Lead: ${leadName}
|
||||
Status: ${lead.status ?? 'Unknown'}
|
||||
Source: ${lead.source ?? 'Unknown'}
|
||||
|
||||
@@ -15,7 +15,9 @@ export class ExotelController {
|
||||
@Post('call-status')
|
||||
@HttpCode(200)
|
||||
async handleCallStatus(@Body() payload: ExotelWebhookPayload) {
|
||||
this.logger.log(`Received Exotel webhook: ${payload.event_details?.event_type}`);
|
||||
this.logger.log(
|
||||
`Received Exotel webhook: ${payload.event_details?.event_type}`,
|
||||
);
|
||||
|
||||
const callEvent = this.exotelService.parseWebhook(payload);
|
||||
|
||||
|
||||
@@ -8,8 +8,11 @@ export class ExotelService {
|
||||
parseWebhook(payload: ExotelWebhookPayload): CallEvent {
|
||||
const { event_details, call_details } = payload;
|
||||
|
||||
const eventType = event_details.event_type === 'answered' ? 'answered'
|
||||
: event_details.event_type === 'terminal' ? 'ended'
|
||||
const eventType =
|
||||
event_details.event_type === 'answered'
|
||||
? 'answered'
|
||||
: event_details.event_type === 'terminal'
|
||||
? 'ended'
|
||||
: 'ringing';
|
||||
|
||||
const callEvent: CallEvent = {
|
||||
@@ -25,7 +28,9 @@ export class ExotelService {
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
|
||||
this.logger.log(`Parsed Exotel event: ${eventType} for call ${call_details.call_sid}`);
|
||||
this.logger.log(
|
||||
`Parsed Exotel event: ${eventType} for call ${call_details.call_sid}`,
|
||||
);
|
||||
return callEvent;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { Controller, Post, Req, Res, Logger, HttpException } from '@nestjs/common';
|
||||
import {
|
||||
Controller,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
Logger,
|
||||
HttpException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { Request, Response } from 'express';
|
||||
import axios from 'axios';
|
||||
@@ -21,16 +28,12 @@ export class GraphqlProxyController {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
this.graphqlUrl,
|
||||
req.body,
|
||||
{
|
||||
const response = await axios.post(this.graphqlUrl, req.body, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': authHeader,
|
||||
Authorization: authHeader,
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
res.status(response.status).json(response.data);
|
||||
} catch (error: any) {
|
||||
|
||||
@@ -18,10 +18,14 @@ export class HealthController {
|
||||
|
||||
try {
|
||||
const start = Date.now();
|
||||
await axios.post(this.graphqlUrl, { query: '{ __typename }' }, {
|
||||
await axios.post(
|
||||
this.graphqlUrl,
|
||||
{ query: '{ __typename }' },
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 5000,
|
||||
});
|
||||
},
|
||||
);
|
||||
platformLatency = Date.now() - start;
|
||||
platformReachable = true;
|
||||
} catch {
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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 {}
|
||||
@@ -27,27 +27,6 @@ async function gql<T = any>(query: string, variables?: Record<string, unknown>):
|
||||
}
|
||||
}
|
||||
|
||||
// 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 }>,
|
||||
@@ -149,58 +128,28 @@ const bookAppointment = llm.tool({
|
||||
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.
|
||||
// Create or find lead
|
||||
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 },
|
||||
contactName: {
|
||||
firstName: patientName.split(' ')[0],
|
||||
lastName: patientName.split(' ').slice(1).join(' ') || '',
|
||||
},
|
||||
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) {
|
||||
@@ -222,53 +171,25 @@ const collectLeadInfo = llm.tool({
|
||||
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(
|
||||
const result = await gql(
|
||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
name: `AI Enquiry — ${name}`,
|
||||
contactName: { firstName: fn, lastName: ln },
|
||||
contactName: {
|
||||
firstName: name.split(' ')[0],
|
||||
lastName: name.split(' ').slice(1).join(' ') || '',
|
||||
},
|
||||
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})`);
|
||||
|
||||
if (result?.createLead?.id) {
|
||||
console.log(`[LIVEKIT-AGENT] Lead created: ${result.createLead.id}`);
|
||||
}
|
||||
return `Thank you ${name}. I have noted your enquiry about ${interest}. One of our team members will call you back on ${phoneNumber} shortly.`;
|
||||
},
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
37
src/main.ts
37
src/main.ts
@@ -1,33 +1,38 @@
|
||||
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';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
|
||||
async function bootstrap() {
|
||||
const logger = LogStreamService.instance;
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule, { logger });
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const config = app.get(ConfigService);
|
||||
|
||||
const corsOrigins = config.get<string[]>('corsOrigins') || [
|
||||
'http://localhost:5173',
|
||||
];
|
||||
|
||||
app.enableCors({
|
||||
origin: config.get('corsOrigin'),
|
||||
origin: corsOrigins,
|
||||
credentials: true,
|
||||
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||
allowedHeaders: ['Content-Type', 'Accept', 'Authorization'],
|
||||
});
|
||||
|
||||
// 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 swaggerConfig = new DocumentBuilder()
|
||||
.setTitle('Helix Engage Server')
|
||||
.setDescription(
|
||||
'Sidecar API — Ozonetel telephony + FortyTwo platform bridge',
|
||||
)
|
||||
.setVersion('1.0')
|
||||
.addBearerAuth()
|
||||
.build();
|
||||
|
||||
const document = SwaggerModule.createDocument(app, swaggerConfig);
|
||||
SwaggerModule.setup('api/docs', app, document);
|
||||
|
||||
const port = config.get('port');
|
||||
await app.listen(port);
|
||||
console.log(`Helix Engage Server running on port ${port}`);
|
||||
console.log(`Swagger UI: http://localhost:${port}/api/docs`);
|
||||
}
|
||||
bootstrap();
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { Body, Controller, HttpException, Post, UseGuards, Logger } from '@nestjs/common';
|
||||
import { Controller, Post, UseGuards, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { MaintGuard } from './maint.guard';
|
||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { SessionService } from '../auth/session.service';
|
||||
import { SupervisorService } from '../supervisor/supervisor.service';
|
||||
import { AgentHistoryService, AgentEventType } from '../supervisor/agent-history.service';
|
||||
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||
import { AgentLookupService } from '../platform/agent-lookup.service';
|
||||
import { CdrEnrichmentService } from '../ozonetel/cdr-enrichment.service';
|
||||
|
||||
@Controller('api/maint')
|
||||
@UseGuards(MaintGuard)
|
||||
@@ -16,41 +13,21 @@ export class MaintController {
|
||||
private readonly logger = new Logger(MaintController.name);
|
||||
|
||||
constructor(
|
||||
private readonly telephony: TelephonyConfigService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly ozonetel: OzonetelAgentService,
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly session: SessionService,
|
||||
private readonly supervisor: SupervisorService,
|
||||
private readonly callerResolution: CallerResolutionService,
|
||||
private readonly history: AgentHistoryService,
|
||||
private readonly agentLookup: AgentLookupService,
|
||||
private readonly cdrEnrichment: CdrEnrichmentService,
|
||||
) {}
|
||||
|
||||
@Post('force-ready')
|
||||
async forceReady(@Body() body: { agentId: string }) {
|
||||
if (!body?.agentId) throw new HttpException('agentId required', 400);
|
||||
const agentId = body.agentId;
|
||||
async forceReady() {
|
||||
const agentId = this.config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
||||
const password = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
|
||||
const sipId = this.config.get<string>('OZONETEL_SIP_ID') ?? '521814';
|
||||
|
||||
// Look up the Agent entity to get sipPassword + sipExtension.
|
||||
// Password comes from the Agent record, not an env var — each
|
||||
// agent owns their own Ozonetel credential.
|
||||
const agentData = await this.platform.query<any>(
|
||||
`{ agents(first: 1, filter: { ozonetelAgentId: { eq: "${agentId}" } }) { edges { node {
|
||||
id sipExtension sipPassword
|
||||
} } } }`,
|
||||
).catch(() => null);
|
||||
|
||||
const agent = agentData?.agents?.edges?.[0]?.node;
|
||||
if (!agent) throw new HttpException(`Agent ${agentId} not found in platform`, 404);
|
||||
|
||||
const password = agent.sipPassword ?? agent.sipExtension;
|
||||
if (!password) throw new HttpException(`Agent ${agentId} has no sipPassword configured`, 400);
|
||||
|
||||
const sipId = agent.sipExtension;
|
||||
if (!sipId) throw new HttpException(`Agent ${agentId} has no sipExtension configured`, 400);
|
||||
|
||||
this.logger.log(`[MAINT] Force ready: agent=${agentId} ext=${sipId}`);
|
||||
this.logger.log(`[MAINT] Force ready: agent=${agentId}`);
|
||||
|
||||
try {
|
||||
await this.ozonetel.logoutAgent({ agentId, password });
|
||||
@@ -69,63 +46,9 @@ export class MaintController {
|
||||
}
|
||||
}
|
||||
|
||||
// Returns the current per-agent session state — which ozonetelAgentIds
|
||||
// are currently locked (held by a member IP) and which are free. Used
|
||||
// by the maint OTP modal to render a picker so a supervisor can unlock
|
||||
// the right agent without knowing the id off the top of their head.
|
||||
// Read-only; OTP-guarded like the rest of /api/maint.
|
||||
@Post('session-status')
|
||||
async sessionStatus() {
|
||||
const data = await this.platform.query<any>(
|
||||
`{ agents(first: 100) { edges { node { id name ozonetelAgentId ozonetelDisplayName } } } }`,
|
||||
).catch(() => ({ agents: { edges: [] } }));
|
||||
|
||||
const allAgents = (data?.agents?.edges ?? []).map((e: any) => e.node).filter((a: any) => a.ozonetelAgentId);
|
||||
const sessions = await this.session.listLockedSessions();
|
||||
const sessionByAgent = new Map(sessions.map((s) => [s.agentId.toLowerCase(), s]));
|
||||
|
||||
const locked: Array<any> = [];
|
||||
const free: Array<any> = [];
|
||||
const seenAgentIds = new Set<string>();
|
||||
|
||||
for (const agent of allAgents) {
|
||||
const key = String(agent.ozonetelAgentId).toLowerCase();
|
||||
seenAgentIds.add(key);
|
||||
const session = sessionByAgent.get(key);
|
||||
const row = {
|
||||
agentId: agent.ozonetelAgentId,
|
||||
displayName: agent.name ?? agent.ozonetelDisplayName ?? agent.ozonetelAgentId,
|
||||
};
|
||||
if (session) {
|
||||
locked.push({ ...row, heldByIp: session.ip, lockedAt: session.lockedAt });
|
||||
} else {
|
||||
free.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
// Surface orphan locks (Redis holds a session for an ozonetelAgentId
|
||||
// with no matching Agent entity). Rare but possible after SDK renames
|
||||
// or workspace resets — without surfacing them, the operator can't
|
||||
// clear the stale lock via the UI.
|
||||
for (const session of sessions) {
|
||||
const key = session.agentId.toLowerCase();
|
||||
if (!seenAgentIds.has(key)) {
|
||||
locked.push({
|
||||
agentId: session.agentId,
|
||||
displayName: `${session.agentId} (orphan — no Agent record)`,
|
||||
heldByIp: session.ip,
|
||||
lockedAt: session.lockedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { locked, free };
|
||||
}
|
||||
|
||||
@Post('unlock-agent')
|
||||
async unlockAgent(@Body() body: { agentId: string }) {
|
||||
if (!body?.agentId) throw new HttpException('agentId required', 400);
|
||||
const agentId = body.agentId;
|
||||
async unlockAgent() {
|
||||
const agentId = this.config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
||||
this.logger.log(`[MAINT] Unlock agent session: ${agentId}`);
|
||||
|
||||
try {
|
||||
@@ -343,7 +266,6 @@ export class MaintController {
|
||||
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
name: `${firstName} ${lastName}`.trim(),
|
||||
fullName: { firstName, lastName },
|
||||
phones: { primaryPhoneNumber: `+91${phone}` },
|
||||
patientType: 'NEW',
|
||||
@@ -390,692 +312,4 @@ export class MaintController {
|
||||
this.logger.log(`[MAINT] Backfill complete: ${linked} linked, ${created} patients created, ${apptLinked} appointments linked, ${skipped} skipped`);
|
||||
return { status: 'ok', leads: { total: leads.length, linked, created, skipped }, appointments: { total: appointments.length, linked: apptLinked } };
|
||||
}
|
||||
|
||||
// Backfill Call records that lost their identity at ingest (missed-call
|
||||
// webhook / poller / dispose flow before the caller-resolution wiring).
|
||||
// Routes each phone through CallerResolutionService so the same code
|
||||
// path the live system uses also fixes historical rows. Idempotent —
|
||||
// safe to re-run; only patches calls that are currently missing
|
||||
// leadName / patientId / leadId.
|
||||
@Post('backfill-caller-resolution')
|
||||
async backfillCallerResolution() {
|
||||
this.logger.log('[MAINT] Backfill caller resolution — patching Calls + Leads via resolver');
|
||||
|
||||
const apiKey = process.env.PLATFORM_API_KEY ?? '';
|
||||
const auth = apiKey ? `Bearer ${apiKey}` : '';
|
||||
if (!auth) throw new HttpException('PLATFORM_API_KEY not configured', 500);
|
||||
|
||||
let callsScanned = 0;
|
||||
let callsPatched = 0;
|
||||
let callsSkipped = 0;
|
||||
let leadsResolved = 0;
|
||||
let resolveErrors = 0;
|
||||
|
||||
// Phone → resolved cache so multiple calls from the same number
|
||||
// only resolve once during this run.
|
||||
const resolvedByPhone = new Map<string, { leadId: string; patientId: string; firstName: string; lastName: string }>();
|
||||
|
||||
// Page through all calls in chunks of 200. We're after rows where
|
||||
// leadName is empty OR leadId is null OR patientId is missing.
|
||||
let cursor: string | null = null;
|
||||
let hasNext = true;
|
||||
while (hasNext) {
|
||||
const pageQuery = cursor
|
||||
? `{ calls(first: 200, after: "${cursor}") { edges { cursor node { id leadId leadName callerNumber { primaryPhoneNumber } } } pageInfo { hasNextPage endCursor } } }`
|
||||
: `{ calls(first: 200) { edges { cursor node { id leadId leadName callerNumber { primaryPhoneNumber } } } pageInfo { hasNextPage endCursor } } }`;
|
||||
let page: any;
|
||||
try {
|
||||
page = await this.platform.query<any>(pageQuery);
|
||||
} catch (err) {
|
||||
this.logger.warn(`[MAINT] calls page query failed: ${err}`);
|
||||
break;
|
||||
}
|
||||
const edges = page?.calls?.edges ?? [];
|
||||
hasNext = page?.calls?.pageInfo?.hasNextPage ?? false;
|
||||
cursor = page?.calls?.pageInfo?.endCursor ?? null;
|
||||
|
||||
for (const edge of edges) {
|
||||
const call = edge.node;
|
||||
callsScanned++;
|
||||
|
||||
const phoneRaw = call.callerNumber?.primaryPhoneNumber ?? '';
|
||||
const phone10 = phoneRaw.replace(/\D/g, '').slice(-10);
|
||||
const needsName = !call.leadName || call.leadName === '';
|
||||
const needsLead = !call.leadId;
|
||||
|
||||
if (!phone10 || phone10.length < 10) { callsSkipped++; continue; }
|
||||
if (!needsName && !needsLead) { callsSkipped++; continue; }
|
||||
|
||||
let resolved = resolvedByPhone.get(phone10) ?? null;
|
||||
if (!resolved) {
|
||||
try {
|
||||
const r = await this.callerResolution.resolve(phone10, auth);
|
||||
resolved = {
|
||||
leadId: r.leadId,
|
||||
patientId: r.patientId,
|
||||
firstName: r.firstName,
|
||||
lastName: r.lastName,
|
||||
};
|
||||
resolvedByPhone.set(phone10, resolved);
|
||||
leadsResolved++;
|
||||
} catch (err) {
|
||||
this.logger.warn(`[MAINT] resolve failed for ${phone10}: ${err}`);
|
||||
resolveErrors++;
|
||||
callsSkipped++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
const fullName = `${resolved.firstName} ${resolved.lastName}`.trim();
|
||||
const updateParts: string[] = [];
|
||||
if (needsLead && resolved.leadId) updateParts.push(`leadId: "${resolved.leadId}"`);
|
||||
if (needsName && fullName) updateParts.push(`leadName: "${fullName.replace(/"/g, '\\"')}"`);
|
||||
if (updateParts.length === 0) { callsSkipped++; continue; }
|
||||
|
||||
try {
|
||||
await this.platform.query<any>(
|
||||
`mutation { updateCall(id: "${call.id}", data: { ${updateParts.join(', ')} }) { id } }`,
|
||||
);
|
||||
callsPatched++;
|
||||
} catch (err) {
|
||||
this.logger.warn(`[MAINT] updateCall failed for ${call.id}: ${err}`);
|
||||
callsSkipped++;
|
||||
}
|
||||
|
||||
// Throttle so the platform isn't hammered
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`[MAINT] Backfill caller resolution complete: scanned=${callsScanned} patched=${callsPatched} skipped=${callsSkipped} uniquePhones=${resolvedByPhone.size} leadsResolved=${leadsResolved} resolveErrors=${resolveErrors}`);
|
||||
return {
|
||||
status: 'ok',
|
||||
calls: { scanned: callsScanned, patched: callsPatched, skipped: callsSkipped },
|
||||
phones: { unique: resolvedByPhone.size, resolved: leadsResolved, errors: resolveErrors },
|
||||
};
|
||||
}
|
||||
|
||||
// Recompute durationS on existing AgentEvent rows using the per-category
|
||||
// pairing logic. Fixes rows written before the slot-split fix where
|
||||
// ACW_START clobbered CALL_START's pending entry. Also re-runs the
|
||||
// session rollup for each affected date. Idempotent — only updates rows
|
||||
// whose stored durationS differs from the recomputed value.
|
||||
//
|
||||
// POST /api/maint/backfill-agent-event-durations
|
||||
// body: { date?: "YYYY-MM-DD" | "all" } — default today IST
|
||||
@Post('backfill-agent-event-durations')
|
||||
async backfillAgentEventDurations(@Body() body: { date?: string }) {
|
||||
const target = body?.date ?? this.todayIst();
|
||||
this.logger.log(`[MAINT] Backfill AgentEvent durations — target=${target}`);
|
||||
|
||||
// Pull events for the range. If "all", no filter; otherwise scope to the IST day.
|
||||
let events = await this.fetchAgentEventsForBackfill(target);
|
||||
if (events.length === 0) {
|
||||
return { status: 'ok', scanned: 0, patched: 0, skipped: 0, dates: [] };
|
||||
}
|
||||
this.logger.log(`[MAINT] Fetched ${events.length} AgentEvent rows`);
|
||||
|
||||
// Group by agent, sort by eventAt ascending.
|
||||
const byAgent = new Map<string, typeof events>();
|
||||
for (const e of events) {
|
||||
const k = e.agentId;
|
||||
if (!k) continue;
|
||||
if (!byAgent.has(k)) byAgent.set(k, []);
|
||||
byAgent.get(k)!.push(e);
|
||||
}
|
||||
for (const list of byAgent.values()) {
|
||||
list.sort((a, b) => new Date(a.eventAt).getTime() - new Date(b.eventAt).getTime());
|
||||
}
|
||||
|
||||
// Per-category slot pairing, same logic as the live ingest.
|
||||
const slotForStart = (t: AgentEventType): 'pause' | 'call' | 'acw' | null =>
|
||||
t === 'PAUSE' ? 'pause' : t === 'CALL_START' ? 'call' : t === 'ACW_START' ? 'acw' : null;
|
||||
const slotForEnd = (t: AgentEventType): 'pause' | 'call' | 'acw' | null =>
|
||||
t === 'RESUME' ? 'pause' : t === 'CALL_END' ? 'call' : t === 'ACW_END' ? 'acw' : null;
|
||||
|
||||
let patched = 0;
|
||||
let skipped = 0;
|
||||
const affectedDates = new Set<string>();
|
||||
|
||||
for (const [agentId, agentEvents] of byAgent) {
|
||||
const pending: { pause?: number; call?: number; acw?: number } = {};
|
||||
for (const e of agentEvents) {
|
||||
const eventMs = new Date(e.eventAt).getTime();
|
||||
const endSlot = slotForEnd(e.eventType);
|
||||
const startSlot = slotForStart(e.eventType);
|
||||
|
||||
let computed: number | null = null;
|
||||
|
||||
if (endSlot) {
|
||||
const at = pending[endSlot];
|
||||
if (at !== undefined) {
|
||||
computed = Math.max(0, Math.round((eventMs - at) / 1000));
|
||||
delete pending[endSlot];
|
||||
}
|
||||
} else if (startSlot) {
|
||||
pending[startSlot] = eventMs;
|
||||
} else if (e.eventType === 'READY' || e.eventType === 'LOGOUT') {
|
||||
delete pending.pause;
|
||||
delete pending.call;
|
||||
delete pending.acw;
|
||||
}
|
||||
|
||||
// Only patch END events that now have a computed duration
|
||||
// different from what's stored.
|
||||
if (endSlot && computed !== null && computed !== (e.durationS ?? null)) {
|
||||
try {
|
||||
await this.platform.query<any>(
|
||||
`mutation { updateAgentEvent(id: "${e.id}", data: { durationS: ${computed} }) { id } }`,
|
||||
);
|
||||
patched++;
|
||||
const datePart = (e.eventAt ?? '').slice(0, 10);
|
||||
if (datePart) affectedDates.add(datePart);
|
||||
this.logger.log(`[MAINT] Patched AgentEvent ${e.id} ${e.eventType} agent=${agentId} ${e.durationS ?? 'null'}s → ${computed}s`);
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
} catch (err) {
|
||||
this.logger.warn(`[MAINT] Patch failed for ${e.id}: ${err}`);
|
||||
skipped++;
|
||||
}
|
||||
} else {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Re-run rollup for each affected date so AgentSession numbers update.
|
||||
const dates = Array.from(affectedDates);
|
||||
for (const d of dates) {
|
||||
try {
|
||||
await this.history.rollupSessions(d);
|
||||
this.logger.log(`[MAINT] Rollup re-run for ${d}`);
|
||||
} catch (err) {
|
||||
this.logger.warn(`[MAINT] Rollup failed for ${d}: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`[MAINT] Backfill AgentEvent durations complete: scanned=${events.length} patched=${patched} skipped=${skipped} dates=${dates.join(',')}`);
|
||||
return { status: 'ok', scanned: events.length, patched, skipped, dates };
|
||||
}
|
||||
|
||||
private todayIst(): string {
|
||||
const ist = new Date(Date.now() + 5.5 * 60 * 60 * 1000);
|
||||
return ist.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
private async fetchAgentEventsForBackfill(date: string): Promise<Array<{ id: string; eventType: AgentEventType; eventAt: string; durationS: number | null; agentId: string }>> {
|
||||
const events: Array<{ id: string; eventType: AgentEventType; eventAt: string; durationS: number | null; agentId: string }> = [];
|
||||
let after: string | null = null;
|
||||
const rangeFilter = date === 'all'
|
||||
? ''
|
||||
: `, filter: { eventAt: { gte: "${date}T00:00:00+05:30", lte: "${date}T23:59:59+05:30" } }`;
|
||||
|
||||
for (let page = 0; page < 50; page++) {
|
||||
const cursorArg: string = after ? `, after: "${after}"` : '';
|
||||
const data: any = await this.platform.query<any>(
|
||||
`{ agentEvents(first: 200${cursorArg}${rangeFilter}, orderBy: [{ eventAt: AscNullsLast }]) {
|
||||
edges { node { id eventType eventAt durationS agentId } }
|
||||
pageInfo { hasNextPage endCursor }
|
||||
} }`,
|
||||
);
|
||||
const edges = data?.agentEvents?.edges ?? [];
|
||||
for (const e of edges) events.push(e.node);
|
||||
const pageInfo: { hasNextPage?: boolean; endCursor?: string } = data?.agentEvents?.pageInfo ?? {};
|
||||
if (!pageInfo.hasNextPage) break;
|
||||
after = pageInfo.endCursor ?? null;
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
// Historical enrichment: runs the same CDR-enrichment loop the cron runs,
|
||||
// but kicks it off immediately and (optionally) widens the date window
|
||||
// beyond "today + yesterday" up to the CDR API's 15-day limit.
|
||||
//
|
||||
// POST /api/maint/enrich-call-agents
|
||||
// Headers: x-maint-otp: <OTP>
|
||||
// Body: { days?: number } — default 2 (matches the cron); max 15
|
||||
@Post('enrich-call-agents')
|
||||
async enrichCallAgents(@Body() body: { days?: number }) {
|
||||
const requestedDays = Math.max(1, Math.min(15, body?.days ?? 2));
|
||||
this.logger.log(`[MAINT] Enrich call agents — days=${requestedDays}`);
|
||||
|
||||
// Call the enrichment service once per date, respecting the 2-req/min
|
||||
// CDR rate limit. Each tick fetches one date's CDR (1 req) so we can
|
||||
// iterate up to 2 dates per minute — enforce a 35s gap between dates.
|
||||
const dates = this.recentDatesIst(requestedDays);
|
||||
let totalScanned = 0;
|
||||
let totalEnriched = 0;
|
||||
let totalSkipped = 0;
|
||||
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
const date = dates[i];
|
||||
try {
|
||||
const result = await this.enrichSingleDate(date);
|
||||
totalScanned += result.scanned;
|
||||
totalEnriched += result.enriched;
|
||||
totalSkipped += result.skipped;
|
||||
this.logger.log(`[MAINT] ${date} — scanned=${result.scanned} enriched=${result.enriched} skipped=${result.skipped}`);
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[MAINT] Enrich failed for ${date}: ${err?.message ?? err}`);
|
||||
}
|
||||
// Rate limiting: 35s between dates to stay under 2 req/min on CDR.
|
||||
if (i < dates.length - 1) await new Promise((r) => setTimeout(r, 35_000));
|
||||
}
|
||||
|
||||
this.logger.log(`[MAINT] Enrichment complete: scanned=${totalScanned} enriched=${totalEnriched} skipped=${totalSkipped} across ${dates.length} dates`);
|
||||
return { status: 'ok', scanned: totalScanned, enriched: totalEnriched, skipped: totalSkipped, dates };
|
||||
}
|
||||
|
||||
// Fallback backfill for historical Calls that pre-date UCID persistence.
|
||||
// Can't join to CDR without UCID, so parse the agentName string (which
|
||||
// may be a transfer chain "A -> B -> C"), take the final segment, and
|
||||
// resolve to an Agent entity by name or ozonetelAgentId (case-insensitive).
|
||||
//
|
||||
// POST /api/maint/backfill-call-agents-by-name
|
||||
// Headers: x-maint-otp: <OTP>
|
||||
// Body: {}
|
||||
@Post('backfill-call-agents-by-name')
|
||||
async backfillCallAgentsByName() {
|
||||
this.logger.log('[MAINT] Backfill call agents by name — matching agentName last-segment to Agent entity');
|
||||
|
||||
// Pull all active agents — cheap, cached at service level but we
|
||||
// also need name → UUID maps for this pass. Three indexes:
|
||||
// - ozonetelAgentId (e.g. "globalhealthx") — matches outbound dispose rows
|
||||
// - ozonetelDisplayName (e.g. "Ganesh Bandi") — matches inbound webhook rows
|
||||
// - platform Agent.name (e.g. "Ganesh Iyer") — last-resort fallback
|
||||
const agentData = await this.platform.query<any>(
|
||||
`{ agents(first: 100) { edges { node { id name ozonetelAgentId ozonetelDisplayName } } } }`,
|
||||
);
|
||||
const agentUuidByName = new Map<string, string>();
|
||||
const agentUuidByOzonetelId = new Map<string, string>();
|
||||
const agentUuidByDisplayName = new Map<string, string>();
|
||||
for (const edge of agentData?.agents?.edges ?? []) {
|
||||
const a = edge.node;
|
||||
if (a.name) agentUuidByName.set(a.name.toLowerCase().trim(), a.id);
|
||||
if (a.ozonetelAgentId) agentUuidByOzonetelId.set(a.ozonetelAgentId.toLowerCase().trim(), a.id);
|
||||
if (a.ozonetelDisplayName) agentUuidByDisplayName.set(a.ozonetelDisplayName.toLowerCase().trim(), a.id);
|
||||
}
|
||||
|
||||
let scanned = 0;
|
||||
let patched = 0;
|
||||
let skipped = 0;
|
||||
let unmatched = 0;
|
||||
const unmatchedSamples = new Set<string>();
|
||||
|
||||
// Paginate through all Calls with agentId=null and agentName set.
|
||||
let after: string | null = null;
|
||||
for (let page = 0; page < 50; page++) {
|
||||
const cursorArg: string = after ? `, after: "${after}"` : '';
|
||||
const data: any = await this.platform.query<any>(
|
||||
`{ calls(first: 200${cursorArg}, filter: {
|
||||
agentId: { is: NULL },
|
||||
agentName: { is: NOT_NULL }
|
||||
}) {
|
||||
edges { node { id agentName } }
|
||||
pageInfo { hasNextPage endCursor }
|
||||
} }`,
|
||||
).catch(() => ({ calls: { edges: [], pageInfo: {} } }));
|
||||
const edges = data?.calls?.edges ?? [];
|
||||
scanned += edges.length;
|
||||
|
||||
for (const edge of edges) {
|
||||
const call = edge.node;
|
||||
if (!call.agentName || call.agentName.trim() === '') { skipped++; continue; }
|
||||
|
||||
// Take the final hop of the transfer chain, trimmed.
|
||||
const segments = call.agentName.split('->').map((s: string) => s.trim()).filter(Boolean);
|
||||
const last = segments[segments.length - 1];
|
||||
if (!last) { skipped++; continue; }
|
||||
|
||||
// Prefer ozonetelAgentId match (outbound rows store
|
||||
// agentName=agentId); fall back to ozonetelDisplayName
|
||||
// (inbound webhook rows store the Ozonetel display string);
|
||||
// last-resort match on platform Agent.name.
|
||||
const key = last.toLowerCase();
|
||||
const uuid = agentUuidByOzonetelId.get(key)
|
||||
?? agentUuidByDisplayName.get(key)
|
||||
?? agentUuidByName.get(key);
|
||||
if (!uuid) {
|
||||
unmatched++;
|
||||
if (unmatchedSamples.size < 10) unmatchedSamples.add(last);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Store the raw chain on transferredTo if it was actually chained,
|
||||
// so the audit trail is preserved even without CDR data.
|
||||
const patchData: Record<string, any> = { agentId: uuid };
|
||||
if (segments.length > 1) patchData.transferredTo = call.agentName;
|
||||
|
||||
try {
|
||||
await this.platform.query<any>(
|
||||
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||
{ id: call.id, data: patchData },
|
||||
);
|
||||
patched++;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
} catch (err) {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
const pageInfo = data?.calls?.pageInfo ?? {};
|
||||
if (!pageInfo.hasNextPage) break;
|
||||
after = pageInfo.endCursor ?? null;
|
||||
}
|
||||
|
||||
this.logger.log(`[MAINT] Backfill by name complete: scanned=${scanned} patched=${patched} unmatched=${unmatched} skipped=${skipped}`);
|
||||
return {
|
||||
status: 'ok',
|
||||
scanned,
|
||||
patched,
|
||||
unmatched,
|
||||
skipped,
|
||||
unmatchedSamples: Array.from(unmatchedSamples),
|
||||
};
|
||||
}
|
||||
|
||||
private async enrichSingleDate(date: string): Promise<{ scanned: number; enriched: number; skipped: number }> {
|
||||
// Reuse the cdr-enrichment path via its runOnce method, but scoped.
|
||||
// For simplicity we reimplement the single-date logic here so we can
|
||||
// parameterize the date without leaking CDR-enrichment internals.
|
||||
const cdrRows = await this.ozonetel.fetchCDR({ date });
|
||||
if (cdrRows.length === 0) return { scanned: 0, enriched: 0, skipped: 0 };
|
||||
|
||||
const byUcid = new Map<string, any>();
|
||||
for (const row of cdrRows) {
|
||||
const ucid = String(row.UCID ?? '').trim();
|
||||
if (ucid) byUcid.set(ucid, row);
|
||||
}
|
||||
|
||||
// Fetch calls missing agent link on this date
|
||||
const gte = `${date}T00:00:00+05:30`;
|
||||
const lte = `${date}T23:59:59+05:30`;
|
||||
const calls: Array<any> = [];
|
||||
let after: string | null = null;
|
||||
for (let page = 0; page < 30; page++) {
|
||||
const cursorArg: string = after ? `, after: "${after}"` : '';
|
||||
const data: any = await this.platform.query<any>(
|
||||
`{ calls(first: 200${cursorArg}, filter: {
|
||||
startedAt: { gte: "${gte}", lte: "${lte}" },
|
||||
ucid: { is: NOT_NULL },
|
||||
agentId: { is: NULL }
|
||||
}) {
|
||||
edges { node { id ucid agentId transferredTo transferType } }
|
||||
pageInfo { hasNextPage endCursor }
|
||||
} }`,
|
||||
).catch(() => ({ calls: { edges: [], pageInfo: {} } }));
|
||||
const edges = data?.calls?.edges ?? [];
|
||||
for (const e of edges) calls.push(e.node);
|
||||
const pageInfo = data?.calls?.pageInfo ?? {};
|
||||
if (!pageInfo.hasNextPage) break;
|
||||
after = pageInfo.endCursor ?? null;
|
||||
}
|
||||
|
||||
let enriched = 0;
|
||||
let skipped = 0;
|
||||
for (const call of calls) {
|
||||
const cdrRow = byUcid.get(String(call.ucid).trim());
|
||||
if (!cdrRow) { skipped++; continue; }
|
||||
const patch: Record<string, any> = {};
|
||||
if (cdrRow.AgentID && !call.agentId) {
|
||||
const uuid = await this.agentLookup.resolveByOzonetelId(cdrRow.AgentID);
|
||||
if (uuid) patch.agentId = uuid;
|
||||
if (cdrRow.AgentName) patch.agentName = cdrRow.AgentName;
|
||||
}
|
||||
if (cdrRow.TransferredTo && !call.transferredTo) patch.transferredTo = cdrRow.TransferredTo;
|
||||
if (cdrRow.TransferType && !call.transferType) patch.transferType = cdrRow.TransferType;
|
||||
|
||||
if (Object.keys(patch).length === 0) { skipped++; continue; }
|
||||
try {
|
||||
await this.platform.query<any>(
|
||||
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||
{ id: call.id, data: patch },
|
||||
);
|
||||
enriched++;
|
||||
await new Promise((r) => setTimeout(r, 80));
|
||||
} catch (err) {
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
return { scanned: calls.length, enriched, skipped };
|
||||
}
|
||||
|
||||
private recentDatesIst(n: number): string[] {
|
||||
const dates: string[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const d = new Date(Date.now() + 5.5 * 60 * 60 * 1000 - i * 24 * 60 * 60 * 1000);
|
||||
dates.push(d.toISOString().slice(0, 10));
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
|
||||
// Infer clinicId on historical Appointments that were written before
|
||||
// the clinicId-persistence fix went live. Lookup path:
|
||||
// Appointment.doctorId + Appointment.scheduledAt.dayOfWeek
|
||||
// → DoctorVisitSlot rows for that doctor on that weekday
|
||||
// → if single clinic → use it
|
||||
// → if multiple clinics → match by time-of-day window (slot covers scheduledAt time)
|
||||
// → if still ambiguous → match by department, else skip
|
||||
//
|
||||
// POST /api/maint/backfill-appointment-clinics
|
||||
// Headers: x-maint-otp: <OTP>
|
||||
@Post('backfill-appointment-clinics')
|
||||
async backfillAppointmentClinics() {
|
||||
this.logger.log('[MAINT] Backfill Appointment.clinicId — inferring from doctorVisitSlots');
|
||||
|
||||
// 1. Pull all appointments missing clinicId
|
||||
const appointments: Array<{ id: string; doctorId: string | null; scheduledAt: string | null; department: string | null }> = [];
|
||||
let after: string | null = null;
|
||||
for (let page = 0; page < 50; page++) {
|
||||
const cursor: string = after ? `, after: "${after}"` : '';
|
||||
const data: any = await this.platform.query<any>(
|
||||
`{ appointments(first: 200${cursor}, filter: { clinicId: { is: NULL } }) {
|
||||
edges { node { id doctorId scheduledAt department } }
|
||||
pageInfo { hasNextPage endCursor }
|
||||
} }`,
|
||||
).catch(() => ({ appointments: { edges: [], pageInfo: {} } }));
|
||||
const edges = data?.appointments?.edges ?? [];
|
||||
for (const e of edges) appointments.push(e.node);
|
||||
const info = data?.appointments?.pageInfo ?? {};
|
||||
if (!info.hasNextPage) break;
|
||||
after = info.endCursor ?? null;
|
||||
}
|
||||
this.logger.log(`[MAINT] Found ${appointments.length} appointments missing clinicId`);
|
||||
if (appointments.length === 0) {
|
||||
return { status: 'ok', scanned: 0, patched: 0, skipped: 0 };
|
||||
}
|
||||
|
||||
// 2. For each unique doctorId, pre-load visit slots (7 weekdays × clinic rows).
|
||||
const uniqueDoctorIds = [...new Set(appointments.map((a) => a.doctorId).filter(Boolean) as string[])];
|
||||
const slotsByDoctor = new Map<string, Array<{ dayOfWeek: string; startTime: string; endTime: string; clinicId: string; clinicName: string }>>();
|
||||
for (const docId of uniqueDoctorIds) {
|
||||
try {
|
||||
const data: any = await this.platform.query<any>(
|
||||
`{ doctorVisitSlots(first: 50, filter: { doctorId: { eq: "${docId}" } }) {
|
||||
edges { node { dayOfWeek startTime endTime clinic { id clinicName } } }
|
||||
} }`,
|
||||
);
|
||||
const rows = (data?.doctorVisitSlots?.edges ?? []).map((e: any) => ({
|
||||
dayOfWeek: e.node.dayOfWeek,
|
||||
startTime: e.node.startTime,
|
||||
endTime: e.node.endTime,
|
||||
clinicId: e.node.clinic?.id,
|
||||
clinicName: e.node.clinic?.clinicName ?? '',
|
||||
})).filter((r: any) => r.clinicId);
|
||||
slotsByDoctor.set(docId, rows);
|
||||
} catch {
|
||||
slotsByDoctor.set(docId, []);
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
|
||||
// 3. Walk each appointment, infer the clinic, patch.
|
||||
let patched = 0;
|
||||
let skipped = 0;
|
||||
const skippedReasons: Record<string, number> = { noDoctor: 0, noScheduledAt: 0, noSlots: 0, ambiguous: 0 };
|
||||
|
||||
for (const appt of appointments) {
|
||||
if (!appt.doctorId) { skipped++; skippedReasons.noDoctor++; continue; }
|
||||
if (!appt.scheduledAt) { skipped++; skippedReasons.noScheduledAt++; continue; }
|
||||
|
||||
const slots = slotsByDoctor.get(appt.doctorId) ?? [];
|
||||
if (slots.length === 0) { skipped++; skippedReasons.noSlots++; continue; }
|
||||
|
||||
// Appointment time in IST
|
||||
const ist = new Date(new Date(appt.scheduledAt).getTime() + 5.5 * 60 * 60 * 1000);
|
||||
const dayOfWeek = ist.toLocaleDateString('en-US', { weekday: 'long', timeZone: 'UTC' }).toUpperCase();
|
||||
const apptMinutes = ist.getUTCHours() * 60 + ist.getUTCMinutes();
|
||||
|
||||
// Match slots for same weekday where the appointment time falls within the window
|
||||
const toMin = (hhmm: string): number => {
|
||||
const [h, m] = hhmm.split(':').map(Number);
|
||||
return h * 60 + (m ?? 0);
|
||||
};
|
||||
let candidates = slots.filter((s) => s.dayOfWeek === dayOfWeek);
|
||||
if (candidates.length > 0) {
|
||||
const inWindow = candidates.filter((s) => {
|
||||
const start = toMin(s.startTime ?? '00:00');
|
||||
const end = toMin(s.endTime ?? '23:59');
|
||||
return apptMinutes >= start && apptMinutes < end;
|
||||
});
|
||||
if (inWindow.length > 0) candidates = inWindow;
|
||||
}
|
||||
// Distinct clinics among candidates
|
||||
const distinctClinics = [...new Set(candidates.map((c) => c.clinicId))];
|
||||
let clinicId: string | null = null;
|
||||
if (distinctClinics.length === 1) {
|
||||
clinicId = distinctClinics[0];
|
||||
} else if (distinctClinics.length > 1) {
|
||||
// Ambiguous — doctor visits multiple clinics in this window.
|
||||
// Pick deterministically by clinic id lex-order so re-runs land
|
||||
// on the same choice. Log the ambiguity so QA can review.
|
||||
clinicId = [...distinctClinics].sort()[0];
|
||||
this.logger.debug(`[MAINT] Ambiguous clinic for appt=${appt.id} — doctor=${appt.doctorId} day=${dayOfWeek} candidates=${distinctClinics.join(',')} picked=${clinicId}`);
|
||||
}
|
||||
// Last resort: any clinic for that doctor (pick first)
|
||||
if (!clinicId && slots.length > 0) clinicId = slots[0].clinicId;
|
||||
|
||||
if (!clinicId) { skipped++; skippedReasons.ambiguous++; continue; }
|
||||
|
||||
try {
|
||||
await this.platform.query<any>(
|
||||
`mutation($id: UUID!, $data: AppointmentUpdateInput!) { updateAppointment(id: $id, data: $data) { id } }`,
|
||||
{ id: appt.id, data: { clinicId } },
|
||||
);
|
||||
patched++;
|
||||
await new Promise((r) => setTimeout(r, 40));
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[MAINT] updateAppointment(${appt.id}) failed: ${err?.message ?? err}`);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`[MAINT] Appointment clinic backfill complete: scanned=${appointments.length} patched=${patched} skipped=${skipped} reasons=${JSON.stringify(skippedReasons)}`);
|
||||
return { status: 'ok', scanned: appointments.length, patched, skipped, skippedReasons };
|
||||
}
|
||||
|
||||
// Backfill disposition + SLA timing on historical calls using CDR data.
|
||||
// Walks calls from a given date (IST), joins to CDR by UCID, and patches
|
||||
// disposition (from CDR's mapped value) + timing fields. Idempotent —
|
||||
// only overwrites null fields (disposition is always overwritten since
|
||||
// the webhook default is unreliable).
|
||||
@Post('backfill-call-disposition-timing')
|
||||
async backfillCallDispositionTiming(@Body() body: { date?: string }) {
|
||||
const date = body.date ?? new Date(Date.now() + 5.5 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
this.logger.log(`[MAINT] Backfill disposition+timing for date=${date}`);
|
||||
|
||||
// Fetch CDR for the date
|
||||
const cdrRows = await this.ozonetel.fetchCDR({ date }).catch(() => []);
|
||||
if (cdrRows.length === 0) return { status: 'ok', date, scanned: 0, patched: 0, skipped: 0 };
|
||||
|
||||
// Build UCID + monitorUCID map
|
||||
const byUcid = new Map<string, any>();
|
||||
for (const row of cdrRows) {
|
||||
const ucid = String(row.UCID ?? '').trim();
|
||||
const monUcid = String(row.monitorUCID ?? '').trim();
|
||||
if (ucid) byUcid.set(ucid, row);
|
||||
if (monUcid && monUcid !== ucid) byUcid.set(monUcid, row);
|
||||
}
|
||||
|
||||
// Fetch calls for the date that have a UCID
|
||||
const gte = `${date}T00:00:00+05:30`;
|
||||
const lte = `${date}T23:59:59+05:30`;
|
||||
const callsData = await this.platform.query<any>(
|
||||
`{ calls(first: 500, filter: {
|
||||
startedAt: { gte: "${gte}", lte: "${lte}" },
|
||||
ucid: { is: NOT_NULL }
|
||||
}) { edges { node {
|
||||
id ucid disposition assignedAt answeredAt responseTimeS startedAt
|
||||
} } } }`,
|
||||
).catch(() => ({ calls: { edges: [] } }));
|
||||
|
||||
const calls = callsData?.calls?.edges?.map((e: any) => e.node) ?? [];
|
||||
let patched = 0;
|
||||
let skipped = 0;
|
||||
|
||||
const dispositionMap: Record<string, string> = {
|
||||
'General Enquiry': 'INFO_PROVIDED',
|
||||
'Appointment Booked': 'APPOINTMENT_BOOKED',
|
||||
'Follow Up': 'FOLLOW_UP_SCHEDULED',
|
||||
'Not Interested': 'NOT_INTERESTED',
|
||||
'Wrong Number': 'WRONG_NUMBER',
|
||||
'No Answer': 'NO_ANSWER',
|
||||
};
|
||||
|
||||
const parseHms = (hms: string | null | undefined): number | null => {
|
||||
if (!hms) return null;
|
||||
const parts = String(hms).split(':').map(Number);
|
||||
if (parts.length !== 3 || parts.some(isNaN)) return null;
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
};
|
||||
|
||||
for (const call of calls) {
|
||||
const cdrRow = byUcid.get(String(call.ucid).trim());
|
||||
if (!cdrRow) { skipped++; continue; }
|
||||
|
||||
const patch: Record<string, any> = {};
|
||||
|
||||
// Disposition — always overwrite (webhook default is unreliable)
|
||||
const cdrDisp = dispositionMap[cdrRow.Disposition] ?? null;
|
||||
if (cdrDisp) patch.disposition = cdrDisp;
|
||||
|
||||
// Timing — only fill if null
|
||||
if (!call.answeredAt && cdrRow.AnswerTime) {
|
||||
patch.answeredAt = new Date(cdrRow.AnswerTime).toISOString();
|
||||
}
|
||||
if (!call.assignedAt && cdrRow.StartTime) {
|
||||
patch.assignedAt = new Date(cdrRow.StartTime).toISOString();
|
||||
}
|
||||
if (!call.responseTimeS && call.startedAt && (patch.answeredAt || call.answeredAt)) {
|
||||
const start = new Date(call.startedAt).getTime();
|
||||
const answered = new Date(patch.answeredAt ?? call.answeredAt).getTime();
|
||||
if (!isNaN(start) && !isNaN(answered)) {
|
||||
patch.responseTimeS = Math.max(0, Math.round((answered - start) / 1000));
|
||||
}
|
||||
}
|
||||
|
||||
// CDR timing fields
|
||||
const handlingSec = parseHms(cdrRow.HandlingTime);
|
||||
const wrapupSec = parseHms(cdrRow.WrapupDuration);
|
||||
const holdSec = parseHms(cdrRow.HoldDuration);
|
||||
if (handlingSec !== null) patch.handlingTimeS = handlingSec;
|
||||
if (wrapupSec !== null) patch.acwDurationS = wrapupSec;
|
||||
if (holdSec !== null) patch.holdDurationS = holdSec;
|
||||
|
||||
if (Object.keys(patch).length === 0) { skipped++; continue; }
|
||||
|
||||
try {
|
||||
await this.platform.query<any>(
|
||||
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||
{ id: call.id, data: patch },
|
||||
);
|
||||
patched++;
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[MAINT] Backfill patch failed for ${call.id}: ${err.message}`);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(`[MAINT] Disposition+timing backfill complete: date=${date} scanned=${calls.length} patched=${patched} skipped=${skipped}`);
|
||||
return { status: 'ok', date, scanned: calls.length, patched, skipped };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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 {}
|
||||
@@ -1,213 +0,0 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -1,377 +0,0 @@
|
||||
{
|
||||
"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" } }
|
||||
]
|
||||
}
|
||||
@@ -1,344 +0,0 @@
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
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})`);
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
// ── 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;
|
||||
};
|
||||
@@ -1,56 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,244 +0,0 @@
|
||||
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`,
|
||||
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, 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 };
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { 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';
|
||||
import { FlowExecutionService } from './flow/flow-execution.service';
|
||||
import { FlowSessionService } from './flow/flow-session.service';
|
||||
import { FlowStoreService } from './flow/flow-store.service';
|
||||
import { FlowVariableService } from './flow/flow-variable.service';
|
||||
import { ToolRegistry } from './flow/tool-registry';
|
||||
import { QrService } from './qr.service';
|
||||
|
||||
@Module({
|
||||
imports: [PlatformModule, CallerResolutionModule],
|
||||
controllers: [MessagingController],
|
||||
providers: [
|
||||
MessagingService,
|
||||
MessagingConversationService,
|
||||
FlowExecutionService,
|
||||
FlowSessionService,
|
||||
FlowStoreService,
|
||||
FlowVariableService,
|
||||
ToolRegistry,
|
||||
QrService,
|
||||
{
|
||||
provide: MessagingProvider,
|
||||
useFactory: (config: ConfigService) => {
|
||||
return new GupshupProvider(config);
|
||||
},
|
||||
inject: [ConfigService],
|
||||
},
|
||||
],
|
||||
})
|
||||
export class MessagingModule {}
|
||||
@@ -1,420 +0,0 @@
|
||||
import { Injectable, Inject, Logger, Optional } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { generateText, tool, stepCountIs } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { MessagingProvider } from './providers/messaging-provider.interface';
|
||||
import { MessagingConversationService } from './messaging-conversation.service';
|
||||
import { FlowExecutionService } from './flow/flow-execution.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, ListSection, InteractiveButton } 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;
|
||||
|
||||
constructor(
|
||||
private config: ConfigService,
|
||||
private provider: MessagingProvider,
|
||||
private conversation: MessagingConversationService,
|
||||
private caller: CallerResolutionService,
|
||||
private callerContext: CallerContextService,
|
||||
private platform: PlatformGraphqlService,
|
||||
private aiConfig: AiConfigService,
|
||||
@Optional() private flowExecution: FlowExecutionService,
|
||||
) {
|
||||
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}` : '';
|
||||
|
||||
if (this.aiModel) {
|
||||
this.logger.log(`WhatsApp AI configured: ${cfg.provider}/${cfg.model}`);
|
||||
} else {
|
||||
this.logger.warn('WhatsApp AI not configured — will send fallback replies');
|
||||
}
|
||||
}
|
||||
|
||||
async handleInbound(message: NormalizedMessage): Promise<void> {
|
||||
const { phone, name, text } = message;
|
||||
const replyId = message.interactiveReply?.id;
|
||||
this.logger.log(`[WA] Inbound from ${phone} (${name}): ${text.substring(0, 100)}${replyId ? ` [reply_id=${replyId}]` : ''}`);
|
||||
|
||||
// Delegate to flow engine if published flows exist
|
||||
if (this.flowExecution?.hasFlows()) {
|
||||
this.logger.log(`[WA] Delegating to flow engine`);
|
||||
await this.flowExecution.handleMessage(message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: hardcoded AI chat (legacy — will be removed once flows are validated)
|
||||
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);
|
||||
// For interactive replies, include the selection ID so the AI can
|
||||
// extract structured data (e.g. "doc:{uuid}:{name}" → doctorId)
|
||||
let userContent = text;
|
||||
if (message.type === 'interactive_reply' && message.interactiveReply?.id) {
|
||||
userContent = `[Selected: ${message.interactiveReply.title}] (selection_id: ${message.interactiveReply.id})`;
|
||||
}
|
||||
const messages = [
|
||||
...history.map(h => ({ role: h.role as 'user' | 'assistant', content: h.content })),
|
||||
{ role: 'user' as const, content: userContent },
|
||||
];
|
||||
|
||||
// 4. Build system prompt
|
||||
const systemPrompt = this.buildSystemPrompt(callerContextPrompt, name, phone, resolved?.isNew ?? true);
|
||||
|
||||
// 5. Build tools
|
||||
const tools = this.buildTools(phone);
|
||||
|
||||
// 6. Run AI
|
||||
try {
|
||||
const result = await generateText({
|
||||
model: this.aiModel,
|
||||
system: systemPrompt,
|
||||
messages,
|
||||
tools,
|
||||
stopWhen: stepCountIs(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 {
|
||||
// Pull hospital name from theme config if available
|
||||
const hospitalName = this.config.get<string>('theme.hospitalName') ?? 'our hospital';
|
||||
|
||||
return `You are a friendly WhatsApp assistant for ${hospitalName}. You help patients with:
|
||||
- Answering questions about departments, doctors, timings, fees
|
||||
- Booking appointments
|
||||
- Checking existing appointments
|
||||
|
||||
APPOINTMENT BOOKING FLOW — follow this exact sequence:
|
||||
1. When the patient wants to book, IMMEDIATELY call send_department_list. Do NOT ask "which department" in text.
|
||||
2. When the patient picks a department (selection_id starts with "dept:"), IMMEDIATELY call send_doctor_list with the department name after "dept:".
|
||||
3. When the patient picks a doctor (selection_id starts with "doc:"), IMMEDIATELY call send_slot_list. Extract the doctorId from the selection_id format "doc:{doctorId}:{doctorName}" — use the UUID between the first and second colon as doctorId, and the text after the second colon as doctorName.
|
||||
4. When the patient picks a slot (selection_id starts with "slot:"), call send_confirm_buttons with a summary. Extract the datetime from "slot:{doctorId}:{datetime}".
|
||||
5. When the patient taps Confirm (selection_id = "confirm_booking"), call book_appointment with all collected details.
|
||||
6. After booking, send a confirmation with doctor name, date, time, and reference number.
|
||||
|
||||
CRITICAL: Always use the interactive list/button tools. Never ask questions in text when a tool exists. When a user message contains "selection_id:", parse it and call the appropriate tool immediately.
|
||||
|
||||
OTHER RULES:
|
||||
- Be concise — WhatsApp messages should be short (2-3 sentences max).
|
||||
- No markdown formatting (no **, ##, bullets). Plain text only.
|
||||
- If the patient mentions a specific department or doctor upfront, skip ahead in the flow.
|
||||
- If the patient asks something you can't help with, suggest they call ${hospitalName} 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;
|
||||
const callerService = this.caller;
|
||||
|
||||
return {
|
||||
lookup_appointments: tool({
|
||||
description: 'Look up existing appointments for the current patient.',
|
||||
inputSchema: z.object({
|
||||
patientId: z.string().optional().describe('Patient ID — omit to use current caller'),
|
||||
}),
|
||||
execute: async ({ patientId }) => {
|
||||
let pid = patientId;
|
||||
if (!pid) {
|
||||
const resolved = await callerService.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
|
||||
} } } }`,
|
||||
);
|
||||
const appts = data.appointments.edges.map((e: any) => e.node);
|
||||
logger.log(`[WA-TOOL] lookup_appointments: ${appts.length} found`);
|
||||
return { appointments: appts };
|
||||
},
|
||||
}),
|
||||
|
||||
send_department_list: tool({
|
||||
description: 'Send an interactive WhatsApp list of available departments. Call when patient wants to book but hasn\'t specified a department.',
|
||||
inputSchema: 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);
|
||||
logger.log(`[WA-TOOL] send_department_list: ${departments.length} departments`);
|
||||
return { sent: true, departments };
|
||||
},
|
||||
}),
|
||||
|
||||
send_doctor_list: tool({
|
||||
description: 'Send an interactive WhatsApp list of doctors in a department. Call after patient selects a department.',
|
||||
inputSchema: 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.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 provider.sendList(phone, `Doctors in ${department}:`, 'View Doctors', sections);
|
||||
logger.log(`[WA-TOOL] send_doctor_list: ${deptDocs.length} doctors in ${department}`);
|
||||
return { sent: true, count: deptDocs.length };
|
||||
},
|
||||
}),
|
||||
|
||||
send_slot_list: tool({
|
||||
description: 'Send available time slots for a doctor as a WhatsApp list. Call after patient selects a doctor.',
|
||||
inputSchema: z.object({
|
||||
doctorId: z.string().describe('Doctor ID from the list selection'),
|
||||
doctorName: z.string().describe('Doctor name for display'),
|
||||
date: z.string().optional().describe('Date in YYYY-MM-DD. Defaults to tomorrow.'),
|
||||
}),
|
||||
execute: async ({ doctorId, doctorName, date }) => {
|
||||
// Default to tomorrow, use IST for day-of-week matching
|
||||
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 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.` };
|
||||
}
|
||||
|
||||
// Find visit slots for the target day-of-week
|
||||
const rawSlots = doctor.visitSlots?.edges?.map((e: any) => e.node) ?? [];
|
||||
const daySlots = rawSlots.filter((s: any) => s.dayOfWeek === targetDay);
|
||||
|
||||
if (!daySlots.length) {
|
||||
return { sent: false, message: `${doctorName} is not available on ${targetDay.charAt(0) + targetDay.slice(1).toLowerCase()} (${targetDate}). Please choose a different date.` };
|
||||
}
|
||||
|
||||
// Generate hourly time slots from startTime-endTime
|
||||
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 available for ${doctorName} on ${targetDate}.` };
|
||||
}
|
||||
|
||||
const sections: ListSection[] = [{
|
||||
title: targetDate, // section title max 24 chars
|
||||
rows: timeSlots.map((s) => ({
|
||||
id: `slot:${doctorId}:${targetDate}T${s.time}:00`,
|
||||
title: s.time, // row title max 24 chars
|
||||
description: s.clinic || undefined,
|
||||
})),
|
||||
}];
|
||||
await provider.sendList(phone, `Available slots for ${doctorName}:`, 'View Slots', sections);
|
||||
logger.log(`[WA-TOOL] send_slot_list: ${timeSlots.length} slots for ${doctorName} on ${targetDate} (${targetDay})`);
|
||||
return { sent: true, slots: timeSlots.length };
|
||||
},
|
||||
}),
|
||||
|
||||
send_confirm_buttons: tool({
|
||||
description: 'Send confirmation buttons before booking. Call after all details are collected.',
|
||||
inputSchema: z.object({
|
||||
summary: z.string().describe('Appointment summary 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);
|
||||
logger.log(`[WA-TOOL] send_confirm_buttons`);
|
||||
return { sent: true };
|
||||
},
|
||||
}),
|
||||
|
||||
book_appointment: tool({
|
||||
description: 'Book the appointment after patient confirms. Only call AFTER the patient taps Confirm.',
|
||||
inputSchema: 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'),
|
||||
reason: z.string().describe('Reason for visit'),
|
||||
}),
|
||||
execute: async ({ patientName, phoneNumber, department, doctorName, scheduledAt, reason }) => {
|
||||
logger.log(`[WA-BOOK] Booking: ${patientName} → ${doctorName} @ ${scheduledAt}`);
|
||||
try {
|
||||
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
||||
const resolved = await callerService.resolve(cleanPhone, auth).catch(() => null);
|
||||
|
||||
// Conflict check: same doctor + same date
|
||||
const bookingDate = scheduledAt.split('T')[0];
|
||||
const existingAppts = await 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));
|
||||
|
||||
// Check if this patient already has a booking with this doctor on the same date
|
||||
const patientConflict = conflicts.find((a: any) =>
|
||||
a.patientName?.toLowerCase().includes(patientName.split(' ')[0].toLowerCase()),
|
||||
);
|
||||
if (patientConflict) {
|
||||
logger.log(`[WA-BOOK] Conflict: patient already booked with ${doctorName} on ${bookingDate}`);
|
||||
return { booked: false, message: `You already have an appointment with ${doctorName} on ${bookingDate}. Would you like to choose a different date?` };
|
||||
}
|
||||
|
||||
// Check if the doctor has too many appointments at this exact time
|
||||
const slotConflicts = conflicts.filter((a: any) => a.scheduledAt === scheduledAt);
|
||||
if (slotConflicts.length >= 3) {
|
||||
logger.log(`[WA-BOOK] Conflict: ${doctorName} fully booked at ${scheduledAt} (${slotConflicts.length} existing)`);
|
||||
return { booked: false, message: `${doctorName} is fully booked at this time. Please choose a different slot.` };
|
||||
}
|
||||
|
||||
let patientId = resolved?.patientId;
|
||||
if (resolved?.isNew) {
|
||||
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, status: 'SCHEDULED', doctorName, department, reasonForVisit: reason, ...(patientId ? { patientId } : {}) } },
|
||||
);
|
||||
const id = result?.createAppointment?.id;
|
||||
if (id) {
|
||||
logger.log(`[WA-BOOK] Success: appointmentId=${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.' };
|
||||
}
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
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 extends 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) {
|
||||
super();
|
||||
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 configured: appId=${this.appId} source=${this.sourceNumber}`);
|
||||
} else {
|
||||
this.logger.warn('Gupshup not configured — missing API key');
|
||||
}
|
||||
}
|
||||
|
||||
validateWebhook(body: any): boolean {
|
||||
return body?.app === this.appId || !this.appId;
|
||||
}
|
||||
|
||||
parseInbound(body: any): NormalizedMessage | null {
|
||||
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;
|
||||
|
||||
if (msgType === 'text') {
|
||||
return {
|
||||
phone, name,
|
||||
text: payload.payload?.text ?? payload.text ?? '',
|
||||
type: 'text',
|
||||
rawPayload: body,
|
||||
};
|
||||
}
|
||||
|
||||
if (msgType === 'button_reply' || msgType === 'list_reply') {
|
||||
// Gupshup sends postbackText (our ID), id can be empty string
|
||||
const replyId = payload.payload?.postbackText || payload.payload?.id || payload.payload?.reply || '';
|
||||
return {
|
||||
phone, name,
|
||||
text: payload.payload?.title ?? '',
|
||||
type: 'interactive_reply',
|
||||
interactiveReply: {
|
||||
id: replyId,
|
||||
title: payload.payload?.title ?? '',
|
||||
},
|
||||
rawPayload: body,
|
||||
};
|
||||
}
|
||||
|
||||
if (msgType === 'location') {
|
||||
return {
|
||||
phone, name,
|
||||
text: `Location: ${payload.payload?.latitude}, ${payload.payload?.longitude}`,
|
||||
type: 'location',
|
||||
rawPayload: body,
|
||||
};
|
||||
}
|
||||
|
||||
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 sendImage(to: string, imageUrl: string, caption?: string): Promise<void> {
|
||||
const message: any = {
|
||||
type: 'image',
|
||||
originalUrl: imageUrl,
|
||||
previewUrl: imageUrl,
|
||||
};
|
||||
if (caption) message.caption = caption;
|
||||
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, 500)}`);
|
||||
|
||||
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)}`);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { NormalizedMessage, InteractiveButton, ListSection } from '../types';
|
||||
|
||||
export abstract class MessagingProvider {
|
||||
/** Parse raw webhook payload into normalized message */
|
||||
abstract parseInbound(body: any): NormalizedMessage | null;
|
||||
|
||||
/** Send a plain text message */
|
||||
abstract sendText(to: string, text: string): Promise<void>;
|
||||
|
||||
/** Send interactive buttons (max 3 for WhatsApp) */
|
||||
abstract sendButtons(to: string, body: string, buttons: InteractiveButton[]): Promise<void>;
|
||||
|
||||
/** Send interactive list (max 10 rows total across sections) */
|
||||
abstract sendList(to: string, body: string, buttonText: string, sections: ListSection[]): Promise<void>;
|
||||
|
||||
/** Send an image with optional caption */
|
||||
abstract sendImage(to: string, imageUrl: string, caption?: string): Promise<void>;
|
||||
|
||||
/** Validate that inbound webhook is authentic */
|
||||
abstract validateWebhook(body: any): boolean;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import * as QRCode from 'qrcode';
|
||||
|
||||
// In-memory cache for generated QR images. Each entry expires after 24h.
|
||||
// Key: appointmentId, Value: { png: Buffer, expiresAt: number }
|
||||
const qrCache = new Map<string, { png: Buffer; expiresAt: number }>();
|
||||
const TTL_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
@Injectable()
|
||||
export class QrService {
|
||||
private readonly logger = new Logger(QrService.name);
|
||||
|
||||
// Generate a QR code PNG for an appointment
|
||||
async generate(appointmentId: string, data: {
|
||||
reference: string;
|
||||
patientName: string;
|
||||
doctorName: string;
|
||||
department: string;
|
||||
scheduledAt: string;
|
||||
}): Promise<Buffer> {
|
||||
// QR content — JSON with appointment details for kiosk scanning
|
||||
const qrContent = JSON.stringify({
|
||||
type: 'helix-appointment',
|
||||
id: appointmentId,
|
||||
ref: data.reference,
|
||||
patient: data.patientName,
|
||||
doctor: data.doctorName,
|
||||
department: data.department,
|
||||
scheduledAt: data.scheduledAt,
|
||||
});
|
||||
|
||||
const png = await QRCode.toBuffer(qrContent, {
|
||||
type: 'png',
|
||||
width: 400,
|
||||
margin: 2,
|
||||
color: { dark: '#000000', light: '#FFFFFF' },
|
||||
errorCorrectionLevel: 'M',
|
||||
});
|
||||
|
||||
// Cache for the image hosting endpoint
|
||||
qrCache.set(appointmentId, { png, expiresAt: Date.now() + TTL_MS });
|
||||
this.logger.log(`[QR] Generated for appointment ${data.reference} (${png.length} bytes)`);
|
||||
|
||||
return png;
|
||||
}
|
||||
|
||||
// Retrieve a cached QR image (for the hosting endpoint)
|
||||
get(appointmentId: string): Buffer | null {
|
||||
const entry = qrCache.get(appointmentId);
|
||||
if (!entry) return null;
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
qrCache.delete(appointmentId);
|
||||
return null;
|
||||
}
|
||||
return entry.png;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
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 }[];
|
||||
};
|
||||
@@ -1,174 +0,0 @@
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { OzonetelAgentService } from './ozonetel-agent.service';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { AgentLookupService } from '../platform/agent-lookup.service';
|
||||
|
||||
/**
|
||||
* Periodically pulls Ozonetel CDR (per-row, includes unique AgentID) and
|
||||
* enriches Call records that were created from the missed-call webhook
|
||||
* or outbound dispose without the authoritative agent relation.
|
||||
*
|
||||
* Runs every 30 minutes — well under Ozonetel's 2-req/min cap on the CDR
|
||||
* endpoints (one fetch per workspace per tick = 2/hour).
|
||||
*
|
||||
* Pairs Call rows to CDR rows by `ucid`. Only patches Calls that are
|
||||
* missing `agentId` / `transferredTo` / `transferType` — idempotent.
|
||||
*/
|
||||
const ENRICHMENT_INTERVAL_MS = 30 * 60 * 1000;
|
||||
const ENRICHMENT_DATE_WINDOW_DAYS = 2; // today + yesterday in case late-arriving calls straddle IST midnight
|
||||
|
||||
@Injectable()
|
||||
export class CdrEnrichmentService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(CdrEnrichmentService.name);
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly ozonetel: OzonetelAgentService,
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly agentLookup: AgentLookupService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
// Kick off after 60s so the sidecar isn't hammering platform during boot,
|
||||
// then settle into the 30-min cadence.
|
||||
setTimeout(() => {
|
||||
this.runOnce().catch((err) => {
|
||||
this.logger.warn(`[CDR-ENRICH] First run failed: ${err?.message ?? err}`);
|
||||
});
|
||||
}, 60_000);
|
||||
this.timer = setInterval(() => {
|
||||
this.runOnce().catch((err) => {
|
||||
this.logger.warn(`[CDR-ENRICH] Tick failed: ${err?.message ?? err}`);
|
||||
});
|
||||
}, ENRICHMENT_INTERVAL_MS);
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
}
|
||||
|
||||
async runOnce(): Promise<{ scanned: number; enriched: number; skipped: number }> {
|
||||
let scanned = 0;
|
||||
let enriched = 0;
|
||||
let skipped = 0;
|
||||
|
||||
// Walk the IST-date window. For each date, pull CDR + patch Calls.
|
||||
// Sleep 35s between dates — Ozonetel caps CDR endpoints at 2 req/min
|
||||
// and the dispose flow shares that budget (fetchCdrByUCID per outbound).
|
||||
const dates = this.recentDatesIst(ENRICHMENT_DATE_WINDOW_DAYS);
|
||||
for (let i = 0; i < dates.length; i++) {
|
||||
const date = dates[i];
|
||||
if (i > 0) await new Promise((r) => setTimeout(r, 35_000));
|
||||
const cdrRows = await this.ozonetel.fetchCDR({ date }).catch(() => []);
|
||||
if (cdrRows.length === 0) continue;
|
||||
|
||||
// Build UCID → cdr-row map so we can O(1) join per Call.
|
||||
// Ozonetel emits two identifiers per call — `UCID` (caller-leg)
|
||||
// and `monitorUCID` (agent-leg). The webhook stores `monitorUCID`,
|
||||
// but the bulk CDR rows are keyed on caller-leg `UCID`. Index
|
||||
// both so the lookup at line ~79 finds the row regardless of
|
||||
// which side was persisted. Without this, transferred inbound
|
||||
// calls never get their agent relation enriched.
|
||||
const byUcid = new Map<string, any>();
|
||||
for (const row of cdrRows) {
|
||||
const ucid = String(row.UCID ?? '').trim();
|
||||
const monitorUcid = String(row.monitorUCID ?? '').trim();
|
||||
if (ucid) byUcid.set(ucid, row);
|
||||
if (monitorUcid && monitorUcid !== ucid) byUcid.set(monitorUcid, row);
|
||||
}
|
||||
if (byUcid.size === 0) continue;
|
||||
|
||||
// Pull Calls in the same date window that are missing agent linkage
|
||||
// (i.e. ucid set, agentId null). Patch each.
|
||||
const calls = await this.fetchCallsMissingAgent(date);
|
||||
scanned += calls.length;
|
||||
|
||||
for (const call of calls) {
|
||||
const cdrRow = byUcid.get(String(call.ucid).trim());
|
||||
if (!cdrRow) { skipped++; continue; }
|
||||
|
||||
const patch: Record<string, any> = {};
|
||||
if (!call.agentId) {
|
||||
// Primary resolution: use AgentID from CDR (unique lowercase id).
|
||||
const cdrAgentId = cdrRow.AgentID;
|
||||
let uuid = cdrAgentId
|
||||
? await this.agentLookup.resolveByOzonetelId(cdrAgentId)
|
||||
: null;
|
||||
// Fallback: CDR AgentName may be a chain ("A -> B") for
|
||||
// transferred calls. Pick the final handler (last segment)
|
||||
// and look it up by display name or ozonetelId. Matches
|
||||
// the write-time resolution in missed-call-webhook.
|
||||
if (!uuid && cdrRow.AgentName) {
|
||||
const segments = String(cdrRow.AgentName).split('->').map((s) => s.trim()).filter(Boolean);
|
||||
const finalHandler = segments[segments.length - 1];
|
||||
if (finalHandler) {
|
||||
uuid =
|
||||
(await this.agentLookup.resolveByOzonetelId(finalHandler)) ??
|
||||
(await this.agentLookup.resolveByDisplayName(finalHandler));
|
||||
}
|
||||
}
|
||||
if (uuid) patch.agentId = uuid;
|
||||
if (cdrRow.AgentName) patch.agentName = cdrRow.AgentName;
|
||||
}
|
||||
if (cdrRow.TransferredTo && !call.transferredTo) patch.transferredTo = cdrRow.TransferredTo;
|
||||
if (cdrRow.TransferType && !call.transferType) patch.transferType = cdrRow.TransferType;
|
||||
|
||||
if (Object.keys(patch).length === 0) { skipped++; continue; }
|
||||
|
||||
try {
|
||||
await this.platform.query<any>(
|
||||
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||
{ id: call.id, data: patch },
|
||||
);
|
||||
enriched++;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[CDR-ENRICH] Patch failed for ${call.id}: ${err?.message ?? err}`);
|
||||
skipped++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (scanned > 0 || enriched > 0) {
|
||||
this.logger.log(`[CDR-ENRICH] Pass complete — dates=[${dates.join(',')}] scanned=${scanned} enriched=${enriched} skipped=${skipped}`);
|
||||
}
|
||||
return { scanned, enriched, skipped };
|
||||
}
|
||||
|
||||
private async fetchCallsMissingAgent(date: string): Promise<Array<{ id: string; ucid: string | null; agentId: string | null; transferredTo: string | null; transferType: string | null }>> {
|
||||
// Bound by IST day. CDR window is 15 days; we only ever need recent.
|
||||
const gte = `${date}T00:00:00+05:30`;
|
||||
const lte = `${date}T23:59:59+05:30`;
|
||||
const results: Array<any> = [];
|
||||
let after: string | null = null;
|
||||
|
||||
for (let page = 0; page < 20; page++) {
|
||||
const cursorArg: string = after ? `, after: "${after}"` : '';
|
||||
const data: any = await this.platform.query<any>(
|
||||
`{ calls(first: 200${cursorArg}, filter: {
|
||||
startedAt: { gte: "${gte}", lte: "${lte}" },
|
||||
ucid: { is: NOT_NULL },
|
||||
agentId: { is: NULL }
|
||||
}) {
|
||||
edges { node { id ucid agentId transferredTo transferType } }
|
||||
pageInfo { hasNextPage endCursor }
|
||||
} }`,
|
||||
).catch(() => ({ calls: { edges: [], pageInfo: {} } }));
|
||||
const edges = data?.calls?.edges ?? [];
|
||||
for (const e of edges) results.push(e.node);
|
||||
const pageInfo = data?.calls?.pageInfo ?? {};
|
||||
if (!pageInfo.hasNextPage) break;
|
||||
after = pageInfo.endCursor ?? null;
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
private recentDatesIst(n: number): string[] {
|
||||
const dates: string[] = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const d = new Date(Date.now() + 5.5 * 60 * 60 * 1000 - i * 24 * 60 * 60 * 1000);
|
||||
dates.push(d.toISOString().slice(0, 10));
|
||||
}
|
||||
return dates;
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
import { Controller, Get, Query, Logger, Header } from '@nestjs/common';
|
||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Controller('kookoo')
|
||||
export class KookooIvrController {
|
||||
private readonly logger = new Logger(KookooIvrController.name);
|
||||
private readonly sipId: string;
|
||||
private readonly callerId: string;
|
||||
|
||||
constructor(private telephony: TelephonyConfigService) {}
|
||||
|
||||
private get sipId(): string {
|
||||
return this.telephony.getConfig().ozonetel.sipId || '523590';
|
||||
}
|
||||
private get callerId(): string {
|
||||
return this.telephony.getConfig().ozonetel.did || '918041763265';
|
||||
constructor(private config: ConfigService) {
|
||||
this.sipId = process.env.OZONETEL_SIP_ID ?? '523590';
|
||||
this.callerId = process.env.OZONETEL_DID ?? '918041763265';
|
||||
}
|
||||
|
||||
@Get('ivr')
|
||||
@@ -22,12 +20,16 @@ export class KookooIvrController {
|
||||
const cid = query.cid ?? '';
|
||||
const status = query.status ?? '';
|
||||
|
||||
this.logger.log(`Kookoo IVR: event=${event} sid=${sid} cid=${cid} status=${status}`);
|
||||
this.logger.log(
|
||||
`Kookoo IVR: event=${event} sid=${sid} cid=${cid} status=${status}`,
|
||||
);
|
||||
|
||||
// New outbound call — customer answered, put them in a conference room
|
||||
// The room ID is based on the call SID so we can join from the browser
|
||||
if (event === 'NewCall') {
|
||||
this.logger.log(`Customer ${cid} answered — dialing DID ${this.callerId} to route to agent`);
|
||||
this.logger.log(
|
||||
`Customer ${cid} answered — dialing DID ${this.callerId} to route to agent`,
|
||||
);
|
||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<response>
|
||||
<dial record="true" timeout="30" moh="ring">${this.callerId}</dial>
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { publicEncrypt, constants as cryptoConstants } from 'crypto';
|
||||
import axios from 'axios';
|
||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||
|
||||
// Ozonetel admin API auth — login with RSA-encrypted credentials, cache JWT.
|
||||
// Used by supervisor barge endpoints to call dashboardApi.
|
||||
//
|
||||
// Auth flow (from CA-Admin source code):
|
||||
// 1. GET /api/auth/public-key → { publicKey, keyId }
|
||||
// 2. RSA-encrypt username + password with publicKey
|
||||
// 3. POST /auth/login → JWT token
|
||||
// 4. All admin API calls use: Authorization: Bearer <jwt>, userId, userName, isSuperAdmin
|
||||
|
||||
@Injectable()
|
||||
export class OzonetelAdminAuthService implements OnModuleInit {
|
||||
private readonly logger = new Logger(OzonetelAdminAuthService.name);
|
||||
private cachedToken: string | null = null;
|
||||
private cachedUserId: string | null = null;
|
||||
private cachedUserName: string | null = null;
|
||||
private tokenExpiresAt = 0;
|
||||
|
||||
constructor(private readonly telephony: TelephonyConfigService) {}
|
||||
|
||||
async onModuleInit() {
|
||||
const config = this.telephony.getConfig();
|
||||
if (config.ozonetel.adminUsername && config.ozonetel.adminPassword) {
|
||||
this.logger.log('Ozonetel admin credentials configured — will authenticate on first use');
|
||||
} else {
|
||||
this.logger.warn('Ozonetel admin credentials not configured — supervisor barge will be unavailable');
|
||||
}
|
||||
}
|
||||
|
||||
private get apiBase(): string {
|
||||
return 'https://api.cloudagent.ozonetel.com';
|
||||
}
|
||||
|
||||
async getAuthHeaders(): Promise<Record<string, string>> {
|
||||
const token = await this.getToken();
|
||||
return {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'userId': this.cachedUserId ?? '',
|
||||
'userName': this.cachedUserName ?? '',
|
||||
'isSuperAdmin': 'true',
|
||||
'dAccessType': 'false',
|
||||
};
|
||||
}
|
||||
|
||||
async getToken(): Promise<string> {
|
||||
if (this.cachedToken && Date.now() < this.tokenExpiresAt) {
|
||||
return this.cachedToken;
|
||||
}
|
||||
return this.login();
|
||||
}
|
||||
|
||||
private rsaEncrypt(publicKeyRaw: string, plaintext: string): string {
|
||||
// Ozonetel returns raw base64 without PEM headers — wrap it
|
||||
const pem = publicKeyRaw.includes('-----BEGIN')
|
||||
? publicKeyRaw
|
||||
: `-----BEGIN PUBLIC KEY-----\n${publicKeyRaw}\n-----END PUBLIC KEY-----`;
|
||||
const buffer = Buffer.from(plaintext, 'utf8');
|
||||
const encrypted = publicEncrypt(
|
||||
{ key: pem, padding: cryptoConstants.RSA_PKCS1_PADDING },
|
||||
buffer,
|
||||
);
|
||||
return encrypted.toString('base64');
|
||||
}
|
||||
|
||||
private async login(): Promise<string> {
|
||||
const config = this.telephony.getConfig();
|
||||
const { adminUsername, adminPassword } = config.ozonetel;
|
||||
|
||||
if (!adminUsername || !adminPassword) {
|
||||
throw new Error('Ozonetel admin credentials not configured');
|
||||
}
|
||||
|
||||
// Step 1: Get RSA public key
|
||||
this.logger.log('Fetching Ozonetel public key...');
|
||||
const preLoginRes = await axios.get(`${this.apiBase}/api/auth/public-key`);
|
||||
const { publicKey, keyId } = preLoginRes.data;
|
||||
|
||||
if (!publicKey || !keyId) {
|
||||
throw new Error('Failed to get Ozonetel public key');
|
||||
}
|
||||
|
||||
// Step 2: RSA-encrypt credentials using Node crypto
|
||||
const encryptedUsername = this.rsaEncrypt(publicKey, adminUsername);
|
||||
const encryptedPassword = this.rsaEncrypt(publicKey, adminPassword);
|
||||
|
||||
// Step 3: Login
|
||||
this.logger.log('Logging into Ozonetel admin portal...');
|
||||
const loginRes = await axios.post(`${this.apiBase}/auth/login`, {
|
||||
username: encryptedUsername,
|
||||
password: encryptedPassword,
|
||||
keyId,
|
||||
ltype: 'PORTAL',
|
||||
}, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const data = loginRes.data;
|
||||
if (!data.token) {
|
||||
throw new Error(`Ozonetel admin login failed: ${JSON.stringify(data)}`);
|
||||
}
|
||||
|
||||
this.cachedToken = data.token;
|
||||
this.cachedUserId = data.userId?.toString() ?? data.UserId?.toString() ?? '';
|
||||
this.cachedUserName = data.name ?? adminUsername;
|
||||
|
||||
// Decode token expiry — fallback to 6 hours
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(data.token.split('.')[1], 'base64').toString());
|
||||
this.tokenExpiresAt = (payload.exp ?? 0) * 1000 - 60_000; // refresh 1 min early
|
||||
} catch {
|
||||
this.tokenExpiresAt = Date.now() + 6 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
this.logger.log(`Ozonetel admin login successful (userId=${this.cachedUserId}, expires in ${Math.round((this.tokenExpiresAt - Date.now()) / 60000)}min)`);
|
||||
return this.cachedToken!;
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
const config = this.telephony.getConfig();
|
||||
return !!(config.ozonetel.adminUsername && config.ozonetel.adminPassword);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,29 @@
|
||||
import { Controller, Post, Get, Body, Query, Logger, HttpException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { OzonetelAgentService } from './ozonetel-agent.service';
|
||||
import { MissedQueueService } from '../worklist/missed-queue.service';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { AgentLookupService } from '../platform/agent-lookup.service';
|
||||
import { EventBusService } from '../events/event-bus.service';
|
||||
import { Topics } from '../events/event-types';
|
||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||
import { SupervisorService } from '../supervisor/supervisor.service';
|
||||
import { AgentHistoryService } from '../supervisor/agent-history.service';
|
||||
|
||||
// Convert Ozonetel "HH:MM:SS" (or null/empty) to integer seconds.
|
||||
// Returns null when input is missing or all-zero.
|
||||
function parseHmsToSec(raw: any): number | null {
|
||||
if (!raw || typeof raw !== 'string') return null;
|
||||
if (raw === '00:00:00') return null;
|
||||
const parts = raw.split(':').map((p) => parseInt(p, 10));
|
||||
if (parts.length !== 3 || parts.some((n) => isNaN(n))) return null;
|
||||
return parts[0] * 3600 + parts[1] * 60 + parts[2];
|
||||
}
|
||||
|
||||
@Controller('api/ozonetel')
|
||||
export class OzonetelAgentController {
|
||||
private readonly logger = new Logger(OzonetelAgentController.name);
|
||||
private readonly defaultAgentId: string;
|
||||
private readonly defaultAgentPassword: string;
|
||||
|
||||
private readonly defaultSipId: string;
|
||||
|
||||
constructor(
|
||||
private readonly ozonetelAgent: OzonetelAgentService,
|
||||
private readonly telephony: TelephonyConfigService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly missedQueue: MissedQueueService,
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
private readonly eventBus: EventBusService,
|
||||
private readonly supervisor: SupervisorService,
|
||||
private readonly agentLookup: AgentLookupService,
|
||||
private readonly agentHistory: AgentHistoryService,
|
||||
) {}
|
||||
|
||||
private requireAgentId(agentId: string | undefined | null): string {
|
||||
if (!agentId) throw new HttpException('agentId required', 400);
|
||||
return agentId;
|
||||
) {
|
||||
this.defaultAgentId = config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
||||
this.defaultAgentPassword = config.get<string>('OZONETEL_AGENT_PASSWORD') ?? '';
|
||||
this.defaultSipId = config.get<string>('OZONETEL_SIP_ID') ?? '521814';
|
||||
}
|
||||
|
||||
@Post('agent-login')
|
||||
@@ -75,18 +62,17 @@ export class OzonetelAgentController {
|
||||
|
||||
@Post('agent-state')
|
||||
async agentState(
|
||||
@Body() body: { agentId: string; state: 'Ready' | 'Pause'; pauseReason?: string },
|
||||
@Body() body: { state: 'Ready' | 'Pause'; pauseReason?: string },
|
||||
) {
|
||||
if (!body.state) {
|
||||
throw new HttpException('state required', 400);
|
||||
}
|
||||
const agentId = this.requireAgentId(body.agentId);
|
||||
|
||||
this.logger.log(`[AGENT-STATE] ${agentId} → ${body.state} (${body.pauseReason ?? 'none'})`);
|
||||
this.logger.log(`[AGENT-STATE] ${this.defaultAgentId} → ${body.state} (${body.pauseReason ?? 'none'})`);
|
||||
|
||||
try {
|
||||
const result = await this.ozonetelAgent.changeAgentState({
|
||||
agentId,
|
||||
agentId: this.defaultAgentId,
|
||||
state: body.state,
|
||||
pauseReason: body.pauseReason,
|
||||
});
|
||||
@@ -95,7 +81,7 @@ export class OzonetelAgentController {
|
||||
// Auto-assign missed call when agent goes Ready
|
||||
if (body.state === 'Ready') {
|
||||
try {
|
||||
const assigned = await this.missedQueue.assignNext(agentId);
|
||||
const assigned = await this.missedQueue.assignNext(this.defaultAgentId);
|
||||
if (assigned) {
|
||||
this.logger.log(`[AGENT-STATE] Auto-assigned missed call ${assigned.id}`);
|
||||
return { ...result, assignedCall: assigned };
|
||||
@@ -121,12 +107,10 @@ export class OzonetelAgentController {
|
||||
@Body() body: {
|
||||
ucid: string;
|
||||
disposition: string;
|
||||
agentId: string;
|
||||
callerPhone?: string;
|
||||
direction?: string;
|
||||
durationSec?: number;
|
||||
leadId?: string;
|
||||
leadName?: string;
|
||||
notes?: string;
|
||||
missedCallId?: string;
|
||||
},
|
||||
@@ -135,17 +119,13 @@ export class OzonetelAgentController {
|
||||
throw new HttpException('ucid and disposition required', 400);
|
||||
}
|
||||
|
||||
const agentId = this.requireAgentId(body.agentId);
|
||||
const ozonetelDisposition = this.mapToOzonetelDisposition(body.disposition);
|
||||
|
||||
// Cancel the ACW auto-dispose timer — the frontend submitted disposition
|
||||
this.supervisor.cancelAcwTimer(agentId);
|
||||
|
||||
this.logger.log(`[DISPOSE] ucid=${body.ucid} disposition=${body.disposition} → ozonetel="${ozonetelDisposition}" agentId=${agentId} callerPhone=${body.callerPhone ?? 'none'} direction=${body.direction ?? 'unknown'} leadId=${body.leadId ?? 'none'}`);
|
||||
this.logger.log(`[DISPOSE] ucid=${body.ucid} disposition=${body.disposition} → ozonetel="${ozonetelDisposition}" agentId=${this.defaultAgentId} callerPhone=${body.callerPhone ?? 'none'} direction=${body.direction ?? 'unknown'} leadId=${body.leadId ?? 'none'}`);
|
||||
|
||||
try {
|
||||
const result = await this.ozonetelAgent.setDisposition({
|
||||
agentId,
|
||||
agentId: this.defaultAgentId,
|
||||
ucid: body.ucid,
|
||||
disposition: ozonetelDisposition,
|
||||
});
|
||||
@@ -156,123 +136,20 @@ export class OzonetelAgentController {
|
||||
this.logger.error(`[DISPOSE] FAILED: ${message} ${responseData}`);
|
||||
}
|
||||
|
||||
// Create call record at dispose time for ALL answered calls
|
||||
// (inbound + outbound). The dispose endpoint fires BEFORE the
|
||||
// CDR webhook, so creating here gives us the correct agent-side
|
||||
// UCID and the agent's chosen disposition immediately. The webhook
|
||||
// arrives ~5s later and enriches with recording URL + chain name.
|
||||
if (body.callerPhone) {
|
||||
const isInbound = body.direction !== 'OUTBOUND';
|
||||
try {
|
||||
const durationSec = body.durationSec ?? 0;
|
||||
const endedAt = new Date().toISOString();
|
||||
const startedAt = durationSec > 0
|
||||
? new Date(Date.now() - durationSec * 1000).toISOString()
|
||||
: endedAt;
|
||||
const callData: Record<string, any> = {
|
||||
name: isInbound ? `Inbound — ${body.callerPhone}` : `Outbound — ${body.callerPhone}`,
|
||||
direction: isInbound ? 'INBOUND' : 'OUTBOUND',
|
||||
callStatus: 'COMPLETED',
|
||||
callerNumber: { primaryPhoneNumber: `+91${body.callerPhone.replace(/^\+?91/, '')}` },
|
||||
agentName: agentId,
|
||||
durationSec,
|
||||
disposition: body.disposition,
|
||||
startedAt,
|
||||
endedAt,
|
||||
};
|
||||
// Persist UCID so the CDR enrichment cron and backfill can
|
||||
// resolve the authoritative agent relation even if the initial
|
||||
// lookup misses.
|
||||
if (body.ucid) callData.ucid = body.ucid;
|
||||
// Resolve the agent relation from the logged-in agentId. For
|
||||
// outbound, the dispatching agent IS the handler — no transfer.
|
||||
const agentUuid = await this.agentLookup.resolveByOzonetelId(agentId);
|
||||
if (agentUuid) callData.agentId = agentUuid;
|
||||
if (body.leadId) callData.leadId = body.leadId;
|
||||
if (body.leadName) callData.leadName = body.leadName;
|
||||
|
||||
const apiKey = process.env.PLATFORM_API_KEY;
|
||||
if (apiKey) {
|
||||
const result = await this.platform.queryWithAuth<any>(
|
||||
`mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`,
|
||||
{ data: callData },
|
||||
`Bearer ${apiKey}`,
|
||||
);
|
||||
this.logger.log(`[DISPOSE] Created ${isInbound ? 'inbound' : 'outbound'} call record: ${result.createCall.id} ucid=${body.ucid} disposition=${body.disposition} phone=${body.callerPhone}`);
|
||||
|
||||
// Fetch recording URL from CDR after a delay (Ozonetel needs time to process)
|
||||
const callId = result.createCall.id;
|
||||
const ucid = body.ucid;
|
||||
const dateStr = new Date().toISOString().split('T')[0];
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
// fetchCdrByUCID is the targeted lookup — Ozonetel resolves
|
||||
// leg-pair UCIDs server-side, so the agent-facing UCID we
|
||||
// hold reliably returns the call row and its CallAudio.
|
||||
const record = await this.ozonetelAgent.fetchCdrByUCID({ date: dateStr, ucid });
|
||||
const audioUrl = record?.CallAudio || record?.AudioFile;
|
||||
// Compose a single update with recording + SLA timing
|
||||
// fields. CDR exposes HandlingTime, WrapupDuration,
|
||||
// HoldDuration as HH:MM:SS strings.
|
||||
const updateData: Record<string, any> = {};
|
||||
if (audioUrl) {
|
||||
updateData.recording = { primaryLinkUrl: audioUrl, primaryLinkLabel: 'Recording' };
|
||||
}
|
||||
const handlingSec = parseHmsToSec(record?.HandlingTime);
|
||||
const wrapupSec = parseHmsToSec(record?.WrapupDuration);
|
||||
const holdSec = parseHmsToSec(record?.HoldDuration);
|
||||
if (handlingSec !== null) updateData.handlingTimeS = handlingSec;
|
||||
if (wrapupSec !== null) updateData.acwDurationS = wrapupSec;
|
||||
if (holdSec !== null) updateData.holdDurationS = holdSec;
|
||||
// Overwrite agent relation with CDR's AgentID (the
|
||||
// actual final handler; may differ from the caller
|
||||
// agentId if Ozonetel transferred the dial).
|
||||
const cdrAgentId = record?.AgentID;
|
||||
if (cdrAgentId) {
|
||||
const cdrAgentUuid = await this.agentLookup.resolveByOzonetelId(cdrAgentId);
|
||||
if (cdrAgentUuid) updateData.agentId = cdrAgentUuid;
|
||||
if (record.AgentName) updateData.agentName = record.AgentName;
|
||||
}
|
||||
if (record?.TransferredTo) updateData.transferredTo = record.TransferredTo;
|
||||
if (record?.TransferType) updateData.transferType = record.TransferType;
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await this.platform.queryWithAuth<any>(
|
||||
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||
{ id: callId, data: updateData },
|
||||
`Bearer ${apiKey}`,
|
||||
);
|
||||
this.logger.log(`[DISPOSE] Updated outbound call ${callId} ${audioUrl ? 'with recording + ' : ''}timing (handling=${handlingSec ?? 'na'}s wrap=${wrapupSec ?? 'na'}s hold=${holdSec ?? 'na'}s)`);
|
||||
} else {
|
||||
this.logger.warn(`[DISPOSE] No CallAudio or timing for ucid=${ucid} — record=${JSON.stringify(record ?? null)}`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[DISPOSE] Failed to fetch recording for outbound call: ${err.message}`);
|
||||
}
|
||||
}, 30_000);
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[DISPOSE] Failed to create outbound call record: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle missed call callback status update
|
||||
if (body.missedCallId) {
|
||||
const statusMap: Record<string, string> = {
|
||||
APPOINTMENT_BOOKED: 'CALLBACK_COMPLETED',
|
||||
APPOINTMENT_RESCHEDULED: 'CALLBACK_COMPLETED',
|
||||
APPOINTMENT_CANCELLED: 'CALLBACK_COMPLETED',
|
||||
INFO_PROVIDED: 'CALLBACK_COMPLETED',
|
||||
FOLLOW_UP_SCHEDULED: 'CALLBACK_COMPLETED',
|
||||
CALLBACK_REQUESTED: 'CALLBACK_COMPLETED',
|
||||
NOT_INTERESTED: 'CALLBACK_COMPLETED',
|
||||
WRONG_NUMBER: 'WRONG_NUMBER',
|
||||
NO_ANSWER: 'CALLBACK_ATTEMPTED',
|
||||
};
|
||||
const newStatus = statusMap[body.disposition];
|
||||
if (newStatus) {
|
||||
try {
|
||||
await this.platform.query<any>(
|
||||
`mutation { updateCall(id: "${body.missedCallId}", data: { callbackStatus: ${newStatus}, disposition: ${body.disposition} }) { id } }`,
|
||||
`mutation { updateCall(id: "${body.missedCallId}", data: { callbackstatus: ${newStatus} }) { id } }`,
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to update missed call status: ${err}`);
|
||||
@@ -280,13 +157,9 @@ export class OzonetelAgentController {
|
||||
}
|
||||
}
|
||||
|
||||
// Inbound disposition is now handled by the call record creation
|
||||
// above — the dispose endpoint creates the record with the correct
|
||||
// disposition. No separate update-by-UCID needed.
|
||||
|
||||
// Auto-assign next missed call to this agent
|
||||
try {
|
||||
await this.missedQueue.assignNext(agentId);
|
||||
await this.missedQueue.assignNext(this.defaultAgentId);
|
||||
} catch (err) {
|
||||
this.logger.warn(`Auto-assignment after dispose failed: ${err}`);
|
||||
}
|
||||
@@ -295,7 +168,7 @@ export class OzonetelAgentController {
|
||||
this.eventBus.emit(Topics.CALL_COMPLETED, {
|
||||
callId: null,
|
||||
ucid: body.ucid,
|
||||
agentId,
|
||||
agentId: this.defaultAgentId,
|
||||
callerPhone: body.callerPhone ?? '',
|
||||
direction: body.direction ?? 'INBOUND',
|
||||
durationSec: body.durationSec ?? 0,
|
||||
@@ -310,27 +183,19 @@ export class OzonetelAgentController {
|
||||
|
||||
@Post('dial')
|
||||
async dial(
|
||||
@Body() body: { phoneNumber: string; agentId: string; campaignName?: string; leadId?: string },
|
||||
@Body() body: { phoneNumber: string; campaignName?: string; leadId?: string },
|
||||
) {
|
||||
if (!body.phoneNumber) {
|
||||
throw new HttpException('phoneNumber required', 400);
|
||||
}
|
||||
|
||||
const agentId = this.requireAgentId(body.agentId);
|
||||
const did = this.telephony.getConfig().ozonetel.did;
|
||||
const campaignName = body.campaignName
|
||||
|| this.telephony.getConfig().ozonetel.campaignName
|
||||
|| (did ? `Inbound_${did}` : '');
|
||||
const campaignName = body.campaignName ?? process.env.OZONETEL_CAMPAIGN_NAME ?? 'Inbound_918041763265';
|
||||
|
||||
if (!campaignName) {
|
||||
throw new HttpException('Campaign name not configured — set in Telephony settings or pass campaignName', 400);
|
||||
}
|
||||
|
||||
this.logger.log(`[DIAL] phone=${body.phoneNumber} campaign=${campaignName} agentId=${agentId} lead=${body.leadId ?? 'none'}`);
|
||||
this.logger.log(`[DIAL] phone=${body.phoneNumber} campaign=${campaignName} agentId=${this.defaultAgentId} lead=${body.leadId ?? 'none'}`);
|
||||
|
||||
try {
|
||||
const result = await this.ozonetelAgent.manualDial({
|
||||
agentId,
|
||||
agentId: this.defaultAgentId,
|
||||
campaignName,
|
||||
customerNumber: body.phoneNumber,
|
||||
});
|
||||
@@ -360,13 +225,6 @@ export class OzonetelAgentController {
|
||||
|
||||
try {
|
||||
const result = await this.ozonetelAgent.callControl(body);
|
||||
|
||||
if (body.action === 'HOLD') {
|
||||
this.supervisor.updateCallStatus(body.ucid, 'on-hold');
|
||||
} else if (body.action === 'UNHOLD') {
|
||||
this.supervisor.updateCallStatus(body.ucid, 'active');
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
const message = error.response?.data?.message ?? error.message ?? 'Call control failed';
|
||||
@@ -415,56 +273,23 @@ export class OzonetelAgentController {
|
||||
}
|
||||
|
||||
@Get('performance')
|
||||
async performance(@Query('date') date?: string, @Query('agentId') agentId?: string) {
|
||||
const agent = this.requireAgentId(agentId);
|
||||
async performance(@Query('date') date?: string) {
|
||||
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
||||
this.logger.log(`Performance: date=${targetDate} agent=${agent}`);
|
||||
this.logger.log(`Performance: date=${targetDate} agent=${this.defaultAgentId}`);
|
||||
|
||||
// Trigger an on-demand rollup for the requested date so the
|
||||
// AgentSession row reflects the current open session (caps at now)
|
||||
// instead of waiting up to 15 min for the background tick. Fire-and-
|
||||
// forget with a short await so we don't block the whole response on
|
||||
// cache-refresh tail but still hand the read a fresh row when Redpanda
|
||||
// is quiet. Safe to error — AgentSession just stays stale.
|
||||
await this.agentHistory.rollupSessions(targetDate).catch(() => {});
|
||||
|
||||
const [cdr, summary, aht, agentSessionBreakdown] = await Promise.all([
|
||||
const [cdr, summary, aht] = await Promise.all([
|
||||
this.ozonetelAgent.fetchCDR({ date: targetDate }),
|
||||
this.ozonetelAgent.getAgentSummary(agent, targetDate),
|
||||
this.ozonetelAgent.getAHT(agent),
|
||||
this.fetchAgentSessionTimeBreakdown(agent, targetDate),
|
||||
this.ozonetelAgent.getAgentSummary(this.defaultAgentId, targetDate),
|
||||
this.ozonetelAgent.getAHT(this.defaultAgentId),
|
||||
]);
|
||||
|
||||
// Prefer our AgentSession rollup when present — it correctly counts
|
||||
// the current OPEN session (caps at now), while Ozonetel's summaryReport
|
||||
// only tallies CLOSED login→logout pairs. Fall back to Ozonetel if
|
||||
// our rollup hasn't captured this agent yet (e.g., brand-new agent,
|
||||
// workspace without AgentEvent entity synced).
|
||||
const timeUtilization = agentSessionBreakdown ?? summary;
|
||||
const totalCalls = cdr.length;
|
||||
const inbound = cdr.filter((c: any) => c.Type === 'InBound').length;
|
||||
const outbound = cdr.filter((c: any) => c.Type === 'Manual' || c.Type === 'Progressive').length;
|
||||
const answered = cdr.filter((c: any) => c.Status === 'Answered').length;
|
||||
const missed = cdr.filter((c: any) => c.Status === 'Unanswered' || c.Status === 'NotAnswered').length;
|
||||
|
||||
// Filter CDR to this agent only — fetchCDR returns all agents' calls
|
||||
// Use case-insensitive matching — Ozonetel field casing varies
|
||||
const agentLower = agent.toLowerCase();
|
||||
const agentCdr = cdr.filter((c: any) =>
|
||||
(c.AgentID ?? '').toLowerCase() === agentLower ||
|
||||
(c.AgentName ?? '').toLowerCase() === agentLower,
|
||||
);
|
||||
this.logger.log(`[PERFORMANCE] CDR total=${cdr.length} agentFiltered=${agentCdr.length} agent="${agent}"`);
|
||||
if (cdr.length > 0 && agentCdr.length === 0) {
|
||||
const sampleIds = cdr.slice(0, 3).map((c: any) => `AgentID="${c.AgentID}" AgentName="${c.AgentName}"`);
|
||||
this.logger.warn(`[PERFORMANCE] No CDR match for agent "${agent}". Sample CDR agents: ${sampleIds.join(', ')}`);
|
||||
}
|
||||
|
||||
const totalCalls = agentCdr.length;
|
||||
const inbound = agentCdr.filter((c: any) => (c.Type ?? '').toLowerCase() === 'inbound').length;
|
||||
const outbound = agentCdr.filter((c: any) => {
|
||||
const type = (c.Type ?? '').toLowerCase();
|
||||
return type === 'manual' || type === 'progressive' || type === 'outbound';
|
||||
}).length;
|
||||
const answered = agentCdr.filter((c: any) => (c.Status ?? '').toLowerCase() === 'answered').length;
|
||||
const missed = agentCdr.filter((c: any) => (c.Status ?? '').toLowerCase() === 'notanswered').length;
|
||||
|
||||
const talkTimes = agentCdr
|
||||
const talkTimes = cdr
|
||||
.filter((c: any) => c.TalkTime && c.TalkTime !== '00:00:00')
|
||||
.map((c: any) => {
|
||||
const parts = c.TalkTime.split(':').map(Number);
|
||||
@@ -475,12 +300,12 @@ export class OzonetelAgentController {
|
||||
: 0;
|
||||
|
||||
const dispositions: Record<string, number> = {};
|
||||
for (const c of agentCdr) {
|
||||
for (const c of cdr) {
|
||||
const d = (c as any).Disposition || 'No Disposition';
|
||||
dispositions[d] = (dispositions[d] ?? 0) + 1;
|
||||
}
|
||||
|
||||
const appointmentsBooked = agentCdr.filter((c: any) =>
|
||||
const appointmentsBooked = cdr.filter((c: any) =>
|
||||
c.Disposition?.toLowerCase().includes('appointment'),
|
||||
).length;
|
||||
|
||||
@@ -491,7 +316,7 @@ export class OzonetelAgentController {
|
||||
avgHandlingTime: aht,
|
||||
conversionRate: totalCalls > 0 ? Math.round((appointmentsBooked / totalCalls) * 100) : 0,
|
||||
appointmentsBooked,
|
||||
timeUtilization,
|
||||
timeUtilization: summary,
|
||||
dispositions,
|
||||
};
|
||||
}
|
||||
@@ -500,63 +325,12 @@ export class OzonetelAgentController {
|
||||
// Campaign only has 'General Enquiry' configured currently
|
||||
const map: Record<string, string> = {
|
||||
'APPOINTMENT_BOOKED': 'General Enquiry',
|
||||
'APPOINTMENT_RESCHEDULED': 'General Enquiry',
|
||||
'APPOINTMENT_CANCELLED': 'General Enquiry',
|
||||
'FOLLOW_UP_SCHEDULED': 'General Enquiry',
|
||||
'INFO_PROVIDED': 'General Enquiry',
|
||||
'NO_ANSWER': 'General Enquiry',
|
||||
'WRONG_NUMBER': 'General Enquiry',
|
||||
'NOT_INTERESTED': 'General Enquiry',
|
||||
'CALLBACK_REQUESTED': 'General Enquiry',
|
||||
};
|
||||
return map[disposition] ?? 'General Enquiry';
|
||||
}
|
||||
|
||||
// Convert our AgentSession rollup (seconds per category) into the HH:MM:SS
|
||||
// shape the frontend expects — so My Performance gets LOGIN TIME with the
|
||||
// current open session included, not just closed sessions from Ozonetel.
|
||||
private async fetchAgentSessionTimeBreakdown(ozonetelAgentId: string, date: string): Promise<{
|
||||
totalLoginDuration: string;
|
||||
totalBusyTime: string;
|
||||
totalIdleTime: string;
|
||||
totalPauseTime: string;
|
||||
totalWrapupTime: string;
|
||||
totalDialTime: string;
|
||||
} | null> {
|
||||
try {
|
||||
const agentUuid = await this.agentLookup.resolveByOzonetelId(ozonetelAgentId);
|
||||
if (!agentUuid) return null;
|
||||
const data = await this.platform.query<any>(
|
||||
`{ agentSessions(first: 1, filter: {
|
||||
agentId: { eq: "${agentUuid}" },
|
||||
date: { eq: "${date}" }
|
||||
}) { edges { node {
|
||||
loginDurationS busyTimeS idleTimeS pauseTimeS wrapupTimeS dialTimeS
|
||||
} } } }`,
|
||||
);
|
||||
const node = data?.agentSessions?.edges?.[0]?.node;
|
||||
if (!node) return null;
|
||||
const hms = (sec: number | null | undefined): string => {
|
||||
const s = Math.max(0, Math.round(sec ?? 0));
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const r = s % 60;
|
||||
return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${r.toString().padStart(2, '0')}`;
|
||||
};
|
||||
// If the entire rollup is zero, treat as "no data yet" — fall back
|
||||
// to Ozonetel's summaryReport so the KPI isn't all zeroes.
|
||||
const total = (node.loginDurationS ?? 0) + (node.busyTimeS ?? 0) + (node.idleTimeS ?? 0) + (node.pauseTimeS ?? 0) + (node.wrapupTimeS ?? 0);
|
||||
if (total === 0) return null;
|
||||
return {
|
||||
totalLoginDuration: hms(node.loginDurationS),
|
||||
totalBusyTime: hms(node.busyTimeS),
|
||||
totalIdleTime: hms(node.idleTimeS),
|
||||
totalPauseTime: hms(node.pauseTimeS),
|
||||
totalWrapupTime: hms(node.wrapupTimeS),
|
||||
totalDialTime: hms(node.dialTimeS),
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,15 +2,13 @@ import { Module, forwardRef } from '@nestjs/common';
|
||||
import { OzonetelAgentController } from './ozonetel-agent.controller';
|
||||
import { OzonetelAgentService } from './ozonetel-agent.service';
|
||||
import { KookooIvrController } from './kookoo-ivr.controller';
|
||||
import { CdrEnrichmentService } from './cdr-enrichment.service';
|
||||
import { WorklistModule } from '../worklist/worklist.module';
|
||||
import { PlatformModule } from '../platform/platform.module';
|
||||
import { SupervisorModule } from '../supervisor/supervisor.module';
|
||||
|
||||
@Module({
|
||||
imports: [PlatformModule, forwardRef(() => WorklistModule), forwardRef(() => SupervisorModule)],
|
||||
imports: [PlatformModule, forwardRef(() => WorklistModule)],
|
||||
controllers: [OzonetelAgentController, KookooIvrController],
|
||||
providers: [OzonetelAgentService, CdrEnrichmentService],
|
||||
exports: [OzonetelAgentService, CdrEnrichmentService],
|
||||
providers: [OzonetelAgentService],
|
||||
exports: [OzonetelAgentService],
|
||||
})
|
||||
export class OzonetelAgentModule {}
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
/**
|
||||
* Ozonetel Agent Service — unit tests
|
||||
*
|
||||
* QA coverage: agent auth (login/logout), manual dial, set disposition,
|
||||
* change agent state, call control. Covers the Ozonetel HTTP layer that
|
||||
* backs TC-IB-01→06, TC-OB-01→06, TC-FU-01→02 via disposition flows.
|
||||
*
|
||||
* axios is mocked — no real HTTP to Ozonetel.
|
||||
*/
|
||||
import { Test } from '@nestjs/testing';
|
||||
import { OzonetelAgentService } from './ozonetel-agent.service';
|
||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||
import axios from 'axios';
|
||||
import {
|
||||
AGENT_AUTH_LOGIN_SUCCESS,
|
||||
AGENT_AUTH_LOGIN_ALREADY,
|
||||
AGENT_AUTH_LOGOUT_SUCCESS,
|
||||
AGENT_AUTH_INVALID,
|
||||
DISPOSITION_SET_DURING_CALL,
|
||||
DISPOSITION_SET_AFTER_CALL,
|
||||
DISPOSITION_INVALID_UCID,
|
||||
} from '../__fixtures__/ozonetel-payloads';
|
||||
|
||||
jest.mock('axios');
|
||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||
|
||||
describe('OzonetelAgentService', () => {
|
||||
let service: OzonetelAgentService;
|
||||
|
||||
const mockTelephonyConfig = {
|
||||
exotel: {
|
||||
apiKey: 'KK_TEST_KEY',
|
||||
accountSid: 'test_account',
|
||||
subdomain: 'in1-ccaas-api.ozonetel.com',
|
||||
},
|
||||
ozonetel: {
|
||||
agentId: 'global',
|
||||
agentPassword: 'Test123$',
|
||||
sipId: '523590',
|
||||
campaignName: 'Inbound_918041763400',
|
||||
did: '918041763400',
|
||||
},
|
||||
sip: { domain: 'blr-pub-rtc4.ozonetel.com', wsPort: '444' },
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock token generation (needed before most API calls)
|
||||
mockedAxios.post.mockImplementation(async (url: string, data?: any) => {
|
||||
if (url.includes('generateToken')) {
|
||||
return { data: { token: 'mock-bearer-token', status: 'success' } };
|
||||
}
|
||||
return { data: {} };
|
||||
});
|
||||
|
||||
const module = await Test.createTestingModule({
|
||||
providers: [
|
||||
OzonetelAgentService,
|
||||
{
|
||||
provide: TelephonyConfigService,
|
||||
useValue: { getConfig: () => mockTelephonyConfig },
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get(OzonetelAgentService);
|
||||
});
|
||||
|
||||
// ── Agent Login ──────────────────────────────────────────────
|
||||
|
||||
describe('loginAgent', () => {
|
||||
it('should send correct params to Ozonetel auth endpoint', async () => {
|
||||
mockedAxios.post.mockResolvedValueOnce({ data: AGENT_AUTH_LOGIN_SUCCESS });
|
||||
|
||||
const result = await service.loginAgent({
|
||||
agentId: 'global',
|
||||
password: 'Test123$',
|
||||
phoneNumber: '523590',
|
||||
mode: 'blended',
|
||||
});
|
||||
|
||||
expect(result).toEqual(AGENT_AUTH_LOGIN_SUCCESS);
|
||||
|
||||
const authCall = mockedAxios.post.mock.calls[0];
|
||||
expect(authCall[0]).toContain('AgentAuthenticationV2');
|
||||
// Body is URLSearchParams string
|
||||
expect(authCall[1]).toContain('userName=test_account');
|
||||
expect(authCall[1]).toContain('apiKey=KK_TEST_KEY');
|
||||
expect(authCall[1]).toContain('phoneNumber=523590');
|
||||
expect(authCall[1]).toContain('action=login');
|
||||
expect(authCall[1]).toContain('mode=blended');
|
||||
// Basic auth
|
||||
expect(authCall[2]?.auth).toEqual({ username: 'global', password: 'Test123$' });
|
||||
});
|
||||
|
||||
it('should auto-retry on "already logged in" response', async () => {
|
||||
// First call: already logged in
|
||||
mockedAxios.post
|
||||
.mockResolvedValueOnce({ data: AGENT_AUTH_LOGIN_ALREADY })
|
||||
// Logout call
|
||||
.mockResolvedValueOnce({ data: AGENT_AUTH_LOGOUT_SUCCESS })
|
||||
// Re-login call
|
||||
.mockResolvedValueOnce({ data: AGENT_AUTH_LOGIN_SUCCESS });
|
||||
|
||||
const result = await service.loginAgent({
|
||||
agentId: 'global',
|
||||
password: 'Test123$',
|
||||
phoneNumber: '523590',
|
||||
});
|
||||
|
||||
// Should have made 3 calls: login, logout, re-login
|
||||
expect(mockedAxios.post).toHaveBeenCalledTimes(3);
|
||||
expect(result).toEqual(AGENT_AUTH_LOGIN_SUCCESS);
|
||||
});
|
||||
|
||||
it('should throw on invalid authentication', async () => {
|
||||
mockedAxios.post.mockRejectedValueOnce({
|
||||
response: { status: 401, data: AGENT_AUTH_INVALID },
|
||||
});
|
||||
|
||||
await expect(
|
||||
service.loginAgent({ agentId: 'bad', password: 'wrong', phoneNumber: '000' }),
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Agent Logout ─────────────────────────────────────────────
|
||||
|
||||
describe('logoutAgent', () => {
|
||||
it('should send logout action', async () => {
|
||||
mockedAxios.post.mockResolvedValueOnce({ data: AGENT_AUTH_LOGOUT_SUCCESS });
|
||||
|
||||
const result = await service.logoutAgent({ agentId: 'global', password: 'Test123$' });
|
||||
|
||||
expect(result).toEqual(AGENT_AUTH_LOGOUT_SUCCESS);
|
||||
const call = mockedAxios.post.mock.calls[0];
|
||||
expect(call[1]).toContain('action=logout');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Manual Dial ──────────────────────────────────────────────
|
||||
|
||||
describe('manualDial', () => {
|
||||
it('should send correct params with bearer token', async () => {
|
||||
// First call: token generation, second: manual dial
|
||||
mockedAxios.post
|
||||
.mockResolvedValueOnce({ data: { token: 'mock-token', status: 'success' } })
|
||||
.mockResolvedValueOnce({
|
||||
data: { status: 'success', ucid: '31712345678901234', message: 'Call initiated' },
|
||||
});
|
||||
|
||||
const result = await service.manualDial({
|
||||
agentId: 'global',
|
||||
campaignName: 'Inbound_918041763400',
|
||||
customerNumber: '9949879837',
|
||||
});
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ status: 'success' }));
|
||||
|
||||
// The dial call (second post)
|
||||
const dialCall = mockedAxios.post.mock.calls[1];
|
||||
expect(dialCall[0]).toContain('AgentManualDial');
|
||||
expect(dialCall[1]).toMatchObject({
|
||||
userName: 'test_account',
|
||||
agentID: 'global',
|
||||
campaignName: 'Inbound_918041763400',
|
||||
customerNumber: '9949879837',
|
||||
});
|
||||
expect(dialCall[2]?.headers?.Authorization).toBe('Bearer mock-token');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Set Disposition ──────────────────────────────────────────
|
||||
|
||||
describe('setDisposition', () => {
|
||||
it('should send disposition with correct fields', async () => {
|
||||
mockedAxios.post
|
||||
.mockResolvedValueOnce({ data: { token: 'mock-token', status: 'success' } })
|
||||
.mockResolvedValueOnce({ data: DISPOSITION_SET_AFTER_CALL });
|
||||
|
||||
const result = await service.setDisposition({
|
||||
agentId: 'global',
|
||||
ucid: '31712345678901234',
|
||||
disposition: 'General Enquiry',
|
||||
});
|
||||
|
||||
expect(result).toEqual(DISPOSITION_SET_AFTER_CALL);
|
||||
|
||||
const dispCall = mockedAxios.post.mock.calls[1];
|
||||
expect(dispCall[0]).toContain('DispositionAPIV2');
|
||||
expect(dispCall[1]).toMatchObject({
|
||||
userName: 'test_account',
|
||||
agentID: 'global',
|
||||
ucid: '31712345678901234',
|
||||
action: 'Set',
|
||||
disposition: 'General Enquiry',
|
||||
did: '918041763400',
|
||||
autoRelease: 'true',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle queued disposition (during call)', async () => {
|
||||
mockedAxios.post
|
||||
.mockResolvedValueOnce({ data: { token: 'mock-token', status: 'success' } })
|
||||
.mockResolvedValueOnce({ data: DISPOSITION_SET_DURING_CALL });
|
||||
|
||||
const result = await service.setDisposition({
|
||||
agentId: 'global',
|
||||
ucid: '31712345678901234',
|
||||
disposition: 'Appointment Booked',
|
||||
});
|
||||
|
||||
expect(result.status).toBe('Success');
|
||||
expect(result.message).toContain('Queued');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Change Agent State ───────────────────────────────────────
|
||||
|
||||
describe('changeAgentState', () => {
|
||||
it('should send Ready state', async () => {
|
||||
mockedAxios.post
|
||||
.mockResolvedValueOnce({ data: { token: 'mock-token', status: 'success' } })
|
||||
.mockResolvedValueOnce({ data: { status: 'success', message: 'State changed' } });
|
||||
|
||||
await service.changeAgentState({ agentId: 'global', state: 'Ready' });
|
||||
|
||||
const stateCall = mockedAxios.post.mock.calls[1];
|
||||
expect(stateCall[0]).toContain('changeAgentState');
|
||||
expect(stateCall[1]).toMatchObject({
|
||||
userName: 'test_account',
|
||||
agentId: 'global',
|
||||
state: 'Ready',
|
||||
});
|
||||
});
|
||||
|
||||
it('should include pauseReason when pausing', async () => {
|
||||
mockedAxios.post
|
||||
.mockResolvedValueOnce({ data: { token: 'mock-token', status: 'success' } })
|
||||
.mockResolvedValueOnce({ data: { status: 'success', message: 'Agent paused' } });
|
||||
|
||||
await service.changeAgentState({ agentId: 'global', state: 'Pause', pauseReason: 'Break' });
|
||||
|
||||
const stateCall = mockedAxios.post.mock.calls[1];
|
||||
expect(stateCall[1]).toMatchObject({ pauseReason: 'Break' });
|
||||
});
|
||||
});
|
||||
|
||||
// ── Token caching ────────────────────────────────────────────
|
||||
|
||||
describe('token management', () => {
|
||||
it('should cache token and reuse for subsequent calls', async () => {
|
||||
// First call generates token
|
||||
mockedAxios.post
|
||||
.mockResolvedValueOnce({ data: { token: 'cached-token', status: 'success' } })
|
||||
.mockResolvedValueOnce({ data: { status: 'success' } })
|
||||
// Second API call should reuse token
|
||||
.mockResolvedValueOnce({ data: { status: 'success' } });
|
||||
|
||||
await service.manualDial({ agentId: 'a', campaignName: 'c', customerNumber: '1' });
|
||||
await service.manualDial({ agentId: 'a', campaignName: 'c', customerNumber: '2' });
|
||||
|
||||
// Token generation should only be called once
|
||||
const tokenCalls = mockedAxios.post.mock.calls.filter(c => c[0].includes('generateToken'));
|
||||
expect(tokenCalls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,27 +1,21 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||
|
||||
@Injectable()
|
||||
export class OzonetelAgentService {
|
||||
private readonly logger = new Logger(OzonetelAgentService.name);
|
||||
private readonly apiDomain: string;
|
||||
private readonly apiKey: string;
|
||||
private readonly accountId: string;
|
||||
private cachedToken: string | null = null;
|
||||
private tokenExpiry: number = 0;
|
||||
|
||||
constructor(private telephony: TelephonyConfigService) {}
|
||||
|
||||
// Read-through getters so admin updates to telephony.json take effect
|
||||
// immediately without a sidecar restart. The default for apiDomain is
|
||||
// preserved here because the legacy env-var path used a different default
|
||||
// ('in1-ccaas-api.ozonetel.com') than the rest of the Exotel config.
|
||||
private get apiDomain(): string {
|
||||
return this.telephony.getConfig().exotel.subdomain || 'in1-ccaas-api.ozonetel.com';
|
||||
}
|
||||
private get apiKey(): string {
|
||||
return this.telephony.getConfig().exotel.apiKey;
|
||||
}
|
||||
private get accountId(): string {
|
||||
return this.telephony.getConfig().exotel.accountSid;
|
||||
constructor(private config: ConfigService) {
|
||||
this.apiDomain =
|
||||
config.get<string>('exotel.subdomain') ?? 'in1-ccaas-api.ozonetel.com';
|
||||
this.apiKey = config.get<string>('exotel.apiKey') ?? '';
|
||||
this.accountId = config.get<string>('exotel.accountSid') ?? '';
|
||||
}
|
||||
|
||||
private async getToken(): Promise<string> {
|
||||
@@ -36,9 +30,13 @@ export class OzonetelAgentService {
|
||||
const url = `https://${this.apiDomain}/ca_apis/CAToken/generateToken`;
|
||||
this.logger.log('Generating CloudAgent API token');
|
||||
|
||||
const response = await axios.post(url, { userName: this.accountId }, {
|
||||
const response = await axios.post(
|
||||
url,
|
||||
{ userName: this.accountId },
|
||||
{
|
||||
headers: { apiKey: this.apiKey, 'Content-Type': 'application/json' },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const data = response.data;
|
||||
if (data.token) {
|
||||
@@ -56,7 +54,6 @@ export class OzonetelAgentService {
|
||||
this.tokenExpiry = 0;
|
||||
}
|
||||
|
||||
|
||||
async loginAgent(params: {
|
||||
agentId: string;
|
||||
password: string;
|
||||
@@ -65,7 +62,9 @@ export class OzonetelAgentService {
|
||||
}): Promise<{ status: string; message: string }> {
|
||||
const url = `https://${this.apiDomain}/CAServices/AgentAuthenticationV2/index.php`;
|
||||
|
||||
this.logger.log(`Logging in agent ${params.agentId} with phone ${params.phoneNumber}`);
|
||||
this.logger.log(
|
||||
`Logging in agent ${params.agentId} with phone ${params.phoneNumber}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const response = await axios.post(
|
||||
@@ -92,10 +91,18 @@ export class OzonetelAgentService {
|
||||
const data = response.data;
|
||||
|
||||
// "already logged in" — force logout + re-login to refresh SIP phone mapping
|
||||
if (data.status === 'error' && data.message?.includes('already logged in')) {
|
||||
this.logger.log(`Agent ${params.agentId} already logged in — forcing logout + re-login`);
|
||||
if (
|
||||
data.status === 'error' &&
|
||||
data.message?.includes('already logged in')
|
||||
) {
|
||||
this.logger.log(
|
||||
`Agent ${params.agentId} already logged in — forcing logout + re-login`,
|
||||
);
|
||||
try {
|
||||
await this.logoutAgent({ agentId: params.agentId, password: params.password });
|
||||
await this.logoutAgent({
|
||||
agentId: params.agentId,
|
||||
password: params.password,
|
||||
});
|
||||
const retryResponse = await axios.post(
|
||||
url,
|
||||
new URLSearchParams({
|
||||
@@ -111,7 +118,9 @@ export class OzonetelAgentService {
|
||||
auth: { username: params.agentId, password: params.password },
|
||||
},
|
||||
);
|
||||
this.logger.log(`Agent re-login response: ${JSON.stringify(retryResponse.data)}`);
|
||||
this.logger.log(
|
||||
`Agent re-login response: ${JSON.stringify(retryResponse.data)}`,
|
||||
);
|
||||
return retryResponse.data;
|
||||
} catch (retryErr: any) {
|
||||
this.logger.error(`Agent re-login failed: ${retryErr.message}`);
|
||||
@@ -135,27 +144,35 @@ export class OzonetelAgentService {
|
||||
}): Promise<{ status: string; ucid?: string; message?: string }> {
|
||||
const url = `https://${this.apiDomain}/ca_apis/AgentManualDial`;
|
||||
|
||||
this.logger.log(`Manual dial: agent=${params.agentId} campaign=${params.campaignName} number=${params.customerNumber}`);
|
||||
this.logger.log(
|
||||
`Manual dial: agent=${params.agentId} campaign=${params.campaignName} number=${params.customerNumber}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const token = await this.getToken();
|
||||
const response = await axios.post(url, {
|
||||
const response = await axios.post(
|
||||
url,
|
||||
{
|
||||
userName: this.accountId,
|
||||
agentID: params.agentId,
|
||||
campaignName: params.campaignName,
|
||||
customerNumber: params.customerNumber,
|
||||
UCID: 'true',
|
||||
}, {
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
this.logger.log(`Manual dial response: ${JSON.stringify(response.data)}`);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 401) this.invalidateToken();
|
||||
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
||||
const responseData = error?.response?.data
|
||||
? JSON.stringify(error.response.data)
|
||||
: '';
|
||||
this.logger.error(`Manual dial failed: ${error.message} ${responseData}`);
|
||||
throw error;
|
||||
}
|
||||
@@ -168,7 +185,9 @@ export class OzonetelAgentService {
|
||||
}): Promise<{ status: string; message: string }> {
|
||||
const url = `https://${this.apiDomain}/ca_apis/changeAgentState`;
|
||||
|
||||
this.logger.log(`Changing agent ${params.agentId} state to ${params.state}`);
|
||||
this.logger.log(
|
||||
`Changing agent ${params.agentId} state to ${params.state}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const body: Record<string, string> = {
|
||||
@@ -188,11 +207,17 @@ export class OzonetelAgentService {
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Change agent state response: ${JSON.stringify(response.data)}`);
|
||||
this.logger.log(
|
||||
`Change agent state response: ${JSON.stringify(response.data)}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
||||
this.logger.error(`Change agent state failed: ${error.message} ${responseData}`);
|
||||
const responseData = error?.response?.data
|
||||
? JSON.stringify(error.response.data)
|
||||
: '';
|
||||
this.logger.error(
|
||||
`Change agent state failed: ${error.message} ${responseData}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -203,13 +228,17 @@ export class OzonetelAgentService {
|
||||
disposition: string;
|
||||
}): Promise<{ status: string; message?: string; details?: string }> {
|
||||
const url = `https://${this.apiDomain}/ca_apis/DispositionAPIV2`;
|
||||
const did = this.telephony.getConfig().ozonetel.did;
|
||||
const did = process.env.OZONETEL_DID ?? '918041763265';
|
||||
|
||||
this.logger.log(`Set disposition: agent=${params.agentId} ucid=${params.ucid} disposition=${params.disposition}`);
|
||||
this.logger.log(
|
||||
`Set disposition: agent=${params.agentId} ucid=${params.ucid} disposition=${params.disposition}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const token = await this.getToken();
|
||||
const response = await axios.post(url, {
|
||||
const response = await axios.post(
|
||||
url,
|
||||
{
|
||||
userName: this.accountId,
|
||||
agentID: params.agentId,
|
||||
did,
|
||||
@@ -217,18 +246,26 @@ export class OzonetelAgentService {
|
||||
action: 'Set',
|
||||
disposition: params.disposition,
|
||||
autoRelease: 'true',
|
||||
}, {
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
this.logger.log(`Set disposition response: ${JSON.stringify(response.data)}`);
|
||||
this.logger.log(
|
||||
`Set disposition response: ${JSON.stringify(response.data)}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
||||
this.logger.error(`Set disposition failed: ${error.message} ${responseData}`);
|
||||
const responseData = error?.response?.data
|
||||
? JSON.stringify(error.response.data)
|
||||
: '';
|
||||
this.logger.error(
|
||||
`Set disposition failed: ${error.message} ${responseData}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -239,11 +276,12 @@ export class OzonetelAgentService {
|
||||
conferenceNumber?: string;
|
||||
}): Promise<{ status: string; message: string; ucid?: string }> {
|
||||
const url = `https://${this.apiDomain}/ca_apis/CallControl_V4`;
|
||||
const tcfg = this.telephony.getConfig().ozonetel;
|
||||
const did = tcfg.did;
|
||||
const agentPhoneName = tcfg.sipId;
|
||||
const did = process.env.OZONETEL_DID ?? '918041763265';
|
||||
const agentPhoneName = process.env.OZONETEL_SIP_ID ?? '523590';
|
||||
|
||||
this.logger.log(`Call control: action=${params.action} ucid=${params.ucid} conference=${params.conferenceNumber ?? 'none'}`);
|
||||
this.logger.log(
|
||||
`Call control: action=${params.action} ucid=${params.ucid} conference=${params.conferenceNumber ?? 'none'}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const token = await this.getToken();
|
||||
@@ -265,11 +303,17 @@ export class OzonetelAgentService {
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Call control response: ${JSON.stringify(response.data)}`);
|
||||
this.logger.log(
|
||||
`Call control response: ${JSON.stringify(response.data)}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
||||
this.logger.error(`Call control failed: ${error.message} ${responseData}`);
|
||||
const responseData = error?.response?.data
|
||||
? JSON.stringify(error.response.data)
|
||||
: '';
|
||||
this.logger.error(
|
||||
`Call control failed: ${error.message} ${responseData}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -292,11 +336,17 @@ export class OzonetelAgentService {
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.log(`Recording control response: ${JSON.stringify(response.data)}`);
|
||||
this.logger.log(
|
||||
`Recording control response: ${JSON.stringify(response.data)}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
||||
this.logger.error(`Recording control failed: ${error.message} ${responseData}`);
|
||||
const responseData = error?.response?.data
|
||||
? JSON.stringify(error.response.data)
|
||||
: '';
|
||||
this.logger.error(
|
||||
`Recording control failed: ${error.message} ${responseData}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -305,7 +355,8 @@ export class OzonetelAgentService {
|
||||
fromTime?: string;
|
||||
toTime?: string;
|
||||
campaignName?: string;
|
||||
}): Promise<Array<{
|
||||
}): Promise<
|
||||
Array<{
|
||||
monitorUCID: string;
|
||||
type: string;
|
||||
status: string;
|
||||
@@ -316,7 +367,8 @@ export class OzonetelAgentService {
|
||||
agent: string;
|
||||
hangupBy: string;
|
||||
callTime: string;
|
||||
}>> {
|
||||
}>
|
||||
> {
|
||||
const url = `https://${this.apiDomain}/ca_apis/abandonCalls`;
|
||||
|
||||
this.logger.log('Fetching abandon calls');
|
||||
@@ -339,7 +391,9 @@ export class OzonetelAgentService {
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
this.logger.log(`Abandon calls response: ${JSON.stringify(data).substring(0, 200)}`);
|
||||
this.logger.log(
|
||||
`Abandon calls response: ${JSON.stringify(data).substring(0, 200)}`,
|
||||
);
|
||||
if (data.status === 'success' && Array.isArray(data.message)) {
|
||||
return data.message;
|
||||
}
|
||||
@@ -388,55 +442,18 @@ export class OzonetelAgentService {
|
||||
}
|
||||
return [];
|
||||
} catch (error: any) {
|
||||
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
||||
const responseData = error?.response?.data
|
||||
? JSON.stringify(error.response.data)
|
||||
: '';
|
||||
this.logger.error(`Fetch CDR failed: ${error.message} ${responseData}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch a single CDR record by UCID. Preferred over fetchCDR + .find()
|
||||
// for recording lookups — Ozonetel resolves leg-pair UCIDs internally,
|
||||
// so the agent-side UCID we hold reliably returns the call row.
|
||||
// Same rate limit as fetchCDR (2 req/min, 15-day window).
|
||||
async fetchCdrByUCID(params: { date: string; ucid: string }): Promise<Record<string, any> | null> {
|
||||
const url = `https://${this.apiDomain}/ca_reports/fetchCdrByUCID`;
|
||||
this.logger.log(`Fetch CDR by UCID: ucid=${params.ucid} date=${params.date}`);
|
||||
|
||||
try {
|
||||
const token = await this.getToken();
|
||||
const body = {
|
||||
userName: this.accountId,
|
||||
fromDate: `${params.date} 00:00:00`,
|
||||
toDate: `${params.date} 23:59:59`,
|
||||
ucid: params.ucid,
|
||||
};
|
||||
|
||||
const response = await axios({
|
||||
method: 'GET',
|
||||
url,
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
data: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = response.data;
|
||||
if (data.status === 'success' && Array.isArray(data.details) && data.details.length > 0) {
|
||||
return data.details[0];
|
||||
}
|
||||
if (data.status === 'success' && data.details && !Array.isArray(data.details)) {
|
||||
return data.details;
|
||||
}
|
||||
return null;
|
||||
} catch (error: any) {
|
||||
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
||||
this.logger.error(`Fetch CDR by UCID failed: ${error.message} ${responseData}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getAgentSummary(agentId: string, date: string): Promise<{
|
||||
async getAgentSummary(
|
||||
agentId: string,
|
||||
date: string,
|
||||
): Promise<{
|
||||
totalLoginDuration: string;
|
||||
totalBusyTime: string;
|
||||
totalIdleTime: string;
|
||||
@@ -465,7 +482,9 @@ export class OzonetelAgentService {
|
||||
|
||||
const data = response.data;
|
||||
if (data.status === 'success' && data.message) {
|
||||
const record = Array.isArray(data.message) ? data.message[0] : data.message;
|
||||
const record = Array.isArray(data.message)
|
||||
? data.message[0]
|
||||
: data.message;
|
||||
return {
|
||||
totalLoginDuration: record.TotalLoginDuration ?? '00:00:00',
|
||||
totalBusyTime: record.TotalBusyTime ?? '00:00:00',
|
||||
@@ -542,7 +561,9 @@ export class OzonetelAgentService {
|
||||
},
|
||||
);
|
||||
|
||||
this.logger.log(`Agent logout response: ${JSON.stringify(response.data)}`);
|
||||
this.logger.log(
|
||||
`Agent logout response: ${JSON.stringify(response.data)}`,
|
||||
);
|
||||
return response.data;
|
||||
} catch (error: any) {
|
||||
if (error?.response?.status === 401) this.invalidateToken();
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from './platform-graphql.service';
|
||||
|
||||
/**
|
||||
* Maps Ozonetel agent identifiers (unique — e.g. "ramaiahadmin",
|
||||
* "globalhealthx", "global") to the platform Agent entity UUID. Used by
|
||||
* ingest paths (webhook, dispose, CDR enrichment, backfill) so every Call
|
||||
* ends up with the correct `agent` relation regardless of how Ozonetel
|
||||
* formats the display name (AgentName collisions, transfer chains like
|
||||
* "A -> B -> C", etc.).
|
||||
*
|
||||
* The cache is case-insensitive because Ozonetel occasionally mixes
|
||||
* casing ("global" vs "Global" vs "GLOBAL") across webhook/CDR responses.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AgentLookupService implements OnModuleInit {
|
||||
private readonly logger = new Logger(AgentLookupService.name);
|
||||
private readonly uuidByOzonetelId = new Map<string, string>();
|
||||
private readonly uuidByDisplayName = new Map<string, string>();
|
||||
|
||||
constructor(private readonly platform: PlatformGraphqlService) {}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
try {
|
||||
const data = await this.platform.query<any>(
|
||||
`{ agents(first: 100) { edges { node { id ozonetelAgentId ozonetelDisplayName } } } }`,
|
||||
);
|
||||
const edges = data?.agents?.edges ?? [];
|
||||
this.uuidByOzonetelId.clear();
|
||||
this.uuidByDisplayName.clear();
|
||||
for (const edge of edges) {
|
||||
const n = edge.node;
|
||||
if (n.ozonetelAgentId) {
|
||||
this.uuidByOzonetelId.set(n.ozonetelAgentId.toLowerCase(), n.id);
|
||||
}
|
||||
if (n.ozonetelDisplayName) {
|
||||
this.uuidByDisplayName.set(n.ozonetelDisplayName.toLowerCase().trim(), n.id);
|
||||
}
|
||||
}
|
||||
this.logger.log(`[AGENT-LOOKUP] Loaded ${this.uuidByOzonetelId.size} agents (${this.uuidByDisplayName.size} with display name)`);
|
||||
} catch (err) {
|
||||
this.logger.warn(`[AGENT-LOOKUP] Refresh failed: ${err}`);
|
||||
}
|
||||
}
|
||||
|
||||
async resolveByOzonetelId(ozonetelId: string | null | undefined): Promise<string | null> {
|
||||
if (!ozonetelId) return null;
|
||||
const key = ozonetelId.toLowerCase();
|
||||
const cached = this.uuidByOzonetelId.get(key);
|
||||
if (cached) return cached;
|
||||
// Cache miss — refresh once (handles late-provisioned agents)
|
||||
await this.refresh();
|
||||
return this.uuidByOzonetelId.get(key) ?? null;
|
||||
}
|
||||
|
||||
// Resolve by Ozonetel display name (e.g. "Ganesh Bandi") — used by
|
||||
// missed-call webhook backfill where only AgentName (display) is available.
|
||||
async resolveByDisplayName(displayName: string | null | undefined): Promise<string | null> {
|
||||
if (!displayName) return null;
|
||||
const key = displayName.toLowerCase().trim();
|
||||
const cached = this.uuidByDisplayName.get(key);
|
||||
if (cached) return cached;
|
||||
await this.refresh();
|
||||
return this.uuidByDisplayName.get(key) ?? null;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import type { LeadNode, LeadActivityNode, CreateCallInput, CreateLeadActivityInput, UpdateLeadInput } from './platform.types';
|
||||
import type {
|
||||
LeadNode,
|
||||
LeadActivityNode,
|
||||
CreateCallInput,
|
||||
CreateLeadActivityInput,
|
||||
CreateLeadInput,
|
||||
UpdateLeadInput,
|
||||
} from './platform.types';
|
||||
|
||||
@Injectable()
|
||||
export class PlatformGraphqlService {
|
||||
@@ -19,14 +26,18 @@ export class PlatformGraphqlService {
|
||||
}
|
||||
|
||||
// Query using a passed-through auth header (user JWT)
|
||||
async queryWithAuth<T>(query: string, variables: Record<string, any> | undefined, authHeader: string): Promise<T> {
|
||||
async queryWithAuth<T>(
|
||||
query: string,
|
||||
variables: Record<string, any> | undefined,
|
||||
authHeader: string,
|
||||
): Promise<T> {
|
||||
const response = await axios.post(
|
||||
this.graphqlUrl,
|
||||
{ query, variables },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': authHeader,
|
||||
Authorization: authHeader,
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -63,10 +74,16 @@ export class PlatformGraphqlService {
|
||||
|
||||
// Client-side phone matching (strip non-digits for comparison)
|
||||
const normalizedPhone = phone.replace(/\D/g, '');
|
||||
return data.leads.edges.find(edge => {
|
||||
return (
|
||||
data.leads.edges.find((edge) => {
|
||||
const leadPhones = edge.node.contactPhone ?? [];
|
||||
return leadPhones.some(p => p.number.replace(/\D/g, '').endsWith(normalizedPhone) || normalizedPhone.endsWith(p.number.replace(/\D/g, '')));
|
||||
})?.node ?? null;
|
||||
return leadPhones.some(
|
||||
(p) =>
|
||||
p.number.replace(/\D/g, '').endsWith(normalizedPhone) ||
|
||||
normalizedPhone.endsWith(p.number.replace(/\D/g, '')),
|
||||
);
|
||||
})?.node ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async findLeadById(id: string): Promise<LeadNode | null> {
|
||||
@@ -110,7 +127,9 @@ export class PlatformGraphqlService {
|
||||
return data.createCall;
|
||||
}
|
||||
|
||||
async createLeadActivity(input: CreateLeadActivityInput): Promise<{ id: string }> {
|
||||
async createLeadActivity(
|
||||
input: CreateLeadActivityInput,
|
||||
): Promise<{ id: string }> {
|
||||
const data = await this.query<{ createLeadActivity: { id: string } }>(
|
||||
`mutation CreateLeadActivity($data: LeadActivityCreateInput!) {
|
||||
createLeadActivity(data: $data) { id }
|
||||
@@ -120,13 +139,28 @@ export class PlatformGraphqlService {
|
||||
return data.createLeadActivity;
|
||||
}
|
||||
|
||||
async createLead(input: CreateLeadInput): Promise<{ id: string }> {
|
||||
const data = await this.query<{ createLead: { id: string } }>(
|
||||
`mutation CreateLead($data: LeadCreateInput!) {
|
||||
createLead(data: $data) { id }
|
||||
}`,
|
||||
{ data: input },
|
||||
);
|
||||
return data.createLead;
|
||||
}
|
||||
|
||||
// --- Token passthrough versions (for user-driven requests) ---
|
||||
|
||||
async findLeadByPhoneWithToken(phone: string, authHeader: string): Promise<LeadNode | null> {
|
||||
async findLeadByPhoneWithToken(
|
||||
phone: string,
|
||||
authHeader: string,
|
||||
): Promise<LeadNode | null> {
|
||||
const normalizedPhone = phone.replace(/\D/g, '');
|
||||
const last10 = normalizedPhone.slice(-10);
|
||||
|
||||
const data = await this.queryWithAuth<{ leads: { edges: { node: LeadNode }[] } }>(
|
||||
const data = await this.queryWithAuth<{
|
||||
leads: { edges: { node: LeadNode }[] };
|
||||
}>(
|
||||
`query FindLeads($first: Int) {
|
||||
leads(first: $first, orderBy: [{ createdAt: DescNullsLast }]) {
|
||||
edges {
|
||||
@@ -148,22 +182,37 @@ export class PlatformGraphqlService {
|
||||
);
|
||||
|
||||
// Client-side phone matching
|
||||
return data.leads.edges.find(edge => {
|
||||
return (
|
||||
data.leads.edges.find((edge) => {
|
||||
const phones = edge.node.contactPhone ?? [];
|
||||
if (Array.isArray(phones)) {
|
||||
return phones.some((p: any) => {
|
||||
const num = (p.number ?? p.primaryPhoneNumber ?? '').replace(/\D/g, '');
|
||||
const num = (p.number ?? p.primaryPhoneNumber ?? '').replace(
|
||||
/\D/g,
|
||||
'',
|
||||
);
|
||||
return num.endsWith(last10) || last10.endsWith(num);
|
||||
});
|
||||
}
|
||||
// Handle single phone object
|
||||
const num = ((phones as any).primaryPhoneNumber ?? (phones as any).number ?? '').replace(/\D/g, '');
|
||||
const num = (
|
||||
(phones as any).primaryPhoneNumber ??
|
||||
(phones as any).number ??
|
||||
''
|
||||
).replace(/\D/g, '');
|
||||
return num.endsWith(last10) || last10.endsWith(num);
|
||||
})?.node ?? null;
|
||||
})?.node ?? null
|
||||
);
|
||||
}
|
||||
|
||||
async getLeadActivitiesWithToken(leadId: string, authHeader: string, limit = 5): Promise<LeadActivityNode[]> {
|
||||
const data = await this.queryWithAuth<{ leadActivities: { edges: { node: LeadActivityNode }[] } }>(
|
||||
async getLeadActivitiesWithToken(
|
||||
leadId: string,
|
||||
authHeader: string,
|
||||
limit = 5,
|
||||
): Promise<LeadActivityNode[]> {
|
||||
const data = await this.queryWithAuth<{
|
||||
leadActivities: { edges: { node: LeadActivityNode }[] };
|
||||
}>(
|
||||
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) {
|
||||
leadActivities(filter: $filter, first: $first, orderBy: [{ occurredAt: DescNullsLast }]) {
|
||||
edges {
|
||||
@@ -176,20 +225,18 @@ export class PlatformGraphqlService {
|
||||
{ filter: { leadId: { eq: leadId } }, first: limit },
|
||||
authHeader,
|
||||
);
|
||||
return data.leadActivities.edges.map(e => e.node);
|
||||
return data.leadActivities.edges.map((e) => e.node);
|
||||
}
|
||||
|
||||
async updateLeadWithToken(id: string, input: UpdateLeadInput, authHeader: string): Promise<LeadNode> {
|
||||
// Response fragment deliberately excludes `leadStatus` — the staging
|
||||
// platform schema has this field renamed to `status`. Selecting the
|
||||
// old name rejects the whole mutation. Callers don't use the
|
||||
// returned fragment today, so returning just the id + AI fields
|
||||
// keeps this working across both schema shapes without a wider
|
||||
// rename hotfix.
|
||||
async updateLeadWithToken(
|
||||
id: string,
|
||||
input: UpdateLeadInput,
|
||||
authHeader: string,
|
||||
): Promise<LeadNode> {
|
||||
const data = await this.queryWithAuth<{ updateLead: LeadNode }>(
|
||||
`mutation UpdateLead($id: ID!, $data: LeadUpdateInput!) {
|
||||
updateLead(id: $id, data: $data) {
|
||||
id aiSummary aiSuggestedAction
|
||||
id leadStatus aiSummary aiSuggestedAction
|
||||
}
|
||||
}`,
|
||||
{ id, data: input },
|
||||
@@ -198,71 +245,15 @@ export class PlatformGraphqlService {
|
||||
return data.updateLead;
|
||||
}
|
||||
|
||||
// Fetch a single lead by id with the caller's JWT. Used by the
|
||||
// lead-enrich flow when the agent explicitly renames a caller from
|
||||
// the appointment/enquiry form and we need to regenerate the lead's
|
||||
// AI summary against fresh identity.
|
||||
//
|
||||
// The selected fields deliberately use the staging-aligned names
|
||||
// (`status`, `source`, `lastContacted`) rather than the older
|
||||
// `leadStatus`/`leadSource`/`lastContactedAt` names — otherwise the
|
||||
// query would be rejected on staging.
|
||||
async findLeadByIdWithToken(id: string, authHeader: string): Promise<any | null> {
|
||||
try {
|
||||
const data = await this.queryWithAuth<{ lead: any }>(
|
||||
`query FindLead($id: UUID!) {
|
||||
lead(filter: { id: { eq: $id } }) {
|
||||
id
|
||||
createdAt
|
||||
contactName { firstName lastName }
|
||||
contactPhone { primaryPhoneNumber primaryPhoneCallingCode }
|
||||
source
|
||||
status
|
||||
interestedService
|
||||
contactAttempts
|
||||
lastContacted
|
||||
aiSummary
|
||||
aiSuggestedAction
|
||||
}
|
||||
}`,
|
||||
{ id },
|
||||
authHeader,
|
||||
);
|
||||
return data.lead ?? null;
|
||||
} catch {
|
||||
// Fall back to edge-style query in case the singular field
|
||||
// doesn't exist on this platform version.
|
||||
const data = await this.queryWithAuth<{ leads: { edges: { node: any }[] } }>(
|
||||
`query FindLead($id: UUID!) {
|
||||
leads(filter: { id: { eq: $id } }, first: 1) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
createdAt
|
||||
contactName { firstName lastName }
|
||||
contactPhone { primaryPhoneNumber primaryPhoneCallingCode }
|
||||
source
|
||||
status
|
||||
interestedService
|
||||
contactAttempts
|
||||
lastContacted
|
||||
aiSummary
|
||||
aiSuggestedAction
|
||||
}
|
||||
}
|
||||
}
|
||||
}`,
|
||||
{ id },
|
||||
authHeader,
|
||||
);
|
||||
return data.leads.edges[0]?.node ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Server-to-server versions (for webhooks, background jobs) ---
|
||||
|
||||
async getLeadActivities(leadId: string, limit = 3): Promise<LeadActivityNode[]> {
|
||||
const data = await this.query<{ leadActivities: { edges: { node: LeadActivityNode }[] } }>(
|
||||
async getLeadActivities(
|
||||
leadId: string,
|
||||
limit = 3,
|
||||
): Promise<LeadActivityNode[]> {
|
||||
const data = await this.query<{
|
||||
leadActivities: { edges: { node: LeadActivityNode }[] };
|
||||
}>(
|
||||
`query GetLeadActivities($filter: LeadActivityFilterInput, $first: Int) {
|
||||
leadActivities(filter: $filter, first: $first, orderBy: { occurredAt: DescNullsLast }) {
|
||||
edges {
|
||||
@@ -274,6 +265,6 @@ export class PlatformGraphqlService {
|
||||
}`,
|
||||
{ filter: { leadId: { eq: leadId } }, first: limit },
|
||||
);
|
||||
return data.leadActivities.edges.map(e => e.node);
|
||||
return data.leadActivities.edges.map((e) => e.node);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from './platform-graphql.service';
|
||||
import { AgentLookupService } from './agent-lookup.service';
|
||||
|
||||
@Module({
|
||||
providers: [PlatformGraphqlService, AgentLookupService],
|
||||
exports: [PlatformGraphqlService, AgentLookupService],
|
||||
providers: [PlatformGraphqlService],
|
||||
exports: [PlatformGraphqlService],
|
||||
})
|
||||
export class PlatformModule {}
|
||||
|
||||
@@ -63,6 +63,19 @@ export type CreateLeadActivityInput = {
|
||||
leadId: string;
|
||||
};
|
||||
|
||||
export type CreateLeadInput = {
|
||||
name: string;
|
||||
contactName?: { firstName: string; lastName?: string };
|
||||
contactPhone?: { primaryPhoneNumber: string };
|
||||
contactEmail?: { primaryEmailAddress: string };
|
||||
source?: string;
|
||||
status?: string;
|
||||
interestedService?: string;
|
||||
assignedAgent?: string;
|
||||
campaignId?: string;
|
||||
notes?: string;
|
||||
};
|
||||
|
||||
export type UpdateLeadInput = {
|
||||
leadStatus?: string;
|
||||
lastContactedAt?: string;
|
||||
|
||||
@@ -4,7 +4,6 @@ import { generateObject } from 'ai';
|
||||
import { z } from 'zod';
|
||||
import { createAiModel } from '../ai/ai-provider';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import { AiConfigService } from '../config/ai-config.service';
|
||||
|
||||
const DEEPGRAM_API = 'https://api.deepgram.com/v1/listen';
|
||||
|
||||
@@ -45,18 +44,9 @@ export class RecordingsService {
|
||||
private readonly deepgramApiKey: string;
|
||||
private readonly aiModel: LanguageModel | null;
|
||||
|
||||
constructor(
|
||||
private config: ConfigService,
|
||||
private aiConfig: AiConfigService,
|
||||
) {
|
||||
constructor(private config: ConfigService) {
|
||||
this.deepgramApiKey = process.env.DEEPGRAM_API_KEY ?? '';
|
||||
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.aiModel = createAiModel(config);
|
||||
}
|
||||
|
||||
async analyzeRecording(recordingUrl: string): Promise<CallAnalysis> {
|
||||
@@ -235,11 +225,11 @@ The CUSTOMER typically:
|
||||
patientSatisfaction: z.string().describe('One-line assessment of patient satisfaction'),
|
||||
callOutcome: z.string().describe('One-line summary of what was accomplished'),
|
||||
}),
|
||||
system: this.aiConfig.renderPrompt('recordingAnalysis', {
|
||||
hospitalName: process.env.HOSPITAL_NAME ?? 'the hospital',
|
||||
summaryBlock: summary ? `\nCall summary: ${summary}` : '',
|
||||
topicsBlock: topics.length > 0 ? `\nDetected topics: ${topics.join(', ')}` : '',
|
||||
}),
|
||||
system: `You are a call quality analyst for Global Hospital Bangalore.
|
||||
Analyze the following call recording transcript and provide structured insights.
|
||||
Be specific, brief, and actionable. Focus on healthcare context.
|
||||
${summary ? `\nCall summary: ${summary}` : ''}
|
||||
${topics.length > 0 ? `\nDetected topics: ${topics.join(', ')}` : ''}`,
|
||||
prompt: transcript,
|
||||
maxOutputTokens: 500,
|
||||
});
|
||||
|
||||
@@ -1,95 +1,12 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
|
||||
import type { ActionHandler, ActionResult } from '../types/action.types';
|
||||
import type { RuleAction, EscalateActionParams } from '../types/rule.types';
|
||||
// src/rules-engine/actions/escalate.action.ts
|
||||
|
||||
import type { ActionHandler, ActionResult } from '../types/action.types';
|
||||
import type { RuleAction } from '../types/rule.types';
|
||||
|
||||
/**
|
||||
* Persists a PerformanceAlert when a rule's escalate action fires.
|
||||
*
|
||||
* Dedupes by (agentId, alertType, IST date) — a single rule firing every
|
||||
* 5 min should only produce ONE alert per day per agent until dismissed.
|
||||
* If a row already exists for that key today and is not dismissed, the
|
||||
* action is a no-op (returns the existing id). If the existing row was
|
||||
* dismissed earlier today, we don't re-fire — supervisor explicitly
|
||||
* acknowledged.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EscalateActionHandler implements ActionHandler {
|
||||
type = 'escalate';
|
||||
private readonly logger = new Logger(EscalateActionHandler.name);
|
||||
|
||||
constructor(private readonly platform: PlatformGraphqlService) {}
|
||||
|
||||
async execute(action: RuleAction, context: Record<string, any>): Promise<ActionResult> {
|
||||
const params = action.params as EscalateActionParams & { ruleId?: string; alertType?: string };
|
||||
const agentId = context['agent.id'] as string | undefined;
|
||||
const agentName = (context['agent.name'] as string | undefined) ?? '';
|
||||
const valueRaw = context['_alertValue'];
|
||||
const valueText = valueRaw != null ? String(valueRaw) : null;
|
||||
|
||||
if (!agentId) {
|
||||
return { success: false, error: 'agent.id missing from facts' };
|
||||
}
|
||||
|
||||
const alertType = params.alertType ?? this.inferAlertType(params.message);
|
||||
const severity = (params.severity ?? 'warning').toUpperCase(); // INFO | WARNING | CRITICAL
|
||||
const today = this.todayIst();
|
||||
|
||||
// Dedupe: any non-dismissed alert today for this agent + type?
|
||||
try {
|
||||
const existing = await this.platform.query<any>(
|
||||
`{ performanceAlerts(first: 1, filter: {
|
||||
agentId: { eq: "${agentId}" },
|
||||
alertType: { eq: ${alertType} },
|
||||
firedAt: { gte: "${today}T00:00:00+05:30", lte: "${today}T23:59:59+05:30" }
|
||||
}) { edges { node { id dismissedAt value } } } }`,
|
||||
);
|
||||
const existingNode = existing?.performanceAlerts?.edges?.[0]?.node;
|
||||
if (existingNode) {
|
||||
// Already fired today. If value changed, update it; otherwise no-op.
|
||||
if (!existingNode.dismissedAt && existingNode.value !== valueText) {
|
||||
await this.platform.query<any>(
|
||||
`mutation($id: UUID!, $data: PerformanceAlertUpdateInput!) { updatePerformanceAlert(id: $id, data: $data) { id } }`,
|
||||
{ id: existingNode.id, data: { value: valueText } },
|
||||
);
|
||||
}
|
||||
return { success: true, data: { id: existingNode.id, deduped: true, agentId, alertType } };
|
||||
}
|
||||
|
||||
const created = await this.platform.query<any>(
|
||||
`mutation($data: PerformanceAlertCreateInput!) { createPerformanceAlert(data: $data) { id } }`,
|
||||
{
|
||||
data: {
|
||||
name: `${agentName || agentId}: ${params.message ?? alertType}${valueText ? ` (${valueText})` : ''}`,
|
||||
agentId,
|
||||
alertType,
|
||||
severity,
|
||||
message: params.message ?? alertType,
|
||||
value: valueText,
|
||||
ruleId: params.ruleId ?? null,
|
||||
firedAt: new Date().toISOString(),
|
||||
},
|
||||
},
|
||||
);
|
||||
const id = created?.createPerformanceAlert?.id;
|
||||
this.logger.log(`[ESCALATE] Created alert ${id} agent=${agentName ?? agentId} type=${alertType} value=${valueText}`);
|
||||
return { success: true, data: { id, agentId, alertType, severity, message: params.message } };
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[ESCALATE] Failed for agent=${agentId}: ${err?.message ?? err}`);
|
||||
return { success: false, error: String(err?.message ?? err) };
|
||||
}
|
||||
}
|
||||
|
||||
private inferAlertType(message: string | undefined): string {
|
||||
const m = (message ?? '').toLowerCase();
|
||||
if (m.includes('idle')) return 'EXCESSIVE_IDLE';
|
||||
if (m.includes('nps')) return 'LOW_NPS';
|
||||
if (m.includes('conversion')) return 'LOW_CONVERSION';
|
||||
return 'OTHER';
|
||||
}
|
||||
|
||||
private todayIst(): string {
|
||||
const ist = new Date(Date.now() + 5.5 * 60 * 60 * 1000);
|
||||
return ist.toISOString().slice(0, 10);
|
||||
async execute(_action: RuleAction, _context: Record<string, any>): Promise<ActionResult> {
|
||||
return { success: true, data: { stub: true, action: 'escalate' } };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { RulesEngineService } from '../rules-engine.service';
|
||||
import { RulesStorageService } from '../rules-storage.service';
|
||||
import { PerformanceFactsProvider } from '../facts/performance-facts.provider';
|
||||
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
|
||||
|
||||
const TICK_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const KICKOFF_DELAY_MS = 90_000; // wait for boot to settle
|
||||
|
||||
/**
|
||||
* Evaluates `on_schedule` performance rules every 5 minutes for every
|
||||
* platform Agent. Facts come from PerformanceFactsProvider; matching
|
||||
* rules dispatch the escalate action which persists a PerformanceAlert.
|
||||
*
|
||||
* Skips quietly when no scheduled performance rules are configured.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PerformanceConsumer implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(PerformanceConsumer.name);
|
||||
private timer: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly engine: RulesEngineService,
|
||||
private readonly storage: RulesStorageService,
|
||||
private readonly facts: PerformanceFactsProvider,
|
||||
private readonly platform: PlatformGraphqlService,
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
setTimeout(() => {
|
||||
this.runOnce().catch((err) => {
|
||||
this.logger.warn(`[PERF-CONSUMER] First run failed: ${err?.message ?? err}`);
|
||||
});
|
||||
}, KICKOFF_DELAY_MS);
|
||||
|
||||
this.timer = setInterval(() => {
|
||||
this.runOnce().catch((err) => {
|
||||
this.logger.warn(`[PERF-CONSUMER] Tick failed: ${err?.message ?? err}`);
|
||||
});
|
||||
}, TICK_INTERVAL_MS);
|
||||
}
|
||||
|
||||
onModuleDestroy() {
|
||||
if (this.timer) clearInterval(this.timer);
|
||||
}
|
||||
|
||||
async runOnce(): Promise<{ agentsScanned: number; alertsFired: number }> {
|
||||
// Storage.getByTrigger doesn't sub-discriminate on_schedule rules, so
|
||||
// filter to only those that reference agent.* facts in their conditions.
|
||||
// Anything else (e.g. SLA-breach rules over call.* facts) belongs to
|
||||
// other consumers.
|
||||
const allScheduled = await this.storage.getByTrigger('on_schedule');
|
||||
const rules = allScheduled.filter((r) => this.referencesAgentFacts(r.conditions));
|
||||
if (rules.length === 0) {
|
||||
this.logger.debug('[PERF-CONSUMER] No agent-fact on_schedule rules — skipping');
|
||||
return { agentsScanned: 0, alertsFired: 0 };
|
||||
}
|
||||
|
||||
const agents = await this.fetchAgents();
|
||||
if (agents.length === 0) return { agentsScanned: 0, alertsFired: 0 };
|
||||
|
||||
let alertsFired = 0;
|
||||
for (const agent of agents) {
|
||||
try {
|
||||
const factContext = await this.facts.resolveFacts({ agentId: agent.id, agentName: agent.name });
|
||||
|
||||
// Each rule's escalate action needs to know which fact value
|
||||
// to surface as the alert's value (e.g. "65m" for idle).
|
||||
// Inject _alertValue per-rule below.
|
||||
for (const rule of rules) {
|
||||
const ruleFacts = { ...factContext };
|
||||
const valueFact = (rule.action.params as any)?.valueFact as string | undefined;
|
||||
if (valueFact && ruleFacts[valueFact] != null) {
|
||||
ruleFacts['_alertValue'] = ruleFacts[valueFact];
|
||||
}
|
||||
const result = await this.engine.evaluate('on_schedule', 'performance', ruleFacts);
|
||||
alertsFired += result.results.filter((r: any) => r.success && !r.data?.deduped).length;
|
||||
}
|
||||
} catch (err: any) {
|
||||
this.logger.warn(`[PERF-CONSUMER] Eval failed for agent=${agent.id}: ${err?.message ?? err}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (alertsFired > 0) {
|
||||
this.logger.log(`[PERF-CONSUMER] Tick complete — agents=${agents.length} alertsFired=${alertsFired}`);
|
||||
}
|
||||
return { agentsScanned: agents.length, alertsFired };
|
||||
}
|
||||
|
||||
private referencesAgentFacts(group: any): boolean {
|
||||
if (!group) return false;
|
||||
const items = group.all ?? group.any ?? [];
|
||||
for (const item of items) {
|
||||
if (item.all || item.any) {
|
||||
if (this.referencesAgentFacts(item)) return true;
|
||||
} else if (typeof item.fact === 'string' && item.fact.startsWith('agent.')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private async fetchAgents(): Promise<Array<{ id: string; name: string }>> {
|
||||
try {
|
||||
const data = await this.platform.query<any>(
|
||||
`{ agents(first: 100) { edges { node { id name } } } }`,
|
||||
);
|
||||
return (data?.agents?.edges ?? []).map((e: any) => e.node);
|
||||
} catch (err) {
|
||||
this.logger.warn(`[PERF-CONSUMER] Agent fetch failed: ${err}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,10 +18,10 @@ export class CallFactsProvider implements FactProvider {
|
||||
'call.status': call.callStatus ?? null,
|
||||
'call.disposition': call.disposition ?? null,
|
||||
'call.durationSeconds': call.durationSeconds ?? call.durationSec ?? 0,
|
||||
'call.callbackStatus': call.callbackStatus ?? call.callbackStatus ?? null,
|
||||
'call.callbackStatus': call.callbackstatus ?? call.callbackStatus ?? null,
|
||||
'call.slaElapsedPercent': slaElapsedPercent,
|
||||
'call.slaBreached': slaElapsedPercent > 100,
|
||||
'call.missedCount': call.missedCallCount ?? call.missedCount ?? 0,
|
||||
'call.missedCount': call.missedcallcount ?? call.missedCount ?? 0,
|
||||
'call.taskType': taskType,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
|
||||
import type { FactProvider, FactValue } from '../types/fact.types';
|
||||
|
||||
/**
|
||||
* Resolves per-agent performance facts for the rules engine.
|
||||
* Used by the PerformanceConsumer to evaluate alert rules every 5 min.
|
||||
*
|
||||
* Facts exposed:
|
||||
* - agent.idleMinutes — from today's AgentSession.idleTimeS
|
||||
* - agent.busyMinutes — from AgentSession.busyTimeS
|
||||
* - agent.totalCallsToday — count of Calls started today
|
||||
* - agent.bookedCallsToday — count of Calls today with disposition=APPOINTMENT_BOOKED
|
||||
* - agent.conversionPercent — bookedCallsToday / totalCallsToday × 100
|
||||
* - agent.id, agent.name — for routing alerts back to the right agent
|
||||
*
|
||||
* NPS deferred — no source signal exists yet.
|
||||
*/
|
||||
@Injectable()
|
||||
export class PerformanceFactsProvider implements FactProvider {
|
||||
name = 'performance';
|
||||
private readonly logger = new Logger(PerformanceFactsProvider.name);
|
||||
|
||||
constructor(private readonly platform: PlatformGraphqlService) {}
|
||||
|
||||
/**
|
||||
* @param entityData { agentId: string, agentName?: string }
|
||||
*/
|
||||
async resolveFacts(entityData: { agentId: string; agentName?: string }): Promise<Record<string, FactValue>> {
|
||||
const agentId = entityData.agentId;
|
||||
const today = this.todayIst();
|
||||
|
||||
const session = await this.fetchTodaySession(agentId, today);
|
||||
const callTotals = await this.fetchTodayCallTotals(agentId, today);
|
||||
|
||||
const idleMinutes = Math.round((session?.idleTimeS ?? 0) / 60);
|
||||
const busyMinutes = Math.round((session?.busyTimeS ?? 0) / 60);
|
||||
const conversionPercent = callTotals.total > 0
|
||||
? Math.round((callTotals.booked / callTotals.total) * 100)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
'agent.id': agentId,
|
||||
'agent.name': entityData.agentName ?? '',
|
||||
'agent.idleMinutes': idleMinutes,
|
||||
'agent.busyMinutes': busyMinutes,
|
||||
'agent.totalCallsToday': callTotals.total,
|
||||
'agent.bookedCallsToday': callTotals.booked,
|
||||
'agent.conversionPercent': conversionPercent,
|
||||
};
|
||||
}
|
||||
|
||||
private todayIst(): string {
|
||||
const ist = new Date(Date.now() + 5.5 * 60 * 60 * 1000);
|
||||
return ist.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
private async fetchTodaySession(agentId: string, date: string): Promise<{ idleTimeS: number; busyTimeS: number } | null> {
|
||||
try {
|
||||
const data = await this.platform.query<any>(
|
||||
`{ agentSessions(first: 1, filter: { agentId: { eq: "${agentId}" }, date: { eq: "${date}" } }) {
|
||||
edges { node { idleTimeS busyTimeS } }
|
||||
} }`,
|
||||
);
|
||||
const node = data?.agentSessions?.edges?.[0]?.node;
|
||||
if (!node) return null;
|
||||
return { idleTimeS: node.idleTimeS ?? 0, busyTimeS: node.busyTimeS ?? 0 };
|
||||
} catch (err) {
|
||||
this.logger.warn(`[PERF-FACTS] Session fetch failed for agent=${agentId}: ${err}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchTodayCallTotals(agentId: string, date: string): Promise<{ total: number; booked: number }> {
|
||||
const gte = `${date}T00:00:00+05:30`;
|
||||
const lte = `${date}T23:59:59+05:30`;
|
||||
try {
|
||||
const data = await this.platform.query<any>(
|
||||
`{ calls(first: 200, filter: {
|
||||
agentId: { eq: "${agentId}" },
|
||||
startedAt: { gte: "${gte}", lte: "${lte}" }
|
||||
}) { edges { node { disposition } } } }`,
|
||||
);
|
||||
const edges = data?.calls?.edges ?? [];
|
||||
const total = edges.length;
|
||||
const booked = edges.filter((e: any) => e.node.disposition === 'APPOINTMENT_BOOKED').length;
|
||||
return { total, booked };
|
||||
} catch (err) {
|
||||
this.logger.warn(`[PERF-FACTS] Call totals fetch failed for agent=${agentId}: ${err}`);
|
||||
return { total: 0, booked: 0 };
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user