mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
Compare commits
89 Commits
feature/om
...
9890559ec1
| Author | SHA1 | Date | |
|---|---|---|---|
| 9890559ec1 | |||
| 9cb4d1c122 | |||
| 014b27cf90 | |||
| 826ced1e62 | |||
| bbea12185d | |||
| f1c026cf7a | |||
| d819888351 | |||
| 300fff25c1 | |||
| 9ee087b898 | |||
| 963cf28d23 | |||
| 903e82b536 | |||
| 2e0527e1d8 | |||
| 4549241b78 | |||
| 6a3834a7eb | |||
| 6847f5de95 | |||
| d857a0b270 | |||
| 214cc60917 | |||
| c4c437abd6 | |||
| b1922809d0 | |||
| 8aae95e8cc | |||
| 2c947517af | |||
|
|
473183869a | ||
| 3bb4315925 | |||
| 350fcdd926 | |||
| 7402590969 | |||
| 3f22166ac0 | |||
| 8c8b1e78b0 | |||
| 77b3e917db | |||
| 68ba3e135d | |||
| e1babb30e5 | |||
| ae360a183d | |||
| e03b1e6235 | |||
| 2d18110786 | |||
| a576552f8a | |||
| b11f4ea336 | |||
| 96ae867288 | |||
| 9a016a2ed0 | |||
| 9cf0f69dde | |||
| a6f4c51ca9 | |||
| 2d8308bed8 | |||
| 2666a10f48 | |||
| a00668c517 | |||
| a1413aae40 | |||
| 6adb3985cb | |||
| 67c41f4783 | |||
| d459d6469a | |||
| 60d2329dd8 | |||
| f375e7736c | |||
| 96977e84a1 | |||
| 00303df95b | |||
| 34e053204f | |||
| 98f5bc0347 | |||
| 048545317d | |||
| 8dcfa5a72f | |||
| 5b40f49b65 | |||
| fb616d47ee | |||
| 6fd17acf78 | |||
| 846c5f4c9b | |||
| 9472f83cd8 | |||
| 6de1989536 | |||
| 2acba59963 | |||
| 4eb8cb80b2 | |||
| fbe782b5ac | |||
| b6b597fdda | |||
| a4ff052fef | |||
| 5969441868 | |||
| 01348123e6 | |||
| d97d73dd1a | |||
| 7b178f9dc7 | |||
| 3d790e51dc | |||
| 1c3e42ad7c | |||
| ea60787da0 | |||
| c23792496b | |||
| 27a3fbcfed | |||
| 0f5bd7d61a | |||
| f1313f0e2f | |||
| 44f1ec36e1 | |||
| 4bd08a9b02 | |||
| 0248c4cad1 | |||
| be505b8d1f | |||
| dbefa9675a | |||
| 9dc02e107a | |||
| c807cf737f | |||
| 96d0c32000 | |||
| 9665500b63 | |||
| 9f5935e417 | |||
| 898ff65951 | |||
| 7717536622 | |||
| 33dc8b5669 |
203
.claude/skills/generate-whatsapp-flow.md
Normal file
203
.claude/skills/generate-whatsapp-flow.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# Generate WhatsApp Flow
|
||||||
|
|
||||||
|
Generate a config-driven WhatsApp conversation flow JSON for the Helix Engage flow runtime engine.
|
||||||
|
|
||||||
|
## When to use
|
||||||
|
|
||||||
|
When the user asks to create a new WhatsApp flow, chatbot flow, or conversation automation — e.g., "create a WhatsApp flow for prescription refills", "build a feedback collection flow", "add a lab report flow".
|
||||||
|
|
||||||
|
## Flow Runtime Architecture
|
||||||
|
|
||||||
|
The flow engine reads JSON flow definitions from `src/messaging/flow/default-flows/` and executes them at runtime. Each flow is a graph of **Groups** (containers) containing **Blocks** (steps), connected by **Edges**.
|
||||||
|
|
||||||
|
### Execution Model
|
||||||
|
|
||||||
|
```
|
||||||
|
Inbound WhatsApp message → match flow by trigger → create/resume session
|
||||||
|
→ walk forward through Groups → Blocks:
|
||||||
|
MessageBlock → send text/buttons/list to patient
|
||||||
|
InputBlock → PAUSE, wait for next message
|
||||||
|
ConditionBlock → evaluate variable, follow matching edge
|
||||||
|
SetVariableBlock → assign/transform variable
|
||||||
|
ToolCallBlock → call registered tool
|
||||||
|
AIBlock → generate LLM response
|
||||||
|
JumpBlock → jump to another group
|
||||||
|
→ End of group → follow outgoing edge → next group
|
||||||
|
→ No more edges → flow complete, session cleared
|
||||||
|
```
|
||||||
|
|
||||||
|
Session state stored in Redis with 24h TTL. Per-phone execution lock prevents concurrent flows.
|
||||||
|
|
||||||
|
### Flow JSON Schema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type Flow = {
|
||||||
|
id: string; // "flow-{kebab-name}"
|
||||||
|
name: string; // Human-readable name
|
||||||
|
description: string; // Admin-facing description
|
||||||
|
trigger: FlowTrigger; // What starts this flow
|
||||||
|
groups: Group[]; // Ordered containers of blocks
|
||||||
|
edges: Edge[]; // Connections between blocks/groups
|
||||||
|
variables: VariableDefinition[];// Flow-scoped variables
|
||||||
|
version: number; // Start at 1
|
||||||
|
status: 'draft' | 'published'; // Only published flows execute
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlowTrigger =
|
||||||
|
| { type: 'message'; conditions?: { keywords?: string[]; regex?: string } }
|
||||||
|
| { type: 'default' }; // Catch-all when no other flow matches
|
||||||
|
|
||||||
|
type Group = {
|
||||||
|
id: string; // "g1", "g2", etc.
|
||||||
|
title: string; // "Greeting", "Department Selection"
|
||||||
|
blocks: Block[]; // Executed in order
|
||||||
|
};
|
||||||
|
|
||||||
|
type Edge = {
|
||||||
|
id: string; // "e1", "e2", etc.
|
||||||
|
from: { blockId: string; conditionId?: string };
|
||||||
|
to: { groupId: string; blockId?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type VariableDefinition = {
|
||||||
|
id: string; // "v1", "v2", etc.
|
||||||
|
name: string; // "selectedDepartment"
|
||||||
|
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||||
|
defaultValue?: any;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Block Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Send text, buttons, or list to patient
|
||||||
|
type MessageBlock = {
|
||||||
|
id: string; type: 'message';
|
||||||
|
content:
|
||||||
|
| { format: 'text'; text: string } // Supports {{variables}}
|
||||||
|
| { format: 'buttons'; text: string; buttons: { id: string; title: string }[] } // Max 3 buttons, title max 20 chars
|
||||||
|
| { format: 'list'; text: string; buttonText: string; sections: { title: string; rows: { id: string; title: string; description?: string }[] }[] }; // Section title max 24 chars, row title max 24 chars, max 10 rows total
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wait for patient reply — PAUSES execution
|
||||||
|
type InputBlock = {
|
||||||
|
id: string; type: 'input';
|
||||||
|
inputType: 'text' | 'interactive_reply' | 'any';
|
||||||
|
variableId: string; // Store reply in this variable
|
||||||
|
validation?: { regex?: string; errorMessage?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Branch based on variable value
|
||||||
|
type ConditionBlock = {
|
||||||
|
id: string; type: 'condition';
|
||||||
|
conditions: {
|
||||||
|
id: string; // "c1" — used in edge.from.conditionId
|
||||||
|
variableId: string;
|
||||||
|
operator: 'equals' | 'contains' | 'exists' | 'not_exists' | 'gt' | 'lt' | 'starts_with';
|
||||||
|
value?: string; // Supports {{variables}}
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Assign or transform a variable
|
||||||
|
type SetVariableBlock = {
|
||||||
|
id: string; type: 'set_variable';
|
||||||
|
variableId: string;
|
||||||
|
value: string;
|
||||||
|
expression?: 'extract_id' | 'extract_datetime' | 'date_tomorrow' | 'date_day_after';
|
||||||
|
// extract_id: "doc:{uuid}:{name}" → uuid (second segment)
|
||||||
|
// extract_datetime: "slot:{id}:{datetime}" → datetime (third+ segments, rejoined with :)
|
||||||
|
// date_tomorrow/date_day_after: computes date string YYYY-MM-DD
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute a registered tool
|
||||||
|
type ToolCallBlock = {
|
||||||
|
id: string; type: 'tool_call';
|
||||||
|
toolName: string; // Must be a registered tool (see below)
|
||||||
|
inputs: Record<string, string>; // Values support {{variables}} and {{var.field}} dot notation
|
||||||
|
outputVariableId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate dynamic LLM response
|
||||||
|
type AIBlock = {
|
||||||
|
id: string; type: 'ai';
|
||||||
|
prompt: string; // Supports {{variables}}
|
||||||
|
outputVariableId?: string;
|
||||||
|
sendToPatient: boolean; // true = send as WhatsApp message
|
||||||
|
};
|
||||||
|
|
||||||
|
// Jump to another group
|
||||||
|
type JumpBlock = {
|
||||||
|
id: string; type: 'jump';
|
||||||
|
targetGroupId: string;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available Tools (ToolRegistry)
|
||||||
|
|
||||||
|
| Tool Name | Description | Inputs | Output |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `resolve_caller` | Phone → Lead + Patient | phone? (defaults to current) | { leadId, patientId, isNew, phone } |
|
||||||
|
| `send_department_list` | Interactive department list | (none) | { sent, departments[] } |
|
||||||
|
| `send_doctor_list` | Interactive doctor list | department | { sent, count } |
|
||||||
|
| `send_slot_list` | Time slots for doctor+date | doctorId, doctorName, date | { sent, slots } |
|
||||||
|
| `send_confirm_buttons` | Confirm/Cancel buttons | summary | { sent } |
|
||||||
|
| `book_appointment` | Book with conflict check | patientName, phoneNumber, department, doctorName, scheduledAt, reason | { booked, appointmentId, reference } |
|
||||||
|
| `lookup_appointments` | Check existing appointments | (none — uses current caller) | { appointments[] } |
|
||||||
|
| `send_appointment_qr` | Generate and send QR code | appointmentId, reference, patientName, doctorName, department, scheduledAt | { sent, qrUrl } |
|
||||||
|
|
||||||
|
### System Variables (auto-injected)
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|---|---|
|
||||||
|
| `_initialMessage` | The first message the patient sent |
|
||||||
|
| `_senderName` | WhatsApp profile name |
|
||||||
|
| `_phone` | Phone number (E.164 without +) |
|
||||||
|
| `_callerName` | Resolved patient name from platform |
|
||||||
|
| `_leadId` | Lead ID if exists |
|
||||||
|
| `_patientId` | Patient ID if exists |
|
||||||
|
| `_isNew` | true if no prior records |
|
||||||
|
|
||||||
|
### Variable Interpolation
|
||||||
|
|
||||||
|
- `{{variableName}}` — simple substitution
|
||||||
|
- `{{result.fieldName}}` — dot notation for object fields (e.g., `{{bookingResult.appointmentId}}`)
|
||||||
|
- Interactive reply IDs stored in `variableId`, display titles in `variableId_title`
|
||||||
|
|
||||||
|
### WhatsApp Constraints
|
||||||
|
|
||||||
|
- Button title: max 20 characters
|
||||||
|
- List section title: max 24 characters
|
||||||
|
- List row title: max 24 characters
|
||||||
|
- List row description: max 72 characters
|
||||||
|
- Max 3 buttons per message
|
||||||
|
- Max 10 list rows total across all sections
|
||||||
|
- No markdown in text messages (plain text only)
|
||||||
|
- Interactive messages only work within 24h session window
|
||||||
|
|
||||||
|
## How to Generate
|
||||||
|
|
||||||
|
1. **Ask the user** what the flow should do — purpose, steps, what data to collect
|
||||||
|
2. **Design the groups** — each logical phase is a group (Greeting, Selection, Confirmation, etc.)
|
||||||
|
3. **Define variables** — what data flows through the conversation
|
||||||
|
4. **Build blocks** — MessageBlocks for output, InputBlocks to pause for reply, ConditionBlocks for branching, ToolCallBlocks for platform operations, AIBlocks for dynamic responses
|
||||||
|
5. **Wire edges** — connect groups via edges, condition edges for branching
|
||||||
|
6. **Write the JSON** to `src/messaging/flow/default-flows/{flow-name}.json`
|
||||||
|
7. **Register new tools** if needed in `src/messaging/flow/tool-registry.ts`
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
See `src/messaging/flow/default-flows/appointment-booking.json` for a complete working example with:
|
||||||
|
- AI greeting
|
||||||
|
- Intent routing (book / check / question)
|
||||||
|
- Interactive lists (departments, doctors, slots)
|
||||||
|
- Date selection with custom date AI parsing
|
||||||
|
- Confirmation buttons
|
||||||
|
- Booking with conflict check
|
||||||
|
- QR code generation
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
After creating the flow JSON:
|
||||||
|
1. `npm run build` — verifies the JSON is copied to dist (via nest-cli.json assets)
|
||||||
|
2. Deploy to EC2 — the flow store auto-seeds on first run if `data/flows/` is empty
|
||||||
|
3. If updating an existing flow: `docker exec sidecar cp /app/dist/.../flow.json /app/data/flows/flow-id.json && docker compose restart sidecar`
|
||||||
24
.woodpecker.yml
Normal file
24
.woodpecker.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Woodpecker CI pipeline for Helix Engage Server (sidecar)
|
||||||
|
|
||||||
|
when:
|
||||||
|
- event: [push, manual]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
unit-tests:
|
||||||
|
image: node:20
|
||||||
|
commands:
|
||||||
|
- npm ci
|
||||||
|
- npm test -- --ci --forceExit
|
||||||
|
|
||||||
|
notify-teams:
|
||||||
|
image: curlimages/curl
|
||||||
|
environment:
|
||||||
|
TEAMS_WEBHOOK:
|
||||||
|
from_secret: teams_webhook
|
||||||
|
commands:
|
||||||
|
- >
|
||||||
|
curl -s -X POST "$TEAMS_WEBHOOK"
|
||||||
|
-H "Content-Type:application/json"
|
||||||
|
-d '{"type":"message","attachments":[{"contentType":"application/vnd.microsoft.card.adaptive","content":{"type":"AdaptiveCard","version":"1.4","body":[{"type":"TextBlock","size":"Medium","weight":"Bolder","text":"Helix Engage Server — Build #'"$CI_PIPELINE_NUMBER"'"},{"type":"TextBlock","text":"Branch: '"$CI_COMMIT_BRANCH"'","wrap":true},{"type":"TextBlock","text":"'"$(echo $CI_COMMIT_MESSAGE | head -c 80)"'","wrap":true}],"actions":[{"type":"Action.OpenUrl","title":"View Pipeline","url":"https://operations.healix360.net/repos/2/pipeline/'"$CI_PIPELINE_NUMBER"'"}]}}]}'
|
||||||
|
when:
|
||||||
|
- status: [success, failure]
|
||||||
@@ -54,5 +54,8 @@ COPY --from=builder /app/dist ./dist
|
|||||||
COPY --from=builder /app/node_modules ./node_modules
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
COPY --from=builder /app/package.json ./
|
COPY --from=builder /app/package.json ./
|
||||||
|
|
||||||
|
# Widget embed script (pre-built, served via NestJS static assets)
|
||||||
|
COPY public ./public
|
||||||
|
|
||||||
EXPOSE 4100
|
EXPOSE 4100
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
901
docs/plans/2026-04-20-whatsapp-ai-assistant.md
Normal file
901
docs/plans/2026-04-20-whatsapp-ai-assistant.md
Normal file
@@ -0,0 +1,901 @@
|
|||||||
|
# WhatsApp AI Assistant — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Provider-agnostic WhatsApp AI assistant that handles inbound patient messages — answers questions from KB, books appointments via interactive buttons, and creates/updates leads automatically.
|
||||||
|
|
||||||
|
**Architecture:** A `MessagingModule` with a provider interface (Gupshup first, swappable to Ozonetel/Meta later). Inbound webhook → caller resolution → AI conversation with tools (reuses existing `book_appointment`, `lookup_doctor`, etc.) → outbound replies via provider. Conversation history stored in Redis with 24h TTL. Interactive WhatsApp buttons/lists for structured selection steps.
|
||||||
|
|
||||||
|
**Tech Stack:** NestJS, Vercel AI SDK (`generateText` with tools), Redis, Gupshup WhatsApp API (`POST https://api.gupshup.io/wa/api/v1/msg`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/messaging/
|
||||||
|
├── messaging.module.ts — NestJS module, wires everything
|
||||||
|
├── messaging.controller.ts — POST /api/messaging/webhook (inbound)
|
||||||
|
├── messaging.service.ts — Conversation orchestration (resolve caller, build prompt, call AI, send reply)
|
||||||
|
├── messaging-conversation.service.ts — Redis conversation history (store/load/clear, 24h TTL)
|
||||||
|
├── providers/
|
||||||
|
│ ├── messaging-provider.interface.ts — Provider contract (sendText, sendList, sendButtons, parseInbound)
|
||||||
|
│ └── gupshup.provider.ts — Gupshup implementation
|
||||||
|
└── types.ts — NormalizedMessage, ConversationEntry, InteractiveButton, ListSection
|
||||||
|
```
|
||||||
|
|
||||||
|
**Modified files:**
|
||||||
|
- `src/config/configuration.ts` — add `messaging` config block
|
||||||
|
- `src/app.module.ts` — import MessagingModule
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Types and Provider Interface
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/messaging/types.ts`
|
||||||
|
- Create: `src/messaging/providers/messaging-provider.interface.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create types**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/messaging/types.ts
|
||||||
|
|
||||||
|
export type NormalizedMessage = {
|
||||||
|
phone: string; // E.164 without +, e.g. "919949879837"
|
||||||
|
name: string; // sender name from WhatsApp profile
|
||||||
|
text: string; // message text (or button reply title)
|
||||||
|
type: 'text' | 'interactive_reply' | 'location' | 'image' | 'unknown';
|
||||||
|
interactiveReply?: { // populated when user taps a button or list item
|
||||||
|
id: string; // button/row ID set by us
|
||||||
|
title: string; // display text
|
||||||
|
};
|
||||||
|
rawPayload: any; // original provider payload for debugging
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConversationEntry = {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InteractiveButton = {
|
||||||
|
id: string;
|
||||||
|
title: string; // max 20 chars for WhatsApp
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListSection = {
|
||||||
|
title: string;
|
||||||
|
rows: { id: string; title: string; description?: string }[];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create provider interface**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/messaging/providers/messaging-provider.interface.ts
|
||||||
|
|
||||||
|
import { NormalizedMessage, InteractiveButton, ListSection } from '../types';
|
||||||
|
|
||||||
|
export interface MessagingProvider {
|
||||||
|
/** Parse raw webhook payload into normalized message */
|
||||||
|
parseInbound(body: any): NormalizedMessage | null;
|
||||||
|
|
||||||
|
/** Send a plain text message */
|
||||||
|
sendText(to: string, text: string): Promise<void>;
|
||||||
|
|
||||||
|
/** Send interactive buttons (max 3 for WhatsApp) */
|
||||||
|
sendButtons(to: string, body: string, buttons: InteractiveButton[]): Promise<void>;
|
||||||
|
|
||||||
|
/** Send interactive list (max 10 rows total across sections) */
|
||||||
|
sendList(to: string, body: string, buttonText: string, sections: ListSection[]): Promise<void>;
|
||||||
|
|
||||||
|
/** Validate that inbound webhook is authentic */
|
||||||
|
validateWebhook(body: any): boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/messaging/types.ts src/messaging/providers/messaging-provider.interface.ts
|
||||||
|
git commit -m "feat(messaging): types and provider interface"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Gupshup Provider
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/messaging/providers/gupshup.provider.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement Gupshup provider**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/messaging/providers/gupshup.provider.ts
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { MessagingProvider } from './messaging-provider.interface';
|
||||||
|
import { NormalizedMessage, InteractiveButton, ListSection } from '../types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GupshupProvider implements MessagingProvider {
|
||||||
|
private readonly logger = new Logger(GupshupProvider.name);
|
||||||
|
private readonly apiKey: string;
|
||||||
|
private readonly appId: string;
|
||||||
|
private readonly sourceNumber: string;
|
||||||
|
private readonly apiUrl = 'https://api.gupshup.io/wa/api/v1/msg';
|
||||||
|
|
||||||
|
constructor(private config: ConfigService) {
|
||||||
|
this.apiKey = config.get<string>('messaging.gupshup.apiKey') ?? '';
|
||||||
|
this.appId = config.get<string>('messaging.gupshup.appId') ?? '';
|
||||||
|
this.sourceNumber = config.get<string>('messaging.gupshup.sourceNumber') ?? '';
|
||||||
|
if (this.apiKey) {
|
||||||
|
this.logger.log(`Gupshup provider configured: appId=${this.appId} source=${this.sourceNumber}`);
|
||||||
|
} else {
|
||||||
|
this.logger.warn('Gupshup provider not configured — missing API key');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validateWebhook(body: any): boolean {
|
||||||
|
// Gupshup doesn't sign webhooks — validate by app name match
|
||||||
|
return body?.app === this.appId || !this.appId;
|
||||||
|
}
|
||||||
|
|
||||||
|
parseInbound(body: any): NormalizedMessage | null {
|
||||||
|
// Gupshup sends: { app, timestamp, version, type, payload }
|
||||||
|
if (body?.type !== 'message') return null;
|
||||||
|
|
||||||
|
const payload = body.payload;
|
||||||
|
if (!payload?.sender?.phone) return null;
|
||||||
|
|
||||||
|
const phone = payload.sender.phone.replace(/\D/g, '');
|
||||||
|
const name = payload.sender.name ?? '';
|
||||||
|
const msgType = payload.type;
|
||||||
|
|
||||||
|
// Text message
|
||||||
|
if (msgType === 'text') {
|
||||||
|
return {
|
||||||
|
phone, name,
|
||||||
|
text: payload.payload?.text ?? payload.text ?? '',
|
||||||
|
type: 'text',
|
||||||
|
rawPayload: body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interactive reply (button tap or list selection)
|
||||||
|
if (msgType === 'button_reply' || msgType === 'list_reply') {
|
||||||
|
return {
|
||||||
|
phone, name,
|
||||||
|
text: payload.payload?.title ?? '',
|
||||||
|
type: 'interactive_reply',
|
||||||
|
interactiveReply: {
|
||||||
|
id: payload.payload?.id ?? '',
|
||||||
|
title: payload.payload?.title ?? '',
|
||||||
|
},
|
||||||
|
rawPayload: body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Location
|
||||||
|
if (msgType === 'location') {
|
||||||
|
return {
|
||||||
|
phone, name,
|
||||||
|
text: `Location: ${payload.payload?.latitude}, ${payload.payload?.longitude}`,
|
||||||
|
type: 'location',
|
||||||
|
rawPayload: body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image/document/audio — acknowledge but treat as text
|
||||||
|
if (['image', 'audio', 'video', 'document', 'sticker'].includes(msgType)) {
|
||||||
|
return {
|
||||||
|
phone, name,
|
||||||
|
text: `[Sent ${msgType}]`,
|
||||||
|
type: 'image',
|
||||||
|
rawPayload: body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.warn(`[GUPSHUP] Unknown message type: ${msgType}`);
|
||||||
|
return { phone, name, text: '', type: 'unknown', rawPayload: body };
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendText(to: string, text: string): Promise<void> {
|
||||||
|
await this.send(to, JSON.stringify({ type: 'text', text }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendButtons(to: string, body: string, buttons: InteractiveButton[]): Promise<void> {
|
||||||
|
const message = {
|
||||||
|
type: 'quick_reply',
|
||||||
|
content: { type: 'text', text: body },
|
||||||
|
options: buttons.map(b => ({ type: 'text', title: b.title, postbackText: b.id })),
|
||||||
|
};
|
||||||
|
await this.send(to, JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendList(to: string, body: string, buttonText: string, sections: ListSection[]): Promise<void> {
|
||||||
|
const message = {
|
||||||
|
type: 'list',
|
||||||
|
title: buttonText,
|
||||||
|
body: body,
|
||||||
|
globalButtons: [{ type: 'text', title: buttonText }],
|
||||||
|
items: sections.map(s => ({
|
||||||
|
title: s.title,
|
||||||
|
options: s.rows.map(r => ({
|
||||||
|
type: 'text',
|
||||||
|
title: r.title,
|
||||||
|
description: r.description ?? '',
|
||||||
|
postbackText: r.id,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
await this.send(to, JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async send(to: string, message: string): Promise<void> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('channel', 'whatsapp');
|
||||||
|
params.append('source', this.sourceNumber);
|
||||||
|
params.append('destination', to);
|
||||||
|
params.append('message', message);
|
||||||
|
params.append('src.name', this.appId);
|
||||||
|
|
||||||
|
this.logger.log(`[GUPSHUP] Sending to ${to}: ${message.substring(0, 100)}...`);
|
||||||
|
|
||||||
|
const resp = await fetch(this.apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'apikey': this.apiKey,
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: params.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resp.json().catch(() => resp.text());
|
||||||
|
if (!resp.ok) {
|
||||||
|
this.logger.error(`[GUPSHUP] Send failed (${resp.status}): ${JSON.stringify(result)}`);
|
||||||
|
throw new Error(`Gupshup send failed: ${resp.status}`);
|
||||||
|
}
|
||||||
|
this.logger.log(`[GUPSHUP] Sent: ${JSON.stringify(result)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/messaging/providers/gupshup.provider.ts
|
||||||
|
git commit -m "feat(messaging): gupshup provider implementation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Conversation History Service
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/messaging/messaging-conversation.service.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement Redis-backed conversation store**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/messaging/messaging-conversation.service.ts
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { ConversationEntry } from './types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MessagingConversationService {
|
||||||
|
private readonly logger = new Logger(MessagingConversationService.name);
|
||||||
|
private readonly redis: Redis;
|
||||||
|
private readonly ttlSec = 24 * 60 * 60; // 24 hours — matches WhatsApp session window
|
||||||
|
private readonly maxHistory = 20; // keep last 20 message pairs
|
||||||
|
|
||||||
|
constructor(config: ConfigService) {
|
||||||
|
const redisUrl = config.get<string>('redis.url') ?? 'redis://localhost:6379';
|
||||||
|
this.redis = new Redis(redisUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private key(phone: string): string {
|
||||||
|
return `wa:conv:${phone}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHistory(phone: string): Promise<ConversationEntry[]> {
|
||||||
|
const raw = await this.redis.get(this.key(phone));
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMessages(phone: string, entries: ConversationEntry[]): Promise<void> {
|
||||||
|
const existing = await this.getHistory(phone);
|
||||||
|
const updated = [...existing, ...entries].slice(-this.maxHistory);
|
||||||
|
await this.redis.setex(this.key(phone), this.ttlSec, JSON.stringify(updated));
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(phone: string): Promise<void> {
|
||||||
|
await this.redis.del(this.key(phone));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/messaging/messaging-conversation.service.ts
|
||||||
|
git commit -m "feat(messaging): redis conversation history service"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Messaging Service (Conversation Orchestration)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/messaging/messaging.service.ts`
|
||||||
|
|
||||||
|
This is the core — resolves the caller, builds AI context, runs the AI with tools, sends the reply back.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create messaging service**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/messaging/messaging.service.ts
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { generateText, tool } from 'ai';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { MessagingProvider } from './providers/messaging-provider.interface';
|
||||||
|
import { MessagingConversationService } from './messaging-conversation.service';
|
||||||
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
|
import { CallerContextService } from '../caller/caller-context.service';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { createAiModel } from '../ai/ai-provider';
|
||||||
|
import { AiConfigService } from '../config/ai-config.service';
|
||||||
|
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils';
|
||||||
|
import type { NormalizedMessage, InteractiveButton, ListSection } from './types';
|
||||||
|
import type { LanguageModel } from 'ai';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MessagingService {
|
||||||
|
private readonly logger = new Logger(MessagingService.name);
|
||||||
|
private readonly aiModel: LanguageModel | null;
|
||||||
|
private readonly auth: string; // server-to-server API key auth
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private config: ConfigService,
|
||||||
|
private provider: MessagingProvider,
|
||||||
|
private conversation: MessagingConversationService,
|
||||||
|
private caller: CallerResolutionService,
|
||||||
|
private callerContext: CallerContextService,
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
private aiConfig: AiConfigService,
|
||||||
|
) {
|
||||||
|
const cfg = aiConfig.getConfig();
|
||||||
|
this.aiModel = createAiModel({
|
||||||
|
provider: cfg.provider,
|
||||||
|
model: cfg.model,
|
||||||
|
anthropicApiKey: config.get<string>('ai.anthropicApiKey'),
|
||||||
|
openaiApiKey: config.get<string>('ai.openaiApiKey'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// WhatsApp AI uses server-to-server auth (no user JWT)
|
||||||
|
const apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
|
this.auth = apiKey ? `Bearer ${apiKey}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleInbound(message: NormalizedMessage): Promise<void> {
|
||||||
|
const { phone, name, text } = message;
|
||||||
|
this.logger.log(`[WA] Inbound from ${phone} (${name}): ${text.substring(0, 100)}`);
|
||||||
|
|
||||||
|
if (!this.aiModel) {
|
||||||
|
await this.provider.sendText(phone, 'Our assistant is temporarily unavailable. Please call us directly.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Resolve caller
|
||||||
|
const resolved = await this.caller.resolve(phone, this.auth).catch(err => {
|
||||||
|
this.logger.error(`[WA] Caller resolution failed: ${err.message}`);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Build context
|
||||||
|
let callerContextPrompt = '';
|
||||||
|
if (resolved && !resolved.isNew && resolved.leadId) {
|
||||||
|
const ctx = await this.callerContext.getOrBuild(resolved.leadId, resolved.patientId ?? '', this.auth).catch(() => null);
|
||||||
|
if (ctx) {
|
||||||
|
callerContextPrompt = this.callerContext.renderForPrompt(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Load conversation history
|
||||||
|
const history = await this.conversation.getHistory(phone);
|
||||||
|
const messages = [
|
||||||
|
...history.map(h => ({ role: h.role as 'user' | 'assistant', content: h.content })),
|
||||||
|
{ role: 'user' as const, content: text },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 4. Build system prompt
|
||||||
|
const systemPrompt = this.buildSystemPrompt(callerContextPrompt, name, phone, resolved?.isNew ?? true);
|
||||||
|
|
||||||
|
// 5. Build tools — provider is injected so tools can send interactive messages
|
||||||
|
const tools = this.buildTools(phone);
|
||||||
|
|
||||||
|
// 6. Run AI
|
||||||
|
try {
|
||||||
|
const result = await generateText({
|
||||||
|
model: this.aiModel,
|
||||||
|
system: systemPrompt,
|
||||||
|
messages,
|
||||||
|
tools,
|
||||||
|
maxSteps: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reply = result.text?.trim();
|
||||||
|
if (reply) {
|
||||||
|
await this.provider.sendText(phone, reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Persist conversation
|
||||||
|
await this.conversation.addMessages(phone, [
|
||||||
|
{ role: 'user', content: text, timestamp: Date.now() },
|
||||||
|
...(reply ? [{ role: 'assistant' as const, content: reply, timestamp: Date.now() }] : []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[WA] AI error: ${err.message}`);
|
||||||
|
await this.provider.sendText(phone, 'Sorry, I encountered an error. Please try again or call us directly.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSystemPrompt(callerContext: string, name: string, phone: string, isNew: boolean): string {
|
||||||
|
return `You are a friendly WhatsApp assistant for a hospital. You help patients with:
|
||||||
|
- Answering questions about departments, doctors, timings, fees
|
||||||
|
- Booking appointments
|
||||||
|
- Checking existing appointments
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- Be concise — WhatsApp messages should be short (2-3 sentences max per message).
|
||||||
|
- No markdown formatting (no **, ##, bullets). Plain text only.
|
||||||
|
- When booking an appointment, collect: department, doctor preference, preferred date/time, reason for visit.
|
||||||
|
- Use the send_department_list tool to show available departments as a WhatsApp list.
|
||||||
|
- Use the send_doctor_list tool to show available doctors as a WhatsApp list.
|
||||||
|
- Use the send_slot_list tool to show available time slots as a WhatsApp list.
|
||||||
|
- Use the send_confirm_buttons tool to let the patient confirm or cancel before booking.
|
||||||
|
- After booking, send a confirmation with doctor name, date, time, and reference number.
|
||||||
|
- If the patient asks something you can't help with, suggest they call the hospital directly.
|
||||||
|
- Always be warm and professional. Use the patient's name when known.
|
||||||
|
- Reply in the same language the patient uses. Button/list labels stay in English.
|
||||||
|
|
||||||
|
CURRENT PATIENT:
|
||||||
|
Name: ${name || 'Unknown'}
|
||||||
|
Phone: ${phone}
|
||||||
|
${isNew ? 'New patient — no prior records.' : ''}
|
||||||
|
${callerContext ? `\n${callerContext}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildTools(phone: string) {
|
||||||
|
const provider = this.provider;
|
||||||
|
const platform = this.platform;
|
||||||
|
const auth = this.auth;
|
||||||
|
const logger = this.logger;
|
||||||
|
|
||||||
|
return {
|
||||||
|
lookup_appointments: tool({
|
||||||
|
description: 'Look up existing appointments for the current patient.',
|
||||||
|
parameters: z.object({
|
||||||
|
patientId: z.string().optional().describe('Patient ID — omit to use current caller context'),
|
||||||
|
}),
|
||||||
|
execute: async ({ patientId }) => {
|
||||||
|
// Resolve patient from phone if not provided
|
||||||
|
let pid = patientId;
|
||||||
|
if (!pid) {
|
||||||
|
const resolved = await this.caller.resolve(phone, auth).catch(() => null);
|
||||||
|
pid = resolved?.patientId;
|
||||||
|
}
|
||||||
|
if (!pid) return { appointments: [], message: 'No patient record found.' };
|
||||||
|
|
||||||
|
const data = await platform.query<any>(
|
||||||
|
`{ appointments(first: 10, filter: { patientId: { eq: "${pid}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||||
|
id scheduledAt appointmentStatus doctorName department reasonForVisit
|
||||||
|
} } } }`,
|
||||||
|
);
|
||||||
|
return { appointments: data.appointments.edges.map((e: any) => e.node) };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
send_department_list: tool({
|
||||||
|
description: 'Send an interactive WhatsApp list of available departments for the patient to choose from. Call this when the patient wants to book but hasn\'t specified a department.',
|
||||||
|
parameters: z.object({}),
|
||||||
|
execute: async () => {
|
||||||
|
const data = await platform.query<any>(
|
||||||
|
`{ doctors(first: 50) { edges { node { department } } } }`,
|
||||||
|
);
|
||||||
|
const departments = [...new Set(
|
||||||
|
data.doctors.edges.map((e: any) => e.node.department).filter(Boolean),
|
||||||
|
)] as string[];
|
||||||
|
|
||||||
|
if (!departments.length) return { sent: false, message: 'No departments available.' };
|
||||||
|
|
||||||
|
const sections: ListSection[] = [{
|
||||||
|
title: 'Departments',
|
||||||
|
rows: departments.slice(0, 10).map(d => ({
|
||||||
|
id: `dept:${d}`,
|
||||||
|
title: d.substring(0, 24),
|
||||||
|
})),
|
||||||
|
}];
|
||||||
|
await provider.sendList(phone, 'Which department would you like to visit?', 'View Departments', sections);
|
||||||
|
return { sent: true, departments };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
send_doctor_list: tool({
|
||||||
|
description: 'Send an interactive WhatsApp list of doctors in a specific department. Call this after the patient selects a department.',
|
||||||
|
parameters: z.object({
|
||||||
|
department: z.string().describe('Department name'),
|
||||||
|
}),
|
||||||
|
execute: async ({ department }) => {
|
||||||
|
const data = await platform.query<any>(
|
||||||
|
`{ doctors(first: 50) { edges { node {
|
||||||
|
id fullName { firstName lastName }
|
||||||
|
department specialty
|
||||||
|
consultationFeeNew { amountMicros currencyCode }
|
||||||
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||||
|
} } } }`,
|
||||||
|
);
|
||||||
|
const allDocs = normalizeDoctors(data.doctors.edges.map((e: any) => e.node));
|
||||||
|
const deptDocs = allDocs.filter((d: any) =>
|
||||||
|
d.department?.toLowerCase() === department.toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!deptDocs.length) return { sent: false, message: `No doctors found in ${department}.` };
|
||||||
|
|
||||||
|
const sections: ListSection[] = [{
|
||||||
|
title: department,
|
||||||
|
rows: deptDocs.slice(0, 10).map((d: any) => {
|
||||||
|
const name = `Dr. ${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''}`.trim();
|
||||||
|
const fee = d.consultationFeeNew?.amountMicros
|
||||||
|
? `₹${(d.consultationFeeNew.amountMicros / 1000000).toFixed(0)}`
|
||||||
|
: '';
|
||||||
|
return {
|
||||||
|
id: `doc:${d.id}:${name}`,
|
||||||
|
title: name.substring(0, 24),
|
||||||
|
description: fee ? `${d.specialty ?? department} — ${fee}` : (d.specialty ?? department),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}];
|
||||||
|
await provider.sendList(phone, `Doctors in ${department}:`, 'View Doctors', sections);
|
||||||
|
return { sent: true, count: deptDocs.length };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
send_slot_list: tool({
|
||||||
|
description: 'Send available time slots for a doctor as a WhatsApp list. Call this after the patient selects a doctor.',
|
||||||
|
parameters: z.object({
|
||||||
|
doctorId: z.string().describe('Doctor ID from the doctor list selection'),
|
||||||
|
doctorName: z.string().describe('Doctor name for display'),
|
||||||
|
date: z.string().optional().describe('Date in YYYY-MM-DD format. Defaults to tomorrow.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ doctorId, doctorName, date }) => {
|
||||||
|
const targetDate = date ?? new Date(Date.now() + 86400000).toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const data = await platform.query<any>(
|
||||||
|
`{ doctors(first: 50) { edges { node {
|
||||||
|
id fullName { firstName lastName }
|
||||||
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||||
|
} } } }`,
|
||||||
|
);
|
||||||
|
const allDocs = normalizeDoctors(data.doctors.edges.map((e: any) => e.node));
|
||||||
|
const doctor = allDocs.find((d: any) => d.id === doctorId);
|
||||||
|
const slots = doctor?.availableSlots ?? [];
|
||||||
|
|
||||||
|
if (!slots.length) {
|
||||||
|
return { sent: false, message: `No slots available for Dr. ${doctorName} on ${targetDate}.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections: ListSection[] = [{
|
||||||
|
title: `${doctorName} — ${targetDate}`,
|
||||||
|
rows: slots.slice(0, 10).map((s: any, i: number) => ({
|
||||||
|
id: `slot:${doctorId}:${targetDate}T${s.time}:00`,
|
||||||
|
title: s.time,
|
||||||
|
description: s.clinic ?? '',
|
||||||
|
})),
|
||||||
|
}];
|
||||||
|
await provider.sendList(phone, `Available slots for ${doctorName}:`, 'View Slots', sections);
|
||||||
|
return { sent: true, slots: slots.length };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
send_confirm_buttons: tool({
|
||||||
|
description: 'Send confirmation buttons before booking the appointment. Call this after all details are collected.',
|
||||||
|
parameters: z.object({
|
||||||
|
summary: z.string().describe('Appointment summary text to show the patient'),
|
||||||
|
}),
|
||||||
|
execute: async ({ summary }) => {
|
||||||
|
const buttons: InteractiveButton[] = [
|
||||||
|
{ id: 'confirm_booking', title: 'Confirm' },
|
||||||
|
{ id: 'cancel_booking', title: 'Cancel' },
|
||||||
|
];
|
||||||
|
await provider.sendButtons(phone, summary, buttons);
|
||||||
|
return { sent: true };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
book_appointment: tool({
|
||||||
|
description: 'Book the appointment after patient confirms. Only call this AFTER the patient taps the Confirm button.',
|
||||||
|
parameters: z.object({
|
||||||
|
patientName: z.string().describe('Patient name'),
|
||||||
|
phoneNumber: z.string().describe('Patient phone number'),
|
||||||
|
department: z.string().describe('Department'),
|
||||||
|
doctorName: z.string().describe('Doctor name'),
|
||||||
|
scheduledAt: z.string().describe('ISO datetime for the appointment'),
|
||||||
|
reason: z.string().describe('Reason for visit'),
|
||||||
|
}),
|
||||||
|
execute: async ({ patientName, phoneNumber, department, doctorName, scheduledAt, reason }) => {
|
||||||
|
logger.log(`[WA-BOOK] Booking: ${patientName} → ${doctorName} @ ${scheduledAt}`);
|
||||||
|
try {
|
||||||
|
// Ensure lead exists
|
||||||
|
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
||||||
|
const resolved = await this.caller.resolve(cleanPhone, auth).catch(() => null);
|
||||||
|
|
||||||
|
if (resolved?.isNew) {
|
||||||
|
// Create patient + lead
|
||||||
|
const firstName = patientName.split(' ')[0];
|
||||||
|
const lastName = patientName.split(' ').slice(1).join(' ') || '';
|
||||||
|
try {
|
||||||
|
const p = await platform.query<any>(
|
||||||
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
|
{ data: { fullName: { firstName, lastName }, phones: { primaryPhoneNumber: `+91${cleanPhone}` }, patientType: 'NEW' } },
|
||||||
|
);
|
||||||
|
const patientId = p?.createPatient?.id;
|
||||||
|
await platform.query<any>(
|
||||||
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
|
{ data: { name: `WhatsApp — ${patientName}`, contactName: { firstName, lastName }, contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` }, source: 'WHATSAPP', status: 'NEW', interestedService: department, ...(patientId ? { patientId } : {}) } },
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.warn(`[WA-BOOK] Lead/patient creation failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await platform.query<any>(
|
||||||
|
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
||||||
|
{ data: { name: `WhatsApp Booking — ${patientName} (${department})`, scheduledAt, appointmentStatus: 'SCHEDULED', doctorName, department, reasonForVisit: reason } },
|
||||||
|
);
|
||||||
|
const id = result?.createAppointment?.id;
|
||||||
|
if (id) {
|
||||||
|
return { booked: true, appointmentId: id, message: `Appointment booked! Reference: ${id.substring(0, 8)}` };
|
||||||
|
}
|
||||||
|
return { booked: false, message: 'Booking failed. Please try again.' };
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error(`[WA-BOOK] Failed: ${err.message}`);
|
||||||
|
return { booked: false, message: 'Booking failed. Please call us directly.' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/messaging/messaging.service.ts
|
||||||
|
git commit -m "feat(messaging): conversation orchestration service with AI tools"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Webhook Controller
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/messaging/messaging.controller.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the webhook controller**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/messaging/messaging.controller.ts
|
||||||
|
|
||||||
|
import { Controller, Post, Body, Logger } from '@nestjs/common';
|
||||||
|
import { MessagingProvider } from './providers/messaging-provider.interface';
|
||||||
|
import { MessagingService } from './messaging.service';
|
||||||
|
|
||||||
|
@Controller('api/messaging')
|
||||||
|
export class MessagingController {
|
||||||
|
private readonly logger = new Logger(MessagingController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly provider: MessagingProvider,
|
||||||
|
private readonly messaging: MessagingService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('webhook')
|
||||||
|
async webhook(@Body() body: any) {
|
||||||
|
this.logger.log(`[WA-WEBHOOK] Received: ${JSON.stringify(body).substring(0, 300)}`);
|
||||||
|
|
||||||
|
// Validate webhook source
|
||||||
|
if (!this.provider.validateWebhook(body)) {
|
||||||
|
this.logger.warn('[WA-WEBHOOK] Validation failed — ignoring');
|
||||||
|
return { status: 'ignored', reason: 'validation failed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse inbound message
|
||||||
|
const message = this.provider.parseInbound(body);
|
||||||
|
if (!message) {
|
||||||
|
this.logger.log('[WA-WEBHOOK] Non-message event — skipped');
|
||||||
|
return { status: 'ok', type: body?.type ?? 'unknown' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle asynchronously — don't block the webhook response
|
||||||
|
this.messaging.handleInbound(message).catch(err => {
|
||||||
|
this.logger.error(`[WA-WEBHOOK] handleInbound failed: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/messaging/messaging.controller.ts
|
||||||
|
git commit -m "feat(messaging): webhook controller"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Module Wiring and Configuration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/messaging/messaging.module.ts`
|
||||||
|
- Modify: `src/config/configuration.ts`
|
||||||
|
- Modify: `src/app.module.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add messaging config**
|
||||||
|
|
||||||
|
Add to `src/config/configuration.ts`, after the `ai` block:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
messaging: {
|
||||||
|
provider: process.env.MESSAGING_PROVIDER ?? 'gupshup',
|
||||||
|
gupshup: {
|
||||||
|
apiKey: process.env.GUPSHUP_API_KEY ?? '',
|
||||||
|
appId: process.env.GUPSHUP_APP_ID ?? '',
|
||||||
|
sourceNumber: process.env.GUPSHUP_SOURCE_NUMBER ?? '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create module**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/messaging/messaging.module.ts
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
||||||
|
import { MessagingController } from './messaging.controller';
|
||||||
|
import { MessagingService } from './messaging.service';
|
||||||
|
import { MessagingConversationService } from './messaging-conversation.service';
|
||||||
|
import { GupshupProvider } from './providers/gupshup.provider';
|
||||||
|
import { MessagingProvider } from './providers/messaging-provider.interface';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule, PlatformModule, CallerResolutionModule],
|
||||||
|
controllers: [MessagingController],
|
||||||
|
providers: [
|
||||||
|
MessagingService,
|
||||||
|
MessagingConversationService,
|
||||||
|
{
|
||||||
|
provide: MessagingProvider,
|
||||||
|
useFactory: (config: ConfigService) => {
|
||||||
|
const provider = config.get<string>('messaging.provider');
|
||||||
|
// Future: switch on provider to return OzonetelProvider, MetaProvider, etc.
|
||||||
|
return new GupshupProvider(config);
|
||||||
|
},
|
||||||
|
inject: [ConfigService],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class MessagingModule {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Register in app.module.ts**
|
||||||
|
|
||||||
|
Add import at the top:
|
||||||
|
```typescript
|
||||||
|
import { MessagingModule } from './messaging/messaging.module';
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `MessagingModule` to the `imports` array.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/messaging/messaging.module.ts src/config/configuration.ts src/app.module.ts
|
||||||
|
git commit -m "feat(messaging): module wiring and configuration"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Environment Variables and Deployment
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: Ramaiah sidecar env on EC2
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add env vars to Ramaiah sidecar**
|
||||||
|
|
||||||
|
SSH into EC2 and add to the sidecar-ramaiah environment in docker-compose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SSHPASS='SasiSuman@2007' sshpass -P "Enter passphrase" -e \
|
||||||
|
ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194
|
||||||
|
|
||||||
|
cd /opt/fortytwo
|
||||||
|
# Edit docker-compose.yml — add to sidecar-ramaiah environment:
|
||||||
|
# MESSAGING_PROVIDER=gupshup
|
||||||
|
# GUPSHUP_API_KEY=sk_c6dd2ff65d4f4e2d967cf7bbc2f620ed
|
||||||
|
# GUPSHUP_APP_ID=f6196887-ed08-4c4e-9049-e4e4ec59b254
|
||||||
|
# GUPSHUP_SOURCE_NUMBER=<the WhatsApp Business number registered with Gupshup>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Configure Gupshup webhook**
|
||||||
|
|
||||||
|
In the Gupshup dashboard, set the callback URL to:
|
||||||
|
```
|
||||||
|
https://ramaiah.engage.healix360.net/api/messaging/webhook
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build, push, and deploy sidecar**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage-server
|
||||||
|
aws ecr get-login-password --region ap-south-1 | docker login --username AWS --password-stdin 043728036361.dkr.ecr.ap-south-1.amazonaws.com
|
||||||
|
docker buildx build --platform linux/amd64 -t 043728036361.dkr.ecr.ap-south-1.amazonaws.com/fortytwo-eap/helix-engage-sidecar:alpha --push .
|
||||||
|
```
|
||||||
|
|
||||||
|
On EC2:
|
||||||
|
```bash
|
||||||
|
cd /opt/fortytwo && sudo docker compose pull sidecar-ramaiah && sudo docker compose up -d sidecar-ramaiah
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Test end-to-end**
|
||||||
|
|
||||||
|
Send a WhatsApp message to the Gupshup-registered number. Verify:
|
||||||
|
1. Webhook received (check sidecar logs)
|
||||||
|
2. AI response sent back
|
||||||
|
3. Department list renders as interactive WhatsApp list
|
||||||
|
4. Doctor selection works
|
||||||
|
5. Slot selection works
|
||||||
|
6. Confirm/cancel buttons render
|
||||||
|
7. Appointment appears in platform
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit env docs**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/plans/2026-04-20-whatsapp-ai-assistant.md
|
||||||
|
git commit -m "docs: whatsapp AI assistant implementation plan"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Missing: Source Number
|
||||||
|
|
||||||
|
The `GUPSHUP_SOURCE_NUMBER` env var needs the WhatsApp Business number registered with Gupshup. This is the number patients will message. Check the Gupshup dashboard under App Settings → WhatsApp Number.
|
||||||
|
|
||||||
|
## Provider Swap (Future)
|
||||||
|
|
||||||
|
To add Ozonetel or Meta Cloud API:
|
||||||
|
1. Create `src/messaging/providers/ozonetel.provider.ts` implementing `MessagingProvider`
|
||||||
|
2. Add config block in `configuration.ts`
|
||||||
|
3. Update the `useFactory` in `messaging.module.ts` to switch on `config.get('messaging.provider')`
|
||||||
|
4. Set `MESSAGING_PROVIDER=ozonetel` in env
|
||||||
|
|
||||||
|
No other files change — the controller, service, and conversation store are provider-agnostic.
|
||||||
270
docs/specs/2026-04-20-flow-runtime-design.md
Normal file
270
docs/specs/2026-04-20-flow-runtime-design.md
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
# WhatsApp Flow Runtime — Design Spec
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Config-driven conversation engine that reads flow definitions (JSON) and executes them at runtime. Replaces the hardcoded system prompt + tools in `messaging.service.ts`. Hospital admins define flows via API/file — no code changes needed.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Inbound WhatsApp message
|
||||||
|
→ MessagingController (existing)
|
||||||
|
→ FlowExecutionService (NEW — replaces MessagingService AI logic)
|
||||||
|
→ Load/create FlowSession from Redis
|
||||||
|
→ Match flow by trigger (or resume existing session)
|
||||||
|
→ Walk forward through Groups → Blocks
|
||||||
|
→ Pause at InputBlock, resume on next message
|
||||||
|
→ Send messages via MessagingProvider (existing)
|
||||||
|
→ Call tools via ToolRegistry (NEW)
|
||||||
|
→ Reply sent to patient
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flow Definition Schema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type Flow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
trigger: FlowTrigger;
|
||||||
|
groups: Group[];
|
||||||
|
edges: Edge[];
|
||||||
|
variables: VariableDefinition[];
|
||||||
|
version: number;
|
||||||
|
status: 'draft' | 'published';
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlowTrigger =
|
||||||
|
| { type: 'message'; conditions?: { keywords?: string[]; regex?: string } }
|
||||||
|
| { type: 'default' };
|
||||||
|
|
||||||
|
type VariableDefinition = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||||
|
defaultValue?: any;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Groups and Edges
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type Group = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
blocks: Block[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Edge = {
|
||||||
|
id: string;
|
||||||
|
from: { blockId: string; conditionId?: string };
|
||||||
|
to: { groupId: string; blockId?: string };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Block Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type Block =
|
||||||
|
| MessageBlock
|
||||||
|
| InputBlock
|
||||||
|
| ConditionBlock
|
||||||
|
| SetVariableBlock
|
||||||
|
| ToolCallBlock
|
||||||
|
| AIBlock
|
||||||
|
| JumpBlock;
|
||||||
|
|
||||||
|
// Send text/list/buttons to patient
|
||||||
|
type MessageBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'message';
|
||||||
|
content:
|
||||||
|
| { format: 'text'; text: string }
|
||||||
|
| { format: 'buttons'; text: string; buttons: { id: string; title: string }[] }
|
||||||
|
| { format: 'list'; text: string; buttonText: string; sections: { title: string; rows: { id: string; title: string; description?: string }[] }[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wait for patient reply
|
||||||
|
type InputBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'input';
|
||||||
|
inputType: 'text' | 'interactive_reply' | 'any';
|
||||||
|
variableId: string;
|
||||||
|
validation?: { regex?: string; errorMessage?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Branch based on variable value
|
||||||
|
type ConditionBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'condition';
|
||||||
|
conditions: {
|
||||||
|
id: string;
|
||||||
|
variableId: string;
|
||||||
|
operator: 'equals' | 'contains' | 'exists' | 'not_exists' | 'gt' | 'lt' | 'starts_with';
|
||||||
|
value?: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Assign/transform a variable
|
||||||
|
type SetVariableBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'set_variable';
|
||||||
|
variableId: string;
|
||||||
|
value: string;
|
||||||
|
expression?: 'extract_id';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute a registered tool
|
||||||
|
type ToolCallBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'tool_call';
|
||||||
|
toolName: string;
|
||||||
|
inputs: Record<string, string>; // values support {{variables}}
|
||||||
|
outputVariableId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate dynamic LLM response
|
||||||
|
type AIBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'ai';
|
||||||
|
prompt: string; // supports {{variables}}
|
||||||
|
outputVariableId?: string;
|
||||||
|
sendToPatient: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Jump to another group
|
||||||
|
type JumpBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'jump';
|
||||||
|
targetGroupId: string;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Session State (Redis)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type FlowSession = {
|
||||||
|
flowId: string;
|
||||||
|
currentGroupId: string;
|
||||||
|
currentBlockIndex: number;
|
||||||
|
variables: Record<string, any>;
|
||||||
|
history: ConversationEntry[];
|
||||||
|
startedAt: number;
|
||||||
|
lastActiveAt: number;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Key: `wa:flow:{phone}`, TTL: 24 hours (WhatsApp session window).
|
||||||
|
|
||||||
|
## Execution Loop
|
||||||
|
|
||||||
|
```
|
||||||
|
On inbound message:
|
||||||
|
1. Load session from Redis (or create new → match flow by trigger)
|
||||||
|
2. If paused at InputBlock → store reply in variable, advance
|
||||||
|
3. Walk forward:
|
||||||
|
- MessageBlock → send via provider, advance
|
||||||
|
- InputBlock → save session, STOP (wait for next message)
|
||||||
|
- ConditionBlock → evaluate, follow matching edge (or fall through)
|
||||||
|
- SetVariableBlock → assign value, advance
|
||||||
|
- ToolCallBlock → execute tool, store result, advance
|
||||||
|
- AIBlock → call LLM, optionally send, advance
|
||||||
|
- JumpBlock → jump to target group
|
||||||
|
- End of group → follow outgoing edge to next group
|
||||||
|
4. If no more blocks/edges → flow complete, clear session
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tool Registry
|
||||||
|
|
||||||
|
Existing tools from messaging.service.ts become registered tools:
|
||||||
|
|
||||||
|
| Tool Name | Description | Inputs | Output |
|
||||||
|
|---|---|---|---|
|
||||||
|
| resolve_caller | Phone → Lead + Patient | phone | { leadId, patientId, isNew, name } |
|
||||||
|
| send_department_list | Send interactive department list | (none — reads from platform) | { departments[] } |
|
||||||
|
| send_doctor_list | Send interactive doctor list | department | { doctors[] } |
|
||||||
|
| send_slot_list | Send time slots for doctor+date | doctorId, doctorName, date | { slots[] } |
|
||||||
|
| send_confirm_buttons | Send confirm/cancel buttons | summary | { sent: true } |
|
||||||
|
| book_appointment | Book appointment (with conflict check) | patientName, phoneNumber, department, doctorName, scheduledAt, reason | { booked, appointmentId } |
|
||||||
|
| lookup_appointments | Check existing appointments | patientId? | { appointments[] } |
|
||||||
|
| create_lead | Create lead + patient | name, phoneNumber, interest | { leadId } |
|
||||||
|
|
||||||
|
## Example Flow: Appointment Booking
|
||||||
|
|
||||||
|
```
|
||||||
|
Group: "Greeting" (g1)
|
||||||
|
→ AIBlock: greet using patient name + context
|
||||||
|
→ MessageBlock: buttons ["Book Appointment", "Check Appointment", "Ask a Question"]
|
||||||
|
→ InputBlock: store in {{intent}}
|
||||||
|
Edges: g1 → ConditionBlock routes to g2 (book) / g7 (check) / g8 (question)
|
||||||
|
|
||||||
|
Group: "Department Selection" (g2)
|
||||||
|
→ ToolCallBlock: send_department_list
|
||||||
|
→ InputBlock: store in {{selectedDepartment}}
|
||||||
|
Edge: g2 → g3
|
||||||
|
|
||||||
|
Group: "Doctor Selection" (g3)
|
||||||
|
→ ToolCallBlock: send_doctor_list, input: department={{selectedDepartment}}
|
||||||
|
→ InputBlock: store in {{selectedDoctor}}
|
||||||
|
→ SetVariableBlock: extract doctorId from {{selectedDoctor}}
|
||||||
|
Edge: g3 → g4
|
||||||
|
|
||||||
|
Group: "Date Selection" (g4)
|
||||||
|
→ MessageBlock: "When would you like to visit?"
|
||||||
|
→ MessageBlock: buttons ["Tomorrow", "Day After", "Choose Date"]
|
||||||
|
→ InputBlock: store in {{dateChoice}}
|
||||||
|
→ ConditionBlock: tomorrow → SetVariable, day_after → SetVariable, else → AI parse
|
||||||
|
Edge: g4 → g5
|
||||||
|
|
||||||
|
Group: "Slot Selection" (g5)
|
||||||
|
→ ToolCallBlock: send_slot_list, inputs: doctorId={{doctorId}}, date={{selectedDate}}
|
||||||
|
→ InputBlock: store in {{selectedSlot}}
|
||||||
|
Edge: g5 → g6
|
||||||
|
|
||||||
|
Group: "Confirmation" (g6)
|
||||||
|
→ MessageBlock: buttons ["Confirm", "Cancel"], summary text
|
||||||
|
→ InputBlock: store in {{confirmation}}
|
||||||
|
→ ConditionBlock: confirm → g7, cancel → g8
|
||||||
|
Edges: confirm → "Booking" group, cancel → "Cancelled" group
|
||||||
|
|
||||||
|
Group: "Booking" (g7)
|
||||||
|
→ ToolCallBlock: book_appointment with all collected variables
|
||||||
|
→ MessageBlock: confirmation with reference number
|
||||||
|
|
||||||
|
Group: "Cancelled" (g8)
|
||||||
|
→ MessageBlock: "No problem! Let me know if you need anything else."
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Structure (Implementation)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/messaging/
|
||||||
|
├── flow/
|
||||||
|
│ ├── flow-types.ts — All types above
|
||||||
|
│ ├── flow-execution.service.ts — Main execution loop
|
||||||
|
│ ├── flow-session.service.ts — Redis session CRUD
|
||||||
|
│ ├── flow-store.service.ts — Load/save flow definitions (file/Redis)
|
||||||
|
│ ├── flow-variable.service.ts — Variable interpolation + expressions
|
||||||
|
│ ├── tool-registry.ts — Tool name → handler mapping
|
||||||
|
│ └── default-flows/
|
||||||
|
│ └── appointment-booking.json — Seeded default flow
|
||||||
|
├── providers/ (existing, unchanged)
|
||||||
|
├── messaging.module.ts — Wire new services
|
||||||
|
├── messaging.controller.ts — Unchanged (webhook still here)
|
||||||
|
├── messaging.service.ts — Delegates to FlowExecutionService
|
||||||
|
└── types.ts — Existing types (unchanged)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Path
|
||||||
|
|
||||||
|
1. Build FlowExecutionService alongside existing MessagingService
|
||||||
|
2. Seed default appointment-booking.json (equivalent to current hardcoded flow)
|
||||||
|
3. MessagingService checks: if flow config exists → delegate to FlowExecutionService, else → current AI behavior (backward compatible)
|
||||||
|
4. Once validated, remove hardcoded AI flow from MessagingService
|
||||||
|
|
||||||
|
## Not in Scope
|
||||||
|
|
||||||
|
- Visual builder UI (future, maybe never)
|
||||||
|
- Flow versioning/rollback (v2)
|
||||||
|
- Flow analytics/metrics (v2)
|
||||||
|
- Multi-flow routing (v2 — for now, one active flow per trigger type)
|
||||||
239
docs/website-widget.md
Normal file
239
docs/website-widget.md
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
# Website Chat Widget — Operations Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A floating chat/booking/contact widget that hospitals embed on their website via a single `<script>` tag. Visitors can:
|
||||||
|
- **Chat** with an AI assistant (powered by OpenAI/Anthropic)
|
||||||
|
- **Book** appointments (department → doctor → date → slot)
|
||||||
|
- **Contact** the hospital (name, phone, interest — creates a lead in the CRM)
|
||||||
|
|
||||||
|
All interactions create or update leads in Helix Engage, so CC agents see the full visitor journey when they call back.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Embed Snippet
|
||||||
|
|
||||||
|
Add this to any page on the hospital website (before `</body>`):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="https://ramaiah.engage.healix360.net/widget.js"
|
||||||
|
data-key="956018d178194fb9.313657fbc8a912b9cf8c93b9a51dfb209022fcd9910bd5abc7aa16dfaacf98a3">
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
|---|---|
|
||||||
|
| `src` | The sidecar URL + `/widget.js` |
|
||||||
|
| `data-key` | Site key — generated and rotatable from the admin portal |
|
||||||
|
|
||||||
|
The widget renders in a **shadow DOM** — its styles don't leak into or get affected by the host website's CSS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin Configuration
|
||||||
|
|
||||||
|
### Settings Page
|
||||||
|
|
||||||
|
URL: `https://ramaiah.engage.healix360.net/settings/widget` (login as admin/supervisor)
|
||||||
|
|
||||||
|
| Setting | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Enabled** | Master kill switch — when off, widget.js no-ops |
|
||||||
|
| **Site Key** | Read-only HMAC-signed key. Copy-to-clipboard for the embed snippet |
|
||||||
|
| **Site ID** | Read-only identifier |
|
||||||
|
| **Rotate Key** | Generates a new key, invalidates the old embed snippet |
|
||||||
|
| **Hosting URL** | Public base URL for widget.js. Leave blank to use same origin as sidecar |
|
||||||
|
| **Allowed Origins** | Whitelist of domains allowed to embed. Empty = any origin (test mode) |
|
||||||
|
| **Show on Login Page** | Toggle to display widget on the Helix Engage login page |
|
||||||
|
|
||||||
|
### Configuration API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Read config (public — no auth)
|
||||||
|
curl https://ramaiah.engage.healix360.net/api/config/widget
|
||||||
|
|
||||||
|
# Read full config (admin)
|
||||||
|
curl https://ramaiah.engage.healix360.net/api/config/widget/admin
|
||||||
|
|
||||||
|
# Update config
|
||||||
|
curl -X PUT https://ramaiah.engage.healix360.net/api/config/widget \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"enabled": true, "allowedOrigins": ["https://ramaiahmedical.com"]}'
|
||||||
|
|
||||||
|
# Rotate site key (invalidates old embeds)
|
||||||
|
curl -X POST https://ramaiah.engage.healix360.net/api/config/widget/rotate-key
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widget Key Security
|
||||||
|
|
||||||
|
- Key format: `{siteId}.{hmacSignature}`
|
||||||
|
- HMAC computed as: `sha256(siteId, secret=WIDGET_SECRET env var)`
|
||||||
|
- Validated with `timingSafeEqual()` to prevent timing attacks
|
||||||
|
- Origin checked against `allowedOrigins` whitelist
|
||||||
|
- Empty whitelist = test mode (any origin allowed)
|
||||||
|
|
||||||
|
### For Production
|
||||||
|
|
||||||
|
Before going live on a real hospital website:
|
||||||
|
1. Set `allowedOrigins` to the hospital's domain(s): `["https://ramaiahmedical.com", "https://www.ramaiahmedical.com"]`
|
||||||
|
2. Ensure `WIDGET_SECRET` env var is set in the sidecar (auto-generated on first run if missing)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widget API Endpoints
|
||||||
|
|
||||||
|
All endpoints require `WidgetKeyGuard` (key as query param `?key=...` or header `X-Widget-Key`).
|
||||||
|
|
||||||
|
| Endpoint | Method | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `/api/widget/init` | GET | Returns brand name, logo, colors, reCAPTCHA key |
|
||||||
|
| `/api/widget/doctors` | GET | All doctors with departments, specialties, fees, visit slots |
|
||||||
|
| `/api/widget/slots?doctorId=X&date=YYYY-MM-DD` | GET | Available time slots for a doctor on a date |
|
||||||
|
| `/api/widget/book` | POST | Book appointment (requires captcha token) |
|
||||||
|
| `/api/widget/lead` | POST | Create lead from contact form (requires captcha token) |
|
||||||
|
| `/api/widget/chat-start` | POST | Start chat session — body: `{name, phone}`, returns `{leadId}` |
|
||||||
|
| `/api/widget/chat` | POST | Stream AI reply — body: `{leadId, messages[], branch?}`, returns SSE stream |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How the Chat Flow Works
|
||||||
|
|
||||||
|
1. Visitor opens widget → Chat tab shows name + phone form
|
||||||
|
2. Visitor enters name + phone → `POST /api/widget/chat-start` → returns `leadId`
|
||||||
|
- Creates or finds existing lead by phone (deduplication)
|
||||||
|
3. Visitor types a message → `POST /api/widget/chat` with `leadId` + message history
|
||||||
|
- AI streams a reply via Server-Sent Events
|
||||||
|
- AI has tools: branch selection, doctor search, slot lookup, booking suggestions
|
||||||
|
4. After conversation ends, transcript is saved to lead activity timeline
|
||||||
|
5. CC agent sees the WhatsApp/chat history when calling the patient back
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How the Booking Flow Works
|
||||||
|
|
||||||
|
1. Visitor opens widget → Book tab
|
||||||
|
2. Selects department → fetches doctor list
|
||||||
|
3. Selects doctor → fetches available dates/slots
|
||||||
|
4. Enters patient name, phone, chief complaint
|
||||||
|
5. `POST /api/widget/book` with captcha token
|
||||||
|
6. Creates patient + lead + appointment in the platform
|
||||||
|
7. Shows confirmation with reference number
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How Lead Capture Works
|
||||||
|
|
||||||
|
1. Visitor opens widget → Contact tab
|
||||||
|
2. Enters name, phone, interest, optional message
|
||||||
|
3. `POST /api/widget/lead` with captcha token
|
||||||
|
4. Creates lead with source "Website Widget"
|
||||||
|
5. Shows success confirmation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lead Deduplication
|
||||||
|
|
||||||
|
All three flows (chat, book, contact) use `CallerResolutionService` to find existing leads by phone number. If a visitor chats, then books, then contacts — all within 24 hours — they create ONE lead, not three. Activities are appended to the same lead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## reCAPTCHA Protection
|
||||||
|
|
||||||
|
- Booking and lead endpoints use reCAPTCHA v3 (invisible — no user friction)
|
||||||
|
- Chat endpoints do NOT use reCAPTCHA (session already verified by name+phone gate)
|
||||||
|
- reCAPTCHA site key returned by `/api/widget/init`
|
||||||
|
- Server validates tokens via Google reCAPTCHA API
|
||||||
|
- Captcha can be bypassed for webhooks using `captchaToken: "webhook-bypass"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branding & Theming
|
||||||
|
|
||||||
|
The widget pulls branding from the sidecar theme config:
|
||||||
|
- **Hospital name** — displayed in the widget header
|
||||||
|
- **Logo** — shown in the header
|
||||||
|
- **Brand color** — applied to buttons, links, active states
|
||||||
|
- **Location** — shown under the hospital name
|
||||||
|
|
||||||
|
Configure via: `https://ramaiah.engage.healix360.net/settings` → Theme section
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widget Source Code
|
||||||
|
|
||||||
|
| Location | Description |
|
||||||
|
|---|---|
|
||||||
|
| `packages/widget-src/` | Preact source (chat.tsx, booking.tsx, contact.tsx, api.ts, etc.) |
|
||||||
|
| `public/widget.js` | Compiled IIFE bundle (served by NestJS static assets) |
|
||||||
|
| `src/widget/` | Backend API (controller, service, chat service, key guard) |
|
||||||
|
| `src/config/widget-keys.service.ts` | Key generation + HMAC validation |
|
||||||
|
| `src/config/widget-config.service.ts` | Config file management (data/widget.json) |
|
||||||
|
|
||||||
|
### Rebuilding widget.js
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/widget-src
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
# Output goes to ../../public/widget.js (configured in vite.config.ts)
|
||||||
|
```
|
||||||
|
|
||||||
|
After rebuilding:
|
||||||
|
1. Commit `public/widget.js`
|
||||||
|
2. Build Docker image and deploy sidecar
|
||||||
|
3. Widget auto-updates on next page load (1h cache)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment Checklist
|
||||||
|
|
||||||
|
### First-time setup on a new tenant:
|
||||||
|
|
||||||
|
1. **Sidecar serves widget.js** — verify `https://{tenant}.engage.healix360.net/widget.js` returns JS, not HTML
|
||||||
|
2. **Caddy routing** — `/widget.js` must route to sidecar, not frontend. Add to the `@api path` matcher in Caddyfile:
|
||||||
|
```
|
||||||
|
@api path /api/* /widget.js /graphql /auth/* ...
|
||||||
|
```
|
||||||
|
3. **Widget config exists** — `GET /api/config/widget` should return `{enabled: true, key: "..."}`
|
||||||
|
4. **Generate key if needed** — `POST /api/widget/keys/generate`
|
||||||
|
5. **Set allowed origins** (for production) — `PUT /api/config/widget` with `allowedOrigins: ["https://hospital.com"]`
|
||||||
|
6. **Test embed** — paste the `<script>` tag into a test HTML page and verify the widget appears
|
||||||
|
7. **Verify AI** — start a chat, confirm AI responds (requires `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` in sidecar env)
|
||||||
|
|
||||||
|
### Current deployment (Ramaiah):
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
|---|---|
|
||||||
|
| Widget URL | `https://ramaiah.engage.healix360.net/widget.js` |
|
||||||
|
| Site Key | `956018d178194fb9.313657fbc8a912b9cf8c93b9a51dfb209022fcd9910bd5abc7aa16dfaacf98a3` |
|
||||||
|
| Site ID | `956018d178194fb9` |
|
||||||
|
| Allowed Origins | Empty (test mode — any origin) |
|
||||||
|
| Login Page Widget | Enabled |
|
||||||
|
| reCAPTCHA | Configured (key returned by `/api/widget/init`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Widget doesn't appear
|
||||||
|
1. Check browser console for errors
|
||||||
|
2. Verify `VITE_API_URL` in the frontend build points to the sidecar URL (not `localhost`)
|
||||||
|
3. Verify `/api/config/widget` returns `enabled: true` and `embed.loginPage: true`
|
||||||
|
4. Verify `/widget.js` returns actual JavaScript (not HTML from the frontend catch-all)
|
||||||
|
|
||||||
|
### "leadId required" error
|
||||||
|
The chat requires a `chat-start` call first (name + phone → leadId). If the widget skips this step, it's using an old `widget.js`. Clear browser cache or deploy the correct version from commit `aa41a2a`.
|
||||||
|
|
||||||
|
### Chat returns "AI not configured"
|
||||||
|
Missing `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` in sidecar environment. Check with:
|
||||||
|
```bash
|
||||||
|
docker exec sidecar-ramaiah env | grep -i 'OPENAI\|ANTHROPIC\|AI_PROVIDER'
|
||||||
|
```
|
||||||
|
|
||||||
|
### CORS errors
|
||||||
|
The widget key guard validates the request origin against `allowedOrigins`. If empty, any origin is allowed. If set, the host website's domain must be in the list.
|
||||||
|
|
||||||
|
### Widget shows on login page but not on hospital website
|
||||||
|
The login page injection code is in `helix-engage/src/pages/login.tsx`. For external hospital websites, the embed snippet must be manually added to their HTML. There's no automatic injection for third-party sites.
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true
|
"deleteOutDir": true,
|
||||||
|
"assets": [
|
||||||
|
{ "include": "messaging/flow/default-flows/*.json", "watchAssets": true }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
188
package-lock.json
generated
188
package-lock.json
generated
@@ -21,11 +21,13 @@
|
|||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/platform-socket.io": "^11.1.17",
|
"@nestjs/platform-socket.io": "^11.1.17",
|
||||||
"@nestjs/websockets": "^11.1.17",
|
"@nestjs/websockets": "^11.1.17",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"ai": "^6.0.116",
|
"ai": "^6.0.116",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
"json-rules-engine": "^6.6.0",
|
"json-rules-engine": "^6.6.0",
|
||||||
"kafkajs": "^2.2.4",
|
"kafkajs": "^2.2.4",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
@@ -5160,6 +5162,15 @@
|
|||||||
"integrity": "sha512-MIiyZI4/MK9UGUXWt0jJcCZhVw7YdhBuTOuqP/BjuLDLZ2PmmViMIQgZiWxtaMicQfAz/kMrZ5T7PKxFSkTeUA==",
|
"integrity": "sha512-MIiyZI4/MK9UGUXWt0jJcCZhVw7YdhBuTOuqP/BjuLDLZ2PmmViMIQgZiWxtaMicQfAz/kMrZ5T7PKxFSkTeUA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/qrcode": {
|
||||||
|
"version": "1.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||||
|
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
||||||
@@ -6223,7 +6234,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -6233,7 +6243,6 @@
|
|||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1"
|
"color-convert": "^2.0.1"
|
||||||
@@ -6728,7 +6737,6 @@
|
|||||||
"version": "5.3.1",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -7013,7 +7021,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
@@ -7026,7 +7033,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/colorette": {
|
"node_modules/colorette": {
|
||||||
@@ -7266,6 +7272,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decamelize": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dedent": {
|
"node_modules/dedent": {
|
||||||
"version": "1.7.2",
|
"version": "1.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||||
@@ -7434,6 +7449,12 @@
|
|||||||
"node": ">=0.3.1"
|
"node": ">=0.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dijkstrajs": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.2.3",
|
"version": "17.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||||
@@ -7533,7 +7554,6 @@
|
|||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
@@ -8656,7 +8676,6 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
@@ -9255,7 +9274,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -11240,7 +11258,6 @@
|
|||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -11298,7 +11315,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -11657,6 +11673,15 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -11822,6 +11847,127 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dijkstrajs": "^1.0.1",
|
||||||
|
"pngjs": "^5.0.0",
|
||||||
|
"yargs": "^15.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"qrcode": "bin/qrcode"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/cliui": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.0",
|
||||||
|
"wrap-ansi": "^6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/find-up": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^5.0.0",
|
||||||
|
"path-exists": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/locate-path": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/p-limit": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-try": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/p-locate": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/y18n": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs": {
|
||||||
|
"version": "15.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||||
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^6.0.0",
|
||||||
|
"decamelize": "^1.2.0",
|
||||||
|
"find-up": "^4.1.0",
|
||||||
|
"get-caller-file": "^2.0.1",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"require-main-filename": "^2.0.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"which-module": "^2.0.0",
|
||||||
|
"y18n": "^4.0.0",
|
||||||
|
"yargs-parser": "^18.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||||
|
"version": "18.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^5.0.0",
|
||||||
|
"decamelize": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
||||||
@@ -11954,7 +12100,6 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -11984,6 +12129,12 @@
|
|||||||
"node": ">=8.6.0"
|
"node": ">=8.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-main-filename": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -12269,6 +12420,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
@@ -12676,7 +12833,6 @@
|
|||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"emoji-regex": "^8.0.0",
|
"emoji-regex": "^8.0.0",
|
||||||
@@ -12707,7 +12863,6 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1"
|
"ansi-regex": "^5.0.1"
|
||||||
@@ -13929,6 +14084,12 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-module": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
@@ -13950,7 +14111,6 @@
|
|||||||
"version": "6.2.0",
|
"version": "6.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-styles": "^4.0.0",
|
"ansi-styles": "^4.0.0",
|
||||||
|
|||||||
@@ -32,11 +32,13 @@
|
|||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/platform-socket.io": "^11.1.17",
|
"@nestjs/platform-socket.io": "^11.1.17",
|
||||||
"@nestjs/websockets": "^11.1.17",
|
"@nestjs/websockets": "^11.1.17",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"ai": "^6.0.116",
|
"ai": "^6.0.116",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
"json-rules-engine": "^6.6.0",
|
"json-rules-engine": "^6.6.0",
|
||||||
"kafkajs": "^2.2.4",
|
"kafkajs": "^2.2.4",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
|
|||||||
18
packages/widget-src/package.json
Normal file
18
packages/widget-src/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "helix-engage-widget",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"preact": "^10.25.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@preact/preset-vite": "^2.9.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
57
packages/widget-src/src/api.ts
Normal file
57
packages/widget-src/src/api.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import type { WidgetConfig, Doctor, TimeSlot } from './types';
|
||||||
|
|
||||||
|
let baseUrl = '';
|
||||||
|
let widgetKey = '';
|
||||||
|
|
||||||
|
export const initApi = (url: string, key: string) => {
|
||||||
|
baseUrl = url;
|
||||||
|
widgetKey = key;
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers = () => ({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Widget-Key': widgetKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchInit = async (): Promise<WidgetConfig> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/init?key=${widgetKey}`);
|
||||||
|
if (!res.ok) throw new Error('Widget init failed');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchDoctors = async (): Promise<Doctor[]> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/doctors?key=${widgetKey}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to load doctors');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchSlots = async (doctorId: string, date: string): Promise<TimeSlot[]> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/slots?key=${widgetKey}&doctorId=${doctorId}&date=${date}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to load slots');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const submitBooking = async (data: any): Promise<{ appointmentId: string; reference: string }> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/book?key=${widgetKey}`, {
|
||||||
|
method: 'POST', headers: headers(), body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Booking failed');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const submitLead = async (data: any): Promise<{ leadId: string }> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/lead?key=${widgetKey}`, {
|
||||||
|
method: 'POST', headers: headers(), body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Submission failed');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const streamChat = async (messages: any[], captchaToken?: string): Promise<ReadableStream> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/chat?key=${widgetKey}`, {
|
||||||
|
method: 'POST', headers: headers(),
|
||||||
|
body: JSON.stringify({ messages, captchaToken }),
|
||||||
|
});
|
||||||
|
if (!res.ok || !res.body) throw new Error('Chat failed');
|
||||||
|
return res.body;
|
||||||
|
};
|
||||||
199
packages/widget-src/src/booking.tsx
Normal file
199
packages/widget-src/src/booking.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
import { fetchDoctors, fetchSlots, submitBooking } from './api';
|
||||||
|
import type { Doctor, TimeSlot } from './types';
|
||||||
|
|
||||||
|
type Step = 'department' | 'doctor' | 'datetime' | 'details' | 'success';
|
||||||
|
|
||||||
|
export const Booking = () => {
|
||||||
|
const [step, setStep] = useState<Step>('department');
|
||||||
|
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||||
|
const [departments, setDepartments] = useState<string[]>([]);
|
||||||
|
const [selectedDept, setSelectedDept] = useState('');
|
||||||
|
const [selectedDoctor, setSelectedDoctor] = useState<Doctor | null>(null);
|
||||||
|
const [selectedDate, setSelectedDate] = useState('');
|
||||||
|
const [slots, setSlots] = useState<TimeSlot[]>([]);
|
||||||
|
const [selectedSlot, setSelectedSlot] = useState('');
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [complaint, setComplaint] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [reference, setReference] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDoctors().then(docs => {
|
||||||
|
setDoctors(docs);
|
||||||
|
setDepartments([...new Set(docs.map(d => d.department).filter(Boolean))]);
|
||||||
|
}).catch(() => setError('Failed to load doctors'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredDoctors = selectedDept ? doctors.filter(d => d.department === selectedDept) : [];
|
||||||
|
|
||||||
|
const handleDoctorSelect = (doc: Doctor) => {
|
||||||
|
setSelectedDoctor(doc);
|
||||||
|
setSelectedDate(new Date().toISOString().split('T')[0]);
|
||||||
|
setStep('datetime');
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDoctor && selectedDate) {
|
||||||
|
fetchSlots(selectedDoctor.id, selectedDate).then(setSlots).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [selectedDoctor, selectedDate]);
|
||||||
|
|
||||||
|
const handleBook = async () => {
|
||||||
|
if (!selectedDoctor || !selectedSlot || !name || !phone) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const scheduledAt = `${selectedDate}T${selectedSlot}:00`;
|
||||||
|
const result = await submitBooking({
|
||||||
|
departmentId: selectedDept,
|
||||||
|
doctorId: selectedDoctor.id,
|
||||||
|
scheduledAt,
|
||||||
|
patientName: name,
|
||||||
|
patientPhone: phone,
|
||||||
|
chiefComplaint: complaint,
|
||||||
|
captchaToken: 'dev-bypass',
|
||||||
|
});
|
||||||
|
setReference(result.reference);
|
||||||
|
setStep('success');
|
||||||
|
} catch {
|
||||||
|
setError('Booking failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepIndex = { department: 0, doctor: 1, datetime: 2, details: 3, success: 4 };
|
||||||
|
const currentStep = stepIndex[step];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{step !== 'success' && (
|
||||||
|
<div class="widget-steps">
|
||||||
|
{[0, 1, 2, 3].map(i => (
|
||||||
|
<div key={i} class={`widget-step ${i < currentStep ? 'done' : i === currentStep ? 'active' : ''}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div style={{ color: '#dc2626', fontSize: '12px', marginBottom: '8px' }}>{error}</div>}
|
||||||
|
|
||||||
|
{step === 'department' && (
|
||||||
|
<div>
|
||||||
|
<div class="widget-label" style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600 }}>Select Department</div>
|
||||||
|
{departments.map(dept => (
|
||||||
|
<button
|
||||||
|
key={dept}
|
||||||
|
class="widget-btn widget-btn-secondary"
|
||||||
|
style={{ marginBottom: '6px', textAlign: 'left' }}
|
||||||
|
onClick={() => { setSelectedDept(dept); setStep('doctor'); }}
|
||||||
|
>
|
||||||
|
{dept.replace(/_/g, ' ')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'doctor' && (
|
||||||
|
<div>
|
||||||
|
<div class="widget-label" style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600 }}>
|
||||||
|
Select Doctor — {selectedDept.replace(/_/g, ' ')}
|
||||||
|
</div>
|
||||||
|
{filteredDoctors.map(doc => (
|
||||||
|
<button
|
||||||
|
key={doc.id}
|
||||||
|
class="widget-btn widget-btn-secondary"
|
||||||
|
style={{ marginBottom: '6px', textAlign: 'left' }}
|
||||||
|
onClick={() => handleDoctorSelect(doc)}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600 }}>{doc.name}</div>
|
||||||
|
<div style={{ fontSize: '11px', color: '#6b7280' }}>
|
||||||
|
{doc.visitingHours ?? ''} {doc.clinic?.clinicName ? `• ${doc.clinic.clinicName}` : ''}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button class="widget-btn widget-btn-secondary" style={{ marginTop: '8px' }} onClick={() => setStep('department')}>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'datetime' && (
|
||||||
|
<div>
|
||||||
|
<div class="widget-label" style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600 }}>
|
||||||
|
{selectedDoctor?.name} — Pick Date & Time
|
||||||
|
</div>
|
||||||
|
<div class="widget-field">
|
||||||
|
<label class="widget-label">Date</label>
|
||||||
|
<input
|
||||||
|
class="widget-input"
|
||||||
|
type="date"
|
||||||
|
value={selectedDate}
|
||||||
|
min={new Date().toISOString().split('T')[0]}
|
||||||
|
onInput={(e: any) => { setSelectedDate(e.target.value); setSelectedSlot(''); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{slots.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label class="widget-label">Available Slots</label>
|
||||||
|
<div class="widget-slots">
|
||||||
|
{slots.map(s => (
|
||||||
|
<button
|
||||||
|
key={s.time}
|
||||||
|
class={`widget-slot ${s.time === selectedSlot ? 'selected' : ''} ${!s.available ? 'unavailable' : ''}`}
|
||||||
|
onClick={() => s.available && setSelectedSlot(s.time)}
|
||||||
|
disabled={!s.available}
|
||||||
|
>
|
||||||
|
{s.time}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
|
||||||
|
<button class="widget-btn widget-btn-secondary" style={{ flex: 1 }} onClick={() => setStep('doctor')}>← Back</button>
|
||||||
|
<button class="widget-btn" style={{ flex: 1 }} disabled={!selectedSlot} onClick={() => setStep('details')}>Next →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'details' && (
|
||||||
|
<div>
|
||||||
|
<div class="widget-label" style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600 }}>Your Details</div>
|
||||||
|
<div class="widget-field">
|
||||||
|
<label class="widget-label">Full Name *</label>
|
||||||
|
<input class="widget-input" placeholder="Your name" value={name} onInput={(e: any) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div class="widget-field">
|
||||||
|
<label class="widget-label">Phone Number *</label>
|
||||||
|
<input class="widget-input" placeholder="+91 9876543210" value={phone} onInput={(e: any) => setPhone(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div class="widget-field">
|
||||||
|
<label class="widget-label">Chief Complaint</label>
|
||||||
|
<textarea class="widget-input widget-textarea" placeholder="Describe your concern..." value={complaint} onInput={(e: any) => setComplaint(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button class="widget-btn widget-btn-secondary" style={{ flex: 1 }} onClick={() => setStep('datetime')}>← Back</button>
|
||||||
|
<button class="widget-btn" style={{ flex: 1 }} disabled={!name || !phone || loading} onClick={handleBook}>
|
||||||
|
{loading ? 'Booking...' : 'Book Appointment'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'success' && (
|
||||||
|
<div class="widget-success">
|
||||||
|
<div class="widget-success-icon">✅</div>
|
||||||
|
<div class="widget-success-title">Appointment Booked!</div>
|
||||||
|
<div class="widget-success-text">
|
||||||
|
Reference: <strong>{reference}</strong><br />
|
||||||
|
{selectedDoctor?.name} • {selectedDate} at {selectedSlot}<br /><br />
|
||||||
|
We'll send a confirmation SMS to your phone.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
94
packages/widget-src/src/chat.tsx
Normal file
94
packages/widget-src/src/chat.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'preact/hooks';
|
||||||
|
import { streamChat } from './api';
|
||||||
|
import type { ChatMessage } from './types';
|
||||||
|
|
||||||
|
const QUICK_ACTIONS = [
|
||||||
|
'Doctor availability',
|
||||||
|
'Clinic timings',
|
||||||
|
'Book appointment',
|
||||||
|
'Health packages',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Chat = () => {
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const sendMessage = async (text: string) => {
|
||||||
|
if (!text.trim() || loading) return;
|
||||||
|
|
||||||
|
const userMsg: ChatMessage = { role: 'user', content: text.trim() };
|
||||||
|
const updated = [...messages, userMsg];
|
||||||
|
setMessages(updated);
|
||||||
|
setInput('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await streamChat(updated);
|
||||||
|
const reader = stream.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let assistantText = '';
|
||||||
|
|
||||||
|
setMessages([...updated, { role: 'assistant', content: '' }]);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
assistantText += decoder.decode(value, { stream: true });
|
||||||
|
setMessages([...updated, { role: 'assistant', content: assistantText }]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessages([...updated, { role: 'assistant', content: 'Sorry, I encountered an error. Please try again.' }]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
<div class="chat-messages" ref={scrollRef}>
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||||
|
<div style={{ fontSize: '24px', marginBottom: '8px' }}>👋</div>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: 600, color: '#1f2937', marginBottom: '4px' }}>
|
||||||
|
How can we help you?
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#6b7280', marginBottom: '16px' }}>
|
||||||
|
Ask about doctors, clinics, packages, or book an appointment.
|
||||||
|
</div>
|
||||||
|
<div class="quick-actions">
|
||||||
|
{QUICK_ACTIONS.map(q => (
|
||||||
|
<button key={q} class="quick-action" onClick={() => sendMessage(q)}>{q}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map((msg, i) => (
|
||||||
|
<div key={i} class={`chat-msg ${msg.role}`}>
|
||||||
|
<div class="chat-bubble">{msg.content || '...'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div class="chat-input-row">
|
||||||
|
<input
|
||||||
|
class="widget-input chat-input"
|
||||||
|
placeholder="Type a message..."
|
||||||
|
value={input}
|
||||||
|
onInput={(e: any) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e: any) => e.key === 'Enter' && sendMessage(input)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<button class="chat-send" onClick={() => sendMessage(input)} disabled={loading || !input.trim()}>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
85
packages/widget-src/src/contact.tsx
Normal file
85
packages/widget-src/src/contact.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
import { submitLead } from './api';
|
||||||
|
|
||||||
|
export const Contact = () => {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [interest, setInterest] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!name.trim() || !phone.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await submitLead({
|
||||||
|
name: name.trim(),
|
||||||
|
phone: phone.trim(),
|
||||||
|
interest: interest.trim() || undefined,
|
||||||
|
message: message.trim() || undefined,
|
||||||
|
captchaToken: 'dev-bypass',
|
||||||
|
});
|
||||||
|
setSuccess(true);
|
||||||
|
} catch {
|
||||||
|
setError('Submission failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div class="widget-success">
|
||||||
|
<div class="widget-success-icon">🙏</div>
|
||||||
|
<div class="widget-success-title">Thank you!</div>
|
||||||
|
<div class="widget-success-text">
|
||||||
|
An agent will call you shortly on {phone}.<br />
|
||||||
|
We typically respond within 30 minutes during business hours.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '13px', fontWeight: 600, color: '#1f2937', marginBottom: '12px' }}>
|
||||||
|
Get in touch
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#6b7280', marginBottom: '16px' }}>
|
||||||
|
Leave your details and we'll call you back.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div style={{ color: '#dc2626', fontSize: '12px', marginBottom: '8px' }}>{error}</div>}
|
||||||
|
|
||||||
|
<div class="widget-field">
|
||||||
|
<label class="widget-label">Full Name *</label>
|
||||||
|
<input class="widget-input" placeholder="Your name" value={name} onInput={(e: any) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div class="widget-field">
|
||||||
|
<label class="widget-label">Phone Number *</label>
|
||||||
|
<input class="widget-input" placeholder="+91 9876543210" value={phone} onInput={(e: any) => setPhone(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div class="widget-field">
|
||||||
|
<label class="widget-label">Interested In</label>
|
||||||
|
<select class="widget-select" value={interest} onChange={(e: any) => setInterest(e.target.value)}>
|
||||||
|
<option value="">Select (optional)</option>
|
||||||
|
<option value="Consultation">General Consultation</option>
|
||||||
|
<option value="Health Checkup">Health Checkup</option>
|
||||||
|
<option value="Surgery">Surgery</option>
|
||||||
|
<option value="Second Opinion">Second Opinion</option>
|
||||||
|
<option value="Other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="widget-field">
|
||||||
|
<label class="widget-label">Message</label>
|
||||||
|
<textarea class="widget-input widget-textarea" placeholder="How can we help? (optional)" value={message} onInput={(e: any) => setMessage(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<button class="widget-btn" disabled={!name.trim() || !phone.trim() || loading} onClick={handleSubmit}>
|
||||||
|
{loading ? 'Sending...' : 'Send Message'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
27
packages/widget-src/src/icons.ts
Normal file
27
packages/widget-src/src/icons.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// FontAwesome Pro 6.7.2 Duotone SVGs — bundled as inline strings
|
||||||
|
// License: https://fontawesome.com/license (Commercial License)
|
||||||
|
|
||||||
|
export const icons = {
|
||||||
|
chat: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M0 64C0 28.7 28.7 0 64 0L448 0c35.3 0 64 28.7 64 64l0 288c0 35.3-28.7 64-64 64l-138.7 0L185.6 508.8c-4.8 3.6-11.3 4.2-16.8 1.5s-8.8-8.2-8.8-14.3l0-80-96 0c-35.3 0-64-28.7-64-64L0 64zM96 208a32 32 0 1 0 64 0 32 32 0 1 0 -64 0zm128 0a32 32 0 1 0 64 0 32 32 0 1 0 -64 0zm128 0a32 32 0 1 0 64 0 32 32 0 1 0 -64 0z"/><path class="fa-primary" d="M96 208a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm128 0a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm160-32a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"/></svg>`,
|
||||||
|
|
||||||
|
calendar: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M0 192l448 0 0 272c0 26.5-21.5 48-48 48L48 512c-26.5 0-48-21.5-48-48L0 192zM119 319c-9.4 9.4-9.4 24.6 0 33.9l64 64c4.7 4.7 10.8 7 17 7s12.3-2.3 17-7L329 305c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-95 95-47-47c-9.4-9.4-24.6-9.4-33.9 0z"/><path class="fa-primary" d="M128 0C110.3 0 96 14.3 96 32l0 32L48 64C21.5 64 0 85.5 0 112l0 80 448 0 0-80c0-26.5-21.5-48-48-48l-48 0 0-32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 32L160 64l0-32c0-17.7-14.3-32-32-32zM329 305c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-95 95-47-47c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l64 64c9.4 9.4 24.6 9.4 33.9 0L329 305z"/></svg>`,
|
||||||
|
|
||||||
|
phone: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M0 64C0 311.4 200.6 512 448 512c18 0 33.8-12.1 38.6-29.5l24-88c1-3.5 1.4-7 1.4-10.5c0-15.8-9.4-30.6-24.6-36.9l-96-40c-16.3-6.8-35.2-2.1-46.3 11.6L304.7 368C234.3 334.7 177.3 277.7 144 207.3L193.3 167c13.7-11.2 18.4-30 11.6-46.3l-40-96C158.6 9.4 143.8 0 128 0c-3.5 0-7 .5-10.5 1.4l-88 24C12.1 30.2 0 46 0 64z"/><path class="fa-primary" d="M295 217c-9.4-9.4-9.4-24.6 0-33.9l135-135L384 48c-13.3 0-24-10.7-24-24s10.7-24 24-24L488 0c13.3 0 24 10.7 24 24l0 104c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-46.1L329 217c-9.4 9.4-24.6 9.4-33.9 0z"/></svg>`,
|
||||||
|
|
||||||
|
send: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M1.4 72.3c0 6.1 1.4 12.4 4.7 18.6l70 134.6c63.3 7.9 126.6 15.8 190 23.7c3.4 .4 6 3.3 6 6.7s-2.6 6.3-6 6.7l-190 23.7L6.1 421.1c-14.6 28.1 7.3 58.6 35.2 58.6c5.3 0 10.8-1.1 16.3-3.5L492.9 285.3c11.6-5.1 19.1-16.6 19.1-29.3s-7.5-24.2-19.1-29.3L57.6 35.8C29.5 23.5 1.4 45.6 1.4 72.3z"/><path class="fa-primary" d="M76.1 286.5l190-23.7c3.4-.4 6-3.3 6-6.7s-2.6-6.3-6-6.7l-190-23.7 8.2 15.7c4.8 9.3 4.8 20.3 0 29.5l-8.2 15.7z"/></svg>`,
|
||||||
|
|
||||||
|
close: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6z"/></svg>`,
|
||||||
|
|
||||||
|
check: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M0 256a256 256 0 1 0 512 0A256 256 0 1 0 0 256zm136 0c0-6.1 2.3-12.3 7-17c9.4-9.4 24.6-9.4 33.9 0l47 47c37-37 74-74 111-111c4.7-4.7 10.8-7 17-7s12.3 2.3 17 7c2.3 2.3 4.1 5 5.3 7.9c.6 1.5 1 2.9 1.3 4.4c.2 1.1 .3 2.2 .3 2.2c.1 1.2 .1 1.2 .1 2.5c-.1 1.5-.1 1.9-.1 2.3c-.1 .7-.2 1.5-.3 2.2c-.3 1.5-.7 3-1.3 4.4c-1.2 2.9-2.9 5.6-5.3 7.9c-42.7 42.7-85.3 85.3-128 128c-4.7 4.7-10.8 7-17 7s-12.3-2.3-17-7c-21.3-21.3-42.7-42.7-64-64c-4.7-4.7-7-10.8-7-17z"/><path class="fa-primary" d="M369 175c9.4 9.4 9.4 24.6 0 33.9L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0z"/></svg>`,
|
||||||
|
|
||||||
|
sparkles: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M320 96c0 4.8 3 9.1 7.5 10.8L384 128l21.2 56.5c1.7 4.5 6 7.5 10.8 7.5s9.1-3 10.8-7.5L448 128l56.5-21.2c4.5-1.7 7.5-6 7.5-10.8s-3-9.1-7.5-10.8L448 64 426.8 7.5C425.1 3 420.8 0 416 0s-9.1 3-10.8 7.5L384 64 327.5 85.2c-4.5 1.7-7.5 6-7.5 10.8zm0 320c0 4.8 3 9.1 7.5 10.8L384 448l21.2 56.5c1.7 4.5 6 7.5 10.8 7.5s9.1-3 10.8-7.5L448 448l56.5-21.2c4.5-1.7 7.5-6 7.5-10.8s-3-9.1-7.5-10.8L448 384l-21.2-56.5c-1.7-4.5-6-7.5-10.8-7.5s-9.1 3-10.8 7.5L384 384l-56.5 21.2c-4.5 1.7-7.5 6-7.5 10.8z"/><path class="fa-primary" d="M205.1 73.3c-2.6-5.7-8.3-9.3-14.5-9.3s-11.9 3.6-14.5 9.3L123.4 187.4 9.3 240C3.6 242.6 0 248.3 0 254.6s3.6 11.9 9.3 14.5l114.1 52.7L176 435.8c2.6 5.7 8.3 9.3 14.5 9.3s11.9-3.6 14.5-9.3l52.7-114.1 114.1-52.7c5.7-2.6 9.3-8.3 9.3-14.5s-3.6-11.9-9.3-14.5L257.8 187.4 205.1 73.3z"/></svg>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render an icon as an HTML string with given size and color
|
||||||
|
export const icon = (name: keyof typeof icons, size = 16, color = 'currentColor'): string => {
|
||||||
|
const svg = icons[name];
|
||||||
|
return svg
|
||||||
|
.replace('<svg', `<svg width="${size}" height="${size}" style="fill:${color};vertical-align:middle;"`)
|
||||||
|
.replace(/\.fa-primary/g, '.p')
|
||||||
|
.replace(/\.fa-secondary\{opacity:\.4\}/g, `.s{opacity:.4;fill:${color}}`);
|
||||||
|
};
|
||||||
40
packages/widget-src/src/main.tsx
Normal file
40
packages/widget-src/src/main.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { render } from 'preact';
|
||||||
|
import { initApi, fetchInit } from './api';
|
||||||
|
import { Widget } from './widget';
|
||||||
|
import type { WidgetConfig } from './types';
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
const script = document.querySelector('script[data-key]') as HTMLScriptElement | null;
|
||||||
|
if (!script) { console.error('[HelixWidget] Missing data-key attribute'); return; }
|
||||||
|
|
||||||
|
const key = script.getAttribute('data-key') ?? '';
|
||||||
|
const baseUrl = script.src.replace(/\/widget\.js.*$/, '');
|
||||||
|
|
||||||
|
initApi(baseUrl, key);
|
||||||
|
|
||||||
|
let config: WidgetConfig;
|
||||||
|
try {
|
||||||
|
config = await fetchInit();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[HelixWidget] Init failed:', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create shadow DOM host
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.id = 'helix-widget-host';
|
||||||
|
host.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:999999;font-family:-apple-system,sans-serif;';
|
||||||
|
document.body.appendChild(host);
|
||||||
|
|
||||||
|
const shadow = host.attachShadow({ mode: 'open' });
|
||||||
|
const mountPoint = document.createElement('div');
|
||||||
|
shadow.appendChild(mountPoint);
|
||||||
|
|
||||||
|
render(<Widget config={config} shadow={shadow} />, mountPoint);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
138
packages/widget-src/src/styles.ts
Normal file
138
packages/widget-src/src/styles.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import type { WidgetConfig } from './types';
|
||||||
|
|
||||||
|
export const getStyles = (config: WidgetConfig) => `
|
||||||
|
:host { all: initial; font-family: -apple-system, 'Segoe UI', Roboto, sans-serif; }
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
.widget-bubble {
|
||||||
|
width: 56px; height: 56px; border-radius: 50%;
|
||||||
|
background: ${config.colors.primary}; color: #fff;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
transition: transform 0.2s; border: none; outline: none;
|
||||||
|
}
|
||||||
|
.widget-bubble:hover { transform: scale(1.08); }
|
||||||
|
.widget-bubble img { width: 28px; height: 28px; border-radius: 6px; }
|
||||||
|
.widget-bubble svg { width: 24px; height: 24px; fill: currentColor; }
|
||||||
|
|
||||||
|
.widget-panel {
|
||||||
|
width: 380px; height: 520px; border-radius: 16px;
|
||||||
|
background: #fff; box-shadow: 0 8px 32px rgba(0,0,0,0.12);
|
||||||
|
display: flex; flex-direction: column; overflow: hidden;
|
||||||
|
border: 1px solid #e5e7eb; position: absolute; bottom: 68px; right: 0;
|
||||||
|
animation: slideUp 0.25s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-header {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 14px 16px; background: ${config.colors.primary}; color: #fff;
|
||||||
|
}
|
||||||
|
.widget-header img { width: 32px; height: 32px; border-radius: 8px; }
|
||||||
|
.widget-header-text { flex: 1; }
|
||||||
|
.widget-header-name { font-size: 14px; font-weight: 600; }
|
||||||
|
.widget-header-sub { font-size: 11px; opacity: 0.8; }
|
||||||
|
.widget-close {
|
||||||
|
background: none; border: none; color: #fff; cursor: pointer;
|
||||||
|
font-size: 18px; padding: 4px; opacity: 0.8;
|
||||||
|
}
|
||||||
|
.widget-close:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.widget-tabs {
|
||||||
|
display: flex; border-bottom: 1px solid #e5e7eb; background: #fafafa;
|
||||||
|
}
|
||||||
|
.widget-tab {
|
||||||
|
flex: 1; padding: 10px 0; text-align: center; font-size: 12px;
|
||||||
|
font-weight: 500; cursor: pointer; border: none; background: none;
|
||||||
|
color: #6b7280; border-bottom: 2px solid transparent;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.widget-tab.active {
|
||||||
|
color: ${config.colors.primary}; border-bottom-color: ${config.colors.primary};
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-body { flex: 1; overflow-y: auto; padding: 16px; }
|
||||||
|
|
||||||
|
.widget-input {
|
||||||
|
width: 100%; padding: 10px 12px; border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px; font-size: 13px; outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.widget-input:focus { border-color: ${config.colors.primary}; }
|
||||||
|
.widget-textarea { resize: vertical; min-height: 60px; font-family: inherit; }
|
||||||
|
.widget-select {
|
||||||
|
width: 100%; padding: 10px 12px; border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px; font-size: 13px; background: #fff; outline: none;
|
||||||
|
}
|
||||||
|
.widget-label { font-size: 12px; font-weight: 500; color: #374151; margin-bottom: 4px; display: block; }
|
||||||
|
.widget-field { margin-bottom: 12px; }
|
||||||
|
|
||||||
|
.widget-btn {
|
||||||
|
width: 100%; padding: 10px 16px; border: none; border-radius: 8px;
|
||||||
|
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||||
|
transition: opacity 0.15s; color: #fff; background: ${config.colors.primary};
|
||||||
|
}
|
||||||
|
.widget-btn:hover { opacity: 0.9; }
|
||||||
|
.widget-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.widget-btn-secondary { background: #f3f4f6; color: #374151; }
|
||||||
|
|
||||||
|
.widget-slots {
|
||||||
|
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin: 8px 0;
|
||||||
|
}
|
||||||
|
.widget-slot {
|
||||||
|
padding: 8px; text-align: center; font-size: 12px; border-radius: 6px;
|
||||||
|
border: 1px solid #e5e7eb; cursor: pointer; background: #fff;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.widget-slot:hover { border-color: ${config.colors.primary}; }
|
||||||
|
.widget-slot.selected { background: ${config.colors.primary}; color: #fff; border-color: ${config.colors.primary}; }
|
||||||
|
.widget-slot.unavailable { opacity: 0.4; cursor: not-allowed; text-decoration: line-through; }
|
||||||
|
|
||||||
|
.widget-success {
|
||||||
|
text-align: center; padding: 24px 16px;
|
||||||
|
}
|
||||||
|
.widget-success-icon { font-size: 40px; margin-bottom: 12px; }
|
||||||
|
.widget-success-title { font-size: 16px; font-weight: 600; color: #059669; margin-bottom: 8px; }
|
||||||
|
.widget-success-text { font-size: 13px; color: #6b7280; }
|
||||||
|
|
||||||
|
.chat-messages { flex: 1; overflow-y: auto; padding: 12px 0; }
|
||||||
|
.chat-msg { margin-bottom: 10px; display: flex; }
|
||||||
|
.chat-msg.user { justify-content: flex-end; }
|
||||||
|
.chat-bubble {
|
||||||
|
max-width: 80%; padding: 10px 14px; border-radius: 12px;
|
||||||
|
font-size: 13px; line-height: 1.5;
|
||||||
|
}
|
||||||
|
.chat-msg.user .chat-bubble { background: ${config.colors.primary}; color: #fff; border-bottom-right-radius: 4px; }
|
||||||
|
.chat-msg.assistant .chat-bubble { background: #f3f4f6; color: #1f2937; border-bottom-left-radius: 4px; }
|
||||||
|
|
||||||
|
.chat-input-row { display: flex; gap: 8px; padding-top: 8px; border-top: 1px solid #e5e7eb; }
|
||||||
|
.chat-input { flex: 1; }
|
||||||
|
.chat-send {
|
||||||
|
width: 36px; height: 36px; border-radius: 8px;
|
||||||
|
background: ${config.colors.primary}; color: #fff;
|
||||||
|
border: none; cursor: pointer; display: flex;
|
||||||
|
align-items: center; justify-content: center; font-size: 16px;
|
||||||
|
}
|
||||||
|
.chat-send:disabled { opacity: 0.5; }
|
||||||
|
|
||||||
|
.quick-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
|
||||||
|
.quick-action {
|
||||||
|
padding: 6px 12px; border-radius: 16px; font-size: 11px;
|
||||||
|
border: 1px solid ${config.colors.primary}; color: ${config.colors.primary};
|
||||||
|
background: ${config.colors.primaryLight}; cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.quick-action:hover { background: ${config.colors.primary}; color: #fff; }
|
||||||
|
|
||||||
|
.widget-steps { display: flex; gap: 4px; margin-bottom: 16px; }
|
||||||
|
.widget-step {
|
||||||
|
flex: 1; height: 3px; border-radius: 2px; background: #e5e7eb;
|
||||||
|
}
|
||||||
|
.widget-step.active { background: ${config.colors.primary}; }
|
||||||
|
.widget-step.done { background: #059669; }
|
||||||
|
`;
|
||||||
26
packages/widget-src/src/types.ts
Normal file
26
packages/widget-src/src/types.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export type WidgetConfig = {
|
||||||
|
brand: { name: string; logo: string };
|
||||||
|
colors: { primary: string; primaryLight: string; text: string; textLight: string };
|
||||||
|
captchaSiteKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Doctor = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
fullName: { firstName: string; lastName: string };
|
||||||
|
department: string;
|
||||||
|
specialty: string;
|
||||||
|
visitingHours: string;
|
||||||
|
consultationFeeNew: { amountMicros: number; currencyCode: string } | null;
|
||||||
|
clinic: { clinicName: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimeSlot = {
|
||||||
|
time: string;
|
||||||
|
available: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChatMessage = {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
78
packages/widget-src/src/widget.tsx
Normal file
78
packages/widget-src/src/widget.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
import type { WidgetConfig } from './types';
|
||||||
|
import { getStyles } from './styles';
|
||||||
|
import { icon } from './icons';
|
||||||
|
import { Chat } from './chat';
|
||||||
|
import { Booking } from './booking';
|
||||||
|
import { Contact } from './contact';
|
||||||
|
|
||||||
|
type Tab = 'chat' | 'book' | 'contact';
|
||||||
|
|
||||||
|
type WidgetProps = {
|
||||||
|
config: WidgetConfig;
|
||||||
|
shadow: ShadowRoot;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Widget = ({ config, shadow }: WidgetProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [tab, setTab] = useState<Tab>('chat');
|
||||||
|
|
||||||
|
// Inject styles into shadow DOM
|
||||||
|
useEffect(() => {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = getStyles(config);
|
||||||
|
shadow.appendChild(style);
|
||||||
|
return () => { shadow.removeChild(style); };
|
||||||
|
}, [config, shadow]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Floating bubble */}
|
||||||
|
{!open && (
|
||||||
|
<button class="widget-bubble" onClick={() => setOpen(true)}>
|
||||||
|
{config.brand.logo ? (
|
||||||
|
<img src={config.brand.logo} alt={config.brand.name} />
|
||||||
|
) : (
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z"/></svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
|
{open && (
|
||||||
|
<div class="widget-panel">
|
||||||
|
{/* Header */}
|
||||||
|
<div class="widget-header">
|
||||||
|
{config.brand.logo && <img src={config.brand.logo} alt="" />}
|
||||||
|
<div class="widget-header-text">
|
||||||
|
<div class="widget-header-name">{config.brand.name}</div>
|
||||||
|
<div class="widget-header-sub">We're here to help</div>
|
||||||
|
</div>
|
||||||
|
<button class="widget-close" onClick={() => setOpen(false)}>✕</button>
|
||||||
|
{/* Icons bundled from FontAwesome Pro SVGs — static, not user input */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div class="widget-tabs">
|
||||||
|
<button class={`widget-tab ${tab === 'chat' ? 'active' : ''}`} onClick={() => setTab('chat')}>
|
||||||
|
<span innerHTML={icon('chat', 14)} /> Chat
|
||||||
|
</button>
|
||||||
|
<button class={`widget-tab ${tab === 'book' ? 'active' : ''}`} onClick={() => setTab('book')}>
|
||||||
|
<span innerHTML={icon('calendar', 14)} /> Book
|
||||||
|
</button>
|
||||||
|
<button class={`widget-tab ${tab === 'contact' ? 'active' : ''}`} onClick={() => setTab('contact')}>
|
||||||
|
<span innerHTML={icon('phone', 14)} /> Contact
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div class="widget-body">
|
||||||
|
{tab === 'chat' && <Chat />}
|
||||||
|
{tab === 'book' && <Booking />}
|
||||||
|
{tab === 'contact' && <Contact />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
41
packages/widget-src/test.html
Normal file
41
packages/widget-src/test.html
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Global Hospital — Widget Test</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, sans-serif; max-width: 800px; margin: 0 auto; padding: 40px; color: #1f2937; }
|
||||||
|
h1 { font-size: 28px; margin-bottom: 8px; }
|
||||||
|
p { color: #6b7280; line-height: 1.6; }
|
||||||
|
.hero { background: #f0f9ff; border-radius: 12px; padding: 40px; margin: 40px 0; }
|
||||||
|
.hero h2 { color: #1e40af; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>🏥 Global Hospital, Bangalore</h1>
|
||||||
|
<p>Welcome to Global Hospital — Bangalore's leading multi-specialty healthcare provider.</p>
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
<h2>Book Your Appointment Online</h2>
|
||||||
|
<p>Click the chat bubble in the bottom-right corner to talk to our AI assistant, book an appointment, or request a callback.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Our Departments</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Cardiology</li>
|
||||||
|
<li>Orthopedics</li>
|
||||||
|
<li>Gynecology</li>
|
||||||
|
<li>ENT</li>
|
||||||
|
<li>General Medicine</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p style="margin-top: 40px; font-size: 12px; color: #9ca3af;">
|
||||||
|
This is a test page for the Helix Engage website widget.
|
||||||
|
The widget loads from the sidecar and renders in a shadow DOM.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Replace SITE_KEY with the generated key -->
|
||||||
|
<script src="http://localhost:4100/widget.js" data-key="8197d39c9ad946ef.31e3b1f492a7380f77ea0c90d2f86d5d4b1ac8f70fd01423ac3d85b87d9aa511"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
14
packages/widget-src/tsconfig.json
Normal file
14
packages/widget-src/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "preact",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
22
packages/widget-src/vite.config.ts
Normal file
22
packages/widget-src/vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import preact from '@preact/preset-vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [preact()],
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: 'src/main.tsx',
|
||||||
|
name: 'HelixWidget',
|
||||||
|
fileName: () => 'widget.js',
|
||||||
|
formats: ['iife'],
|
||||||
|
},
|
||||||
|
outDir: '../../helix-engage-server/public',
|
||||||
|
emptyOutDir: false,
|
||||||
|
minify: 'esbuild',
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
inlineDynamicImports: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,10 +1,13 @@
|
|||||||
import { Controller, Post, Body, Headers, Req, Res, HttpException, Logger } from '@nestjs/common';
|
import { Controller, Post, Body, Headers, Req, Res, HttpException, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import type { Request, Response } from 'express';
|
import type { Request, Response } from 'express';
|
||||||
import { generateText, streamText, tool, stepCountIs } from 'ai';
|
import { generateText, streamText, Output, tool, stepCountIs } from 'ai';
|
||||||
import type { LanguageModel } from 'ai';
|
import type { LanguageModel } from 'ai';
|
||||||
|
import { aiResponseSchema } from './ai-response-schema';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
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 { createAiModel, isAiConfigured } from './ai-provider';
|
||||||
import { AiConfigService } from '../config/ai-config.service';
|
import { AiConfigService } from '../config/ai-config.service';
|
||||||
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils';
|
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils';
|
||||||
@@ -26,6 +29,8 @@ export class AiChatController {
|
|||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private platform: PlatformGraphqlService,
|
private platform: PlatformGraphqlService,
|
||||||
private aiConfig: AiConfigService,
|
private aiConfig: AiConfigService,
|
||||||
|
private caller: CallerResolutionService,
|
||||||
|
private callerContext: CallerContextService,
|
||||||
) {
|
) {
|
||||||
const cfg = aiConfig.getConfig();
|
const cfg = aiConfig.getConfig();
|
||||||
this.aiModel = createAiModel({
|
this.aiModel = createAiModel({
|
||||||
@@ -94,16 +99,20 @@ export class AiChatController {
|
|||||||
const kb = await this.buildKnowledgeBase(auth);
|
const kb = await this.buildKnowledgeBase(auth);
|
||||||
systemPrompt = this.buildSystemPrompt(kb);
|
systemPrompt = this.buildSystemPrompt(kb);
|
||||||
|
|
||||||
// Inject caller context so the AI knows who is selected
|
// Inject pre-fetched caller context (appointments, call history,
|
||||||
if (ctx) {
|
// activities, AI summary) so the LLM can answer from the KB
|
||||||
const parts: string[] = [];
|
// without tool calls. No UUIDs exposed — only human-readable data.
|
||||||
if (ctx.leadName) parts.push(`Currently viewing/talking to: ${ctx.leadName}`);
|
if (ctx?.leadId) {
|
||||||
if (ctx.callerPhone) parts.push(`Phone: ${ctx.callerPhone}`);
|
const callerCtx = await this.callerContext.getOrBuild(ctx.leadId, '', auth);
|
||||||
if (ctx.leadId) parts.push(`Lead ID: ${ctx.leadId}`);
|
if (callerCtx) {
|
||||||
if (parts.length) {
|
systemPrompt += `\n\n${this.callerContext.renderForPrompt(callerCtx)}`;
|
||||||
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.`;
|
if (callerCtx.suggestionTriggers?.length) {
|
||||||
|
systemPrompt += this.callerContext.renderSuggestionsForPrompt(callerCtx.suggestionTriggers);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if (ctx?.callerPhone) {
|
||||||
|
systemPrompt += `\n\nCURRENT CONTEXT:\nCaller phone: ${ctx.callerPhone}\nNew caller — no prior records.`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const platformService = this.platform;
|
const platformService = this.platform;
|
||||||
@@ -271,7 +280,7 @@ export class AiChatController {
|
|||||||
inputSchema: z.object({}),
|
inputSchema: z.object({}),
|
||||||
execute: async () => {
|
execute: async () => {
|
||||||
const data = await platformService.queryWithAuth<any>(
|
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,
|
undefined, auth,
|
||||||
);
|
);
|
||||||
const breached = data.calls.edges
|
const breached = data.calls.edges
|
||||||
@@ -293,6 +302,54 @@ export class AiChatController {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Agent tools — patient lookup, appointments, doctors
|
// 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 = {
|
const agentTools = {
|
||||||
lookup_patient: tool({
|
lookup_patient: tool({
|
||||||
description: 'Search for a patient/lead by phone number or name. Returns their profile, lead status, AI summary.',
|
description: 'Search for a patient/lead by phone number or name. Returns their profile, lead status, AI summary.',
|
||||||
@@ -327,24 +384,32 @@ export class AiChatController {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
toolLog('lookup_patient', { phone, name }, { scanned: leads.length, matched: matched.length });
|
||||||
if (!matched.length) return { found: false, message: 'No patient/lead found.' };
|
if (!matched.length) return { found: false, message: 'No patient/lead found.' };
|
||||||
return { found: true, count: matched.length, leads: matched };
|
return { found: true, count: matched.length, leads: matched };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
lookup_appointments: tool({
|
lookup_appointments: tool({
|
||||||
description: 'Get appointments for a patient. Returns doctor, department, date, status.',
|
description: 'Get appointments for a patient. Omit patientId to use the current caller — do NOT re-type a UUID you saw in context; just call with no arguments.',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
patientId: z.string().describe('Patient ID'),
|
patientId: z.string().optional().describe('Patient ID (omit when asking about the current caller)'),
|
||||||
}),
|
}),
|
||||||
execute: async ({ patientId }) => {
|
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>(
|
const data = await platformService.queryWithAuth<any>(
|
||||||
`{ appointments(first: 20, filter: { patientId: { eq: "${patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
`{ appointments(first: 20, filter: { patientId: { eq: "${resolved}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||||
id scheduledAt status doctorName department reasonForVisit
|
id scheduledAt status doctorName department reasonForVisit
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined, auth,
|
||||||
);
|
);
|
||||||
return { appointments: data.appointments.edges.map((e: any) => e.node) };
|
const appointments = data.appointments.edges.map((e: any) => e.node);
|
||||||
|
toolLog('lookup_appointments', { patientId }, { resolved, count: appointments.length });
|
||||||
|
return { appointments };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -373,24 +438,25 @@ export class AiChatController {
|
|||||||
const full = `${fn} ${ln}`;
|
const full = `${fn} ${ln}`;
|
||||||
return searchWords.some(w => w.length > 1 && (fn.includes(w) || ln.includes(w) || full.includes(w)));
|
return searchWords.some(w => w.length > 1 && (fn.includes(w) || ln.includes(w) || full.includes(w)));
|
||||||
});
|
});
|
||||||
this.logger.log(`[TOOL] lookup_doctor: search="${doctorName}" → ${matched.length} results`);
|
toolLog('lookup_doctor', { doctorName }, { scanned: doctors.length, matched: matched.length });
|
||||||
if (!matched.length) return { found: false, message: `No doctor matching "${doctorName}". Available: ${doctors.map((d: any) => `Dr. ${d.fullName?.lastName ?? d.fullName?.firstName}`).join(', ')}` };
|
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 };
|
return { found: true, doctors: matched };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
book_appointment: tool({
|
book_appointment: tool({
|
||||||
description: 'Book an appointment for a patient. Collect patient name, phone, department, doctor, preferred date/time, and reason before calling this.',
|
description: 'Book an appointment for a patient. Collect patient name, phone, department, doctor, clinic/branch, preferred date/time, and reason before calling this.',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
patientName: z.string().describe('Full name of the patient'),
|
patientName: z.string().describe('Full name of the patient'),
|
||||||
phoneNumber: z.string().describe('Patient phone number'),
|
phoneNumber: z.string().describe('Patient phone number'),
|
||||||
department: z.string().describe('Department for the appointment'),
|
department: z.string().describe('Department for the appointment'),
|
||||||
doctorName: z.string().describe('Doctor name'),
|
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)'),
|
scheduledAt: z.string().describe('Date and time in ISO format (e.g. 2026-04-01T10:00:00)'),
|
||||||
reason: z.string().describe('Reason for visit'),
|
reason: z.string().describe('Reason for visit'),
|
||||||
}),
|
}),
|
||||||
execute: async ({ patientName, phoneNumber, department, doctorName, scheduledAt, reason }) => {
|
execute: async ({ patientName, phoneNumber, department, doctorName, clinicId, scheduledAt, reason }) => {
|
||||||
this.logger.log(`[TOOL] book_appointment: ${patientName} | ${phoneNumber} | ${department} | ${doctorName} | ${scheduledAt}`);
|
toolLog('book_appointment', { patientName, phoneNumber, department, doctorName, clinicId, scheduledAt }, { reason: reason?.slice(0, 40) });
|
||||||
try {
|
try {
|
||||||
const result = await platformService.queryWithAuth<any>(
|
const result = await platformService.queryWithAuth<any>(
|
||||||
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
||||||
@@ -402,17 +468,20 @@ export class AiChatController {
|
|||||||
doctorName,
|
doctorName,
|
||||||
department,
|
department,
|
||||||
reasonForVisit: reason,
|
reasonForVisit: reason,
|
||||||
|
...(clinicId ? { clinicId } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
auth,
|
auth,
|
||||||
);
|
);
|
||||||
const id = result?.createAppointment?.id;
|
const id = result?.createAppointment?.id;
|
||||||
if (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)}` };
|
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.' };
|
return { booked: false, message: 'Appointment creation failed.' };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.error(`[TOOL] book_appointment failed: ${err.message}`);
|
logger.error(`[AI-TOOL] book_appointment failed: ${err.message}`);
|
||||||
return { booked: false, message: `Failed to book: ${err.message}` };
|
return { booked: false, message: `Failed to book: ${err.message}` };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -426,51 +495,131 @@ export class AiChatController {
|
|||||||
interest: z.string().describe('What they are enquiring about'),
|
interest: z.string().describe('What they are enquiring about'),
|
||||||
}),
|
}),
|
||||||
execute: async ({ name, phoneNumber, interest }) => {
|
execute: async ({ name, phoneNumber, interest }) => {
|
||||||
this.logger.log(`[TOOL] create_lead: ${name} | ${phoneNumber} | ${interest}`);
|
toolLog('create_lead', { name, phoneNumber, interest: interest?.slice(0, 40) }, {});
|
||||||
try {
|
try {
|
||||||
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
||||||
const result = await platformService.queryWithAuth<any>(
|
const resolved = await this.caller.resolve(cleanPhone, auth);
|
||||||
|
const firstName = name.split(' ')[0];
|
||||||
|
const lastName = name.split(' ').slice(1).join(' ') || '';
|
||||||
|
|
||||||
|
if (resolved.isNew) {
|
||||||
|
// Net-new caller — create Patient + Lead with
|
||||||
|
// the AI-collected name from the conversation.
|
||||||
|
let patientId: string | undefined;
|
||||||
|
try {
|
||||||
|
const p = await platformService.queryWithAuth<any>(
|
||||||
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
fullName: { firstName, lastName },
|
||||||
|
phones: { primaryPhoneNumber: `+91${cleanPhone}` },
|
||||||
|
patientType: 'NEW',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
patientId = p?.createPatient?.id;
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.warn(`[AI-TOOL] create_lead patient create failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
const created = await platformService.queryWithAuth<any>(
|
||||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
name: `AI Enquiry — ${name}`,
|
name: `AI Enquiry — ${name}`,
|
||||||
contactName: {
|
contactName: { firstName, lastName },
|
||||||
firstName: name.split(' ')[0],
|
|
||||||
lastName: name.split(' ').slice(1).join(' ') || '',
|
|
||||||
},
|
|
||||||
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
||||||
source: 'PHONE',
|
source: 'PHONE',
|
||||||
status: 'NEW',
|
status: 'NEW',
|
||||||
interestedService: interest,
|
interestedService: interest,
|
||||||
|
...(patientId ? { patientId } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
const id = created?.createLead?.id;
|
||||||
|
if (id) {
|
||||||
|
toolLog('create_lead', { name }, { created: true, isNew: true, leadId: id });
|
||||||
|
return { created: true, leadId: id, message: `Lead created for ${name}. Our team will follow up on ${phoneNumber}.` };
|
||||||
|
}
|
||||||
|
toolLog('create_lead', { name }, { created: false });
|
||||||
|
return { created: false, message: 'Lead creation failed.' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing record — update with AI-collected name.
|
||||||
|
await platformService.queryWithAuth<any>(
|
||||||
|
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
id: resolved.leadId,
|
||||||
|
data: {
|
||||||
|
name: `AI Enquiry — ${name}`,
|
||||||
|
contactName: { firstName, lastName },
|
||||||
|
source: 'PHONE',
|
||||||
|
status: 'NEW',
|
||||||
|
interestedService: interest,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
auth,
|
auth,
|
||||||
);
|
);
|
||||||
const id = result?.createLead?.id;
|
if (resolved.patientId) {
|
||||||
if (id) {
|
await platformService.queryWithAuth<any>(
|
||||||
return { created: true, leadId: id, message: `Lead created for ${name}. Our team will follow up on ${phoneNumber}.` };
|
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: resolved.patientId, data: { fullName: { firstName, lastName } } },
|
||||||
|
auth,
|
||||||
|
).catch(() => {});
|
||||||
}
|
}
|
||||||
return { created: false, message: 'Lead creation failed.' };
|
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) {
|
} catch (err: any) {
|
||||||
this.logger.error(`[TOOL] create_lead failed: ${err.message}`);
|
logger.error(`[AI-TOOL] create_lead failed: ${err.message}`);
|
||||||
return { created: false, message: `Failed: ${err.message}` };
|
return { created: false, message: `Failed: ${err.message}` };
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|
||||||
lookup_call_history: tool({
|
lookup_call_history: tool({
|
||||||
description: 'Get call log for a lead — all inbound/outbound calls with dispositions and durations.',
|
description: 'Get call log for a lead — all inbound/outbound calls with dispositions and durations. Omit leadId to use the current caller — do NOT re-type a UUID you saw in context; just call with no arguments.',
|
||||||
inputSchema: z.object({
|
inputSchema: z.object({
|
||||||
leadId: z.string().describe('Lead ID'),
|
leadId: z.string().optional().describe('Lead ID (omit when asking about the current caller)'),
|
||||||
}),
|
}),
|
||||||
execute: async ({ leadId }) => {
|
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>(
|
const data = await platformService.queryWithAuth<any>(
|
||||||
`{ calls(first: 20, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
`{ calls(first: 20, filter: { leadId: { eq: "${resolved}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||||
id direction callStatus agentName startedAt durationSec disposition
|
id direction callStatus agentName startedAt durationSec disposition
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined, auth,
|
||||||
);
|
);
|
||||||
return { calls: data.calls.edges.map((e: any) => e.node) };
|
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 };
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -481,6 +630,7 @@ export class AiChatController {
|
|||||||
messages,
|
messages,
|
||||||
stopWhen: stepCountIs(5),
|
stopWhen: stepCountIs(5),
|
||||||
tools: isSupervisor ? supervisorTools : agentTools,
|
tools: isSupervisor ? supervisorTools : agentTools,
|
||||||
|
...(isSupervisor ? {} : { output: Output.object({ schema: aiResponseSchema }) }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const response = result.toTextStreamResponse();
|
const response = result.toTextStreamResponse();
|
||||||
@@ -516,16 +666,23 @@ export class AiChatController {
|
|||||||
`{ clinics(first: 20) { edges { node {
|
`{ clinics(first: 20) { edges { node {
|
||||||
id name clinicName
|
id name clinicName
|
||||||
addressCustom { addressStreet1 addressCity addressState addressPostcode }
|
addressCustom { addressStreet1 addressCity addressState addressPostcode }
|
||||||
weekdayHours saturdayHours sundayHours
|
openMonday openTuesday openWednesday openThursday openFriday openSaturday openSunday
|
||||||
|
opensAt closesAt
|
||||||
status walkInAllowed onlineBooking
|
status walkInAllowed onlineBooking
|
||||||
cancellationWindowHours arriveEarlyMin requiredDocuments
|
cancellationWindowHours arriveEarlyMin
|
||||||
acceptsCash acceptsCard acceptsUpi
|
acceptsCash acceptsCard acceptsUpi
|
||||||
|
requiredDocuments { edges { node { documentType notes } } }
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined, auth,
|
||||||
);
|
);
|
||||||
const clinics = clinicData.clinics.edges.map((e: any) => e.node);
|
const clinics = clinicData.clinics.edges.map((e: any) => e.node);
|
||||||
if (clinics.length) {
|
if (clinics.length) {
|
||||||
sections.push('## CLINICS & TIMINGS');
|
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) {
|
for (const c of clinics) {
|
||||||
const name = c.clinicName ?? c.name;
|
const name = c.clinicName ?? c.name;
|
||||||
const addr = c.addressCustom
|
const addr = c.addressCustom
|
||||||
@@ -533,9 +690,15 @@ export class AiChatController {
|
|||||||
: '';
|
: '';
|
||||||
sections.push(`### ${name}`);
|
sections.push(`### ${name}`);
|
||||||
if (addr) sections.push(` Address: ${addr}`);
|
if (addr) sections.push(` Address: ${addr}`);
|
||||||
if (c.weekdayHours) sections.push(` Mon–Fri: ${c.weekdayHours}`);
|
const openDays = dayFlags.filter(([, flag]) => c[flag]).map(([label]) => label);
|
||||||
if (c.saturdayHours) sections.push(` Saturday: ${c.saturdayHours}`);
|
if (openDays.length) {
|
||||||
sections.push(` Sunday: ${c.sundayHours ?? 'Closed'}`);
|
const hours = c.opensAt && c.closesAt ? ` ${c.opensAt}–${c.closesAt}` : '';
|
||||||
|
sections.push(` Open: ${openDays.join(', ')}${hours}`);
|
||||||
|
}
|
||||||
|
const closedDays = dayFlags.filter(([, flag]) => !c[flag]).map(([label]) => label);
|
||||||
|
if (closedDays.length) {
|
||||||
|
sections.push(` Closed: ${closedDays.join(', ')}`);
|
||||||
|
}
|
||||||
if (c.walkInAllowed) sections.push(` Walk-ins: Accepted`);
|
if (c.walkInAllowed) sections.push(` Walk-ins: Accepted`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,7 +706,8 @@ export class AiChatController {
|
|||||||
const rules: string[] = [];
|
const rules: string[] = [];
|
||||||
if (rulesClinic.cancellationWindowHours) rules.push(`Free cancellation up to ${rulesClinic.cancellationWindowHours}h before`);
|
if (rulesClinic.cancellationWindowHours) rules.push(`Free cancellation up to ${rulesClinic.cancellationWindowHours}h before`);
|
||||||
if (rulesClinic.arriveEarlyMin) rules.push(`Arrive ${rulesClinic.arriveEarlyMin}min early`);
|
if (rulesClinic.arriveEarlyMin) rules.push(`Arrive ${rulesClinic.arriveEarlyMin}min early`);
|
||||||
if (rulesClinic.requiredDocuments) rules.push(`First-time patients bring ${rulesClinic.requiredDocuments}`);
|
const docs = rulesClinic.requiredDocuments?.edges?.map((e: any) => e.node?.documentType).filter(Boolean) ?? [];
|
||||||
|
if (docs.length) rules.push(`First-time patients bring: ${docs.join(', ')}`);
|
||||||
if (rulesClinic.walkInAllowed) rules.push('Walk-ins accepted');
|
if (rulesClinic.walkInAllowed) rules.push('Walk-ins accepted');
|
||||||
if (rulesClinic.onlineBooking) rules.push('Online booking available');
|
if (rulesClinic.onlineBooking) rules.push('Online booking available');
|
||||||
if (rules.length) {
|
if (rules.length) {
|
||||||
@@ -609,8 +773,8 @@ export class AiChatController {
|
|||||||
undefined, auth,
|
undefined, auth,
|
||||||
);
|
);
|
||||||
const packages = pkgData.healthPackages.edges.map((e: any) => e.node);
|
const packages = pkgData.healthPackages.edges.map((e: any) => e.node);
|
||||||
if (packages.length) {
|
|
||||||
sections.push('\n## Health Packages');
|
sections.push('\n## Health Packages');
|
||||||
|
if (packages.length) {
|
||||||
for (const p of packages) {
|
for (const p of packages) {
|
||||||
const price = p.price ? `₹${p.price.amountMicros / 1_000_000}` : '';
|
const price = p.price ? `₹${p.price.amountMicros / 1_000_000}` : '';
|
||||||
const disc = p.discountedPrice?.amountMicros ? ` (discounted: ₹${p.discountedPrice.amountMicros / 1_000_000})` : '';
|
const disc = p.discountedPrice?.amountMicros ? ` (discounted: ₹${p.discountedPrice.amountMicros / 1_000_000})` : '';
|
||||||
@@ -627,6 +791,8 @@ export class AiChatController {
|
|||||||
sections.push(` Includes: ${p.inclusions}`);
|
sections.push(` Includes: ${p.inclusions}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
sections.push('No packages available.');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to fetch health packages: ${err}`);
|
this.logger.warn(`Failed to fetch health packages: ${err}`);
|
||||||
|
|||||||
14
src/ai/ai-response-schema.ts
Normal file
14
src/ai/ai-response-schema.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const aiResponseSchema = z.object({
|
||||||
|
message: z.string().describe('Brief 2-3 sentence summary in plain conversational sentences. NEVER include suggestions, bullet lists, markdown, headers, or field labels here — those belong in the suggestions array only.'),
|
||||||
|
suggestions: z.array(z.object({
|
||||||
|
id: z.string().describe('Unique suggestion ID like s1, s2'),
|
||||||
|
type: z.enum(['upsell', 'crosssell', 'retention', 'operational']),
|
||||||
|
title: z.string().describe('Short title for the suggestion pill'),
|
||||||
|
script: z.string().describe('2-3 sentence script the agent can read aloud to the caller'),
|
||||||
|
priority: z.enum(['high', 'medium', 'low']),
|
||||||
|
})).describe('0-4 contextual suggestions based on business rules. Include on first response, update on subsequent.'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type AiResponse = z.infer<typeof aiResponseSchema>;
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { PlatformModule } from '../platform/platform.module';
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
import { AiEnrichmentService } from './ai-enrichment.service';
|
import { AiEnrichmentService } from './ai-enrichment.service';
|
||||||
import { AiChatController } from './ai-chat.controller';
|
import { AiChatController } from './ai-chat.controller';
|
||||||
|
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule],
|
imports: [PlatformModule, forwardRef(() => CallerResolutionModule)],
|
||||||
controllers: [AiChatController],
|
controllers: [AiChatController],
|
||||||
providers: [AiEnrichmentService],
|
providers: [AiEnrichmentService],
|
||||||
exports: [AiEnrichmentService],
|
exports: [AiEnrichmentService],
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ import { RulesEngineModule } from './rules-engine/rules-engine.module';
|
|||||||
import { ConfigThemeModule } from './config/config-theme.module';
|
import { ConfigThemeModule } from './config/config-theme.module';
|
||||||
import { WidgetModule } from './widget/widget.module';
|
import { WidgetModule } from './widget/widget.module';
|
||||||
import { TeamModule } from './team/team.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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -48,6 +52,10 @@ import { TeamModule } from './team/team.module';
|
|||||||
ConfigThemeModule,
|
ConfigThemeModule,
|
||||||
WidgetModule,
|
WidgetModule,
|
||||||
TeamModule,
|
TeamModule,
|
||||||
|
MasterdataModule,
|
||||||
|
LeadsModule,
|
||||||
|
MessagingModule,
|
||||||
],
|
],
|
||||||
|
providers: [TelephonyRegistrationService],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -29,7 +29,11 @@ export class AgentConfigService {
|
|||||||
return this.telephony.getConfig().sip.wsPort || '444';
|
return this.telephony.getConfig().sip.wsPort || '444';
|
||||||
}
|
}
|
||||||
private get defaultCampaignName(): string {
|
private get defaultCampaignName(): string {
|
||||||
return this.telephony.getConfig().ozonetel.campaignName || 'Inbound_918041763265';
|
// No hardcoded fallback — each Agent entity's own campaignName
|
||||||
|
// field is the source of truth. Env var is the per-workspace
|
||||||
|
// default; if neither is set, the Ozonetel login will use
|
||||||
|
// whatever the agent's entity specifies.
|
||||||
|
return this.telephony.getConfig().ozonetel.campaignName || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
async getByMemberId(memberId: string): Promise<AgentConfig | null> {
|
async getByMemberId(memberId: string): Promise<AgentConfig | null> {
|
||||||
|
|||||||
@@ -107,11 +107,9 @@ export class AuthController {
|
|||||||
|
|
||||||
// Determine app role from platform roles
|
// Determine app role from platform roles
|
||||||
let appRole = 'executive'; // default
|
let appRole = 'executive'; // default
|
||||||
if (roleLabels.includes('HelixEngage Manager')) {
|
if (roleLabels.includes('HelixEngage Manager') || roleLabels.includes('HelixEngage Supervisor')) {
|
||||||
appRole = 'admin';
|
appRole = 'admin';
|
||||||
} else if (roleLabels.includes('HelixEngage User')) {
|
} 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;
|
const email = workspaceMember?.userEmail ?? body.email;
|
||||||
appRole = email.includes('cc') ? 'cc-agent' : 'executive';
|
appRole = email.includes('cc') ? 'cc-agent' : 'executive';
|
||||||
}
|
}
|
||||||
@@ -140,10 +138,9 @@ export class AuthController {
|
|||||||
this.logger.warn(`Ozonetel token refresh on login failed: ${err.message}`);
|
this.logger.warn(`Ozonetel token refresh on login failed: ${err.message}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
const ozAgentPassword = this.telephony.getConfig().ozonetel.agentPassword || 'Test123$';
|
|
||||||
this.ozonetelAgent.loginAgent({
|
this.ozonetelAgent.loginAgent({
|
||||||
agentId: agentConfig.ozonetelAgentId,
|
agentId: agentConfig.ozonetelAgentId,
|
||||||
password: ozAgentPassword,
|
password: agentConfig.sipPassword,
|
||||||
phoneNumber: agentConfig.sipExtension,
|
phoneNumber: agentConfig.sipExtension,
|
||||||
mode: 'blended',
|
mode: 'blended',
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
@@ -252,9 +249,14 @@ export class AuthController {
|
|||||||
await this.sessionService.unlockSession(agentConfig.ozonetelAgentId);
|
await this.sessionService.unlockSession(agentConfig.ozonetelAgentId);
|
||||||
this.logger.log(`Session unlocked for ${agentConfig.ozonetelAgentId}`);
|
this.logger.log(`Session unlocked for ${agentConfig.ozonetelAgentId}`);
|
||||||
|
|
||||||
this.ozonetelAgent.logoutAgent({
|
// Await the Ozonetel logout so it completes before the
|
||||||
|
// HTTP response returns. Without this, a fast re-login
|
||||||
|
// (e.g. "remember me" auto-fill) races the logout and
|
||||||
|
// the agent lands in "Telephony Unavailable" because
|
||||||
|
// Ozonetel receives login while still processing logout.
|
||||||
|
await this.ozonetelAgent.logoutAgent({
|
||||||
agentId: agentConfig.ozonetelAgentId,
|
agentId: agentConfig.ozonetelAgentId,
|
||||||
password: this.telephony.getConfig().ozonetel.agentPassword || 'Test123$',
|
password: agentConfig.sipPassword,
|
||||||
}).catch(err => this.logger.warn(`Ozonetel logout failed: ${err.message}`));
|
}).catch(err => this.logger.warn(`Ozonetel logout failed: ${err.message}`));
|
||||||
|
|
||||||
this.agentConfigService.clearCache(memberId);
|
this.agentConfigService.clearCache(memberId);
|
||||||
|
|||||||
@@ -55,6 +55,26 @@ export class SessionService {
|
|||||||
await this.redis.del(this.key(agentId));
|
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
|
// Generic cache operations for any module
|
||||||
async getCache(key: string): Promise<string | null> {
|
async getCache(key: string): Promise<string | null> {
|
||||||
return this.redis.get(key);
|
return this.redis.get(key);
|
||||||
|
|||||||
@@ -99,17 +99,9 @@ export class LeadEnrichController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Invalidate the caller cache so the next incoming call from
|
// Caller resolution no longer caches — every resolve() hits the
|
||||||
// this phone number does a fresh platform lookup (and picks
|
// platform fresh via an indexed phone filter. No invalidation
|
||||||
// up the corrected identity + new summary).
|
// needed after enrichment.
|
||||||
if (body?.phone) {
|
|
||||||
try {
|
|
||||||
await this.callerResolution.invalidate(body.phone);
|
|
||||||
this.logger.log(`[LEAD-ENRICH] Caller cache invalidated for ${body.phone}`);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`[LEAD-ENRICH] Failed to invalidate caller cache: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(`[LEAD-ENRICH] Lead ${leadId} enriched successfully`);
|
this.logger.log(`[LEAD-ENRICH] Lead ${leadId} enriched successfully`);
|
||||||
|
|
||||||
|
|||||||
249
src/caller/caller-context.service.ts
Normal file
249
src/caller/caller-context.service.ts
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { SessionService } from '../auth/session.service';
|
||||||
|
import { evaluateSuggestionRules, type SuggestionTrigger } from '../rules-engine/suggestion-rules';
|
||||||
|
|
||||||
|
export type CallerContext = {
|
||||||
|
leadId: string;
|
||||||
|
patientId: string;
|
||||||
|
name: string;
|
||||||
|
phone: string;
|
||||||
|
isNew: boolean;
|
||||||
|
// Lead profile
|
||||||
|
leadSource: string | null;
|
||||||
|
leadStatus: string | null;
|
||||||
|
interestedService: string | null;
|
||||||
|
aiSummary: string | null;
|
||||||
|
contactAttempts: number;
|
||||||
|
lastContacted: string | null;
|
||||||
|
utmCampaign: string | null;
|
||||||
|
// Appointments
|
||||||
|
appointments: Array<{
|
||||||
|
scheduledAt: string;
|
||||||
|
status: string;
|
||||||
|
doctorName: string;
|
||||||
|
department: string;
|
||||||
|
reasonForVisit: string | null;
|
||||||
|
}>;
|
||||||
|
// Recent call history
|
||||||
|
calls: Array<{
|
||||||
|
startedAt: string;
|
||||||
|
direction: string;
|
||||||
|
duration: number | null;
|
||||||
|
disposition: string | null;
|
||||||
|
agentName: string | null;
|
||||||
|
}>;
|
||||||
|
// Lead activities
|
||||||
|
activities: Array<{
|
||||||
|
activityType: string;
|
||||||
|
summary: string | null;
|
||||||
|
occurredAt: string;
|
||||||
|
outcome: string | null;
|
||||||
|
}>;
|
||||||
|
// Rule-driven suggestion triggers
|
||||||
|
suggestionTriggers: SuggestionTrigger[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const CACHE_KEY_PREFIX = 'caller:context:';
|
||||||
|
const CACHE_TTL = 300; // 5 minutes — covers the call duration
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CallerContextService {
|
||||||
|
private readonly logger = new Logger(CallerContextService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly platform: PlatformGraphqlService,
|
||||||
|
private readonly session: SessionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async getOrBuild(leadId: string, patientId: string, auth: string): Promise<CallerContext | null> {
|
||||||
|
if (!leadId) return null;
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
const cacheKey = `${CACHE_KEY_PREFIX}${leadId}`;
|
||||||
|
try {
|
||||||
|
const cached = await this.session.getCache(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
this.logger.log(`[CALLER-CTX] Cache hit for ${leadId}`);
|
||||||
|
return JSON.parse(cached);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Build fresh
|
||||||
|
this.logger.log(`[CALLER-CTX] Building context for lead=${leadId} patient=${patientId}`);
|
||||||
|
const ctx = await this.build(leadId, patientId, auth);
|
||||||
|
if (ctx) {
|
||||||
|
this.session.setCache(cacheKey, JSON.stringify(ctx), CACHE_TTL).catch(() => {});
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
async invalidateCache(leadId: string): Promise<void> {
|
||||||
|
if (!leadId) return;
|
||||||
|
const cacheKey = `${CACHE_KEY_PREFIX}${leadId}`;
|
||||||
|
await this.session.deleteCache(cacheKey).catch(() => {});
|
||||||
|
this.logger.log(`[CALLER-CTX] Cache invalidated for ${leadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire-and-forget pre-warm — called from caller resolution
|
||||||
|
// so the cache is hot when the AI stream fires seconds later.
|
||||||
|
prewarm(leadId: string, patientId: string, auth: string): void {
|
||||||
|
if (!leadId) return;
|
||||||
|
this.getOrBuild(leadId, patientId, auth).catch(err => {
|
||||||
|
this.logger.warn(`[CALLER-CTX] Prewarm failed: ${err.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async build(leadId: string, patientId: string, auth: string): Promise<CallerContext | null> {
|
||||||
|
try {
|
||||||
|
// Step 1: Fetch lead first to get the authoritative patientId
|
||||||
|
const leadData = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ lead(filter: { id: { eq: "${leadId}" } }) {
|
||||||
|
id contactName { firstName lastName }
|
||||||
|
contactPhone { primaryPhoneNumber }
|
||||||
|
source status interestedService
|
||||||
|
aiSummary contactAttempts lastContacted
|
||||||
|
utmCampaign patientId
|
||||||
|
} }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
|
||||||
|
const lead = leadData?.lead;
|
||||||
|
if (!lead) return null;
|
||||||
|
|
||||||
|
// Use Lead's patientId as authoritative source — the input
|
||||||
|
// param may be empty if caller resolution just linked them.
|
||||||
|
const resolvedPatientId = patientId || lead.patientId || '';
|
||||||
|
this.logger.log(`[CALLER-CTX] Resolved patientId=${resolvedPatientId} (input=${patientId}, lead=${lead.patientId ?? '∅'})`);
|
||||||
|
|
||||||
|
const firstName = lead.contactName?.firstName ?? '';
|
||||||
|
const lastName = lead.contactName?.lastName ?? '';
|
||||||
|
|
||||||
|
// Step 2: Fetch appointments, calls, activities in parallel
|
||||||
|
// using the resolved patientId from the Lead record.
|
||||||
|
const [appointmentsData, callsData, activitiesData] = await Promise.all([
|
||||||
|
resolvedPatientId ? this.platform.queryWithAuth<any>(
|
||||||
|
`{ appointments(first: 10, filter: { patientId: { eq: "${resolvedPatientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||||
|
scheduledAt status doctorName department reasonForVisit
|
||||||
|
} } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
) : Promise.resolve(null),
|
||||||
|
this.platform.queryWithAuth<any>(
|
||||||
|
`{ calls(first: 10, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||||
|
startedAt direction durationSec disposition agentName
|
||||||
|
} } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
),
|
||||||
|
this.platform.queryWithAuth<any>(
|
||||||
|
`{ leadActivities(first: 10, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ occurredAt: DescNullsLast }]) { edges { node {
|
||||||
|
activityType summary occurredAt outcome
|
||||||
|
} } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const appointments = (appointmentsData?.appointments?.edges ?? []).map((e: any) => e.node);
|
||||||
|
const calls = (callsData?.calls?.edges ?? []).map((e: any) => ({
|
||||||
|
startedAt: e.node.startedAt,
|
||||||
|
direction: e.node.direction,
|
||||||
|
duration: e.node.durationSec,
|
||||||
|
disposition: e.node.disposition,
|
||||||
|
agentName: e.node.agentName,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const suggestionTriggers = evaluateSuggestionRules({
|
||||||
|
isNew: false,
|
||||||
|
interestedService: lead.interestedService ?? null,
|
||||||
|
leadStatus: lead.status ?? null,
|
||||||
|
contactAttempts: lead.contactAttempts ?? 0,
|
||||||
|
appointments,
|
||||||
|
calls: calls.map((c: any) => ({ direction: c.direction, disposition: c.disposition, startedAt: c.startedAt })),
|
||||||
|
utmCampaign: lead.utmCampaign ?? null,
|
||||||
|
leadSource: lead.source ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
leadId,
|
||||||
|
patientId: resolvedPatientId,
|
||||||
|
name: `${firstName} ${lastName}`.trim() || 'Unknown',
|
||||||
|
phone: lead.contactPhone?.primaryPhoneNumber ?? '',
|
||||||
|
isNew: false,
|
||||||
|
leadSource: lead.source ?? null,
|
||||||
|
leadStatus: lead.status ?? null,
|
||||||
|
interestedService: lead.interestedService ?? null,
|
||||||
|
aiSummary: lead.aiSummary ?? null,
|
||||||
|
contactAttempts: lead.contactAttempts ?? 0,
|
||||||
|
lastContacted: lead.lastContacted ?? null,
|
||||||
|
utmCampaign: lead.utmCampaign ?? null,
|
||||||
|
appointments,
|
||||||
|
calls,
|
||||||
|
activities: (activitiesData?.leadActivities?.edges ?? []).map((e: any) => e.node),
|
||||||
|
suggestionTriggers,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[CALLER-CTX] Build failed: ${err.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
renderSuggestionsForPrompt(triggers: SuggestionTrigger[]): string {
|
||||||
|
if (triggers.length === 0) return '';
|
||||||
|
const lines = [
|
||||||
|
'',
|
||||||
|
'SUGGESTION RULES (from business configuration):',
|
||||||
|
'Based on this caller\'s profile, the following suggestions should be offered.',
|
||||||
|
'Generate a natural, conversational script for each that the agent can read aloud.',
|
||||||
|
'Return them in the `suggestions` array of your JSON response.',
|
||||||
|
'',
|
||||||
|
];
|
||||||
|
triggers.forEach((t, i) => {
|
||||||
|
lines.push(`${i + 1}. [${t.type}/${t.priority}] ${t.title} — ${t.reason}`);
|
||||||
|
});
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderForPrompt(ctx: CallerContext): string {
|
||||||
|
const lines: string[] = [];
|
||||||
|
lines.push(`## CURRENT CALLER: ${ctx.name}`);
|
||||||
|
lines.push(`Phone: ${ctx.phone}`);
|
||||||
|
if (ctx.leadSource) lines.push(`Source: ${ctx.leadSource}`);
|
||||||
|
if (ctx.leadStatus) lines.push(`Status: ${ctx.leadStatus}`);
|
||||||
|
if (ctx.interestedService) lines.push(`Interested in: ${ctx.interestedService}`);
|
||||||
|
if (ctx.utmCampaign) lines.push(`Campaign: ${ctx.utmCampaign}`);
|
||||||
|
if (ctx.contactAttempts > 0) lines.push(`Contact attempts: ${ctx.contactAttempts}`);
|
||||||
|
if (ctx.lastContacted) lines.push(`Last contacted: ${ctx.lastContacted}`);
|
||||||
|
|
||||||
|
if (ctx.aiSummary) {
|
||||||
|
lines.push(`\nAI Summary: ${ctx.aiSummary}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.appointments.length > 0) {
|
||||||
|
lines.push(`\n### Appointments (${ctx.appointments.length})`);
|
||||||
|
for (const a of ctx.appointments) {
|
||||||
|
const date = a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }) : '?';
|
||||||
|
lines.push(`- ${date} | ${a.doctorName ?? '?'} (${a.department ?? '?'}) | ${a.status}${a.reasonForVisit ? ` | ${a.reasonForVisit}` : ''}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
lines.push('\nNo appointments on record.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.calls.length > 0) {
|
||||||
|
lines.push(`\n### Call History (last ${ctx.calls.length})`);
|
||||||
|
for (const c of ctx.calls) {
|
||||||
|
const date = c.startedAt ? new Date(c.startedAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }) : '?';
|
||||||
|
const dur = c.duration ? `${Math.floor(c.duration / 60)}m${c.duration % 60}s` : '?';
|
||||||
|
lines.push(`- ${date} | ${c.direction ?? '?'} | ${dur} | ${c.disposition ?? 'No disposition'}${c.agentName ? ` | Agent: ${c.agentName}` : ''}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.activities.length > 0) {
|
||||||
|
lines.push(`\n### Recent Activity (last ${ctx.activities.length})`);
|
||||||
|
for (const a of ctx.activities) {
|
||||||
|
const date = a.occurredAt ? new Date(a.occurredAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short', year: 'numeric' }) : '?';
|
||||||
|
lines.push(`- ${date} | ${a.activityType}${a.summary ? `: ${a.summary}` : ''}${a.outcome ? ` → ${a.outcome}` : ''}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,15 @@
|
|||||||
import { Controller, Post, Body, Headers, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
import { Controller, Post, Body, Headers, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||||
import { CallerResolutionService } from './caller-resolution.service';
|
import { CallerResolutionService } from './caller-resolution.service';
|
||||||
|
import { CallerContextService } from './caller-context.service';
|
||||||
|
|
||||||
@Controller('api/caller')
|
@Controller('api/caller')
|
||||||
export class CallerResolutionController {
|
export class CallerResolutionController {
|
||||||
private readonly logger = new Logger(CallerResolutionController.name);
|
private readonly logger = new Logger(CallerResolutionController.name);
|
||||||
|
|
||||||
constructor(private readonly resolution: CallerResolutionService) {}
|
constructor(
|
||||||
|
private readonly resolution: CallerResolutionService,
|
||||||
|
private readonly callerContext: CallerContextService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Post('resolve')
|
@Post('resolve')
|
||||||
async resolve(
|
async resolve(
|
||||||
@@ -21,16 +25,21 @@ export class CallerResolutionController {
|
|||||||
|
|
||||||
this.logger.log(`[RESOLVE] Resolving caller: ${phone}`);
|
this.logger.log(`[RESOLVE] Resolving caller: ${phone}`);
|
||||||
const result = await this.resolution.resolve(phone, auth);
|
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;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('invalidate')
|
@Post('invalidate-context')
|
||||||
async invalidate(@Body('phone') phone: string) {
|
async invalidateContext(@Body('leadId') leadId: string) {
|
||||||
if (!phone) {
|
if (!leadId) {
|
||||||
throw new HttpException('phone is required', HttpStatus.BAD_REQUEST);
|
throw new HttpException('leadId is required', HttpStatus.BAD_REQUEST);
|
||||||
}
|
}
|
||||||
this.logger.log(`[RESOLVE] Invalidating cache for: ${phone}`);
|
await this.callerContext.invalidateCache(leadId);
|
||||||
await this.resolution.invalidate(phone);
|
|
||||||
return { status: 'ok' };
|
return { status: 'ok' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { PlatformModule } from '../platform/platform.module';
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { CallerResolutionController } from './caller-resolution.controller';
|
import { CallerResolutionController } from './caller-resolution.controller';
|
||||||
import { CallerResolutionService } from './caller-resolution.service';
|
import { CallerResolutionService } from './caller-resolution.service';
|
||||||
|
import { CallerContextService } from './caller-context.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, AuthModule],
|
imports: [PlatformModule, forwardRef(() => AuthModule)],
|
||||||
controllers: [CallerResolutionController],
|
controllers: [CallerResolutionController],
|
||||||
providers: [CallerResolutionService],
|
providers: [CallerResolutionService, CallerContextService],
|
||||||
exports: [CallerResolutionService],
|
exports: [CallerResolutionService, CallerContextService],
|
||||||
})
|
})
|
||||||
export class CallerResolutionModule {}
|
export class CallerResolutionModule {}
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
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 = {
|
export type ResolvedCaller = {
|
||||||
leadId: string;
|
leadId: string;
|
||||||
@@ -11,7 +7,7 @@ export type ResolvedCaller = {
|
|||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
phone: string;
|
phone: string;
|
||||||
isNew: boolean; // true if we just created the lead+patient pair
|
isNew: boolean; // true if no Lead/Patient exists for this phone
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -20,28 +16,24 @@ export class CallerResolutionService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly cache: SessionService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// Resolve a caller by phone number. Always returns a paired lead + patient.
|
// Resolve a caller by phone number via indexed platform queries. No
|
||||||
|
// cache — every call hits the DB fresh. Cache was previously used to
|
||||||
|
// compensate for client-side `leads(first: 200)` scans, but we now
|
||||||
|
// filter by phone directly which is O(log n) with the DB index.
|
||||||
|
// Cost: ~2 fast queries per resolve; eventual-consistency window = 0.
|
||||||
async resolve(phone: string, auth: string): Promise<ResolvedCaller> {
|
async resolve(phone: string, auth: string): Promise<ResolvedCaller> {
|
||||||
const normalized = phone.replace(/\D/g, '').slice(-10);
|
const normalized = phone.replace(/\D/g, '').slice(-10);
|
||||||
if (normalized.length < 10) {
|
if (normalized.length < 10) {
|
||||||
throw new Error(`Invalid phone number: ${phone}`);
|
throw new Error(`Invalid phone number: ${phone}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Check cache
|
// Lookup lead + patient by phone, in parallel.
|
||||||
const cached = await this.cache.getCache(`${CACHE_PREFIX}${normalized}`);
|
const [lead, patient] = await Promise.all([
|
||||||
if (cached) {
|
this.findLeadByPhone(normalized, auth),
|
||||||
this.logger.log(`[RESOLVE] Cache hit for ${normalized}`);
|
this.findPatientByPhone(normalized, auth),
|
||||||
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;
|
let result: ResolvedCaller;
|
||||||
|
|
||||||
@@ -51,6 +43,11 @@ export class CallerResolutionService {
|
|||||||
await this.linkLeadToPatient(lead.id, patient.id, auth);
|
await this.linkLeadToPatient(lead.id, patient.id, auth);
|
||||||
this.logger.log(`[RESOLVE] Linked existing lead ${lead.id} → patient ${patient.id}`);
|
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 = {
|
result = {
|
||||||
leadId: lead.id,
|
leadId: lead.id,
|
||||||
patientId: patient.id,
|
patientId: patient.id,
|
||||||
@@ -76,6 +73,9 @@ export class CallerResolutionService {
|
|||||||
// Patient exists, no lead — create lead
|
// Patient exists, no lead — create lead
|
||||||
const newLead = await this.createLead(patient.firstName, patient.lastName, normalized, patient.id, auth);
|
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}`);
|
this.logger.log(`[RESOLVE] Created lead ${newLead.id} for existing patient ${patient.id}`);
|
||||||
|
if (patient.patientType === 'NEW') {
|
||||||
|
this.upgradeToReturning(patient.id, auth);
|
||||||
|
}
|
||||||
result = {
|
result = {
|
||||||
leadId: newLead.id,
|
leadId: newLead.id,
|
||||||
patientId: patient.id,
|
patientId: patient.id,
|
||||||
@@ -85,13 +85,18 @@ export class CallerResolutionService {
|
|||||||
isNew: false,
|
isNew: false,
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// Neither exists — create both
|
// Neither exists — return empty IDs with isNew=true. Caller
|
||||||
const newPatient = await this.createPatient('', '', normalized, auth);
|
// code is responsible for creating records with the real name
|
||||||
const newLead = await this.createLead('', '', normalized, newPatient.id, auth);
|
// they've collected (enquiry form, appointment form, widget,
|
||||||
this.logger.log(`[RESOLVE] Created new lead ${newLead.id} + patient ${newPatient.id} for ${normalized}`);
|
// AI tools). This avoids the "Unknown" placeholder cascade:
|
||||||
|
// no Lead/Patient is ever written unless we have a real name
|
||||||
|
// to attach to it. Missed-call / poller paths that have no
|
||||||
|
// name persist the Call record with leadName=phone as the
|
||||||
|
// honest snapshot.
|
||||||
|
this.logger.log(`[RESOLVE] No existing records for ${normalized} — returning isNew=true`);
|
||||||
result = {
|
result = {
|
||||||
leadId: newLead.id,
|
leadId: '',
|
||||||
patientId: newPatient.id,
|
patientId: '',
|
||||||
firstName: '',
|
firstName: '',
|
||||||
lastName: '',
|
lastName: '',
|
||||||
phone: normalized,
|
phone: normalized,
|
||||||
@@ -99,43 +104,30 @@ export class CallerResolutionService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Cache the result
|
|
||||||
await this.cache.setCache(`${CACHE_PREFIX}${normalized}`, JSON.stringify(result), CACHE_TTL);
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate cache for a phone number (call after updates)
|
// Indexed lookup — platform filters by phone server-side. Matches on
|
||||||
async invalidate(phone: string): Promise<void> {
|
// the last 10 digits regardless of stored format (+919XXXX / 91XXXX /
|
||||||
const normalized = phone.replace(/\D/g, '').slice(-10);
|
// XXXX / +91-XXXX), via the `like: "%XXXXXXXXXX"` predicate.
|
||||||
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> {
|
private async findLeadByPhone(phone10: string, auth: string): Promise<{ id: string; firstName: string; lastName: string; patientId: string | null } | null> {
|
||||||
try {
|
try {
|
||||||
const data = await this.platform.queryWithAuth<{ leads: { edges: { node: any }[] } }>(
|
const data = await this.platform.queryWithAuth<{ leads: { edges: { node: any }[] } }>(
|
||||||
`{ leads(first: 200) { edges { node {
|
`{ leads(first: 1, filter: { contactPhone: { primaryPhoneNumber: { like: "%${phone10}" } } }) { edges { node {
|
||||||
id
|
id
|
||||||
contactName { firstName lastName }
|
contactName { firstName lastName }
|
||||||
contactPhone { primaryPhoneNumber }
|
|
||||||
patientId
|
patientId
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined,
|
undefined,
|
||||||
auth,
|
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;
|
if (!match) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: match.node.id,
|
id: match.id,
|
||||||
firstName: match.node.contactName?.firstName ?? '',
|
firstName: match.contactName?.firstName ?? '',
|
||||||
lastName: match.node.contactName?.lastName ?? '',
|
lastName: match.contactName?.lastName ?? '',
|
||||||
patientId: match.node.patientId || null,
|
patientId: match.patientId || null,
|
||||||
};
|
};
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.warn(`[RESOLVE] Lead lookup failed: ${err.message}`);
|
this.logger.warn(`[RESOLVE] Lead lookup failed: ${err.message}`);
|
||||||
@@ -143,29 +135,24 @@ export class CallerResolutionService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findPatientByPhone(phone10: string, auth: string): Promise<{ id: string; firstName: string; lastName: string } | null> {
|
private async findPatientByPhone(phone10: string, auth: string): Promise<{ id: string; firstName: string; lastName: string; patientType: string | null } | null> {
|
||||||
try {
|
try {
|
||||||
const data = await this.platform.queryWithAuth<{ patients: { edges: { node: any }[] } }>(
|
const data = await this.platform.queryWithAuth<{ patients: { edges: { node: any }[] } }>(
|
||||||
`{ patients(first: 200) { edges { node {
|
`{ patients(first: 1, filter: { phones: { primaryPhoneNumber: { like: "%${phone10}" } } }) { edges { node {
|
||||||
id
|
id
|
||||||
fullName { firstName lastName }
|
fullName { firstName lastName }
|
||||||
phones { primaryPhoneNumber }
|
patientType
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined,
|
undefined,
|
||||||
auth,
|
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;
|
if (!match) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: match.node.id,
|
id: match.id,
|
||||||
firstName: match.node.fullName?.firstName ?? '',
|
firstName: match.fullName?.firstName ?? '',
|
||||||
lastName: match.node.fullName?.lastName ?? '',
|
lastName: match.fullName?.lastName ?? '',
|
||||||
|
patientType: match.patientType ?? null,
|
||||||
};
|
};
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
this.logger.warn(`[RESOLVE] Patient lookup failed: ${err.message}`);
|
this.logger.warn(`[RESOLVE] Patient lookup failed: ${err.message}`);
|
||||||
@@ -178,6 +165,7 @@ export class CallerResolutionService {
|
|||||||
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
|
name: `${firstName || 'Unknown'} ${lastName || ''}`.trim(),
|
||||||
fullName: { firstName: firstName || 'Unknown', lastName: lastName || '' },
|
fullName: { firstName: firstName || 'Unknown', lastName: lastName || '' },
|
||||||
phones: { primaryPhoneNumber: `+91${phone}` },
|
phones: { primaryPhoneNumber: `+91${phone}` },
|
||||||
patientType: 'NEW',
|
patientType: 'NEW',
|
||||||
@@ -206,6 +194,19 @@ export class CallerResolutionService {
|
|||||||
return data.createLead;
|
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> {
|
private async linkLeadToPatient(leadId: string, patientId: string, auth: string): Promise<void> {
|
||||||
await this.platform.queryWithAuth<any>(
|
await this.platform.queryWithAuth<any>(
|
||||||
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||||
|
|||||||
@@ -112,13 +112,32 @@ The knowledge base below contains REAL clinic locations, timings, doctor details
|
|||||||
When asked about clinic timings, locations, doctor availability, packages, or insurance — ALWAYS check the knowledge base FIRST before saying you don't know.
|
When asked about clinic timings, locations, doctor availability, packages, or insurance — ALWAYS check the knowledge base FIRST before saying you don't know.
|
||||||
|
|
||||||
RULES:
|
RULES:
|
||||||
1. For patient-specific questions (history, appointments, calls), use the lookup tools. NEVER guess patient data.
|
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. For doctor details beyond what's in the KB, use the lookup_doctor tool.
|
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:
|
||||||
3. For clinic info, timings, packages, insurance → answer directly from the knowledge base below.
|
- lookup_call_history() → calls for this caller
|
||||||
4. If you truly cannot find the answer in the KB or via tools, say "I couldn't find that in our system."
|
- lookup_lead_activities() → activity log for this caller
|
||||||
5. Be concise — agents are on live calls. Under 100 words unless asked for detail.
|
- lookup_appointments() → appointments for this caller
|
||||||
6. NEVER give medical advice, diagnosis, or treatment recommendations.
|
Pass IDs explicitly only when the agent is asking about a different, specific patient — and even then, prefer name/phone via lookup_patient.
|
||||||
7. Format with bullet points for easy scanning.
|
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):
|
KNOWLEDGE BASE (this is real data from our system):
|
||||||
{{knowledgeBase}}`;
|
{{knowledgeBase}}`;
|
||||||
|
|||||||
@@ -22,10 +22,29 @@ export default () => ({
|
|||||||
missedQueue: {
|
missedQueue: {
|
||||||
pollIntervalMs: parseInt(process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000', 10),
|
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),
|
||||||
|
},
|
||||||
ai: {
|
ai: {
|
||||||
provider: process.env.AI_PROVIDER ?? 'openai',
|
provider: process.env.AI_PROVIDER ?? 'openai',
|
||||||
anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? '',
|
anthropicApiKey: process.env.ANTHROPIC_API_KEY ?? '',
|
||||||
openaiApiKey: process.env.OPENAI_API_KEY ?? '',
|
openaiApiKey: process.env.OPENAI_API_KEY ?? '',
|
||||||
model: process.env.AI_MODEL ?? 'gpt-4o-mini',
|
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 ?? '',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,4 +53,20 @@ export class SetupStateController {
|
|||||||
const updated = this.setupState.resetState();
|
const updated = this.setupState.resetState();
|
||||||
return { ...updated, wizardRequired: this.setupState.isWizardRequired() };
|
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',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export class TelephonyConfigService implements OnModuleInit {
|
|||||||
ozonetel: {
|
ozonetel: {
|
||||||
...c.ozonetel,
|
...c.ozonetel,
|
||||||
agentPassword: c.ozonetel.agentPassword ? '***masked***' : '',
|
agentPassword: c.ozonetel.agentPassword ? '***masked***' : '',
|
||||||
|
adminPassword: c.ozonetel.adminPassword ? '***masked***' : '',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -68,6 +69,9 @@ export class TelephonyConfigService implements OnModuleInit {
|
|||||||
if (merged.ozonetel.agentPassword === '***masked***') {
|
if (merged.ozonetel.agentPassword === '***masked***') {
|
||||||
merged.ozonetel.agentPassword = current.ozonetel.agentPassword;
|
merged.ozonetel.agentPassword = current.ozonetel.agentPassword;
|
||||||
}
|
}
|
||||||
|
if (merged.ozonetel.adminPassword === '***masked***') {
|
||||||
|
merged.ozonetel.adminPassword = current.ozonetel.adminPassword;
|
||||||
|
}
|
||||||
this.backup();
|
this.backup();
|
||||||
this.writeFile(merged);
|
this.writeFile(merged);
|
||||||
this.cached = merged;
|
this.cached = merged;
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ export type TelephonyConfig = {
|
|||||||
sipId: string;
|
sipId: string;
|
||||||
// Default outbound campaign name on Ozonetel CloudAgent.
|
// Default outbound campaign name on Ozonetel CloudAgent.
|
||||||
campaignName: string;
|
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.
|
// Ozonetel WebRTC gateway used by the staff portal softphone.
|
||||||
sip: {
|
sip: {
|
||||||
@@ -46,6 +51,8 @@ export const DEFAULT_TELEPHONY_CONFIG: TelephonyConfig = {
|
|||||||
did: '',
|
did: '',
|
||||||
sipId: '',
|
sipId: '',
|
||||||
campaignName: '',
|
campaignName: '',
|
||||||
|
adminUsername: '',
|
||||||
|
adminPassword: '',
|
||||||
},
|
},
|
||||||
sip: {
|
sip: {
|
||||||
domain: 'blr-pub-rtc4.ozonetel.com',
|
domain: 'blr-pub-rtc4.ozonetel.com',
|
||||||
@@ -62,8 +69,11 @@ export const DEFAULT_TELEPHONY_CONFIG: TelephonyConfig = {
|
|||||||
// Field-by-field mapping from legacy env var names to config paths. Used by
|
// 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.
|
// the first-boot seeder. Keep in sync with the migration target sites.
|
||||||
export const TELEPHONY_ENV_SEEDS: Array<{ env: string; path: string[] }> = [
|
export const TELEPHONY_ENV_SEEDS: Array<{ env: string; path: string[] }> = [
|
||||||
{ env: 'OZONETEL_AGENT_ID', path: ['ozonetel', 'agentId'] },
|
// 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_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_DID', path: ['ozonetel', 'did'] },
|
||||||
{ env: 'OZONETEL_SIP_ID', path: ['ozonetel', 'sipId'] },
|
{ env: 'OZONETEL_SIP_ID', path: ['ozonetel', 'sipId'] },
|
||||||
{ env: 'OZONETEL_CAMPAIGN_NAME', path: ['ozonetel', 'campaignName'] },
|
{ env: 'OZONETEL_CAMPAIGN_NAME', path: ['ozonetel', 'campaignName'] },
|
||||||
|
|||||||
182
src/leads/lead-auto-assign.service.ts
Normal file
182
src/leads/lead-auto-assign.service.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { SupervisorService } from '../supervisor/supervisor.service';
|
||||||
|
|
||||||
|
const TICK_INTERVAL_MS = 60 * 1000; // 60s
|
||||||
|
const KICKOFF_DELAY_MS = 45_000; // let sidecar boot settle
|
||||||
|
const MAX_LEADS_PER_TICK = 100; // guard against runaway batches
|
||||||
|
const ACTIVE_STATES = new Set(['ready', 'calling', 'in-call', 'acw']);
|
||||||
|
// Excluded: 'offline' (agent logged out), 'break' / 'training' (explicitly away).
|
||||||
|
// ACW is included — the agent is still handling work and will return to Ready soon.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Polls for unassigned leads every 60s and assigns them least-loaded across
|
||||||
|
* active agents.
|
||||||
|
*
|
||||||
|
* Why polling instead of platform functions or Redpanda events:
|
||||||
|
* - The platform's lead.created hook isn't wired to the sidecar (no bridge)
|
||||||
|
* - The SDK's lead-auto-assign.function.ts is written but hasn't been
|
||||||
|
* deployed/published to either workspace
|
||||||
|
* - Polling catches EVERY lead creation path (CSV import, enquiry form,
|
||||||
|
* missed-call webhook, widget, livekit) with no per-path instrumentation
|
||||||
|
*
|
||||||
|
* Assignment strategy:
|
||||||
|
* - Count each active agent's OPEN leads (status in NEW/CONTACTED/QUALIFIED)
|
||||||
|
* - Pick the agent with the lowest count — ties broken by platform ordering
|
||||||
|
* - Write agent.name (display name) to lead.assignedAgent (worklist filter matches on this)
|
||||||
|
*
|
||||||
|
* Edge cases:
|
||||||
|
* - No active agents → skip tick; next run retries
|
||||||
|
* - agentName empty → skip agent
|
||||||
|
* - Mutation errors → log, continue with next lead
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class LeadAutoAssignService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(LeadAutoAssignService.name);
|
||||||
|
private timer: NodeJS.Timeout | null = null;
|
||||||
|
private running = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly platform: PlatformGraphqlService,
|
||||||
|
private readonly supervisor: SupervisorService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
setTimeout(() => {
|
||||||
|
this.runOnce().catch((err) => this.logger.warn(`[AUTO-ASSIGN] Kickoff failed: ${err?.message ?? err}`));
|
||||||
|
}, KICKOFF_DELAY_MS);
|
||||||
|
|
||||||
|
this.timer = setInterval(() => {
|
||||||
|
this.runOnce().catch((err) => this.logger.warn(`[AUTO-ASSIGN] Tick failed: ${err?.message ?? err}`));
|
||||||
|
}, TICK_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy() {
|
||||||
|
if (this.timer) clearInterval(this.timer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async runOnce(): Promise<{ assigned: number; skipped: number; noAgents: boolean }> {
|
||||||
|
// Guard against concurrent runs (prev tick hasn't finished).
|
||||||
|
if (this.running) return { assigned: 0, skipped: 0, noAgents: false };
|
||||||
|
this.running = true;
|
||||||
|
try {
|
||||||
|
const unassigned = await this.fetchUnassignedLeads();
|
||||||
|
if (unassigned.length === 0) return { assigned: 0, skipped: 0, noAgents: false };
|
||||||
|
|
||||||
|
const active = await this.fetchActiveAgents();
|
||||||
|
if (active.length === 0) {
|
||||||
|
this.logger.debug(`[AUTO-ASSIGN] ${unassigned.length} leads waiting — no active agents`);
|
||||||
|
return { assigned: 0, skipped: unassigned.length, noAgents: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed current-load map: lead count per agent across their OPEN leads.
|
||||||
|
// Fetch once per tick (not per lead) — the map is updated locally as we assign.
|
||||||
|
const loadByAgent = await this.fetchOpenLeadCounts(active.map((a) => a.name));
|
||||||
|
|
||||||
|
let assigned = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const lead of unassigned) {
|
||||||
|
// Pick the least-loaded active agent.
|
||||||
|
const target = [...active].sort(
|
||||||
|
(a, b) => (loadByAgent.get(a.name) ?? 0) - (loadByAgent.get(b.name) ?? 0),
|
||||||
|
)[0];
|
||||||
|
if (!target?.name) { skipped++; continue; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: lead.id, data: { assignedAgent: target.name } },
|
||||||
|
);
|
||||||
|
assigned++;
|
||||||
|
loadByAgent.set(target.name, (loadByAgent.get(target.name) ?? 0) + 1);
|
||||||
|
await new Promise((r) => setTimeout(r, 40)); // gentle pacing
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[AUTO-ASSIGN] updateLead failed for ${lead.id}: ${err?.message ?? err}`);
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assigned > 0 || skipped > 0) {
|
||||||
|
const loadSummary = active.map((a) => `${a.name}=${loadByAgent.get(a.name) ?? 0}`).join(', ');
|
||||||
|
this.logger.log(`[AUTO-ASSIGN] Pass complete — assigned=${assigned} skipped=${skipped} load=[${loadSummary}]`);
|
||||||
|
}
|
||||||
|
return { assigned, skipped, noAgents: false };
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchUnassignedLeads(): Promise<Array<{ id: string; campaignId: string | null }>> {
|
||||||
|
try {
|
||||||
|
const data: any = await this.platform.query<any>(
|
||||||
|
`{ leads(first: ${MAX_LEADS_PER_TICK}, filter: {
|
||||||
|
or: [
|
||||||
|
{ assignedAgent: { eq: "" } },
|
||||||
|
{ assignedAgent: { is: NULL } }
|
||||||
|
]
|
||||||
|
}, orderBy: [{ createdAt: AscNullsLast }]) {
|
||||||
|
edges { node { id campaignId } }
|
||||||
|
} }`,
|
||||||
|
);
|
||||||
|
return (data?.leads?.edges ?? []).map((e: any) => e.node);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[AUTO-ASSIGN] fetch unassigned failed: ${err?.message ?? err}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchActiveAgents(): Promise<Array<{ id: string; name: string; ozonetelAgentId: string }>> {
|
||||||
|
try {
|
||||||
|
const data: any = await this.platform.query<any>(
|
||||||
|
`{ agents(first: 100) { edges { node { id name ozonetelAgentId } } } }`,
|
||||||
|
);
|
||||||
|
const all: Array<{ id: string; name: string; ozonetelAgentId: string }> =
|
||||||
|
(data?.agents?.edges ?? []).map((e: any) => e.node);
|
||||||
|
// Filter to agents whose in-memory state (from Ozonetel webhooks) is active.
|
||||||
|
// If state is unknown (never seen a state event), treat as offline.
|
||||||
|
return all.filter((a) => {
|
||||||
|
if (!a.name || !a.ozonetelAgentId) return false;
|
||||||
|
const entry = this.supervisor.getAgentState(a.ozonetelAgentId);
|
||||||
|
return entry ? ACTIVE_STATES.has(entry.state) : false;
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[AUTO-ASSIGN] fetch agents failed: ${err?.message ?? err}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fetchOpenLeadCounts(agentNames: string[]): Promise<Map<string, number>> {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (const name of agentNames) map.set(name, 0);
|
||||||
|
if (agentNames.length === 0) return map;
|
||||||
|
|
||||||
|
// Single aggregated query — pull ALL open leads with assignedAgent set,
|
||||||
|
// count by agent locally. Avoids N+1 over agents.
|
||||||
|
try {
|
||||||
|
let after: string | null = null;
|
||||||
|
for (let page = 0; page < 20; page++) {
|
||||||
|
const cursor: string = after ? `, after: "${after}"` : '';
|
||||||
|
const data: any = await this.platform.query<any>(
|
||||||
|
`{ leads(first: 200${cursor}, filter: {
|
||||||
|
status: { in: [NEW, CONTACTED, QUALIFIED] }
|
||||||
|
}) {
|
||||||
|
edges { node { assignedAgent } }
|
||||||
|
pageInfo { hasNextPage endCursor }
|
||||||
|
} }`,
|
||||||
|
);
|
||||||
|
const edges = data?.leads?.edges ?? [];
|
||||||
|
for (const e of edges) {
|
||||||
|
const name = e.node.assignedAgent;
|
||||||
|
if (name && map.has(name)) map.set(name, (map.get(name) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
const info: { hasNextPage?: boolean; endCursor?: string } = data?.leads?.pageInfo ?? {};
|
||||||
|
if (!info.hasNextPage) break;
|
||||||
|
after = info.endCursor ?? null;
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[AUTO-ASSIGN] fetch open-lead counts failed: ${err?.message ?? err}`);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/leads/leads.module.ts
Normal file
11
src/leads/leads.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { SupervisorModule } from '../supervisor/supervisor.module';
|
||||||
|
import { LeadAutoAssignService } from './lead-auto-assign.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule, forwardRef(() => SupervisorModule)],
|
||||||
|
providers: [LeadAutoAssignService],
|
||||||
|
exports: [LeadAutoAssignService],
|
||||||
|
})
|
||||||
|
export class LeadsModule {}
|
||||||
@@ -27,6 +27,27 @@ 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
|
// Hospital context — loaded on startup
|
||||||
let hospitalContext = {
|
let hospitalContext = {
|
||||||
doctors: [] as Array<{ name: string; department: string; specialty: string; id: string }>,
|
doctors: [] as Array<{ name: string; department: string; specialty: string; id: string }>,
|
||||||
@@ -128,28 +149,58 @@ const bookAppointment = llm.tool({
|
|||||||
doctorName: doctor?.name ?? doctorName ?? 'To be assigned',
|
doctorName: doctor?.name ?? doctorName ?? 'To be assigned',
|
||||||
department,
|
department,
|
||||||
reasonForVisit: reason,
|
reasonForVisit: reason,
|
||||||
|
...((doctor as any)?.clinicId ? { clinicId: (doctor as any).clinicId } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create or find lead
|
// Resolve caller — if isNew, create Lead + Patient with the
|
||||||
|
// AI-collected name; otherwise update the existing record.
|
||||||
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
const 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(
|
await gql(
|
||||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
name: `AI — ${patientName}`,
|
name: `AI — ${patientName}`,
|
||||||
contactName: {
|
contactName: { firstName: fn, lastName: ln },
|
||||||
firstName: patientName.split(' ')[0],
|
|
||||||
lastName: patientName.split(' ').slice(1).join(' ') || '',
|
|
||||||
},
|
|
||||||
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
||||||
source: 'PHONE',
|
source: 'PHONE',
|
||||||
status: 'APPOINTMENT_SET',
|
status: 'APPOINTMENT_SET',
|
||||||
interestedService: department,
|
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)}`;
|
const refNum = `GH-${Date.now().toString().slice(-6)}`;
|
||||||
if (result?.createAppointment?.id) {
|
if (result?.createAppointment?.id) {
|
||||||
@@ -171,25 +222,53 @@ const collectLeadInfo = llm.tool({
|
|||||||
console.log(`[LIVEKIT-AGENT] Lead: ${name} | ${phoneNumber} | ${interest}`);
|
console.log(`[LIVEKIT-AGENT] Lead: ${name} | ${phoneNumber} | ${interest}`);
|
||||||
|
|
||||||
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
||||||
const result = await gql(
|
const resolved = await resolveCaller(cleanPhone);
|
||||||
|
const fn = name.split(' ')[0];
|
||||||
|
const ln = name.split(' ').slice(1).join(' ') || '';
|
||||||
|
|
||||||
|
if (resolved?.isNew) {
|
||||||
|
// Net-new caller — create Patient + Lead with the AI-collected name.
|
||||||
|
const p = await gql(
|
||||||
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
|
{ data: { fullName: { firstName: fn, lastName: ln }, phones: { primaryPhoneNumber: `+91${cleanPhone}` }, patientType: 'NEW' } },
|
||||||
|
);
|
||||||
|
const newPatientId = p?.createPatient?.id;
|
||||||
|
const created = await gql(
|
||||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
name: `AI Enquiry — ${name}`,
|
name: `AI Enquiry — ${name}`,
|
||||||
contactName: {
|
contactName: { firstName: fn, lastName: ln },
|
||||||
firstName: name.split(' ')[0],
|
|
||||||
lastName: name.split(' ').slice(1).join(' ') || '',
|
|
||||||
},
|
|
||||||
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
||||||
source: 'PHONE',
|
source: 'PHONE',
|
||||||
status: 'NEW',
|
status: 'NEW',
|
||||||
interestedService: interest,
|
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) {
|
||||||
if (result?.createLead?.id) {
|
await gql(
|
||||||
console.log(`[LIVEKIT-AGENT] Lead created: ${result.createLead.id}`);
|
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: resolved.patientId, data: { fullName: { firstName: fn, lastName: ln } } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(`[LIVEKIT-AGENT] Lead updated: ${resolved.leadId} (patient ${resolved.patientId})`);
|
||||||
}
|
}
|
||||||
return `Thank you ${name}. I have noted your enquiry about ${interest}. One of our team members will call you back on ${phoneNumber} shortly.`;
|
return `Thank you ${name}. I have noted your enquiry about ${interest}. One of our team members will call you back on ${phoneNumber} shortly.`;
|
||||||
},
|
},
|
||||||
|
|||||||
61
src/logging/log-stream.service.ts
Normal file
61
src/logging/log-stream.service.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { ConsoleLogger } from '@nestjs/common';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
|
||||||
|
export type LogEntry = {
|
||||||
|
timestamp: string;
|
||||||
|
level: 'log' | 'error' | 'warn' | 'debug' | 'verbose';
|
||||||
|
context: string;
|
||||||
|
message: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Singleton — created once in main.ts, accessed by the SSE controller
|
||||||
|
// via LogStreamService.instance. NestJS DI isn't available at bootstrap
|
||||||
|
// time (the logger is created before the container), so we use a static
|
||||||
|
// instance instead of @Injectable().
|
||||||
|
export class LogStreamService extends ConsoleLogger {
|
||||||
|
static readonly instance = new LogStreamService();
|
||||||
|
readonly logSubject = new Subject<LogEntry>();
|
||||||
|
private readonly buffer: LogEntry[] = [];
|
||||||
|
private static readonly MAX_BUFFER = 500;
|
||||||
|
|
||||||
|
getRecentLogs(limit = 200): LogEntry[] {
|
||||||
|
return this.buffer.slice(-limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
private emit(level: LogEntry['level'], message: unknown, context?: string) {
|
||||||
|
const entry: LogEntry = {
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
level,
|
||||||
|
context: context ?? this.context ?? '',
|
||||||
|
message: typeof message === 'string' ? message : JSON.stringify(message),
|
||||||
|
};
|
||||||
|
this.buffer.push(entry);
|
||||||
|
if (this.buffer.length > LogStreamService.MAX_BUFFER) this.buffer.shift();
|
||||||
|
this.logSubject.next(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
log(message: unknown, context?: string) {
|
||||||
|
super.log(message, context);
|
||||||
|
this.emit('log', message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: unknown, stack?: string, context?: string) {
|
||||||
|
super.error(message, stack, context);
|
||||||
|
this.emit('error', message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(message: unknown, context?: string) {
|
||||||
|
super.warn(message, context);
|
||||||
|
this.emit('warn', message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(message: unknown, context?: string) {
|
||||||
|
super.debug(message, context);
|
||||||
|
this.emit('debug', message, context);
|
||||||
|
}
|
||||||
|
|
||||||
|
verbose(message: unknown, context?: string) {
|
||||||
|
super.verbose(message, context);
|
||||||
|
this.emit('verbose', message, context);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,9 +3,11 @@ import type { NestExpressApplication } from '@nestjs/platform-express';
|
|||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { LogStreamService } from './logging/log-stream.service';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
const logger = LogStreamService.instance;
|
||||||
|
const app = await NestFactory.create<NestExpressApplication>(AppModule, { logger });
|
||||||
const config = app.get(ConfigService);
|
const config = app.get(ConfigService);
|
||||||
|
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { Controller, Post, UseGuards, Logger } from '@nestjs/common';
|
import { Body, Controller, HttpException, Post, UseGuards, Logger } from '@nestjs/common';
|
||||||
import { MaintGuard } from './maint.guard';
|
import { MaintGuard } from './maint.guard';
|
||||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import { SessionService } from '../auth/session.service';
|
import { SessionService } from '../auth/session.service';
|
||||||
import { SupervisorService } from '../supervisor/supervisor.service';
|
import { SupervisorService } from '../supervisor/supervisor.service';
|
||||||
|
import { AgentHistoryService, AgentEventType } from '../supervisor/agent-history.service';
|
||||||
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
import { TelephonyConfigService } from '../config/telephony-config.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')
|
@Controller('api/maint')
|
||||||
@UseGuards(MaintGuard)
|
@UseGuards(MaintGuard)
|
||||||
@@ -19,16 +22,35 @@ export class MaintController {
|
|||||||
private readonly session: SessionService,
|
private readonly session: SessionService,
|
||||||
private readonly supervisor: SupervisorService,
|
private readonly supervisor: SupervisorService,
|
||||||
private readonly callerResolution: CallerResolutionService,
|
private readonly callerResolution: CallerResolutionService,
|
||||||
|
private readonly history: AgentHistoryService,
|
||||||
|
private readonly agentLookup: AgentLookupService,
|
||||||
|
private readonly cdrEnrichment: CdrEnrichmentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Post('force-ready')
|
@Post('force-ready')
|
||||||
async forceReady() {
|
async forceReady(@Body() body: { agentId: string }) {
|
||||||
const oz = this.telephony.getConfig().ozonetel;
|
if (!body?.agentId) throw new HttpException('agentId required', 400);
|
||||||
const agentId = oz.agentId || 'agent3';
|
const agentId = body.agentId;
|
||||||
const password = oz.agentPassword || 'Test123$';
|
|
||||||
const sipId = oz.sipId || '521814';
|
|
||||||
|
|
||||||
this.logger.log(`[MAINT] Force ready: agent=${agentId}`);
|
// 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}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.ozonetel.logoutAgent({ agentId, password });
|
await this.ozonetel.logoutAgent({ agentId, password });
|
||||||
@@ -47,9 +69,63 @@ 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')
|
@Post('unlock-agent')
|
||||||
async unlockAgent() {
|
async unlockAgent(@Body() body: { agentId: string }) {
|
||||||
const agentId = this.telephony.getConfig().ozonetel.agentId || 'agent3';
|
if (!body?.agentId) throw new HttpException('agentId required', 400);
|
||||||
|
const agentId = body.agentId;
|
||||||
this.logger.log(`[MAINT] Unlock agent session: ${agentId}`);
|
this.logger.log(`[MAINT] Unlock agent session: ${agentId}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -267,6 +343,7 @@ export class MaintController {
|
|||||||
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
{
|
{
|
||||||
data: {
|
data: {
|
||||||
|
name: `${firstName} ${lastName}`.trim(),
|
||||||
fullName: { firstName, lastName },
|
fullName: { firstName, lastName },
|
||||||
phones: { primaryPhoneNumber: `+91${phone}` },
|
phones: { primaryPhoneNumber: `+91${phone}` },
|
||||||
patientType: 'NEW',
|
patientType: 'NEW',
|
||||||
@@ -313,4 +390,692 @@ export class MaintController {
|
|||||||
this.logger.log(`[MAINT] Backfill complete: ${linked} linked, ${created} patients created, ${apptLinked} appointments linked, ${skipped} skipped`);
|
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 } };
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
45
src/masterdata/masterdata.controller.ts
Normal file
45
src/masterdata/masterdata.controller.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import { Controller, Get, Query, Logger } from '@nestjs/common';
|
||||||
|
import { MasterdataService } from './masterdata.service';
|
||||||
|
|
||||||
|
@Controller('api/masterdata')
|
||||||
|
export class MasterdataController {
|
||||||
|
private readonly logger = new Logger(MasterdataController.name);
|
||||||
|
|
||||||
|
constructor(private masterdata: MasterdataService) {}
|
||||||
|
|
||||||
|
@Get('departments')
|
||||||
|
async departments() {
|
||||||
|
return this.masterdata.getDepartments();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('doctors')
|
||||||
|
async doctors() {
|
||||||
|
return this.masterdata.getDoctors();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('clinics')
|
||||||
|
async clinics() {
|
||||||
|
return this.masterdata.getClinics();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Available time slots for a doctor on a given date.
|
||||||
|
// Computed from DoctorVisitSlot entities (doctor × clinic × dayOfWeek).
|
||||||
|
// Returns 30-min slots within the doctor's visiting window for that day.
|
||||||
|
//
|
||||||
|
// GET /api/masterdata/slots?doctorId=xxx&date=2026-04-15
|
||||||
|
@Get('slots')
|
||||||
|
async slots(
|
||||||
|
@Query('doctorId') doctorId: string,
|
||||||
|
@Query('date') date: string,
|
||||||
|
) {
|
||||||
|
if (!doctorId || !date) return [];
|
||||||
|
return this.masterdata.getAvailableSlots(doctorId, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force cache refresh (admin use)
|
||||||
|
@Get('refresh')
|
||||||
|
async refresh() {
|
||||||
|
await this.masterdata.invalidateAll();
|
||||||
|
return { refreshed: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/masterdata/masterdata.module.ts
Normal file
13
src/masterdata/masterdata.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { MasterdataController } from './masterdata.controller';
|
||||||
|
import { MasterdataService } from './masterdata.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule, AuthModule],
|
||||||
|
controllers: [MasterdataController],
|
||||||
|
providers: [MasterdataService],
|
||||||
|
exports: [MasterdataService],
|
||||||
|
})
|
||||||
|
export class MasterdataModule {}
|
||||||
213
src/masterdata/masterdata.service.ts
Normal file
213
src/masterdata/masterdata.service.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { SessionService } from '../auth/session.service';
|
||||||
|
|
||||||
|
// Master data: cached lookups for departments, doctors, clinics.
|
||||||
|
// Fetched from the platform on first request, cached in Redis with TTL.
|
||||||
|
// Frontend dropdowns use these instead of direct GraphQL queries.
|
||||||
|
|
||||||
|
const CACHE_TTL = 300; // 5 minutes
|
||||||
|
const KEY_DEPARTMENTS = 'masterdata:departments';
|
||||||
|
const KEY_DOCTORS = 'masterdata:doctors';
|
||||||
|
const KEY_CLINICS = 'masterdata:clinics';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MasterdataService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(MasterdataService.name);
|
||||||
|
private readonly apiKey: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private config: ConfigService,
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
private cache: SessionService,
|
||||||
|
) {
|
||||||
|
this.apiKey = this.config.get<string>('platform.apiKey') ?? process.env.PLATFORM_API_KEY ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
// Warm cache on startup
|
||||||
|
try {
|
||||||
|
await this.getDepartments();
|
||||||
|
await this.getDoctors();
|
||||||
|
await this.getClinics();
|
||||||
|
this.logger.log('Master data cache warmed');
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`Cache warm failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDepartments(): Promise<string[]> {
|
||||||
|
const cached = await this.cache.getCache(KEY_DEPARTMENTS);
|
||||||
|
if (cached) return JSON.parse(cached);
|
||||||
|
|
||||||
|
const auth = `Bearer ${this.apiKey}`;
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ doctors(first: 500) { edges { node { department } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
|
||||||
|
const departments = Array.from(new Set(
|
||||||
|
data.doctors.edges
|
||||||
|
.map((e: any) => e.node.department)
|
||||||
|
.filter((d: string) => d && d.trim()),
|
||||||
|
)).sort() as string[];
|
||||||
|
|
||||||
|
await this.cache.setCache(KEY_DEPARTMENTS, JSON.stringify(departments), CACHE_TTL);
|
||||||
|
this.logger.log(`Cached ${departments.length} departments`);
|
||||||
|
return departments;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getDoctors(): Promise<Array<{ id: string; name: string; department: string; qualifications: string }>> {
|
||||||
|
const cached = await this.cache.getCache(KEY_DOCTORS);
|
||||||
|
if (cached) return JSON.parse(cached);
|
||||||
|
|
||||||
|
const auth = `Bearer ${this.apiKey}`;
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ doctors(first: 500) { edges { node {
|
||||||
|
id name department qualifications specialty active
|
||||||
|
fullName { firstName lastName }
|
||||||
|
} } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
|
||||||
|
const doctors = data.doctors.edges
|
||||||
|
.map((e: any) => ({
|
||||||
|
id: e.node.id,
|
||||||
|
name: e.node.name ?? `${e.node.fullName?.firstName ?? ''} ${e.node.fullName?.lastName ?? ''}`.trim(),
|
||||||
|
department: e.node.department ?? '',
|
||||||
|
qualifications: e.node.qualifications ?? '',
|
||||||
|
specialty: e.node.specialty ?? '',
|
||||||
|
active: e.node.active ?? true,
|
||||||
|
}))
|
||||||
|
.filter((d: any) => d.active !== false);
|
||||||
|
|
||||||
|
await this.cache.setCache(KEY_DOCTORS, JSON.stringify(doctors), CACHE_TTL);
|
||||||
|
this.logger.log(`Cached ${doctors.length} doctors`);
|
||||||
|
return doctors;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getClinics(): Promise<Array<{ id: string; name: string; phone: string; address: string; opensAt: string; closesAt: string }>> {
|
||||||
|
const cached = await this.cache.getCache(KEY_CLINICS);
|
||||||
|
if (cached) return JSON.parse(cached);
|
||||||
|
|
||||||
|
const auth = `Bearer ${this.apiKey}`;
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ clinics(first: 50) { edges { node {
|
||||||
|
id clinicName status opensAt closesAt
|
||||||
|
phone { primaryPhoneNumber }
|
||||||
|
addressCustom { addressCity addressState }
|
||||||
|
} } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
|
||||||
|
const clinics = data.clinics.edges
|
||||||
|
.filter((e: any) => e.node.status !== 'INACTIVE')
|
||||||
|
.map((e: any) => ({
|
||||||
|
id: e.node.id,
|
||||||
|
name: e.node.clinicName ?? '',
|
||||||
|
phone: e.node.phone?.primaryPhoneNumber ?? '',
|
||||||
|
opensAt: e.node.opensAt ?? '08:00',
|
||||||
|
closesAt: e.node.closesAt ?? '20:00',
|
||||||
|
address: [e.node.addressCustom?.addressCity, e.node.addressCustom?.addressState].filter(Boolean).join(', '),
|
||||||
|
}));
|
||||||
|
|
||||||
|
await this.cache.setCache(KEY_CLINICS, JSON.stringify(clinics), CACHE_TTL);
|
||||||
|
this.logger.log(`Cached ${clinics.length} clinics`);
|
||||||
|
return clinics;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Available time slots for a doctor on a given date.
|
||||||
|
// Reads DoctorVisitSlot entities for the matching dayOfWeek,
|
||||||
|
// then generates 30-min slots within each visiting window.
|
||||||
|
async getAvailableSlots(doctorId: string, date: string): Promise<Array<{ time: string; label: string; clinicId: string; clinicName: string }>> {
|
||||||
|
const dayOfWeek = new Date(date).toLocaleDateString('en-US', { weekday: 'long' }).toUpperCase();
|
||||||
|
const cacheKey = `masterdata:slots:${doctorId}:${dayOfWeek}`;
|
||||||
|
|
||||||
|
// Cache stores the UNFILTERED full-day slot list (keyed by dayOfWeek,
|
||||||
|
// so it's reusable across dates that fall on the same weekday). The
|
||||||
|
// "hide past slots on today" filter is applied AFTER cache read so it
|
||||||
|
// stays correct as real-time advances without cache churn.
|
||||||
|
const cached = await this.cache.getCache(cacheKey);
|
||||||
|
if (cached) return this.filterPastSlotsForToday(JSON.parse(cached), date);
|
||||||
|
|
||||||
|
const auth = `Bearer ${this.apiKey}`;
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ doctorVisitSlots(first: 100, filter: { doctorId: { eq: "${doctorId}" }, dayOfWeek: { eq: ${dayOfWeek} } }) {
|
||||||
|
edges { node { id startTime endTime clinic { id clinicName } } }
|
||||||
|
} }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
|
||||||
|
const slots: Array<{ time: string; label: string; clinicId: string; clinicName: string }> = [];
|
||||||
|
|
||||||
|
for (const edge of data.doctorVisitSlots?.edges ?? []) {
|
||||||
|
const node = edge.node;
|
||||||
|
const clinicId = node.clinic?.id ?? '';
|
||||||
|
const clinicName = node.clinic?.clinicName ?? '';
|
||||||
|
const startTime = node.startTime ?? '09:00';
|
||||||
|
const endTime = node.endTime ?? '17:00';
|
||||||
|
|
||||||
|
// Generate 30-min slots within visiting window
|
||||||
|
const [startH, startM] = startTime.split(':').map(Number);
|
||||||
|
const [endH, endM] = endTime.split(':').map(Number);
|
||||||
|
let h = startH, m = startM ?? 0;
|
||||||
|
const endMin = endH * 60 + (endM ?? 0);
|
||||||
|
|
||||||
|
while (h * 60 + m < endMin) {
|
||||||
|
const hh = h.toString().padStart(2, '0');
|
||||||
|
const mm = m.toString().padStart(2, '0');
|
||||||
|
const ampm = h < 12 ? 'AM' : 'PM';
|
||||||
|
const displayH = h === 0 ? 12 : h > 12 ? h - 12 : h;
|
||||||
|
slots.push({
|
||||||
|
time: `${hh}:${mm}`,
|
||||||
|
label: `${displayH}:${mm.toString().padStart(2, '0')} ${ampm} — ${clinicName}`,
|
||||||
|
clinicId,
|
||||||
|
clinicName,
|
||||||
|
});
|
||||||
|
m += 30;
|
||||||
|
if (m >= 60) { h++; m = 0; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by time
|
||||||
|
slots.sort((a, b) => a.time.localeCompare(b.time));
|
||||||
|
|
||||||
|
// Cache the full UNFILTERED list so reuse across dates (same dayOfWeek)
|
||||||
|
// doesn't mis-serve filtered data from an earlier date.
|
||||||
|
await this.cache.setCache(cacheKey, JSON.stringify(slots), CACHE_TTL);
|
||||||
|
this.logger.log(`Generated ${slots.length} slots for doctor ${doctorId} on ${dayOfWeek}`);
|
||||||
|
|
||||||
|
return this.filterPastSlotsForToday(slots, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When the requested date is today (IST), hide slots whose time has
|
||||||
|
// already passed (30-min buffer so we don't offer the impossible-to-keep
|
||||||
|
// "in 5 minutes" slot). Applies to both cache-hit and fresh fetch paths.
|
||||||
|
private filterPastSlotsForToday(
|
||||||
|
slots: Array<{ time: string; label: string; clinicId: string; clinicName: string }>,
|
||||||
|
date: string,
|
||||||
|
): Array<{ time: string; label: string; clinicId: string; clinicName: string }> {
|
||||||
|
const todayIst = new Date().toLocaleDateString('en-CA', { timeZone: 'Asia/Kolkata' });
|
||||||
|
if (date !== todayIst) return slots;
|
||||||
|
|
||||||
|
const nowHHMM = new Date().toLocaleTimeString('en-GB', {
|
||||||
|
timeZone: 'Asia/Kolkata', hour: '2-digit', minute: '2-digit',
|
||||||
|
});
|
||||||
|
const [nowH, nowM] = nowHHMM.split(':').map(Number);
|
||||||
|
const cutoff = nowH * 60 + nowM + 30; // 30-min buffer
|
||||||
|
const filtered = slots.filter((s) => {
|
||||||
|
const [h, m] = s.time.split(':').map(Number);
|
||||||
|
return h * 60 + m >= cutoff;
|
||||||
|
});
|
||||||
|
this.logger.log(`[SLOTS] Today filter: ${slots.length} → ${filtered.length} (now=${nowHHMM} IST, cutoff=${Math.floor(cutoff / 60)}:${String(cutoff % 60).padStart(2, '0')})`);
|
||||||
|
return filtered;
|
||||||
|
}
|
||||||
|
|
||||||
|
async invalidateAll(): Promise<void> {
|
||||||
|
await this.cache.setCache(KEY_DEPARTMENTS, '', 1);
|
||||||
|
await this.cache.setCache(KEY_DOCTORS, '', 1);
|
||||||
|
await this.cache.setCache(KEY_CLINICS, '', 1);
|
||||||
|
this.logger.log('Master data cache invalidated');
|
||||||
|
}
|
||||||
|
}
|
||||||
377
src/messaging/flow/default-flows/appointment-booking.json
Normal file
377
src/messaging/flow/default-flows/appointment-booking.json
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
{
|
||||||
|
"id": "flow-appointment-booking",
|
||||||
|
"name": "Appointment Booking",
|
||||||
|
"description": "AI-driven appointment booking via WhatsApp with interactive department, doctor, date, and slot selection.",
|
||||||
|
"trigger": { "type": "default" },
|
||||||
|
"version": 1,
|
||||||
|
"status": "published",
|
||||||
|
"variables": [
|
||||||
|
{ "id": "v1", "name": "intent", "type": "string" },
|
||||||
|
{ "id": "v2", "name": "selectedDepartment", "type": "string" },
|
||||||
|
{ "id": "v3", "name": "selectedDepartmentTitle", "type": "string" },
|
||||||
|
{ "id": "v4", "name": "selectedDoctor", "type": "string" },
|
||||||
|
{ "id": "v5", "name": "selectedDoctorTitle", "type": "string" },
|
||||||
|
{ "id": "v6", "name": "doctorId", "type": "string" },
|
||||||
|
{ "id": "v7", "name": "dateChoice", "type": "string" },
|
||||||
|
{ "id": "v8", "name": "selectedDate", "type": "string" },
|
||||||
|
{ "id": "v9", "name": "selectedSlot", "type": "string" },
|
||||||
|
{ "id": "v10", "name": "confirmation", "type": "string" },
|
||||||
|
{ "id": "v11", "name": "bookingResult", "type": "object" },
|
||||||
|
{ "id": "v12", "name": "deptListResult", "type": "object" },
|
||||||
|
{ "id": "v13", "name": "docListResult", "type": "object" },
|
||||||
|
{ "id": "v14", "name": "slotListResult", "type": "object" },
|
||||||
|
{ "id": "v15", "name": "aiGreeting", "type": "string" },
|
||||||
|
{ "id": "v16", "name": "reason", "type": "string" },
|
||||||
|
{ "id": "v17", "name": "scheduledDateTime", "type": "string" }
|
||||||
|
],
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"id": "g1",
|
||||||
|
"title": "Greeting",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "b1",
|
||||||
|
"type": "ai",
|
||||||
|
"prompt": "Greet the patient {{_senderName}} warmly in 1-2 sentences. They messaged: \"{{_initialMessage}}\". You are a WhatsApp assistant for Ramaiah Hospital. Be concise, no markdown.",
|
||||||
|
"outputVariableId": "aiGreeting",
|
||||||
|
"sendToPatient": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b2",
|
||||||
|
"type": "message",
|
||||||
|
"content": {
|
||||||
|
"format": "buttons",
|
||||||
|
"text": "How can I help you today?",
|
||||||
|
"buttons": [
|
||||||
|
{ "id": "intent:book", "title": "Book Appointment" },
|
||||||
|
{ "id": "intent:check", "title": "Check Appointment" },
|
||||||
|
{ "id": "intent:question", "title": "Ask a Question" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b3",
|
||||||
|
"type": "input",
|
||||||
|
"inputType": "any",
|
||||||
|
"variableId": "intent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b4",
|
||||||
|
"type": "condition",
|
||||||
|
"conditions": [
|
||||||
|
{ "id": "c1", "variableId": "intent", "operator": "contains", "value": "book" },
|
||||||
|
{ "id": "c2", "variableId": "intent", "operator": "contains", "value": "check" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g2",
|
||||||
|
"title": "Department Selection",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "b5",
|
||||||
|
"type": "tool_call",
|
||||||
|
"toolName": "send_department_list",
|
||||||
|
"inputs": {},
|
||||||
|
"outputVariableId": "deptListResult"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b6",
|
||||||
|
"type": "input",
|
||||||
|
"inputType": "any",
|
||||||
|
"variableId": "selectedDepartment"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b7",
|
||||||
|
"type": "set_variable",
|
||||||
|
"variableId": "selectedDepartmentTitle",
|
||||||
|
"value": "selectedDepartment",
|
||||||
|
"expression": "extract_id"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g3",
|
||||||
|
"title": "Doctor Selection",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "b8",
|
||||||
|
"type": "tool_call",
|
||||||
|
"toolName": "send_doctor_list",
|
||||||
|
"inputs": { "department": "{{selectedDepartmentTitle}}" },
|
||||||
|
"outputVariableId": "docListResult"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b9",
|
||||||
|
"type": "input",
|
||||||
|
"inputType": "any",
|
||||||
|
"variableId": "selectedDoctor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b10",
|
||||||
|
"type": "set_variable",
|
||||||
|
"variableId": "doctorId",
|
||||||
|
"value": "selectedDoctor",
|
||||||
|
"expression": "extract_id"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g4",
|
||||||
|
"title": "Date Selection",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "b11",
|
||||||
|
"type": "message",
|
||||||
|
"content": {
|
||||||
|
"format": "buttons",
|
||||||
|
"text": "When would you like to visit?",
|
||||||
|
"buttons": [
|
||||||
|
{ "id": "date:tomorrow", "title": "Tomorrow" },
|
||||||
|
{ "id": "date:day_after", "title": "Day After Tomorrow" },
|
||||||
|
{ "id": "date:other", "title": "Choose Another Date" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b12",
|
||||||
|
"type": "input",
|
||||||
|
"inputType": "any",
|
||||||
|
"variableId": "dateChoice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b13",
|
||||||
|
"type": "condition",
|
||||||
|
"conditions": [
|
||||||
|
{ "id": "c3", "variableId": "dateChoice", "operator": "contains", "value": "tomorrow" },
|
||||||
|
{ "id": "c4", "variableId": "dateChoice", "operator": "contains", "value": "day_after" },
|
||||||
|
{ "id": "c7", "variableId": "dateChoice", "operator": "contains", "value": "other" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g4t",
|
||||||
|
"title": "Date - Tomorrow",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "b14",
|
||||||
|
"type": "set_variable",
|
||||||
|
"variableId": "selectedDate",
|
||||||
|
"value": "",
|
||||||
|
"expression": "date_tomorrow"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g4a",
|
||||||
|
"title": "Date - Day After",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "b15",
|
||||||
|
"type": "set_variable",
|
||||||
|
"variableId": "selectedDate",
|
||||||
|
"value": "",
|
||||||
|
"expression": "date_day_after"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g4c",
|
||||||
|
"title": "Date - Custom",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "b15a",
|
||||||
|
"type": "message",
|
||||||
|
"content": {
|
||||||
|
"format": "text",
|
||||||
|
"text": "Please type your preferred date (e.g., April 25 or 25/04/2026)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b15b",
|
||||||
|
"type": "input",
|
||||||
|
"inputType": "text",
|
||||||
|
"variableId": "customDateText"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b15c",
|
||||||
|
"type": "ai",
|
||||||
|
"prompt": "The patient typed this date: \"{{customDateText}}\". Convert it to YYYY-MM-DD format. The current year is 2026. Reply with ONLY the date in YYYY-MM-DD format, nothing else.",
|
||||||
|
"outputVariableId": "selectedDate",
|
||||||
|
"sendToPatient": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g5",
|
||||||
|
"title": "Slot Selection",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "b16",
|
||||||
|
"type": "tool_call",
|
||||||
|
"toolName": "send_slot_list",
|
||||||
|
"inputs": {
|
||||||
|
"doctorId": "{{doctorId}}",
|
||||||
|
"doctorName": "{{selectedDoctor_title}}",
|
||||||
|
"date": "{{selectedDate}}"
|
||||||
|
},
|
||||||
|
"outputVariableId": "slotListResult"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b17",
|
||||||
|
"type": "input",
|
||||||
|
"inputType": "any",
|
||||||
|
"variableId": "selectedSlot"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b17a",
|
||||||
|
"type": "set_variable",
|
||||||
|
"variableId": "scheduledDateTime",
|
||||||
|
"value": "selectedSlot",
|
||||||
|
"expression": "extract_datetime"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g6",
|
||||||
|
"title": "Reason",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "b18",
|
||||||
|
"type": "message",
|
||||||
|
"content": {
|
||||||
|
"format": "text",
|
||||||
|
"text": "What is the reason for your visit? (e.g., General Consultation, Follow-up, etc.)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b19",
|
||||||
|
"type": "input",
|
||||||
|
"inputType": "text",
|
||||||
|
"variableId": "reason"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g7",
|
||||||
|
"title": "Confirmation",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "b20",
|
||||||
|
"type": "tool_call",
|
||||||
|
"toolName": "send_confirm_buttons",
|
||||||
|
"inputs": {
|
||||||
|
"summary": "Appointment Summary:\nDoctor: {{selectedDoctor_title}}\nDate: {{selectedDate}}\nTime: {{selectedSlot_title}}\nReason: {{reason}}\n\nShall I confirm this booking?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b21",
|
||||||
|
"type": "input",
|
||||||
|
"inputType": "any",
|
||||||
|
"variableId": "confirmation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b22",
|
||||||
|
"type": "condition",
|
||||||
|
"conditions": [
|
||||||
|
{ "id": "c5", "variableId": "confirmation", "operator": "contains", "value": "confirm" },
|
||||||
|
{ "id": "c6", "variableId": "confirmation", "operator": "contains", "value": "cancel" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g8",
|
||||||
|
"title": "Booking",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "b23",
|
||||||
|
"type": "tool_call",
|
||||||
|
"toolName": "book_appointment",
|
||||||
|
"inputs": {
|
||||||
|
"patientName": "{{_senderName}}",
|
||||||
|
"phoneNumber": "{{_phone}}",
|
||||||
|
"department": "{{selectedDepartmentTitle}}",
|
||||||
|
"doctorName": "{{selectedDoctor_title}}",
|
||||||
|
"scheduledAt": "{{scheduledDateTime}}",
|
||||||
|
"reason": "{{reason}}"
|
||||||
|
},
|
||||||
|
"outputVariableId": "bookingResult"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b24",
|
||||||
|
"type": "message",
|
||||||
|
"content": {
|
||||||
|
"format": "text",
|
||||||
|
"text": "Your appointment is confirmed!\n\nDoctor: {{selectedDoctor_title}}\nDate: {{selectedDate}}\nTime: {{selectedSlot_title}}\nReason: {{reason}}\n\nThank you for choosing Ramaiah Hospital. See you soon!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b24a",
|
||||||
|
"type": "tool_call",
|
||||||
|
"toolName": "send_appointment_qr",
|
||||||
|
"inputs": {
|
||||||
|
"appointmentId": "{{bookingResult.appointmentId}}",
|
||||||
|
"reference": "{{bookingResult.reference}}",
|
||||||
|
"patientName": "{{_senderName}}",
|
||||||
|
"doctorName": "{{selectedDoctor_title}}",
|
||||||
|
"department": "{{selectedDepartmentTitle}}",
|
||||||
|
"scheduledAt": "{{scheduledDateTime}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g9",
|
||||||
|
"title": "Cancelled",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "b25",
|
||||||
|
"type": "message",
|
||||||
|
"content": {
|
||||||
|
"format": "text",
|
||||||
|
"text": "No problem! Your booking has been cancelled. Feel free to message us again whenever you'd like to book an appointment."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g10",
|
||||||
|
"title": "Check Appointments",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "b26",
|
||||||
|
"type": "tool_call",
|
||||||
|
"toolName": "lookup_appointments",
|
||||||
|
"inputs": {},
|
||||||
|
"outputVariableId": "existingAppts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b27",
|
||||||
|
"type": "ai",
|
||||||
|
"prompt": "The patient {{_senderName}} asked to check their appointments. Here are their appointments: {{existingAppts}}. Summarize them in a friendly WhatsApp message. If no appointments, say they have none and offer to book one. Be concise, no markdown.",
|
||||||
|
"outputVariableId": "apptSummary",
|
||||||
|
"sendToPatient": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{ "id": "e1", "from": { "blockId": "b4", "conditionId": "c1" }, "to": { "groupId": "g2" } },
|
||||||
|
{ "id": "e2", "from": { "blockId": "b4", "conditionId": "c2" }, "to": { "groupId": "g10" } },
|
||||||
|
{ "id": "e3", "from": { "blockId": "b7" }, "to": { "groupId": "g3" } },
|
||||||
|
{ "id": "e4", "from": { "blockId": "b10" }, "to": { "groupId": "g4" } },
|
||||||
|
{ "id": "e5", "from": { "blockId": "b13", "conditionId": "c3" }, "to": { "groupId": "g4t" } },
|
||||||
|
{ "id": "e6", "from": { "blockId": "b13", "conditionId": "c4" }, "to": { "groupId": "g4a" } },
|
||||||
|
{ "id": "e6a", "from": { "blockId": "b13", "conditionId": "c7" }, "to": { "groupId": "g4c" } },
|
||||||
|
{ "id": "e7", "from": { "blockId": "b14" }, "to": { "groupId": "g5" } },
|
||||||
|
{ "id": "e8", "from": { "blockId": "b15" }, "to": { "groupId": "g5" } },
|
||||||
|
{ "id": "e8a", "from": { "blockId": "b15c" }, "to": { "groupId": "g5" } },
|
||||||
|
{ "id": "e9", "from": { "blockId": "b17a" }, "to": { "groupId": "g6" } },
|
||||||
|
{ "id": "e10", "from": { "blockId": "b19" }, "to": { "groupId": "g7" } },
|
||||||
|
{ "id": "e11", "from": { "blockId": "b22", "conditionId": "c5" }, "to": { "groupId": "g8" } },
|
||||||
|
{ "id": "e12", "from": { "blockId": "b22", "conditionId": "c6" }, "to": { "groupId": "g9" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
344
src/messaging/flow/flow-execution.service.ts
Normal file
344
src/messaging/flow/flow-execution.service.ts
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { generateText, stepCountIs } from 'ai';
|
||||||
|
import { createAiModel } from '../../ai/ai-provider';
|
||||||
|
import { AiConfigService } from '../../config/ai-config.service';
|
||||||
|
import { CallerResolutionService } from '../../caller/caller-resolution.service';
|
||||||
|
import { CallerContextService } from '../../caller/caller-context.service';
|
||||||
|
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
|
||||||
|
import { MessagingProvider } from '../providers/messaging-provider.interface';
|
||||||
|
import { FlowSessionService } from './flow-session.service';
|
||||||
|
import { FlowStoreService } from './flow-store.service';
|
||||||
|
import { FlowVariableService } from './flow-variable.service';
|
||||||
|
import { ToolRegistry } from './tool-registry';
|
||||||
|
import type { Flow, FlowSession, Group, Block, ConditionBlock, ToolContext } from './flow-types';
|
||||||
|
import type { NormalizedMessage } from '../types';
|
||||||
|
import type { LanguageModel } from 'ai';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FlowExecutionService {
|
||||||
|
private readonly logger = new Logger(FlowExecutionService.name);
|
||||||
|
private readonly aiModel: LanguageModel | null;
|
||||||
|
private readonly auth: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private config: ConfigService,
|
||||||
|
private provider: MessagingProvider,
|
||||||
|
private sessions: FlowSessionService,
|
||||||
|
private store: FlowStoreService,
|
||||||
|
private variables: FlowVariableService,
|
||||||
|
private tools: ToolRegistry,
|
||||||
|
private caller: CallerResolutionService,
|
||||||
|
private callerContext: CallerContextService,
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
private aiConfig: AiConfigService,
|
||||||
|
) {
|
||||||
|
const cfg = aiConfig.getConfig();
|
||||||
|
this.aiModel = createAiModel({
|
||||||
|
provider: cfg.provider,
|
||||||
|
model: cfg.model,
|
||||||
|
anthropicApiKey: config.get<string>('ai.anthropicApiKey'),
|
||||||
|
openaiApiKey: config.get<string>('ai.openaiApiKey'),
|
||||||
|
});
|
||||||
|
const apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
|
this.auth = apiKey ? `Bearer ${apiKey}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-phone lock to prevent concurrent flow executions
|
||||||
|
private readonly locks = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
async handleMessage(message: NormalizedMessage): Promise<void> {
|
||||||
|
const { phone } = message;
|
||||||
|
|
||||||
|
// Serialize executions per phone — prevent two concurrent flows
|
||||||
|
const existing = this.locks.get(phone);
|
||||||
|
const execute = async () => {
|
||||||
|
if (existing) await existing.catch(() => {});
|
||||||
|
await this._handleMessage(message);
|
||||||
|
};
|
||||||
|
const promise = execute();
|
||||||
|
this.locks.set(phone, promise);
|
||||||
|
await promise.finally(() => {
|
||||||
|
if (this.locks.get(phone) === promise) this.locks.delete(phone);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _handleMessage(message: NormalizedMessage): Promise<void> {
|
||||||
|
const { phone } = message;
|
||||||
|
|
||||||
|
// 1. Load existing session or start new flow
|
||||||
|
let session = await this.sessions.load(phone);
|
||||||
|
let flow: Flow | null = null;
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
flow = this.store.getById(session.flowId);
|
||||||
|
if (!flow) {
|
||||||
|
this.logger.warn(`[FLOW] Flow ${session.flowId} not found — clearing session`);
|
||||||
|
await this.sessions.clear(phone);
|
||||||
|
session = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
flow = this.store.matchFlow(message.text);
|
||||||
|
if (!flow) {
|
||||||
|
this.logger.log(`[FLOW] No matching flow for: ${message.text.substring(0, 50)}`);
|
||||||
|
await this.provider.sendText(phone, 'Sorry, I didn\'t understand. Please try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize session
|
||||||
|
const firstGroup = flow.groups[0];
|
||||||
|
if (!firstGroup) {
|
||||||
|
this.logger.error(`[FLOW] Flow ${flow.id} has no groups`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session = {
|
||||||
|
flowId: flow.id,
|
||||||
|
currentGroupId: firstGroup.id,
|
||||||
|
currentBlockIndex: 0,
|
||||||
|
variables: this.initializeVariables(flow, message),
|
||||||
|
startedAt: Date.now(),
|
||||||
|
lastActiveAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve caller and inject context variables
|
||||||
|
const resolved = await this.caller.resolve(phone, this.auth).catch(() => null);
|
||||||
|
if (resolved) {
|
||||||
|
session.variables['_callerName'] = `${resolved.firstName} ${resolved.lastName}`.trim();
|
||||||
|
session.variables['_leadId'] = resolved.leadId;
|
||||||
|
session.variables['_patientId'] = resolved.patientId;
|
||||||
|
session.variables['_isNew'] = resolved.isNew;
|
||||||
|
session.variables['_phone'] = phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[FLOW] Started flow "${flow.name}" for ${phone}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. If paused at an InputBlock, process the reply
|
||||||
|
const currentGroup = flow!.groups.find(g => g.id === session!.currentGroupId);
|
||||||
|
if (currentGroup) {
|
||||||
|
const currentBlock = currentGroup.blocks[session!.currentBlockIndex];
|
||||||
|
if (currentBlock?.type === 'input') {
|
||||||
|
const value = message.interactiveReply?.id ?? message.text;
|
||||||
|
session!.variables[currentBlock.variableId] = value;
|
||||||
|
|
||||||
|
// Also store the display title for interactive replies
|
||||||
|
if (message.interactiveReply?.title) {
|
||||||
|
session!.variables[currentBlock.variableId + '_title'] = message.interactiveReply.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[FLOW] Input received: ${currentBlock.variableId}=${value}`);
|
||||||
|
session!.currentBlockIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Walk forward
|
||||||
|
await this.walkForward(phone, session!, flow!);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async walkForward(phone: string, session: FlowSession, flow: Flow): Promise<void> {
|
||||||
|
let iterations = 0;
|
||||||
|
const maxIterations = 50; // safety valve
|
||||||
|
|
||||||
|
while (iterations++ < maxIterations) {
|
||||||
|
const group = flow.groups.find(g => g.id === session.currentGroupId);
|
||||||
|
if (!group) {
|
||||||
|
this.logger.log(`[FLOW] Group ${session.currentGroupId} not found — flow complete`);
|
||||||
|
await this.sessions.clear(phone);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// End of group — follow outgoing edge
|
||||||
|
if (session.currentBlockIndex >= group.blocks.length) {
|
||||||
|
const edge = this.findGroupEdge(flow, group);
|
||||||
|
if (!edge) {
|
||||||
|
this.logger.log(`[FLOW] No outgoing edge from group "${group.title}" — flow complete`);
|
||||||
|
await this.sessions.clear(phone);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.currentGroupId = edge.to.groupId;
|
||||||
|
session.currentBlockIndex = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const block = group.blocks[session.currentBlockIndex];
|
||||||
|
this.logger.log(`[FLOW] Executing block ${block.id} (${block.type}) in group "${group.title}"`);
|
||||||
|
|
||||||
|
const shouldStop = await this.executeBlock(block, phone, session, flow);
|
||||||
|
if (shouldStop) {
|
||||||
|
await this.sessions.save(phone, session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.error(`[FLOW] Max iterations reached for ${phone} — possible infinite loop`);
|
||||||
|
await this.sessions.clear(phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if execution should pause (InputBlock)
|
||||||
|
private async executeBlock(block: Block, phone: string, session: FlowSession, flow: Flow): Promise<boolean> {
|
||||||
|
const ctx: ToolContext = {
|
||||||
|
phone,
|
||||||
|
session,
|
||||||
|
provider: this.provider,
|
||||||
|
platform: this.platform,
|
||||||
|
auth: this.auth,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (block.type) {
|
||||||
|
case 'message': {
|
||||||
|
const content = block.content;
|
||||||
|
if (content.format === 'text') {
|
||||||
|
const text = this.variables.interpolate(content.text, session.variables);
|
||||||
|
await this.provider.sendText(phone, text);
|
||||||
|
} else if (content.format === 'buttons') {
|
||||||
|
const text = this.variables.interpolate(content.text, session.variables);
|
||||||
|
await this.provider.sendButtons(phone, text, content.buttons);
|
||||||
|
} else if (content.format === 'list') {
|
||||||
|
const text = this.variables.interpolate(content.text, session.variables);
|
||||||
|
await this.provider.sendList(phone, text, content.buttonText, content.sections);
|
||||||
|
}
|
||||||
|
session.currentBlockIndex++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'input': {
|
||||||
|
// Pause — wait for next message
|
||||||
|
this.logger.log(`[FLOW] Waiting for input → ${block.variableId}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'condition': {
|
||||||
|
const matched = this.evaluateConditions(block, session);
|
||||||
|
if (matched) {
|
||||||
|
const edge = flow.edges.find(e =>
|
||||||
|
e.from.blockId === block.id && e.from.conditionId === matched.id,
|
||||||
|
);
|
||||||
|
if (edge) {
|
||||||
|
session.currentGroupId = edge.to.groupId;
|
||||||
|
session.currentBlockIndex = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No match — fall through to next block
|
||||||
|
session.currentBlockIndex++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'set_variable': {
|
||||||
|
if (block.expression) {
|
||||||
|
const rawValue = session.variables[block.value] ?? block.value;
|
||||||
|
session.variables[block.variableId] = this.variables.evaluateExpression(
|
||||||
|
block.expression, String(rawValue), session.variables,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
session.variables[block.variableId] = this.variables.interpolate(block.value, session.variables);
|
||||||
|
}
|
||||||
|
this.logger.log(`[FLOW] Set ${block.variableId}=${session.variables[block.variableId]}`);
|
||||||
|
session.currentBlockIndex++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tool_call': {
|
||||||
|
const inputs = this.variables.interpolateObject(block.inputs, session.variables);
|
||||||
|
const result = await this.tools.execute(block.toolName, inputs, ctx);
|
||||||
|
if (block.outputVariableId) {
|
||||||
|
session.variables[block.outputVariableId] = result;
|
||||||
|
}
|
||||||
|
session.currentBlockIndex++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ai': {
|
||||||
|
if (!this.aiModel) {
|
||||||
|
session.currentBlockIndex++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const prompt = this.variables.interpolate(block.prompt, session.variables);
|
||||||
|
try {
|
||||||
|
const result = await generateText({
|
||||||
|
model: this.aiModel,
|
||||||
|
prompt,
|
||||||
|
stopWhen: stepCountIs(1),
|
||||||
|
});
|
||||||
|
const text = result.text?.trim() ?? '';
|
||||||
|
if (block.outputVariableId) {
|
||||||
|
session.variables[block.outputVariableId] = text;
|
||||||
|
}
|
||||||
|
if (block.sendToPatient && text) {
|
||||||
|
await this.provider.sendText(phone, text);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[FLOW] AI block failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
session.currentBlockIndex++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'jump': {
|
||||||
|
session.currentGroupId = block.targetGroupId;
|
||||||
|
session.currentBlockIndex = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
this.logger.warn(`[FLOW] Unknown block type: ${(block as any).type}`);
|
||||||
|
session.currentBlockIndex++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private evaluateConditions(block: ConditionBlock, session: FlowSession) {
|
||||||
|
for (const cond of block.conditions) {
|
||||||
|
const value = session.variables[cond.variableId];
|
||||||
|
const target = cond.value ? this.variables.interpolate(cond.value, session.variables) : undefined;
|
||||||
|
|
||||||
|
let match = false;
|
||||||
|
switch (cond.operator) {
|
||||||
|
case 'equals': match = String(value) === target; break;
|
||||||
|
case 'contains': match = String(value ?? '').toLowerCase().includes((target ?? '').toLowerCase()); break;
|
||||||
|
case 'exists': match = value !== undefined && value !== null && value !== ''; break;
|
||||||
|
case 'not_exists': match = value === undefined || value === null || value === ''; break;
|
||||||
|
case 'starts_with': match = String(value ?? '').startsWith(target ?? ''); break;
|
||||||
|
case 'gt': match = Number(value) > Number(target); break;
|
||||||
|
case 'lt': match = Number(value) < Number(target); break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match) return cond;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private findGroupEdge(flow: Flow, group: Group) {
|
||||||
|
// Find edge from the last block in the group (default outgoing)
|
||||||
|
const lastBlock = group.blocks[group.blocks.length - 1];
|
||||||
|
if (lastBlock) {
|
||||||
|
const edge = flow.edges.find(e => e.from.blockId === lastBlock.id && !e.from.conditionId);
|
||||||
|
if (edge) return edge;
|
||||||
|
}
|
||||||
|
// Fallback: any edge from any block in this group without conditionId
|
||||||
|
for (const block of group.blocks) {
|
||||||
|
const edge = flow.edges.find(e => e.from.blockId === block.id && !e.from.conditionId);
|
||||||
|
if (edge) return edge;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeVariables(flow: Flow, message: NormalizedMessage): Record<string, any> {
|
||||||
|
const vars: Record<string, any> = {};
|
||||||
|
for (const v of flow.variables) {
|
||||||
|
vars[v.name] = v.defaultValue ?? null;
|
||||||
|
}
|
||||||
|
// Inject message context
|
||||||
|
vars['_initialMessage'] = message.text;
|
||||||
|
vars['_senderName'] = message.name;
|
||||||
|
return vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if flow engine has any published flows
|
||||||
|
hasFlows(): boolean {
|
||||||
|
return this.store.getAll().some(f => f.status === 'published');
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/messaging/flow/flow-session.service.ts
Normal file
39
src/messaging/flow/flow-session.service.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import type { FlowSession } from './flow-types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FlowSessionService {
|
||||||
|
private readonly logger = new Logger(FlowSessionService.name);
|
||||||
|
private readonly redis: Redis;
|
||||||
|
private readonly ttlSec = 24 * 60 * 60; // 24h
|
||||||
|
|
||||||
|
constructor(config: ConfigService) {
|
||||||
|
const redisUrl = config.get<string>('redis.url') ?? 'redis://localhost:6379';
|
||||||
|
this.redis = new Redis(redisUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private key(phone: string): string {
|
||||||
|
return `wa:flow:${phone}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(phone: string): Promise<FlowSession | null> {
|
||||||
|
const raw = await this.redis.get(this.key(phone));
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(phone: string, session: FlowSession): Promise<void> {
|
||||||
|
session.lastActiveAt = Date.now();
|
||||||
|
await this.redis.setex(this.key(phone), this.ttlSec, JSON.stringify(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(phone: string): Promise<void> {
|
||||||
|
await this.redis.del(this.key(phone));
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/messaging/flow/flow-store.service.ts
Normal file
102
src/messaging/flow/flow-store.service.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { existsSync, readFileSync, writeFileSync, readdirSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import type { Flow } from './flow-types';
|
||||||
|
|
||||||
|
const FLOWS_DIR = join(process.cwd(), 'data', 'flows');
|
||||||
|
const DEFAULTS_DIR = join(__dirname, 'default-flows');
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FlowStoreService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(FlowStoreService.name);
|
||||||
|
private flows: Map<string, Flow> = new Map();
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
this.ensureDirectory();
|
||||||
|
this.seedDefaults();
|
||||||
|
this.loadAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureDirectory() {
|
||||||
|
const { mkdirSync } = require('fs');
|
||||||
|
if (!existsSync(FLOWS_DIR)) {
|
||||||
|
mkdirSync(FLOWS_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private seedDefaults() {
|
||||||
|
// Copy default flows if data/flows/ is empty
|
||||||
|
if (!existsSync(DEFAULTS_DIR)) return;
|
||||||
|
const existing = readdirSync(FLOWS_DIR).filter(f => f.endsWith('.json'));
|
||||||
|
if (existing.length > 0) return;
|
||||||
|
|
||||||
|
const defaults = readdirSync(DEFAULTS_DIR).filter(f => f.endsWith('.json'));
|
||||||
|
for (const file of defaults) {
|
||||||
|
const src = join(DEFAULTS_DIR, file);
|
||||||
|
const dest = join(FLOWS_DIR, file);
|
||||||
|
const content = readFileSync(src, 'utf-8');
|
||||||
|
writeFileSync(dest, content);
|
||||||
|
this.logger.log(`[FLOW-STORE] Seeded default flow: ${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadAll() {
|
||||||
|
this.flows.clear();
|
||||||
|
const files = readdirSync(FLOWS_DIR).filter(f => f.endsWith('.json'));
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(join(FLOWS_DIR, file), 'utf-8');
|
||||||
|
const flow: Flow = JSON.parse(raw);
|
||||||
|
this.flows.set(flow.id, flow);
|
||||||
|
this.logger.log(`[FLOW-STORE] Loaded flow: ${flow.name} (${flow.id}) status=${flow.status}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[FLOW-STORE] Failed to load ${file}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.logger.log(`[FLOW-STORE] ${this.flows.size} flow(s) loaded`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(id: string): Flow | null {
|
||||||
|
return this.flows.get(id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match inbound message to a published flow by trigger
|
||||||
|
matchFlow(messageText: string): Flow | null {
|
||||||
|
let defaultFlow: Flow | null = null;
|
||||||
|
|
||||||
|
for (const flow of this.flows.values()) {
|
||||||
|
if (flow.status !== 'published') continue;
|
||||||
|
|
||||||
|
if (flow.trigger.type === 'default') {
|
||||||
|
defaultFlow = flow;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flow.trigger.type === 'message' && flow.trigger.conditions) {
|
||||||
|
const { keywords, regex } = flow.trigger.conditions;
|
||||||
|
const lower = messageText.toLowerCase();
|
||||||
|
|
||||||
|
if (keywords?.some(k => lower.includes(k.toLowerCase()))) {
|
||||||
|
return flow;
|
||||||
|
}
|
||||||
|
if (regex && new RegExp(regex, 'i').test(messageText)) {
|
||||||
|
return flow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultFlow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRUD for admin API (future)
|
||||||
|
getAll(): Flow[] {
|
||||||
|
return Array.from(this.flows.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
save(flow: Flow): void {
|
||||||
|
this.flows.set(flow.id, flow);
|
||||||
|
const file = join(FLOWS_DIR, `${flow.id}.json`);
|
||||||
|
writeFileSync(file, JSON.stringify(flow, null, 2));
|
||||||
|
this.logger.log(`[FLOW-STORE] Saved flow: ${flow.name} (${flow.id})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
src/messaging/flow/flow-types.ts
Normal file
133
src/messaging/flow/flow-types.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
// ── Flow Definition ──
|
||||||
|
|
||||||
|
export type Flow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
trigger: FlowTrigger;
|
||||||
|
groups: Group[];
|
||||||
|
edges: Edge[];
|
||||||
|
variables: VariableDefinition[];
|
||||||
|
version: number;
|
||||||
|
status: 'draft' | 'published';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FlowTrigger =
|
||||||
|
| { type: 'message'; conditions?: { keywords?: string[]; regex?: string } }
|
||||||
|
| { type: 'default' };
|
||||||
|
|
||||||
|
export type VariableDefinition = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||||
|
defaultValue?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Groups & Edges ──
|
||||||
|
|
||||||
|
export type Group = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
blocks: Block[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Edge = {
|
||||||
|
id: string;
|
||||||
|
from: { blockId: string; conditionId?: string };
|
||||||
|
to: { groupId: string; blockId?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Blocks ──
|
||||||
|
|
||||||
|
export type Block =
|
||||||
|
| MessageBlock
|
||||||
|
| InputBlock
|
||||||
|
| ConditionBlock
|
||||||
|
| SetVariableBlock
|
||||||
|
| ToolCallBlock
|
||||||
|
| AIBlock
|
||||||
|
| JumpBlock;
|
||||||
|
|
||||||
|
export type MessageBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'message';
|
||||||
|
content:
|
||||||
|
| { format: 'text'; text: string }
|
||||||
|
| { format: 'buttons'; text: string; buttons: { id: string; title: string }[] }
|
||||||
|
| { format: 'list'; text: string; buttonText: string; sections: { title: string; rows: { id: string; title: string; description?: string }[] }[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InputBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'input';
|
||||||
|
inputType: 'text' | 'interactive_reply' | 'any';
|
||||||
|
variableId: string;
|
||||||
|
validation?: { regex?: string; errorMessage?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConditionBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'condition';
|
||||||
|
conditions: {
|
||||||
|
id: string;
|
||||||
|
variableId: string;
|
||||||
|
operator: 'equals' | 'contains' | 'exists' | 'not_exists' | 'gt' | 'lt' | 'starts_with';
|
||||||
|
value?: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SetVariableBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'set_variable';
|
||||||
|
variableId: string;
|
||||||
|
value: string;
|
||||||
|
expression?: 'extract_id' | 'extract_datetime' | 'date_tomorrow' | 'date_day_after';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolCallBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'tool_call';
|
||||||
|
toolName: string;
|
||||||
|
inputs: Record<string, string>;
|
||||||
|
outputVariableId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AIBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'ai';
|
||||||
|
prompt: string;
|
||||||
|
outputVariableId?: string;
|
||||||
|
sendToPatient: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JumpBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'jump';
|
||||||
|
targetGroupId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Session State ──
|
||||||
|
|
||||||
|
export type FlowSession = {
|
||||||
|
flowId: string;
|
||||||
|
currentGroupId: string;
|
||||||
|
currentBlockIndex: number;
|
||||||
|
variables: Record<string, any>;
|
||||||
|
startedAt: number;
|
||||||
|
lastActiveAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Tool Registry ──
|
||||||
|
|
||||||
|
export type ToolHandler = (
|
||||||
|
inputs: Record<string, any>,
|
||||||
|
context: ToolContext,
|
||||||
|
) => Promise<any>;
|
||||||
|
|
||||||
|
export type ToolContext = {
|
||||||
|
phone: string;
|
||||||
|
session: FlowSession;
|
||||||
|
provider: import('../providers/messaging-provider.interface').MessagingProvider;
|
||||||
|
platform: import('../../platform/platform-graphql.service').PlatformGraphqlService;
|
||||||
|
auth: string;
|
||||||
|
};
|
||||||
56
src/messaging/flow/flow-variable.service.ts
Normal file
56
src/messaging/flow/flow-variable.service.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FlowVariableService {
|
||||||
|
// Replace {{variableName}} with values from session variables
|
||||||
|
interpolate(template: string, variables: Record<string, any>): string {
|
||||||
|
return template.replace(/\{\{([\w.]+)\}\}/g, (match, path) => {
|
||||||
|
// Support dot notation: {{bookingResult.appointmentId}}
|
||||||
|
const parts = path.split('.');
|
||||||
|
let value: any = variables;
|
||||||
|
for (const part of parts) {
|
||||||
|
value = value?.[part];
|
||||||
|
if (value === undefined) return match;
|
||||||
|
}
|
||||||
|
if (value === null) return match;
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value);
|
||||||
|
return String(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interpolate all string values in an object
|
||||||
|
interpolateObject(obj: Record<string, string>, variables: Record<string, any>): Record<string, any> {
|
||||||
|
const result: Record<string, any> = {};
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
result[key] = this.interpolate(value, variables);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute expressions for SetVariableBlock
|
||||||
|
evaluateExpression(expression: string, value: string, variables: Record<string, any>): any {
|
||||||
|
switch (expression) {
|
||||||
|
case 'extract_id': {
|
||||||
|
// Extract second segment: "doc:{uuid}:{name}" → uuid, "dept:{name}" → name
|
||||||
|
const parts = value.split(':');
|
||||||
|
return parts.length >= 2 ? parts[1] : value;
|
||||||
|
}
|
||||||
|
case 'extract_datetime': {
|
||||||
|
// Extract datetime from "slot:{doctorId}:{datetime}" → "2026-04-21T14:00:00"
|
||||||
|
const parts = value.split(':');
|
||||||
|
// Rejoin from index 2 onwards (datetime contains colons: 2026-04-21T14:00:00)
|
||||||
|
return parts.length >= 3 ? parts.slice(2).join(':') : value;
|
||||||
|
}
|
||||||
|
case 'date_tomorrow': {
|
||||||
|
const d = new Date(Date.now() + 86400000);
|
||||||
|
return d.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
case 'date_day_after': {
|
||||||
|
const d = new Date(Date.now() + 2 * 86400000);
|
||||||
|
return d.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return this.interpolate(value, variables);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
244
src/messaging/flow/tool-registry.ts
Normal file
244
src/messaging/flow/tool-registry.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
|
||||||
|
import { CallerResolutionService } from '../../caller/caller-resolution.service';
|
||||||
|
import { QrService } from '../qr.service';
|
||||||
|
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../../shared/doctor-utils';
|
||||||
|
import type { ToolHandler, ToolContext } from './flow-types';
|
||||||
|
import type { ListSection, InteractiveButton } from '../types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ToolRegistry {
|
||||||
|
private readonly logger = new Logger(ToolRegistry.name);
|
||||||
|
private readonly tools: Map<string, ToolHandler> = new Map();
|
||||||
|
|
||||||
|
private readonly sidecarUrl: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
private caller: CallerResolutionService,
|
||||||
|
private qr: QrService,
|
||||||
|
private config: ConfigService,
|
||||||
|
) {
|
||||||
|
this.sidecarUrl = config.get<string>('sidecarUrl') ?? '';
|
||||||
|
this.registerDefaults();
|
||||||
|
}
|
||||||
|
|
||||||
|
register(name: string, handler: ToolHandler) {
|
||||||
|
this.tools.set(name, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(name: string, inputs: Record<string, any>, context: ToolContext): Promise<any> {
|
||||||
|
const handler = this.tools.get(name);
|
||||||
|
if (!handler) {
|
||||||
|
this.logger.error(`[TOOL] Unknown tool: ${name}`);
|
||||||
|
return { error: `Unknown tool: ${name}` };
|
||||||
|
}
|
||||||
|
this.logger.log(`[TOOL] ${name} inputs=${JSON.stringify(inputs).substring(0, 200)}`);
|
||||||
|
const result = await handler(inputs, context);
|
||||||
|
this.logger.log(`[TOOL] ${name} result=${JSON.stringify(result).substring(0, 200)}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerDefaults() {
|
||||||
|
this.register('resolve_caller', async (inputs, ctx) => {
|
||||||
|
const phone = inputs.phone ?? ctx.phone;
|
||||||
|
const resolved = await this.caller.resolve(phone, ctx.auth).catch(() => null);
|
||||||
|
return resolved ?? { isNew: true, leadId: '', patientId: '', phone };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.register('send_department_list', async (_inputs, ctx) => {
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ doctors(first: 50) { edges { node { department } } } }`,
|
||||||
|
);
|
||||||
|
const departments = [...new Set(
|
||||||
|
data.doctors.edges.map((e: any) => e.node.department).filter(Boolean),
|
||||||
|
)] as string[];
|
||||||
|
|
||||||
|
if (!departments.length) return { sent: false, message: 'No departments available.' };
|
||||||
|
|
||||||
|
const sections: ListSection[] = [{
|
||||||
|
title: 'Departments',
|
||||||
|
rows: departments.slice(0, 10).map(d => ({
|
||||||
|
id: `dept:${d}`,
|
||||||
|
title: d.substring(0, 24),
|
||||||
|
})),
|
||||||
|
}];
|
||||||
|
await ctx.provider.sendList(ctx.phone, 'Which department would you like to visit?', 'View Departments', sections);
|
||||||
|
return { sent: true, departments };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.register('send_doctor_list', async (inputs, ctx) => {
|
||||||
|
const department = inputs.department;
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ doctors(first: 50) { edges { node {
|
||||||
|
id fullName { firstName lastName }
|
||||||
|
department specialty
|
||||||
|
consultationFeeNew { amountMicros currencyCode }
|
||||||
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||||
|
} } } }`,
|
||||||
|
);
|
||||||
|
const allDocs = normalizeDoctors(data.doctors.edges.map((e: any) => e.node));
|
||||||
|
const deptDocs = allDocs.filter((d: any) =>
|
||||||
|
d.department?.toLowerCase() === department.toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!deptDocs.length) return { sent: false, message: `No doctors found in ${department}.` };
|
||||||
|
|
||||||
|
const sections: ListSection[] = [{
|
||||||
|
title: department.substring(0, 24),
|
||||||
|
rows: deptDocs.slice(0, 10).map((d: any) => {
|
||||||
|
const docName = `Dr. ${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''}`.trim();
|
||||||
|
const fee = d.consultationFeeNew?.amountMicros
|
||||||
|
? `₹${(d.consultationFeeNew.amountMicros / 1000000).toFixed(0)}`
|
||||||
|
: '';
|
||||||
|
return {
|
||||||
|
id: `doc:${d.id}:${docName}`,
|
||||||
|
title: docName.substring(0, 24),
|
||||||
|
description: fee ? `${d.specialty ?? department} — ${fee}` : (d.specialty ?? department),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}];
|
||||||
|
await ctx.provider.sendList(ctx.phone, `Doctors in ${department}:`, 'View Doctors', sections);
|
||||||
|
return { sent: true, count: deptDocs.length };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.register('send_slot_list', async (inputs, ctx) => {
|
||||||
|
const { doctorId, doctorName, date } = inputs;
|
||||||
|
const targetDate = date ?? new Date(Date.now() + 86400000).toISOString().split('T')[0];
|
||||||
|
const dayNames = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'];
|
||||||
|
const targetDay = dayNames[new Date(targetDate + 'T00:00:00+05:30').getDay()];
|
||||||
|
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ doctors(first: 50) { edges { node {
|
||||||
|
id fullName { firstName lastName }
|
||||||
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||||
|
} } } }`,
|
||||||
|
);
|
||||||
|
const rawDocs = data.doctors.edges.map((e: any) => e.node);
|
||||||
|
const doctor = rawDocs.find((d: any) => d.id === doctorId);
|
||||||
|
if (!doctor) return { sent: false, message: 'Doctor not found.' };
|
||||||
|
|
||||||
|
const rawSlots = doctor.visitSlots?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
const daySlots = rawSlots.filter((s: any) => s.dayOfWeek === targetDay);
|
||||||
|
|
||||||
|
if (!daySlots.length) {
|
||||||
|
const dayLabel = targetDay.charAt(0) + targetDay.slice(1).toLowerCase();
|
||||||
|
return { sent: false, message: `${doctorName} is not available on ${dayLabel} (${targetDate}).` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeSlots: { time: string; clinic: string }[] = [];
|
||||||
|
for (const ds of daySlots) {
|
||||||
|
const startHour = parseInt(ds.startTime?.split(':')[0] ?? '9', 10);
|
||||||
|
const endHour = parseInt(ds.endTime?.split(':')[0] ?? '17', 10);
|
||||||
|
const clinicName = ds.clinic?.clinicName ?? '';
|
||||||
|
for (let h = startHour; h < endHour && timeSlots.length < 10; h++) {
|
||||||
|
timeSlots.push({ time: `${String(h).padStart(2, '0')}:00`, clinic: clinicName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!timeSlots.length) return { sent: false, message: `No slots for ${doctorName} on ${targetDate}.` };
|
||||||
|
|
||||||
|
const sections: ListSection[] = [{
|
||||||
|
title: targetDate,
|
||||||
|
rows: timeSlots.map(s => ({
|
||||||
|
id: `slot:${doctorId}:${targetDate}T${s.time}:00+05:30`,
|
||||||
|
title: s.time,
|
||||||
|
description: s.clinic || undefined,
|
||||||
|
})),
|
||||||
|
}];
|
||||||
|
await ctx.provider.sendList(ctx.phone, `Available slots for ${doctorName}:`, 'View Slots', sections);
|
||||||
|
return { sent: true, slots: timeSlots.length };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.register('send_confirm_buttons', async (inputs, ctx) => {
|
||||||
|
const buttons: InteractiveButton[] = [
|
||||||
|
{ id: 'confirm_booking', title: 'Confirm' },
|
||||||
|
{ id: 'cancel_booking', title: 'Cancel' },
|
||||||
|
];
|
||||||
|
await ctx.provider.sendButtons(ctx.phone, inputs.summary, buttons);
|
||||||
|
return { sent: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.register('book_appointment', async (inputs, ctx) => {
|
||||||
|
const { patientName, phoneNumber, department, doctorName, scheduledAt, reason } = inputs;
|
||||||
|
const cleanPhone = (phoneNumber ?? ctx.phone).replace(/[^0-9]/g, '').slice(-10);
|
||||||
|
|
||||||
|
// Conflict check
|
||||||
|
const bookingDate = scheduledAt.split('T')[0];
|
||||||
|
const existingAppts = await this.platform.query<any>(
|
||||||
|
`{ appointments(first: 50, filter: { doctorName: { eq: "${doctorName}" } }, orderBy: [{ scheduledAt: AscNullsLast }]) { edges { node { id scheduledAt status patientName } } } }`,
|
||||||
|
).catch(() => ({ appointments: { edges: [] } }));
|
||||||
|
|
||||||
|
const conflicts = existingAppts.appointments.edges
|
||||||
|
.map((e: any) => e.node)
|
||||||
|
.filter((a: any) => a.status === 'SCHEDULED' && a.scheduledAt?.startsWith(bookingDate));
|
||||||
|
|
||||||
|
const slotConflicts = conflicts.filter((a: any) => a.scheduledAt === scheduledAt);
|
||||||
|
if (slotConflicts.length >= 3) {
|
||||||
|
return { booked: false, message: `${doctorName} is fully booked at this time.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve caller — creates lead/patient if new
|
||||||
|
const resolved = await this.caller.resolve(cleanPhone, ctx.auth).catch(() => null);
|
||||||
|
let patientId = resolved?.patientId;
|
||||||
|
|
||||||
|
if (resolved?.isNew && patientName) {
|
||||||
|
const firstName = patientName.split(' ')[0];
|
||||||
|
const lastName = patientName.split(' ').slice(1).join(' ') || '';
|
||||||
|
try {
|
||||||
|
const p = await this.platform.query<any>(
|
||||||
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
|
{ data: { fullName: { firstName, lastName }, phones: { primaryPhoneNumber: `+91${cleanPhone}` }, patientType: 'NEW' } },
|
||||||
|
);
|
||||||
|
patientId = p?.createPatient?.id;
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
|
{ data: { name: `WhatsApp — ${patientName}`, contactName: { firstName, lastName }, contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` }, source: 'WHATSAPP', status: 'NEW', interestedService: department, ...(patientId ? { patientId } : {}) } },
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Book — include patientId so appointment is linked to patient record
|
||||||
|
const result = await this.platform.query<any>(
|
||||||
|
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
||||||
|
{ data: { name: `WhatsApp Booking — ${patientName} (${department})`, scheduledAt: scheduledAt.includes('+') || scheduledAt.includes('Z') ? scheduledAt : `${scheduledAt}+05:30`, status: 'SCHEDULED', doctorName, department, reasonForVisit: reason ?? 'General Consultation', ...(patientId ? { patientId } : {}) } },
|
||||||
|
);
|
||||||
|
const id = result?.createAppointment?.id;
|
||||||
|
if (id) {
|
||||||
|
return { booked: true, appointmentId: id, reference: id.substring(0, 8) };
|
||||||
|
}
|
||||||
|
return { booked: false, message: 'Booking failed.' };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.register('lookup_appointments', async (inputs, ctx) => {
|
||||||
|
const resolved = await this.caller.resolve(ctx.phone, ctx.auth).catch(() => null);
|
||||||
|
if (!resolved?.patientId) return { appointments: [], message: 'No patient record found.' };
|
||||||
|
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ appointments(first: 10, filter: { patientId: { eq: "${resolved.patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||||
|
id scheduledAt status doctorName department reasonForVisit
|
||||||
|
} } } }`,
|
||||||
|
);
|
||||||
|
return { appointments: data.appointments.edges.map((e: any) => e.node) };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.register('send_appointment_qr', async (inputs, ctx) => {
|
||||||
|
const { appointmentId, reference, patientName, doctorName, department, scheduledAt } = inputs;
|
||||||
|
if (!appointmentId) return { sent: false, message: 'No appointment ID.' };
|
||||||
|
|
||||||
|
await this.qr.generate(appointmentId, {
|
||||||
|
reference: reference ?? appointmentId.substring(0, 8),
|
||||||
|
patientName: patientName ?? '',
|
||||||
|
doctorName: doctorName ?? '',
|
||||||
|
department: department ?? '',
|
||||||
|
scheduledAt: scheduledAt ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const qrUrl = `${this.sidecarUrl}/api/messaging/qr/${appointmentId}`;
|
||||||
|
await ctx.provider.sendImage(ctx.phone, qrUrl, `Your appointment QR code — show this at the hospital reception desk.`);
|
||||||
|
this.logger.log(`[TOOL] send_appointment_qr: sent QR for ${reference ?? appointmentId}`);
|
||||||
|
return { sent: true, qrUrl };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/messaging/messaging-conversation.service.ts
Normal file
41
src/messaging/messaging-conversation.service.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { ConversationEntry } from './types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MessagingConversationService {
|
||||||
|
private readonly logger = new Logger(MessagingConversationService.name);
|
||||||
|
private readonly redis: Redis;
|
||||||
|
private readonly ttlSec = 24 * 60 * 60; // 24h — matches WhatsApp session window
|
||||||
|
private readonly maxHistory = 20;
|
||||||
|
|
||||||
|
constructor(config: ConfigService) {
|
||||||
|
const redisUrl = config.get<string>('redis.url') ?? 'redis://localhost:6379';
|
||||||
|
this.redis = new Redis(redisUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private key(phone: string): string {
|
||||||
|
return `wa:conv:${phone}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHistory(phone: string): Promise<ConversationEntry[]> {
|
||||||
|
const raw = await this.redis.get(this.key(phone));
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMessages(phone: string, entries: ConversationEntry[]): Promise<void> {
|
||||||
|
const existing = await this.getHistory(phone);
|
||||||
|
const updated = [...existing, ...entries].slice(-this.maxHistory);
|
||||||
|
await this.redis.setex(this.key(phone), this.ttlSec, JSON.stringify(updated));
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(phone: string): Promise<void> {
|
||||||
|
await this.redis.del(this.key(phone));
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/messaging/messaging.controller.ts
Normal file
52
src/messaging/messaging.controller.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Controller, Post, Get, Body, Param, Res, Logger } from '@nestjs/common';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import { MessagingProvider } from './providers/messaging-provider.interface';
|
||||||
|
import { MessagingService } from './messaging.service';
|
||||||
|
import { QrService } from './qr.service';
|
||||||
|
|
||||||
|
@Controller('api/messaging')
|
||||||
|
export class MessagingController {
|
||||||
|
private readonly logger = new Logger(MessagingController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly provider: MessagingProvider,
|
||||||
|
private readonly messaging: MessagingService,
|
||||||
|
private readonly qr: QrService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('webhook')
|
||||||
|
async webhook(@Body() body: any) {
|
||||||
|
this.logger.log(`[WA-WEBHOOK] Received: ${JSON.stringify(body).substring(0, 500)}`);
|
||||||
|
|
||||||
|
if (!this.provider.validateWebhook(body)) {
|
||||||
|
this.logger.warn('[WA-WEBHOOK] Validation failed — ignoring');
|
||||||
|
return { status: 'ignored', reason: 'validation failed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = this.provider.parseInbound(body);
|
||||||
|
if (!message) {
|
||||||
|
this.logger.log('[WA-WEBHOOK] Non-message event — skipped');
|
||||||
|
return { status: 'ok', type: body?.type ?? 'unknown' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle async — don't block webhook response
|
||||||
|
this.messaging.handleInbound(message).catch(err => {
|
||||||
|
this.logger.error(`[WA-WEBHOOK] handleInbound failed: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve QR code images — Gupshup needs a public URL to send images
|
||||||
|
@Get('qr/:appointmentId')
|
||||||
|
async serveQr(@Param('appointmentId') appointmentId: string, @Res() res: Response) {
|
||||||
|
const png = this.qr.get(appointmentId);
|
||||||
|
if (!png) {
|
||||||
|
res.status(404).json({ error: 'QR code not found or expired' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.set('Content-Type', 'image/png');
|
||||||
|
res.set('Cache-Control', 'public, max-age=86400');
|
||||||
|
res.send(png);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/messaging/messaging.module.ts
Normal file
38
src/messaging/messaging.module.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
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 {}
|
||||||
420
src/messaging/messaging.service.ts
Normal file
420
src/messaging/messaging.service.ts
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
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.' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
154
src/messaging/providers/gupshup.provider.ts
Normal file
154
src/messaging/providers/gupshup.provider.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/messaging/providers/messaging-provider.interface.ts
Normal file
21
src/messaging/providers/messaging-provider.interface.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
57
src/messaging/qr.service.ts
Normal file
57
src/messaging/qr.service.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/messaging/types.ts
Normal file
27
src/messaging/types.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
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 }[];
|
||||||
|
};
|
||||||
174
src/ozonetel/cdr-enrichment.service.ts
Normal file
174
src/ozonetel/cdr-enrichment.service.ts
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
127
src/ozonetel/ozonetel-admin-auth.service.ts
Normal file
127
src/ozonetel/ozonetel-admin-auth.service.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,22 @@ import { Controller, Post, Get, Body, Query, Logger, HttpException } from '@nest
|
|||||||
import { OzonetelAgentService } from './ozonetel-agent.service';
|
import { OzonetelAgentService } from './ozonetel-agent.service';
|
||||||
import { MissedQueueService } from '../worklist/missed-queue.service';
|
import { MissedQueueService } from '../worklist/missed-queue.service';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { AgentLookupService } from '../platform/agent-lookup.service';
|
||||||
import { EventBusService } from '../events/event-bus.service';
|
import { EventBusService } from '../events/event-bus.service';
|
||||||
import { Topics } from '../events/event-types';
|
import { Topics } from '../events/event-types';
|
||||||
import { TelephonyConfigService } from '../config/telephony-config.service';
|
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')
|
@Controller('api/ozonetel')
|
||||||
export class OzonetelAgentController {
|
export class OzonetelAgentController {
|
||||||
@@ -16,17 +29,14 @@ export class OzonetelAgentController {
|
|||||||
private readonly missedQueue: MissedQueueService,
|
private readonly missedQueue: MissedQueueService,
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly eventBus: EventBusService,
|
private readonly eventBus: EventBusService,
|
||||||
|
private readonly supervisor: SupervisorService,
|
||||||
|
private readonly agentLookup: AgentLookupService,
|
||||||
|
private readonly agentHistory: AgentHistoryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
// Read-through accessors so admin updates take effect immediately.
|
private requireAgentId(agentId: string | undefined | null): string {
|
||||||
private get defaultAgentId(): string {
|
if (!agentId) throw new HttpException('agentId required', 400);
|
||||||
return this.telephony.getConfig().ozonetel.agentId || 'agent3';
|
return agentId;
|
||||||
}
|
|
||||||
private get defaultAgentPassword(): string {
|
|
||||||
return this.telephony.getConfig().ozonetel.agentPassword;
|
|
||||||
}
|
|
||||||
private get defaultSipId(): string {
|
|
||||||
return this.telephony.getConfig().ozonetel.sipId || '521814';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('agent-login')
|
@Post('agent-login')
|
||||||
@@ -65,17 +75,18 @@ export class OzonetelAgentController {
|
|||||||
|
|
||||||
@Post('agent-state')
|
@Post('agent-state')
|
||||||
async agentState(
|
async agentState(
|
||||||
@Body() body: { state: 'Ready' | 'Pause'; pauseReason?: string },
|
@Body() body: { agentId: string; state: 'Ready' | 'Pause'; pauseReason?: string },
|
||||||
) {
|
) {
|
||||||
if (!body.state) {
|
if (!body.state) {
|
||||||
throw new HttpException('state required', 400);
|
throw new HttpException('state required', 400);
|
||||||
}
|
}
|
||||||
|
const agentId = this.requireAgentId(body.agentId);
|
||||||
|
|
||||||
this.logger.log(`[AGENT-STATE] ${this.defaultAgentId} → ${body.state} (${body.pauseReason ?? 'none'})`);
|
this.logger.log(`[AGENT-STATE] ${agentId} → ${body.state} (${body.pauseReason ?? 'none'})`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.ozonetelAgent.changeAgentState({
|
const result = await this.ozonetelAgent.changeAgentState({
|
||||||
agentId: this.defaultAgentId,
|
agentId,
|
||||||
state: body.state,
|
state: body.state,
|
||||||
pauseReason: body.pauseReason,
|
pauseReason: body.pauseReason,
|
||||||
});
|
});
|
||||||
@@ -84,7 +95,7 @@ export class OzonetelAgentController {
|
|||||||
// Auto-assign missed call when agent goes Ready
|
// Auto-assign missed call when agent goes Ready
|
||||||
if (body.state === 'Ready') {
|
if (body.state === 'Ready') {
|
||||||
try {
|
try {
|
||||||
const assigned = await this.missedQueue.assignNext(this.defaultAgentId);
|
const assigned = await this.missedQueue.assignNext(agentId);
|
||||||
if (assigned) {
|
if (assigned) {
|
||||||
this.logger.log(`[AGENT-STATE] Auto-assigned missed call ${assigned.id}`);
|
this.logger.log(`[AGENT-STATE] Auto-assigned missed call ${assigned.id}`);
|
||||||
return { ...result, assignedCall: assigned };
|
return { ...result, assignedCall: assigned };
|
||||||
@@ -110,10 +121,12 @@ export class OzonetelAgentController {
|
|||||||
@Body() body: {
|
@Body() body: {
|
||||||
ucid: string;
|
ucid: string;
|
||||||
disposition: string;
|
disposition: string;
|
||||||
|
agentId: string;
|
||||||
callerPhone?: string;
|
callerPhone?: string;
|
||||||
direction?: string;
|
direction?: string;
|
||||||
durationSec?: number;
|
durationSec?: number;
|
||||||
leadId?: string;
|
leadId?: string;
|
||||||
|
leadName?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
missedCallId?: string;
|
missedCallId?: string;
|
||||||
},
|
},
|
||||||
@@ -122,13 +135,17 @@ export class OzonetelAgentController {
|
|||||||
throw new HttpException('ucid and disposition required', 400);
|
throw new HttpException('ucid and disposition required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const agentId = this.requireAgentId(body.agentId);
|
||||||
const ozonetelDisposition = this.mapToOzonetelDisposition(body.disposition);
|
const ozonetelDisposition = this.mapToOzonetelDisposition(body.disposition);
|
||||||
|
|
||||||
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'}`);
|
// 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'}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.ozonetelAgent.setDisposition({
|
const result = await this.ozonetelAgent.setDisposition({
|
||||||
agentId: this.defaultAgentId,
|
agentId,
|
||||||
ucid: body.ucid,
|
ucid: body.ucid,
|
||||||
disposition: ozonetelDisposition,
|
disposition: ozonetelDisposition,
|
||||||
});
|
});
|
||||||
@@ -139,20 +156,123 @@ export class OzonetelAgentController {
|
|||||||
this.logger.error(`[DISPOSE] FAILED: ${message} ${responseData}`);
|
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
|
// Handle missed call callback status update
|
||||||
if (body.missedCallId) {
|
if (body.missedCallId) {
|
||||||
const statusMap: Record<string, string> = {
|
const statusMap: Record<string, string> = {
|
||||||
APPOINTMENT_BOOKED: 'CALLBACK_COMPLETED',
|
APPOINTMENT_BOOKED: 'CALLBACK_COMPLETED',
|
||||||
|
APPOINTMENT_RESCHEDULED: 'CALLBACK_COMPLETED',
|
||||||
|
APPOINTMENT_CANCELLED: 'CALLBACK_COMPLETED',
|
||||||
INFO_PROVIDED: 'CALLBACK_COMPLETED',
|
INFO_PROVIDED: 'CALLBACK_COMPLETED',
|
||||||
FOLLOW_UP_SCHEDULED: 'CALLBACK_COMPLETED',
|
FOLLOW_UP_SCHEDULED: 'CALLBACK_COMPLETED',
|
||||||
CALLBACK_REQUESTED: 'CALLBACK_COMPLETED',
|
CALLBACK_REQUESTED: 'CALLBACK_COMPLETED',
|
||||||
|
NOT_INTERESTED: 'CALLBACK_COMPLETED',
|
||||||
WRONG_NUMBER: 'WRONG_NUMBER',
|
WRONG_NUMBER: 'WRONG_NUMBER',
|
||||||
|
NO_ANSWER: 'CALLBACK_ATTEMPTED',
|
||||||
};
|
};
|
||||||
const newStatus = statusMap[body.disposition];
|
const newStatus = statusMap[body.disposition];
|
||||||
if (newStatus) {
|
if (newStatus) {
|
||||||
try {
|
try {
|
||||||
await this.platform.query<any>(
|
await this.platform.query<any>(
|
||||||
`mutation { updateCall(id: "${body.missedCallId}", data: { callbackstatus: ${newStatus} }) { id } }`,
|
`mutation { updateCall(id: "${body.missedCallId}", data: { callbackStatus: ${newStatus}, disposition: ${body.disposition} }) { id } }`,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to update missed call status: ${err}`);
|
this.logger.warn(`Failed to update missed call status: ${err}`);
|
||||||
@@ -160,9 +280,13 @@ 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
|
// Auto-assign next missed call to this agent
|
||||||
try {
|
try {
|
||||||
await this.missedQueue.assignNext(this.defaultAgentId);
|
await this.missedQueue.assignNext(agentId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Auto-assignment after dispose failed: ${err}`);
|
this.logger.warn(`Auto-assignment after dispose failed: ${err}`);
|
||||||
}
|
}
|
||||||
@@ -171,7 +295,7 @@ export class OzonetelAgentController {
|
|||||||
this.eventBus.emit(Topics.CALL_COMPLETED, {
|
this.eventBus.emit(Topics.CALL_COMPLETED, {
|
||||||
callId: null,
|
callId: null,
|
||||||
ucid: body.ucid,
|
ucid: body.ucid,
|
||||||
agentId: this.defaultAgentId,
|
agentId,
|
||||||
callerPhone: body.callerPhone ?? '',
|
callerPhone: body.callerPhone ?? '',
|
||||||
direction: body.direction ?? 'INBOUND',
|
direction: body.direction ?? 'INBOUND',
|
||||||
durationSec: body.durationSec ?? 0,
|
durationSec: body.durationSec ?? 0,
|
||||||
@@ -186,19 +310,27 @@ export class OzonetelAgentController {
|
|||||||
|
|
||||||
@Post('dial')
|
@Post('dial')
|
||||||
async dial(
|
async dial(
|
||||||
@Body() body: { phoneNumber: string; campaignName?: string; leadId?: string },
|
@Body() body: { phoneNumber: string; agentId: string; campaignName?: string; leadId?: string },
|
||||||
) {
|
) {
|
||||||
if (!body.phoneNumber) {
|
if (!body.phoneNumber) {
|
||||||
throw new HttpException('phoneNumber required', 400);
|
throw new HttpException('phoneNumber required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const campaignName = body.campaignName ?? this.telephony.getConfig().ozonetel.campaignName ?? 'Inbound_918041763265';
|
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}` : '');
|
||||||
|
|
||||||
this.logger.log(`[DIAL] phone=${body.phoneNumber} campaign=${campaignName} agentId=${this.defaultAgentId} lead=${body.leadId ?? 'none'}`);
|
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'}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.ozonetelAgent.manualDial({
|
const result = await this.ozonetelAgent.manualDial({
|
||||||
agentId: this.defaultAgentId,
|
agentId,
|
||||||
campaignName,
|
campaignName,
|
||||||
customerNumber: body.phoneNumber,
|
customerNumber: body.phoneNumber,
|
||||||
});
|
});
|
||||||
@@ -228,6 +360,13 @@ export class OzonetelAgentController {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.ozonetelAgent.callControl(body);
|
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;
|
return result;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message = error.response?.data?.message ?? error.message ?? 'Call control failed';
|
const message = error.response?.data?.message ?? error.message ?? 'Call control failed';
|
||||||
@@ -276,23 +415,56 @@ export class OzonetelAgentController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('performance')
|
@Get('performance')
|
||||||
async performance(@Query('date') date?: string) {
|
async performance(@Query('date') date?: string, @Query('agentId') agentId?: string) {
|
||||||
|
const agent = this.requireAgentId(agentId);
|
||||||
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
||||||
this.logger.log(`Performance: date=${targetDate} agent=${this.defaultAgentId}`);
|
this.logger.log(`Performance: date=${targetDate} agent=${agent}`);
|
||||||
|
|
||||||
const [cdr, summary, aht] = await Promise.all([
|
// 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([
|
||||||
this.ozonetelAgent.fetchCDR({ date: targetDate }),
|
this.ozonetelAgent.fetchCDR({ date: targetDate }),
|
||||||
this.ozonetelAgent.getAgentSummary(this.defaultAgentId, targetDate),
|
this.ozonetelAgent.getAgentSummary(agent, targetDate),
|
||||||
this.ozonetelAgent.getAHT(this.defaultAgentId),
|
this.ozonetelAgent.getAHT(agent),
|
||||||
|
this.fetchAgentSessionTimeBreakdown(agent, targetDate),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const totalCalls = cdr.length;
|
// Prefer our AgentSession rollup when present — it correctly counts
|
||||||
const inbound = cdr.filter((c: any) => c.Type === 'InBound').length;
|
// the current OPEN session (caps at now), while Ozonetel's summaryReport
|
||||||
const outbound = cdr.filter((c: any) => c.Type === 'Manual' || c.Type === 'Progressive').length;
|
// only tallies CLOSED login→logout pairs. Fall back to Ozonetel if
|
||||||
const answered = cdr.filter((c: any) => c.Status === 'Answered').length;
|
// our rollup hasn't captured this agent yet (e.g., brand-new agent,
|
||||||
const missed = cdr.filter((c: any) => c.Status === 'Unanswered' || c.Status === 'NotAnswered').length;
|
// workspace without AgentEvent entity synced).
|
||||||
|
const timeUtilization = agentSessionBreakdown ?? summary;
|
||||||
|
|
||||||
const talkTimes = cdr
|
// 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
|
||||||
.filter((c: any) => c.TalkTime && c.TalkTime !== '00:00:00')
|
.filter((c: any) => c.TalkTime && c.TalkTime !== '00:00:00')
|
||||||
.map((c: any) => {
|
.map((c: any) => {
|
||||||
const parts = c.TalkTime.split(':').map(Number);
|
const parts = c.TalkTime.split(':').map(Number);
|
||||||
@@ -303,12 +475,12 @@ export class OzonetelAgentController {
|
|||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
const dispositions: Record<string, number> = {};
|
const dispositions: Record<string, number> = {};
|
||||||
for (const c of cdr) {
|
for (const c of agentCdr) {
|
||||||
const d = (c as any).Disposition || 'No Disposition';
|
const d = (c as any).Disposition || 'No Disposition';
|
||||||
dispositions[d] = (dispositions[d] ?? 0) + 1;
|
dispositions[d] = (dispositions[d] ?? 0) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
const appointmentsBooked = cdr.filter((c: any) =>
|
const appointmentsBooked = agentCdr.filter((c: any) =>
|
||||||
c.Disposition?.toLowerCase().includes('appointment'),
|
c.Disposition?.toLowerCase().includes('appointment'),
|
||||||
).length;
|
).length;
|
||||||
|
|
||||||
@@ -319,7 +491,7 @@ export class OzonetelAgentController {
|
|||||||
avgHandlingTime: aht,
|
avgHandlingTime: aht,
|
||||||
conversionRate: totalCalls > 0 ? Math.round((appointmentsBooked / totalCalls) * 100) : 0,
|
conversionRate: totalCalls > 0 ? Math.round((appointmentsBooked / totalCalls) * 100) : 0,
|
||||||
appointmentsBooked,
|
appointmentsBooked,
|
||||||
timeUtilization: summary,
|
timeUtilization,
|
||||||
dispositions,
|
dispositions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -328,12 +500,63 @@ export class OzonetelAgentController {
|
|||||||
// Campaign only has 'General Enquiry' configured currently
|
// Campaign only has 'General Enquiry' configured currently
|
||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
'APPOINTMENT_BOOKED': 'General Enquiry',
|
'APPOINTMENT_BOOKED': 'General Enquiry',
|
||||||
|
'APPOINTMENT_RESCHEDULED': 'General Enquiry',
|
||||||
|
'APPOINTMENT_CANCELLED': 'General Enquiry',
|
||||||
'FOLLOW_UP_SCHEDULED': 'General Enquiry',
|
'FOLLOW_UP_SCHEDULED': 'General Enquiry',
|
||||||
'INFO_PROVIDED': 'General Enquiry',
|
'INFO_PROVIDED': 'General Enquiry',
|
||||||
'NO_ANSWER': 'General Enquiry',
|
'NO_ANSWER': 'General Enquiry',
|
||||||
'WRONG_NUMBER': 'General Enquiry',
|
'WRONG_NUMBER': 'General Enquiry',
|
||||||
|
'NOT_INTERESTED': 'General Enquiry',
|
||||||
'CALLBACK_REQUESTED': 'General Enquiry',
|
'CALLBACK_REQUESTED': 'General Enquiry',
|
||||||
};
|
};
|
||||||
return map[disposition] ?? '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,13 +2,15 @@ import { Module, forwardRef } from '@nestjs/common';
|
|||||||
import { OzonetelAgentController } from './ozonetel-agent.controller';
|
import { OzonetelAgentController } from './ozonetel-agent.controller';
|
||||||
import { OzonetelAgentService } from './ozonetel-agent.service';
|
import { OzonetelAgentService } from './ozonetel-agent.service';
|
||||||
import { KookooIvrController } from './kookoo-ivr.controller';
|
import { KookooIvrController } from './kookoo-ivr.controller';
|
||||||
|
import { CdrEnrichmentService } from './cdr-enrichment.service';
|
||||||
import { WorklistModule } from '../worklist/worklist.module';
|
import { WorklistModule } from '../worklist/worklist.module';
|
||||||
import { PlatformModule } from '../platform/platform.module';
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { SupervisorModule } from '../supervisor/supervisor.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, forwardRef(() => WorklistModule)],
|
imports: [PlatformModule, forwardRef(() => WorklistModule), forwardRef(() => SupervisorModule)],
|
||||||
controllers: [OzonetelAgentController, KookooIvrController],
|
controllers: [OzonetelAgentController, KookooIvrController],
|
||||||
providers: [OzonetelAgentService],
|
providers: [OzonetelAgentService, CdrEnrichmentService],
|
||||||
exports: [OzonetelAgentService],
|
exports: [OzonetelAgentService, CdrEnrichmentService],
|
||||||
})
|
})
|
||||||
export class OzonetelAgentModule {}
|
export class OzonetelAgentModule {}
|
||||||
|
|||||||
@@ -394,6 +394,48 @@ export class OzonetelAgentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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;
|
totalLoginDuration: string;
|
||||||
totalBusyTime: string;
|
totalBusyTime: string;
|
||||||
|
|||||||
70
src/platform/agent-lookup.service.ts
Normal file
70
src/platform/agent-lookup.service.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
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,8 +1,9 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PlatformGraphqlService } from './platform-graphql.service';
|
import { PlatformGraphqlService } from './platform-graphql.service';
|
||||||
|
import { AgentLookupService } from './agent-lookup.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
providers: [PlatformGraphqlService],
|
providers: [PlatformGraphqlService, AgentLookupService],
|
||||||
exports: [PlatformGraphqlService],
|
exports: [PlatformGraphqlService, AgentLookupService],
|
||||||
})
|
})
|
||||||
export class PlatformModule {}
|
export class PlatformModule {}
|
||||||
|
|||||||
@@ -1,12 +1,95 @@
|
|||||||
// src/rules-engine/actions/escalate.action.ts
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
|
||||||
import type { ActionHandler, ActionResult } from '../types/action.types';
|
import type { ActionHandler, ActionResult } from '../types/action.types';
|
||||||
import type { RuleAction } from '../types/rule.types';
|
import type { RuleAction, EscalateActionParams } 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 {
|
export class EscalateActionHandler implements ActionHandler {
|
||||||
type = 'escalate';
|
type = 'escalate';
|
||||||
|
private readonly logger = new Logger(EscalateActionHandler.name);
|
||||||
|
|
||||||
async execute(_action: RuleAction, _context: Record<string, any>): Promise<ActionResult> {
|
constructor(private readonly platform: PlatformGraphqlService) {}
|
||||||
return { success: true, data: { stub: true, action: 'escalate' } };
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
114
src/rules-engine/consumers/performance.consumer.ts
Normal file
114
src/rules-engine/consumers/performance.consumer.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
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.status': call.callStatus ?? null,
|
||||||
'call.disposition': call.disposition ?? null,
|
'call.disposition': call.disposition ?? null,
|
||||||
'call.durationSeconds': call.durationSeconds ?? call.durationSec ?? 0,
|
'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.slaElapsedPercent': slaElapsedPercent,
|
||||||
'call.slaBreached': slaElapsedPercent > 100,
|
'call.slaBreached': slaElapsedPercent > 100,
|
||||||
'call.missedCount': call.missedcallcount ?? call.missedCount ?? 0,
|
'call.missedCount': call.missedCallCount ?? call.missedCount ?? 0,
|
||||||
'call.taskType': taskType,
|
'call.taskType': taskType,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
93
src/rules-engine/facts/performance-facts.provider.ts
Normal file
93
src/rules-engine/facts/performance-facts.provider.ts
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,26 @@
|
|||||||
// src/rules-engine/rules-engine.module.ts
|
// src/rules-engine/rules-engine.module.ts
|
||||||
|
|
||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
import { RulesEngineController } from './rules-engine.controller';
|
import { RulesEngineController } from './rules-engine.controller';
|
||||||
import { RulesEngineService } from './rules-engine.service';
|
import { RulesEngineService } from './rules-engine.service';
|
||||||
import { RulesStorageService } from './rules-storage.service';
|
import { RulesStorageService } from './rules-storage.service';
|
||||||
import { WorklistConsumer } from './consumers/worklist.consumer';
|
import { WorklistConsumer } from './consumers/worklist.consumer';
|
||||||
|
import { PerformanceConsumer } from './consumers/performance.consumer';
|
||||||
|
import { EscalateActionHandler } from './actions/escalate.action';
|
||||||
|
import { PerformanceFactsProvider } from './facts/performance-facts.provider';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [PlatformModule],
|
||||||
controllers: [RulesEngineController],
|
controllers: [RulesEngineController],
|
||||||
providers: [RulesEngineService, RulesStorageService, WorklistConsumer],
|
providers: [
|
||||||
exports: [RulesEngineService, RulesStorageService, WorklistConsumer],
|
RulesEngineService,
|
||||||
|
RulesStorageService,
|
||||||
|
WorklistConsumer,
|
||||||
|
PerformanceConsumer,
|
||||||
|
EscalateActionHandler,
|
||||||
|
PerformanceFactsProvider,
|
||||||
|
],
|
||||||
|
exports: [RulesEngineService, RulesStorageService, WorklistConsumer, PerformanceConsumer],
|
||||||
})
|
})
|
||||||
export class RulesEngineModule {}
|
export class RulesEngineModule {}
|
||||||
|
|||||||
@@ -20,11 +20,14 @@ export class RulesEngineService {
|
|||||||
private readonly agentFacts = new AgentFactsProvider();
|
private readonly agentFacts = new AgentFactsProvider();
|
||||||
private readonly actionHandlers: Map<string, ActionHandler>;
|
private readonly actionHandlers: Map<string, ActionHandler>;
|
||||||
|
|
||||||
constructor(private readonly storage: RulesStorageService) {
|
constructor(
|
||||||
|
private readonly storage: RulesStorageService,
|
||||||
|
private readonly escalateHandler: EscalateActionHandler,
|
||||||
|
) {
|
||||||
this.actionHandlers = new Map([
|
this.actionHandlers = new Map([
|
||||||
['score', new ScoreActionHandler()],
|
['score', new ScoreActionHandler()],
|
||||||
['assign', new AssignActionHandler()],
|
['assign', new AssignActionHandler()],
|
||||||
['escalate', new EscalateActionHandler()],
|
['escalate', this.escalateHandler],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
152
src/rules-engine/suggestion-rules.ts
Normal file
152
src/rules-engine/suggestion-rules.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
export type SuggestionType = 'upsell' | 'crosssell' | 'retention' | 'operational';
|
||||||
|
export type SuggestionPriority = 'high' | 'medium' | 'low';
|
||||||
|
|
||||||
|
export type SuggestionTrigger = {
|
||||||
|
type: SuggestionType;
|
||||||
|
title: string;
|
||||||
|
reason: string;
|
||||||
|
priority: SuggestionPriority;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CallerFacts = {
|
||||||
|
isNew: boolean;
|
||||||
|
interestedService: string | null;
|
||||||
|
leadStatus: string | null;
|
||||||
|
contactAttempts: number;
|
||||||
|
appointments: Array<{ status: string; department: string; doctorName: string; scheduledAt: string }>;
|
||||||
|
calls: Array<{ direction: string; disposition: string | null; startedAt: string }>;
|
||||||
|
utmCampaign: string | null;
|
||||||
|
leadSource: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEPARTMENT_PACKAGES: Record<string, { package: string; description: string }> = {
|
||||||
|
CARDIOLOGY: { package: 'Cardiac Wellness Package', description: 'ECG, stress test, lipid panel' },
|
||||||
|
ORTHOPEDICS: { package: 'Joint Care Package', description: 'X-ray, physiotherapy assessment, bone density' },
|
||||||
|
GENERAL_MEDICINE: { package: 'Full Body Checkup', description: 'Complete health screening with blood work' },
|
||||||
|
NEUROLOGY: { package: 'Neuro Wellness Package', description: 'EEG, nerve conduction, cognitive assessment' },
|
||||||
|
GYNECOLOGY: { package: 'Women\'s Health Package', description: 'Pap smear, mammogram, hormone panel' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const CROSS_SELL_MAP: Record<string, { department: string; reason: string }> = {
|
||||||
|
ORTHOPEDICS: { department: 'Physiotherapy', reason: 'complement orthopedic treatment' },
|
||||||
|
CARDIOLOGY: { department: 'Dietician', reason: 'dietary guidance for heart health' },
|
||||||
|
GENERAL_MEDICINE: { department: 'Ophthalmology', reason: 'routine eye screening' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const evaluateSuggestionRules = (facts: CallerFacts): SuggestionTrigger[] => {
|
||||||
|
const triggers: SuggestionTrigger[] = [];
|
||||||
|
|
||||||
|
// Rule 1: Package upsell by department
|
||||||
|
for (const appt of facts.appointments) {
|
||||||
|
const dept = (appt.department ?? '').toUpperCase().replace(/\s+/g, '_');
|
||||||
|
const pkg = DEPARTMENT_PACKAGES[dept];
|
||||||
|
if (pkg && appt.status === 'SCHEDULED') {
|
||||||
|
triggers.push({
|
||||||
|
type: 'upsell',
|
||||||
|
title: pkg.package,
|
||||||
|
reason: `Patient has ${appt.department} appointment with ${appt.doctorName}, offer ${pkg.description}`,
|
||||||
|
priority: 'high',
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 2: Reschedule missed/cancelled appointments
|
||||||
|
const needsReschedule = facts.appointments.find(a =>
|
||||||
|
a.status === 'CANCELLED' || a.status === 'RESCHEDULED' || a.status === 'NO_SHOW'
|
||||||
|
);
|
||||||
|
if (needsReschedule) {
|
||||||
|
triggers.push({
|
||||||
|
type: 'retention',
|
||||||
|
title: 'Reschedule appointment',
|
||||||
|
reason: `Last ${needsReschedule.department} appointment was ${needsReschedule.status.toLowerCase()}, offer to rebook with ${needsReschedule.doctorName}`,
|
||||||
|
priority: 'medium',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 3: Cross-sell related department
|
||||||
|
for (const appt of facts.appointments) {
|
||||||
|
const dept = (appt.department ?? '').toUpperCase().replace(/\s+/g, '_');
|
||||||
|
const cross = CROSS_SELL_MAP[dept];
|
||||||
|
if (cross && appt.status === 'SCHEDULED') {
|
||||||
|
triggers.push({
|
||||||
|
type: 'crosssell',
|
||||||
|
title: `${cross.department} consultation`,
|
||||||
|
reason: `${cross.reason} — patient already seeing ${appt.department}`,
|
||||||
|
priority: 'low',
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 4: First-visit patient — health checkup
|
||||||
|
if (facts.isNew || facts.contactAttempts === 0) {
|
||||||
|
triggers.push({
|
||||||
|
type: 'upsell',
|
||||||
|
title: 'Welcome Health Checkup',
|
||||||
|
reason: 'First-time patient, offer introductory health screening package',
|
||||||
|
priority: 'medium',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rule 5: Returning patient with no recent appointment
|
||||||
|
if (!facts.isNew && facts.appointments.length === 0 && facts.contactAttempts > 2) {
|
||||||
|
triggers.push({
|
||||||
|
type: 'retention',
|
||||||
|
title: 'Re-engagement',
|
||||||
|
reason: `Returning patient with ${facts.contactAttempts} prior contacts but no active appointments`,
|
||||||
|
priority: 'high',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return triggers.slice(0, 4);
|
||||||
|
};
|
||||||
|
|
||||||
|
// For display in Settings > Automations (read-only cards)
|
||||||
|
export const SUGGESTION_RULE_DEFINITIONS = [
|
||||||
|
{
|
||||||
|
name: 'Package Upsell by Department',
|
||||||
|
category: 'upsell' as const,
|
||||||
|
description: 'Suggest department wellness package when patient has a scheduled appointment.',
|
||||||
|
trigger: 'On call connect',
|
||||||
|
condition: 'Scheduled appointment exists',
|
||||||
|
action: 'Suggest department package',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Reschedule Missed Appointment',
|
||||||
|
category: 'retention' as const,
|
||||||
|
description: 'Offer to rebook when patient has a cancelled or rescheduled appointment.',
|
||||||
|
trigger: 'On call connect',
|
||||||
|
condition: 'Cancelled/Rescheduled/No-show appointment exists',
|
||||||
|
action: 'Suggest rebooking',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Cross-sell Related Department',
|
||||||
|
category: 'crosssell' as const,
|
||||||
|
description: 'Suggest complementary department service based on current appointment.',
|
||||||
|
trigger: 'On call connect',
|
||||||
|
condition: 'Scheduled appointment in mapped department',
|
||||||
|
action: 'Suggest related service',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'First Visit Health Checkup',
|
||||||
|
category: 'upsell' as const,
|
||||||
|
description: 'Suggest introductory health screening for first-time patients.',
|
||||||
|
trigger: 'On call connect',
|
||||||
|
condition: 'New patient or zero contact attempts',
|
||||||
|
action: 'Suggest health checkup package',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Returning Patient Re-engagement',
|
||||||
|
category: 'retention' as const,
|
||||||
|
description: 'Prompt re-engagement for returning patients with no active appointments.',
|
||||||
|
trigger: 'On call connect',
|
||||||
|
condition: 'Returning patient, no appointments, 3+ contacts',
|
||||||
|
action: 'Suggest booking',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -84,6 +84,51 @@
|
|||||||
"trigger": { "type": "on_schedule", "interval": "5m" },
|
"trigger": { "type": "on_schedule", "interval": "5m" },
|
||||||
"conditions": { "all": [{ "fact": "call.slaBreached", "operator": "equal", "value": true }, { "fact": "call.callbackStatus", "operator": "equal", "value": "PENDING_CALLBACK" }] },
|
"conditions": { "all": [{ "fact": "call.slaBreached", "operator": "equal", "value": true }, { "fact": "call.callbackStatus", "operator": "equal", "value": "PENDING_CALLBACK" }] },
|
||||||
"action": { "type": "escalate", "params": { "channel": "notification", "recipients": "supervisor", "message": "SLA breached — no callback attempted", "severity": "critical" } }
|
"action": { "type": "escalate", "params": { "channel": "notification", "recipients": "supervisor", "message": "SLA breached — no callback attempted", "severity": "critical" } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ruleType": "automation",
|
||||||
|
"name": "Excessive idle time",
|
||||||
|
"description": "Agent has been idle for more than the configured threshold today",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 2,
|
||||||
|
"trigger": { "type": "on_schedule", "interval": "5m" },
|
||||||
|
"conditions": { "all": [{ "fact": "agent.idleMinutes", "operator": "greaterThan", "value": 60 }] },
|
||||||
|
"action": {
|
||||||
|
"type": "escalate",
|
||||||
|
"params": {
|
||||||
|
"channel": "notification",
|
||||||
|
"recipients": "supervisor",
|
||||||
|
"message": "Excessive Idle Time",
|
||||||
|
"severity": "warning",
|
||||||
|
"alertType": "EXCESSIVE_IDLE",
|
||||||
|
"valueFact": "agent.idleMinutes"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ruleType": "automation",
|
||||||
|
"name": "Low conversion rate",
|
||||||
|
"description": "Agent's conversion (booked/total) is below the workspace floor",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 3,
|
||||||
|
"trigger": { "type": "on_schedule", "interval": "5m" },
|
||||||
|
"conditions": {
|
||||||
|
"all": [
|
||||||
|
{ "fact": "agent.conversionPercent", "operator": "lessThan", "value": 15 },
|
||||||
|
{ "fact": "agent.totalCallsToday", "operator": "greaterThan", "value": 10 }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"action": {
|
||||||
|
"type": "escalate",
|
||||||
|
"params": {
|
||||||
|
"channel": "notification",
|
||||||
|
"recipients": "supervisor",
|
||||||
|
"message": "Low Conversion",
|
||||||
|
"severity": "warning",
|
||||||
|
"alertType": "LOW_CONVERSION",
|
||||||
|
"valueFact": "agent.conversionPercent"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
381
src/supervisor/agent-history.service.ts
Normal file
381
src/supervisor/agent-history.service.ts
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
|
||||||
|
// AgentEvent enum values (mirror of the SDK app's agent-event.object.ts).
|
||||||
|
// Ozonetel webhook actions → Helix event types.
|
||||||
|
export type AgentEventType =
|
||||||
|
| 'LOGIN'
|
||||||
|
| 'LOGOUT'
|
||||||
|
| 'READY'
|
||||||
|
| 'PAUSE'
|
||||||
|
| 'RESUME'
|
||||||
|
| 'CALL_START'
|
||||||
|
| 'CALL_END'
|
||||||
|
| 'ACW_START'
|
||||||
|
| 'ACW_END';
|
||||||
|
|
||||||
|
// Separate pending slots per event category. Call + ACW overlap (agent
|
||||||
|
// enters ACW before the CALL_END arrives), so a single shared slot would
|
||||||
|
// let ACW_START clobber pending CALL_START and produce 0-second call
|
||||||
|
// durations. Keep one slot per category so each END event pairs cleanly.
|
||||||
|
type PendingSlot = 'pause' | 'call' | 'acw';
|
||||||
|
type PendingStarts = {
|
||||||
|
pause?: number; // PAUSE eventAt ms
|
||||||
|
call?: number; // CALL_START eventAt ms
|
||||||
|
acw?: number; // ACW_START eventAt ms
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persists agent activity and per-call timing into the platform entities
|
||||||
|
* we added in Phase 1 (AgentEvent, Call SLA fields). Reads AgentSession
|
||||||
|
* later via the rollup job.
|
||||||
|
*
|
||||||
|
* Called from:
|
||||||
|
* - supervisor.service.handleAgentEvent → persistAgentEvent()
|
||||||
|
* - supervisor.service.handleCallEvent → patchCallTiming()
|
||||||
|
* - ozonetel-agent.controller dispose flow → patchCallTiming()
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AgentHistoryService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(AgentHistoryService.name);
|
||||||
|
|
||||||
|
// ozonetelAgentId → Agent entity UUID. Loaded at startup.
|
||||||
|
private readonly agentUuidByOzonetelId = new Map<string, string>();
|
||||||
|
|
||||||
|
// agentId → map of pending start events per category, used to compute
|
||||||
|
// durationSec on the matching END event.
|
||||||
|
private readonly pendingStartsByAgent = new Map<string, PendingStarts>();
|
||||||
|
|
||||||
|
constructor(private readonly platform: PlatformGraphqlService) {}
|
||||||
|
|
||||||
|
private rollupTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
await this.refreshAgentCache();
|
||||||
|
// Roll up today's sessions every 15 minutes. Rollup is idempotent
|
||||||
|
// (upsert by agent+date), so missing a tick is safe — the next tick
|
||||||
|
// recomputes from AgentEvent history. Written with setInterval because
|
||||||
|
// @nestjs/schedule isn't installed in this sidecar.
|
||||||
|
this.rollupTimer = setInterval(() => {
|
||||||
|
this.rollupSessions(this.currentSessionDate()).catch((err) => {
|
||||||
|
this.logger.warn(`[HISTORY] Rollup tick failed: ${err?.message ?? err}`);
|
||||||
|
});
|
||||||
|
}, 15 * 60 * 1000);
|
||||||
|
// Kick off one immediately so the dashboard has data on boot.
|
||||||
|
this.rollupSessions(this.currentSessionDate()).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleDestroy() {
|
||||||
|
if (this.rollupTimer) clearInterval(this.rollupTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// IST day boundary — agents work in IST, so the rollup is by IST date.
|
||||||
|
private currentSessionDate(): string {
|
||||||
|
const now = new Date();
|
||||||
|
const ist = new Date(now.getTime() + 5.5 * 60 * 60 * 1000);
|
||||||
|
return ist.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshAgentCache(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ agents(first: 50) { edges { node { id ozonetelAgentId } } } }`,
|
||||||
|
);
|
||||||
|
const edges = data?.agents?.edges ?? [];
|
||||||
|
this.agentUuidByOzonetelId.clear();
|
||||||
|
for (const edge of edges) {
|
||||||
|
const n = edge.node;
|
||||||
|
if (n.ozonetelAgentId) {
|
||||||
|
this.agentUuidByOzonetelId.set(n.ozonetelAgentId, n.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.logger.log(`[HISTORY] Loaded ${this.agentUuidByOzonetelId.size} agent UUIDs into cache`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[HISTORY] Failed to refresh agent cache: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveAgentUuid(ozonetelAgentId: string): Promise<string | null> {
|
||||||
|
if (!ozonetelAgentId) return null;
|
||||||
|
const cached = this.agentUuidByOzonetelId.get(ozonetelAgentId);
|
||||||
|
if (cached) return cached;
|
||||||
|
// Cache miss — refresh once (handles late-provisioned agents like Ganesh)
|
||||||
|
await this.refreshAgentCache();
|
||||||
|
return this.agentUuidByOzonetelId.get(ozonetelAgentId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record an agent activity event. Computes durationSec for END events
|
||||||
|
* (RESUME, CALL_END, ACW_END) by pairing against the most recent START.
|
||||||
|
* Non-fatal on failure — realtime SSE flow continues even if the
|
||||||
|
* platform write errors.
|
||||||
|
*/
|
||||||
|
async persistAgentEvent(params: {
|
||||||
|
ozonetelAgentId: string;
|
||||||
|
eventType: AgentEventType;
|
||||||
|
eventAt: string; // ISO
|
||||||
|
pauseReason?: string | null;
|
||||||
|
callId?: string | null;
|
||||||
|
}): Promise<void> {
|
||||||
|
const agentUuid = await this.resolveAgentUuid(params.ozonetelAgentId);
|
||||||
|
if (!agentUuid) {
|
||||||
|
this.logger.warn(`[HISTORY] No Agent entity for ozonetelAgentId=${params.ozonetelAgentId} — skipping event persist`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pair START → END events by category. CALL and ACW can overlap
|
||||||
|
// (agent enters ACW before CALL_END arrives), so each lives in its
|
||||||
|
// own slot. READY is a fallback close — supervisor.service already
|
||||||
|
// maps 'release'/'IDLE' to RESUME / ACW_END when it knows the prior
|
||||||
|
// state; READY only fires when that disambiguation failed, so it
|
||||||
|
// clears anything dangling.
|
||||||
|
let durationSec: number | null = null;
|
||||||
|
const endSlot = this.slotForEnd(params.eventType);
|
||||||
|
const startSlot = this.slotForStart(params.eventType);
|
||||||
|
const eventMs = new Date(params.eventAt).getTime();
|
||||||
|
|
||||||
|
if (endSlot) {
|
||||||
|
const pending = this.pendingStartsByAgent.get(params.ozonetelAgentId);
|
||||||
|
const at = pending?.[endSlot];
|
||||||
|
if (at !== undefined) {
|
||||||
|
durationSec = Math.max(0, Math.round((eventMs - at) / 1000));
|
||||||
|
delete pending![endSlot];
|
||||||
|
if (!pending!.pause && !pending!.call && !pending!.acw) {
|
||||||
|
this.pendingStartsByAgent.delete(params.ozonetelAgentId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (startSlot) {
|
||||||
|
const existing = this.pendingStartsByAgent.get(params.ozonetelAgentId) ?? {};
|
||||||
|
existing[startSlot] = eventMs;
|
||||||
|
this.pendingStartsByAgent.set(params.ozonetelAgentId, existing);
|
||||||
|
} else if (params.eventType === 'READY' || params.eventType === 'LOGOUT') {
|
||||||
|
// Defensive flush of any lingering slots on session boundaries.
|
||||||
|
this.pendingStartsByAgent.delete(params.ozonetelAgentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: Record<string, any> = {
|
||||||
|
name: `${params.ozonetelAgentId} ${params.eventType}`,
|
||||||
|
eventType: params.eventType,
|
||||||
|
eventAt: params.eventAt,
|
||||||
|
source: 'OZONETEL_SUBSCRIPTION',
|
||||||
|
agentId: agentUuid,
|
||||||
|
};
|
||||||
|
if (params.pauseReason) data.pauseReason = params.pauseReason;
|
||||||
|
if (durationSec !== null) data.durationS = durationSec;
|
||||||
|
if (params.callId) data.callId = params.callId;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($data: AgentEventCreateInput!) { createAgentEvent(data: $data) { id } }`,
|
||||||
|
{ data },
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (this.isEntityMissingError(err)) {
|
||||||
|
if (!this.warnedEntityMissing) {
|
||||||
|
this.logger.warn('[HISTORY] AgentEvent entity not synced on this workspace — skipping persistence');
|
||||||
|
this.warnedEntityMissing = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logger.warn(`[HISTORY] createAgentEvent failed: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private warnedEntityMissing = false;
|
||||||
|
|
||||||
|
private isEntityMissingError(err: unknown): boolean {
|
||||||
|
const msg = String((err as any)?.message ?? err ?? '');
|
||||||
|
return msg.includes('Cannot query field') || msg.includes('Unknown type')
|
||||||
|
|| msg.includes('AgentEventCreateInput') || msg.includes('AgentSessionCreateInput');
|
||||||
|
}
|
||||||
|
|
||||||
|
private slotForStart(eventType: AgentEventType): PendingSlot | null {
|
||||||
|
if (eventType === 'PAUSE') return 'pause';
|
||||||
|
if (eventType === 'CALL_START') return 'call';
|
||||||
|
if (eventType === 'ACW_START') return 'acw';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private slotForEnd(eventType: AgentEventType): PendingSlot | null {
|
||||||
|
if (eventType === 'RESUME') return 'pause';
|
||||||
|
if (eventType === 'CALL_END') return 'call';
|
||||||
|
if (eventType === 'ACW_END') return 'acw';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patch a Call record with SLA / timing fields derived from Ozonetel
|
||||||
|
* webhooks or post-call CDR. All fields optional — caller passes only
|
||||||
|
* what it has. Used for response-time and ACW histograms on the
|
||||||
|
* supervisor dashboard.
|
||||||
|
*/
|
||||||
|
async patchCallTiming(callId: string, fields: {
|
||||||
|
assignedAt?: string;
|
||||||
|
answeredAt?: string;
|
||||||
|
responseTimeSec?: number;
|
||||||
|
handlingTimeSec?: number;
|
||||||
|
acwDurationSec?: number;
|
||||||
|
holdDurationSec?: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
// Platform truncates `*Sec` → `*S` on field names.
|
||||||
|
const fieldNameMap: Record<string, string> = {
|
||||||
|
responseTimeSec: 'responseTimeS',
|
||||||
|
handlingTimeSec: 'handlingTimeS',
|
||||||
|
acwDurationSec: 'acwDurationS',
|
||||||
|
holdDurationSec: 'holdDurationS',
|
||||||
|
};
|
||||||
|
const data: Record<string, any> = {};
|
||||||
|
for (const [k, v] of Object.entries(fields)) {
|
||||||
|
if (v !== undefined && v !== null) {
|
||||||
|
data[fieldNameMap[k] ?? k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(data).length === 0) return;
|
||||||
|
try {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: callId, data },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[HISTORY] updateCall timing failed (${callId}): ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Aggregate AgentEvent rows into an AgentSession row per agent for the
|
||||||
|
* given IST date. Called on a 15-minute interval; upserts by (agent,
|
||||||
|
* sessionDate) so re-runs are safe.
|
||||||
|
*/
|
||||||
|
async rollupSessions(sessionDate: string): Promise<void> {
|
||||||
|
if (this.agentUuidByOzonetelId.size === 0) await this.refreshAgentCache();
|
||||||
|
const agentUuids = Array.from(new Set(this.agentUuidByOzonetelId.values()));
|
||||||
|
if (agentUuids.length === 0) return;
|
||||||
|
|
||||||
|
const startIso = `${sessionDate}T00:00:00+05:30`;
|
||||||
|
const endIso = `${sessionDate}T23:59:59+05:30`;
|
||||||
|
|
||||||
|
let succeeded = 0;
|
||||||
|
for (const agentUuid of agentUuids) {
|
||||||
|
try {
|
||||||
|
const events = await this.fetchAgentEvents(agentUuid, startIso, endIso);
|
||||||
|
const totals = this.aggregateEvents(events);
|
||||||
|
await this.upsertSession(agentUuid, sessionDate, totals);
|
||||||
|
succeeded++;
|
||||||
|
} catch (err: any) {
|
||||||
|
if (this.isEntityMissingError(err)) {
|
||||||
|
if (!this.warnedEntityMissing) {
|
||||||
|
this.logger.warn('[HISTORY] AgentEvent/AgentSession entities not synced on this workspace — skipping rollup');
|
||||||
|
this.warnedEntityMissing = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logger.warn(`[HISTORY] Rollup failed for agent ${agentUuid}: ${err?.message ?? err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.logger.log(`[HISTORY] Rollup complete for ${sessionDate} — ${succeeded}/${agentUuids.length} agents`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platform strips the `Sec` suffix on numeric field names — schema uses
|
||||||
|
// `durationS`, `loginDurationS`, etc. Map back to our canonical names
|
||||||
|
// when reading.
|
||||||
|
private async fetchAgentEvents(agentUuid: string, startIso: string, endIso: string): Promise<Array<{ eventType: AgentEventType; durationSec: number | null; eventAt: string }>> {
|
||||||
|
const events: Array<{ eventType: AgentEventType; durationSec: number | null; eventAt: string }> = [];
|
||||||
|
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>(
|
||||||
|
`{ agentEvents(first: 200${cursorArg}, filter: { agentId: { eq: "${agentUuid}" }, eventAt: { gte: "${startIso}", lte: "${endIso}" } }, orderBy: [{ eventAt: AscNullsLast }]) {
|
||||||
|
edges { node { eventType eventAt durationS } }
|
||||||
|
pageInfo { hasNextPage endCursor }
|
||||||
|
} }`,
|
||||||
|
);
|
||||||
|
const edges = data?.agentEvents?.edges ?? [];
|
||||||
|
for (const e of edges) {
|
||||||
|
events.push({
|
||||||
|
eventType: e.node.eventType,
|
||||||
|
eventAt: e.node.eventAt,
|
||||||
|
durationSec: e.node.durationS ?? null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const pageInfo: { hasNextPage?: boolean; endCursor?: string } = data?.agentEvents?.pageInfo ?? {};
|
||||||
|
if (!pageInfo.hasNextPage) break;
|
||||||
|
after = pageInfo.endCursor ?? null;
|
||||||
|
}
|
||||||
|
return events;
|
||||||
|
}
|
||||||
|
|
||||||
|
private aggregateEvents(events: Array<{ eventType: AgentEventType; durationSec: number | null; eventAt: string }>) {
|
||||||
|
let busyTimeSec = 0;
|
||||||
|
let pauseTimeSec = 0;
|
||||||
|
let wrapupTimeSec = 0;
|
||||||
|
let handlingSum = 0;
|
||||||
|
let handlingCount = 0;
|
||||||
|
|
||||||
|
// Login duration: sum each LOGIN → (next LOGOUT on same day | now) span.
|
||||||
|
// Ozonetel doesn't emit a LOGOUT if the agent just closes the tab, so
|
||||||
|
// cap open sessions at the end of the rollup day.
|
||||||
|
let loginDurationSec = 0;
|
||||||
|
let openLoginAt: number | null = null;
|
||||||
|
|
||||||
|
for (const e of events) {
|
||||||
|
if (e.eventType === 'LOGIN') {
|
||||||
|
openLoginAt = new Date(e.eventAt).getTime();
|
||||||
|
} else if (e.eventType === 'LOGOUT' && openLoginAt !== null) {
|
||||||
|
loginDurationSec += Math.max(0, Math.round((new Date(e.eventAt).getTime() - openLoginAt) / 1000));
|
||||||
|
openLoginAt = null;
|
||||||
|
} else if (e.eventType === 'CALL_END' && e.durationSec) {
|
||||||
|
busyTimeSec += e.durationSec;
|
||||||
|
handlingSum += e.durationSec;
|
||||||
|
handlingCount++;
|
||||||
|
} else if (e.eventType === 'RESUME' && e.durationSec) {
|
||||||
|
pauseTimeSec += e.durationSec;
|
||||||
|
} else if (e.eventType === 'ACW_END' && e.durationSec) {
|
||||||
|
wrapupTimeSec += e.durationSec;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (openLoginAt !== null) {
|
||||||
|
// Still logged in — count up to now (capped to the rollup day end).
|
||||||
|
loginDurationSec += Math.max(0, Math.round((Date.now() - openLoginAt) / 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
const avgHandlingTimeSec = handlingCount > 0 ? Math.round(handlingSum / handlingCount) : null;
|
||||||
|
const idleTimeSec = Math.max(0, loginDurationSec - busyTimeSec - pauseTimeSec - wrapupTimeSec);
|
||||||
|
|
||||||
|
return { loginDurationSec, busyTimeSec, pauseTimeSec, wrapupTimeSec, idleTimeSec, avgHandlingTimeSec };
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentSession fields map: our `*Sec` → platform `*S`, `sessionDate` → `date`.
|
||||||
|
private async upsertSession(
|
||||||
|
agentUuid: string,
|
||||||
|
sessionDate: string,
|
||||||
|
totals: { loginDurationSec: number; busyTimeSec: number; pauseTimeSec: number; wrapupTimeSec: number; idleTimeSec: number; avgHandlingTimeSec: number | null },
|
||||||
|
): Promise<void> {
|
||||||
|
const existing = await this.platform.query<any>(
|
||||||
|
`{ agentSessions(first: 1, filter: { agentId: { eq: "${agentUuid}" }, date: { eq: "${sessionDate}" } }) { edges { node { id } } } }`,
|
||||||
|
);
|
||||||
|
const existingId = existing?.agentSessions?.edges?.[0]?.node?.id;
|
||||||
|
|
||||||
|
const data: Record<string, any> = {
|
||||||
|
loginDurationS: totals.loginDurationSec,
|
||||||
|
busyTimeS: totals.busyTimeSec,
|
||||||
|
pauseTimeS: totals.pauseTimeSec,
|
||||||
|
wrapupTimeS: totals.wrapupTimeSec,
|
||||||
|
idleTimeS: totals.idleTimeSec,
|
||||||
|
source: 'COMPUTED',
|
||||||
|
lastSyncedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
if (totals.avgHandlingTimeSec !== null) data.avgHandlingTimeS = totals.avgHandlingTimeSec;
|
||||||
|
|
||||||
|
if (existingId) {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($id: UUID!, $data: AgentSessionUpdateInput!) { updateAgentSession(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: existingId, data },
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($data: AgentSessionCreateInput!) { createAgentSession(data: $data) { id } }`,
|
||||||
|
{ data: { ...data, name: `Session ${sessionDate}`, agentId: agentUuid, date: sessionDate } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
91
src/supervisor/performance-alerts.controller.ts
Normal file
91
src/supervisor/performance-alerts.controller.ts
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
import { Controller, Get, Post, Param, Logger } from '@nestjs/common';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read API for the supervisor notification bell. Returns active (non-
|
||||||
|
* dismissed) PerformanceAlert rows the rules engine has emitted.
|
||||||
|
*
|
||||||
|
* Frontend polls every 60s. Dismiss is per-alert.
|
||||||
|
*/
|
||||||
|
@Controller('api/supervisor/performance-alerts')
|
||||||
|
export class PerformanceAlertsController {
|
||||||
|
private readonly logger = new Logger(PerformanceAlertsController.name);
|
||||||
|
|
||||||
|
constructor(private readonly platform: PlatformGraphqlService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async list() {
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ performanceAlerts(
|
||||||
|
first: 50,
|
||||||
|
filter: { dismissedAt: { is: NULL } },
|
||||||
|
orderBy: [{ firedAt: DescNullsLast }]
|
||||||
|
) {
|
||||||
|
edges { node {
|
||||||
|
id alertType severity message value ruleId firedAt
|
||||||
|
agent { id name }
|
||||||
|
} }
|
||||||
|
} }`,
|
||||||
|
);
|
||||||
|
const edges = data?.performanceAlerts?.edges ?? [];
|
||||||
|
return {
|
||||||
|
alerts: edges.map((e: any) => {
|
||||||
|
const n = e.node;
|
||||||
|
return {
|
||||||
|
id: n.id,
|
||||||
|
agent: n.agent?.name ?? 'Unknown',
|
||||||
|
agentId: n.agent?.id ?? null,
|
||||||
|
type: this.toLabel(n.alertType),
|
||||||
|
severity: (n.severity ?? 'WARNING').toLowerCase(),
|
||||||
|
value: n.value ?? '',
|
||||||
|
message: n.message,
|
||||||
|
firedAt: n.firedAt,
|
||||||
|
ruleId: n.ruleId,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':id/dismiss')
|
||||||
|
async dismiss(@Param('id') id: string) {
|
||||||
|
try {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($id: UUID!, $data: PerformanceAlertUpdateInput!) { updatePerformanceAlert(id: $id, data: $data) { id } }`,
|
||||||
|
{ id, data: { dismissedAt: new Date().toISOString() } },
|
||||||
|
);
|
||||||
|
return { status: 'ok' };
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[ALERTS] Dismiss failed for ${id}: ${err?.message ?? err}`);
|
||||||
|
return { status: 'error', message: String(err?.message ?? err) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private toLabel(alertType: string | null | undefined): string {
|
||||||
|
switch (alertType) {
|
||||||
|
case 'EXCESSIVE_IDLE': return 'Excessive Idle Time';
|
||||||
|
case 'LOW_NPS': return 'Low NPS';
|
||||||
|
case 'LOW_CONVERSION': return 'Low Conversion';
|
||||||
|
default: return alertType ?? 'Alert';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('dismiss-all')
|
||||||
|
async dismissAll() {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ performanceAlerts(first: 100, filter: { dismissedAt: { is: NULL } }) { edges { node { id } } } }`,
|
||||||
|
);
|
||||||
|
const ids = (data?.performanceAlerts?.edges ?? []).map((e: any) => e.node.id);
|
||||||
|
let dismissed = 0;
|
||||||
|
for (const id of ids) {
|
||||||
|
try {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($id: UUID!, $data: PerformanceAlertUpdateInput!) { updatePerformanceAlert(id: $id, data: $data) { id } }`,
|
||||||
|
{ id, data: { dismissedAt: now } },
|
||||||
|
);
|
||||||
|
dismissed++;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
return { status: 'ok', dismissed };
|
||||||
|
}
|
||||||
|
}
|
||||||
163
src/supervisor/supervisor-barge.controller.ts
Normal file
163
src/supervisor/supervisor-barge.controller.ts
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import { Controller, Post, Get, Body, HttpException, Logger } from '@nestjs/common';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { OzonetelAdminAuthService } from '../ozonetel/ozonetel-admin-auth.service';
|
||||||
|
import { SupervisorService } from './supervisor.service';
|
||||||
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
|
|
||||||
|
// Supervisor barge/whisper/listen endpoints.
|
||||||
|
// Proxies requests to Ozonetel's dashboardApi using admin JWT auth.
|
||||||
|
//
|
||||||
|
// API reference (from CA-Admin source code):
|
||||||
|
// apiId 63 → CALL_BARGEIN (initiate barge)
|
||||||
|
// apiId 158 → Redis barge state (insert/delete)
|
||||||
|
// apiId 139 → SIP credential pool (sipSubscribe)
|
||||||
|
|
||||||
|
@Controller('api/supervisor/barge')
|
||||||
|
export class SupervisorBargeController {
|
||||||
|
private readonly logger = new Logger(SupervisorBargeController.name);
|
||||||
|
private readonly dashboardApiUrl = 'https://api.cloudagent.ozonetel.com/dashboardApi/monitor/api';
|
||||||
|
private readonly adminApiUrl = 'https://api.cloudagent.ozonetel.com/ca-admin-Api/CloudAgentAPI';
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly adminAuth: OzonetelAdminAuthService,
|
||||||
|
private readonly supervisor: SupervisorService,
|
||||||
|
private readonly telephony: TelephonyConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('sip-credentials')
|
||||||
|
async getSipCredentials() {
|
||||||
|
if (!this.adminAuth.isConfigured()) {
|
||||||
|
throw new HttpException('Ozonetel admin not configured — add credentials in Settings → Telephony', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
const config = this.telephony.getConfig();
|
||||||
|
const sipGateway = `${config.sip.domain}:${config.sip.wsPort}`;
|
||||||
|
const headers = await this.adminAuth.getAuthHeaders();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.post(`${this.adminApiUrl}/endpoint/sipnumber/sipSubscribe`, {
|
||||||
|
apiId: 139,
|
||||||
|
sipURL: sipGateway,
|
||||||
|
}, { headers });
|
||||||
|
|
||||||
|
const data = res.data;
|
||||||
|
this.logger.log(`[BARGE] SIP credentials response: ${JSON.stringify(data)}`);
|
||||||
|
|
||||||
|
if (!data?.sip_number) {
|
||||||
|
throw new HttpException('No SIP numbers available in pool', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sipNumber: data.sip_number,
|
||||||
|
sipPassword: data.password,
|
||||||
|
sipDomain: data.pop_location ?? config.sip.domain,
|
||||||
|
sipPort: config.sip.wsPort,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[BARGE] SIP credentials failed: ${err.message}`);
|
||||||
|
if (err instanceof HttpException) throw err;
|
||||||
|
throw new HttpException('Failed to fetch SIP credentials', 502);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async initiateBarge(@Body() body: { ucid: string; agentId: string; agentNumber: string; supervisorId?: string }) {
|
||||||
|
if (!body.ucid || !body.agentNumber) {
|
||||||
|
throw new HttpException('ucid and agentNumber required', 400);
|
||||||
|
}
|
||||||
|
if (!this.adminAuth.isConfigured()) {
|
||||||
|
throw new HttpException('Ozonetel admin not configured — add credentials in Settings → Telephony', 503);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prevent double-barge on same agent
|
||||||
|
const existing = this.supervisor.getBargeSession(body.agentId);
|
||||||
|
if (existing) {
|
||||||
|
throw new HttpException(`Agent ${body.agentId} is already being monitored`, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get SIP credentials from Ozonetel pool
|
||||||
|
const sipCreds = await this.getSipCredentials();
|
||||||
|
const headers = await this.adminAuth.getAuthHeaders();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.post(this.dashboardApiUrl, {
|
||||||
|
apiId: 63,
|
||||||
|
ucid: body.ucid,
|
||||||
|
action: 'CALL_BARGEIN',
|
||||||
|
isSip: true,
|
||||||
|
phoneno: sipCreds.sipNumber,
|
||||||
|
agentNumber: body.agentNumber,
|
||||||
|
cbURL: 'helix-engage',
|
||||||
|
}, { headers });
|
||||||
|
|
||||||
|
this.logger.log(`[BARGE] Initiated: ucid=${body.ucid} agent=${body.agentId} sip=${sipCreds.sipNumber} response=${JSON.stringify(res.data)}`);
|
||||||
|
|
||||||
|
// Track the session
|
||||||
|
this.supervisor.startBargeSession({
|
||||||
|
supervisorId: body.supervisorId ?? 'admin',
|
||||||
|
agentId: body.agentId,
|
||||||
|
sipNumber: sipCreds.sipNumber,
|
||||||
|
mode: 'listen',
|
||||||
|
startedAt: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
...sipCreds,
|
||||||
|
ozonetelResponse: res.data,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[BARGE] Initiation failed: ${err.message} ${err.response?.data ? JSON.stringify(err.response.data) : ''}`);
|
||||||
|
throw new HttpException(`Barge failed: ${err.response?.data?.Message ?? err.message}`, 502);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('mode')
|
||||||
|
async updateMode(@Body() body: { agentId: string; mode: 'listen' | 'whisper' | 'barge' }) {
|
||||||
|
if (!body.agentId || !body.mode) {
|
||||||
|
throw new HttpException('agentId and mode required', 400);
|
||||||
|
}
|
||||||
|
if (!['listen', 'whisper', 'barge'].includes(body.mode)) {
|
||||||
|
throw new HttpException('mode must be listen, whisper, or barge', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = this.supervisor.getBargeSession(body.agentId);
|
||||||
|
if (!session) {
|
||||||
|
throw new HttpException('No active barge session for this agent', 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.supervisor.updateBargeMode(body.agentId, body.mode);
|
||||||
|
return { status: 'ok', mode: body.mode };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('end')
|
||||||
|
async endBarge(@Body() body: { agentId: string }) {
|
||||||
|
if (!body.agentId) {
|
||||||
|
throw new HttpException('agentId required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = this.supervisor.getBargeSession(body.agentId);
|
||||||
|
if (!session) {
|
||||||
|
return { status: 'ok', message: 'No active session' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear Redis tracking on Ozonetel side (best-effort)
|
||||||
|
if (this.adminAuth.isConfigured()) {
|
||||||
|
try {
|
||||||
|
const headers = await this.adminAuth.getAuthHeaders();
|
||||||
|
await axios.post(this.dashboardApiUrl, {
|
||||||
|
apiId: 158,
|
||||||
|
Action: 'delete',
|
||||||
|
AgentId: body.agentId,
|
||||||
|
Sip: session.sipNumber,
|
||||||
|
}, { headers });
|
||||||
|
this.logger.log(`[BARGE] Redis cleanup: ${body.agentId}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[BARGE] Redis cleanup failed (non-critical): ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.supervisor.endBargeSession(body.agentId);
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Get, Post, Body, Query, Sse, Logger } from '@nestjs/common';
|
import { Controller, Get, Post, Body, Query, Sse, Logger } from '@nestjs/common';
|
||||||
import { Observable, filter, map } from 'rxjs';
|
import { Observable, filter, map } from 'rxjs';
|
||||||
import { SupervisorService } from './supervisor.service';
|
import { SupervisorService } from './supervisor.service';
|
||||||
|
import { LogStreamService } from '../logging/log-stream.service';
|
||||||
|
|
||||||
@Controller('api/supervisor')
|
@Controller('api/supervisor')
|
||||||
export class SupervisorController {
|
export class SupervisorController {
|
||||||
@@ -13,6 +14,16 @@ export class SupervisorController {
|
|||||||
return this.supervisor.getActiveCalls();
|
return this.supervisor.getActiveCalls();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Sse('active-calls/stream')
|
||||||
|
streamActiveCalls(): Observable<MessageEvent> {
|
||||||
|
this.logger.log('[SSE] Active calls stream opened');
|
||||||
|
return this.supervisor.activeCallSubject.pipe(
|
||||||
|
map(event => ({
|
||||||
|
data: JSON.stringify(event),
|
||||||
|
} as MessageEvent)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('team-performance')
|
@Get('team-performance')
|
||||||
async getTeamPerformance(@Query('date') date?: string) {
|
async getTeamPerformance(@Query('date') date?: string) {
|
||||||
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
||||||
@@ -52,4 +63,33 @@ export class SupervisorController {
|
|||||||
} as MessageEvent)),
|
} as MessageEvent)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Worklist SSE — broadcast to all connected agents. When a missed
|
||||||
|
// call is created by the webhook, this fires immediately so agents
|
||||||
|
// don't wait for the 30s worklist poll. The payload includes the
|
||||||
|
// caller's phone + name for a toast notification.
|
||||||
|
@Sse('worklist/stream')
|
||||||
|
streamWorklistUpdates(): Observable<MessageEvent> {
|
||||||
|
this.logger.log('[SSE] Worklist stream opened');
|
||||||
|
return this.supervisor.worklistSubject.pipe(
|
||||||
|
map(event => ({
|
||||||
|
data: JSON.stringify(event),
|
||||||
|
} as MessageEvent)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('logs/recent')
|
||||||
|
getRecentLogs(@Query('limit') limit?: string) {
|
||||||
|
return LogStreamService.instance.getRecentLogs(limit ? parseInt(limit, 10) : 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Sse('logs/stream')
|
||||||
|
streamLogs(): Observable<MessageEvent> {
|
||||||
|
this.logger.log('[SSE] Log stream opened');
|
||||||
|
return LogStreamService.instance.logSubject.pipe(
|
||||||
|
map(entry => ({
|
||||||
|
data: JSON.stringify(entry),
|
||||||
|
} as MessageEvent)),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { PlatformModule } from '../platform/platform.module';
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||||
import { SupervisorController } from './supervisor.controller';
|
import { SupervisorController } from './supervisor.controller';
|
||||||
|
import { SupervisorBargeController } from './supervisor-barge.controller';
|
||||||
|
import { PerformanceAlertsController } from './performance-alerts.controller';
|
||||||
import { SupervisorService } from './supervisor.service';
|
import { SupervisorService } from './supervisor.service';
|
||||||
|
import { AgentHistoryService } from './agent-history.service';
|
||||||
|
import { OzonetelAdminAuthService } from '../ozonetel/ozonetel-admin-auth.service';
|
||||||
|
|
||||||
|
// Note: TelephonyConfigService is available without import because
|
||||||
|
// ConfigThemeModule is @Global(). Do NOT import ConfigThemeModule here
|
||||||
|
// — it causes a circular dependency via AuthModule.
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, OzonetelAgentModule],
|
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule)],
|
||||||
controllers: [SupervisorController],
|
controllers: [SupervisorController, SupervisorBargeController, PerformanceAlertsController],
|
||||||
providers: [SupervisorService],
|
providers: [SupervisorService, AgentHistoryService, OzonetelAdminAuthService],
|
||||||
exports: [SupervisorService],
|
exports: [SupervisorService, AgentHistoryService, OzonetelAdminAuthService],
|
||||||
})
|
})
|
||||||
export class SupervisorModule {}
|
export class SupervisorModule {}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||||
|
import { AgentHistoryService, AgentEventType } from './agent-history.service';
|
||||||
|
|
||||||
type ActiveCall = {
|
type ActiveCall = {
|
||||||
ucid: string;
|
ucid: string;
|
||||||
@@ -20,61 +21,252 @@ type AgentStateEntry = {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ACW auto-dispose: if an agent has been in ACW for longer than this
|
||||||
|
// without the frontend calling /api/ozonetel/dispose, the server
|
||||||
|
// auto-disposes with a default disposition + autoRelease. This is the
|
||||||
|
// Layer 3 safety net — covers browser crash, tab close, page refresh
|
||||||
|
// where sendBeacon didn't fire, or any other frontend failure.
|
||||||
|
const ACW_TIMEOUT_MS = 30_000; // 30 seconds
|
||||||
|
const ACW_DEFAULT_DISPOSITION = 'General Enquiry';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SupervisorService implements OnModuleInit {
|
export class SupervisorService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(SupervisorService.name);
|
private readonly logger = new Logger(SupervisorService.name);
|
||||||
private readonly activeCalls = new Map<string, ActiveCall>();
|
private readonly activeCalls = new Map<string, ActiveCall>();
|
||||||
private readonly agentStates = new Map<string, AgentStateEntry>();
|
private readonly agentStates = new Map<string, AgentStateEntry>();
|
||||||
readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState; timestamp: string }>();
|
private readonly acwTimers = new Map<string, NodeJS.Timeout>();
|
||||||
|
// monitorUCID → agentUCID. Real-time events carry both; CDR webhook only has monitorUCID.
|
||||||
|
private readonly ucidMap = new Map<string, string>();
|
||||||
|
readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState | string; timestamp: string }>();
|
||||||
|
readonly activeCallSubject = new Subject<{ type: 'update' | 'remove'; call?: ActiveCall; ucid: string }>();
|
||||||
|
// Worklist update stream — emitted when a missed call is created or
|
||||||
|
// assigned. Frontend SSE listener triggers an immediate worklist
|
||||||
|
// refresh so agents see new missed calls without waiting for the 30s poll.
|
||||||
|
readonly worklistSubject = new Subject<{ type: string; callerPhone?: string; callerName?: string; callId?: string; timestamp: string }>();
|
||||||
|
|
||||||
|
emitWorklistUpdate(data: { type: string; callerPhone?: string; callerName?: string; callId?: string }) {
|
||||||
|
this.worklistSubject.next({ ...data, timestamp: new Date().toISOString() });
|
||||||
|
this.logger.log(`[WORKLIST-SSE] ${data.type} phone=${data.callerPhone ?? '?'} name=${data.callerName ?? '?'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Barge session tracking — key is agentId
|
||||||
|
private readonly bargeSessions = new Map<string, {
|
||||||
|
supervisorId: string;
|
||||||
|
agentId: string;
|
||||||
|
sipNumber: string;
|
||||||
|
mode: 'listen' | 'whisper' | 'barge';
|
||||||
|
startedAt: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private platform: PlatformGraphqlService,
|
private platform: PlatformGraphqlService,
|
||||||
private ozonetel: OzonetelAgentService,
|
private ozonetel: OzonetelAgentService,
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
|
private history: AgentHistoryService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async onModuleInit() {
|
async onModuleInit() {
|
||||||
this.logger.log('Supervisor service initialized');
|
this.logger.log('Supervisor service initialized');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Called by the dispose endpoint to cancel the ACW timer
|
||||||
|
// (agent submitted disposition before the timeout)
|
||||||
|
cancelAcwTimer(agentId: string) {
|
||||||
|
const timer = this.acwTimers.get(agentId);
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.acwTimers.delete(agentId);
|
||||||
|
this.logger.log(`[ACW-TIMER] Cancelled for ${agentId} (disposition received)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveAgentUcid(monitorUcid: string): string | null {
|
||||||
|
return this.ucidMap.get(monitorUcid) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
handleCallEvent(event: any) {
|
handleCallEvent(event: any) {
|
||||||
const action = event.action;
|
const action = event.action;
|
||||||
const ucid = event.ucid ?? event.monitorUCID;
|
const ucid = event.ucid ?? event.monitorUCID;
|
||||||
|
const monitorUcid = event.monitor_ucid ?? event.monitorUCID;
|
||||||
const agentId = event.agent_id ?? event.agentID;
|
const agentId = event.agent_id ?? event.agentID;
|
||||||
const callerNumber = event.caller_id ?? event.callerID;
|
const callerNumber = event.caller_id ?? event.callerID;
|
||||||
const callType = event.call_type ?? event.Type;
|
const callType = event.call_type ?? event.Type;
|
||||||
const eventTime = event.event_time ?? event.eventTime ?? new Date().toISOString();
|
const eventTime = event.event_time ?? event.eventTime ?? new Date().toISOString();
|
||||||
|
const iso = this.parseOzonetelTime(eventTime);
|
||||||
|
|
||||||
if (!ucid) return;
|
if (!ucid) return;
|
||||||
|
|
||||||
if (action === 'Answered' || action === 'Calling') {
|
if (monitorUcid && ucid !== monitorUcid) {
|
||||||
this.activeCalls.set(ucid, {
|
this.ucidMap.set(monitorUcid, ucid);
|
||||||
ucid, agentId, callerNumber,
|
this.logger.log(`[UCID-MAP] monitor=${monitorUcid} → agent=${ucid}`);
|
||||||
callType, startTime: eventTime, status: 'active',
|
setTimeout(() => this.ucidMap.delete(monitorUcid), 600_000);
|
||||||
});
|
|
||||||
this.logger.log(`Active call: ${agentId} ↔ ${callerNumber} (${ucid})`);
|
|
||||||
} else if (action === 'Disconnect') {
|
|
||||||
this.activeCalls.delete(ucid);
|
|
||||||
this.logger.log(`Call ended: ${ucid}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === 'Answered' || action === 'Calling') {
|
||||||
|
// Don't show calls for offline agents (ghost calls)
|
||||||
|
const agentState = this.agentStates.get(agentId);
|
||||||
|
if (agentState?.state === 'offline') {
|
||||||
|
this.logger.warn(`Ignoring call event for offline agent ${agentId} (${ucid})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const call: ActiveCall = { ucid, agentId, callerNumber, callType, startTime: eventTime, status: 'active' };
|
||||||
|
this.activeCalls.set(ucid, call);
|
||||||
|
this.activeCallSubject.next({ type: 'update', call, ucid });
|
||||||
|
this.logger.log(`Active call: ${agentId} ↔ ${callerNumber} (${ucid})`);
|
||||||
|
|
||||||
|
// Persist CALL_START as AgentEvent on the "Answered" moment
|
||||||
|
// (that's when busy-time actually begins). "Calling" is the
|
||||||
|
// ring — doesn't count as busy.
|
||||||
|
if (action === 'Answered' && agentId) {
|
||||||
|
this.history.persistAgentEvent({
|
||||||
|
ozonetelAgentId: agentId,
|
||||||
|
eventType: 'CALL_START',
|
||||||
|
eventAt: iso,
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
// Write answeredAt + responseTimeS to the Call record.
|
||||||
|
// Look up the Call by UCID, then patch. The "Calling" event
|
||||||
|
// sets assignedAt (ring start); "Answered" computes response
|
||||||
|
// time as answered - assigned (queue wait time).
|
||||||
|
this.patchCallTimingByUcid(ucid, {
|
||||||
|
answeredAt: iso,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// "Calling" = agent's phone is ringing → write assignedAt
|
||||||
|
// (the moment the call was routed to this agent).
|
||||||
|
if (action === 'Calling') {
|
||||||
|
this.patchCallTimingByUcid(ucid, {
|
||||||
|
assignedAt: iso,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
} else if (action === 'Disconnect') {
|
||||||
|
const wasActive = this.activeCalls.get(ucid);
|
||||||
|
this.activeCalls.delete(ucid);
|
||||||
|
this.activeCallSubject.next({ type: 'remove', ucid });
|
||||||
|
this.logger.log(`Call ended: ${ucid}`);
|
||||||
|
|
||||||
|
// Persist CALL_END — pair against the start for duration.
|
||||||
|
if (wasActive?.agentId) {
|
||||||
|
this.history.persistAgentEvent({
|
||||||
|
ozonetelAgentId: wasActive.agentId,
|
||||||
|
eventType: 'CALL_END',
|
||||||
|
eventAt: iso,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ozonetel sends timestamps in "YYYY-MM-DD HH:MM:SS" IST format. Normalise.
|
||||||
|
private parseOzonetelTime(raw: string): string {
|
||||||
|
if (!raw) return new Date().toISOString();
|
||||||
|
const asDate = new Date(raw);
|
||||||
|
if (!isNaN(asDate.getTime())) return asDate.toISOString();
|
||||||
|
return new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
handleAgentEvent(event: any) {
|
handleAgentEvent(event: any) {
|
||||||
const agentId = event.agentId ?? event.agent_id ?? 'unknown';
|
const agentId = event.agentId ?? event.agent_id ?? 'unknown';
|
||||||
const action = event.action ?? 'unknown';
|
const action = event.action ?? 'unknown';
|
||||||
const eventData = event.eventData ?? '';
|
const eventData = event.eventData ?? event.data ?? '';
|
||||||
|
const pauseReason = event.pauseReason ?? event.pause_reason ?? event.breakReason ?? '';
|
||||||
const eventTime = event.event_time ?? event.eventTime ?? new Date().toISOString();
|
const eventTime = event.event_time ?? event.eventTime ?? new Date().toISOString();
|
||||||
this.logger.log(`[AGENT-STATE] ${agentId} → ${action}${eventData ? ` (${eventData})` : ''} at ${eventTime}`);
|
this.logger.log(`[AGENT-STATE] ${agentId} → ${action} eventData="${eventData}" pauseReason="${pauseReason}" at ${eventTime}`);
|
||||||
|
this.logger.log(`[AGENT-STATE] Full event payload: ${JSON.stringify(event)}`);
|
||||||
|
|
||||||
|
const priorState = this.agentStates.get(agentId)?.state;
|
||||||
|
const mapped = this.mapOzonetelAction(action, eventData, pauseReason);
|
||||||
|
|
||||||
|
// Persist to AgentEvent table regardless of state mapping.
|
||||||
|
// login returns null for state (UI waits for release/ready) but
|
||||||
|
// the history pipeline needs LOGIN to compute loginDuration.
|
||||||
|
const historyEventType = this.mapToHistoryEventType(action, priorState);
|
||||||
|
if (historyEventType) {
|
||||||
|
const resolvedPauseReason = (pauseReason || eventData || '') || null;
|
||||||
|
this.logger.log(`[AGENT-HISTORY] ${agentId} action=${action} → eventType=${historyEventType} priorState=${priorState ?? 'none'} mapped=${mapped ?? 'null'}`);
|
||||||
|
this.history.persistAgentEvent({
|
||||||
|
ozonetelAgentId: agentId,
|
||||||
|
eventType: historyEventType,
|
||||||
|
eventAt: this.parseOzonetelTime(eventTime),
|
||||||
|
pauseReason: historyEventType === 'PAUSE' ? resolvedPauseReason : null,
|
||||||
|
}).catch((err) => {
|
||||||
|
this.logger.warn(`[AGENT-HISTORY] Failed to persist ${historyEventType} for ${agentId}: ${err?.message ?? err}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.logger.log(`[AGENT-HISTORY] ${agentId} action=${action} → no history event (priorState=${priorState ?? 'none'} mapped=${mapped ?? 'null'})`);
|
||||||
|
}
|
||||||
|
|
||||||
const mapped = this.mapOzonetelAction(action, eventData);
|
|
||||||
if (mapped) {
|
if (mapped) {
|
||||||
this.agentStates.set(agentId, { state: mapped, timestamp: eventTime });
|
this.agentStates.set(agentId, { state: mapped, timestamp: eventTime });
|
||||||
this.agentStateSubject.next({ agentId, state: mapped, timestamp: eventTime });
|
this.agentStateSubject.next({ agentId, state: mapped, timestamp: eventTime });
|
||||||
this.logger.log(`[AGENT-STATE] Emitted: ${agentId} → ${mapped}`);
|
this.logger.log(`[AGENT-STATE] ${agentId} ${priorState ?? 'none'} → ${mapped} (action=${action})`);
|
||||||
|
|
||||||
|
// Layer 3: ACW auto-dispose safety net
|
||||||
|
if (mapped === 'acw') {
|
||||||
|
// Find the most recent UCID for this agent
|
||||||
|
const lastCall = Array.from(this.activeCalls.values())
|
||||||
|
.filter(c => c.agentId === agentId)
|
||||||
|
.pop();
|
||||||
|
const ucid = lastCall?.ucid;
|
||||||
|
|
||||||
|
this.cancelAcwTimer(agentId); // clear any existing timer
|
||||||
|
const timer = setTimeout(async () => {
|
||||||
|
// Check if agent is STILL in ACW (they might have disposed by now)
|
||||||
|
const current = this.agentStates.get(agentId);
|
||||||
|
if (current?.state !== 'acw') {
|
||||||
|
this.logger.log(`[ACW-TIMER] ${agentId} no longer in ACW — skipping auto-dispose`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.logger.warn(`[ACW-TIMER] ${agentId} stuck in ACW for ${ACW_TIMEOUT_MS / 1000}s — auto-disposing${ucid ? ` (UCID ${ucid})` : ''}`);
|
||||||
|
try {
|
||||||
|
if (ucid) {
|
||||||
|
await this.ozonetel.setDisposition({ agentId, ucid, disposition: ACW_DEFAULT_DISPOSITION });
|
||||||
|
} else {
|
||||||
|
await this.ozonetel.changeAgentState({ agentId, state: 'Ready' });
|
||||||
|
}
|
||||||
|
this.logger.log(`[ACW-TIMER] Auto-dispose successful for ${agentId}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[ACW-TIMER] Auto-dispose failed for ${agentId}: ${err.message}`);
|
||||||
|
// Last resort: try force-ready
|
||||||
|
try {
|
||||||
|
await this.ozonetel.changeAgentState({ agentId, state: 'Ready' });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
this.acwTimers.delete(agentId);
|
||||||
|
}, ACW_TIMEOUT_MS);
|
||||||
|
this.acwTimers.set(agentId, timer);
|
||||||
|
this.logger.log(`[ACW-TIMER] Started ${ACW_TIMEOUT_MS / 1000}s timer for ${agentId}`);
|
||||||
|
} else if (mapped === 'ready' || mapped === 'offline') {
|
||||||
|
// Agent left ACW normally — cancel the timer
|
||||||
|
this.cancelAcwTimer(agentId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapOzonetelAction(action: string, eventData: string): AgentOzonetelState | null {
|
// Map the Ozonetel webhook action to our AgentEvent.eventType enum.
|
||||||
|
// 'release' means "agent is available again" — could be post-pause,
|
||||||
|
// post-ACW, or post-call. Use the previous agent state to emit the
|
||||||
|
// specific close-out event so session rollups can sum durations by
|
||||||
|
// category (pause vs wrapup vs busy) without extra metadata.
|
||||||
|
private mapToHistoryEventType(action: string, priorState: AgentOzonetelState | undefined): AgentEventType | null {
|
||||||
|
switch (action) {
|
||||||
|
case 'login': return 'LOGIN';
|
||||||
|
case 'logout': return 'LOGOUT';
|
||||||
|
case 'ACW': return 'ACW_START';
|
||||||
|
case 'pause':
|
||||||
|
case 'AUX':
|
||||||
|
return 'PAUSE';
|
||||||
|
case 'release':
|
||||||
|
case 'IDLE':
|
||||||
|
if (priorState === 'acw') return 'ACW_END';
|
||||||
|
if (priorState === 'break' || priorState === 'training') return 'RESUME';
|
||||||
|
return 'READY';
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapOzonetelAction(action: string, eventData: string, pauseReason?: string): AgentOzonetelState | null {
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'release': return 'ready';
|
case 'release': return 'ready';
|
||||||
case 'IDLE': return 'ready'; // agent available after unanswered/canceled call
|
case 'IDLE': return 'ready'; // agent available after unanswered/canceled call
|
||||||
@@ -82,11 +274,16 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
case 'incall': return 'in-call';
|
case 'incall': return 'in-call';
|
||||||
case 'ACW': return 'acw';
|
case 'ACW': return 'acw';
|
||||||
case 'logout': return 'offline';
|
case 'logout': return 'offline';
|
||||||
case 'AUX':
|
case 'pause': // Ozonetel sends 'pause' via webhook when agent is paused
|
||||||
|
case 'AUX': {
|
||||||
// "changeMode" is the brief AUX during login — not a real pause
|
// "changeMode" is the brief AUX during login — not a real pause
|
||||||
if (eventData === 'changeMode') return null;
|
if (eventData === 'changeMode') return null;
|
||||||
if (eventData?.toLowerCase().includes('training')) return 'training';
|
// Check pauseReason first (explicit field), then fall back to eventData
|
||||||
|
const reason = (pauseReason || eventData || '').toLowerCase();
|
||||||
|
this.logger.log(`[AGENT-STATE] Pause reason resolved: "${reason}"`);
|
||||||
|
if (reason.includes('training')) return 'training';
|
||||||
return 'break';
|
return 'break';
|
||||||
|
}
|
||||||
case 'login': return null; // wait for release
|
case 'login': return null; // wait for release
|
||||||
default: return null;
|
default: return null;
|
||||||
}
|
}
|
||||||
@@ -103,10 +300,110 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
this.agentStateSubject.next({ agentId, state: 'force-logout' as any, timestamp: new Date().toISOString() });
|
this.agentStateSubject.next({ agentId, state: 'force-logout' as any, timestamp: new Date().toISOString() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Max plausible call length before the entry is treated as orphaned.
|
||||||
|
// Real Ozonetel calls cap out far short of this — 30 minutes is a safe
|
||||||
|
// ceiling for a hospital call-center context. If a genuinely longer
|
||||||
|
// call existed, losing it from Live Monitor is preferable to the ghost
|
||||||
|
// state (supervisors lose trust in the dashboard otherwise).
|
||||||
|
private static readonly MAX_ACTIVE_CALL_AGE_MS = 30 * 60 * 1000;
|
||||||
|
|
||||||
|
// Agent states that are incompatible with having an active call. If the
|
||||||
|
// mapped agent is currently in one of these, the activeCalls entry is
|
||||||
|
// definitely stale (e.g. Disconnect webhook was dropped).
|
||||||
|
private static readonly NON_CALL_AGENT_STATES = new Set(['ready', 'offline', 'paused']);
|
||||||
|
|
||||||
|
updateCallStatus(ucid: string, status: 'active' | 'on-hold') {
|
||||||
|
const call = this.activeCalls.get(ucid);
|
||||||
|
if (!call) {
|
||||||
|
this.logger.warn(`[CALL-STATUS] No active call found for UCID ${ucid}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
call.status = status;
|
||||||
|
this.activeCallSubject.next({ type: 'update', call, ucid });
|
||||||
|
this.logger.log(`[CALL-STATUS] ${ucid} → ${status} (agent=${call.agentId})`);
|
||||||
|
}
|
||||||
|
|
||||||
getActiveCalls(): ActiveCall[] {
|
getActiveCalls(): ActiveCall[] {
|
||||||
|
// Sweep stale entries before returning. The activeCalls Map is a
|
||||||
|
// best-effort in-memory projection of Ozonetel call events — if
|
||||||
|
// Ozonetel drops a Disconnect (network blip, subscription hiccup,
|
||||||
|
// sidecar restart mid-call), the entry lingers forever and the
|
||||||
|
// Live Call Monitor shows a ghost call with a runaway timer.
|
||||||
|
//
|
||||||
|
// Two signals identify staleness:
|
||||||
|
// 1. The associated agent is not in a busy state (ready, offline,
|
||||||
|
// paused — they can't be on a call).
|
||||||
|
// 2. startTime is older than MAX_ACTIVE_CALL_AGE_MS (hard ceiling
|
||||||
|
// regardless of agent-state signal).
|
||||||
|
const now = Date.now();
|
||||||
|
const toDelete: string[] = [];
|
||||||
|
|
||||||
|
for (const [ucid, call] of this.activeCalls.entries()) {
|
||||||
|
const ageMs = now - new Date(call.startTime).getTime();
|
||||||
|
if (isNaN(ageMs)) continue;
|
||||||
|
|
||||||
|
if (ageMs > SupervisorService.MAX_ACTIVE_CALL_AGE_MS) {
|
||||||
|
toDelete.push(ucid);
|
||||||
|
this.logger.warn(`[ACTIVE-CALLS] Sweep: dropping ${ucid} (age ${Math.round(ageMs / 60000)}m, exceeds ${SupervisorService.MAX_ACTIVE_CALL_AGE_MS / 60000}m cap)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agentState = this.agentStates.get(call.agentId)?.state;
|
||||||
|
if (agentState && SupervisorService.NON_CALL_AGENT_STATES.has(agentState)) {
|
||||||
|
toDelete.push(ucid);
|
||||||
|
this.logger.warn(`[ACTIVE-CALLS] Sweep: dropping ${ucid} — agent ${call.agentId} is ${agentState}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const ucid of toDelete) this.activeCalls.delete(ucid);
|
||||||
|
|
||||||
return Array.from(this.activeCalls.values());
|
return Array.from(this.activeCalls.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Look up a Call by UCID and patch its timing fields. Used by
|
||||||
|
// handleCallEvent to write assignedAt/answeredAt in real-time.
|
||||||
|
// Also computes responseTimeS when answeredAt is written and
|
||||||
|
// the Call already has a startedAt.
|
||||||
|
private async patchCallTimingByUcid(ucid: string, fields: {
|
||||||
|
assignedAt?: string;
|
||||||
|
answeredAt?: string;
|
||||||
|
}): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ calls(first: 1, filter: { ucid: { eq: "${ucid}" } }) { edges { node { id startedAt assignedAt } } } }`,
|
||||||
|
);
|
||||||
|
const call = data?.calls?.edges?.[0]?.node;
|
||||||
|
if (!call) {
|
||||||
|
this.logger.warn(`[SLA] No Call for ucid=${ucid} — timing not written`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const patch: Record<string, any> = {};
|
||||||
|
if (fields.assignedAt) patch.assignedAt = fields.assignedAt;
|
||||||
|
if (fields.answeredAt) {
|
||||||
|
patch.answeredAt = fields.answeredAt;
|
||||||
|
// Compute response time: answered - started (how long the
|
||||||
|
// caller waited from call creation to agent pickup).
|
||||||
|
const start = call.startedAt ? new Date(call.startedAt).getTime() : null;
|
||||||
|
const answered = new Date(fields.answeredAt).getTime();
|
||||||
|
if (start && !isNaN(start) && !isNaN(answered)) {
|
||||||
|
const responseS = Math.max(0, Math.round((answered - start) / 1000));
|
||||||
|
patch.responseTimeS = responseS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(patch).length > 0) {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: call.id, data: patch },
|
||||||
|
);
|
||||||
|
this.logger.log(`[SLA] Patched call ${call.id} — ${Object.entries(patch).map(([k, v]) => `${k}=${v}`).join(' ')}`);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[SLA] patchCallTimingByUcid failed for ${ucid}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async getTeamPerformance(date: string): Promise<any> {
|
async getTeamPerformance(date: string): Promise<any> {
|
||||||
// Get all agents from platform. Field names are label-derived
|
// Get all agents from platform. Field names are label-derived
|
||||||
// camelCase on the current platform schema — see
|
// camelCase on the current platform schema — see
|
||||||
@@ -120,20 +417,152 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
);
|
);
|
||||||
const agents = agentData?.agents?.edges?.map((e: any) => e.node) ?? [];
|
const agents = agentData?.agents?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
|
||||||
// Fetch Ozonetel time summary per agent
|
// Fetch AgentSession rows for this date — the authoritative source
|
||||||
|
// for time breakdowns now that Phase 2 ingest is live. Keyed by
|
||||||
|
// agentId (UUID on platform) so we can match back by agent.id.
|
||||||
|
const sessionByAgentId = await this.fetchAgentSessionsByDate(date);
|
||||||
|
|
||||||
|
// Fetch CDR for the entire account for this date (one call, not per-agent)
|
||||||
|
let allCdr: any[] = [];
|
||||||
|
try {
|
||||||
|
allCdr = await this.ozonetel.fetchCDR({ date });
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to fetch CDR for ${date}: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge AgentSession → timeBreakdown (Ozonetel shape for UI compat);
|
||||||
|
// fall back to Ozonetel summary when no session row exists.
|
||||||
const summaries = await Promise.all(
|
const summaries = await Promise.all(
|
||||||
agents.map(async (agent: any) => {
|
agents.map(async (agent: any) => {
|
||||||
if (!agent.ozonetelAgentId) return { ...agent, timeBreakdown: null };
|
if (!agent.ozonetelAgentId) return { ...agent, timeBreakdown: null, calls: null };
|
||||||
try {
|
try {
|
||||||
const summary = await this.ozonetel.getAgentSummary(agent.ozonetelAgentId, date);
|
let timeBreakdown: any = null;
|
||||||
return { ...agent, timeBreakdown: summary };
|
let source: 'AGENT_SESSION' | 'OZONETEL_SUMMARY' | 'NONE' = 'NONE';
|
||||||
|
|
||||||
|
const session = sessionByAgentId.get(agent.id);
|
||||||
|
if (session) {
|
||||||
|
timeBreakdown = this.sessionToTimeBreakdown(session);
|
||||||
|
source = 'AGENT_SESSION';
|
||||||
|
} else {
|
||||||
|
timeBreakdown = await this.ozonetel.getAgentSummary(agent.ozonetelAgentId, date);
|
||||||
|
if (timeBreakdown) source = 'OZONETEL_SUMMARY';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter CDR to this agent
|
||||||
|
const agentCdr = allCdr.filter(
|
||||||
|
(c: any) => c.AgentID === agent.ozonetelAgentId || c.AgentName === agent.ozonetelAgentId,
|
||||||
|
);
|
||||||
|
const totalCalls = agentCdr.length;
|
||||||
|
const inbound = agentCdr.filter((c: any) => c.Type === 'InBound').length;
|
||||||
|
const outbound = agentCdr.filter((c: any) => c.Type === 'Manual' || c.Type === 'Progressive').length;
|
||||||
|
const answered = agentCdr.filter((c: any) => c.Status === 'Answered').length;
|
||||||
|
const missed = agentCdr.filter((c: any) => c.Status === 'NotAnswered').length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...agent,
|
||||||
|
timeBreakdown,
|
||||||
|
timeBreakdownSource: source,
|
||||||
|
calls: { total: totalCalls, inbound, outbound, answered, missed },
|
||||||
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to get summary for ${agent.ozonetelAgentId}: ${err}`);
|
this.logger.warn(`Failed to get summary for ${agent.ozonetelAgentId}: ${err}`);
|
||||||
return { ...agent, timeBreakdown: null };
|
return { ...agent, timeBreakdown: null, calls: null };
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
return { date, agents: summaries };
|
// Aggregate team totals
|
||||||
|
const teamTotals = {
|
||||||
|
totalCalls: summaries.reduce((sum, a) => sum + (a.calls?.total ?? 0), 0),
|
||||||
|
inbound: summaries.reduce((sum, a) => sum + (a.calls?.inbound ?? 0), 0),
|
||||||
|
outbound: summaries.reduce((sum, a) => sum + (a.calls?.outbound ?? 0), 0),
|
||||||
|
answered: summaries.reduce((sum, a) => sum + (a.calls?.answered ?? 0), 0),
|
||||||
|
missed: summaries.reduce((sum, a) => sum + (a.calls?.missed ?? 0), 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
return { date, agents: summaries, teamTotals };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull AgentSession rows for the given IST date, keyed by agent UUID so
|
||||||
|
// getTeamPerformance can look them up per-agent.
|
||||||
|
private async fetchAgentSessionsByDate(date: string): Promise<Map<string, any>> {
|
||||||
|
const map = new Map<string, any>();
|
||||||
|
try {
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ agentSessions(first: 100, filter: { date: { eq: "${date}" } }) {
|
||||||
|
edges { node {
|
||||||
|
agentId loginDurationS busyTimeS idleTimeS pauseTimeS
|
||||||
|
wrapupTimeS dialTimeS avgHandlingTimeS source lastSyncedAt
|
||||||
|
} }
|
||||||
|
} }`,
|
||||||
|
);
|
||||||
|
const edges = data?.agentSessions?.edges ?? [];
|
||||||
|
for (const e of edges) {
|
||||||
|
if (e.node?.agentId) map.set(e.node.agentId, e.node);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[PERF] Failed to fetch AgentSession rows for ${date}: ${err}`);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render AgentSession seconds in the HH:MM:SS shape the frontend expects
|
||||||
|
// (matches Ozonetel's summary so team-performance.tsx can parseTime() it
|
||||||
|
// without changing the page code).
|
||||||
|
private sessionToTimeBreakdown(session: any): any {
|
||||||
|
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}:${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}`;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
totalLoginTime: hms(session.loginDurationS),
|
||||||
|
totalBusyTime: hms(session.busyTimeS),
|
||||||
|
totalIdleTime: hms(session.idleTimeS),
|
||||||
|
totalPauseTime: hms(session.pauseTimeS),
|
||||||
|
totalWrapupTime: hms(session.wrapupTimeS),
|
||||||
|
totalDialTime: hms(session.dialTimeS),
|
||||||
|
avgHandlingTime: hms(session.avgHandlingTimeS),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Barge session management ---
|
||||||
|
|
||||||
|
getBargeSession(agentId: string) {
|
||||||
|
return this.bargeSessions.get(agentId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
startBargeSession(session: { supervisorId: string; agentId: string; sipNumber: string; mode: 'listen' | 'whisper' | 'barge'; startedAt: string }) {
|
||||||
|
this.bargeSessions.set(session.agentId, session);
|
||||||
|
this.logger.log(`[BARGE] Started: ${session.supervisorId} → ${session.agentId} (${session.mode})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateBargeMode(agentId: string, mode: 'listen' | 'whisper' | 'barge') {
|
||||||
|
const session = this.bargeSessions.get(agentId);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
const previousMode = session.mode;
|
||||||
|
session.mode = mode;
|
||||||
|
|
||||||
|
// Emit SSE to agent — whisper/barge show indicator, listen is silent
|
||||||
|
if (mode === 'whisper' || mode === 'barge') {
|
||||||
|
this.agentStateSubject.next({ agentId, state: `supervisor-${mode}`, timestamp: new Date().toISOString() });
|
||||||
|
} else if (previousMode !== 'listen') {
|
||||||
|
// Switching back to listen from whisper/barge
|
||||||
|
this.agentStateSubject.next({ agentId, state: 'supervisor-left', timestamp: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[BARGE] Mode: ${agentId} → ${mode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
endBargeSession(agentId: string) {
|
||||||
|
const session = this.bargeSessions.get(agentId);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
this.bargeSessions.delete(agentId);
|
||||||
|
this.agentStateSubject.next({ agentId, state: 'supervisor-left', timestamp: new Date().toISOString() });
|
||||||
|
this.logger.log(`[BARGE] Ended: ${session.supervisorId} → ${agentId}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
114
src/telephony-registration.service.ts
Normal file
114
src/telephony-registration.service.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import axios from 'axios';
|
||||||
|
import { PlatformGraphqlService } from './platform/platform-graphql.service';
|
||||||
|
|
||||||
|
// On startup, registers this sidecar with the telephony dispatcher
|
||||||
|
// so Ozonetel events are routed to the correct sidecar by agentId.
|
||||||
|
//
|
||||||
|
// Flow:
|
||||||
|
// 1. Load agent list from platform (Agent entities in this workspace)
|
||||||
|
// 2. POST /api/supervisor/register to the dispatcher
|
||||||
|
// 3. Start heartbeat interval (every 30s)
|
||||||
|
// 4. On shutdown, DELETE /api/supervisor/register
|
||||||
|
|
||||||
|
const HEARTBEAT_INTERVAL_MS = 30_000;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class TelephonyRegistrationService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(TelephonyRegistrationService.name);
|
||||||
|
private heartbeatTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private config: ConfigService,
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private get dispatcherUrl(): string {
|
||||||
|
return this.config.get<string>('TELEPHONY_DISPATCHER_URL') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private get sidecarUrl(): string {
|
||||||
|
return this.config.get<string>('TELEPHONY_CALLBACK_URL') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private get workspace(): string {
|
||||||
|
return process.env.PLATFORM_WORKSPACE_SUBDOMAIN ?? 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
if (!this.dispatcherUrl || !this.sidecarUrl) {
|
||||||
|
this.logger.warn('TELEPHONY_DISPATCHER_URL or TELEPHONY_CALLBACK_URL not set — skipping telephony registration');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.register();
|
||||||
|
|
||||||
|
this.heartbeatTimer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
await axios.post(`${this.dispatcherUrl}/api/supervisor/heartbeat`, {
|
||||||
|
sidecarUrl: this.sidecarUrl,
|
||||||
|
}, { timeout: 5000 });
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`Heartbeat failed: ${err.message} — attempting re-registration`);
|
||||||
|
await this.register();
|
||||||
|
}
|
||||||
|
}, HEARTBEAT_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer);
|
||||||
|
|
||||||
|
if (this.dispatcherUrl && this.sidecarUrl) {
|
||||||
|
try {
|
||||||
|
await axios.delete(`${this.dispatcherUrl}/api/supervisor/register`, {
|
||||||
|
data: { sidecarUrl: this.sidecarUrl },
|
||||||
|
timeout: 5000,
|
||||||
|
});
|
||||||
|
this.logger.log('Deregistered from telephony dispatcher');
|
||||||
|
} catch {
|
||||||
|
// Best-effort — TTL will clean up anyway
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async register() {
|
||||||
|
try {
|
||||||
|
const agents = await this.loadAgentIds();
|
||||||
|
if (agents.length === 0) {
|
||||||
|
this.logger.warn('No agents found in workspace — skipping registration');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await axios.post(`${this.dispatcherUrl}/api/supervisor/register`, {
|
||||||
|
sidecarUrl: this.sidecarUrl,
|
||||||
|
workspace: this.workspace,
|
||||||
|
agents,
|
||||||
|
}, { timeout: 5000 });
|
||||||
|
|
||||||
|
this.logger.log(`Registered with telephony dispatcher: ${agents.length} agents (${agents.join(', ')})`);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`Registration failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async loadAgentIds(): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
const apiKey = this.config.get<string>('PLATFORM_API_KEY');
|
||||||
|
if (!apiKey) return [];
|
||||||
|
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ agents(first: 50) { edges { node { ozonetelAgentId } } } }`,
|
||||||
|
undefined,
|
||||||
|
`Bearer ${apiKey}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (data.agents?.edges ?? [])
|
||||||
|
.map((e: any) => e.node.ozonetelAgentId)
|
||||||
|
.filter((id: string) => id && id !== 'PENDING');
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`Failed to load agents from platform: ${err.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module, forwardRef } from '@nestjs/common';
|
||||||
import { WidgetController } from './widget.controller';
|
import { WidgetController } from './widget.controller';
|
||||||
import { WebhooksController } from './webhooks.controller';
|
import { WebhooksController } from './webhooks.controller';
|
||||||
import { WidgetService } from './widget.service';
|
import { WidgetService } from './widget.service';
|
||||||
@@ -6,12 +6,13 @@ import { WidgetChatService } from './widget-chat.service';
|
|||||||
import { PlatformModule } from '../platform/platform.module';
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { ConfigThemeModule } from '../config/config-theme.module';
|
import { ConfigThemeModule } from '../config/config-theme.module';
|
||||||
|
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
||||||
|
|
||||||
// WidgetKeysService lives in ConfigThemeModule now — injected here via the
|
// WidgetKeysService lives in ConfigThemeModule now — injected here via the
|
||||||
// module's exports. This module only owns the widget-facing API endpoints
|
// module's exports. This module only owns the widget-facing API endpoints
|
||||||
// (init / chat / book / lead) plus the NestJS guards that consume the keys.
|
// (init / chat / book / lead) plus the NestJS guards that consume the keys.
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, AuthModule, ConfigThemeModule],
|
imports: [PlatformModule, AuthModule, ConfigThemeModule, forwardRef(() => CallerResolutionModule)],
|
||||||
controllers: [WidgetController, WebhooksController],
|
controllers: [WidgetController, WebhooksController],
|
||||||
providers: [WidgetService, WidgetChatService],
|
providers: [WidgetService, WidgetChatService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import type { WidgetInitResponse, WidgetBookRequest, WidgetLeadRequest } from './widget.types';
|
import type { WidgetInitResponse, WidgetBookRequest, WidgetLeadRequest } from './widget.types';
|
||||||
import { ThemeService } from '../config/theme.service';
|
import { ThemeService } from '../config/theme.service';
|
||||||
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors, type NormalizedDoctor } from '../shared/doctor-utils';
|
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors, type NormalizedDoctor } from '../shared/doctor-utils';
|
||||||
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
|
|
||||||
// Dedup window: any lead created for this phone within the last 24h is
|
// Dedup window: any lead created for this phone within the last 24h is
|
||||||
// considered the same visitor's lead — chat + book + contact by the same
|
// considered the same visitor's lead — chat + book + contact by the same
|
||||||
@@ -25,6 +26,7 @@ export class WidgetService {
|
|||||||
private platform: PlatformGraphqlService,
|
private platform: PlatformGraphqlService,
|
||||||
private theme: ThemeService,
|
private theme: ThemeService,
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
|
private caller: CallerResolutionService,
|
||||||
) {
|
) {
|
||||||
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
}
|
}
|
||||||
@@ -37,8 +39,10 @@ export class WidgetService {
|
|||||||
return raw.replace(/[^0-9]/g, '').slice(-10);
|
return raw.replace(/[^0-9]/g, '').slice(-10);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shared lead dedup: finds a lead created in the last 24h for the same
|
// Shared lead dedup. Resolves via CallerResolutionService; when isNew
|
||||||
// phone, or creates a new one. Public so WidgetChatService can reuse it.
|
// (no prior Lead/Patient), we have a name here (widget form field),
|
||||||
|
// so we create both records inline. When an existing record is
|
||||||
|
// returned we update it with the latest channel + name.
|
||||||
async findOrCreateLeadByPhone(
|
async findOrCreateLeadByPhone(
|
||||||
name: string,
|
name: string,
|
||||||
rawPhone: string,
|
rawPhone: string,
|
||||||
@@ -47,35 +51,32 @@ export class WidgetService {
|
|||||||
const phone = this.normalizePhone(rawPhone);
|
const phone = this.normalizePhone(rawPhone);
|
||||||
if (!phone) throw new Error('Invalid phone number');
|
if (!phone) throw new Error('Invalid phone number');
|
||||||
|
|
||||||
const since = new Date(Date.now() - LEAD_DEDUP_WINDOW_MS).toISOString();
|
const resolved = await this.caller.resolve(phone, this.auth);
|
||||||
|
const firstName = name.split(' ')[0] || name || 'Unknown';
|
||||||
try {
|
|
||||||
const existing = await this.platform.queryWithAuth<any>(
|
|
||||||
`query($phone: String!, $since: DateTime!) {
|
|
||||||
leads(
|
|
||||||
first: 1,
|
|
||||||
filter: {
|
|
||||||
contactPhone: { primaryPhoneNumber: { like: $phone } },
|
|
||||||
createdAt: { gte: $since }
|
|
||||||
},
|
|
||||||
orderBy: [{ createdAt: DescNullsLast }]
|
|
||||||
) { edges { node { id createdAt } } }
|
|
||||||
}`,
|
|
||||||
{ phone: `%${phone}`, since },
|
|
||||||
this.auth,
|
|
||||||
);
|
|
||||||
const match = existing?.leads?.edges?.[0]?.node;
|
|
||||||
if (match?.id) {
|
|
||||||
this.logger.log(`Lead dedup: reusing ${match.id} for phone ${phone}`);
|
|
||||||
return match.id as string;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`Lead dedup lookup failed, falling through to create: ${err}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstName = name.split(' ')[0] || name;
|
|
||||||
const lastName = name.split(' ').slice(1).join(' ') || '';
|
const lastName = name.split(' ').slice(1).join(' ') || '';
|
||||||
|
|
||||||
|
if (resolved.isNew) {
|
||||||
|
// Net-new visitor — create Patient + Lead with the widget-
|
||||||
|
// collected name. Both records get the real name from the
|
||||||
|
// first moment they exist.
|
||||||
|
let patientId: string | undefined;
|
||||||
|
try {
|
||||||
|
const p = await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
name: `${firstName} ${lastName}`.trim() || 'Unknown',
|
||||||
|
fullName: { firstName, lastName },
|
||||||
|
phones: { primaryPhoneNumber: `+91${phone}` },
|
||||||
|
patientType: 'NEW',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
this.auth,
|
||||||
|
);
|
||||||
|
patientId = p?.createPatient?.id;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Widget patient create failed (${phone}): ${err}`);
|
||||||
|
}
|
||||||
const created = await this.platform.queryWithAuth<any>(
|
const created = await this.platform.queryWithAuth<any>(
|
||||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
{
|
{
|
||||||
@@ -86,14 +87,50 @@ export class WidgetService {
|
|||||||
source: opts.source ?? 'WEBSITE',
|
source: opts.source ?? 'WEBSITE',
|
||||||
status: opts.status ?? 'NEW',
|
status: opts.status ?? 'NEW',
|
||||||
interestedService: opts.interestedService ?? 'Website Enquiry',
|
interestedService: opts.interestedService ?? 'Website Enquiry',
|
||||||
|
...(patientId ? { patientId } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
this.auth,
|
this.auth,
|
||||||
);
|
);
|
||||||
const id = created?.createLead?.id;
|
const leadId = created?.createLead?.id;
|
||||||
if (!id) throw new Error('Lead creation returned no id');
|
if (!leadId) throw new Error('Lead creation returned no id');
|
||||||
this.logger.log(`Lead dedup: created ${id} for ${name} (${phone})`);
|
this.logger.log(`Widget lead created: ${leadId} (patient ${patientId ?? 'none'}) for ${name} (${phone})`);
|
||||||
return id as string;
|
return leadId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Existing Lead found — update with widget-supplied details.
|
||||||
|
const leadId = resolved.leadId;
|
||||||
|
try {
|
||||||
|
await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
id: leadId,
|
||||||
|
data: {
|
||||||
|
name,
|
||||||
|
contactName: { firstName, lastName },
|
||||||
|
source: opts.source ?? 'WEBSITE',
|
||||||
|
status: opts.status ?? 'NEW',
|
||||||
|
interestedService: opts.interestedService ?? 'Website Enquiry',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
this.auth,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Lead update after resolve failed (lead=${leadId}): ${err}`);
|
||||||
|
}
|
||||||
|
if (resolved.patientId) {
|
||||||
|
try {
|
||||||
|
await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($id: UUID!, $data: PatientUpdateInput!) { updatePatient(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: resolved.patientId, data: { fullName: { firstName, lastName } } },
|
||||||
|
this.auth,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Patient rename after resolve failed (patient=${resolved.patientId}): ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.logger.log(`Widget lead updated: ${leadId} (patient ${resolved.patientId}) for ${name} (${phone})`);
|
||||||
|
return leadId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upgrade a lead's status — used when an existing lead is promoted from
|
// Upgrade a lead's status — used when an existing lead is promoted from
|
||||||
@@ -183,6 +220,7 @@ export class WidgetService {
|
|||||||
const created = await this.platform.queryWithAuth<any>(
|
const created = await this.platform.queryWithAuth<any>(
|
||||||
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
{ data: {
|
{ data: {
|
||||||
|
name: req.patientName.trim() || 'Unknown',
|
||||||
fullName: { firstName, lastName },
|
fullName: { firstName, lastName },
|
||||||
phones: { primaryPhoneNumber: `+91${phone}` },
|
phones: { primaryPhoneNumber: `+91${phone}` },
|
||||||
patientType: 'NEW',
|
patientType: 'NEW',
|
||||||
@@ -196,7 +234,11 @@ export class WidgetService {
|
|||||||
const appt = await this.platform.queryWithAuth<any>(
|
const appt = await this.platform.queryWithAuth<any>(
|
||||||
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
||||||
{ data: {
|
{ data: {
|
||||||
scheduledAt: req.scheduledAt,
|
name: `${req.patientName.trim() || 'Patient'} — ${new Date(req.scheduledAt).toISOString().slice(0, 10)}`,
|
||||||
|
// Ensure IST offset — widget may send bare datetime without timezone
|
||||||
|
scheduledAt: req.scheduledAt.includes('+') || req.scheduledAt.includes('Z')
|
||||||
|
? req.scheduledAt
|
||||||
|
: `${req.scheduledAt}+05:30`,
|
||||||
durationMin: 30,
|
durationMin: 30,
|
||||||
appointmentType: 'CONSULTATION',
|
appointmentType: 'CONSULTATION',
|
||||||
status: 'SCHEDULED',
|
status: 'SCHEDULED',
|
||||||
@@ -204,6 +246,7 @@ export class WidgetService {
|
|||||||
department: req.departmentId,
|
department: req.departmentId,
|
||||||
reasonForVisit: req.chiefComplaint ?? '',
|
reasonForVisit: req.chiefComplaint ?? '',
|
||||||
patientId,
|
patientId,
|
||||||
|
...(req.clinicId ? { clinicId: req.clinicId } : {}),
|
||||||
} },
|
} },
|
||||||
this.auth,
|
this.auth,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type WidgetInitResponse = {
|
|||||||
export type WidgetBookRequest = {
|
export type WidgetBookRequest = {
|
||||||
departmentId: string;
|
departmentId: string;
|
||||||
doctorId: string;
|
doctorId: string;
|
||||||
|
clinicId?: string;
|
||||||
scheduledAt: string;
|
scheduledAt: string;
|
||||||
patientName: string;
|
patientName: string;
|
||||||
patientPhone: string;
|
patientPhone: string;
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { Controller, Post, Body, Headers, Logger } from '@nestjs/common';
|
import { Controller, Post, Body, Headers, Logger, Inject, forwardRef } from '@nestjs/common';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { AgentLookupService } from '../platform/agent-lookup.service';
|
||||||
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
|
import { SupervisorService } from '../supervisor/supervisor.service';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
// Ozonetel sends all timestamps in IST — convert to UTC for storage
|
// Ozonetel sends all timestamps in IST — convert to UTC for storage
|
||||||
@@ -20,6 +23,9 @@ export class MissedCallWebhookController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
|
private readonly caller: CallerResolutionService,
|
||||||
|
private readonly agentLookup: AgentLookupService,
|
||||||
|
@Inject(forwardRef(() => SupervisorService)) private readonly supervisor: SupervisorService,
|
||||||
) {
|
) {
|
||||||
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
}
|
}
|
||||||
@@ -44,7 +50,15 @@ export class MissedCallWebhookController {
|
|||||||
const duration = this.parseDuration(payload.CallDuration ?? '00:00:00');
|
const duration = this.parseDuration(payload.CallDuration ?? '00:00:00');
|
||||||
const agentName = payload.AgentName ?? null;
|
const agentName = payload.AgentName ?? null;
|
||||||
const recordingUrl = payload.AudioFile ?? null;
|
const recordingUrl = payload.AudioFile ?? null;
|
||||||
const ucid = payload.monitorUCID ?? null;
|
const monitorUcid = payload.monitorUCID ?? null;
|
||||||
|
// Resolve agent-side UCID from real-time event mapping.
|
||||||
|
// The dispose endpoint creates Call records with the agent UCID;
|
||||||
|
// this lets us find and enrich that record instead of duplicating.
|
||||||
|
const agentUcid = monitorUcid ? this.supervisor.resolveAgentUcid(monitorUcid) : null;
|
||||||
|
const ucid = agentUcid ?? monitorUcid;
|
||||||
|
if (agentUcid) {
|
||||||
|
this.logger.log(`[WEBHOOK] Resolved monitorUCID ${monitorUcid} → agent UCID ${agentUcid}`);
|
||||||
|
}
|
||||||
const disposition = payload.Disposition ?? null;
|
const disposition = payload.Disposition ?? null;
|
||||||
const hangupBy = payload.HangupBy ?? null;
|
const hangupBy = payload.HangupBy ?? null;
|
||||||
|
|
||||||
@@ -53,9 +67,17 @@ export class MissedCallWebhookController {
|
|||||||
return { received: true, processed: false };
|
return { received: true, processed: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip outbound calls — an unanswered outbound dial is NOT a
|
||||||
|
// "missed call" in the call-center sense. Outbound call records
|
||||||
|
// are created by the disposition flow, not the webhook.
|
||||||
|
if (type === 'Manual' || type === 'OutBound') {
|
||||||
|
this.logger.log(`Skipping outbound call webhook (type=${type}, status=${status})`);
|
||||||
|
return { received: true, processed: false, reason: 'outbound' };
|
||||||
|
}
|
||||||
|
|
||||||
// Determine call status for our platform
|
// Determine call status for our platform
|
||||||
const callStatus = status === 'Answered' ? 'COMPLETED' : 'MISSED';
|
const callStatus = status === 'Answered' ? 'COMPLETED' : 'MISSED';
|
||||||
const direction = type === 'InBound' ? 'INBOUND' : 'OUTBOUND';
|
const direction = 'INBOUND'; // only inbound reaches here now
|
||||||
|
|
||||||
// Use API key auth for server-to-server writes
|
// Use API key auth for server-to-server writes
|
||||||
const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : '';
|
const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : '';
|
||||||
@@ -65,37 +87,103 @@ export class MissedCallWebhookController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Create call record
|
// Step 1: Resolve caller. CallerResolutionService looks up BOTH
|
||||||
const callId = await this.createCall({
|
// leads and patients — for an existing patient with no lead yet
|
||||||
callerPhone,
|
// it creates the lead on the fly and returns the name. This is
|
||||||
direction,
|
// the single source of truth for caller identity across webhook,
|
||||||
callStatus,
|
// polling, and agent-initiated paths.
|
||||||
agentName,
|
let resolved: { leadId: string; leadName: string | null; patientId: string } = {
|
||||||
startTime,
|
leadId: '',
|
||||||
endTime,
|
leadName: null,
|
||||||
duration,
|
patientId: '',
|
||||||
recordingUrl,
|
};
|
||||||
disposition,
|
try {
|
||||||
ucid,
|
const r = await this.caller.resolve(callerPhone, authHeader);
|
||||||
|
const fullName = `${r.firstName} ${r.lastName}`.trim();
|
||||||
|
resolved = {
|
||||||
|
leadId: r.leadId,
|
||||||
|
// Resolver returns isNew when no Lead/Patient exists for
|
||||||
|
// this phone. We do NOT auto-create records from the
|
||||||
|
// webhook — agents don't have a name to attach, so we
|
||||||
|
// persist the phone as leadName (honest snapshot). The
|
||||||
|
// first agent action (enquiry, appointment) will create
|
||||||
|
// real Lead+Patient records and retroactive identity
|
||||||
|
// isn't a data-layer concern.
|
||||||
|
leadName: r.isNew ? `+91${callerPhone}` : (fullName || null),
|
||||||
|
patientId: r.patientId,
|
||||||
|
};
|
||||||
|
this.logger.log(`[WEBHOOK] Resolved ${callerPhone} → lead=${resolved.leadId || 'none'} name=${resolved.leadName ?? 'unresolved'} isNew=${r.isNew}`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[WEBHOOK] Caller resolution failed for ${callerPhone}: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: For answered calls, the dispose endpoint creates the
|
||||||
|
// Call record ~5s before this webhook fires. Check if it already
|
||||||
|
// exists and enrich it instead of creating a duplicate.
|
||||||
|
let callId: string;
|
||||||
|
if (callStatus === 'COMPLETED' && ucid) {
|
||||||
|
const existing = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ calls(first: 1, filter: { ucid: { eq: "${ucid}" } }) { edges { node { id } } } }`,
|
||||||
|
undefined, authHeader,
|
||||||
|
).catch(() => null);
|
||||||
|
const existingId = existing?.calls?.edges?.[0]?.node?.id;
|
||||||
|
if (existingId) {
|
||||||
|
// Enrich existing record with webhook data (recording, chain name, timing)
|
||||||
|
const enrichData: Record<string, any> = {};
|
||||||
|
if (agentName) enrichData.agentName = agentName;
|
||||||
|
if (recordingUrl) enrichData.recording = { primaryLinkUrl: recordingUrl, primaryLinkLabel: 'Recording' };
|
||||||
|
if (resolved.leadId) enrichData.leadId = resolved.leadId;
|
||||||
|
if (resolved.leadName) enrichData.leadName = resolved.leadName;
|
||||||
|
if (startTime) enrichData.startedAt = istToUtc(startTime);
|
||||||
|
if (endTime) enrichData.endedAt = istToUtc(endTime);
|
||||||
|
if (duration) enrichData.durationSec = duration;
|
||||||
|
if (Object.keys(enrichData).length > 0) {
|
||||||
|
await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: existingId, data: enrichData },
|
||||||
|
authHeader,
|
||||||
|
).catch(err => this.logger.warn(`[WEBHOOK] Failed to enrich call ${existingId}: ${err}`));
|
||||||
|
}
|
||||||
|
callId = existingId;
|
||||||
|
this.logger.log(`[WEBHOOK] Enriched existing call ${callId} with recording=${recordingUrl ? 'yes' : 'no'} agentName=${agentName}`);
|
||||||
|
} else {
|
||||||
|
// Fallback: dispose didn't create it (edge case) — create normally
|
||||||
|
this.logger.log(`[WEBHOOK] No existing call found for ucid=${ucid} — creating new record`);
|
||||||
|
callId = await this.createCall({
|
||||||
|
callerPhone, direction, callStatus, agentName,
|
||||||
|
startTime, endTime, duration, recordingUrl, disposition, ucid,
|
||||||
|
leadId: resolved.leadId || null, leadName: resolved.leadName,
|
||||||
}, authHeader);
|
}, authHeader);
|
||||||
|
this.logger.log(`Created call record: ${callId} (${callStatus})${resolved.leadName ? ` linked to ${resolved.leadName}` : ''}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Missed calls — always create (no dispose fires for unanswered)
|
||||||
|
callId = await this.createCall({
|
||||||
|
callerPhone, direction, callStatus, agentName,
|
||||||
|
startTime, endTime, duration, recordingUrl, disposition, ucid,
|
||||||
|
leadId: resolved.leadId || null, leadName: resolved.leadName,
|
||||||
|
}, authHeader);
|
||||||
|
this.logger.log(`Created call record: ${callId} (${callStatus})${resolved.leadName ? ` linked to ${resolved.leadName}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
this.logger.log(`Created call record: ${callId} (${callStatus})`);
|
// Push worklist SSE so agents see new calls instantly
|
||||||
|
// instead of waiting for the 30s frontend poll.
|
||||||
|
this.supervisor.emitWorklistUpdate({
|
||||||
|
type: callStatus === 'MISSED' ? 'missed-call' : 'inbound-call',
|
||||||
|
callerPhone: callerPhone,
|
||||||
|
callerName: resolved.leadName ?? undefined,
|
||||||
|
callId,
|
||||||
|
});
|
||||||
|
|
||||||
// Step 2: Find matching lead by phone number
|
// Step 3: Lead-side side-effects (activity log + contact stats)
|
||||||
const lead = await this.findLeadByPhone(callerPhone, authHeader);
|
if (resolved.leadId) {
|
||||||
|
|
||||||
if (lead) {
|
|
||||||
// Step 3: Link call to lead
|
|
||||||
await this.updateCall(callId, { leadId: lead.id }, authHeader);
|
|
||||||
|
|
||||||
// Step 4: Create lead activity
|
|
||||||
const summary = callStatus === 'MISSED'
|
const summary = callStatus === 'MISSED'
|
||||||
? `Missed inbound call from ${callerPhone} (${duration}s, ${hangupBy ?? 'unknown'})`
|
? `Missed inbound call from ${callerPhone} (${duration}s, ${hangupBy ?? 'unknown'})`
|
||||||
: `Inbound call from ${callerPhone} — ${duration}s, ${disposition || 'no disposition'}`;
|
: `Inbound call from ${callerPhone} — ${duration}s, ${disposition || 'no disposition'}`;
|
||||||
|
|
||||||
await this.createLeadActivity({
|
await this.createLeadActivity({
|
||||||
leadId: lead.id,
|
leadId: resolved.leadId,
|
||||||
activityType: callStatus === 'MISSED' ? 'CALL_RECEIVED' : 'CALL_RECEIVED',
|
activityType: 'CALL_RECEIVED',
|
||||||
summary,
|
summary,
|
||||||
channel: 'PHONE',
|
channel: 'PHONE',
|
||||||
performedBy: agentName ?? 'System',
|
performedBy: agentName ?? 'System',
|
||||||
@@ -103,18 +191,16 @@ export class MissedCallWebhookController {
|
|||||||
outcome: callStatus === 'MISSED' ? 'NO_ANSWER' : 'SUCCESSFUL',
|
outcome: callStatus === 'MISSED' ? 'NO_ANSWER' : 'SUCCESSFUL',
|
||||||
}, authHeader);
|
}, authHeader);
|
||||||
|
|
||||||
// Step 5: Update lead contact timestamps
|
// Bump contact timestamps. Read current contactAttempts first
|
||||||
await this.updateLead(lead.id, {
|
// (kept local rather than extending resolve() signature).
|
||||||
|
const leadMeta = await this.findLeadByPhone(callerPhone, authHeader);
|
||||||
|
await this.updateLead(resolved.leadId, {
|
||||||
lastContacted: startTime ? new Date(startTime).toISOString() : new Date().toISOString(),
|
lastContacted: startTime ? new Date(startTime).toISOString() : new Date().toISOString(),
|
||||||
contactAttempts: (lead.contactAttempts ?? 0) + 1,
|
contactAttempts: ((leadMeta?.contactAttempts) ?? 0) + 1,
|
||||||
}, authHeader);
|
}, authHeader);
|
||||||
|
|
||||||
this.logger.log(`Linked call to lead ${lead.id} (${lead.name}), activity created`);
|
|
||||||
} else {
|
|
||||||
this.logger.log(`No matching lead for ${callerPhone} — call record created without lead link`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { received: true, processed: true, callId, leadId: lead?.id ?? null };
|
return { received: true, processed: true, callId, leadId: resolved.leadId || null };
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
const responseData = err?.response?.data ? JSON.stringify(err.response.data) : '';
|
const responseData = err?.response?.data ? JSON.stringify(err.response.data) : '';
|
||||||
this.logger.error(`Webhook processing failed: ${err.message} ${responseData}`);
|
this.logger.error(`Webhook processing failed: ${err.message} ${responseData}`);
|
||||||
@@ -133,6 +219,8 @@ export class MissedCallWebhookController {
|
|||||||
recordingUrl: string | null;
|
recordingUrl: string | null;
|
||||||
disposition: string | null;
|
disposition: string | null;
|
||||||
ucid: string | null;
|
ucid: string | null;
|
||||||
|
leadId?: string | null;
|
||||||
|
leadName?: string | null;
|
||||||
}, authHeader: string): Promise<string> {
|
}, authHeader: string): Promise<string> {
|
||||||
const callData: Record<string, any> = {
|
const callData: Record<string, any> = {
|
||||||
name: `${data.direction === 'INBOUND' ? 'Inbound' : 'Outbound'} — ${data.callerPhone}`,
|
name: `${data.direction === 'INBOUND' ? 'Inbound' : 'Outbound'} — ${data.callerPhone}`,
|
||||||
@@ -145,15 +233,40 @@ export class MissedCallWebhookController {
|
|||||||
durationSec: data.duration,
|
durationSec: data.duration,
|
||||||
disposition: this.mapDisposition(data.disposition),
|
disposition: this.mapDisposition(data.disposition),
|
||||||
};
|
};
|
||||||
|
// Persist UCID so the 30-min CDR enrichment cron and historical
|
||||||
|
// backfill can pair this row to a CDR record and fill in the
|
||||||
|
// authoritative agent relation.
|
||||||
|
if (data.ucid) callData.ucid = data.ucid;
|
||||||
|
if (data.leadId) callData.leadId = data.leadId;
|
||||||
|
if (data.leadName) callData.leadName = data.leadName;
|
||||||
// Set callback tracking fields for missed calls so they appear in the worklist
|
// Set callback tracking fields for missed calls so they appear in the worklist
|
||||||
if (data.callStatus === 'MISSED') {
|
if (data.callStatus === 'MISSED') {
|
||||||
callData.callbackstatus = 'PENDING_CALLBACK';
|
callData.callbackStatus = 'PENDING_CALLBACK';
|
||||||
callData.missedcallcount = 1;
|
callData.missedCallCount = 1;
|
||||||
}
|
}
|
||||||
if (data.recordingUrl) {
|
if (data.recordingUrl) {
|
||||||
callData.recording = { primaryLinkUrl: data.recordingUrl, primaryLinkLabel: 'Recording' };
|
callData.recording = { primaryLinkUrl: data.recordingUrl, primaryLinkLabel: 'Recording' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve agent relation at write-time so the supervisor dashboard
|
||||||
|
// can bucket the row immediately. Ozonetel sends transferred calls
|
||||||
|
// with a chain-style AgentName like "RamaiahAdmin -> GlobalHealthX" —
|
||||||
|
// the final handler is the last segment, so split on " -> " and
|
||||||
|
// resolve that. Try both ozonetelAgentId (lowercase unique) and
|
||||||
|
// ozonetelDisplayName (mixed-case human label) since Ozonetel mixes
|
||||||
|
// formats across webhook payloads. Leaves agentId null on miss so
|
||||||
|
// the cdr-enrichment cron can still attempt a match by UCID later.
|
||||||
|
if (data.agentName) {
|
||||||
|
const segments = data.agentName.split('->').map((s) => s.trim()).filter(Boolean);
|
||||||
|
const finalHandler = segments[segments.length - 1];
|
||||||
|
if (finalHandler) {
|
||||||
|
const uuid =
|
||||||
|
(await this.agentLookup.resolveByOzonetelId(finalHandler)) ??
|
||||||
|
(await this.agentLookup.resolveByDisplayName(finalHandler));
|
||||||
|
if (uuid) callData.agentId = uuid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const result = await this.platform.queryWithAuth<any>(
|
const result = await this.platform.queryWithAuth<any>(
|
||||||
`mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`,
|
`mutation($data: CallCreateInput!) { createCall(data: $data) { id } }`,
|
||||||
{ data: callData },
|
{ data: callData },
|
||||||
@@ -234,8 +347,9 @@ export class MissedCallWebhookController {
|
|||||||
'General Enquiry': 'INFO_PROVIDED',
|
'General Enquiry': 'INFO_PROVIDED',
|
||||||
'Appointment Booked': 'APPOINTMENT_BOOKED',
|
'Appointment Booked': 'APPOINTMENT_BOOKED',
|
||||||
'Follow Up': 'FOLLOW_UP_SCHEDULED',
|
'Follow Up': 'FOLLOW_UP_SCHEDULED',
|
||||||
'Not Interested': 'CALLBACK_REQUESTED',
|
'Not Interested': 'NOT_INTERESTED',
|
||||||
'Wrong Number': 'WRONG_NUMBER',
|
'Wrong Number': 'WRONG_NUMBER',
|
||||||
|
'No Answer': 'NO_ANSWER',
|
||||||
};
|
};
|
||||||
return map[disposition] ?? null;
|
return map[disposition] ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { Test } from '@nestjs/testing';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { MissedCallWebhookController } from './missed-call-webhook.controller';
|
import { MissedCallWebhookController } from './missed-call-webhook.controller';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { AgentLookupService } from '../platform/agent-lookup.service';
|
||||||
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
import {
|
import {
|
||||||
WEBHOOK_INBOUND_ANSWERED,
|
WEBHOOK_INBOUND_ANSWERED,
|
||||||
WEBHOOK_INBOUND_MISSED,
|
WEBHOOK_INBOUND_MISSED,
|
||||||
@@ -48,11 +50,28 @@ describe('MissedCallWebhookController', () => {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockCaller = {
|
||||||
|
resolve: jest.fn().mockResolvedValue({
|
||||||
|
leadId: '',
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
patientId: '',
|
||||||
|
isNew: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockAgentLookup = {
|
||||||
|
resolveByOzonetelId: jest.fn().mockResolvedValue(null),
|
||||||
|
resolveByDisplayName: jest.fn().mockResolvedValue(null),
|
||||||
|
};
|
||||||
|
|
||||||
const module = await Test.createTestingModule({
|
const module = await Test.createTestingModule({
|
||||||
controllers: [MissedCallWebhookController],
|
controllers: [MissedCallWebhookController],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: PlatformGraphqlService, useValue: mockPlatformGql },
|
{ provide: PlatformGraphqlService, useValue: mockPlatformGql },
|
||||||
{ provide: ConfigService, useValue: mockConfig },
|
{ provide: ConfigService, useValue: mockConfig },
|
||||||
|
{ provide: CallerResolutionService, useValue: mockCaller },
|
||||||
|
{ provide: AgentLookupService, useValue: mockAgentLookup },
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||||
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
|
|
||||||
// Ozonetel sends all timestamps in IST — convert to UTC for storage
|
// Ozonetel sends all timestamps in IST — convert to UTC for storage
|
||||||
export function istToUtc(istDateStr: string | null): string | null {
|
export function istToUtc(istDateStr: string | null): string | null {
|
||||||
@@ -33,10 +35,17 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly ozonetel: OzonetelAgentService,
|
private readonly ozonetel: OzonetelAgentService,
|
||||||
|
private readonly telephony: TelephonyConfigService,
|
||||||
|
private readonly caller: CallerResolutionService,
|
||||||
) {
|
) {
|
||||||
this.pollIntervalMs = this.config.get<number>('missedQueue.pollIntervalMs', 30000);
|
this.pollIntervalMs = this.config.get<number>('missedQueue.pollIntervalMs', 30000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Read-through so admin config changes take effect without restart
|
||||||
|
private get ownCampaign(): string {
|
||||||
|
return this.telephony.getConfig().ozonetel.campaignName ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
this.logger.log(`Starting missed call ingestion polling every ${this.pollIntervalMs}ms`);
|
this.logger.log(`Starting missed call ingestion polling every ${this.pollIntervalMs}ms`);
|
||||||
setInterval(() => this.ingest().catch(err => this.logger.error('Ingestion failed', err)), this.pollIntervalMs);
|
setInterval(() => this.ingest().catch(err => this.logger.error('Ingestion failed', err)), this.pollIntervalMs);
|
||||||
@@ -61,7 +70,17 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
|
|
||||||
if (!abandonCalls?.length) return { created: 0, updated: 0 };
|
if (!abandonCalls?.length) return { created: 0, updated: 0 };
|
||||||
|
|
||||||
for (const call of abandonCalls) {
|
// Filter to this sidecar's campaign only — the Ozonetel API
|
||||||
|
// returns ALL abandoned calls across the account.
|
||||||
|
const filtered = this.ownCampaign
|
||||||
|
? abandonCalls.filter((c: any) => c.campaign === this.ownCampaign)
|
||||||
|
: abandonCalls;
|
||||||
|
|
||||||
|
if (filtered.length < abandonCalls.length) {
|
||||||
|
this.logger.log(`Filtered ${abandonCalls.length - filtered.length} calls from other campaigns (own=${this.ownCampaign})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const call of filtered) {
|
||||||
const ucid = call.monitorUCID;
|
const ucid = call.monitorUCID;
|
||||||
if (!ucid || this.processedUcids.has(ucid)) continue;
|
if (!ucid || this.processedUcids.has(ucid)) continue;
|
||||||
this.processedUcids.add(ucid);
|
this.processedUcids.add(ucid);
|
||||||
@@ -73,43 +92,46 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
const callTime = istToUtc(call.callTime) ?? new Date().toISOString();
|
const callTime = istToUtc(call.callTime) ?? new Date().toISOString();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Look up lead by phone number — strip +91 prefix for flexible matching
|
// Resolve caller via the shared service — covers the case
|
||||||
const phoneDigits = phone.replace(/^\+91/, '');
|
// where there's an existing patient but no lead yet (the
|
||||||
|
// service creates the lead on the fly and returns the name).
|
||||||
|
// Same source of truth as the webhook path.
|
||||||
let leadId: string | null = null;
|
let leadId: string | null = null;
|
||||||
let leadName: string | null = null;
|
let leadName: string | null = null;
|
||||||
try {
|
try {
|
||||||
const leadResult = await this.platform.query<any>(
|
const apiKey = this.config.get<string>('platform.apiKey') ?? '';
|
||||||
`{ leads(first: 1, filter: {
|
const auth = apiKey ? `Bearer ${apiKey}` : '';
|
||||||
contactPhone: { primaryPhoneNumber: { like: "%${phoneDigits}" } }
|
const r = await this.caller.resolve(phone, auth);
|
||||||
}) { edges { node { id contactName { firstName lastName } patientId } } } }`,
|
if (r.isNew) {
|
||||||
);
|
// No existing Lead/Patient — write phone as leadName.
|
||||||
const matchedLead = leadResult?.leads?.edges?.[0]?.node;
|
// Record creation is deferred to the first agent
|
||||||
if (matchedLead) {
|
// action (enquiry / appointment).
|
||||||
leadId = matchedLead.id;
|
leadName = phone;
|
||||||
const fn = matchedLead.contactName?.firstName ?? '';
|
} else if (r.leadId) {
|
||||||
const ln = matchedLead.contactName?.lastName ?? '';
|
leadId = r.leadId;
|
||||||
leadName = `${fn} ${ln}`.trim() || null;
|
const fullName = `${r.firstName} ${r.lastName}`.trim();
|
||||||
this.logger.log(`Matched missed call ${phone} → lead ${leadId} (${leadName})`);
|
leadName = fullName || null;
|
||||||
|
this.logger.log(`Matched missed call ${phone} → lead ${leadId} (${leadName ?? 'no name'})`);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Lead lookup failed for ${phone}: ${err}`);
|
this.logger.warn(`Caller resolution failed for ${phone}: ${err}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await this.platform.query<any>(
|
const existing = await this.platform.query<any>(
|
||||||
`{ calls(first: 1, filter: {
|
`{ calls(first: 1, filter: {
|
||||||
callbackstatus: { eq: PENDING_CALLBACK },
|
callbackStatus: { eq: PENDING_CALLBACK },
|
||||||
callerNumber: { primaryPhoneNumber: { eq: "${phone}" } }
|
callerNumber: { primaryPhoneNumber: { eq: "${phone}" } }
|
||||||
}) { edges { node { id missedcallcount } } } }`,
|
}) { edges { node { id missedCallCount } } } }`,
|
||||||
);
|
);
|
||||||
|
|
||||||
const existingNode = existing?.calls?.edges?.[0]?.node;
|
const existingNode = existing?.calls?.edges?.[0]?.node;
|
||||||
|
|
||||||
if (existingNode) {
|
if (existingNode) {
|
||||||
const newCount = (existingNode.missedcallcount || 1) + 1;
|
const newCount = (existingNode.missedCallCount || 1) + 1;
|
||||||
const updateParts = [
|
const updateParts = [
|
||||||
`missedcallcount: ${newCount}`,
|
`missedCallCount: ${newCount}`,
|
||||||
`startedAt: "${callTime}"`,
|
`startedAt: "${callTime}"`,
|
||||||
`callsourcenumber: "${did}"`,
|
`callSourceNumber: "${did}"`,
|
||||||
];
|
];
|
||||||
if (leadId) updateParts.push(`leadId: "${leadId}"`);
|
if (leadId) updateParts.push(`leadId: "${leadId}"`);
|
||||||
if (leadName) updateParts.push(`leadName: "${leadName}"`);
|
if (leadName) updateParts.push(`leadName: "${leadName}"`);
|
||||||
@@ -120,12 +142,13 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
this.logger.log(`Dedup missed call ${phone}: count now ${newCount}${leadName ? ` (${leadName})` : ''}`);
|
this.logger.log(`Dedup missed call ${phone}: count now ${newCount}${leadName ? ` (${leadName})` : ''}`);
|
||||||
} else {
|
} else {
|
||||||
const dataParts = [
|
const dataParts = [
|
||||||
|
`name: "Missed — ${phone}"`,
|
||||||
`callStatus: MISSED`,
|
`callStatus: MISSED`,
|
||||||
`direction: INBOUND`,
|
`direction: INBOUND`,
|
||||||
`callerNumber: { primaryPhoneNumber: "${phone}", primaryPhoneCallingCode: "+91" }`,
|
`callerNumber: { primaryPhoneNumber: "${phone}", primaryPhoneCallingCode: "+91" }`,
|
||||||
`callsourcenumber: "${did}"`,
|
`callSourceNumber: "${did}"`,
|
||||||
`callbackstatus: PENDING_CALLBACK`,
|
`callbackStatus: PENDING_CALLBACK`,
|
||||||
`missedcallcount: 1`,
|
`missedCallCount: 1`,
|
||||||
`startedAt: "${callTime}"`,
|
`startedAt: "${callTime}"`,
|
||||||
];
|
];
|
||||||
if (leadId) dataParts.push(`leadId: "${leadId}"`);
|
if (leadId) dataParts.push(`leadId: "${leadId}"`);
|
||||||
@@ -160,12 +183,12 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
// Find oldest unassigned PENDING_CALLBACK call (empty agentName)
|
// Find oldest unassigned PENDING_CALLBACK call (empty agentName)
|
||||||
let result = await this.platform.query<any>(
|
let result = await this.platform.query<any>(
|
||||||
`{ calls(first: 1, filter: {
|
`{ calls(first: 1, filter: {
|
||||||
callbackstatus: { eq: PENDING_CALLBACK },
|
callbackStatus: { eq: PENDING_CALLBACK },
|
||||||
agentName: { eq: "" }
|
agentName: { eq: "" }
|
||||||
}, orderBy: [{ startedAt: AscNullsLast }]) {
|
}, orderBy: [{ startedAt: AscNullsLast }]) {
|
||||||
edges { node {
|
edges { node {
|
||||||
id callerNumber { primaryPhoneNumber }
|
id callerNumber { primaryPhoneNumber }
|
||||||
startedAt callsourcenumber missedcallcount
|
startedAt callSourceNumber missedCallCount
|
||||||
} }
|
} }
|
||||||
} }`,
|
} }`,
|
||||||
);
|
);
|
||||||
@@ -176,12 +199,12 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
if (!call) {
|
if (!call) {
|
||||||
result = await this.platform.query<any>(
|
result = await this.platform.query<any>(
|
||||||
`{ calls(first: 1, filter: {
|
`{ calls(first: 1, filter: {
|
||||||
callbackstatus: { eq: PENDING_CALLBACK },
|
callbackStatus: { eq: PENDING_CALLBACK },
|
||||||
agentName: { is: NULL }
|
agentName: { is: NULL }
|
||||||
}, orderBy: [{ startedAt: AscNullsLast }]) {
|
}, orderBy: [{ startedAt: AscNullsLast }]) {
|
||||||
edges { node {
|
edges { node {
|
||||||
id callerNumber { primaryPhoneNumber }
|
id callerNumber { primaryPhoneNumber }
|
||||||
startedAt callsourcenumber missedcallcount
|
startedAt callSourceNumber missedCallCount
|
||||||
} }
|
} }
|
||||||
} }`,
|
} }`,
|
||||||
);
|
);
|
||||||
@@ -209,13 +232,13 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
throw new Error(`Invalid status: ${status}. Must be one of: ${validStatuses.join(', ')}`);
|
throw new Error(`Invalid status: ${status}. Must be one of: ${validStatuses.join(', ')}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataParts: string[] = [`callbackstatus: ${status}`];
|
const dataParts: string[] = [`callbackStatus: ${status}`];
|
||||||
if (status === 'CALLBACK_ATTEMPTED') {
|
if (status === 'CALLBACK_ATTEMPTED') {
|
||||||
dataParts.push(`callbackattemptedat: "${new Date().toISOString()}"`);
|
dataParts.push(`callbackAttemptedAt: "${new Date().toISOString()}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.platform.queryWithAuth<any>(
|
return this.platform.queryWithAuth<any>(
|
||||||
`mutation { updateCall(id: "${callId}", data: { ${dataParts.join(', ')} }) { id callbackstatus callbackattemptedat } }`,
|
`mutation { updateCall(id: "${callId}", data: { ${dataParts.join(', ')} }) { id callbackStatus callbackAttemptedAt } }`,
|
||||||
undefined,
|
undefined,
|
||||||
authHeader,
|
authHeader,
|
||||||
);
|
);
|
||||||
@@ -230,12 +253,12 @@ export class MissedQueueService implements OnModuleInit {
|
|||||||
const fields = `id name createdAt direction callStatus agentName
|
const fields = `id name createdAt direction callStatus agentName
|
||||||
callerNumber { primaryPhoneNumber }
|
callerNumber { primaryPhoneNumber }
|
||||||
startedAt endedAt durationSec disposition leadId
|
startedAt endedAt durationSec disposition leadId
|
||||||
callbackstatus callsourcenumber missedcallcount callbackattemptedat`;
|
callbackStatus callSourceNumber missedCallCount callbackAttemptedAt`;
|
||||||
|
|
||||||
const buildQuery = (status: string) => `{ calls(first: 50, filter: {
|
const buildQuery = (status: string) => `{ calls(first: 50, filter: {
|
||||||
agentName: { eq: "${agentName}" },
|
agentName: { eq: "${agentName}" },
|
||||||
callStatus: { eq: MISSED },
|
callStatus: { eq: MISSED },
|
||||||
callbackstatus: { eq: ${status} }
|
callbackStatus: { eq: ${status} }
|
||||||
}, orderBy: [{ startedAt: AscNullsLast }]) { edges { node { ${fields} } } } }`;
|
}, orderBy: [{ startedAt: AscNullsLast }]) { edges { node { ${fields} } } } }`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { MissedQueueService, istToUtc, normalizePhone } from './missed-queue.service';
|
import { MissedQueueService, istToUtc, normalizePhone } from './missed-queue.service';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||||
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
import { ABANDON_CALL_RECORD } from '../__fixtures__/ozonetel-payloads';
|
import { ABANDON_CALL_RECORD } from '../__fixtures__/ozonetel-payloads';
|
||||||
|
|
||||||
describe('MissedQueueService', () => {
|
describe('MissedQueueService', () => {
|
||||||
@@ -57,6 +58,16 @@ describe('MissedQueueService', () => {
|
|||||||
getAbandonCalls: jest.fn().mockResolvedValue([ABANDON_CALL_RECORD]),
|
getAbandonCalls: jest.fn().mockResolvedValue([ABANDON_CALL_RECORD]),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: TelephonyConfigService,
|
||||||
|
useValue: {
|
||||||
|
getConfig: () => ({
|
||||||
|
ozonetel: { campaignName: 'Inbound_918041763400', agentId: '', agentPassword: '', did: '918041763400', sipId: '' },
|
||||||
|
sip: { domain: 'test', wsPort: '444' },
|
||||||
|
exotel: { apiKey: '', accountSid: '', subdomain: '' },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ import { PlatformModule } from '../platform/platform.module';
|
|||||||
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { RulesEngineModule } from '../rules-engine/rules-engine.module';
|
import { RulesEngineModule } from '../rules-engine/rules-engine.module';
|
||||||
|
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
||||||
|
import { SupervisorModule } from '../supervisor/supervisor.module';
|
||||||
|
import { TelephonyConfigService } from '../config/telephony-config.service';
|
||||||
import { WorklistController } from './worklist.controller';
|
import { WorklistController } from './worklist.controller';
|
||||||
import { WorklistService } from './worklist.service';
|
import { WorklistService } from './worklist.service';
|
||||||
import { MissedQueueService } from './missed-queue.service';
|
import { MissedQueueService } from './missed-queue.service';
|
||||||
@@ -10,9 +13,9 @@ import { MissedCallWebhookController } from './missed-call-webhook.controller';
|
|||||||
import { KookooCallbackController } from './kookoo-callback.controller';
|
import { KookooCallbackController } from './kookoo-callback.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), forwardRef(() => AuthModule), RulesEngineModule],
|
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), forwardRef(() => AuthModule), RulesEngineModule, forwardRef(() => CallerResolutionModule), forwardRef(() => SupervisorModule)],
|
||||||
controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController],
|
controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController],
|
||||||
providers: [WorklistService, MissedQueueService],
|
providers: [WorklistService, MissedQueueService, TelephonyConfigService],
|
||||||
exports: [MissedQueueService],
|
exports: [MissedQueueService],
|
||||||
})
|
})
|
||||||
export class WorklistModule {}
|
export class WorklistModule {}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Injectable, Logger } from '@nestjs/common';
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import { WorklistConsumer } from '../rules-engine/consumers/worklist.consumer';
|
import { WorklistConsumer } from '../rules-engine/consumers/worklist.consumer';
|
||||||
|
|
||||||
@@ -16,8 +17,49 @@ export class WorklistService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
private readonly worklistConsumer: WorklistConsumer,
|
private readonly worklistConsumer: WorklistConsumer,
|
||||||
|
private readonly config: ConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
private get pageSize(): number {
|
||||||
|
return this.config.get<number>('worklist.pageSize', 50);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get maxPages(): number {
|
||||||
|
return this.config.get<number>('worklist.maxPages', 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paginate a Relay connection query. Caller provides a function that
|
||||||
|
// builds the query for a given cursor ('' on first page). Stops when
|
||||||
|
// the platform reports no more pages OR the safety ceiling hits.
|
||||||
|
private async fetchAllPages<T>(
|
||||||
|
buildQuery: (cursorClause: string) => string,
|
||||||
|
connectionKey: string,
|
||||||
|
authHeader: string,
|
||||||
|
): Promise<T[]> {
|
||||||
|
const all: T[] = [];
|
||||||
|
let cursor = '';
|
||||||
|
for (let page = 0; page < this.maxPages; page++) {
|
||||||
|
const cursorClause = cursor ? `, after: "${cursor}"` : '';
|
||||||
|
try {
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
buildQuery(cursorClause),
|
||||||
|
undefined,
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
const conn = data?.[connectionKey];
|
||||||
|
if (!conn) break;
|
||||||
|
all.push(...(conn.edges?.map((e: any) => e.node) ?? []));
|
||||||
|
if (!conn.pageInfo?.hasNextPage) break;
|
||||||
|
cursor = conn.pageInfo.endCursor ?? '';
|
||||||
|
if (!cursor) break;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[WORKLIST] ${connectionKey} page ${page} failed: ${err}`);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
async getWorklist(agentName: string, authHeader: string): Promise<WorklistResponse> {
|
async getWorklist(agentName: string, authHeader: string): Promise<WorklistResponse> {
|
||||||
const [rawMissedCalls, rawFollowUps, rawMarketingLeads] = await Promise.all([
|
const [rawMissedCalls, rawFollowUps, rawMarketingLeads] = await Promise.all([
|
||||||
this.getMissedCalls(agentName, authHeader),
|
this.getMissedCalls(agentName, authHeader),
|
||||||
@@ -49,9 +91,8 @@ export class WorklistService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getAssignedLeads(agentName: string, authHeader: string): Promise<any[]> {
|
private async getAssignedLeads(agentName: string, authHeader: string): Promise<any[]> {
|
||||||
try {
|
return this.fetchAllPages<any>(
|
||||||
const data = await this.platform.queryWithAuth<any>(
|
(cursor) => `{ leads(first: ${this.pageSize}${cursor}, filter: { assignedAgent: { eq: "${agentName}" } }, orderBy: [{ createdAt: AscNullsLast }]) { edges { node {
|
||||||
`{ leads(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }, orderBy: [{ createdAt: AscNullsLast }]) { edges { node {
|
|
||||||
id createdAt
|
id createdAt
|
||||||
contactName { firstName lastName }
|
contactName { firstName lastName }
|
||||||
contactPhone { primaryPhoneNumber }
|
contactPhone { primaryPhoneNumber }
|
||||||
@@ -60,58 +101,86 @@ export class WorklistService {
|
|||||||
assignedAgent campaignId
|
assignedAgent campaignId
|
||||||
contactAttempts spamScore isSpam
|
contactAttempts spamScore isSpam
|
||||||
aiSummary aiSuggestedAction
|
aiSummary aiSuggestedAction
|
||||||
} } } }`,
|
patientId
|
||||||
undefined,
|
} } pageInfo { hasNextPage endCursor } } }`,
|
||||||
|
'leads',
|
||||||
authHeader,
|
authHeader,
|
||||||
);
|
);
|
||||||
return data.leads.edges.map((e: any) => e.node);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`Failed to fetch assigned leads: ${err}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getPendingFollowUps(agentName: string, authHeader: string): Promise<any[]> {
|
private async getPendingFollowUps(agentName: string, authHeader: string): Promise<any[]> {
|
||||||
try {
|
const raw = await this.fetchAllPages<any>(
|
||||||
const data = await this.platform.queryWithAuth<any>(
|
(cursor) => `{ followUps(first: ${this.pageSize}${cursor}, filter: { assignedAgent: { eq: "${agentName}" } }) { edges { node {
|
||||||
`{ followUps(first: 20, filter: { assignedAgent: { eq: "${agentName}" } }) { edges { node {
|
|
||||||
id name createdAt
|
id name createdAt
|
||||||
typeCustom status scheduledAt completedAt
|
typeCustom status scheduledAt completedAt
|
||||||
priority assignedAgent
|
priority assignedAgent
|
||||||
patientId
|
patientId
|
||||||
|
} } pageInfo { hasNextPage endCursor } } }`,
|
||||||
|
'followUps',
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
// Filter to PENDING/OVERDUE client-side since platform may not support in-filter on remapped fields
|
||||||
|
const followUps = raw.filter((f: any) => f.status === 'PENDING' || f.status === 'OVERDUE');
|
||||||
|
try {
|
||||||
|
|
||||||
|
// Enrich with patient name/phone so the worklist can render them.
|
||||||
|
// FollowUp stores only patientId — the name in fu.name is free-form
|
||||||
|
// and phone isn't stored at all, so one patient fetch fills both.
|
||||||
|
const patientIds: string[] = Array.from(
|
||||||
|
new Set(followUps.map((f: any) => f.patientId).filter((id: any): id is string => typeof id === 'string' && id.length > 0)),
|
||||||
|
);
|
||||||
|
if (patientIds.length > 0) {
|
||||||
|
try {
|
||||||
|
const idsGql = patientIds.map((id) => `"${id}"`).join(',');
|
||||||
|
const patientsData = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ patients(first: ${patientIds.length}, filter: { id: { in: [${idsGql}] } }) { edges { node {
|
||||||
|
id fullName { firstName lastName } phones { primaryPhoneNumber }
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined,
|
undefined,
|
||||||
authHeader,
|
authHeader,
|
||||||
);
|
);
|
||||||
// Filter to PENDING/OVERDUE client-side since platform may not support in-filter on remapped fields
|
const patientMap = new Map<string, { name: string; phone: string }>();
|
||||||
return data.followUps.edges
|
for (const edge of patientsData.patients.edges) {
|
||||||
.map((e: any) => e.node)
|
const p = edge.node;
|
||||||
.filter((f: any) => f.status === 'PENDING' || f.status === 'OVERDUE');
|
const name = [p.fullName?.firstName, p.fullName?.lastName].filter(Boolean).join(' ').trim();
|
||||||
|
const phone = p.phones?.primaryPhoneNumber ?? '';
|
||||||
|
patientMap.set(p.id, { name, phone });
|
||||||
|
}
|
||||||
|
for (const fu of followUps) {
|
||||||
|
if (fu.patientId && patientMap.has(fu.patientId)) {
|
||||||
|
const p = patientMap.get(fu.patientId)!;
|
||||||
|
fu.patientName = p.name;
|
||||||
|
fu.patientPhone = p.phone;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to enrich follow-ups with patient data: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return followUps;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to fetch follow-ups: ${err}`);
|
this.logger.warn(`Failed to fetch follow-ups: ${err}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getMissedCalls(agentName: string, authHeader: string): Promise<any[]> {
|
private async getMissedCalls(_agentName: string, authHeader: string): Promise<any[]> {
|
||||||
try {
|
// FIFO ordering (AscNullsLast) — oldest first. No agentName filter —
|
||||||
// FIFO ordering (AscNullsLast) — oldest first. No agentName filter — missed calls are a shared queue.
|
// missed calls are a shared queue. Paginated via WORKLIST_PAGE_SIZE
|
||||||
const data = await this.platform.queryWithAuth<any>(
|
// × WORKLIST_MAX_PAGES ceiling.
|
||||||
`{ calls(first: 20, filter: { callStatus: { eq: MISSED }, callbackstatus: { in: [PENDING_CALLBACK, CALLBACK_ATTEMPTED] } }, orderBy: [{ startedAt: AscNullsLast }]) { edges { node {
|
return this.fetchAllPages<any>(
|
||||||
|
(cursor) => `{ calls(first: ${this.pageSize}${cursor}, filter: { callStatus: { eq: MISSED }, callbackStatus: { in: [PENDING_CALLBACK, CALLBACK_ATTEMPTED] } }, orderBy: [{ startedAt: AscNullsLast }]) { edges { node {
|
||||||
id name createdAt
|
id name createdAt
|
||||||
direction callStatus agentName
|
direction callStatus agentName
|
||||||
callerNumber { primaryPhoneNumber }
|
callerNumber { primaryPhoneNumber }
|
||||||
startedAt endedAt durationSec
|
startedAt endedAt durationSec
|
||||||
disposition leadId
|
disposition leadId leadName
|
||||||
callbackstatus callsourcenumber missedcallcount callbackattemptedat
|
callbackStatus callSourceNumber missedCallCount callbackAttemptedAt
|
||||||
} } } }`,
|
campaign { id campaignName }
|
||||||
undefined,
|
} } pageInfo { hasNextPage endCursor } } }`,
|
||||||
|
'calls',
|
||||||
authHeader,
|
authHeader,
|
||||||
);
|
);
|
||||||
return data.calls.edges.map((e: any) => e.node);
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.warn(`Failed to fetch missed calls: ${err}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user