mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff318dd10d | ||
|
|
2aef616ee3 | ||
| 8cc1bdc812 | |||
| f231f6fd73 | |||
| 1d1f27607f | |||
| d0df6618b5 | |||
| 5e3ccbd040 | |||
| b8556cf440 | |||
|
|
09c7930b52 | ||
|
|
e912b982df | ||
| 7b59543d36 | |||
| 3e2e7372cc | |||
| 3c06a01e7b | |||
| fcc7c90e84 | |||
| eb4000961f | |||
| d3331e56c0 | |||
| fd08a5d5db | |||
| 2e4f97ff1a | |||
| a35a7d70bf | |||
| 77c5335955 | |||
| e4a24feedb | |||
| 4b5edc4e55 | |||
| 0b98d490f0 | |||
| 30a4cda178 | |||
| feedec0588 | |||
| cec2526d37 | |||
| 4963a698d9 |
@@ -1,6 +1,6 @@
|
|||||||
# Server
|
# Server
|
||||||
PORT=4100
|
PORT=4100
|
||||||
CORS_ORIGIN=http://localhost:5173
|
CORS_ORIGINS=http://localhost:5173,http://localhost:8000
|
||||||
|
|
||||||
# Fortytwo Platform
|
# Fortytwo Platform
|
||||||
PLATFORM_GRAPHQL_URL=http://localhost:4000/graphql
|
PLATFORM_GRAPHQL_URL=http://localhost:4000/graphql
|
||||||
|
|||||||
264
README.md
264
README.md
@@ -1,98 +1,210 @@
|
|||||||
<p align="center">
|
# Helix Engage Server — Sidecar Backend
|
||||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
|
||||||
</p>
|
|
||||||
|
|
||||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
NestJS sidecar that bridges Ozonetel telephony APIs with the FortyTwo platform. Handles agent auth, call control, disposition, missed call queue, worklist aggregation, AI enrichment, and live call assist.
|
||||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
|
||||||
|
|
||||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
**Owner: Karthik**
|
||||||
<p align="center">
|
|
||||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
|
|
||||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
|
|
||||||
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
|
|
||||||
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
|
|
||||||
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
|
|
||||||
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
|
|
||||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
|
|
||||||
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
|
|
||||||
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
|
|
||||||
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
|
|
||||||
</p>
|
|
||||||
<!--[](https://opencollective.com/nest#backer)
|
|
||||||
[](https://opencollective.com/nest#sponsor)-->
|
|
||||||
|
|
||||||
## Description
|
## Architecture
|
||||||
|
|
||||||
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
|
```
|
||||||
|
┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
|
||||||
## Project setup
|
│ helix-engage │ │ helix-engage-server │ │ FortyTwo Platform │
|
||||||
|
│ React frontend │────▶│ (this repo) │────▶│ GraphQL API │
|
||||||
```bash
|
│ │ │ Port 4100 │ │ Port 4000 │
|
||||||
$ npm install
|
└─────────────────────┘ └──────────────────────┘ └─────────────────────┘
|
||||||
|
│
|
||||||
|
│ Ozonetel CloudAgent APIs
|
||||||
|
▼
|
||||||
|
┌──────────────┐
|
||||||
|
│ Ozonetel │
|
||||||
|
│ in1-ccaas-api│
|
||||||
|
└──────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## Compile and run the project
|
This server has **no database**. All persistent data flows to/from the FortyTwo platform via GraphQL. Ozonetel is the telephony provider (CloudAgent APIs).
|
||||||
|
|
||||||
|
**Three repos:**
|
||||||
|
| Repo | Purpose | Owner |
|
||||||
|
|------|---------|-------|
|
||||||
|
| `helix-engage` | React frontend | Mouli |
|
||||||
|
| `helix-engage-server` (this) | NestJS sidecar | Karthik |
|
||||||
|
| `helix-engage-app` | FortyTwo SDK app — entity schemas | Shared |
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# development
|
npm install
|
||||||
$ npm run start
|
npm run start:dev # http://localhost:4100 (watch mode)
|
||||||
|
npm run build # Production build
|
||||||
# watch mode
|
npm run start:prod # Run production build
|
||||||
$ npm run start:dev
|
|
||||||
|
|
||||||
# production mode
|
|
||||||
$ npm run start:prod
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Run tests
|
### Environment Variables
|
||||||
|
|
||||||
```bash
|
| Variable | Purpose | Default |
|
||||||
# unit tests
|
|----------|---------|---------|
|
||||||
$ npm run test
|
| `PORT` | Server port | `4100` |
|
||||||
|
| `CORS_ORIGIN` | Allowed frontend origin | `http://localhost:5173` |
|
||||||
|
| `PLATFORM_GRAPHQL_URL` | FortyTwo GraphQL endpoint | `http://localhost:4000/graphql` |
|
||||||
|
| `PLATFORM_API_KEY` | FortyTwo API key (server-to-server) | — |
|
||||||
|
| `EXOTEL_API_KEY` | Ozonetel API key | — |
|
||||||
|
| `EXOTEL_API_TOKEN` | Ozonetel API token | — |
|
||||||
|
| `EXOTEL_ACCOUNT_SID` | Ozonetel account SID | — |
|
||||||
|
| `OZONETEL_AGENT_ID` | Default agent ID | `agent3` |
|
||||||
|
| `OZONETEL_AGENT_PASSWORD` | Default agent password | — |
|
||||||
|
| `OZONETEL_SIP_ID` | Default SIP extension | `521814` |
|
||||||
|
| `OZONETEL_DID` | Inbound DID number | `918041763265` |
|
||||||
|
| `OZONETEL_CAMPAIGN_NAME` | Default campaign | `Inbound_918041763265` |
|
||||||
|
| `MISSED_QUEUE_POLL_INTERVAL_MS` | Missed call ingestion interval | `30000` |
|
||||||
|
| `OPENAI_API_KEY` | For AI enrichment / call assist | — |
|
||||||
|
| `ANTHROPIC_API_KEY` | Alternative AI provider | — |
|
||||||
|
| `DEEPGRAM_API_KEY` | Live transcription (STT) | — |
|
||||||
|
|
||||||
# e2e tests
|
## Module Structure
|
||||||
$ npm run test:e2e
|
|
||||||
|
|
||||||
# test coverage
|
|
||||||
$ npm run test:cov
|
|
||||||
```
|
```
|
||||||
|
src/
|
||||||
|
├── ozonetel/ # ⚡ Ozonetel telephony — WHERE MOST WORK HAPPENS
|
||||||
|
│ ├── ozonetel-agent.controller.ts # REST endpoints for agent operations
|
||||||
|
│ ├── ozonetel-agent.service.ts # Ozonetel API wrapper (token, CDR, abandon calls)
|
||||||
|
│ ├── ozonetel-agent.module.ts # Module wiring
|
||||||
|
│ └── kookoo-ivr.controller.ts # IVR callback handler (XML responses)
|
||||||
|
│
|
||||||
|
├── worklist/ # Agent task queue + missed call queue
|
||||||
|
│ ├── worklist.controller.ts # GET /api/worklist, missed queue endpoints
|
||||||
|
│ ├── worklist.service.ts # Aggregates leads + missed calls + follow-ups
|
||||||
|
│ ├── missed-queue.service.ts # Ingestion, dedup, auto-assignment
|
||||||
|
│ ├── missed-call-webhook.controller.ts # Webhook receiver
|
||||||
|
│ └── kookoo-callback.controller.ts # Kookoo webhook
|
||||||
|
│
|
||||||
|
├── call-events/ # Real-time call event processing
|
||||||
|
│ ├── call-events.service.ts # Incoming call handling, AI enrichment, disposition logging
|
||||||
|
│ ├── call-events.gateway.ts # WebSocket push to frontend (Socket.IO)
|
||||||
|
│ └── call-lookup.controller.ts # Reverse phone lookup + AI enrichment
|
||||||
|
│
|
||||||
|
├── platform/ # FortyTwo platform GraphQL client
|
||||||
|
│ ├── platform-graphql.service.ts # query() for server-to-server, queryWithAuth() for user JWT
|
||||||
|
│ ├── platform.types.ts # Lead, Call, Activity types
|
||||||
|
│ └── platform.module.ts
|
||||||
|
│
|
||||||
|
├── search/ # Cross-entity search
|
||||||
|
│ └── search.controller.ts # GET /api/search — leads + patients + appointments
|
||||||
|
│
|
||||||
|
├── call-assist/ # Live call assistance
|
||||||
|
│ └── (Socket.IO namespace /call-assist, Deepgram STT, AI suggestions)
|
||||||
|
│
|
||||||
|
├── ai/ # AI enrichment (lead summaries, suggested actions)
|
||||||
|
├── auth/ # User auth proxy
|
||||||
|
├── graphql-proxy/ # GraphQL passthrough to platform
|
||||||
|
├── health/ # Health check endpoint
|
||||||
|
├── config/
|
||||||
|
│ └── configuration.ts # All env var loading
|
||||||
|
├── app.module.ts # Root module — imports all feature modules
|
||||||
|
└── main.ts # NestJS bootstrap (port 4100, CORS)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### Ozonetel Agent (`/api/ozonetel/`)
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| POST | `/agent-login` | Agent login to Ozonetel |
|
||||||
|
| POST | `/agent-logout` | Agent logout |
|
||||||
|
| POST | `/agent-state` | Change state (Ready/Pause) + auto-assign missed call on Ready |
|
||||||
|
| POST | `/agent-ready` | Force ready (logout + login) |
|
||||||
|
| POST | `/dispose` | Submit call disposition + update missed call status + auto-assign next |
|
||||||
|
| POST | `/dial` | Manual outbound dial |
|
||||||
|
| POST | `/call-control` | CONFERENCE, HOLD, UNHOLD, MUTE, UNMUTE, KICK_CALL |
|
||||||
|
| POST | `/recording` | Pause/unpause recording |
|
||||||
|
| GET | `/missed-calls` | Raw Ozonetel abandon calls |
|
||||||
|
| GET | `/call-history?date=` | CDR for a date |
|
||||||
|
| GET | `/performance?date=` | Aggregated agent metrics |
|
||||||
|
|
||||||
|
### Worklist (`/api/worklist/`)
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| GET | `/` | Agent's worklist (missed calls + follow-ups + leads) |
|
||||||
|
| GET | `/missed-queue` | Missed calls grouped by callback status |
|
||||||
|
| PATCH | `/missed-queue/:id/status` | Update callback status on a missed call |
|
||||||
|
|
||||||
|
### Other
|
||||||
|
|
||||||
|
| Method | Path | Purpose |
|
||||||
|
|--------|------|---------|
|
||||||
|
| GET | `/api/search?q=` | Cross-entity search (leads, patients, appointments) |
|
||||||
|
| POST | `/api/call/lookup` | Reverse phone lookup + AI enrichment |
|
||||||
|
| GET | `/api/health` | Health check |
|
||||||
|
| POST | `/graphql` | GraphQL proxy to platform |
|
||||||
|
|
||||||
|
## Troubleshooting Guide — Where to Look
|
||||||
|
|
||||||
|
### "Agent can't log in to Ozonetel"
|
||||||
|
**File:** `src/ozonetel/ozonetel-agent.controller.ts` → `agentLogin()`
|
||||||
|
**Service:** `src/ozonetel/ozonetel-agent.service.ts` → `loginAgent()`
|
||||||
|
Uses HTTP Basic auth to Ozonetel's `AgentAuthenticationV2` endpoint. "Already logged in" responses have `status: "error"` but are not real errors. Check `OZONETEL_AGENT_ID` and `OZONETEL_AGENT_PASSWORD` env vars.
|
||||||
|
|
||||||
|
### "Disposition failing / ACW not releasing"
|
||||||
|
**File:** `src/ozonetel/ozonetel-agent.controller.ts` → `dispose()`
|
||||||
|
**Service:** `src/ozonetel/ozonetel-agent.service.ts` → `setDisposition()`
|
||||||
|
All dispositions currently map to `'General Enquiry'` (campaign limitation). Uses `autoRelease: 'true'` to end ACW. If agent stays in ACW, the Ozonetel campaign's wrapup time (8s) may not have elapsed.
|
||||||
|
|
||||||
|
### "Missed calls not being ingested"
|
||||||
|
**File:** `src/worklist/missed-queue.service.ts` → `ingest()`
|
||||||
|
Runs on a 30s interval (`onModuleInit`). Polls Ozonetel `abandonCalls` API for the last 5 minutes. Look for log lines with `[MissedQueueService]`. Common issues: Ozonetel token expired (55-min cache), platform API key missing, phone number format mismatch.
|
||||||
|
|
||||||
|
### "Auto-assignment not working"
|
||||||
|
**File:** `src/worklist/missed-queue.service.ts` → `assignNext()`
|
||||||
|
Triggered from two places: `dispose()` and `agent-state()` in `ozonetel-agent.controller.ts`. Queries platform for oldest `PENDING_CALLBACK` call with empty `agentName`. Uses a mutex to prevent race conditions. If no calls are assigned, check that `callbackstatus` field exists on the Call entity (custom field, all-lowercase in GraphQL).
|
||||||
|
|
||||||
|
### "Worklist returning empty"
|
||||||
|
**File:** `src/worklist/worklist.service.ts`
|
||||||
|
Three parallel queries: `getMissedCalls()`, `getPendingFollowUps()`, `getAssignedLeads()`. All filter by `agentName`. If the agent name from the JWT doesn't match what's stored in lead/call records, results will be empty. Check `resolveAgentName()` in `worklist.controller.ts`.
|
||||||
|
|
||||||
|
### "Call events / webhooks not arriving"
|
||||||
|
**File:** `src/call-events/call-events.service.ts`
|
||||||
|
Ozonetel sends webhooks to the sidecar. Check that the webhook URL is configured in the Ozonetel dashboard and that the sidecar is reachable from the internet (Caddy reverse proxy on the VPS).
|
||||||
|
|
||||||
|
### "AI enrichment / call assist broken"
|
||||||
|
**Files:** `src/ai/`, `src/call-assist/`
|
||||||
|
Live transcription uses Deepgram Nova STT via raw WebSocket. AI suggestions use OpenAI gpt-4o-mini. Check `DEEPGRAM_API_KEY` and `OPENAI_API_KEY` env vars. The call-assist gateway uses Socket.IO namespace `/call-assist`.
|
||||||
|
|
||||||
|
### "Search not finding records"
|
||||||
|
**File:** `src/search/search.controller.ts`
|
||||||
|
Runs three parallel GraphQL queries (leads, patients, appointments), filters client-side. Requires minimum 2 characters. Uses the user's JWT (passed from frontend auth header).
|
||||||
|
|
||||||
|
## Key Technical Patterns
|
||||||
|
|
||||||
|
### Two Auth Models
|
||||||
|
1. **User JWT passthrough** — `platform.queryWithAuth(query, vars, authHeader)` — for user-facing endpoints (worklist, search). The frontend sends its JWT and the sidecar forwards it.
|
||||||
|
2. **Server API key** — `platform.query(query, vars)` — for server-to-server operations (missed call ingestion, auto-assignment). Uses `PLATFORM_API_KEY`.
|
||||||
|
|
||||||
|
### Ozonetel Token Caching
|
||||||
|
`ozonetel-agent.service.ts` → `getToken()` caches the bearer token for 55 minutes (tokens expire at 60 min). All CloudAgent API calls use this cached token.
|
||||||
|
|
||||||
|
### Custom Field Naming
|
||||||
|
Fields added via the FortyTwo admin portal use **all-lowercase** GraphQL names:
|
||||||
|
- `callbackstatus` (not `callbackStatus`)
|
||||||
|
- `callsourcenumber` (not `callSourceNumber`)
|
||||||
|
- `missedcallcount` (not `missedCallCount`)
|
||||||
|
- `callbackattemptedat` (not `callbackAttemptedAt`)
|
||||||
|
|
||||||
|
App-defined (managed) fields keep camelCase: `callStatus`, `agentName`, etc.
|
||||||
|
|
||||||
|
### Error Handling Pattern
|
||||||
|
Ozonetel endpoints return `{ status: 'error', message }` instead of throwing — this prevents UI from blocking on telephony failures. The frontend catches errors silently on disposition and recording.
|
||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
|
|
||||||
|
|
||||||
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ npm install -g @nestjs/mau
|
npm run build
|
||||||
$ mau deploy
|
# Then tar + scp + docker cp + restart (see deploy script in project docs)
|
||||||
```
|
```
|
||||||
|
|
||||||
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
The sidecar runs inside a Docker container (`fortytwo-staging-sidecar-1`) on the staging VPS.
|
||||||
|
|
||||||
## Resources
|
## Git Workflow
|
||||||
|
|
||||||
Check out a few resources that may come in handy when working with NestJS:
|
- `dev` — active development
|
||||||
|
- `master` — stable baseline
|
||||||
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
|
|
||||||
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
|
|
||||||
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
|
|
||||||
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
|
|
||||||
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
|
|
||||||
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
|
|
||||||
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
|
|
||||||
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
|
|
||||||
|
|
||||||
## Support
|
|
||||||
|
|
||||||
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
|
|
||||||
|
|
||||||
## Stay in touch
|
|
||||||
|
|
||||||
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
|
|
||||||
- Website - [https://nestjs.com](https://nestjs.com/)
|
|
||||||
- Twitter - [@nestframework](https://twitter.com/nestframework)
|
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
|
|
||||||
|
|||||||
50
data/theme-backups/theme-2026-04-02T09-33-40-460Z.json
Normal file
50
data/theme-backups/theme-2026-04-02T09-33-40-460Z.json
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
{
|
||||||
|
"brand": {
|
||||||
|
"name": "Helix Engage",
|
||||||
|
"hospitalName": "Global Hospital",
|
||||||
|
"logo": "/helix-logo.png",
|
||||||
|
"favicon": "/favicon.ico"
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"brand": {
|
||||||
|
"25": "rgb(239 246 255)",
|
||||||
|
"50": "rgb(219 234 254)",
|
||||||
|
"100": "rgb(191 219 254)",
|
||||||
|
"200": "rgb(147 197 253)",
|
||||||
|
"300": "rgb(96 165 250)",
|
||||||
|
"400": "rgb(59 130 246)",
|
||||||
|
"500": "rgb(37 99 235)",
|
||||||
|
"600": "rgb(29 78 216)",
|
||||||
|
"700": "rgb(30 64 175)",
|
||||||
|
"800": "rgb(30 58 138)",
|
||||||
|
"900": "rgb(23 37 84)",
|
||||||
|
"950": "rgb(15 23 42)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"typography": {
|
||||||
|
"body": "'Satoshi', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif",
|
||||||
|
"display": "'General Sans', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Sign in to Helix Engage",
|
||||||
|
"subtitle": "Global Hospital",
|
||||||
|
"showGoogleSignIn": true,
|
||||||
|
"showForgotPassword": true,
|
||||||
|
"poweredBy": {
|
||||||
|
"label": "Powered by F0rty2.ai",
|
||||||
|
"url": "https://f0rty2.ai"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Helix Engage",
|
||||||
|
"subtitle": "Global Hospital · {role}"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"quickActions": [
|
||||||
|
{ "label": "Doctor availability", "prompt": "What doctors are available and what are their visiting hours?" },
|
||||||
|
{ "label": "Clinic timings", "prompt": "What are the clinic locations and timings?" },
|
||||||
|
{ "label": "Patient history", "prompt": "Can you summarize this patient's history?" },
|
||||||
|
{ "label": "Treatment packages", "prompt": "What treatment packages are available?" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
62
data/theme-backups/theme-2026-04-02T09-34-04-404Z.json
Normal file
62
data/theme-backups/theme-2026-04-02T09-34-04-404Z.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"brand": {
|
||||||
|
"name": "Test",
|
||||||
|
"hospitalName": "Global Hospital",
|
||||||
|
"logo": "/helix-logo.png",
|
||||||
|
"favicon": "/favicon.ico"
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"brand": {
|
||||||
|
"25": "rgb(239 246 255)",
|
||||||
|
"50": "rgb(219 234 254)",
|
||||||
|
"100": "rgb(191 219 254)",
|
||||||
|
"200": "rgb(147 197 253)",
|
||||||
|
"300": "rgb(96 165 250)",
|
||||||
|
"400": "rgb(59 130 246)",
|
||||||
|
"500": "rgb(37 99 235)",
|
||||||
|
"600": "rgb(29 78 216)",
|
||||||
|
"700": "rgb(30 64 175)",
|
||||||
|
"800": "rgb(30 58 138)",
|
||||||
|
"900": "rgb(23 37 84)",
|
||||||
|
"950": "rgb(15 23 42)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"typography": {
|
||||||
|
"body": "'Satoshi', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif",
|
||||||
|
"display": "'General Sans', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Sign in to Helix Engage",
|
||||||
|
"subtitle": "Global Hospital",
|
||||||
|
"showGoogleSignIn": true,
|
||||||
|
"showForgotPassword": true,
|
||||||
|
"poweredBy": {
|
||||||
|
"label": "Powered by F0rty2.ai",
|
||||||
|
"url": "https://f0rty2.ai"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Helix Engage",
|
||||||
|
"subtitle": "Global Hospital · {role}"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"quickActions": [
|
||||||
|
{
|
||||||
|
"label": "Doctor availability",
|
||||||
|
"prompt": "What doctors are available and what are their visiting hours?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Clinic timings",
|
||||||
|
"prompt": "What are the clinic locations and timings?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Patient history",
|
||||||
|
"prompt": "Can you summarize this patient's history?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Treatment packages",
|
||||||
|
"prompt": "What treatment packages are available?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
62
data/theme-backups/theme-2026-04-02T09-41-45-744Z.json
Normal file
62
data/theme-backups/theme-2026-04-02T09-41-45-744Z.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"brand": {
|
||||||
|
"name": "Helix Engage",
|
||||||
|
"hospitalName": "Global Hospital",
|
||||||
|
"logo": "/helix-logo.png",
|
||||||
|
"favicon": "/favicon.ico"
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"brand": {
|
||||||
|
"25": "rgb(239 246 255)",
|
||||||
|
"50": "rgb(219 234 254)",
|
||||||
|
"100": "rgb(191 219 254)",
|
||||||
|
"200": "rgb(147 197 253)",
|
||||||
|
"300": "rgb(96 165 250)",
|
||||||
|
"400": "rgb(59 130 246)",
|
||||||
|
"500": "rgb(37 99 235)",
|
||||||
|
"600": "rgb(29 78 216)",
|
||||||
|
"700": "rgb(30 64 175)",
|
||||||
|
"800": "rgb(30 58 138)",
|
||||||
|
"900": "rgb(23 37 84)",
|
||||||
|
"950": "rgb(15 23 42)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"typography": {
|
||||||
|
"body": "'Satoshi', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif",
|
||||||
|
"display": "'General Sans', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Sign in to Helix Engage",
|
||||||
|
"subtitle": "Global Hospital",
|
||||||
|
"showGoogleSignIn": true,
|
||||||
|
"showForgotPassword": true,
|
||||||
|
"poweredBy": {
|
||||||
|
"label": "Powered by F0rty2.ai",
|
||||||
|
"url": "https://f0rty2.ai"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Helix Engage",
|
||||||
|
"subtitle": "Global Hospital · {role}"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"quickActions": [
|
||||||
|
{
|
||||||
|
"label": "Doctor availability",
|
||||||
|
"prompt": "What doctors are available and what are their visiting hours?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Clinic timings",
|
||||||
|
"prompt": "What are the clinic locations and timings?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Patient history",
|
||||||
|
"prompt": "Can you summarize this patient's history?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Treatment packages",
|
||||||
|
"prompt": "What treatment packages are available?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
62
data/theme-backups/theme-2026-04-02T09-42-24-047Z.json
Normal file
62
data/theme-backups/theme-2026-04-02T09-42-24-047Z.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"brand": {
|
||||||
|
"name": "Helix Engage",
|
||||||
|
"hospitalName": "Global Hospital",
|
||||||
|
"logo": "/helix-logo.png",
|
||||||
|
"favicon": "/favicon.ico"
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"brand": {
|
||||||
|
"25": "rgb(250 245 255)",
|
||||||
|
"50": "rgb(245 235 255)",
|
||||||
|
"100": "rgb(235 215 254)",
|
||||||
|
"200": "rgb(214 187 251)",
|
||||||
|
"300": "rgb(182 146 246)",
|
||||||
|
"400": "rgb(158 119 237)",
|
||||||
|
"500": "rgb(127 86 217)",
|
||||||
|
"600": "rgb(105 65 198)",
|
||||||
|
"700": "rgb(83 56 158)",
|
||||||
|
"800": "rgb(66 48 125)",
|
||||||
|
"900": "rgb(53 40 100)",
|
||||||
|
"950": "rgb(44 28 95)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"typography": {
|
||||||
|
"body": "'Satoshi', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif",
|
||||||
|
"display": "'General Sans', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Sign in to Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital",
|
||||||
|
"showGoogleSignIn": true,
|
||||||
|
"showForgotPassword": true,
|
||||||
|
"poweredBy": {
|
||||||
|
"label": "Powered by F0rty2.ai",
|
||||||
|
"url": "https://f0rty2.ai"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital · {role}"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"quickActions": [
|
||||||
|
{
|
||||||
|
"label": "Doctor availability",
|
||||||
|
"prompt": "What doctors are available and what are their visiting hours?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Clinic timings",
|
||||||
|
"prompt": "What are the clinic locations and timings?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Patient history",
|
||||||
|
"prompt": "Can you summarize this patient's history?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Treatment packages",
|
||||||
|
"prompt": "What treatment packages are available?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
62
data/theme-backups/theme-2026-04-02T09-43-19-186Z.json
Normal file
62
data/theme-backups/theme-2026-04-02T09-43-19-186Z.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"brand": {
|
||||||
|
"name": "Helix Engage",
|
||||||
|
"hospitalName": "Global Hospital",
|
||||||
|
"logo": "/helix-logo.png",
|
||||||
|
"favicon": "/favicon.ico"
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"brand": {
|
||||||
|
"25": "rgb(248 250 252)",
|
||||||
|
"50": "rgb(241 245 249)",
|
||||||
|
"100": "rgb(226 232 240)",
|
||||||
|
"200": "rgb(203 213 225)",
|
||||||
|
"300": "rgb(148 163 184)",
|
||||||
|
"400": "rgb(100 116 139)",
|
||||||
|
"500": "rgb(71 85 105)",
|
||||||
|
"600": "rgb(47 64 89)",
|
||||||
|
"700": "rgb(37 49 72)",
|
||||||
|
"800": "rgb(30 41 59)",
|
||||||
|
"900": "rgb(15 23 42)",
|
||||||
|
"950": "rgb(2 6 23)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"typography": {
|
||||||
|
"body": "'Plus Jakarta Sans', 'Inter', sans-serif",
|
||||||
|
"display": "'Plus Jakarta Sans', 'Inter', sans-serif"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Sign in to Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital",
|
||||||
|
"showGoogleSignIn": true,
|
||||||
|
"showForgotPassword": true,
|
||||||
|
"poweredBy": {
|
||||||
|
"label": "Powered by F0rty2.ai",
|
||||||
|
"url": "https://f0rty2.ai"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital · {role}"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"quickActions": [
|
||||||
|
{
|
||||||
|
"label": "Doctor availability",
|
||||||
|
"prompt": "What doctors are available and what are their visiting hours?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Clinic timings",
|
||||||
|
"prompt": "What are the clinic locations and timings?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Patient history",
|
||||||
|
"prompt": "Can you summarize this patient's history?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Treatment packages",
|
||||||
|
"prompt": "What treatment packages are available?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
62
data/theme-backups/theme-2026-04-02T09-53-00-903Z.json
Normal file
62
data/theme-backups/theme-2026-04-02T09-53-00-903Z.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"brand": {
|
||||||
|
"name": "Helix Engage",
|
||||||
|
"hospitalName": "Global Hospital",
|
||||||
|
"logo": "/helix-logo.png",
|
||||||
|
"favicon": "/favicon.ico"
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"brand": {
|
||||||
|
"25": "rgb(248 250 252)",
|
||||||
|
"50": "rgb(241 245 249)",
|
||||||
|
"100": "rgb(226 232 240)",
|
||||||
|
"200": "rgb(203 213 225)",
|
||||||
|
"300": "rgb(148 163 184)",
|
||||||
|
"400": "rgb(100 116 139)",
|
||||||
|
"500": "rgb(71 85 105)",
|
||||||
|
"600": "rgb(47 64 89)",
|
||||||
|
"700": "rgb(37 49 72)",
|
||||||
|
"800": "rgb(30 41 59)",
|
||||||
|
"900": "rgb(15 23 42)",
|
||||||
|
"950": "rgb(2 6 23)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"typography": {
|
||||||
|
"body": "'Plus Jakarta Sans', 'Inter', sans-serif",
|
||||||
|
"display": "'Plus Jakarta Sans', 'Inter', sans-serif"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Sign in to Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital",
|
||||||
|
"showGoogleSignIn": false,
|
||||||
|
"showForgotPassword": true,
|
||||||
|
"poweredBy": {
|
||||||
|
"label": "Powered by F0rty2.ai",
|
||||||
|
"url": "https://f0rty2.ai"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital · {role}"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"quickActions": [
|
||||||
|
{
|
||||||
|
"label": "Doctor availability",
|
||||||
|
"prompt": "What doctors are available and what are their visiting hours?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Clinic timings",
|
||||||
|
"prompt": "What are the clinic locations and timings?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Patient history",
|
||||||
|
"prompt": "Can you summarize this patient's history?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Treatment packages",
|
||||||
|
"prompt": "What treatment packages are available?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
62
data/theme-backups/theme-2026-04-02T10-00-48-735Z.json
Normal file
62
data/theme-backups/theme-2026-04-02T10-00-48-735Z.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"brand": {
|
||||||
|
"name": "Helix Engage",
|
||||||
|
"hospitalName": "Global Hospital",
|
||||||
|
"logo": "/helix-logo.png",
|
||||||
|
"favicon": "/favicon.ico"
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"brand": {
|
||||||
|
"25": "rgb(240 253 250)",
|
||||||
|
"50": "rgb(204 251 241)",
|
||||||
|
"100": "rgb(153 246 228)",
|
||||||
|
"200": "rgb(94 234 212)",
|
||||||
|
"300": "rgb(45 212 191)",
|
||||||
|
"400": "rgb(20 184 166)",
|
||||||
|
"500": "rgb(13 148 136)",
|
||||||
|
"600": "rgb(15 118 110)",
|
||||||
|
"700": "rgb(17 94 89)",
|
||||||
|
"800": "rgb(19 78 74)",
|
||||||
|
"900": "rgb(17 63 61)",
|
||||||
|
"950": "rgb(4 47 46)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"typography": {
|
||||||
|
"body": "'Plus Jakarta Sans', 'Inter', sans-serif",
|
||||||
|
"display": "'Plus Jakarta Sans', 'Inter', sans-serif"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Sign in to Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital",
|
||||||
|
"showGoogleSignIn": false,
|
||||||
|
"showForgotPassword": true,
|
||||||
|
"poweredBy": {
|
||||||
|
"label": "Powered by F0rty2.ai",
|
||||||
|
"url": "https://f0rty2.ai"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital · {role}"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"quickActions": [
|
||||||
|
{
|
||||||
|
"label": "Doctor availability",
|
||||||
|
"prompt": "What doctors are available and what are their visiting hours?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Clinic timings",
|
||||||
|
"prompt": "What are the clinic locations and timings?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Patient history",
|
||||||
|
"prompt": "Can you summarize this patient's history?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Treatment packages",
|
||||||
|
"prompt": "What treatment packages are available?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
62
data/theme-backups/theme-2026-04-02T10-19-29-559Z.json
Normal file
62
data/theme-backups/theme-2026-04-02T10-19-29-559Z.json
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
{
|
||||||
|
"brand": {
|
||||||
|
"name": "Helix Engage",
|
||||||
|
"hospitalName": "Global Hospital",
|
||||||
|
"logo": "/helix-logo.png",
|
||||||
|
"favicon": "/favicon.ico"
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"brand": {
|
||||||
|
"25": "rgb(240 253 250)",
|
||||||
|
"50": "rgb(204 251 241)",
|
||||||
|
"100": "rgb(153 246 228)",
|
||||||
|
"200": "rgb(94 234 212)",
|
||||||
|
"300": "rgb(45 212 191)",
|
||||||
|
"400": "rgb(20 184 166)",
|
||||||
|
"500": "rgb(13 148 136)",
|
||||||
|
"600": "rgb(15 118 110)",
|
||||||
|
"700": "rgb(17 94 89)",
|
||||||
|
"800": "rgb(19 78 74)",
|
||||||
|
"900": "rgb(17 63 61)",
|
||||||
|
"950": "rgb(4 47 46)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"typography": {
|
||||||
|
"body": "'Satoshi', 'Inter', -apple-system, sans-serif",
|
||||||
|
"display": "'Satoshi', 'Inter', -apple-system, sans-serif"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Sign in to Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital",
|
||||||
|
"showGoogleSignIn": false,
|
||||||
|
"showForgotPassword": true,
|
||||||
|
"poweredBy": {
|
||||||
|
"label": "Powered by F0rty2.ai",
|
||||||
|
"url": "https://f0rty2.ai"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital · {role}"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"quickActions": [
|
||||||
|
{
|
||||||
|
"label": "Doctor availability",
|
||||||
|
"prompt": "What doctors are available and what are their visiting hours?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Clinic timings",
|
||||||
|
"prompt": "What are the clinic locations and timings?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Patient history",
|
||||||
|
"prompt": "Can you summarize this patient's history?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Treatment packages",
|
||||||
|
"prompt": "What treatment packages are available?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
64
data/theme-backups/theme-2026-04-02T10-19-35-284Z.json
Normal file
64
data/theme-backups/theme-2026-04-02T10-19-35-284Z.json
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"brand": {
|
||||||
|
"name": "Helix Engage",
|
||||||
|
"hospitalName": "Global Hospital",
|
||||||
|
"logo": "/helix-logo.png",
|
||||||
|
"favicon": "/favicon.ico"
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"brand": {
|
||||||
|
"25": "rgb(249 252 243)",
|
||||||
|
"50": "rgb(244 249 231)",
|
||||||
|
"100": "rgb(235 244 210)",
|
||||||
|
"200": "rgb(224 247 161)",
|
||||||
|
"300": "rgb(206 243 104)",
|
||||||
|
"400": "rgb(195 255 31)",
|
||||||
|
"500": "rgb(172 235 0)",
|
||||||
|
"600": "rgb(142 194 0)",
|
||||||
|
"700": "rgb(116 158 0)",
|
||||||
|
"800": "rgb(97 133 0)",
|
||||||
|
"900": "rgb(75 102 0)",
|
||||||
|
"950": "rgb(49 66 0)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"typography": {
|
||||||
|
"body": "'Satoshi', 'Inter', -apple-system, sans-serif",
|
||||||
|
"display": "'Satoshi', 'Inter', -apple-system, sans-serif"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Sign in to Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital",
|
||||||
|
"showGoogleSignIn": false,
|
||||||
|
"showForgotPassword": true,
|
||||||
|
"poweredBy": {
|
||||||
|
"label": "Powered by F0rty2.ai",
|
||||||
|
"url": "https://f0rty2.ai"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital · {role}"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"quickActions": [
|
||||||
|
{
|
||||||
|
"label": "Doctor availability",
|
||||||
|
"prompt": "What doctors are available and what are their visiting hours?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Clinic timings",
|
||||||
|
"prompt": "What are the clinic locations and timings?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Patient history",
|
||||||
|
"prompt": "Can you summarize this patient's history?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Treatment packages",
|
||||||
|
"prompt": "What treatment packages are available?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"version": 1,
|
||||||
|
"updatedAt": "2026-04-02T10:19:29.559Z"
|
||||||
|
}
|
||||||
64
data/theme.json
Normal file
64
data/theme.json
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{
|
||||||
|
"brand": {
|
||||||
|
"name": "Helix Engage",
|
||||||
|
"hospitalName": "Global Hospital",
|
||||||
|
"logo": "/helix-logo.png",
|
||||||
|
"favicon": "/favicon.ico"
|
||||||
|
},
|
||||||
|
"colors": {
|
||||||
|
"brand": {
|
||||||
|
"25": "rgb(250 245 255)",
|
||||||
|
"50": "rgb(245 235 255)",
|
||||||
|
"100": "rgb(235 215 254)",
|
||||||
|
"200": "rgb(214 187 251)",
|
||||||
|
"300": "rgb(182 146 246)",
|
||||||
|
"400": "rgb(158 119 237)",
|
||||||
|
"500": "rgb(127 86 217)",
|
||||||
|
"600": "rgb(105 65 198)",
|
||||||
|
"700": "rgb(83 56 158)",
|
||||||
|
"800": "rgb(66 48 125)",
|
||||||
|
"900": "rgb(53 40 100)",
|
||||||
|
"950": "rgb(44 28 95)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"typography": {
|
||||||
|
"body": "'Satoshi', 'Inter', -apple-system, sans-serif",
|
||||||
|
"display": "'Satoshi', 'Inter', -apple-system, sans-serif"
|
||||||
|
},
|
||||||
|
"login": {
|
||||||
|
"title": "Sign in to Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital",
|
||||||
|
"showGoogleSignIn": false,
|
||||||
|
"showForgotPassword": true,
|
||||||
|
"poweredBy": {
|
||||||
|
"label": "Powered by F0rty2.ai",
|
||||||
|
"url": "https://f0rty2.ai"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sidebar": {
|
||||||
|
"title": "Ramaiah",
|
||||||
|
"subtitle": "Ramaiah Hospital · {role}"
|
||||||
|
},
|
||||||
|
"ai": {
|
||||||
|
"quickActions": [
|
||||||
|
{
|
||||||
|
"label": "Doctor availability",
|
||||||
|
"prompt": "What doctors are available and what are their visiting hours?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Clinic timings",
|
||||||
|
"prompt": "What are the clinic locations and timings?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Patient history",
|
||||||
|
"prompt": "Can you summarize this patient's history?"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"label": "Treatment packages",
|
||||||
|
"prompt": "What treatment packages are available?"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"version": 2,
|
||||||
|
"updatedAt": "2026-04-02T10:19:35.284Z"
|
||||||
|
}
|
||||||
3331
package-lock.json
generated
3331
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,9 @@
|
|||||||
"@ai-sdk/anthropic": "^3.0.58",
|
"@ai-sdk/anthropic": "^3.0.58",
|
||||||
"@ai-sdk/openai": "^3.0.41",
|
"@ai-sdk/openai": "^3.0.41",
|
||||||
"@deepgram/sdk": "^5.0.0",
|
"@deepgram/sdk": "^5.0.0",
|
||||||
|
"@livekit/agents": "^1.2.1",
|
||||||
|
"@livekit/agents-plugin-google": "^1.2.1",
|
||||||
|
"@livekit/agents-plugin-silero": "^1.2.1",
|
||||||
"@nestjs/common": "^11.0.1",
|
"@nestjs/common": "^11.0.1",
|
||||||
"@nestjs/config": "^4.0.3",
|
"@nestjs/config": "^4.0.3",
|
||||||
"@nestjs/core": "^11.0.1",
|
"@nestjs/core": "^11.0.1",
|
||||||
@@ -31,9 +34,13 @@
|
|||||||
"@nestjs/websockets": "^11.1.17",
|
"@nestjs/websockets": "^11.1.17",
|
||||||
"ai": "^6.0.116",
|
"ai": "^6.0.116",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
|
"ioredis": "^5.10.1",
|
||||||
|
"json-rules-engine": "^6.6.0",
|
||||||
|
"kafkajs": "^2.2.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",
|
||||||
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Controller, Post, Body, Headers, HttpException, Logger } from '@nestjs/common';
|
import { Controller, Post, Body, Headers, Req, Res, HttpException, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { generateText, tool, stepCountIs } from 'ai';
|
import type { Request, Response } from 'express';
|
||||||
|
import { generateText, streamText, tool, stepCountIs } from 'ai';
|
||||||
import type { LanguageModel } from 'ai';
|
import type { LanguageModel } from 'ai';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
@@ -61,9 +62,436 @@ export class AiChatController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('stream')
|
||||||
|
async stream(@Req() req: Request, @Res() res: Response, @Headers('authorization') auth: string) {
|
||||||
|
if (!auth) throw new HttpException('Authorization required', 401);
|
||||||
|
|
||||||
|
const body = req.body;
|
||||||
|
const messages = body.messages ?? [];
|
||||||
|
if (!messages.length) throw new HttpException('messages required', 400);
|
||||||
|
|
||||||
|
if (!this.aiModel) {
|
||||||
|
res.status(500).json({ error: 'AI not configured' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = body.context;
|
||||||
|
let systemPrompt: string;
|
||||||
|
|
||||||
|
// Rules engine context — use rules-specific system prompt
|
||||||
|
if (ctx?.type === 'rules-engine') {
|
||||||
|
systemPrompt = this.buildRulesSystemPrompt(ctx.currentConfig);
|
||||||
|
} else if (ctx?.type === 'supervisor') {
|
||||||
|
systemPrompt = this.buildSupervisorSystemPrompt();
|
||||||
|
} else {
|
||||||
|
const kb = await this.buildKnowledgeBase(auth);
|
||||||
|
systemPrompt = this.buildSystemPrompt(kb);
|
||||||
|
|
||||||
|
// Inject caller context so the AI knows who is selected
|
||||||
|
if (ctx) {
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (ctx.leadName) parts.push(`Currently viewing/talking to: ${ctx.leadName}`);
|
||||||
|
if (ctx.callerPhone) parts.push(`Phone: ${ctx.callerPhone}`);
|
||||||
|
if (ctx.leadId) parts.push(`Lead ID: ${ctx.leadId}`);
|
||||||
|
if (parts.length) {
|
||||||
|
systemPrompt += `\n\nCURRENT CONTEXT:\n${parts.join('\n')}\nUse this context to answer questions about "this patient" or "this caller" without asking for their name.`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformService = this.platform;
|
||||||
|
const isSupervisor = ctx?.type === 'supervisor';
|
||||||
|
|
||||||
|
// Supervisor tools — agent performance, campaign stats, team metrics
|
||||||
|
const supervisorTools = {
|
||||||
|
get_agent_performance: tool({
|
||||||
|
description: 'Get performance metrics for all agents or a specific agent. Returns call counts, conversion rates, idle time, NPS scores.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
agentName: z.string().optional().describe('Agent name to look up. Leave empty for all agents.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ agentName }) => {
|
||||||
|
const [callsData, leadsData, agentsData, followUpsData] = await Promise.all([
|
||||||
|
platformService.queryWithAuth<any>(
|
||||||
|
`{ calls(first: 200, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { id direction callStatus agentName startedAt durationSec disposition } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
),
|
||||||
|
platformService.queryWithAuth<any>(
|
||||||
|
`{ leads(first: 200) { edges { node { id assignedAgent status } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
),
|
||||||
|
platformService.queryWithAuth<any>(
|
||||||
|
`{ agents(first: 20) { edges { node { id name ozonetelagentid npsscore maxidleminutes minnpsthreshold minconversionpercent } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
),
|
||||||
|
platformService.queryWithAuth<any>(
|
||||||
|
`{ followUps(first: 100) { edges { node { id assignedAgent status } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const calls = callsData.calls.edges.map((e: any) => e.node);
|
||||||
|
const leads = leadsData.leads.edges.map((e: any) => e.node);
|
||||||
|
const agents = agentsData.agents.edges.map((e: any) => e.node);
|
||||||
|
const followUps = followUpsData.followUps.edges.map((e: any) => e.node);
|
||||||
|
|
||||||
|
const agentMetrics = agents
|
||||||
|
.filter((a: any) => !agentName || a.name.toLowerCase().includes(agentName.toLowerCase()))
|
||||||
|
.map((agent: any) => {
|
||||||
|
const agentCalls = calls.filter((c: any) => c.agentName === agent.name || c.agentName === agent.ozonetelagentid);
|
||||||
|
const totalCalls = agentCalls.length;
|
||||||
|
const missed = agentCalls.filter((c: any) => c.callStatus === 'MISSED').length;
|
||||||
|
const completed = agentCalls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
||||||
|
const apptBooked = agentCalls.filter((c: any) => c.disposition === 'APPOINTMENT_BOOKED').length;
|
||||||
|
const agentLeads = leads.filter((l: any) => l.assignedAgent === agent.name);
|
||||||
|
const agentFollowUps = followUps.filter((f: any) => f.assignedAgent === agent.name);
|
||||||
|
const pendingFollowUps = agentFollowUps.filter((f: any) => f.status === 'PENDING' || f.status === 'OVERDUE').length;
|
||||||
|
const conversionRate = totalCalls > 0 ? Math.round((apptBooked / totalCalls) * 100) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: agent.name,
|
||||||
|
totalCalls,
|
||||||
|
completed,
|
||||||
|
missed,
|
||||||
|
appointmentsBooked: apptBooked,
|
||||||
|
conversionRate: `${conversionRate}%`,
|
||||||
|
assignedLeads: agentLeads.length,
|
||||||
|
pendingFollowUps,
|
||||||
|
npsScore: agent.npsscore,
|
||||||
|
maxIdleMinutes: agent.maxidleminutes,
|
||||||
|
minNpsThreshold: agent.minnpsthreshold,
|
||||||
|
minConversionPercent: agent.minconversionpercent,
|
||||||
|
belowNpsThreshold: agent.minnpsthreshold && (agent.npsscore ?? 100) < agent.minnpsthreshold,
|
||||||
|
belowConversionThreshold: agent.minconversionpercent && conversionRate < agent.minconversionpercent,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return { agents: agentMetrics, totalAgents: agentMetrics.length };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
get_campaign_stats: tool({
|
||||||
|
description: 'Get campaign performance stats — lead counts, conversion rates, sources.',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
execute: async () => {
|
||||||
|
const [campaignsData, leadsData] = await Promise.all([
|
||||||
|
platformService.queryWithAuth<any>(
|
||||||
|
`{ campaigns(first: 20) { edges { node { id campaignName campaignStatus platform leadCount convertedCount budget { amountMicros } } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
),
|
||||||
|
platformService.queryWithAuth<any>(
|
||||||
|
`{ leads(first: 200) { edges { node { id campaignId status } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const campaigns = campaignsData.campaigns.edges.map((e: any) => e.node);
|
||||||
|
const leads = leadsData.leads.edges.map((e: any) => e.node);
|
||||||
|
|
||||||
|
return {
|
||||||
|
campaigns: campaigns.map((c: any) => {
|
||||||
|
const campaignLeads = leads.filter((l: any) => l.campaignId === c.id);
|
||||||
|
const converted = campaignLeads.filter((l: any) => l.status === 'CONVERTED' || l.status === 'APPOINTMENT_SET').length;
|
||||||
|
return {
|
||||||
|
name: c.campaignName,
|
||||||
|
status: c.campaignStatus,
|
||||||
|
platform: c.platform,
|
||||||
|
totalLeads: campaignLeads.length,
|
||||||
|
converted,
|
||||||
|
conversionRate: campaignLeads.length > 0 ? `${Math.round((converted / campaignLeads.length) * 100)}%` : '0%',
|
||||||
|
budget: c.budget ? `₹${c.budget.amountMicros / 1_000_000}` : null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
get_call_summary: tool({
|
||||||
|
description: 'Get aggregate call statistics — total calls, inbound/outbound split, missed call rate, average duration, disposition breakdown.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
period: z.string().optional().describe('Period: "today", "week", "month". Defaults to "week".'),
|
||||||
|
}),
|
||||||
|
execute: async ({ period }) => {
|
||||||
|
const data = await platformService.queryWithAuth<any>(
|
||||||
|
`{ calls(first: 500, orderBy: [{ startedAt: DescNullsLast }]) { edges { node { id direction callStatus agentName startedAt durationSec disposition } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
const allCalls = data.calls.edges.map((e: any) => e.node);
|
||||||
|
|
||||||
|
// Filter by period
|
||||||
|
const now = new Date();
|
||||||
|
const start = new Date(now);
|
||||||
|
if (period === 'today') start.setHours(0, 0, 0, 0);
|
||||||
|
else if (period === 'month') start.setDate(start.getDate() - 30);
|
||||||
|
else start.setDate(start.getDate() - 7); // default week
|
||||||
|
|
||||||
|
const calls = allCalls.filter((c: any) => c.startedAt && new Date(c.startedAt) >= start);
|
||||||
|
|
||||||
|
const total = calls.length;
|
||||||
|
const inbound = calls.filter((c: any) => c.direction === 'INBOUND').length;
|
||||||
|
const outbound = total - inbound;
|
||||||
|
const missed = calls.filter((c: any) => c.callStatus === 'MISSED').length;
|
||||||
|
const completed = calls.filter((c: any) => c.callStatus === 'COMPLETED').length;
|
||||||
|
const totalDuration = calls.reduce((sum: number, c: any) => sum + (c.durationSec ?? 0), 0);
|
||||||
|
const avgDuration = completed > 0 ? Math.round(totalDuration / completed) : 0;
|
||||||
|
|
||||||
|
const dispositions: Record<string, number> = {};
|
||||||
|
for (const c of calls) {
|
||||||
|
if (c.disposition) dispositions[c.disposition] = (dispositions[c.disposition] ?? 0) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
period: period ?? 'week',
|
||||||
|
total,
|
||||||
|
inbound,
|
||||||
|
outbound,
|
||||||
|
missed,
|
||||||
|
completed,
|
||||||
|
missedRate: total > 0 ? `${Math.round((missed / total) * 100)}%` : '0%',
|
||||||
|
avgDurationSeconds: avgDuration,
|
||||||
|
dispositions,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
get_sla_breaches: tool({
|
||||||
|
description: 'Get calls/leads that have breached their SLA — items that were not handled within the expected timeframe.',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
execute: async () => {
|
||||||
|
const data = await platformService.queryWithAuth<any>(
|
||||||
|
`{ calls(first: 100, filter: { callStatus: { eq: MISSED }, callbackstatus: { eq: PENDING_CALLBACK } }) { edges { node { id callerNumber { primaryPhoneNumber } startedAt agentName sla } } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
const breached = data.calls.edges
|
||||||
|
.map((e: any) => e.node)
|
||||||
|
.filter((c: any) => (c.sla ?? 0) > 100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
breachedCount: breached.length,
|
||||||
|
items: breached.map((c: any) => ({
|
||||||
|
id: c.id,
|
||||||
|
phone: c.callerNumber?.primaryPhoneNumber ?? 'Unknown',
|
||||||
|
slaPercent: c.sla,
|
||||||
|
missedAt: c.startedAt,
|
||||||
|
agent: c.agentName,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Agent tools — patient lookup, appointments, doctors
|
||||||
|
const agentTools = {
|
||||||
|
lookup_patient: tool({
|
||||||
|
description: 'Search for a patient/lead by phone number or name. Returns their profile, lead status, AI summary.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
phone: z.string().optional().describe('Phone number to search'),
|
||||||
|
name: z.string().optional().describe('Patient/lead name to search'),
|
||||||
|
}),
|
||||||
|
execute: async ({ phone, name }) => {
|
||||||
|
const data = await platformService.queryWithAuth<any>(
|
||||||
|
`{ leads(first: 50) { edges { node {
|
||||||
|
id name contactName { firstName lastName }
|
||||||
|
contactPhone { primaryPhoneNumber }
|
||||||
|
source status interestedService
|
||||||
|
contactAttempts lastContacted
|
||||||
|
aiSummary aiSuggestedAction patientId
|
||||||
|
} } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
const leads = data.leads.edges.map((e: any) => e.node);
|
||||||
|
const phoneClean = (phone ?? '').replace(/\D/g, '');
|
||||||
|
const nameClean = (name ?? '').toLowerCase();
|
||||||
|
|
||||||
|
const matched = leads.filter((l: any) => {
|
||||||
|
if (phoneClean) {
|
||||||
|
const lp = (l.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, '');
|
||||||
|
if (lp.endsWith(phoneClean) || phoneClean.endsWith(lp)) return true;
|
||||||
|
}
|
||||||
|
if (nameClean) {
|
||||||
|
const fn = `${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase();
|
||||||
|
if (fn.includes(nameClean)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!matched.length) return { found: false, message: 'No patient/lead found.' };
|
||||||
|
return { found: true, count: matched.length, leads: matched };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
lookup_appointments: tool({
|
||||||
|
description: 'Get appointments for a patient. Returns doctor, department, date, status.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
patientId: z.string().describe('Patient ID'),
|
||||||
|
}),
|
||||||
|
execute: async ({ patientId }) => {
|
||||||
|
const data = await platformService.queryWithAuth<any>(
|
||||||
|
`{ appointments(first: 20, filter: { patientId: { eq: "${patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||||
|
id scheduledAt status doctorName department reasonForVisit
|
||||||
|
} } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
return { appointments: data.appointments.edges.map((e: any) => e.node) };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
lookup_doctor: tool({
|
||||||
|
description: 'Get doctor details — schedule, clinic, fees, specialty.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
doctorName: z.string().describe('Doctor name'),
|
||||||
|
}),
|
||||||
|
execute: async ({ doctorName }) => {
|
||||||
|
const data = await platformService.queryWithAuth<any>(
|
||||||
|
`{ doctors(first: 10) { edges { node {
|
||||||
|
id fullName { firstName lastName }
|
||||||
|
department specialty visitingHours
|
||||||
|
consultationFeeNew { amountMicros currencyCode }
|
||||||
|
clinic { clinicName }
|
||||||
|
} } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
const doctors = data.doctors.edges.map((e: any) => e.node);
|
||||||
|
// Strip "Dr." prefix and search flexibly
|
||||||
|
const search = doctorName.toLowerCase().replace(/^dr\.?\s*/i, '').trim();
|
||||||
|
const searchWords = search.split(/\s+/);
|
||||||
|
const matched = doctors.filter((d: any) => {
|
||||||
|
const fn = (d.fullName?.firstName ?? '').toLowerCase();
|
||||||
|
const ln = (d.fullName?.lastName ?? '').toLowerCase();
|
||||||
|
const full = `${fn} ${ln}`;
|
||||||
|
return searchWords.some(w => w.length > 1 && (fn.includes(w) || ln.includes(w) || full.includes(w)));
|
||||||
|
});
|
||||||
|
this.logger.log(`[TOOL] lookup_doctor: search="${doctorName}" → ${matched.length} results`);
|
||||||
|
if (!matched.length) return { found: false, message: `No doctor matching "${doctorName}". Available: ${doctors.map((d: any) => `Dr. ${d.fullName?.lastName ?? d.fullName?.firstName}`).join(', ')}` };
|
||||||
|
return { found: true, doctors: matched };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
book_appointment: tool({
|
||||||
|
description: 'Book an appointment for a patient. Collect patient name, phone, department, doctor, preferred date/time, and reason before calling this.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
patientName: z.string().describe('Full name of the patient'),
|
||||||
|
phoneNumber: z.string().describe('Patient phone number'),
|
||||||
|
department: z.string().describe('Department for the appointment'),
|
||||||
|
doctorName: z.string().describe('Doctor name'),
|
||||||
|
scheduledAt: z.string().describe('Date and time in ISO format (e.g. 2026-04-01T10:00:00)'),
|
||||||
|
reason: z.string().describe('Reason for visit'),
|
||||||
|
}),
|
||||||
|
execute: async ({ patientName, phoneNumber, department, doctorName, scheduledAt, reason }) => {
|
||||||
|
this.logger.log(`[TOOL] book_appointment: ${patientName} | ${phoneNumber} | ${department} | ${doctorName} | ${scheduledAt}`);
|
||||||
|
try {
|
||||||
|
const result = await platformService.queryWithAuth<any>(
|
||||||
|
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
name: `AI Booking — ${patientName} (${department})`,
|
||||||
|
scheduledAt,
|
||||||
|
status: 'SCHEDULED',
|
||||||
|
doctorName,
|
||||||
|
department,
|
||||||
|
reasonForVisit: reason,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
const id = result?.createAppointment?.id;
|
||||||
|
if (id) {
|
||||||
|
return { booked: true, appointmentId: id, message: `Appointment booked for ${patientName} with ${doctorName} on ${scheduledAt}. Reference: ${id.substring(0, 8)}` };
|
||||||
|
}
|
||||||
|
return { booked: false, message: 'Appointment creation failed.' };
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[TOOL] book_appointment failed: ${err.message}`);
|
||||||
|
return { booked: false, message: `Failed to book: ${err.message}` };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
create_lead: tool({
|
||||||
|
description: 'Create a new lead/enquiry for a caller who is not an existing patient. Collect name, phone, and interest.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
name: z.string().describe('Caller name'),
|
||||||
|
phoneNumber: z.string().describe('Phone number'),
|
||||||
|
interest: z.string().describe('What they are enquiring about'),
|
||||||
|
}),
|
||||||
|
execute: async ({ name, phoneNumber, interest }) => {
|
||||||
|
this.logger.log(`[TOOL] create_lead: ${name} | ${phoneNumber} | ${interest}`);
|
||||||
|
try {
|
||||||
|
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
||||||
|
const result = await platformService.queryWithAuth<any>(
|
||||||
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
name: `AI Enquiry — ${name}`,
|
||||||
|
contactName: {
|
||||||
|
firstName: name.split(' ')[0],
|
||||||
|
lastName: name.split(' ').slice(1).join(' ') || '',
|
||||||
|
},
|
||||||
|
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
||||||
|
source: 'PHONE',
|
||||||
|
status: 'NEW',
|
||||||
|
interestedService: interest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
const id = result?.createLead?.id;
|
||||||
|
if (id) {
|
||||||
|
return { created: true, leadId: id, message: `Lead created for ${name}. Our team will follow up on ${phoneNumber}.` };
|
||||||
|
}
|
||||||
|
return { created: false, message: 'Lead creation failed.' };
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[TOOL] create_lead failed: ${err.message}`);
|
||||||
|
return { created: false, message: `Failed: ${err.message}` };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
lookup_call_history: tool({
|
||||||
|
description: 'Get call log for a lead — all inbound/outbound calls with dispositions and durations.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
leadId: z.string().describe('Lead ID'),
|
||||||
|
}),
|
||||||
|
execute: async ({ leadId }) => {
|
||||||
|
const data = await platformService.queryWithAuth<any>(
|
||||||
|
`{ calls(first: 20, filter: { leadId: { eq: "${leadId}" } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
||||||
|
id direction callStatus agentName startedAt durationSec disposition
|
||||||
|
} } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
return { calls: data.calls.edges.map((e: any) => e.node) };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = streamText({
|
||||||
|
model: this.aiModel,
|
||||||
|
system: systemPrompt,
|
||||||
|
messages,
|
||||||
|
stopWhen: stepCountIs(5),
|
||||||
|
tools: isSupervisor ? supervisorTools : agentTools,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = result.toTextStreamResponse();
|
||||||
|
res.status(response.status);
|
||||||
|
response.headers.forEach((value, key) => res.setHeader(key, value));
|
||||||
|
if (response.body) {
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const pump = async () => {
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) { res.end(); break; }
|
||||||
|
res.write(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
pump().catch(() => res.end());
|
||||||
|
} else {
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async buildKnowledgeBase(auth: string): Promise<string> {
|
private async buildKnowledgeBase(auth: string): Promise<string> {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (this.knowledgeBase && now - this.kbLoadedAt < this.kbTtlMs) {
|
if (this.knowledgeBase && now - this.kbLoadedAt < this.kbTtlMs) {
|
||||||
|
this.logger.log(`KB cache hit (${this.knowledgeBase.length} chars, age ${Math.round((now - this.kbLoadedAt) / 1000)}s)`);
|
||||||
return this.knowledgeBase;
|
return this.knowledgeBase;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,17 +512,18 @@ export class AiChatController {
|
|||||||
);
|
);
|
||||||
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');
|
sections.push('## CLINICS & TIMINGS');
|
||||||
for (const c of clinics) {
|
for (const c of clinics) {
|
||||||
|
const name = c.clinicName ?? c.name;
|
||||||
const addr = c.addressCustom
|
const addr = c.addressCustom
|
||||||
? [c.addressCustom.addressStreet1, c.addressCustom.addressCity].filter(Boolean).join(', ')
|
? [c.addressCustom.addressStreet1, c.addressCustom.addressCity].filter(Boolean).join(', ')
|
||||||
: '';
|
: '';
|
||||||
const hours = [
|
sections.push(`### ${name}`);
|
||||||
c.weekdayHours ? `Mon–Fri ${c.weekdayHours}` : '',
|
if (addr) sections.push(` Address: ${addr}`);
|
||||||
c.saturdayHours ? `Sat ${c.saturdayHours}` : '',
|
if (c.weekdayHours) sections.push(` Mon–Fri: ${c.weekdayHours}`);
|
||||||
c.sundayHours ? `Sun ${c.sundayHours}` : 'Sun closed',
|
if (c.saturdayHours) sections.push(` Saturday: ${c.saturdayHours}`);
|
||||||
].filter(Boolean).join(', ');
|
sections.push(` Sunday: ${c.sundayHours ?? 'Closed'}`);
|
||||||
sections.push(`- ${c.clinicName ?? c.name}: ${addr}. ${hours}.`);
|
if (c.walkInAllowed) sections.push(` Walk-ins: Accepted`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rulesClinic = clinics[0];
|
const rulesClinic = clinics[0];
|
||||||
@@ -120,6 +549,36 @@ export class AiChatController {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to fetch clinics: ${err}`);
|
this.logger.warn(`Failed to fetch clinics: ${err}`);
|
||||||
|
sections.push('## CLINICS\nFailed to load clinic data.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add doctors to KB
|
||||||
|
try {
|
||||||
|
const docData = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ doctors(first: 20) { edges { node {
|
||||||
|
fullName { firstName lastName } department specialty visitingHours
|
||||||
|
consultationFeeNew { amountMicros currencyCode }
|
||||||
|
clinic { clinicName }
|
||||||
|
} } } }`,
|
||||||
|
undefined, auth,
|
||||||
|
);
|
||||||
|
const doctors = docData.doctors.edges.map((e: any) => e.node);
|
||||||
|
if (doctors.length) {
|
||||||
|
sections.push('\n## DOCTORS');
|
||||||
|
for (const d of doctors) {
|
||||||
|
const name = `Dr. ${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''}`.trim();
|
||||||
|
const fee = d.consultationFeeNew ? `₹${d.consultationFeeNew.amountMicros / 1_000_000}` : '';
|
||||||
|
const clinic = d.clinic?.clinicName ?? '';
|
||||||
|
sections.push(`### ${name}`);
|
||||||
|
sections.push(` Department: ${d.department ?? 'N/A'}`);
|
||||||
|
sections.push(` Specialty: ${d.specialty ?? 'N/A'}`);
|
||||||
|
if (d.visitingHours) sections.push(` Hours: ${d.visitingHours}`);
|
||||||
|
if (fee) sections.push(` Consultation fee: ${fee}`);
|
||||||
|
if (clinic) sections.push(` Clinic: ${clinic}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to fetch doctors for KB: ${err}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -155,6 +614,7 @@ export class AiChatController {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to fetch health packages: ${err}`);
|
this.logger.warn(`Failed to fetch health packages: ${err}`);
|
||||||
|
sections.push('\n## Health Packages\nFailed to load package data.');
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -175,6 +635,7 @@ export class AiChatController {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to fetch insurance partners: ${err}`);
|
this.logger.warn(`Failed to fetch insurance partners: ${err}`);
|
||||||
|
sections.push('\n## Insurance Partners\nFailed to load insurance data.');
|
||||||
}
|
}
|
||||||
|
|
||||||
this.knowledgeBase = sections.join('\n') || 'No hospital information available yet.';
|
this.knowledgeBase = sections.join('\n') || 'No hospital information available yet.';
|
||||||
@@ -183,26 +644,100 @@ export class AiChatController {
|
|||||||
return this.knowledgeBase;
|
return this.knowledgeBase;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private buildSupervisorSystemPrompt(): string {
|
||||||
|
return `You are an AI assistant for supervisors at Global Hospital's call center (Helix Engage).
|
||||||
|
You help supervisors monitor team performance, identify issues, and make data-driven decisions.
|
||||||
|
|
||||||
|
## YOUR CAPABILITIES
|
||||||
|
You have access to tools that query real-time data:
|
||||||
|
- **Agent performance**: call counts, conversion rates, NPS scores, idle time, pending follow-ups
|
||||||
|
- **Campaign stats**: lead counts, conversion rates per campaign, platform breakdown
|
||||||
|
- **Call summary**: total calls, inbound/outbound split, missed call rate, disposition breakdown
|
||||||
|
- **SLA breaches**: missed calls that haven't been called back within the SLA threshold
|
||||||
|
|
||||||
|
## RULES
|
||||||
|
1. ALWAYS use tools to fetch data before answering. NEVER guess or fabricate performance numbers.
|
||||||
|
2. Be specific — include actual numbers from the tool response, not vague qualifiers.
|
||||||
|
3. When comparing agents, use their configured thresholds (minConversionPercent, minNpsThreshold, maxIdleMinutes) and team averages. Let the data determine who is underperforming — do not assume.
|
||||||
|
4. Be concise — supervisors want quick answers. Use bullet points.
|
||||||
|
5. When recommending actions, ground them in the data returned by tools.
|
||||||
|
6. If asked about trends, use the call summary tool with different periods.
|
||||||
|
7. Do not use any agent name in a negative context unless the data explicitly supports it.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildRulesSystemPrompt(currentConfig: any): string {
|
||||||
|
const configJson = JSON.stringify(currentConfig, null, 2);
|
||||||
|
return `You are an AI assistant helping a hospital supervisor configure the Rules Engine for their call center worklist.
|
||||||
|
|
||||||
|
## YOUR ROLE
|
||||||
|
You help the supervisor understand and optimize priority scoring rules. You explain concepts clearly and make specific recommendations based on their current configuration.
|
||||||
|
|
||||||
|
## SCORING FORMULA
|
||||||
|
finalScore = baseWeight × slaMultiplier × campaignMultiplier
|
||||||
|
|
||||||
|
- **Base Weight** (0-10): Each task type (missed calls, follow-ups, campaign leads, 2nd/3rd attempts) has a configurable weight. Higher weight = higher priority in the worklist.
|
||||||
|
- **SLA Multiplier**: Time-based urgency curve. Formula: elapsed^1.6 (accelerates as SLA deadline approaches). Past SLA breach: 1.0 + (excess × 0.5). This means items get increasingly urgent as they approach their SLA deadline.
|
||||||
|
- **Campaign Multiplier**: (campaignWeight/10) × (sourceWeight/10). IVF(9) × WhatsApp(9) = 0.81. Health(7) × Instagram(5) = 0.35.
|
||||||
|
- **SLA Thresholds**: Each task type has an SLA in minutes. Missed calls default to 12h (720min), follow-ups to 1d (1440min), campaign leads to 2d (2880min).
|
||||||
|
|
||||||
|
## SLA STATUS COLORS
|
||||||
|
- Green (low): < 50% SLA elapsed
|
||||||
|
- Amber (medium): 50-80% SLA elapsed
|
||||||
|
- Red (high): 80-100% SLA elapsed
|
||||||
|
- Dark red pulsing (critical): > 100% SLA elapsed (breached)
|
||||||
|
|
||||||
|
## PRIORITY RULES vs AUTOMATION RULES
|
||||||
|
- **Priority Rules** (what the supervisor is configuring now): Determine worklist order. Computed in real-time at request time. No permanent data changes.
|
||||||
|
- **Automation Rules** (coming soon): Trigger durable actions — assign leads to agents, escalate SLA breaches to supervisors, update lead status automatically. These write back to entity fields and need a draft/publish workflow.
|
||||||
|
|
||||||
|
## BEST PRACTICES FOR HOSPITAL CALL CENTERS
|
||||||
|
- Missed calls should have the highest weight (8-10) — these are patients who tried to reach you
|
||||||
|
- Follow-ups should be high (7-9) — you committed to calling them back
|
||||||
|
- Campaign leads vary by campaign value (5-8)
|
||||||
|
- SLA for missed calls: 4-12 hours (shorter = more responsive)
|
||||||
|
- SLA for follow-ups: 12-24 hours
|
||||||
|
- High-value campaigns (IVF, cancer screening): weight 8-9
|
||||||
|
- General campaigns (health checkup): weight 5-7
|
||||||
|
- WhatsApp/Phone leads convert better than social media → weight them higher
|
||||||
|
|
||||||
|
## CURRENT CONFIGURATION
|
||||||
|
${configJson}
|
||||||
|
|
||||||
|
## RULES
|
||||||
|
1. Be concise — under 100 words unless asked for detail
|
||||||
|
2. When recommending changes, be specific: "Set missed call weight to 9, SLA to 8 hours"
|
||||||
|
3. Explain WHY a change matters: "This ensures IVF patients get called within 4 hours"
|
||||||
|
4. Reference the scoring formula when explaining scores
|
||||||
|
5. If asked about automation rules, explain the concept and say it's coming soon`;
|
||||||
|
}
|
||||||
|
|
||||||
private buildSystemPrompt(kb: string): string {
|
private buildSystemPrompt(kb: string): string {
|
||||||
return `You are an AI assistant for call center agents at a hospital.
|
return `You are an AI assistant for call center agents at Global Hospital, Bangalore.
|
||||||
You help agents answer questions about patients, doctors, appointments, and hospital services during live calls.
|
You help agents answer questions about patients, doctors, appointments, clinics, and hospital services during live calls.
|
||||||
|
|
||||||
|
IMPORTANT — ANSWER FROM KNOWLEDGE BASE FIRST:
|
||||||
|
The knowledge base below contains REAL clinic locations, timings, doctor details, health packages, and insurance partners.
|
||||||
|
When asked about clinic timings, locations, doctor availability, packages, or insurance — ALWAYS check the knowledge base FIRST before saying you don't know.
|
||||||
|
Example: "What are the Koramangala timings?" → Look for "Koramangala" in the Clinics section below.
|
||||||
|
|
||||||
RULES:
|
RULES:
|
||||||
1. For patient-specific questions, you MUST use lookup tools. NEVER guess patient data.
|
1. For patient-specific questions (history, appointments, calls), use the lookup tools. NEVER guess patient data.
|
||||||
2. For doctor schedule/fees, use the lookup_doctor tool to get real data from the system.
|
2. For doctor details beyond what's in the KB, use the lookup_doctor tool.
|
||||||
3. For hospital info (clinics, packages, insurance), use the knowledge base below.
|
3. For clinic info, timings, packages, insurance → answer directly from the knowledge base below.
|
||||||
4. If a tool returns no data, say "I couldn't find that in our system."
|
4. If you truly cannot find the answer in the KB or via tools, say "I couldn't find that in our system."
|
||||||
5. Be concise — agents are on live calls. Under 100 words unless asked for detail.
|
5. Be concise — agents are on live calls. Under 100 words unless asked for detail.
|
||||||
6. NEVER give medical advice, diagnosis, or treatment recommendations.
|
6. NEVER give medical advice, diagnosis, or treatment recommendations.
|
||||||
7. NEVER share sensitive hospital data (revenue, salaries, internal policies).
|
7. Format with bullet points for easy scanning.
|
||||||
8. Format with bullet points for easy scanning.
|
|
||||||
|
|
||||||
|
KNOWLEDGE BASE (this is real data from our system):
|
||||||
${kb}`;
|
${kb}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async chatWithTools(userMessage: string, auth: string) {
|
private async chatWithTools(userMessage: string, auth: string) {
|
||||||
const kb = await this.buildKnowledgeBase(auth);
|
const kb = await this.buildKnowledgeBase(auth);
|
||||||
|
this.logger.log(`KB content preview: ${kb.substring(0, 300)}...`);
|
||||||
const systemPrompt = this.buildSystemPrompt(kb);
|
const systemPrompt = this.buildSystemPrompt(kb);
|
||||||
|
this.logger.log(`System prompt length: ${systemPrompt.length} chars, user message: "${userMessage.substring(0, 100)}"`);
|
||||||
const platformService = this.platform;
|
const platformService = this.platform;
|
||||||
|
|
||||||
const { text, steps } = await generateText({
|
const { text, steps } = await generateText({
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { GraphqlProxyModule } from './graphql-proxy/graphql-proxy.module';
|
|||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
import { WorklistModule } from './worklist/worklist.module';
|
import { WorklistModule } from './worklist/worklist.module';
|
||||||
import { CallAssistModule } from './call-assist/call-assist.module';
|
import { CallAssistModule } from './call-assist/call-assist.module';
|
||||||
|
import { SearchModule } from './search/search.module';
|
||||||
|
import { SupervisorModule } from './supervisor/supervisor.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -28,6 +30,8 @@ import { CallAssistModule } from './call-assist/call-assist.module';
|
|||||||
HealthModule,
|
HealthModule,
|
||||||
WorklistModule,
|
WorklistModule,
|
||||||
CallAssistModule,
|
CallAssistModule,
|
||||||
|
SearchModule,
|
||||||
|
SupervisorModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
70
src/auth/agent-config.service.ts
Normal file
70
src/auth/agent-config.service.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
|
||||||
|
export type AgentConfig = {
|
||||||
|
id: string;
|
||||||
|
ozonetelAgentId: string;
|
||||||
|
sipExtension: string;
|
||||||
|
sipPassword: string;
|
||||||
|
campaignName: string;
|
||||||
|
sipUri: string;
|
||||||
|
sipWsServer: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AgentConfigService {
|
||||||
|
private readonly logger = new Logger(AgentConfigService.name);
|
||||||
|
private readonly cache = new Map<string, AgentConfig>();
|
||||||
|
private readonly sipDomain: string;
|
||||||
|
private readonly sipWsPort: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
private config: ConfigService,
|
||||||
|
) {
|
||||||
|
this.sipDomain = config.get<string>('sip.domain', 'blr-pub-rtc4.ozonetel.com');
|
||||||
|
this.sipWsPort = config.get<string>('sip.wsPort', '444');
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByMemberId(memberId: string): Promise<AgentConfig | null> {
|
||||||
|
const cached = this.cache.get(memberId);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ agents(first: 1, filter: { wsmemberId: { eq: "${memberId}" } }) { edges { node {
|
||||||
|
id ozonetelagentid sipextension sippassword campaignname
|
||||||
|
} } } }`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const node = data?.agents?.edges?.[0]?.node;
|
||||||
|
if (!node || !node.ozonetelagentid || !node.sipextension) return null;
|
||||||
|
|
||||||
|
const agentConfig: AgentConfig = {
|
||||||
|
id: node.id,
|
||||||
|
ozonetelAgentId: node.ozonetelagentid,
|
||||||
|
sipExtension: node.sipextension,
|
||||||
|
sipPassword: node.sippassword ?? node.sipextension,
|
||||||
|
campaignName: node.campaignname ?? process.env.OZONETEL_CAMPAIGN_NAME ?? 'Inbound_918041763265',
|
||||||
|
sipUri: `sip:${node.sipextension}@${this.sipDomain}`,
|
||||||
|
sipWsServer: `wss://${this.sipDomain}:${this.sipWsPort}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.cache.set(memberId, agentConfig);
|
||||||
|
this.logger.log(`Loaded agent config for member ${memberId}: ${agentConfig.ozonetelAgentId} / ${agentConfig.sipExtension}`);
|
||||||
|
return agentConfig;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to fetch agent config: ${err}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getFromCache(memberId: string): AgentConfig | null {
|
||||||
|
return this.cache.get(memberId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearCache(memberId: string): void {
|
||||||
|
this.cache.delete(memberId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { Controller, Post, Body, Logger, HttpException } from '@nestjs/common';
|
import { Controller, Post, Body, Headers, Req, Logger, HttpException } from '@nestjs/common';
|
||||||
|
import type { Request } from 'express';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||||
|
import { SessionService } from './session.service';
|
||||||
|
import { AgentConfigService } from './agent-config.service';
|
||||||
|
|
||||||
@Controller('auth')
|
@Controller('auth')
|
||||||
export class AuthController {
|
export class AuthController {
|
||||||
@@ -13,6 +16,8 @@ export class AuthController {
|
|||||||
constructor(
|
constructor(
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private ozonetelAgent: OzonetelAgentService,
|
private ozonetelAgent: OzonetelAgentService,
|
||||||
|
private sessionService: SessionService,
|
||||||
|
private agentConfigService: AgentConfigService,
|
||||||
) {
|
) {
|
||||||
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
this.graphqlUrl = config.get<string>('platform.graphqlUrl')!;
|
||||||
this.workspaceSubdomain = process.env.PLATFORM_WORKSPACE_SUBDOMAIN ?? 'fortytwo-dev';
|
this.workspaceSubdomain = process.env.PLATFORM_WORKSPACE_SUBDOMAIN ?? 'fortytwo-dev';
|
||||||
@@ -20,7 +25,7 @@ export class AuthController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
async login(@Body() body: { email: string; password: string }) {
|
async login(@Body() body: { email: string; password: string }, @Req() req: Request) {
|
||||||
this.logger.log(`Login attempt for ${body.email}`);
|
this.logger.log(`Login attempt for ${body.email}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -111,20 +116,60 @@ export class AuthController {
|
|||||||
|
|
||||||
this.logger.log(`User ${body.email} logged in with role: ${appRole} (platform roles: ${roleLabels.join(', ')})`);
|
this.logger.log(`User ${body.email} logged in with role: ${appRole} (platform roles: ${roleLabels.join(', ')})`);
|
||||||
|
|
||||||
// Auto-login Ozonetel agent for CC agents (fire and forget)
|
// Check if user has an Agent entity with SIP config — applies to ALL roles
|
||||||
if (appRole === 'cc-agent') {
|
let agentConfigResponse: any = undefined;
|
||||||
const ozAgentId = process.env.OZONETEL_AGENT_ID ?? 'agent3';
|
const memberId = workspaceMember?.id;
|
||||||
const ozAgentPassword = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
|
|
||||||
const ozSipId = process.env.OZONETEL_SIP_ID ?? '521814';
|
|
||||||
|
|
||||||
this.ozonetelAgent.loginAgent({
|
if (memberId) {
|
||||||
agentId: ozAgentId,
|
const agentConfig = await this.agentConfigService.getByMemberId(memberId);
|
||||||
password: ozAgentPassword,
|
|
||||||
phoneNumber: ozSipId,
|
if (agentConfig) {
|
||||||
mode: 'blended',
|
// Agent entity found — set up SIP + Ozonetel
|
||||||
}).catch(err => {
|
const clientIp = (req.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() ?? req.ip ?? 'unknown';
|
||||||
this.logger.warn(`Ozonetel agent login failed (non-blocking): ${err.message}`);
|
const existingSession = await this.sessionService.getSession(agentConfig.ozonetelAgentId);
|
||||||
});
|
if (existingSession) {
|
||||||
|
this.logger.warn(`Duplicate login blocked for ${body.email} — session held by IP ${existingSession.ip} since ${existingSession.lockedAt}`);
|
||||||
|
throw new HttpException(`You are already logged in from another device (${existingSession.ip}). Please log out there first.`, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.sessionService.lockSession(agentConfig.ozonetelAgentId, memberId, clientIp);
|
||||||
|
|
||||||
|
this.ozonetelAgent.refreshToken().catch(err => {
|
||||||
|
this.logger.warn(`Ozonetel token refresh on login failed: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const ozAgentPassword = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
|
||||||
|
this.ozonetelAgent.loginAgent({
|
||||||
|
agentId: agentConfig.ozonetelAgentId,
|
||||||
|
password: ozAgentPassword,
|
||||||
|
phoneNumber: agentConfig.sipExtension,
|
||||||
|
mode: 'blended',
|
||||||
|
}).catch(err => {
|
||||||
|
this.logger.warn(`Ozonetel agent login failed (non-blocking): ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
agentConfigResponse = {
|
||||||
|
ozonetelAgentId: agentConfig.ozonetelAgentId,
|
||||||
|
sipExtension: agentConfig.sipExtension,
|
||||||
|
sipPassword: agentConfig.sipPassword,
|
||||||
|
sipUri: agentConfig.sipUri,
|
||||||
|
sipWsServer: agentConfig.sipWsServer,
|
||||||
|
campaignName: agentConfig.campaignName,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.log(`Agent ${body.email} → Ozonetel ${agentConfig.ozonetelAgentId} / SIP ${agentConfig.sipExtension}`);
|
||||||
|
} else if (appRole === 'cc-agent') {
|
||||||
|
// CC agent role but no Agent entity — block login
|
||||||
|
throw new HttpException('Agent account not configured. Contact administrator.', 403);
|
||||||
|
} else {
|
||||||
|
this.logger.log(`User ${body.email} has no Agent entity — SIP disabled`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache agent name for worklist resolution (avoids re-querying currentUser with user JWT)
|
||||||
|
const agentFullName = `${workspaceMember?.name?.firstName ?? ''} ${workspaceMember?.name?.lastName ?? ''}`.trim();
|
||||||
|
if (agentFullName) {
|
||||||
|
await this.sessionService.setCache(`agent:name:${accessToken.slice(-16)}`, agentFullName, 86400);
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -139,6 +184,7 @@ export class AuthController {
|
|||||||
role: appRole,
|
role: appRole,
|
||||||
platformRoles: roleLabels,
|
platformRoles: roleLabels,
|
||||||
},
|
},
|
||||||
|
...(agentConfigResponse ? { agentConfig: agentConfigResponse } : {}),
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof HttpException) throw error;
|
if (error instanceof HttpException) throw error;
|
||||||
@@ -146,4 +192,98 @@ export class AuthController {
|
|||||||
throw new HttpException('Authentication service unavailable', 503);
|
throw new HttpException('Authentication service unavailable', 503);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('refresh')
|
||||||
|
async refresh(@Body() body: { refreshToken: string }) {
|
||||||
|
if (!body.refreshToken) {
|
||||||
|
throw new HttpException('refreshToken required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log('Token refresh request');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await axios.post(this.graphqlUrl, {
|
||||||
|
query: `mutation RefreshToken($token: String!) {
|
||||||
|
renewToken(appToken: $token) {
|
||||||
|
tokens {
|
||||||
|
accessOrWorkspaceAgnosticToken { token expiresAt }
|
||||||
|
refreshToken { token }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
variables: { token: body.refreshToken },
|
||||||
|
}, {
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.data.errors) {
|
||||||
|
this.logger.warn(`Token refresh failed: ${res.data.errors[0]?.message}`);
|
||||||
|
throw new HttpException('Token refresh failed', 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokens = res.data.data.renewToken.tokens;
|
||||||
|
return {
|
||||||
|
accessToken: tokens.accessOrWorkspaceAgnosticToken.token,
|
||||||
|
refreshToken: tokens.refreshToken.token,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof HttpException) throw error;
|
||||||
|
this.logger.error(`Token refresh failed: ${error}`);
|
||||||
|
throw new HttpException('Token refresh failed', 401);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('logout')
|
||||||
|
async logout(@Headers('authorization') auth: string) {
|
||||||
|
if (!auth) return { status: 'ok' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profileRes = await axios.post(this.graphqlUrl, {
|
||||||
|
query: '{ currentUser { workspaceMember { id } } }',
|
||||||
|
}, { headers: { 'Content-Type': 'application/json', Authorization: auth } });
|
||||||
|
|
||||||
|
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
|
||||||
|
if (!memberId) return { status: 'ok' };
|
||||||
|
|
||||||
|
const agentConfig = this.agentConfigService.getFromCache(memberId);
|
||||||
|
if (agentConfig) {
|
||||||
|
await this.sessionService.unlockSession(agentConfig.ozonetelAgentId);
|
||||||
|
this.logger.log(`Session unlocked for ${agentConfig.ozonetelAgentId}`);
|
||||||
|
|
||||||
|
this.ozonetelAgent.logoutAgent({
|
||||||
|
agentId: agentConfig.ozonetelAgentId,
|
||||||
|
password: process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$',
|
||||||
|
}).catch(err => this.logger.warn(`Ozonetel logout failed: ${err.message}`));
|
||||||
|
|
||||||
|
this.agentConfigService.clearCache(memberId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'ok' };
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Logout cleanup failed: ${err}`);
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('heartbeat')
|
||||||
|
async heartbeat(@Headers('authorization') auth: string) {
|
||||||
|
if (!auth) return { status: 'ok' };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const profileRes = await axios.post(this.graphqlUrl, {
|
||||||
|
query: '{ currentUser { workspaceMember { id } } }',
|
||||||
|
}, { headers: { 'Content-Type': 'application/json', Authorization: auth } });
|
||||||
|
|
||||||
|
const memberId = profileRes.data?.data?.currentUser?.workspaceMember?.id;
|
||||||
|
const agentConfig = memberId ? this.agentConfigService.getFromCache(memberId) : null;
|
||||||
|
|
||||||
|
if (agentConfig) {
|
||||||
|
await this.sessionService.refreshSession(agentConfig.ozonetelAgentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { status: 'ok' };
|
||||||
|
} catch {
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthController } from './auth.controller';
|
import { AuthController } from './auth.controller';
|
||||||
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { SessionService } from './session.service';
|
||||||
|
import { AgentConfigService } from './agent-config.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [OzonetelAgentModule],
|
imports: [OzonetelAgentModule, PlatformModule],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
|
providers: [SessionService, AgentConfigService],
|
||||||
|
exports: [SessionService, AgentConfigService],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
77
src/auth/session.service.ts
Normal file
77
src/auth/session.service.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
|
||||||
|
const SESSION_TTL = 3600; // 1 hour
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SessionService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(SessionService.name);
|
||||||
|
private redis: Redis;
|
||||||
|
|
||||||
|
constructor(private config: ConfigService) {}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
const url = this.config.get<string>('redis.url', 'redis://localhost:6379');
|
||||||
|
this.redis = new Redis(url);
|
||||||
|
this.redis.on('connect', () => this.logger.log('Redis connected'));
|
||||||
|
this.redis.on('error', (err) => this.logger.error(`Redis error: ${err.message}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
private key(agentId: string): string {
|
||||||
|
return `agent:session:${agentId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async lockSession(agentId: string, memberId: string, ip?: string): Promise<void> {
|
||||||
|
const value = JSON.stringify({ memberId, ip: ip ?? 'unknown', lockedAt: new Date().toISOString() });
|
||||||
|
await this.redis.set(this.key(agentId), value, 'EX', SESSION_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getSession(agentId: string): Promise<{ memberId: string; ip: string; lockedAt: string } | null> {
|
||||||
|
const raw = await this.redis.get(this.key(agentId));
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
// Legacy format — just memberId string
|
||||||
|
return { memberId: raw, ip: 'unknown', lockedAt: 'unknown' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async isSessionLocked(agentId: string): Promise<string | null> {
|
||||||
|
const session = await this.getSession(agentId);
|
||||||
|
return session ? session.memberId : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshSession(agentId: string): Promise<void> {
|
||||||
|
await this.redis.expire(this.key(agentId), SESSION_TTL);
|
||||||
|
}
|
||||||
|
|
||||||
|
async unlockSession(agentId: string): Promise<void> {
|
||||||
|
await this.redis.del(this.key(agentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic cache operations for any module
|
||||||
|
async getCache(key: string): Promise<string | null> {
|
||||||
|
return this.redis.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setCache(key: string, value: string, ttlSeconds: number): Promise<void> {
|
||||||
|
await this.redis.set(key, value, 'EX', ttlSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteCache(key: string): Promise<void> {
|
||||||
|
await this.redis.del(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
async scanKeys(pattern: string): Promise<string[]> {
|
||||||
|
const keys: string[] = [];
|
||||||
|
let cursor = '0';
|
||||||
|
do {
|
||||||
|
const [next, batch] = await this.redis.scan(cursor, 'MATCH', pattern, 'COUNT', 100);
|
||||||
|
cursor = next;
|
||||||
|
keys.push(...batch);
|
||||||
|
} while (cursor !== '0');
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,7 +52,7 @@ export class CallAssistService {
|
|||||||
|
|
||||||
const apptResult = await this.platform.queryWithAuth<any>(
|
const apptResult = await this.platform.queryWithAuth<any>(
|
||||||
`{ appointments(first: 10, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
`{ appointments(first: 10, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||||
id scheduledAt appointmentStatus doctorName department reasonForVisit patientId
|
id scheduledAt status doctorName department reasonForVisit patientId
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, authHeader,
|
undefined, authHeader,
|
||||||
);
|
);
|
||||||
@@ -63,7 +63,7 @@ export class CallAssistService {
|
|||||||
parts.push('\nPAST APPOINTMENTS:');
|
parts.push('\nPAST APPOINTMENTS:');
|
||||||
for (const a of appts) {
|
for (const a of appts) {
|
||||||
const date = a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString('en-IN') : '?';
|
const date = a.scheduledAt ? new Date(a.scheduledAt).toLocaleDateString('en-IN') : '?';
|
||||||
parts.push(`- ${date}: ${a.doctorName ?? '?'} (${a.department ?? '?'}) — ${a.appointmentStatus}`);
|
parts.push(`- ${date}: ${a.doctorName ?? '?'} (${a.department ?? '?'}) — ${a.status}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (callerPhone) {
|
} else if (callerPhone) {
|
||||||
|
|||||||
@@ -35,6 +35,20 @@ export class CallEventsGateway {
|
|||||||
this.server.to(room).emit('call:incoming', event);
|
this.server.to(room).emit('call:incoming', event);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Broadcast to supervisors when a new call record is created
|
||||||
|
broadcastCallCreated(callData: any) {
|
||||||
|
this.logger.log('Broadcasting call:created to supervisor room');
|
||||||
|
this.server.to('supervisor').emit('call:created', callData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Supervisor registers to receive real-time updates
|
||||||
|
@SubscribeMessage('supervisor:register')
|
||||||
|
handleSupervisorRegister(@ConnectedSocket() client: Socket) {
|
||||||
|
client.join('supervisor');
|
||||||
|
this.logger.log(`Supervisor registered (socket: ${client.id})`);
|
||||||
|
client.emit('supervisor:registered', { room: 'supervisor' });
|
||||||
|
}
|
||||||
|
|
||||||
// Agent registers when they open the Call Desk page
|
// Agent registers when they open the Call Desk page
|
||||||
@SubscribeMessage('agent:register')
|
@SubscribeMessage('agent:register')
|
||||||
handleAgentRegister(
|
handleAgentRegister(
|
||||||
|
|||||||
@@ -167,7 +167,24 @@ export class CallEventsService {
|
|||||||
`Processing disposition: ${payload.disposition} for call ${payload.callSid}`,
|
`Processing disposition: ${payload.disposition} for call ${payload.callSid}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 1. Create Call record in platform
|
// 1. Compute SLA % if lead is linked
|
||||||
|
let sla: number | undefined;
|
||||||
|
if (payload.leadId && payload.startedAt) {
|
||||||
|
try {
|
||||||
|
const lead = await this.platform.findLeadById(payload.leadId);
|
||||||
|
if (lead?.createdAt) {
|
||||||
|
const leadCreated = new Date(lead.createdAt).getTime();
|
||||||
|
const callStarted = new Date(payload.startedAt).getTime();
|
||||||
|
const elapsedMin = Math.max(0, (callStarted - leadCreated) / 60000);
|
||||||
|
const slaThresholdMin = 1440; // Default 24h; missed calls use 720 but this is a completed call
|
||||||
|
sla = Math.round((elapsedMin / slaThresholdMin) * 100);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// SLA computation is best-effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Create Call record in platform
|
||||||
try {
|
try {
|
||||||
await this.platform.createCall({
|
await this.platform.createCall({
|
||||||
callDirection: 'INBOUND',
|
callDirection: 'INBOUND',
|
||||||
@@ -187,8 +204,11 @@ export class CallEventsService {
|
|||||||
disposition: payload.disposition,
|
disposition: payload.disposition,
|
||||||
callNotes: payload.notes || undefined,
|
callNotes: payload.notes || undefined,
|
||||||
leadId: payload.leadId || undefined,
|
leadId: payload.leadId || undefined,
|
||||||
|
sla,
|
||||||
});
|
});
|
||||||
this.logger.log(`Call record created for ${payload.callSid}`);
|
this.logger.log(`Call record created for ${payload.callSid} (SLA: ${sla ?? 'N/A'}%)`);
|
||||||
|
// Notify supervisors in real-time
|
||||||
|
this.gateway.broadcastCallCreated({ callSid: payload.callSid, agentName: payload.agentName, disposition: payload.disposition });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to create call record: ${error}`);
|
this.logger.error(`Failed to create call record: ${error}`);
|
||||||
}
|
}
|
||||||
|
|||||||
36
src/caller/caller-resolution.controller.ts
Normal file
36
src/caller/caller-resolution.controller.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Controller, Post, Body, Headers, HttpException, HttpStatus, Logger } from '@nestjs/common';
|
||||||
|
import { CallerResolutionService } from './caller-resolution.service';
|
||||||
|
|
||||||
|
@Controller('api/caller')
|
||||||
|
export class CallerResolutionController {
|
||||||
|
private readonly logger = new Logger(CallerResolutionController.name);
|
||||||
|
|
||||||
|
constructor(private readonly resolution: CallerResolutionService) {}
|
||||||
|
|
||||||
|
@Post('resolve')
|
||||||
|
async resolve(
|
||||||
|
@Body('phone') phone: string,
|
||||||
|
@Headers('authorization') auth: string,
|
||||||
|
) {
|
||||||
|
if (!phone) {
|
||||||
|
throw new HttpException('phone is required', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
if (!auth) {
|
||||||
|
throw new HttpException('Authorization header required', HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[RESOLVE] Resolving caller: ${phone}`);
|
||||||
|
const result = await this.resolution.resolve(phone, auth);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('invalidate')
|
||||||
|
async invalidate(@Body('phone') phone: string) {
|
||||||
|
if (!phone) {
|
||||||
|
throw new HttpException('phone is required', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
this.logger.log(`[RESOLVE] Invalidating cache for: ${phone}`);
|
||||||
|
await this.resolution.invalidate(phone);
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/caller/caller-resolution.module.ts
Normal file
13
src/caller/caller-resolution.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 { CallerResolutionController } from './caller-resolution.controller';
|
||||||
|
import { CallerResolutionService } from './caller-resolution.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule, AuthModule],
|
||||||
|
controllers: [CallerResolutionController],
|
||||||
|
providers: [CallerResolutionService],
|
||||||
|
exports: [CallerResolutionService],
|
||||||
|
})
|
||||||
|
export class CallerResolutionModule {}
|
||||||
216
src/caller/caller-resolution.service.ts
Normal file
216
src/caller/caller-resolution.service.ts
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { SessionService } from '../auth/session.service';
|
||||||
|
|
||||||
|
const CACHE_TTL = 3600; // 1 hour
|
||||||
|
const CACHE_PREFIX = 'caller:';
|
||||||
|
|
||||||
|
export type ResolvedCaller = {
|
||||||
|
leadId: string;
|
||||||
|
patientId: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
phone: string;
|
||||||
|
isNew: boolean; // true if we just created the lead+patient pair
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CallerResolutionService {
|
||||||
|
private readonly logger = new Logger(CallerResolutionService.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly platform: PlatformGraphqlService,
|
||||||
|
private readonly cache: SessionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Resolve a caller by phone number. Always returns a paired lead + patient.
|
||||||
|
async resolve(phone: string, auth: string): Promise<ResolvedCaller> {
|
||||||
|
const normalized = phone.replace(/\D/g, '').slice(-10);
|
||||||
|
if (normalized.length < 10) {
|
||||||
|
throw new Error(`Invalid phone number: ${phone}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Check cache
|
||||||
|
const cached = await this.cache.getCache(`${CACHE_PREFIX}${normalized}`);
|
||||||
|
if (cached) {
|
||||||
|
this.logger.log(`[RESOLVE] Cache hit for ${normalized}`);
|
||||||
|
return JSON.parse(cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Look up lead by phone
|
||||||
|
const lead = await this.findLeadByPhone(normalized, auth);
|
||||||
|
|
||||||
|
// 3. Look up patient by phone
|
||||||
|
const patient = await this.findPatientByPhone(normalized, auth);
|
||||||
|
|
||||||
|
let result: ResolvedCaller;
|
||||||
|
|
||||||
|
if (lead && patient) {
|
||||||
|
// Both exist — link them if not already linked
|
||||||
|
if (!lead.patientId) {
|
||||||
|
await this.linkLeadToPatient(lead.id, patient.id, auth);
|
||||||
|
this.logger.log(`[RESOLVE] Linked existing lead ${lead.id} → patient ${patient.id}`);
|
||||||
|
}
|
||||||
|
result = {
|
||||||
|
leadId: lead.id,
|
||||||
|
patientId: patient.id,
|
||||||
|
firstName: lead.firstName || patient.firstName,
|
||||||
|
lastName: lead.lastName || patient.lastName,
|
||||||
|
phone: normalized,
|
||||||
|
isNew: false,
|
||||||
|
};
|
||||||
|
} else if (lead && !patient) {
|
||||||
|
// Lead exists, no patient — create patient
|
||||||
|
const newPatient = await this.createPatient(lead.firstName, lead.lastName, normalized, auth);
|
||||||
|
await this.linkLeadToPatient(lead.id, newPatient.id, auth);
|
||||||
|
this.logger.log(`[RESOLVE] Created patient ${newPatient.id} for existing lead ${lead.id}`);
|
||||||
|
result = {
|
||||||
|
leadId: lead.id,
|
||||||
|
patientId: newPatient.id,
|
||||||
|
firstName: lead.firstName,
|
||||||
|
lastName: lead.lastName,
|
||||||
|
phone: normalized,
|
||||||
|
isNew: false,
|
||||||
|
};
|
||||||
|
} else if (!lead && patient) {
|
||||||
|
// Patient exists, no lead — create lead
|
||||||
|
const newLead = await this.createLead(patient.firstName, patient.lastName, normalized, patient.id, auth);
|
||||||
|
this.logger.log(`[RESOLVE] Created lead ${newLead.id} for existing patient ${patient.id}`);
|
||||||
|
result = {
|
||||||
|
leadId: newLead.id,
|
||||||
|
patientId: patient.id,
|
||||||
|
firstName: patient.firstName,
|
||||||
|
lastName: patient.lastName,
|
||||||
|
phone: normalized,
|
||||||
|
isNew: false,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Neither exists — create both
|
||||||
|
const newPatient = await this.createPatient('', '', normalized, auth);
|
||||||
|
const newLead = await this.createLead('', '', normalized, newPatient.id, auth);
|
||||||
|
this.logger.log(`[RESOLVE] Created new lead ${newLead.id} + patient ${newPatient.id} for ${normalized}`);
|
||||||
|
result = {
|
||||||
|
leadId: newLead.id,
|
||||||
|
patientId: newPatient.id,
|
||||||
|
firstName: '',
|
||||||
|
lastName: '',
|
||||||
|
phone: normalized,
|
||||||
|
isNew: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Cache the result
|
||||||
|
await this.cache.setCache(`${CACHE_PREFIX}${normalized}`, JSON.stringify(result), CACHE_TTL);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invalidate cache for a phone number (call after updates)
|
||||||
|
async invalidate(phone: string): Promise<void> {
|
||||||
|
const normalized = phone.replace(/\D/g, '').slice(-10);
|
||||||
|
await this.cache.setCache(`${CACHE_PREFIX}${normalized}`, '', 1); // expire immediately
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findLeadByPhone(phone10: string, auth: string): Promise<{ id: string; firstName: string; lastName: string; patientId: string | null } | null> {
|
||||||
|
try {
|
||||||
|
const data = await this.platform.queryWithAuth<{ leads: { edges: { node: any }[] } }>(
|
||||||
|
`{ leads(first: 200) { edges { node {
|
||||||
|
id
|
||||||
|
contactName { firstName lastName }
|
||||||
|
contactPhone { primaryPhoneNumber }
|
||||||
|
patientId
|
||||||
|
} } } }`,
|
||||||
|
undefined,
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
|
||||||
|
const match = data.leads.edges.find(e => {
|
||||||
|
const num = (e.node.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, '').slice(-10);
|
||||||
|
return num.length >= 10 && num === phone10;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: match.node.id,
|
||||||
|
firstName: match.node.contactName?.firstName ?? '',
|
||||||
|
lastName: match.node.contactName?.lastName ?? '',
|
||||||
|
patientId: match.node.patientId || null,
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[RESOLVE] Lead lookup failed: ${err.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findPatientByPhone(phone10: string, auth: string): Promise<{ id: string; firstName: string; lastName: string } | null> {
|
||||||
|
try {
|
||||||
|
const data = await this.platform.queryWithAuth<{ patients: { edges: { node: any }[] } }>(
|
||||||
|
`{ patients(first: 200) { edges { node {
|
||||||
|
id
|
||||||
|
fullName { firstName lastName }
|
||||||
|
phones { primaryPhoneNumber }
|
||||||
|
} } } }`,
|
||||||
|
undefined,
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
|
||||||
|
const match = data.patients.edges.find(e => {
|
||||||
|
const num = (e.node.phones?.primaryPhoneNumber ?? '').replace(/\D/g, '').slice(-10);
|
||||||
|
return num.length >= 10 && num === phone10;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: match.node.id,
|
||||||
|
firstName: match.node.fullName?.firstName ?? '',
|
||||||
|
lastName: match.node.fullName?.lastName ?? '',
|
||||||
|
};
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`[RESOLVE] Patient lookup failed: ${err.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createPatient(firstName: string, lastName: string, phone: string, auth: string): Promise<{ id: string }> {
|
||||||
|
const data = await this.platform.queryWithAuth<{ createPatient: { id: string } }>(
|
||||||
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
fullName: { firstName: firstName || 'Unknown', lastName: lastName || '' },
|
||||||
|
phones: { primaryPhoneNumber: `+91${phone}` },
|
||||||
|
patientType: 'NEW',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
return data.createPatient;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createLead(firstName: string, lastName: string, phone: string, patientId: string, auth: string): Promise<{ id: string }> {
|
||||||
|
const data = await this.platform.queryWithAuth<{ createLead: { id: string } }>(
|
||||||
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
name: `${firstName} ${lastName}`.trim() || 'Unknown Caller',
|
||||||
|
contactName: { firstName: firstName || 'Unknown', lastName: lastName || '' },
|
||||||
|
contactPhone: { primaryPhoneNumber: `+91${phone}` },
|
||||||
|
source: 'PHONE',
|
||||||
|
status: 'NEW',
|
||||||
|
patientId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
return data.createLead;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async linkLeadToPatient(leadId: string, patientId: string, auth: string): Promise<void> {
|
||||||
|
await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: leadId, data: { patientId } },
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/config/config-theme.module.ts
Normal file
10
src/config/config-theme.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ThemeController } from './theme.controller';
|
||||||
|
import { ThemeService } from './theme.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [ThemeController],
|
||||||
|
providers: [ThemeService],
|
||||||
|
exports: [ThemeService],
|
||||||
|
})
|
||||||
|
export class ConfigThemeModule {}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
export default () => ({
|
export default () => ({
|
||||||
port: parseInt(process.env.PORT ?? '4100', 10),
|
port: parseInt(process.env.PORT ?? '4100', 10),
|
||||||
corsOrigin: process.env.CORS_ORIGIN ?? 'http://localhost:5173',
|
corsOrigins: (process.env.CORS_ORIGINS ?? 'http://localhost:5173')
|
||||||
|
.split(',')
|
||||||
|
.map(origin => origin.trim())
|
||||||
|
.filter(origin => origin.length > 0),
|
||||||
platform: {
|
platform: {
|
||||||
graphqlUrl: process.env.PLATFORM_GRAPHQL_URL ?? 'http://localhost:4000/graphql',
|
graphqlUrl: process.env.PLATFORM_GRAPHQL_URL ?? 'http://localhost:4000/graphql',
|
||||||
apiKey: process.env.PLATFORM_API_KEY ?? '',
|
apiKey: process.env.PLATFORM_API_KEY ?? '',
|
||||||
@@ -12,6 +15,16 @@ export default () => ({
|
|||||||
subdomain: process.env.EXOTEL_SUBDOMAIN ?? 'api.exotel.com',
|
subdomain: process.env.EXOTEL_SUBDOMAIN ?? 'api.exotel.com',
|
||||||
webhookSecret: process.env.EXOTEL_WEBHOOK_SECRET ?? '',
|
webhookSecret: process.env.EXOTEL_WEBHOOK_SECRET ?? '',
|
||||||
},
|
},
|
||||||
|
redis: {
|
||||||
|
url: process.env.REDIS_URL ?? 'redis://localhost:6379',
|
||||||
|
},
|
||||||
|
sip: {
|
||||||
|
domain: process.env.SIP_DOMAIN ?? 'blr-pub-rtc4.ozonetel.com',
|
||||||
|
wsPort: process.env.SIP_WS_PORT ?? '444',
|
||||||
|
},
|
||||||
|
missedQueue: {
|
||||||
|
pollIntervalMs: parseInt(process.env.MISSED_QUEUE_POLL_INTERVAL_MS ?? '30000', 10),
|
||||||
|
},
|
||||||
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 ?? '',
|
||||||
|
|||||||
27
src/config/theme.controller.ts
Normal file
27
src/config/theme.controller.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { Controller, Get, Put, Post, Body, Logger } from '@nestjs/common';
|
||||||
|
import { ThemeService } from './theme.service';
|
||||||
|
import type { ThemeConfig } from './theme.defaults';
|
||||||
|
|
||||||
|
@Controller('api/config')
|
||||||
|
export class ThemeController {
|
||||||
|
private readonly logger = new Logger(ThemeController.name);
|
||||||
|
|
||||||
|
constructor(private readonly theme: ThemeService) {}
|
||||||
|
|
||||||
|
@Get('theme')
|
||||||
|
getTheme() {
|
||||||
|
return this.theme.getTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('theme')
|
||||||
|
updateTheme(@Body() body: Partial<ThemeConfig>) {
|
||||||
|
this.logger.log('Theme update request');
|
||||||
|
return this.theme.updateTheme(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('theme/reset')
|
||||||
|
resetTheme() {
|
||||||
|
this.logger.log('Theme reset request');
|
||||||
|
return this.theme.resetTheme();
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/config/theme.defaults.ts
Normal file
79
src/config/theme.defaults.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
export type ThemeConfig = {
|
||||||
|
version?: number;
|
||||||
|
updatedAt?: string;
|
||||||
|
brand: {
|
||||||
|
name: string;
|
||||||
|
hospitalName: string;
|
||||||
|
logo: string;
|
||||||
|
favicon: string;
|
||||||
|
};
|
||||||
|
colors: {
|
||||||
|
brand: Record<string, string>;
|
||||||
|
};
|
||||||
|
typography: {
|
||||||
|
body: string;
|
||||||
|
display: string;
|
||||||
|
};
|
||||||
|
login: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
showGoogleSignIn: boolean;
|
||||||
|
showForgotPassword: boolean;
|
||||||
|
poweredBy: { label: string; url: string };
|
||||||
|
};
|
||||||
|
sidebar: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
};
|
||||||
|
ai: {
|
||||||
|
quickActions: Array<{ label: string; prompt: string }>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_THEME: ThemeConfig = {
|
||||||
|
brand: {
|
||||||
|
name: 'Helix Engage',
|
||||||
|
hospitalName: 'Global Hospital',
|
||||||
|
logo: '/helix-logo.png',
|
||||||
|
favicon: '/favicon.ico',
|
||||||
|
},
|
||||||
|
colors: {
|
||||||
|
brand: {
|
||||||
|
'25': 'rgb(239 246 255)',
|
||||||
|
'50': 'rgb(219 234 254)',
|
||||||
|
'100': 'rgb(191 219 254)',
|
||||||
|
'200': 'rgb(147 197 253)',
|
||||||
|
'300': 'rgb(96 165 250)',
|
||||||
|
'400': 'rgb(59 130 246)',
|
||||||
|
'500': 'rgb(37 99 235)',
|
||||||
|
'600': 'rgb(29 78 216)',
|
||||||
|
'700': 'rgb(30 64 175)',
|
||||||
|
'800': 'rgb(30 58 138)',
|
||||||
|
'900': 'rgb(23 37 84)',
|
||||||
|
'950': 'rgb(15 23 42)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
typography: {
|
||||||
|
body: "'Satoshi', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif",
|
||||||
|
display: "'General Sans', var(--font-inter, 'Inter'), -apple-system, 'Segoe UI', Roboto, Arial, sans-serif",
|
||||||
|
},
|
||||||
|
login: {
|
||||||
|
title: 'Sign in to Helix Engage',
|
||||||
|
subtitle: 'Global Hospital',
|
||||||
|
showGoogleSignIn: true,
|
||||||
|
showForgotPassword: true,
|
||||||
|
poweredBy: { label: 'Powered by F0rty2.ai', url: 'https://f0rty2.ai' },
|
||||||
|
},
|
||||||
|
sidebar: {
|
||||||
|
title: 'Helix Engage',
|
||||||
|
subtitle: 'Global Hospital \u00b7 {role}',
|
||||||
|
},
|
||||||
|
ai: {
|
||||||
|
quickActions: [
|
||||||
|
{ label: 'Doctor availability', prompt: 'What doctors are available and what are their visiting hours?' },
|
||||||
|
{ label: 'Clinic timings', prompt: 'What are the clinic locations and timings?' },
|
||||||
|
{ label: 'Patient history', prompt: "Can you summarize this patient's history?" },
|
||||||
|
{ label: 'Treatment packages', prompt: 'What treatment packages are available?' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
98
src/config/theme.service.ts
Normal file
98
src/config/theme.service.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { readFileSync, writeFileSync, existsSync, mkdirSync, copyFileSync } from 'fs';
|
||||||
|
import { join, dirname } from 'path';
|
||||||
|
import { DEFAULT_THEME, type ThemeConfig } from './theme.defaults';
|
||||||
|
|
||||||
|
const THEME_PATH = join(process.cwd(), 'data', 'theme.json');
|
||||||
|
const BACKUP_DIR = join(process.cwd(), 'data', 'theme-backups');
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ThemeService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(ThemeService.name);
|
||||||
|
private cached: ThemeConfig | null = null;
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
getTheme(): ThemeConfig {
|
||||||
|
if (this.cached) return this.cached;
|
||||||
|
return this.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTheme(updates: Partial<ThemeConfig>): ThemeConfig {
|
||||||
|
const current = this.getTheme();
|
||||||
|
|
||||||
|
const merged: ThemeConfig = {
|
||||||
|
brand: { ...current.brand, ...updates.brand },
|
||||||
|
colors: {
|
||||||
|
brand: { ...current.colors.brand, ...updates.colors?.brand },
|
||||||
|
},
|
||||||
|
typography: { ...current.typography, ...updates.typography },
|
||||||
|
login: { ...current.login, ...updates.login, poweredBy: { ...current.login.poweredBy, ...updates.login?.poweredBy } },
|
||||||
|
sidebar: { ...current.sidebar, ...updates.sidebar },
|
||||||
|
ai: {
|
||||||
|
quickActions: updates.ai?.quickActions ?? current.ai.quickActions,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
merged.version = (current.version ?? 0) + 1;
|
||||||
|
merged.updatedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
this.backup();
|
||||||
|
|
||||||
|
const dir = dirname(THEME_PATH);
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(THEME_PATH, JSON.stringify(merged, null, 2), 'utf8');
|
||||||
|
this.cached = merged;
|
||||||
|
|
||||||
|
this.logger.log(`Theme updated to v${merged.version}`);
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
resetTheme(): ThemeConfig {
|
||||||
|
this.backup();
|
||||||
|
const dir = dirname(THEME_PATH);
|
||||||
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
|
writeFileSync(THEME_PATH, JSON.stringify(DEFAULT_THEME, null, 2), 'utf8');
|
||||||
|
this.cached = DEFAULT_THEME;
|
||||||
|
this.logger.log('Theme reset to defaults');
|
||||||
|
return DEFAULT_THEME;
|
||||||
|
}
|
||||||
|
|
||||||
|
private load(): ThemeConfig {
|
||||||
|
try {
|
||||||
|
if (existsSync(THEME_PATH)) {
|
||||||
|
const raw = readFileSync(THEME_PATH, 'utf8');
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
this.cached = {
|
||||||
|
brand: { ...DEFAULT_THEME.brand, ...parsed.brand },
|
||||||
|
colors: { brand: { ...DEFAULT_THEME.colors.brand, ...parsed.colors?.brand } },
|
||||||
|
typography: { ...DEFAULT_THEME.typography, ...parsed.typography },
|
||||||
|
login: { ...DEFAULT_THEME.login, ...parsed.login, poweredBy: { ...DEFAULT_THEME.login.poweredBy, ...parsed.login?.poweredBy } },
|
||||||
|
sidebar: { ...DEFAULT_THEME.sidebar, ...parsed.sidebar },
|
||||||
|
ai: { quickActions: parsed.ai?.quickActions ?? DEFAULT_THEME.ai.quickActions },
|
||||||
|
};
|
||||||
|
this.logger.log('Theme loaded from file');
|
||||||
|
return this.cached;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to load theme: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cached = DEFAULT_THEME;
|
||||||
|
this.logger.log('Using default theme');
|
||||||
|
return DEFAULT_THEME;
|
||||||
|
}
|
||||||
|
|
||||||
|
private backup() {
|
||||||
|
try {
|
||||||
|
if (!existsSync(THEME_PATH)) return;
|
||||||
|
if (!existsSync(BACKUP_DIR)) mkdirSync(BACKUP_DIR, { recursive: true });
|
||||||
|
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
|
copyFileSync(THEME_PATH, join(BACKUP_DIR, `theme-${ts}.json`));
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Backup failed: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/embed/embed.module.ts
Normal file
9
src/embed/embed.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { LeadEmbedController } from './lead-embed.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule],
|
||||||
|
controllers: [LeadEmbedController],
|
||||||
|
})
|
||||||
|
export class EmbedModule {}
|
||||||
227
src/embed/lead-embed.controller.ts
Normal file
227
src/embed/lead-embed.controller.ts
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { Controller, Post, Body, Logger, HttpException } from '@nestjs/common';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
@Controller('embed/leads')
|
||||||
|
export class LeadEmbedController {
|
||||||
|
private readonly logger = new Logger(LeadEmbedController.name);
|
||||||
|
private readonly apiKey: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly platform: PlatformGraphqlService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {
|
||||||
|
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('create')
|
||||||
|
async handleLeadCreation(@Body() body: Record<string, any>) {
|
||||||
|
console.log("Lead creation from embed received:", body);
|
||||||
|
this.logger.log(`Lead creation from embed received: ${JSON.stringify(body)}`);
|
||||||
|
|
||||||
|
const authHeader = this.apiKey ? `Bearer ${this.apiKey}` : '';
|
||||||
|
if (!authHeader) {
|
||||||
|
this.logger.warn('No PLATFORM_API_KEY configured — cannot create lead');
|
||||||
|
throw new HttpException('Server configuration error', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const leadData = this.mapIncomingDataToLead(body);
|
||||||
|
|
||||||
|
if (!leadData.contactPhone && !leadData.contactEmail) {
|
||||||
|
throw new HttpException('Either contact phone or email is required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Look up campaign by name and link via relation if not already set by ID
|
||||||
|
if (!leadData.campaignId) {
|
||||||
|
const campaignName = body.utm_campaign || body.utmCampaign || body.campaign;
|
||||||
|
if (campaignName) {
|
||||||
|
const campaignId = await this.lookupCampaignByName(campaignName, authHeader);
|
||||||
|
if (campaignId) {
|
||||||
|
leadData.campaignId = campaignId;
|
||||||
|
this.logger.log(`Matched campaign "${campaignName}" → ${campaignId}`);
|
||||||
|
} else {
|
||||||
|
this.logger.warn(`No campaign found matching name: "${campaignName}"`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
|
{ data: leadData },
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
|
||||||
|
const leadId = result.createLead.id;
|
||||||
|
this.logger.log(`Lead created successfully: ${leadId}`);
|
||||||
|
|
||||||
|
if (body.notes || body.type) {
|
||||||
|
await this.createInitialActivity(leadId, body, authHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
leadId,
|
||||||
|
message: 'Lead created successfully',
|
||||||
|
};
|
||||||
|
} catch (error: any) {
|
||||||
|
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
||||||
|
this.logger.error(`Lead creation failed: ${error.message} ${responseData}`);
|
||||||
|
|
||||||
|
if (error instanceof HttpException) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new HttpException(
|
||||||
|
error.message || 'Lead creation failed',
|
||||||
|
error.response?.status || 500,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapIncomingDataToLead(body: Record<string, any>): Record<string, any> {
|
||||||
|
const leadData: Record<string, any> = {};
|
||||||
|
|
||||||
|
const contactName = body.contact_name || body.contactName || 'Unknown';
|
||||||
|
const nameParts = contactName.split(' ');
|
||||||
|
const firstName = nameParts[0] || 'Unknown';
|
||||||
|
const lastName = nameParts.slice(1).join(' ');
|
||||||
|
|
||||||
|
leadData.name = contactName;
|
||||||
|
leadData.contactName = {
|
||||||
|
firstName,
|
||||||
|
lastName: lastName || undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (body.contact_phone || body.contactPhone) {
|
||||||
|
const phone = body.contact_phone || body.contactPhone;
|
||||||
|
const cleanPhone = phone.replace(/\D/g, '');
|
||||||
|
leadData.contactPhone = {
|
||||||
|
primaryPhoneNumber: cleanPhone.startsWith('91') ? `+${cleanPhone}` : `+91${cleanPhone}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.contact_email || body.contactEmail) {
|
||||||
|
leadData.contactEmail = {
|
||||||
|
primaryEmail: body.contact_email || body.contactEmail,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
leadData.source = body.source || 'WEBSITE';
|
||||||
|
leadData.status = body.lead_status || body.status || 'NEW';
|
||||||
|
|
||||||
|
const interestedService = this.mapInterestedService(body);
|
||||||
|
if (interestedService) {
|
||||||
|
leadData.interestedService = interestedService;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.assigned_agent || body.assignedAgent) {
|
||||||
|
leadData.assignedAgent = body.assigned_agent || body.assignedAgent;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (body.campaign_id || body.campaignId) {
|
||||||
|
leadData.campaignId = body.campaign_id || body.campaignId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// UTM tracking fields
|
||||||
|
const utmCampaign = body.utm_campaign || body.utmCampaign || body.campaign;
|
||||||
|
if (utmCampaign) {
|
||||||
|
leadData.utmCampaign = utmCampaign;
|
||||||
|
}
|
||||||
|
if (body.utm_source || body.utmSource) {
|
||||||
|
leadData.utmSource = body.utm_source || body.utmSource;
|
||||||
|
}
|
||||||
|
if (body.utm_medium || body.utmMedium) {
|
||||||
|
leadData.utmMedium = body.utm_medium || body.utmMedium;
|
||||||
|
}
|
||||||
|
if (body.utm_content || body.utmContent) {
|
||||||
|
leadData.utmContent = body.utm_content || body.utmContent;
|
||||||
|
}
|
||||||
|
if (body.utm_term || body.utmTerm) {
|
||||||
|
leadData.utmTerm = body.utm_term || body.utmTerm;
|
||||||
|
}
|
||||||
|
|
||||||
|
return leadData;
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapInterestedService(body: Record<string, any>): string | null {
|
||||||
|
const type = body.type || body.interested_service || body.interestedService;
|
||||||
|
|
||||||
|
if (!type) {
|
||||||
|
return body.department || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serviceMap: Record<string, string> = {
|
||||||
|
'consultation': 'Appointment',
|
||||||
|
'follow_up': 'Appointment',
|
||||||
|
'procedure': 'Appointment',
|
||||||
|
'emergency': 'Appointment',
|
||||||
|
'general_enquiry': 'General Enquiry',
|
||||||
|
'general': 'General Enquiry',
|
||||||
|
};
|
||||||
|
|
||||||
|
return serviceMap[type.toLowerCase()] || type;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async lookupCampaignByName(name: string, authHeader: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
|
`{ campaigns(first: 50) { edges { node { id campaignName } } } }`,
|
||||||
|
undefined,
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
const campaigns = data?.campaigns?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
const match = campaigns.find(
|
||||||
|
(c: any) => (c.campaignName ?? '').toLowerCase() === name.toLowerCase(),
|
||||||
|
);
|
||||||
|
return match?.id ?? null;
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`Campaign lookup failed: ${err.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createInitialActivity(
|
||||||
|
leadId: string,
|
||||||
|
body: Record<string, any>,
|
||||||
|
authHeader: string,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const activityType = body.type === 'consultation' || body.type === 'appointment'
|
||||||
|
? 'APPOINTMENT_BOOKED'
|
||||||
|
: 'CALL_RECEIVED';
|
||||||
|
|
||||||
|
let summary = 'Lead submitted via web form';
|
||||||
|
if (body.type) {
|
||||||
|
summary = `${body.type.replace(/_/g, ' ')} requested`;
|
||||||
|
}
|
||||||
|
if (body.department) {
|
||||||
|
summary += ` - ${body.department}`;
|
||||||
|
}
|
||||||
|
if (body.title) {
|
||||||
|
summary += ` (from ${body.title})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($data: LeadActivityCreateInput!) { createLeadActivity(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
name: summary.substring(0, 80),
|
||||||
|
activityType,
|
||||||
|
summary,
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
performedBy: 'System',
|
||||||
|
channel: 'PHONE',
|
||||||
|
leadId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Initial activity created for lead ${leadId}`);
|
||||||
|
} catch (error: any) {
|
||||||
|
const errorDetails = error?.response?.data ? JSON.stringify(error.response.data) : error.message;
|
||||||
|
this.logger.error(`Failed to create initial activity: ${errorDetails}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
119
src/events/consumers/ai-insight.consumer.ts
Normal file
119
src/events/consumers/ai-insight.consumer.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { generateObject } from 'ai';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { EventBusService } from '../event-bus.service';
|
||||||
|
import { Topics } from '../event-types';
|
||||||
|
import type { CallCompletedEvent } from '../event-types';
|
||||||
|
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
|
||||||
|
import { createAiModel } from '../../ai/ai-provider';
|
||||||
|
import type { LanguageModel } from 'ai';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AiInsightConsumer implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(AiInsightConsumer.name);
|
||||||
|
private readonly aiModel: LanguageModel | null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private eventBus: EventBusService,
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
private config: ConfigService,
|
||||||
|
) {
|
||||||
|
this.aiModel = createAiModel(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
this.eventBus.on(Topics.CALL_COMPLETED, (event: CallCompletedEvent) => this.handleCallCompleted(event));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleCallCompleted(event: CallCompletedEvent): Promise<void> {
|
||||||
|
if (!event.leadId) {
|
||||||
|
this.logger.debug('[AI-INSIGHT] No leadId — skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.aiModel) {
|
||||||
|
this.logger.debug('[AI-INSIGHT] No AI model configured — skipping');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[AI-INSIGHT] Generating insight for lead ${event.leadId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch lead + all activities
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ leads(filter: { id: { eq: "${event.leadId}" } }) { edges { node {
|
||||||
|
id name contactName { firstName lastName }
|
||||||
|
status source interestedService
|
||||||
|
contactAttempts lastContacted
|
||||||
|
} } } }`,
|
||||||
|
);
|
||||||
|
const lead = data?.leads?.edges?.[0]?.node;
|
||||||
|
if (!lead) return;
|
||||||
|
|
||||||
|
const activityData = await this.platform.query<any>(
|
||||||
|
`{ leadActivities(first: 20, filter: { leadId: { eq: "${event.leadId}" } }, orderBy: [{ occurredAt: DescNullsLast }]) {
|
||||||
|
edges { node { activityType summary occurredAt channel durationSec outcome } }
|
||||||
|
} }`,
|
||||||
|
);
|
||||||
|
const activities = activityData?.leadActivities?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
|
||||||
|
const leadName = lead.contactName
|
||||||
|
? `${lead.contactName.firstName ?? ''} ${lead.contactName.lastName ?? ''}`.trim()
|
||||||
|
: lead.name ?? 'Unknown';
|
||||||
|
|
||||||
|
// Build context
|
||||||
|
const activitySummary = activities.map((a: any) =>
|
||||||
|
`${a.activityType}: ${a.summary} (${a.occurredAt ?? 'unknown date'})`,
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
// Generate insight
|
||||||
|
const { object } = await generateObject({
|
||||||
|
model: this.aiModel,
|
||||||
|
schema: z.object({
|
||||||
|
summary: z.string().describe('2-3 sentence summary of this lead based on all their interactions'),
|
||||||
|
suggestedAction: z.string().describe('One clear next action for the agent'),
|
||||||
|
}),
|
||||||
|
system: `You are a CRM assistant for Global Hospital Bangalore.
|
||||||
|
Generate a brief, actionable insight about this lead based on their interaction history.
|
||||||
|
Be specific — reference actual dates, dispositions, and patterns.
|
||||||
|
If the lead has booked appointments, mention upcoming ones.
|
||||||
|
If they keep calling about the same thing, note the pattern.`,
|
||||||
|
prompt: `Lead: ${leadName}
|
||||||
|
Status: ${lead.status ?? 'Unknown'}
|
||||||
|
Source: ${lead.source ?? 'Unknown'}
|
||||||
|
Interested in: ${lead.interestedService ?? 'Not specified'}
|
||||||
|
Contact attempts: ${lead.contactAttempts ?? 0}
|
||||||
|
Last contacted: ${lead.lastContacted ?? 'Never'}
|
||||||
|
|
||||||
|
Recent activity (newest first):
|
||||||
|
${activitySummary || 'No activity recorded'}
|
||||||
|
|
||||||
|
Latest call:
|
||||||
|
- Direction: ${event.direction}
|
||||||
|
- Duration: ${event.durationSec}s
|
||||||
|
- Disposition: ${event.disposition}
|
||||||
|
- Notes: ${event.notes ?? 'None'}`,
|
||||||
|
maxOutputTokens: 200,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update lead with new AI insight
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($id: UUID!, $data: LeadUpdateInput!) { updateLead(id: $id, data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
id: event.leadId,
|
||||||
|
data: {
|
||||||
|
aiSummary: object.summary,
|
||||||
|
aiSuggestedAction: object.suggestedAction,
|
||||||
|
lastContacted: new Date().toISOString(),
|
||||||
|
contactAttempts: (lead.contactAttempts ?? 0) + 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`[AI-INSIGHT] Updated lead ${event.leadId}: "${object.summary.substring(0, 60)}..."`);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[AI-INSIGHT] Failed for lead ${event.leadId}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
114
src/events/event-bus.service.ts
Normal file
114
src/events/event-bus.service.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||||
|
import { Kafka, Producer, Consumer, EachMessagePayload } from 'kafkajs';
|
||||||
|
import type { EventPayload } from './event-types';
|
||||||
|
|
||||||
|
type EventHandler = (payload: any) => Promise<void>;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EventBusService implements OnModuleInit, OnModuleDestroy {
|
||||||
|
private readonly logger = new Logger(EventBusService.name);
|
||||||
|
private kafka: Kafka;
|
||||||
|
private producer: Producer;
|
||||||
|
private consumer: Consumer;
|
||||||
|
private handlers = new Map<string, EventHandler[]>();
|
||||||
|
private connected = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
const brokers = (process.env.KAFKA_BROKERS ?? 'localhost:9092').split(',');
|
||||||
|
this.kafka = new Kafka({
|
||||||
|
clientId: 'helix-engage-sidecar',
|
||||||
|
brokers,
|
||||||
|
retry: { retries: 5, initialRetryTime: 1000 },
|
||||||
|
logLevel: 1, // ERROR only
|
||||||
|
});
|
||||||
|
this.producer = this.kafka.producer();
|
||||||
|
this.consumer = this.kafka.consumer({ groupId: 'helix-engage-workers' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
try {
|
||||||
|
await this.producer.connect();
|
||||||
|
await this.consumer.connect();
|
||||||
|
this.connected = true;
|
||||||
|
this.logger.log('Event bus connected (Kafka/Redpanda)');
|
||||||
|
|
||||||
|
// Subscribe to all topics we have handlers for
|
||||||
|
// Handlers are registered by consumer modules during their onModuleInit
|
||||||
|
// We start consuming after a short delay to let all handlers register
|
||||||
|
setTimeout(() => this.startConsuming(), 2000);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.warn(`Event bus not available (${err.message}) — running without events`);
|
||||||
|
this.connected = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleDestroy() {
|
||||||
|
if (this.connected) {
|
||||||
|
await this.consumer.disconnect().catch(() => {});
|
||||||
|
await this.producer.disconnect().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async emit(topic: string, payload: EventPayload): Promise<void> {
|
||||||
|
if (!this.connected) {
|
||||||
|
this.logger.debug(`[EVENT] Skipped (not connected): ${topic}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.producer.send({
|
||||||
|
topic,
|
||||||
|
messages: [{ value: JSON.stringify(payload), timestamp: Date.now().toString() }],
|
||||||
|
});
|
||||||
|
this.logger.log(`[EVENT] Emitted: ${topic}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[EVENT] Failed to emit ${topic}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
on(topic: string, handler: EventHandler): void {
|
||||||
|
const existing = this.handlers.get(topic) ?? [];
|
||||||
|
existing.push(handler);
|
||||||
|
this.handlers.set(topic, existing);
|
||||||
|
this.logger.log(`[EVENT] Handler registered for: ${topic}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async startConsuming(): Promise<void> {
|
||||||
|
if (!this.connected) return;
|
||||||
|
|
||||||
|
const topics = Array.from(this.handlers.keys());
|
||||||
|
if (topics.length === 0) {
|
||||||
|
this.logger.log('[EVENT] No handlers registered — skipping consumer');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
for (const topic of topics) {
|
||||||
|
await this.consumer.subscribe({ topic, fromBeginning: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.consumer.run({
|
||||||
|
eachMessage: async (payload: EachMessagePayload) => {
|
||||||
|
const { topic, message } = payload;
|
||||||
|
const handlers = this.handlers.get(topic) ?? [];
|
||||||
|
if (handlers.length === 0 || !message.value) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(message.value.toString());
|
||||||
|
for (const handler of handlers) {
|
||||||
|
await handler(data).catch(err =>
|
||||||
|
this.logger.error(`[EVENT] Handler error on ${topic}: ${err.message}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[EVENT] Parse error on ${topic}: ${err.message}`);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`[EVENT] Consuming: ${topics.join(', ')}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[EVENT] Consumer failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/events/event-types.ts
Normal file
36
src/events/event-types.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
// Event topic names
|
||||||
|
export const Topics = {
|
||||||
|
CALL_COMPLETED: 'call.completed',
|
||||||
|
CALL_MISSED: 'call.missed',
|
||||||
|
AGENT_STATE: 'agent.state',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Event payloads
|
||||||
|
export type CallCompletedEvent = {
|
||||||
|
callId: string | null;
|
||||||
|
ucid: string;
|
||||||
|
agentId: string;
|
||||||
|
callerPhone: string;
|
||||||
|
direction: string;
|
||||||
|
durationSec: number;
|
||||||
|
disposition: string;
|
||||||
|
leadId: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CallMissedEvent = {
|
||||||
|
callId: string | null;
|
||||||
|
callerPhone: string;
|
||||||
|
leadId: string | null;
|
||||||
|
leadName: string | null;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentStateEvent = {
|
||||||
|
agentId: string;
|
||||||
|
state: string;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EventPayload = CallCompletedEvent | CallMissedEvent | AgentStateEvent;
|
||||||
12
src/events/events.module.ts
Normal file
12
src/events/events.module.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Module, Global } from '@nestjs/common';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { EventBusService } from './event-bus.service';
|
||||||
|
import { AiInsightConsumer } from './consumers/ai-insight.consumer';
|
||||||
|
|
||||||
|
@Global()
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule],
|
||||||
|
providers: [EventBusService, AiInsightConsumer],
|
||||||
|
exports: [EventBusService],
|
||||||
|
})
|
||||||
|
export class EventsModule {}
|
||||||
279
src/livekit-agent/agent.ts
Normal file
279
src/livekit-agent/agent.ts
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import { WorkerOptions, defineAgent, llm, voice, VAD } from '@livekit/agents';
|
||||||
|
import * as google from '@livekit/agents-plugin-google';
|
||||||
|
import * as silero from '@livekit/agents-plugin-silero';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
// Platform GraphQL helper
|
||||||
|
const SIDECAR_URL = process.env.SIDECAR_URL ?? 'http://localhost:4100';
|
||||||
|
const PLATFORM_API_KEY = process.env.PLATFORM_API_KEY ?? '';
|
||||||
|
|
||||||
|
async function gql<T = any>(query: string, variables?: Record<string, unknown>): Promise<T | null> {
|
||||||
|
if (!PLATFORM_API_KEY) return null;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${SIDECAR_URL}/graphql`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${PLATFORM_API_KEY}` },
|
||||||
|
body: JSON.stringify({ query, variables }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.errors) {
|
||||||
|
console.error('[AGENT-GQL] Error:', data.errors[0]?.message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data.data;
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[AGENT-GQL] Failed:', err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hospital context — loaded on startup
|
||||||
|
let hospitalContext = {
|
||||||
|
doctors: [] as Array<{ name: string; department: string; specialty: string; id: string }>,
|
||||||
|
departments: [] as string[],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function loadHospitalContext() {
|
||||||
|
const data = await gql(`{ doctors(first: 20) { edges { node { id fullName { firstName lastName } department specialty } } } }`);
|
||||||
|
if (data?.doctors?.edges) {
|
||||||
|
hospitalContext.doctors = data.doctors.edges.map((e: any) => ({
|
||||||
|
id: e.node.id,
|
||||||
|
name: `Dr. ${e.node.fullName?.firstName ?? ''} ${e.node.fullName?.lastName ?? ''}`.trim(),
|
||||||
|
department: e.node.department ?? '',
|
||||||
|
specialty: e.node.specialty ?? '',
|
||||||
|
}));
|
||||||
|
hospitalContext.departments = [...new Set(hospitalContext.doctors.map(d => d.department))] as string[];
|
||||||
|
console.log(`[LIVEKIT-AGENT] Loaded ${hospitalContext.doctors.length} doctors, ${hospitalContext.departments.length} departments`);
|
||||||
|
} else {
|
||||||
|
// Fallback
|
||||||
|
hospitalContext.doctors = [
|
||||||
|
{ id: '', name: 'Dr. Arun Sharma', department: 'Cardiology', specialty: 'Interventional Cardiology' },
|
||||||
|
{ id: '', name: 'Dr. Rajesh Kumar', department: 'Orthopedics', specialty: 'Joint Replacement' },
|
||||||
|
{ id: '', name: 'Dr. Meena Patel', department: 'Gynecology', specialty: 'Reproductive Medicine' },
|
||||||
|
{ id: '', name: 'Dr. Lakshmi Reddy', department: 'General Medicine', specialty: 'Internal Medicine' },
|
||||||
|
{ id: '', name: 'Dr. Harpreet Singh', department: 'ENT', specialty: 'Head & Neck Surgery' },
|
||||||
|
];
|
||||||
|
hospitalContext.departments = ['Cardiology', 'Orthopedics', 'Gynecology', 'General Medicine', 'ENT'];
|
||||||
|
console.log('[LIVEKIT-AGENT] Using fallback doctor list');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tools ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const lookupDoctor = llm.tool({
|
||||||
|
description: 'Look up available doctors by department or specialty. Call this when the patient asks about a specific department or type of doctor.',
|
||||||
|
parameters: z.object({
|
||||||
|
department: z.string().nullable().describe('Department name like Cardiology, Orthopedics, ENT'),
|
||||||
|
specialty: z.string().nullable().describe('Specialty or condition like joint pain, heart, ear'),
|
||||||
|
}),
|
||||||
|
execute: async ({ department, specialty }) => {
|
||||||
|
let results = hospitalContext.doctors;
|
||||||
|
if (department) {
|
||||||
|
results = results.filter(d => d.department.toLowerCase().includes(department.toLowerCase()));
|
||||||
|
}
|
||||||
|
if (specialty) {
|
||||||
|
results = results.filter(d =>
|
||||||
|
d.specialty.toLowerCase().includes(specialty.toLowerCase()) ||
|
||||||
|
d.department.toLowerCase().includes(specialty.toLowerCase()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (results.length === 0) return 'No matching doctors found. Available departments: ' + hospitalContext.departments.join(', ');
|
||||||
|
return results.map(d => `${d.name} — ${d.department} (${d.specialty})`).join('\n');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const bookAppointment = llm.tool({
|
||||||
|
description: 'Book an appointment for the caller. You MUST collect patient name, phone number, department, preferred date/time, and reason before calling this.',
|
||||||
|
parameters: z.object({
|
||||||
|
patientName: z.string().describe('Full name of the patient'),
|
||||||
|
phoneNumber: z.string().describe('Patient phone number with country code'),
|
||||||
|
department: z.string().describe('Department for the appointment'),
|
||||||
|
doctorName: z.string().nullable().describe('Preferred doctor name if specified'),
|
||||||
|
preferredDate: z.string().describe('Date in YYYY-MM-DD format or natural language'),
|
||||||
|
preferredTime: z.string().describe('Time slot like 10:00 AM, morning, afternoon'),
|
||||||
|
reason: z.string().describe('Reason for visit'),
|
||||||
|
}),
|
||||||
|
execute: async ({ patientName, phoneNumber, department, doctorName, preferredDate, preferredTime, reason }) => {
|
||||||
|
console.log(`[LIVEKIT-AGENT] Booking: ${patientName} | ${phoneNumber} | ${department} | ${doctorName ?? 'any'} | ${preferredDate} ${preferredTime}`);
|
||||||
|
|
||||||
|
// Parse date — try ISO format first, fallback to tomorrow
|
||||||
|
let scheduledAt: string;
|
||||||
|
try {
|
||||||
|
const parsed = new Date(preferredDate);
|
||||||
|
if (!isNaN(parsed.getTime())) {
|
||||||
|
// Map time to hour
|
||||||
|
const timeMap: Record<string, string> = { morning: '10:00', afternoon: '14:00', evening: '17:00' };
|
||||||
|
const timeStr = timeMap[preferredTime.toLowerCase()] ?? preferredTime.replace(/\s*(AM|PM)/i, (_, p) => '');
|
||||||
|
scheduledAt = new Date(`${parsed.toISOString().split('T')[0]}T${timeStr}:00`).toISOString();
|
||||||
|
} else {
|
||||||
|
scheduledAt = new Date(Date.now() + 86400000).toISOString(); // tomorrow
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
scheduledAt = new Date(Date.now() + 86400000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find matching doctor
|
||||||
|
const doctor = doctorName
|
||||||
|
? hospitalContext.doctors.find(d => d.name.toLowerCase().includes(doctorName.toLowerCase()))
|
||||||
|
: hospitalContext.doctors.find(d => d.department.toLowerCase().includes(department.toLowerCase()));
|
||||||
|
|
||||||
|
// Create appointment on platform
|
||||||
|
const result = await gql(
|
||||||
|
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
name: `AI Booking — ${patientName} (${department})`,
|
||||||
|
scheduledAt,
|
||||||
|
status: 'SCHEDULED',
|
||||||
|
doctorName: doctor?.name ?? doctorName ?? 'To be assigned',
|
||||||
|
department,
|
||||||
|
reasonForVisit: reason,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create or find lead
|
||||||
|
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
||||||
|
await gql(
|
||||||
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
name: `AI — ${patientName}`,
|
||||||
|
contactName: {
|
||||||
|
firstName: patientName.split(' ')[0],
|
||||||
|
lastName: patientName.split(' ').slice(1).join(' ') || '',
|
||||||
|
},
|
||||||
|
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
||||||
|
source: 'PHONE',
|
||||||
|
status: 'APPOINTMENT_SET',
|
||||||
|
interestedService: department,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const refNum = `GH-${Date.now().toString().slice(-6)}`;
|
||||||
|
if (result?.createAppointment?.id) {
|
||||||
|
console.log(`[LIVEKIT-AGENT] Appointment created: ${result.createAppointment.id}`);
|
||||||
|
return `Appointment booked successfully! Reference number ${refNum}. ${patientName} is scheduled for ${department} on ${preferredDate} at ${preferredTime} with ${doctor?.name ?? 'an available doctor'}. A confirmation SMS will be sent to ${phoneNumber}.`;
|
||||||
|
}
|
||||||
|
return `I have noted the appointment request. Reference number ${refNum}. Our team will confirm the booking and send an SMS to ${phoneNumber}.`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const collectLeadInfo = llm.tool({
|
||||||
|
description: 'Save the caller as a lead/enquiry when they are interested but not ready to book. Collect their name and phone number.',
|
||||||
|
parameters: z.object({
|
||||||
|
name: z.string().describe('Caller name'),
|
||||||
|
phoneNumber: z.string().describe('Caller phone number'),
|
||||||
|
interest: z.string().describe('What they are interested in or enquiring about'),
|
||||||
|
}),
|
||||||
|
execute: async ({ name, phoneNumber, interest }) => {
|
||||||
|
console.log(`[LIVEKIT-AGENT] Lead: ${name} | ${phoneNumber} | ${interest}`);
|
||||||
|
|
||||||
|
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
||||||
|
const result = await gql(
|
||||||
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
name: `AI Enquiry — ${name}`,
|
||||||
|
contactName: {
|
||||||
|
firstName: name.split(' ')[0],
|
||||||
|
lastName: name.split(' ').slice(1).join(' ') || '',
|
||||||
|
},
|
||||||
|
contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` },
|
||||||
|
source: 'PHONE',
|
||||||
|
status: 'NEW',
|
||||||
|
interestedService: interest,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result?.createLead?.id) {
|
||||||
|
console.log(`[LIVEKIT-AGENT] Lead created: ${result.createLead.id}`);
|
||||||
|
}
|
||||||
|
return `Thank you ${name}. I have noted your enquiry about ${interest}. One of our team members will call you back on ${phoneNumber} shortly.`;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const transferToAgent = llm.tool({
|
||||||
|
description: 'Transfer the call to a human agent. Use this when the caller explicitly asks to speak with a person, or when the query is too complex.',
|
||||||
|
parameters: z.object({
|
||||||
|
reason: z.string().describe('Why the caller needs a human agent'),
|
||||||
|
}),
|
||||||
|
execute: async ({ reason }) => {
|
||||||
|
console.log(`[LIVEKIT-AGENT] Transfer requested: ${reason}`);
|
||||||
|
// TODO: When SIP is connected, trigger Ozonetel transfer via sidecar API
|
||||||
|
return 'I am transferring you to one of our agents now. Please hold for a moment. If no agent is available, someone will call you back within 15 minutes.';
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Agent ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const hospitalAgent = new voice.Agent({
|
||||||
|
instructions: `You are the AI receptionist for Global Hospital, Bangalore. Your name is Helix.
|
||||||
|
|
||||||
|
PERSONALITY:
|
||||||
|
- Warm, professional, and empathetic
|
||||||
|
- Speak clearly and at a moderate pace
|
||||||
|
- Use simple language — many callers may not be fluent in English
|
||||||
|
- Be concise — this is a phone call, not a chat
|
||||||
|
- Respond in the same language the caller uses (English, Hindi, Kannada)
|
||||||
|
|
||||||
|
CAPABILITIES:
|
||||||
|
- Answer questions about hospital departments, doctors, and specialties
|
||||||
|
- Book appointments — collect: name, phone, department, preferred date/time, reason
|
||||||
|
- Take messages and create enquiries for callback
|
||||||
|
- Transfer to a human agent when needed
|
||||||
|
|
||||||
|
HOSPITAL INFO:
|
||||||
|
- Global Hospital, Bangalore
|
||||||
|
- Open Monday to Saturday, 8 AM to 8 PM
|
||||||
|
- Emergency services available 24/7
|
||||||
|
- Departments: ${hospitalContext.departments.join(', ') || 'Cardiology, Orthopedics, Gynecology, General Medicine, ENT'}
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- Greet: "Hello, thank you for calling Global Hospital. This is Helix, how may I help you today?"
|
||||||
|
- If caller asks about pricing, say you will have the team call back with details
|
||||||
|
- Never give medical advice — always recommend consulting a doctor
|
||||||
|
- If the caller is in an emergency, tell them to visit the ER immediately or call 108
|
||||||
|
- Always confirm all details before booking an appointment
|
||||||
|
- End calls politely: "Thank you for calling Global Hospital. Have a good day!"
|
||||||
|
- If you cannot understand the caller, politely ask them to repeat`,
|
||||||
|
llm: new google.beta.realtime.RealtimeModel({
|
||||||
|
model: 'gemini-2.5-flash-native-audio-latest',
|
||||||
|
voice: 'Aoede',
|
||||||
|
temperature: 0.7,
|
||||||
|
}),
|
||||||
|
tools: { lookupDoctor, bookAppointment, collectLeadInfo, transferToAgent },
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Entry Point ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export default defineAgent({
|
||||||
|
prewarm: async (proc) => {
|
||||||
|
proc.userData.vad = await silero.VAD.load();
|
||||||
|
await loadHospitalContext();
|
||||||
|
},
|
||||||
|
entry: async (ctx) => {
|
||||||
|
await ctx.connect();
|
||||||
|
console.log(`[LIVEKIT-AGENT] Connected to room: ${ctx.room.name}`);
|
||||||
|
|
||||||
|
const session = new voice.AgentSession({
|
||||||
|
vad: ctx.proc.userData.vad as VAD,
|
||||||
|
});
|
||||||
|
|
||||||
|
await session.start({ agent: hospitalAgent, room: ctx.room });
|
||||||
|
console.log('[LIVEKIT-AGENT] Voice session started');
|
||||||
|
|
||||||
|
// Gemini Realtime handles greeting via instructions — no separate say() needed
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// CLI runner
|
||||||
|
if (require.main === module) {
|
||||||
|
const options = new WorkerOptions({
|
||||||
|
agent: __filename,
|
||||||
|
});
|
||||||
|
const { cli } = require('@livekit/agents');
|
||||||
|
cli.runApp(options);
|
||||||
|
}
|
||||||
@@ -6,9 +6,13 @@ async function bootstrap() {
|
|||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
const config = app.get(ConfigService);
|
const config = app.get(ConfigService);
|
||||||
|
|
||||||
|
const corsOrigins = config.get<string[]>('corsOrigins') || ['http://localhost:5173'];
|
||||||
|
|
||||||
app.enableCors({
|
app.enableCors({
|
||||||
origin: config.get('corsOrigin'),
|
origin: corsOrigins,
|
||||||
credentials: true,
|
credentials: true,
|
||||||
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'OPTIONS'],
|
||||||
|
allowedHeaders: ['Content-Type', 'Accept', 'Authorization'],
|
||||||
});
|
});
|
||||||
|
|
||||||
const port = config.get('port');
|
const port = config.get('port');
|
||||||
|
|||||||
315
src/maint/maint.controller.ts
Normal file
315
src/maint/maint.controller.ts
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import { Controller, Post, UseGuards, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { MaintGuard } from './maint.guard';
|
||||||
|
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { SessionService } from '../auth/session.service';
|
||||||
|
import { SupervisorService } from '../supervisor/supervisor.service';
|
||||||
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
|
|
||||||
|
@Controller('api/maint')
|
||||||
|
@UseGuards(MaintGuard)
|
||||||
|
export class MaintController {
|
||||||
|
private readonly logger = new Logger(MaintController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly ozonetel: OzonetelAgentService,
|
||||||
|
private readonly platform: PlatformGraphqlService,
|
||||||
|
private readonly session: SessionService,
|
||||||
|
private readonly supervisor: SupervisorService,
|
||||||
|
private readonly callerResolution: CallerResolutionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('force-ready')
|
||||||
|
async forceReady() {
|
||||||
|
const agentId = this.config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
||||||
|
const password = process.env.OZONETEL_AGENT_PASSWORD ?? 'Test123$';
|
||||||
|
const sipId = this.config.get<string>('OZONETEL_SIP_ID') ?? '521814';
|
||||||
|
|
||||||
|
this.logger.log(`[MAINT] Force ready: agent=${agentId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.ozonetel.logoutAgent({ agentId, password });
|
||||||
|
const result = await this.ozonetel.loginAgent({
|
||||||
|
agentId,
|
||||||
|
password,
|
||||||
|
phoneNumber: sipId,
|
||||||
|
mode: 'blended',
|
||||||
|
});
|
||||||
|
this.logger.log(`[MAINT] Force ready complete: ${JSON.stringify(result)}`);
|
||||||
|
return { status: 'ok', message: `Agent ${agentId} force-readied`, result };
|
||||||
|
} catch (error: any) {
|
||||||
|
const message = error.response?.data?.message ?? error.message ?? 'Force ready failed';
|
||||||
|
this.logger.error(`[MAINT] Force ready failed: ${message}`);
|
||||||
|
return { status: 'error', message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('unlock-agent')
|
||||||
|
async unlockAgent() {
|
||||||
|
const agentId = this.config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
||||||
|
this.logger.log(`[MAINT] Unlock agent session: ${agentId}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const existing = await this.session.getSession(agentId);
|
||||||
|
if (!existing) {
|
||||||
|
return { status: 'ok', message: `No active session for ${agentId}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.session.unlockSession(agentId);
|
||||||
|
|
||||||
|
// Push force-logout via SSE to all connected browsers for this agent
|
||||||
|
this.supervisor.emitForceLogout(agentId);
|
||||||
|
|
||||||
|
this.logger.log(`[MAINT] Session unlocked + force-logout pushed for ${agentId} (was held by IP ${existing.ip} since ${existing.lockedAt})`);
|
||||||
|
return { status: 'ok', message: `Session unlocked and force-logout sent for ${agentId}`, previousSession: existing };
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`[MAINT] Unlock failed: ${error.message}`);
|
||||||
|
return { status: 'error', message: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('backfill-missed-calls')
|
||||||
|
async backfillMissedCalls() {
|
||||||
|
this.logger.log('[MAINT] Backfill missed call lead names — starting');
|
||||||
|
|
||||||
|
// Fetch all missed calls without a leadId
|
||||||
|
const result = await this.platform.query<any>(
|
||||||
|
`{ calls(first: 200, filter: {
|
||||||
|
callStatus: { eq: MISSED },
|
||||||
|
leadId: { is: NULL }
|
||||||
|
}) { edges { node { id callerNumber { primaryPhoneNumber } } } } }`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const calls = result?.calls?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
if (calls.length === 0) {
|
||||||
|
this.logger.log('[MAINT] No missed calls without leadId found');
|
||||||
|
return { status: 'ok', total: 0, patched: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[MAINT] Found ${calls.length} missed calls without leadId`);
|
||||||
|
|
||||||
|
let patched = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const call of calls) {
|
||||||
|
const phone = call.callerNumber?.primaryPhoneNumber;
|
||||||
|
if (!phone) { skipped++; continue; }
|
||||||
|
|
||||||
|
const phoneDigits = phone.replace(/^\+91/, '');
|
||||||
|
try {
|
||||||
|
const leadResult = await this.platform.query<any>(
|
||||||
|
`{ leads(first: 1, filter: {
|
||||||
|
contactPhone: { primaryPhoneNumber: { like: "%${phoneDigits}" } }
|
||||||
|
}) { edges { node { id contactName { firstName lastName } } } } }`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const lead = leadResult?.leads?.edges?.[0]?.node;
|
||||||
|
if (!lead) { skipped++; continue; }
|
||||||
|
|
||||||
|
const fn = lead.contactName?.firstName ?? '';
|
||||||
|
const ln = lead.contactName?.lastName ?? '';
|
||||||
|
const leadName = `${fn} ${ln}`.trim();
|
||||||
|
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation { updateCall(id: "${call.id}", data: {
|
||||||
|
leadId: "${lead.id}"${leadName ? `, leadName: "${leadName}"` : ''}
|
||||||
|
}) { id } }`,
|
||||||
|
);
|
||||||
|
|
||||||
|
patched++;
|
||||||
|
this.logger.log(`[MAINT] Patched ${phone} → ${leadName} (${lead.id})`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[MAINT] Failed to patch ${call.id}: ${err}`);
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[MAINT] Backfill complete: ${patched} patched, ${skipped} skipped out of ${calls.length}`);
|
||||||
|
return { status: 'ok', total: calls.length, patched, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('fix-timestamps')
|
||||||
|
async fixTimestamps() {
|
||||||
|
this.logger.log('[MAINT] Fix call timestamps — subtracting 5:30 IST offset from existing records');
|
||||||
|
|
||||||
|
const result = await this.platform.query<any>(
|
||||||
|
`{ calls(first: 200) { edges { node { id startedAt endedAt createdAt } } } }`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const calls = result?.calls?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
if (calls.length === 0) {
|
||||||
|
return { status: 'ok', total: 0, fixed: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[MAINT] Found ${calls.length} call records to check`);
|
||||||
|
|
||||||
|
let fixed = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const call of calls) {
|
||||||
|
if (!call.startedAt) { skipped++; continue; }
|
||||||
|
|
||||||
|
// Skip records that don't need fixing: if startedAt is BEFORE createdAt,
|
||||||
|
// it was already corrected (or is naturally correct)
|
||||||
|
const started = new Date(call.startedAt).getTime();
|
||||||
|
const created = new Date(call.createdAt).getTime();
|
||||||
|
if (started <= created) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updates: string[] = [];
|
||||||
|
|
||||||
|
const startDate = new Date(call.startedAt);
|
||||||
|
startDate.setMinutes(startDate.getMinutes() - 330);
|
||||||
|
updates.push(`startedAt: "${startDate.toISOString()}"`);
|
||||||
|
|
||||||
|
if (call.endedAt) {
|
||||||
|
const endDate = new Date(call.endedAt);
|
||||||
|
endDate.setMinutes(endDate.getMinutes() - 330);
|
||||||
|
updates.push(`endedAt: "${endDate.toISOString()}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation { updateCall(id: "${call.id}", data: { ${updates.join(', ')} }) { id } }`,
|
||||||
|
);
|
||||||
|
|
||||||
|
fixed++;
|
||||||
|
|
||||||
|
// Throttle: 700ms between mutations to stay under 100/min rate limit
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 700));
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[MAINT] Failed to fix ${call.id}: ${err}`);
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[MAINT] Timestamp fix complete: ${fixed} fixed, ${skipped} skipped out of ${calls.length}`);
|
||||||
|
return { status: 'ok', total: calls.length, fixed, skipped };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('clear-analysis-cache')
|
||||||
|
async clearAnalysisCache() {
|
||||||
|
this.logger.log('[MAINT] Clearing all recording analysis cache');
|
||||||
|
const keys = await this.session.scanKeys('call:analysis:*');
|
||||||
|
let cleared = 0;
|
||||||
|
for (const key of keys) {
|
||||||
|
await this.session.deleteCache(key);
|
||||||
|
cleared++;
|
||||||
|
}
|
||||||
|
this.logger.log(`[MAINT] Cleared ${cleared} analysis cache entries`);
|
||||||
|
return { status: 'ok', cleared };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('backfill-lead-patient-links')
|
||||||
|
async backfillLeadPatientLinks() {
|
||||||
|
this.logger.log('[MAINT] Backfill lead-patient links — matching by phone number');
|
||||||
|
|
||||||
|
// Fetch all leads
|
||||||
|
const leadResult = await this.platform.query<any>(
|
||||||
|
`{ leads(first: 200) { edges { node { id patientId contactPhone { primaryPhoneNumber } contactName { firstName lastName } } } } }`,
|
||||||
|
);
|
||||||
|
const leads = leadResult?.leads?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
|
||||||
|
// Fetch all patients
|
||||||
|
const patientResult = await this.platform.query<any>(
|
||||||
|
`{ patients(first: 200) { edges { node { id phones { primaryPhoneNumber } fullName { firstName lastName } } } } }`,
|
||||||
|
);
|
||||||
|
const patients = patientResult?.patients?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
|
||||||
|
// Build patient phone → id map
|
||||||
|
const patientByPhone = new Map<string, { id: string; firstName: string; lastName: string }>();
|
||||||
|
for (const p of patients) {
|
||||||
|
const phone = (p.phones?.primaryPhoneNumber ?? '').replace(/\D/g, '').slice(-10);
|
||||||
|
if (phone.length === 10) {
|
||||||
|
patientByPhone.set(phone, {
|
||||||
|
id: p.id,
|
||||||
|
firstName: p.fullName?.firstName ?? '',
|
||||||
|
lastName: p.fullName?.lastName ?? '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let linked = 0;
|
||||||
|
let created = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const lead of leads) {
|
||||||
|
const phone = (lead.contactPhone?.primaryPhoneNumber ?? '').replace(/\D/g, '').slice(-10);
|
||||||
|
if (!phone || phone.length < 10) { skipped++; continue; }
|
||||||
|
|
||||||
|
if (lead.patientId) { skipped++; continue; } // already linked
|
||||||
|
|
||||||
|
const matchedPatient = patientByPhone.get(phone);
|
||||||
|
|
||||||
|
if (matchedPatient) {
|
||||||
|
// Patient exists — link lead to patient
|
||||||
|
try {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation { updateLead(id: "${lead.id}", data: { patientId: "${matchedPatient.id}" }) { id } }`,
|
||||||
|
);
|
||||||
|
linked++;
|
||||||
|
this.logger.log(`[MAINT] Linked lead ${lead.id} → patient ${matchedPatient.id} (${phone})`);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[MAINT] Failed to link lead ${lead.id}: ${err}`);
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No patient — create one from lead data
|
||||||
|
try {
|
||||||
|
const firstName = lead.contactName?.firstName ?? 'Unknown';
|
||||||
|
const lastName = lead.contactName?.lastName ?? '';
|
||||||
|
const result = await this.platform.query<any>(
|
||||||
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
fullName: { firstName, lastName },
|
||||||
|
phones: { primaryPhoneNumber: `+91${phone}` },
|
||||||
|
patientType: 'NEW',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const newPatientId = result?.createPatient?.id;
|
||||||
|
if (newPatientId) {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation { updateLead(id: "${lead.id}", data: { patientId: "${newPatientId}" }) { id } }`,
|
||||||
|
);
|
||||||
|
patientByPhone.set(phone, { id: newPatientId, firstName, lastName });
|
||||||
|
created++;
|
||||||
|
this.logger.log(`[MAINT] Created patient ${newPatientId} + linked to lead ${lead.id} (${phone})`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[MAINT] Failed to create patient for lead ${lead.id}: ${err}`);
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Throttle
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now backfill appointments — link to patient via lead
|
||||||
|
const apptResult = await this.platform.query<any>(
|
||||||
|
`{ appointments(first: 200) { edges { node { id patientId createdAt } } } }`,
|
||||||
|
);
|
||||||
|
const appointments = apptResult?.appointments?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
|
||||||
|
let apptLinked = 0;
|
||||||
|
// For appointments without patientId, find the lead that was active around that time
|
||||||
|
// and use its patientId. This is best-effort.
|
||||||
|
for (const appt of appointments) {
|
||||||
|
if (appt.patientId) continue;
|
||||||
|
|
||||||
|
// Find the most recent lead that has a patientId (best-effort match)
|
||||||
|
// In practice, for the current data set this is sufficient
|
||||||
|
// A proper fix would store leadId on the appointment
|
||||||
|
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 } };
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/maint/maint.guard.ts
Normal file
20
src/maint/maint.guard.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { CanActivate, ExecutionContext, Injectable, HttpException } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MaintGuard implements CanActivate {
|
||||||
|
private readonly otp: string;
|
||||||
|
|
||||||
|
constructor(private config: ConfigService) {
|
||||||
|
this.otp = process.env.MAINT_OTP ?? '400168';
|
||||||
|
}
|
||||||
|
|
||||||
|
canActivate(context: ExecutionContext): boolean {
|
||||||
|
const request = context.switchToHttp().getRequest();
|
||||||
|
const provided = request.headers['x-maint-otp'] ?? request.body?.otp;
|
||||||
|
if (!provided || provided !== this.otp) {
|
||||||
|
throw new HttpException('Invalid maintenance OTP', 403);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/maint/maint.module.ts
Normal file
13
src/maint/maint.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { SupervisorModule } from '../supervisor/supervisor.module';
|
||||||
|
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
||||||
|
import { MaintController } from './maint.controller';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule, OzonetelAgentModule, AuthModule, SupervisorModule, CallerResolutionModule],
|
||||||
|
controllers: [MaintController],
|
||||||
|
})
|
||||||
|
export class MaintModule {}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
import { Controller, Post, Get, Body, Query, Logger, HttpException } from '@nestjs/common';
|
import { Controller, Post, Get, Body, Query, Logger, HttpException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { OzonetelAgentService } from './ozonetel-agent.service';
|
import { OzonetelAgentService } from './ozonetel-agent.service';
|
||||||
|
import { MissedQueueService } from '../worklist/missed-queue.service';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { EventBusService } from '../events/event-bus.service';
|
||||||
|
import { Topics } from '../events/event-types';
|
||||||
|
|
||||||
@Controller('api/ozonetel')
|
@Controller('api/ozonetel')
|
||||||
export class OzonetelAgentController {
|
export class OzonetelAgentController {
|
||||||
@@ -13,6 +17,9 @@ export class OzonetelAgentController {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly ozonetelAgent: OzonetelAgentService,
|
private readonly ozonetelAgent: OzonetelAgentService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
|
private readonly missedQueue: MissedQueueService,
|
||||||
|
private readonly platform: PlatformGraphqlService,
|
||||||
|
private readonly eventBus: EventBusService,
|
||||||
) {
|
) {
|
||||||
this.defaultAgentId = config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
this.defaultAgentId = config.get<string>('OZONETEL_AGENT_ID') ?? 'agent3';
|
||||||
this.defaultAgentPassword = config.get<string>('OZONETEL_AGENT_PASSWORD') ?? '';
|
this.defaultAgentPassword = config.get<string>('OZONETEL_AGENT_PASSWORD') ?? '';
|
||||||
@@ -53,29 +60,48 @@ export class OzonetelAgentController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('agent-ready')
|
@Post('agent-state')
|
||||||
async agentReady() {
|
async agentState(
|
||||||
this.logger.log(`Force ready: logging out and back in agent ${this.defaultAgentId}`);
|
@Body() body: { state: 'Ready' | 'Pause'; pauseReason?: string },
|
||||||
|
) {
|
||||||
|
if (!body.state) {
|
||||||
|
throw new HttpException('state required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[AGENT-STATE] ${this.defaultAgentId} → ${body.state} (${body.pauseReason ?? 'none'})`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.ozonetelAgent.logoutAgent({
|
const result = await this.ozonetelAgent.changeAgentState({
|
||||||
agentId: this.defaultAgentId,
|
agentId: this.defaultAgentId,
|
||||||
password: this.defaultAgentPassword,
|
state: body.state,
|
||||||
});
|
pauseReason: body.pauseReason,
|
||||||
const result = await this.ozonetelAgent.loginAgent({
|
|
||||||
agentId: this.defaultAgentId,
|
|
||||||
password: this.defaultAgentPassword,
|
|
||||||
phoneNumber: this.defaultSipId,
|
|
||||||
mode: 'blended',
|
|
||||||
});
|
});
|
||||||
|
this.logger.log(`[AGENT-STATE] Ozonetel response: ${JSON.stringify(result)}`);
|
||||||
|
|
||||||
|
// Auto-assign missed call when agent goes Ready
|
||||||
|
if (body.state === 'Ready') {
|
||||||
|
try {
|
||||||
|
const assigned = await this.missedQueue.assignNext(this.defaultAgentId);
|
||||||
|
if (assigned) {
|
||||||
|
this.logger.log(`[AGENT-STATE] Auto-assigned missed call ${assigned.id}`);
|
||||||
|
return { ...result, assignedCall: assigned };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[AGENT-STATE] Auto-assignment on Ready failed: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message = error.response?.data?.message ?? error.message ?? 'Force ready failed';
|
const message = error.response?.data?.message ?? error.message ?? 'State change failed';
|
||||||
this.logger.error(`Force ready failed: ${message}`);
|
const responseData = error.response?.data ? JSON.stringify(error.response.data) : '';
|
||||||
throw new HttpException(message, error.response?.status ?? 502);
|
this.logger.error(`[AGENT-STATE] FAILED: ${message} ${responseData}`);
|
||||||
|
return { status: 'error', message };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// force-ready moved to /api/maint/force-ready
|
||||||
|
|
||||||
@Post('dispose')
|
@Post('dispose')
|
||||||
async dispose(
|
async dispose(
|
||||||
@Body() body: {
|
@Body() body: {
|
||||||
@@ -86,29 +112,73 @@ export class OzonetelAgentController {
|
|||||||
durationSec?: number;
|
durationSec?: number;
|
||||||
leadId?: string;
|
leadId?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
|
missedCallId?: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
if (!body.ucid || !body.disposition) {
|
if (!body.ucid || !body.disposition) {
|
||||||
throw new HttpException('ucid and disposition required', 400);
|
throw new HttpException('ucid and disposition required', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Dispose: ucid=${body.ucid} disposition=${body.disposition}`);
|
|
||||||
|
|
||||||
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'}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.ozonetelAgent.setDisposition({
|
const result = await this.ozonetelAgent.setDisposition({
|
||||||
agentId: this.defaultAgentId,
|
agentId: this.defaultAgentId,
|
||||||
ucid: body.ucid,
|
ucid: body.ucid,
|
||||||
disposition: ozonetelDisposition,
|
disposition: ozonetelDisposition,
|
||||||
});
|
});
|
||||||
return result;
|
this.logger.log(`[DISPOSE] Ozonetel response: ${JSON.stringify(result)}`);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message = error.response?.data?.message ?? error.message ?? 'Disposition failed';
|
const message = error.response?.data?.message ?? error.message ?? 'Disposition failed';
|
||||||
this.logger.error(`Dispose failed: ${message}`);
|
const responseData = error.response?.data ? JSON.stringify(error.response.data) : '';
|
||||||
// Don't throw — disposition failure shouldn't block the UI
|
this.logger.error(`[DISPOSE] FAILED: ${message} ${responseData}`);
|
||||||
return { status: 'error', message };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle missed call callback status update
|
||||||
|
if (body.missedCallId) {
|
||||||
|
const statusMap: Record<string, string> = {
|
||||||
|
APPOINTMENT_BOOKED: 'CALLBACK_COMPLETED',
|
||||||
|
INFO_PROVIDED: 'CALLBACK_COMPLETED',
|
||||||
|
FOLLOW_UP_SCHEDULED: 'CALLBACK_COMPLETED',
|
||||||
|
CALLBACK_REQUESTED: 'CALLBACK_COMPLETED',
|
||||||
|
WRONG_NUMBER: 'WRONG_NUMBER',
|
||||||
|
};
|
||||||
|
const newStatus = statusMap[body.disposition];
|
||||||
|
if (newStatus) {
|
||||||
|
try {
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation { updateCall(id: "${body.missedCallId}", data: { callbackstatus: ${newStatus} }) { id } }`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to update missed call status: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-assign next missed call to this agent
|
||||||
|
try {
|
||||||
|
await this.missedQueue.assignNext(this.defaultAgentId);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Auto-assignment after dispose failed: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Emit event for downstream processing (AI insights, metrics, etc.)
|
||||||
|
this.eventBus.emit(Topics.CALL_COMPLETED, {
|
||||||
|
callId: null,
|
||||||
|
ucid: body.ucid,
|
||||||
|
agentId: this.defaultAgentId,
|
||||||
|
callerPhone: body.callerPhone ?? '',
|
||||||
|
direction: body.direction ?? 'INBOUND',
|
||||||
|
durationSec: body.durationSec ?? 0,
|
||||||
|
disposition: body.disposition,
|
||||||
|
leadId: body.leadId ?? null,
|
||||||
|
notes: body.notes ?? null,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
|
return { status: 'ok' };
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('dial')
|
@Post('dial')
|
||||||
@@ -121,7 +191,7 @@ export class OzonetelAgentController {
|
|||||||
|
|
||||||
const campaignName = body.campaignName ?? process.env.OZONETEL_CAMPAIGN_NAME ?? 'Inbound_918041763265';
|
const campaignName = body.campaignName ?? process.env.OZONETEL_CAMPAIGN_NAME ?? 'Inbound_918041763265';
|
||||||
|
|
||||||
this.logger.log(`Manual dial: ${body.phoneNumber} campaign=${campaignName} (lead: ${body.leadId ?? 'none'})`);
|
this.logger.log(`[DIAL] phone=${body.phoneNumber} campaign=${campaignName} agentId=${this.defaultAgentId} lead=${body.leadId ?? 'none'}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await this.ozonetelAgent.manualDial({
|
const result = await this.ozonetelAgent.manualDial({
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
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 { WorklistModule } from '../worklist/worklist.module';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [PlatformModule, forwardRef(() => WorklistModule)],
|
||||||
controllers: [OzonetelAgentController, KookooIvrController],
|
controllers: [OzonetelAgentController, KookooIvrController],
|
||||||
providers: [OzonetelAgentService],
|
providers: [OzonetelAgentService],
|
||||||
exports: [OzonetelAgentService],
|
exports: [OzonetelAgentService],
|
||||||
|
|||||||
@@ -22,6 +22,10 @@ export class OzonetelAgentService {
|
|||||||
return this.cachedToken;
|
return this.cachedToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return this.refreshToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshToken(): Promise<string> {
|
||||||
const url = `https://${this.apiDomain}/ca_apis/CAToken/generateToken`;
|
const url = `https://${this.apiDomain}/ca_apis/CAToken/generateToken`;
|
||||||
this.logger.log('Generating CloudAgent API token');
|
this.logger.log('Generating CloudAgent API token');
|
||||||
|
|
||||||
@@ -32,7 +36,7 @@ export class OzonetelAgentService {
|
|||||||
const data = response.data;
|
const data = response.data;
|
||||||
if (data.token) {
|
if (data.token) {
|
||||||
this.cachedToken = data.token;
|
this.cachedToken = data.token;
|
||||||
this.tokenExpiry = Date.now() + 55 * 60 * 1000;
|
this.tokenExpiry = Date.now() + 10 * 60 * 1000; // 10 min cache (Ozonetel expires in ~15 min)
|
||||||
this.logger.log('CloudAgent token generated successfully');
|
this.logger.log('CloudAgent token generated successfully');
|
||||||
return data.token;
|
return data.token;
|
||||||
}
|
}
|
||||||
@@ -40,6 +44,12 @@ export class OzonetelAgentService {
|
|||||||
throw new Error(data.message ?? 'Token generation failed');
|
throw new Error(data.message ?? 'Token generation failed');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private invalidateToken(): void {
|
||||||
|
this.cachedToken = null;
|
||||||
|
this.tokenExpiry = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async loginAgent(params: {
|
async loginAgent(params: {
|
||||||
agentId: string;
|
agentId: string;
|
||||||
password: string;
|
password: string;
|
||||||
@@ -74,15 +84,38 @@ export class OzonetelAgentService {
|
|||||||
|
|
||||||
const data = response.data;
|
const data = response.data;
|
||||||
|
|
||||||
// "already logged in" is not a real error — treat as success
|
// "already logged in" — force logout + re-login to refresh SIP phone mapping
|
||||||
if (data.status === 'error' && data.message?.includes('already logged in')) {
|
if (data.status === 'error' && data.message?.includes('already logged in')) {
|
||||||
this.logger.log(`Agent ${params.agentId} already logged in — treating as success`);
|
this.logger.log(`Agent ${params.agentId} already logged in — forcing logout + re-login`);
|
||||||
return { status: 'success', message: data.message };
|
try {
|
||||||
|
await this.logoutAgent({ agentId: params.agentId, password: params.password });
|
||||||
|
const retryResponse = await axios.post(
|
||||||
|
url,
|
||||||
|
new URLSearchParams({
|
||||||
|
userName: this.accountId,
|
||||||
|
apiKey: this.apiKey,
|
||||||
|
phoneNumber: params.phoneNumber,
|
||||||
|
action: 'login',
|
||||||
|
mode: params.mode ?? 'blended',
|
||||||
|
state: 'Ready',
|
||||||
|
}).toString(),
|
||||||
|
{
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
auth: { username: params.agentId, password: params.password },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
this.logger.log(`Agent re-login response: ${JSON.stringify(retryResponse.data)}`);
|
||||||
|
return retryResponse.data;
|
||||||
|
} catch (retryErr: any) {
|
||||||
|
this.logger.error(`Agent re-login failed: ${retryErr.message}`);
|
||||||
|
return { status: 'success', message: 'Re-login attempted' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.log(`Agent login response: ${JSON.stringify(data)}`);
|
this.logger.log(`Agent login response: ${JSON.stringify(data)}`);
|
||||||
return data;
|
return data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (error?.response?.status === 401) this.invalidateToken();
|
||||||
this.logger.error(`Agent login failed: ${error.message}`);
|
this.logger.error(`Agent login failed: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -111,10 +144,10 @@ export class OzonetelAgentService {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log(`Manual dial response: ${JSON.stringify(response.data)}`);
|
this.logger.log(`Manual dial response: ${JSON.stringify(response.data)}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (error?.response?.status === 401) this.invalidateToken();
|
||||||
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
const responseData = error?.response?.data ? JSON.stringify(error.response.data) : '';
|
||||||
this.logger.error(`Manual dial failed: ${error.message} ${responseData}`);
|
this.logger.error(`Manual dial failed: ${error.message} ${responseData}`);
|
||||||
throw error;
|
throw error;
|
||||||
@@ -304,6 +337,7 @@ export class OzonetelAgentService {
|
|||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (error?.response?.status === 401) this.invalidateToken();
|
||||||
this.logger.error(`Abandon calls failed: ${error.message}`);
|
this.logger.error(`Abandon calls failed: ${error.message}`);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
@@ -393,6 +427,7 @@ export class OzonetelAgentService {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (error?.response?.status === 401) this.invalidateToken();
|
||||||
this.logger.error(`Agent summary failed: ${error.message}`);
|
this.logger.error(`Agent summary failed: ${error.message}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -422,6 +457,7 @@ export class OzonetelAgentService {
|
|||||||
}
|
}
|
||||||
return '00:00:00';
|
return '00:00:00';
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (error?.response?.status === 401) this.invalidateToken();
|
||||||
this.logger.error(`AHT failed: ${error.message}`);
|
this.logger.error(`AHT failed: ${error.message}`);
|
||||||
return '00:00:00';
|
return '00:00:00';
|
||||||
}
|
}
|
||||||
@@ -459,6 +495,7 @@ export class OzonetelAgentService {
|
|||||||
this.logger.log(`Agent logout response: ${JSON.stringify(response.data)}`);
|
this.logger.log(`Agent logout response: ${JSON.stringify(response.data)}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
if (error?.response?.status === 401) this.invalidateToken();
|
||||||
this.logger.error(`Agent logout failed: ${error.message}`);
|
this.logger.error(`Agent logout failed: ${error.message}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import type { LeadNode, LeadActivityNode, CreateCallInput, CreateLeadActivityInput, UpdateLeadInput } from './platform.types';
|
import type { LeadNode, LeadActivityNode, CreateCallInput, CreateLeadActivityInput, CreateLeadInput, UpdateLeadInput } from './platform.types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PlatformGraphqlService {
|
export class PlatformGraphqlService {
|
||||||
@@ -14,7 +14,7 @@ export class PlatformGraphqlService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Server-to-server query using API key
|
// Server-to-server query using API key
|
||||||
private async query<T>(query: string, variables?: Record<string, any>): Promise<T> {
|
async query<T>(query: string, variables?: Record<string, any>): Promise<T> {
|
||||||
return this.queryWithAuth<T>(query, variables, `Bearer ${this.apiKey}`);
|
return this.queryWithAuth<T>(query, variables, `Bearer ${this.apiKey}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +120,16 @@ export class PlatformGraphqlService {
|
|||||||
return data.createLeadActivity;
|
return data.createLeadActivity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createLead(input: CreateLeadInput): Promise<{ id: string }> {
|
||||||
|
const data = await this.query<{ createLead: { id: string } }>(
|
||||||
|
`mutation CreateLead($data: LeadCreateInput!) {
|
||||||
|
createLead(data: $data) { id }
|
||||||
|
}`,
|
||||||
|
{ data: input },
|
||||||
|
);
|
||||||
|
return data.createLead;
|
||||||
|
}
|
||||||
|
|
||||||
// --- Token passthrough versions (for user-driven requests) ---
|
// --- Token passthrough versions (for user-driven requests) ---
|
||||||
|
|
||||||
async findLeadByPhoneWithToken(phone: string, authHeader: string): Promise<LeadNode | null> {
|
async findLeadByPhoneWithToken(phone: string, authHeader: string): Promise<LeadNode | null> {
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export type CreateCallInput = {
|
|||||||
disposition?: string;
|
disposition?: string;
|
||||||
callNotes?: string;
|
callNotes?: string;
|
||||||
leadId?: string;
|
leadId?: string;
|
||||||
|
sla?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CreateLeadActivityInput = {
|
export type CreateLeadActivityInput = {
|
||||||
@@ -62,6 +63,19 @@ export type CreateLeadActivityInput = {
|
|||||||
leadId: string;
|
leadId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type CreateLeadInput = {
|
||||||
|
name: string;
|
||||||
|
contactName?: { firstName: string; lastName?: string };
|
||||||
|
contactPhone?: { primaryPhoneNumber: string };
|
||||||
|
contactEmail?: { primaryEmailAddress: string };
|
||||||
|
source?: string;
|
||||||
|
status?: string;
|
||||||
|
interestedService?: string;
|
||||||
|
assignedAgent?: string;
|
||||||
|
campaignId?: string;
|
||||||
|
notes?: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type UpdateLeadInput = {
|
export type UpdateLeadInput = {
|
||||||
leadStatus?: string;
|
leadStatus?: string;
|
||||||
lastContactedAt?: string;
|
lastContactedAt?: string;
|
||||||
|
|||||||
53
src/recordings/recordings.controller.ts
Normal file
53
src/recordings/recordings.controller.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Controller, Post, Body, Logger, HttpException } from '@nestjs/common';
|
||||||
|
import { RecordingsService } from './recordings.service';
|
||||||
|
import { SessionService } from '../auth/session.service';
|
||||||
|
|
||||||
|
const CACHE_TTL = 7 * 24 * 3600; // 7 days
|
||||||
|
|
||||||
|
@Controller('api/recordings')
|
||||||
|
export class RecordingsController {
|
||||||
|
private readonly logger = new Logger(RecordingsController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly recordings: RecordingsService,
|
||||||
|
private readonly session: SessionService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('analyze')
|
||||||
|
async analyze(@Body() body: { recordingUrl: string; callId?: string }) {
|
||||||
|
if (!body.recordingUrl) {
|
||||||
|
throw new HttpException('recordingUrl required', 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cacheKey = body.callId ? `call:analysis:${body.callId}` : null;
|
||||||
|
|
||||||
|
// Check Redis cache first
|
||||||
|
if (cacheKey) {
|
||||||
|
try {
|
||||||
|
const cached = await this.session.getCache(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
this.logger.log(`[RECORDING] Cache hit: ${cacheKey}`);
|
||||||
|
return JSON.parse(cached);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[RECORDING] Cache miss — analyzing: ${body.recordingUrl} callId=${body.callId ?? 'none'}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const analysis = await this.recordings.analyzeRecording(body.recordingUrl);
|
||||||
|
this.logger.log(`[RECORDING] Analysis complete: ${analysis.transcript.length} utterances, sentiment=${analysis.sentiment}`);
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
if (cacheKey) {
|
||||||
|
this.session.setCache(cacheKey, JSON.stringify(analysis), CACHE_TTL)
|
||||||
|
.catch(err => this.logger.warn(`[RECORDING] Cache write failed: ${err}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
return analysis;
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`[RECORDING] Analysis failed: ${error.message}`);
|
||||||
|
throw new HttpException(error.message ?? 'Analysis failed', 502);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/recordings/recordings.module.ts
Normal file
11
src/recordings/recordings.module.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { RecordingsController } from './recordings.controller';
|
||||||
|
import { RecordingsService } from './recordings.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
|
controllers: [RecordingsController],
|
||||||
|
providers: [RecordingsService],
|
||||||
|
})
|
||||||
|
export class RecordingsModule {}
|
||||||
250
src/recordings/recordings.service.ts
Normal file
250
src/recordings/recordings.service.ts
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { generateObject } from 'ai';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { createAiModel } from '../ai/ai-provider';
|
||||||
|
import type { LanguageModel } from 'ai';
|
||||||
|
|
||||||
|
const DEEPGRAM_API = 'https://api.deepgram.com/v1/listen';
|
||||||
|
|
||||||
|
export type TranscriptWord = {
|
||||||
|
word: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
speaker: number;
|
||||||
|
confidence: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TranscriptUtterance = {
|
||||||
|
speaker: number;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CallAnalysis = {
|
||||||
|
transcript: TranscriptUtterance[];
|
||||||
|
summary: string | null;
|
||||||
|
sentiment: 'positive' | 'neutral' | 'negative' | 'mixed';
|
||||||
|
sentimentScore: number;
|
||||||
|
insights: {
|
||||||
|
keyTopics: string[];
|
||||||
|
actionItems: string[];
|
||||||
|
coachingNotes: string[];
|
||||||
|
complianceFlags: string[];
|
||||||
|
patientSatisfaction: string;
|
||||||
|
callOutcome: string;
|
||||||
|
};
|
||||||
|
durationSec: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RecordingsService {
|
||||||
|
private readonly logger = new Logger(RecordingsService.name);
|
||||||
|
private readonly deepgramApiKey: string;
|
||||||
|
private readonly aiModel: LanguageModel | null;
|
||||||
|
|
||||||
|
constructor(private config: ConfigService) {
|
||||||
|
this.deepgramApiKey = process.env.DEEPGRAM_API_KEY ?? '';
|
||||||
|
this.aiModel = createAiModel(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
async analyzeRecording(recordingUrl: string): Promise<CallAnalysis> {
|
||||||
|
if (!this.deepgramApiKey) throw new Error('DEEPGRAM_API_KEY not configured');
|
||||||
|
|
||||||
|
this.logger.log(`[RECORDING] Analyzing: ${recordingUrl}`);
|
||||||
|
|
||||||
|
// Step 1: Send to Deepgram pre-recorded API with diarization + sentiment
|
||||||
|
const dgResponse = await fetch(DEEPGRAM_API + '?' + new URLSearchParams({
|
||||||
|
model: 'nova-2',
|
||||||
|
language: 'multi',
|
||||||
|
smart_format: 'true',
|
||||||
|
diarize: 'true',
|
||||||
|
multichannel: 'true',
|
||||||
|
topics: 'true',
|
||||||
|
sentiment: 'true',
|
||||||
|
utterances: 'true',
|
||||||
|
}), {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Token ${this.deepgramApiKey}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ url: recordingUrl }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!dgResponse.ok) {
|
||||||
|
const err = await dgResponse.text();
|
||||||
|
this.logger.error(`[RECORDING] Deepgram failed: ${dgResponse.status} ${err}`);
|
||||||
|
throw new Error(`Deepgram transcription failed: ${dgResponse.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dgData = await dgResponse.json();
|
||||||
|
const results = dgData.results;
|
||||||
|
|
||||||
|
// Extract utterances (channel-labeled for multichannel, speaker-labeled otherwise)
|
||||||
|
const utterances: TranscriptUtterance[] = (results?.utterances ?? []).map((u: any) => ({
|
||||||
|
speaker: u.channel ?? u.speaker ?? 0,
|
||||||
|
start: u.start ?? 0,
|
||||||
|
end: u.end ?? 0,
|
||||||
|
text: u.transcript ?? '',
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Extract summary
|
||||||
|
const summary = results?.summary?.short ?? null;
|
||||||
|
|
||||||
|
// Extract sentiment from Deepgram
|
||||||
|
const sentiments = results?.sentiments?.segments ?? [];
|
||||||
|
const avgSentiment = this.computeAverageSentiment(sentiments);
|
||||||
|
|
||||||
|
// Extract topics
|
||||||
|
const topics = results?.topics?.segments?.flatMap((s: any) =>
|
||||||
|
(s.topics ?? []).map((t: any) => t.topic),
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
const duration = results?.channels?.[0]?.alternatives?.[0]?.words?.length > 0
|
||||||
|
? results.channels[0].alternatives[0].words.slice(-1)[0].end
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Step 2: Build raw transcript with channel labels for AI to identify roles
|
||||||
|
const rawTranscript = utterances.map(u =>
|
||||||
|
`Channel ${u.speaker}: ${u.text}`,
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
this.logger.log(`[RECORDING] Transcribed: ${utterances.length} utterances, ${Math.round(duration)}s`);
|
||||||
|
|
||||||
|
// Step 3: Ask AI to identify agent vs customer, then generate insights
|
||||||
|
const speakerMap = await this.identifySpeakers(rawTranscript);
|
||||||
|
const fullTranscript = utterances.map(u =>
|
||||||
|
`${speakerMap[u.speaker] ?? `Speaker ${u.speaker}`}: ${u.text}`,
|
||||||
|
).join('\n');
|
||||||
|
|
||||||
|
// Remap utterance speaker labels for the frontend
|
||||||
|
for (const u of utterances) {
|
||||||
|
// 0 = agent, 1 = customer in the returned data
|
||||||
|
const role = speakerMap[u.speaker];
|
||||||
|
if (role === 'Agent') u.speaker = 0;
|
||||||
|
else if (role === 'Customer') u.speaker = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const insights = await this.generateInsights(fullTranscript, summary, topics);
|
||||||
|
|
||||||
|
return {
|
||||||
|
transcript: utterances,
|
||||||
|
summary,
|
||||||
|
sentiment: avgSentiment.label,
|
||||||
|
sentimentScore: avgSentiment.score,
|
||||||
|
insights,
|
||||||
|
durationSec: Math.round(duration),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async identifySpeakers(rawTranscript: string): Promise<Record<number, string>> {
|
||||||
|
if (!this.aiModel || !rawTranscript.trim()) {
|
||||||
|
return { 0: 'Customer', 1: 'Agent' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { object } = await generateObject({
|
||||||
|
model: this.aiModel,
|
||||||
|
schema: z.object({
|
||||||
|
agentChannel: z.number().describe('The channel number (0 or 1) that is the call center agent'),
|
||||||
|
reasoning: z.string().describe('Brief explanation of how you identified the agent'),
|
||||||
|
}),
|
||||||
|
system: `You are analyzing a hospital call center recording transcript.
|
||||||
|
Each line is labeled with a channel number. One channel is the call center agent, the other is the customer/patient.
|
||||||
|
|
||||||
|
The AGENT typically:
|
||||||
|
- Greets professionally ("Hello, Global Hospital", "How can I help you?")
|
||||||
|
- Asks for patient details (name, phone, department)
|
||||||
|
- Provides information about doctors, schedules, services
|
||||||
|
- Navigates systems, puts on hold, transfers calls
|
||||||
|
|
||||||
|
The CUSTOMER typically:
|
||||||
|
- Asks questions about appointments, doctors, services
|
||||||
|
- Provides personal details when asked
|
||||||
|
- Describes symptoms or reasons for calling`,
|
||||||
|
prompt: rawTranscript,
|
||||||
|
maxOutputTokens: 100,
|
||||||
|
});
|
||||||
|
|
||||||
|
const agentCh = object.agentChannel;
|
||||||
|
const customerCh = agentCh === 0 ? 1 : 0;
|
||||||
|
this.logger.log(`[RECORDING] Speaker ID: agent=Ch${agentCh}, customer=Ch${customerCh} (${object.reasoning})`);
|
||||||
|
return { [agentCh]: 'Agent', [customerCh]: 'Customer' };
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`[RECORDING] Speaker identification failed: ${err}`);
|
||||||
|
return { 0: 'Customer', 1: 'Agent' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private computeAverageSentiment(segments: any[]): { label: 'positive' | 'neutral' | 'negative' | 'mixed'; score: number } {
|
||||||
|
if (!segments?.length) return { label: 'neutral', score: 0 };
|
||||||
|
|
||||||
|
let positive = 0, negative = 0, neutral = 0;
|
||||||
|
for (const seg of segments) {
|
||||||
|
const s = seg.sentiment ?? 'neutral';
|
||||||
|
if (s === 'positive') positive++;
|
||||||
|
else if (s === 'negative') negative++;
|
||||||
|
else neutral++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = segments.length;
|
||||||
|
const score = (positive - negative) / total;
|
||||||
|
|
||||||
|
if (positive > negative * 2) return { label: 'positive', score };
|
||||||
|
if (negative > positive * 2) return { label: 'negative', score };
|
||||||
|
if (positive > 0 && negative > 0) return { label: 'mixed', score };
|
||||||
|
return { label: 'neutral', score };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateInsights(
|
||||||
|
transcript: string,
|
||||||
|
summary: string | null,
|
||||||
|
topics: string[],
|
||||||
|
): Promise<CallAnalysis['insights']> {
|
||||||
|
if (!this.aiModel || !transcript.trim()) {
|
||||||
|
return {
|
||||||
|
keyTopics: topics.slice(0, 5),
|
||||||
|
actionItems: [],
|
||||||
|
coachingNotes: [],
|
||||||
|
complianceFlags: [],
|
||||||
|
patientSatisfaction: 'Unknown',
|
||||||
|
callOutcome: 'Unknown',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { object } = await generateObject({
|
||||||
|
model: this.aiModel,
|
||||||
|
schema: z.object({
|
||||||
|
keyTopics: z.array(z.string()).describe('Main topics discussed (max 5)'),
|
||||||
|
actionItems: z.array(z.string()).describe('Follow-up actions needed'),
|
||||||
|
coachingNotes: z.array(z.string()).describe('Agent performance observations — what went well and what could improve'),
|
||||||
|
complianceFlags: z.array(z.string()).describe('Any compliance concerns (HIPAA, patient safety, misinformation)'),
|
||||||
|
patientSatisfaction: z.string().describe('One-line assessment of patient satisfaction'),
|
||||||
|
callOutcome: z.string().describe('One-line summary of what was accomplished'),
|
||||||
|
}),
|
||||||
|
system: `You are a call quality analyst for Global Hospital Bangalore.
|
||||||
|
Analyze the following call recording transcript and provide structured insights.
|
||||||
|
Be specific, brief, and actionable. Focus on healthcare context.
|
||||||
|
${summary ? `\nCall summary: ${summary}` : ''}
|
||||||
|
${topics.length > 0 ? `\nDetected topics: ${topics.join(', ')}` : ''}`,
|
||||||
|
prompt: transcript,
|
||||||
|
maxOutputTokens: 500,
|
||||||
|
});
|
||||||
|
|
||||||
|
return object;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error(`[RECORDING] AI insights failed: ${err}`);
|
||||||
|
return {
|
||||||
|
keyTopics: topics.slice(0, 5),
|
||||||
|
actionItems: [],
|
||||||
|
coachingNotes: [],
|
||||||
|
complianceFlags: [],
|
||||||
|
patientSatisfaction: 'Analysis unavailable',
|
||||||
|
callOutcome: 'Analysis unavailable',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/rules-engine/actions/assign.action.ts
Normal file
12
src/rules-engine/actions/assign.action.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// src/rules-engine/actions/assign.action.ts
|
||||||
|
|
||||||
|
import type { ActionHandler, ActionResult } from '../types/action.types';
|
||||||
|
import type { RuleAction } from '../types/rule.types';
|
||||||
|
|
||||||
|
export class AssignActionHandler implements ActionHandler {
|
||||||
|
type = 'assign';
|
||||||
|
|
||||||
|
async execute(_action: RuleAction, _context: Record<string, any>): Promise<ActionResult> {
|
||||||
|
return { success: true, data: { stub: true, action: 'assign' } };
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/rules-engine/actions/escalate.action.ts
Normal file
12
src/rules-engine/actions/escalate.action.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// src/rules-engine/actions/escalate.action.ts
|
||||||
|
|
||||||
|
import type { ActionHandler, ActionResult } from '../types/action.types';
|
||||||
|
import type { RuleAction } from '../types/rule.types';
|
||||||
|
|
||||||
|
export class EscalateActionHandler implements ActionHandler {
|
||||||
|
type = 'escalate';
|
||||||
|
|
||||||
|
async execute(_action: RuleAction, _context: Record<string, any>): Promise<ActionResult> {
|
||||||
|
return { success: true, data: { stub: true, action: 'escalate' } };
|
||||||
|
}
|
||||||
|
}
|
||||||
33
src/rules-engine/actions/score.action.ts
Normal file
33
src/rules-engine/actions/score.action.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
// src/rules-engine/actions/score.action.ts
|
||||||
|
|
||||||
|
import type { ActionHandler, ActionResult } from '../types/action.types';
|
||||||
|
import type { RuleAction, ScoreActionParams } from '../types/rule.types';
|
||||||
|
import { computeSlaMultiplier } from '../facts/call-facts.provider';
|
||||||
|
|
||||||
|
export class ScoreActionHandler implements ActionHandler {
|
||||||
|
type = 'score';
|
||||||
|
|
||||||
|
async execute(action: RuleAction, context: Record<string, any>): Promise<ActionResult> {
|
||||||
|
const params = action.params as ScoreActionParams;
|
||||||
|
let score = params.weight;
|
||||||
|
let slaApplied = false;
|
||||||
|
let campaignApplied = false;
|
||||||
|
|
||||||
|
if (params.slaMultiplier && context['call.slaElapsedPercent'] != null) {
|
||||||
|
score *= computeSlaMultiplier(context['call.slaElapsedPercent']);
|
||||||
|
slaApplied = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.campaignMultiplier) {
|
||||||
|
const campaignWeight = (context['_campaignWeight'] ?? 5) / 10;
|
||||||
|
const sourceWeight = (context['_sourceWeight'] ?? 5) / 10;
|
||||||
|
score *= campaignWeight * sourceWeight;
|
||||||
|
campaignApplied = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
data: { score, weight: params.weight, slaApplied, campaignApplied },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/rules-engine/consumers/worklist.consumer.ts
Normal file
25
src/rules-engine/consumers/worklist.consumer.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// src/rules-engine/consumers/worklist.consumer.ts
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { RulesEngineService } from '../rules-engine.service';
|
||||||
|
import { RulesStorageService } from '../rules-storage.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class WorklistConsumer {
|
||||||
|
private readonly logger = new Logger(WorklistConsumer.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly engine: RulesEngineService,
|
||||||
|
private readonly storage: RulesStorageService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async scoreAndRank(worklistItems: any[]): Promise<any[]> {
|
||||||
|
const rules = await this.storage.getByTrigger('on_request', 'worklist');
|
||||||
|
if (rules.length === 0) {
|
||||||
|
this.logger.debug('No scoring rules configured — returning unsorted');
|
||||||
|
return worklistItems;
|
||||||
|
}
|
||||||
|
this.logger.debug(`Scoring ${worklistItems.length} items with ${rules.length} rules`);
|
||||||
|
return this.engine.scoreWorklist(worklistItems);
|
||||||
|
}
|
||||||
|
}
|
||||||
18
src/rules-engine/facts/agent-facts.provider.ts
Normal file
18
src/rules-engine/facts/agent-facts.provider.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
// src/rules-engine/facts/agent-facts.provider.ts
|
||||||
|
|
||||||
|
import type { FactProvider, FactValue } from '../types/fact.types';
|
||||||
|
|
||||||
|
export class AgentFactsProvider implements FactProvider {
|
||||||
|
name = 'agent';
|
||||||
|
|
||||||
|
async resolveFacts(agent: any): Promise<Record<string, FactValue>> {
|
||||||
|
return {
|
||||||
|
'agent.status': agent.status ?? 'OFFLINE',
|
||||||
|
'agent.activeCallCount': agent.activeCallCount ?? 0,
|
||||||
|
'agent.todayCallCount': agent.todayCallCount ?? 0,
|
||||||
|
'agent.skills': agent.skills ?? [],
|
||||||
|
'agent.campaigns': agent.campaigns ?? [],
|
||||||
|
'agent.idleMinutes': agent.idleMinutes ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/rules-engine/facts/call-facts.provider.ts
Normal file
52
src/rules-engine/facts/call-facts.provider.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
// src/rules-engine/facts/call-facts.provider.ts
|
||||||
|
|
||||||
|
import type { FactProvider, FactValue } from '../types/fact.types';
|
||||||
|
import type { PriorityConfig } from '../types/rule.types';
|
||||||
|
|
||||||
|
export class CallFactsProvider implements FactProvider {
|
||||||
|
name = 'call';
|
||||||
|
|
||||||
|
async resolveFacts(call: any, priorityConfig?: PriorityConfig): Promise<Record<string, FactValue>> {
|
||||||
|
const taskType = this.inferTaskType(call);
|
||||||
|
const slaMinutes = priorityConfig?.taskWeights[taskType]?.slaMinutes ?? 1440;
|
||||||
|
const createdAt = call.createdAt ? new Date(call.createdAt).getTime() : Date.now();
|
||||||
|
const elapsedMinutes = Math.round((Date.now() - createdAt) / 60000);
|
||||||
|
const slaElapsedPercent = Math.round((elapsedMinutes / slaMinutes) * 100);
|
||||||
|
|
||||||
|
return {
|
||||||
|
'call.direction': call.callDirection ?? call.direction ?? null,
|
||||||
|
'call.status': call.callStatus ?? null,
|
||||||
|
'call.disposition': call.disposition ?? null,
|
||||||
|
'call.durationSeconds': call.durationSeconds ?? call.durationSec ?? 0,
|
||||||
|
'call.callbackStatus': call.callbackstatus ?? call.callbackStatus ?? null,
|
||||||
|
'call.slaElapsedPercent': slaElapsedPercent,
|
||||||
|
'call.slaBreached': slaElapsedPercent > 100,
|
||||||
|
'call.missedCount': call.missedcallcount ?? call.missedCount ?? 0,
|
||||||
|
'call.taskType': taskType,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private inferTaskType(call: any): string {
|
||||||
|
if (call.callStatus === 'MISSED' || call.type === 'missed') return 'missed_call';
|
||||||
|
if (call.followUpType === 'CALLBACK' || call.type === 'callback') return 'follow_up';
|
||||||
|
if (call.type === 'follow-up') return 'follow_up';
|
||||||
|
if (call.contactAttempts >= 3) return 'attempt_3';
|
||||||
|
if (call.contactAttempts >= 2) return 'attempt_2';
|
||||||
|
if (call.campaignId || call.type === 'lead') return 'campaign_lead';
|
||||||
|
return 'campaign_lead';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exported scoring functions — used by both sidecar and frontend (via scoring.ts)
|
||||||
|
export function computeSlaMultiplier(slaElapsedPercent: number): number {
|
||||||
|
const elapsed = slaElapsedPercent / 100;
|
||||||
|
if (elapsed > 1) return 1.0 + (elapsed - 1) * 0.5;
|
||||||
|
return Math.pow(elapsed, 1.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function computeSlaStatus(slaElapsedPercent: number): 'low' | 'medium' | 'high' | 'critical' {
|
||||||
|
if (slaElapsedPercent > 100) return 'critical';
|
||||||
|
if (slaElapsedPercent >= 80) return 'high';
|
||||||
|
if (slaElapsedPercent >= 50) return 'medium';
|
||||||
|
return 'low';
|
||||||
|
}
|
||||||
30
src/rules-engine/facts/lead-facts.provider.ts
Normal file
30
src/rules-engine/facts/lead-facts.provider.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
// src/rules-engine/facts/lead-facts.provider.ts
|
||||||
|
|
||||||
|
import type { FactProvider, FactValue } from '../types/fact.types';
|
||||||
|
|
||||||
|
export class LeadFactsProvider implements FactProvider {
|
||||||
|
name = 'lead';
|
||||||
|
|
||||||
|
async resolveFacts(lead: any): Promise<Record<string, FactValue>> {
|
||||||
|
const createdAt = lead.createdAt ? new Date(lead.createdAt).getTime() : Date.now();
|
||||||
|
const lastContacted = lead.lastContacted ? new Date(lead.lastContacted).getTime() : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
'lead.source': lead.leadSource ?? lead.source ?? null,
|
||||||
|
'lead.status': lead.leadStatus ?? lead.status ?? null,
|
||||||
|
'lead.priority': lead.priority ?? 'NORMAL',
|
||||||
|
'lead.campaignId': lead.campaignId ?? null,
|
||||||
|
'lead.campaignName': lead.campaignName ?? null,
|
||||||
|
'lead.interestedService': lead.interestedService ?? null,
|
||||||
|
'lead.contactAttempts': lead.contactAttempts ?? 0,
|
||||||
|
'lead.ageMinutes': Math.round((Date.now() - createdAt) / 60000),
|
||||||
|
'lead.ageDays': Math.round((Date.now() - createdAt) / 86400000),
|
||||||
|
'lead.lastContactedMinutes': lastContacted ? Math.round((Date.now() - lastContacted) / 60000) : null,
|
||||||
|
'lead.hasPatient': !!lead.patientId,
|
||||||
|
'lead.isDuplicate': lead.isDuplicate ?? false,
|
||||||
|
'lead.isSpam': lead.isSpam ?? false,
|
||||||
|
'lead.spamScore': lead.spamScore ?? 0,
|
||||||
|
'lead.leadScore': lead.leadScore ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
123
src/rules-engine/rules-engine.controller.ts
Normal file
123
src/rules-engine/rules-engine.controller.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
// src/rules-engine/rules-engine.controller.ts
|
||||||
|
|
||||||
|
import { Controller, Get, Post, Put, Delete, Patch, Param, Body, HttpException, Logger } from '@nestjs/common';
|
||||||
|
import { RulesStorageService } from './rules-storage.service';
|
||||||
|
import { RulesEngineService } from './rules-engine.service';
|
||||||
|
import type { Rule, PriorityConfig } from './types/rule.types';
|
||||||
|
import { readFileSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
|
@Controller('api/rules')
|
||||||
|
export class RulesEngineController {
|
||||||
|
private readonly logger = new Logger(RulesEngineController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly storage: RulesStorageService,
|
||||||
|
private readonly engine: RulesEngineService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// --- Priority Config (slider UI) ---
|
||||||
|
|
||||||
|
@Get('priority-config')
|
||||||
|
async getPriorityConfig() {
|
||||||
|
return this.storage.getPriorityConfig();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('priority-config')
|
||||||
|
async updatePriorityConfig(@Body() body: PriorityConfig) {
|
||||||
|
return this.storage.updatePriorityConfig(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rule CRUD ---
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async listRules() {
|
||||||
|
return this.storage.getAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
async getRule(@Param('id') id: string) {
|
||||||
|
const rule = await this.storage.getById(id);
|
||||||
|
if (!rule) throw new HttpException('Rule not found', 404);
|
||||||
|
return rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
async createRule(@Body() body: any) {
|
||||||
|
if (!body.name || !body.trigger || !body.conditions || !body.action) {
|
||||||
|
throw new HttpException('name, trigger, conditions, and action are required', 400);
|
||||||
|
}
|
||||||
|
return this.storage.create({
|
||||||
|
...body,
|
||||||
|
ruleType: body.ruleType ?? 'priority',
|
||||||
|
enabled: body.enabled ?? true,
|
||||||
|
priority: body.priority ?? 99,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
async updateRule(@Param('id') id: string, @Body() body: Partial<Rule>) {
|
||||||
|
const updated = await this.storage.update(id, body);
|
||||||
|
if (!updated) throw new HttpException('Rule not found', 404);
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
async deleteRule(@Param('id') id: string) {
|
||||||
|
const deleted = await this.storage.delete(id);
|
||||||
|
if (!deleted) throw new HttpException('Rule not found', 404);
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':id/toggle')
|
||||||
|
async toggleRule(@Param('id') id: string) {
|
||||||
|
const toggled = await this.storage.toggle(id);
|
||||||
|
if (!toggled) throw new HttpException('Rule not found', 404);
|
||||||
|
return toggled;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('reorder')
|
||||||
|
async reorderRules(@Body() body: { ids: string[] }) {
|
||||||
|
if (!body.ids?.length) throw new HttpException('ids array required', 400);
|
||||||
|
return this.storage.reorder(body.ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Evaluation ---
|
||||||
|
|
||||||
|
@Post('evaluate')
|
||||||
|
async evaluate(@Body() body: { trigger: string; triggerValue: string; facts: Record<string, any> }) {
|
||||||
|
return this.engine.evaluate(body.trigger, body.triggerValue, body.facts);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Templates ---
|
||||||
|
|
||||||
|
@Get('templates/list')
|
||||||
|
async listTemplates() {
|
||||||
|
return [{ id: 'hospital-starter', name: 'Hospital Starter Pack', description: 'Default rules for a hospital call center', ruleCount: 7 }];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('templates/:id/apply')
|
||||||
|
async applyTemplate(@Param('id') id: string) {
|
||||||
|
if (id !== 'hospital-starter') throw new HttpException('Template not found', 404);
|
||||||
|
|
||||||
|
let template: any;
|
||||||
|
try {
|
||||||
|
template = JSON.parse(readFileSync(join(__dirname, 'templates', 'hospital-starter.json'), 'utf8'));
|
||||||
|
} catch {
|
||||||
|
throw new HttpException('Failed to load template', 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply priority config
|
||||||
|
await this.storage.updatePriorityConfig(template.priorityConfig);
|
||||||
|
|
||||||
|
// Create rules
|
||||||
|
const created: Rule[] = [];
|
||||||
|
for (const rule of template.rules) {
|
||||||
|
const newRule = await this.storage.create(rule);
|
||||||
|
created.push(newRule);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Applied hospital-starter template: ${created.length} rules + priority config`);
|
||||||
|
return { status: 'ok', rulesCreated: created.length, rules: created };
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/rules-engine/rules-engine.module.ts
Normal file
14
src/rules-engine/rules-engine.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// src/rules-engine/rules-engine.module.ts
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { RulesEngineController } from './rules-engine.controller';
|
||||||
|
import { RulesEngineService } from './rules-engine.service';
|
||||||
|
import { RulesStorageService } from './rules-storage.service';
|
||||||
|
import { WorklistConsumer } from './consumers/worklist.consumer';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [RulesEngineController],
|
||||||
|
providers: [RulesEngineService, RulesStorageService, WorklistConsumer],
|
||||||
|
exports: [RulesEngineService, RulesStorageService, WorklistConsumer],
|
||||||
|
})
|
||||||
|
export class RulesEngineModule {}
|
||||||
139
src/rules-engine/rules-engine.service.ts
Normal file
139
src/rules-engine/rules-engine.service.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
// src/rules-engine/rules-engine.service.ts
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Engine } from 'json-rules-engine';
|
||||||
|
import { RulesStorageService } from './rules-storage.service';
|
||||||
|
import { LeadFactsProvider } from './facts/lead-facts.provider';
|
||||||
|
import { CallFactsProvider, computeSlaMultiplier, computeSlaStatus } from './facts/call-facts.provider';
|
||||||
|
import { AgentFactsProvider } from './facts/agent-facts.provider';
|
||||||
|
import { ScoreActionHandler } from './actions/score.action';
|
||||||
|
import { AssignActionHandler } from './actions/assign.action';
|
||||||
|
import { EscalateActionHandler } from './actions/escalate.action';
|
||||||
|
import type { Rule, ScoredItem, ScoreBreakdown, PriorityConfig } from './types/rule.types';
|
||||||
|
import type { ActionHandler } from './types/action.types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RulesEngineService {
|
||||||
|
private readonly logger = new Logger(RulesEngineService.name);
|
||||||
|
private readonly leadFacts = new LeadFactsProvider();
|
||||||
|
private readonly callFacts = new CallFactsProvider();
|
||||||
|
private readonly agentFacts = new AgentFactsProvider();
|
||||||
|
private readonly actionHandlers: Map<string, ActionHandler>;
|
||||||
|
|
||||||
|
constructor(private readonly storage: RulesStorageService) {
|
||||||
|
this.actionHandlers = new Map([
|
||||||
|
['score', new ScoreActionHandler()],
|
||||||
|
['assign', new AssignActionHandler()],
|
||||||
|
['escalate', new EscalateActionHandler()],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async evaluate(triggerType: string, triggerValue: string, factContext: Record<string, any>): Promise<{ rulesApplied: string[]; results: any[] }> {
|
||||||
|
const rules = await this.storage.getByTrigger(triggerType, triggerValue);
|
||||||
|
if (rules.length === 0) return { rulesApplied: [], results: [] };
|
||||||
|
|
||||||
|
const engine = new Engine();
|
||||||
|
const ruleMap = new Map<string, Rule>();
|
||||||
|
|
||||||
|
for (const rule of rules) {
|
||||||
|
engine.addRule({
|
||||||
|
conditions: rule.conditions as any,
|
||||||
|
event: { type: rule.action.type, params: { ruleId: rule.id, ...rule.action.params as any } },
|
||||||
|
priority: rule.priority,
|
||||||
|
});
|
||||||
|
ruleMap.set(rule.id, rule);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(factContext)) {
|
||||||
|
engine.addFact(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { events } = await engine.run();
|
||||||
|
const results: any[] = [];
|
||||||
|
const rulesApplied: string[] = [];
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
const ruleId = event.params?.ruleId;
|
||||||
|
const rule = ruleMap.get(ruleId);
|
||||||
|
if (!rule) continue;
|
||||||
|
const handler = this.actionHandlers.get(event.type);
|
||||||
|
if (handler) {
|
||||||
|
const result = await handler.execute(rule.action, factContext);
|
||||||
|
results.push({ ruleId, ruleName: rule.name, ...result });
|
||||||
|
rulesApplied.push(rule.name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rulesApplied, results };
|
||||||
|
}
|
||||||
|
|
||||||
|
async scoreWorklistItem(item: any, priorityConfig: PriorityConfig): Promise<ScoredItem> {
|
||||||
|
const leadFacts = await this.leadFacts.resolveFacts(item.originalLead ?? item);
|
||||||
|
const callFacts = await this.callFacts.resolveFacts(item, priorityConfig);
|
||||||
|
const taskType = callFacts['call.taskType'] as string;
|
||||||
|
|
||||||
|
// Inject priority config weights into context for the score action
|
||||||
|
const campaignWeight = item.campaignId ? (priorityConfig.campaignWeights[item.campaignId] ?? 5) : 5;
|
||||||
|
const sourceWeight = priorityConfig.sourceWeights[leadFacts['lead.source'] as string] ?? 5;
|
||||||
|
|
||||||
|
const allFacts: Record<string, any> = {
|
||||||
|
...leadFacts,
|
||||||
|
...callFacts,
|
||||||
|
'_campaignWeight': campaignWeight,
|
||||||
|
'_sourceWeight': sourceWeight,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { rulesApplied, results } = await this.evaluate('on_request', 'worklist', allFacts);
|
||||||
|
|
||||||
|
let totalScore = 0;
|
||||||
|
let slaMultiplierVal = 1;
|
||||||
|
let campaignMultiplierVal = 1;
|
||||||
|
|
||||||
|
for (const result of results) {
|
||||||
|
if (result.success && result.data?.score != null) {
|
||||||
|
totalScore += result.data.score;
|
||||||
|
if (result.data.slaApplied) slaMultiplierVal = computeSlaMultiplier((allFacts['call.slaElapsedPercent'] as number) ?? 0);
|
||||||
|
if (result.data.campaignApplied) campaignMultiplierVal = (campaignWeight / 10) * (sourceWeight / 10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const slaElapsedPercent = (allFacts['call.slaElapsedPercent'] as number) ?? 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
score: Math.round(totalScore * 100) / 100,
|
||||||
|
scoreBreakdown: {
|
||||||
|
baseScore: totalScore,
|
||||||
|
slaMultiplier: Math.round(slaMultiplierVal * 100) / 100,
|
||||||
|
campaignMultiplier: Math.round(campaignMultiplierVal * 100) / 100,
|
||||||
|
rulesApplied,
|
||||||
|
},
|
||||||
|
slaStatus: computeSlaStatus(slaElapsedPercent),
|
||||||
|
slaElapsedPercent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async scoreWorklist(items: any[]): Promise<(any & ScoredItem)[]> {
|
||||||
|
const priorityConfig = await this.storage.getPriorityConfig();
|
||||||
|
const scored = await Promise.all(
|
||||||
|
items.map(async (item) => {
|
||||||
|
const scoreData = await this.scoreWorklistItem(item, priorityConfig);
|
||||||
|
return { ...item, ...scoreData };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
scored.sort((a, b) => b.score - a.score);
|
||||||
|
return scored;
|
||||||
|
}
|
||||||
|
|
||||||
|
async previewScoring(items: any[], config: PriorityConfig): Promise<(any & ScoredItem)[]> {
|
||||||
|
// Same as scoreWorklist but uses provided config (for live preview)
|
||||||
|
const scored = await Promise.all(
|
||||||
|
items.map(async (item) => {
|
||||||
|
const scoreData = await this.scoreWorklistItem(item, config);
|
||||||
|
return { ...item, ...scoreData };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
scored.sort((a, b) => b.score - a.score);
|
||||||
|
return scored;
|
||||||
|
}
|
||||||
|
}
|
||||||
186
src/rules-engine/rules-storage.service.ts
Normal file
186
src/rules-engine/rules-storage.service.ts
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
// src/rules-engine/rules-storage.service.ts
|
||||||
|
|
||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
||||||
|
import { dirname, join } from 'path';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import type { Rule, PriorityConfig } from './types/rule.types';
|
||||||
|
import { DEFAULT_PRIORITY_CONFIG } from './types/rule.types';
|
||||||
|
|
||||||
|
const RULES_KEY = 'rules:config';
|
||||||
|
const PRIORITY_CONFIG_KEY = 'rules:priority-config';
|
||||||
|
const VERSION_KEY = 'rules:scores:version';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class RulesStorageService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(RulesStorageService.name);
|
||||||
|
private readonly redis: Redis;
|
||||||
|
private readonly backupDir: string;
|
||||||
|
|
||||||
|
constructor(private config: ConfigService) {
|
||||||
|
this.redis = new Redis(config.get<string>('REDIS_URL') ?? 'redis://localhost:6379');
|
||||||
|
this.backupDir = config.get<string>('RULES_BACKUP_DIR') ?? join(process.cwd(), 'data');
|
||||||
|
}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
// Restore rules from backup if Redis is empty
|
||||||
|
const existing = await this.redis.get(RULES_KEY);
|
||||||
|
if (!existing) {
|
||||||
|
const rulesBackup = join(this.backupDir, 'rules-config.json');
|
||||||
|
if (existsSync(rulesBackup)) {
|
||||||
|
const data = readFileSync(rulesBackup, 'utf8');
|
||||||
|
await this.redis.set(RULES_KEY, data);
|
||||||
|
this.logger.log(`Restored ${JSON.parse(data).length} rules from backup`);
|
||||||
|
} else {
|
||||||
|
await this.redis.set(RULES_KEY, '[]');
|
||||||
|
this.logger.log('Initialized empty rules config');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore priority config from backup if Redis is empty
|
||||||
|
const existingConfig = await this.redis.get(PRIORITY_CONFIG_KEY);
|
||||||
|
if (!existingConfig) {
|
||||||
|
const configBackup = join(this.backupDir, 'priority-config.json');
|
||||||
|
if (existsSync(configBackup)) {
|
||||||
|
const data = readFileSync(configBackup, 'utf8');
|
||||||
|
await this.redis.set(PRIORITY_CONFIG_KEY, data);
|
||||||
|
this.logger.log('Restored priority config from backup');
|
||||||
|
} else {
|
||||||
|
await this.redis.set(PRIORITY_CONFIG_KEY, JSON.stringify(DEFAULT_PRIORITY_CONFIG));
|
||||||
|
this.logger.log('Initialized default priority config');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Priority Config ---
|
||||||
|
|
||||||
|
async getPriorityConfig(): Promise<PriorityConfig> {
|
||||||
|
const data = await this.redis.get(PRIORITY_CONFIG_KEY);
|
||||||
|
return data ? JSON.parse(data) : DEFAULT_PRIORITY_CONFIG;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updatePriorityConfig(config: PriorityConfig): Promise<PriorityConfig> {
|
||||||
|
await this.redis.set(PRIORITY_CONFIG_KEY, JSON.stringify(config));
|
||||||
|
await this.redis.incr(VERSION_KEY);
|
||||||
|
this.backupFile('priority-config.json', config);
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Rules CRUD ---
|
||||||
|
|
||||||
|
async getAll(): Promise<Rule[]> {
|
||||||
|
const data = await this.redis.get(RULES_KEY);
|
||||||
|
return data ? JSON.parse(data) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async getById(id: string): Promise<Rule | null> {
|
||||||
|
const rules = await this.getAll();
|
||||||
|
return rules.find(r => r.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getByTrigger(triggerType: string, triggerValue?: string): Promise<Rule[]> {
|
||||||
|
const rules = await this.getAll();
|
||||||
|
return rules.filter(r => {
|
||||||
|
if (!r.enabled) return false;
|
||||||
|
if (r.trigger.type !== triggerType) return false;
|
||||||
|
if (triggerValue && 'request' in r.trigger && r.trigger.request !== triggerValue) return false;
|
||||||
|
if (triggerValue && 'event' in r.trigger && r.trigger.event !== triggerValue) return false;
|
||||||
|
return true;
|
||||||
|
}).sort((a, b) => a.priority - b.priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(rule: Omit<Rule, 'id' | 'metadata'> & { createdBy?: string }): Promise<Rule> {
|
||||||
|
const rules = await this.getAll();
|
||||||
|
const newRule: Rule = {
|
||||||
|
...rule,
|
||||||
|
id: randomUUID(),
|
||||||
|
metadata: {
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
createdBy: rule.createdBy ?? 'system',
|
||||||
|
category: this.inferCategory(rule.action.type),
|
||||||
|
tags: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
rules.push(newRule);
|
||||||
|
await this.saveRules(rules);
|
||||||
|
return newRule;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: string, updates: Partial<Rule>): Promise<Rule | null> {
|
||||||
|
const rules = await this.getAll();
|
||||||
|
const index = rules.findIndex(r => r.id === id);
|
||||||
|
if (index === -1) return null;
|
||||||
|
rules[index] = {
|
||||||
|
...rules[index],
|
||||||
|
...updates,
|
||||||
|
id,
|
||||||
|
metadata: { ...rules[index].metadata, updatedAt: new Date().toISOString(), ...(updates.metadata ?? {}) },
|
||||||
|
};
|
||||||
|
await this.saveRules(rules);
|
||||||
|
return rules[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string): Promise<boolean> {
|
||||||
|
const rules = await this.getAll();
|
||||||
|
const filtered = rules.filter(r => r.id !== id);
|
||||||
|
if (filtered.length === rules.length) return false;
|
||||||
|
await this.saveRules(filtered);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async toggle(id: string): Promise<Rule | null> {
|
||||||
|
const rule = await this.getById(id);
|
||||||
|
if (!rule) return null;
|
||||||
|
return this.update(id, { enabled: !rule.enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
async reorder(ids: string[]): Promise<Rule[]> {
|
||||||
|
const rules = await this.getAll();
|
||||||
|
const reorderedIds = new Set(ids);
|
||||||
|
const reordered = ids.map((id, i) => {
|
||||||
|
const rule = rules.find(r => r.id === id);
|
||||||
|
if (rule) rule.priority = i;
|
||||||
|
return rule;
|
||||||
|
}).filter(Boolean) as Rule[];
|
||||||
|
const remaining = rules.filter(r => !reorderedIds.has(r.id));
|
||||||
|
const final = [...reordered, ...remaining];
|
||||||
|
await this.saveRules(final);
|
||||||
|
return final;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getVersion(): Promise<number> {
|
||||||
|
const v = await this.redis.get(VERSION_KEY);
|
||||||
|
return v ? parseInt(v, 10) : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Internal ---
|
||||||
|
|
||||||
|
private async saveRules(rules: Rule[]) {
|
||||||
|
const json = JSON.stringify(rules, null, 2);
|
||||||
|
await this.redis.set(RULES_KEY, json);
|
||||||
|
await this.redis.incr(VERSION_KEY);
|
||||||
|
this.backupFile('rules-config.json', rules);
|
||||||
|
}
|
||||||
|
|
||||||
|
private backupFile(filename: string, data: any) {
|
||||||
|
try {
|
||||||
|
if (!existsSync(this.backupDir)) mkdirSync(this.backupDir, { recursive: true });
|
||||||
|
writeFileSync(join(this.backupDir, filename), JSON.stringify(data, null, 2), 'utf8');
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to write backup ${filename}: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private inferCategory(actionType: string): Rule['metadata']['category'] {
|
||||||
|
switch (actionType) {
|
||||||
|
case 'score': return 'priority';
|
||||||
|
case 'assign': return 'assignment';
|
||||||
|
case 'escalate': return 'escalation';
|
||||||
|
case 'update': return 'lifecycle';
|
||||||
|
default: return 'priority';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
89
src/rules-engine/templates/hospital-starter.json
Normal file
89
src/rules-engine/templates/hospital-starter.json
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
{
|
||||||
|
"priorityConfig": {
|
||||||
|
"taskWeights": {
|
||||||
|
"missed_call": { "weight": 9, "slaMinutes": 720, "enabled": true },
|
||||||
|
"follow_up": { "weight": 8, "slaMinutes": 1440, "enabled": true },
|
||||||
|
"campaign_lead": { "weight": 7, "slaMinutes": 2880, "enabled": true },
|
||||||
|
"attempt_2": { "weight": 6, "slaMinutes": 1440, "enabled": true },
|
||||||
|
"attempt_3": { "weight": 4, "slaMinutes": 2880, "enabled": true }
|
||||||
|
},
|
||||||
|
"campaignWeights": {},
|
||||||
|
"sourceWeights": {
|
||||||
|
"WHATSAPP": 9, "PHONE": 8, "FACEBOOK_AD": 7, "GOOGLE_AD": 7,
|
||||||
|
"INSTAGRAM": 5, "WEBSITE": 7, "REFERRAL": 6, "WALK_IN": 5, "OTHER": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"rules": [
|
||||||
|
{
|
||||||
|
"ruleType": "priority",
|
||||||
|
"name": "Missed calls — high urgency",
|
||||||
|
"description": "Missed calls get highest priority with SLA-based urgency",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 1,
|
||||||
|
"trigger": { "type": "on_request", "request": "worklist" },
|
||||||
|
"conditions": { "all": [{ "fact": "call.taskType", "operator": "equal", "value": "missed_call" }] },
|
||||||
|
"action": { "type": "score", "params": { "weight": 9, "slaMultiplier": true } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ruleType": "priority",
|
||||||
|
"name": "Scheduled follow-ups",
|
||||||
|
"description": "Committed callbacks from prior calls",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 2,
|
||||||
|
"trigger": { "type": "on_request", "request": "worklist" },
|
||||||
|
"conditions": { "all": [{ "fact": "call.taskType", "operator": "equal", "value": "follow_up" }] },
|
||||||
|
"action": { "type": "score", "params": { "weight": 8, "slaMultiplier": true } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ruleType": "priority",
|
||||||
|
"name": "Campaign leads — weighted",
|
||||||
|
"description": "Outbound campaign calls, weighted by campaign importance",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 3,
|
||||||
|
"trigger": { "type": "on_request", "request": "worklist" },
|
||||||
|
"conditions": { "all": [{ "fact": "call.taskType", "operator": "equal", "value": "campaign_lead" }] },
|
||||||
|
"action": { "type": "score", "params": { "weight": 7, "slaMultiplier": true, "campaignMultiplier": true } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ruleType": "priority",
|
||||||
|
"name": "2nd attempt — medium urgency",
|
||||||
|
"description": "First call went unanswered, try again",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 4,
|
||||||
|
"trigger": { "type": "on_request", "request": "worklist" },
|
||||||
|
"conditions": { "all": [{ "fact": "call.taskType", "operator": "equal", "value": "attempt_2" }] },
|
||||||
|
"action": { "type": "score", "params": { "weight": 6, "slaMultiplier": true } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ruleType": "priority",
|
||||||
|
"name": "3rd attempt — lower urgency",
|
||||||
|
"description": "Two prior unanswered attempts",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 5,
|
||||||
|
"trigger": { "type": "on_request", "request": "worklist" },
|
||||||
|
"conditions": { "all": [{ "fact": "call.taskType", "operator": "equal", "value": "attempt_3" }] },
|
||||||
|
"action": { "type": "score", "params": { "weight": 4, "slaMultiplier": true } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ruleType": "priority",
|
||||||
|
"name": "Spam leads — deprioritize",
|
||||||
|
"description": "High spam score leads get pushed down",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 10,
|
||||||
|
"trigger": { "type": "on_request", "request": "worklist" },
|
||||||
|
"conditions": { "all": [{ "fact": "lead.spamScore", "operator": "greaterThan", "value": 60 }] },
|
||||||
|
"action": { "type": "score", "params": { "weight": -3 } }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ruleType": "automation",
|
||||||
|
"name": "SLA breach — escalate to supervisor",
|
||||||
|
"description": "Alert supervisor when callback SLA is breached",
|
||||||
|
"enabled": true,
|
||||||
|
"priority": 1,
|
||||||
|
"status": "draft",
|
||||||
|
"trigger": { "type": "on_schedule", "interval": "5m" },
|
||||||
|
"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" } }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
14
src/rules-engine/types/action.types.ts
Normal file
14
src/rules-engine/types/action.types.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
// src/rules-engine/types/action.types.ts
|
||||||
|
|
||||||
|
import type { RuleAction } from './rule.types';
|
||||||
|
|
||||||
|
export interface ActionHandler {
|
||||||
|
type: string;
|
||||||
|
execute(action: RuleAction, context: Record<string, any>): Promise<ActionResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ActionResult = {
|
||||||
|
success: boolean;
|
||||||
|
data?: Record<string, any>;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
15
src/rules-engine/types/fact.types.ts
Normal file
15
src/rules-engine/types/fact.types.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
// src/rules-engine/types/fact.types.ts
|
||||||
|
|
||||||
|
export type FactValue = string | number | boolean | string[] | null;
|
||||||
|
|
||||||
|
export type FactContext = {
|
||||||
|
lead?: Record<string, FactValue>;
|
||||||
|
call?: Record<string, FactValue>;
|
||||||
|
agent?: Record<string, FactValue>;
|
||||||
|
campaign?: Record<string, FactValue>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface FactProvider {
|
||||||
|
name: string;
|
||||||
|
resolveFacts(entityData: any): Promise<Record<string, FactValue>>;
|
||||||
|
}
|
||||||
126
src/rules-engine/types/rule.types.ts
Normal file
126
src/rules-engine/types/rule.types.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
// src/rules-engine/types/rule.types.ts
|
||||||
|
|
||||||
|
export type RuleType = 'priority' | 'automation';
|
||||||
|
|
||||||
|
export type RuleTrigger =
|
||||||
|
| { type: 'on_request'; request: 'worklist' | 'assignment' }
|
||||||
|
| { type: 'on_event'; event: string }
|
||||||
|
| { type: 'on_schedule'; interval: string }
|
||||||
|
| { type: 'always' };
|
||||||
|
|
||||||
|
export type RuleCategory = 'priority' | 'assignment' | 'escalation' | 'lifecycle' | 'qualification';
|
||||||
|
|
||||||
|
export type RuleOperator =
|
||||||
|
| 'equal' | 'notEqual'
|
||||||
|
| 'greaterThan' | 'greaterThanInclusive'
|
||||||
|
| 'lessThan' | 'lessThanInclusive'
|
||||||
|
| 'in' | 'notIn'
|
||||||
|
| 'contains' | 'doesNotContain'
|
||||||
|
| 'exists' | 'doesNotExist';
|
||||||
|
|
||||||
|
export type RuleCondition = {
|
||||||
|
fact: string;
|
||||||
|
operator: RuleOperator;
|
||||||
|
value: any;
|
||||||
|
path?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuleConditionGroup = {
|
||||||
|
all?: (RuleCondition | RuleConditionGroup)[];
|
||||||
|
any?: (RuleCondition | RuleConditionGroup)[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuleActionType = 'score' | 'assign' | 'escalate' | 'update' | 'notify';
|
||||||
|
|
||||||
|
export type ScoreActionParams = {
|
||||||
|
weight: number;
|
||||||
|
slaMultiplier?: boolean;
|
||||||
|
campaignMultiplier?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AssignActionParams = {
|
||||||
|
agentId?: string;
|
||||||
|
agentPool?: string[];
|
||||||
|
strategy: 'specific' | 'round-robin' | 'least-loaded' | 'skill-based';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EscalateActionParams = {
|
||||||
|
channel: 'toast' | 'notification' | 'sms' | 'email';
|
||||||
|
recipients: 'supervisor' | 'agent' | string[];
|
||||||
|
message: string;
|
||||||
|
severity: 'warning' | 'critical';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateActionParams = {
|
||||||
|
entity: string;
|
||||||
|
field: string;
|
||||||
|
value: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuleAction = {
|
||||||
|
type: RuleActionType;
|
||||||
|
params: ScoreActionParams | AssignActionParams | EscalateActionParams | UpdateActionParams;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Rule = {
|
||||||
|
id: string;
|
||||||
|
ruleType: RuleType;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
enabled: boolean;
|
||||||
|
priority: number;
|
||||||
|
trigger: RuleTrigger;
|
||||||
|
conditions: RuleConditionGroup;
|
||||||
|
action: RuleAction;
|
||||||
|
status?: 'draft' | 'published';
|
||||||
|
metadata: {
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
createdBy: string;
|
||||||
|
category: RuleCategory;
|
||||||
|
tags?: string[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScoreBreakdown = {
|
||||||
|
baseScore: number;
|
||||||
|
slaMultiplier: number;
|
||||||
|
campaignMultiplier: number;
|
||||||
|
rulesApplied: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ScoredItem = {
|
||||||
|
id: string;
|
||||||
|
score: number;
|
||||||
|
scoreBreakdown: ScoreBreakdown;
|
||||||
|
slaStatus: 'low' | 'medium' | 'high' | 'critical';
|
||||||
|
slaElapsedPercent: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Priority config — what the supervisor edits via sliders
|
||||||
|
export type TaskWeightConfig = {
|
||||||
|
weight: number; // 0-10
|
||||||
|
slaMinutes: number; // SLA in minutes
|
||||||
|
enabled: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PriorityConfig = {
|
||||||
|
taskWeights: Record<string, TaskWeightConfig>;
|
||||||
|
campaignWeights: Record<string, number>; // campaignId → 0-10
|
||||||
|
sourceWeights: Record<string, number>; // leadSource → 0-10
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_PRIORITY_CONFIG: PriorityConfig = {
|
||||||
|
taskWeights: {
|
||||||
|
missed_call: { weight: 9, slaMinutes: 720, enabled: true },
|
||||||
|
follow_up: { weight: 8, slaMinutes: 1440, enabled: true },
|
||||||
|
campaign_lead: { weight: 7, slaMinutes: 2880, enabled: true },
|
||||||
|
attempt_2: { weight: 6, slaMinutes: 1440, enabled: true },
|
||||||
|
attempt_3: { weight: 4, slaMinutes: 2880, enabled: true },
|
||||||
|
},
|
||||||
|
campaignWeights: {},
|
||||||
|
sourceWeights: {
|
||||||
|
WHATSAPP: 9, PHONE: 8, FACEBOOK_AD: 7, GOOGLE_AD: 7,
|
||||||
|
INSTAGRAM: 5, WEBSITE: 7, REFERRAL: 6, WALK_IN: 5, OTHER: 5,
|
||||||
|
},
|
||||||
|
};
|
||||||
94
src/search/search.controller.ts
Normal file
94
src/search/search.controller.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { Controller, Get, Query, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
|
||||||
|
@Controller('api/search')
|
||||||
|
export class SearchController {
|
||||||
|
private readonly logger = new Logger(SearchController.name);
|
||||||
|
private readonly platformApiKey: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly platform: PlatformGraphqlService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {
|
||||||
|
this.platformApiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
async search(@Query('q') query?: string) {
|
||||||
|
if (!query || query.length < 2) {
|
||||||
|
return { leads: [], patients: [], appointments: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const authHeader = this.platformApiKey ? `Bearer ${this.platformApiKey}` : '';
|
||||||
|
if (!authHeader) {
|
||||||
|
return { leads: [], patients: [], appointments: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Search: "${query}"`);
|
||||||
|
|
||||||
|
// Fetch all three in parallel, filter client-side for flexible matching
|
||||||
|
try {
|
||||||
|
const [leadsResult, patientsResult, appointmentsResult] = await Promise.all([
|
||||||
|
this.platform.queryWithAuth<any>(
|
||||||
|
`{ leads(first: 50) { edges { node {
|
||||||
|
id name contactName { firstName lastName }
|
||||||
|
contactPhone { primaryPhoneNumber }
|
||||||
|
source status interestedService
|
||||||
|
} } } }`,
|
||||||
|
undefined, authHeader,
|
||||||
|
).catch(() => ({ leads: { edges: [] } })),
|
||||||
|
|
||||||
|
this.platform.queryWithAuth<any>(
|
||||||
|
`{ patients(first: 50) { edges { node {
|
||||||
|
id name fullName { firstName lastName }
|
||||||
|
phones { primaryPhoneNumber }
|
||||||
|
gender dateOfBirth
|
||||||
|
} } } }`,
|
||||||
|
undefined, authHeader,
|
||||||
|
).catch(() => ({ patients: { edges: [] } })),
|
||||||
|
|
||||||
|
this.platform.queryWithAuth<any>(
|
||||||
|
`{ appointments(first: 50, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||||
|
id scheduledAt doctorName department status patientId
|
||||||
|
} } } }`,
|
||||||
|
undefined, authHeader,
|
||||||
|
).catch(() => ({ appointments: { edges: [] } })),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
|
||||||
|
const leads = (leadsResult.leads?.edges ?? [])
|
||||||
|
.map((e: any) => e.node)
|
||||||
|
.filter((l: any) => {
|
||||||
|
const name = `${l.contactName?.firstName ?? ''} ${l.contactName?.lastName ?? ''}`.toLowerCase();
|
||||||
|
const phone = l.contactPhone?.primaryPhoneNumber ?? '';
|
||||||
|
return name.includes(q) || phone.includes(q) || (l.name ?? '').toLowerCase().includes(q);
|
||||||
|
})
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
const patients = (patientsResult.patients?.edges ?? [])
|
||||||
|
.map((e: any) => e.node)
|
||||||
|
.filter((p: any) => {
|
||||||
|
const name = `${p.fullName?.firstName ?? ''} ${p.fullName?.lastName ?? ''}`.toLowerCase();
|
||||||
|
const phone = p.phones?.primaryPhoneNumber ?? '';
|
||||||
|
return name.includes(q) || phone.includes(q) || (p.name ?? '').toLowerCase().includes(q);
|
||||||
|
})
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
const appointments = (appointmentsResult.appointments?.edges ?? [])
|
||||||
|
.map((e: any) => e.node)
|
||||||
|
.filter((a: any) => {
|
||||||
|
const doctor = (a.doctorName ?? '').toLowerCase();
|
||||||
|
const dept = (a.department ?? '').toLowerCase();
|
||||||
|
return doctor.includes(q) || dept.includes(q);
|
||||||
|
})
|
||||||
|
.slice(0, 5);
|
||||||
|
|
||||||
|
return { leads, patients, appointments };
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`Search failed: ${err.message}`);
|
||||||
|
return { leads: [], patients: [], appointments: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
src/search/search.module.ts
Normal file
9
src/search/search.module.ts
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { SearchController } from './search.controller';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule],
|
||||||
|
controllers: [SearchController],
|
||||||
|
})
|
||||||
|
export class SearchModule {}
|
||||||
55
src/supervisor/supervisor.controller.ts
Normal file
55
src/supervisor/supervisor.controller.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { Controller, Get, Post, Body, Query, Sse, Logger } from '@nestjs/common';
|
||||||
|
import { Observable, filter, map } from 'rxjs';
|
||||||
|
import { SupervisorService } from './supervisor.service';
|
||||||
|
|
||||||
|
@Controller('api/supervisor')
|
||||||
|
export class SupervisorController {
|
||||||
|
private readonly logger = new Logger(SupervisorController.name);
|
||||||
|
|
||||||
|
constructor(private readonly supervisor: SupervisorService) {}
|
||||||
|
|
||||||
|
@Get('active-calls')
|
||||||
|
getActiveCalls() {
|
||||||
|
return this.supervisor.getActiveCalls();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('team-performance')
|
||||||
|
async getTeamPerformance(@Query('date') date?: string) {
|
||||||
|
const targetDate = date ?? new Date().toISOString().split('T')[0];
|
||||||
|
this.logger.log(`Team performance: date=${targetDate}`);
|
||||||
|
return this.supervisor.getTeamPerformance(targetDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('call-event')
|
||||||
|
handleCallEvent(@Body() body: any) {
|
||||||
|
const event = body.data ?? body;
|
||||||
|
this.logger.log(`[CALL-EVENT] ${JSON.stringify(event)}`);
|
||||||
|
this.supervisor.handleCallEvent(event);
|
||||||
|
return { received: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('agent-event')
|
||||||
|
handleAgentEvent(@Body() body: any) {
|
||||||
|
const event = body.data ?? body;
|
||||||
|
this.logger.log(`[AGENT-EVENT] ${JSON.stringify(event)}`);
|
||||||
|
this.supervisor.handleAgentEvent(event);
|
||||||
|
return { received: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('agent-state')
|
||||||
|
getAgentState(@Query('agentId') agentId: string) {
|
||||||
|
const state = this.supervisor.getAgentState(agentId);
|
||||||
|
return state ?? { state: 'offline', timestamp: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Sse('agent-state/stream')
|
||||||
|
streamAgentState(@Query('agentId') agentId: string): Observable<MessageEvent> {
|
||||||
|
this.logger.log(`[SSE] Agent state stream opened for ${agentId}`);
|
||||||
|
return this.supervisor.agentStateSubject.pipe(
|
||||||
|
filter(event => event.agentId === agentId),
|
||||||
|
map(event => ({
|
||||||
|
data: JSON.stringify({ state: event.state, timestamp: event.timestamp }),
|
||||||
|
} as MessageEvent)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/supervisor/supervisor.module.ts
Normal file
13
src/supervisor/supervisor.module.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { OzonetelAgentModule } from '../ozonetel/ozonetel-agent.module';
|
||||||
|
import { SupervisorController } from './supervisor.controller';
|
||||||
|
import { SupervisorService } from './supervisor.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule, OzonetelAgentModule],
|
||||||
|
controllers: [SupervisorController],
|
||||||
|
providers: [SupervisorService],
|
||||||
|
exports: [SupervisorService],
|
||||||
|
})
|
||||||
|
export class SupervisorModule {}
|
||||||
136
src/supervisor/supervisor.service.ts
Normal file
136
src/supervisor/supervisor.service.ts
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||||
|
|
||||||
|
type ActiveCall = {
|
||||||
|
ucid: string;
|
||||||
|
agentId: string;
|
||||||
|
callerNumber: string;
|
||||||
|
callType: string;
|
||||||
|
startTime: string;
|
||||||
|
status: 'active' | 'on-hold';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AgentOzonetelState = 'ready' | 'break' | 'training' | 'calling' | 'in-call' | 'acw' | 'offline';
|
||||||
|
|
||||||
|
type AgentStateEntry = {
|
||||||
|
state: AgentOzonetelState;
|
||||||
|
timestamp: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SupervisorService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(SupervisorService.name);
|
||||||
|
private readonly activeCalls = new Map<string, ActiveCall>();
|
||||||
|
private readonly agentStates = new Map<string, AgentStateEntry>();
|
||||||
|
readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState; timestamp: string }>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
private ozonetel: OzonetelAgentService,
|
||||||
|
private config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async onModuleInit() {
|
||||||
|
this.logger.log('Supervisor service initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
handleCallEvent(event: any) {
|
||||||
|
const action = event.action;
|
||||||
|
const ucid = event.ucid ?? event.monitorUCID;
|
||||||
|
const agentId = event.agent_id ?? event.agentID;
|
||||||
|
const callerNumber = event.caller_id ?? event.callerID;
|
||||||
|
const callType = event.call_type ?? event.Type;
|
||||||
|
const eventTime = event.event_time ?? event.eventTime ?? new Date().toISOString();
|
||||||
|
|
||||||
|
if (!ucid) return;
|
||||||
|
|
||||||
|
if (action === 'Answered' || action === 'Calling') {
|
||||||
|
this.activeCalls.set(ucid, {
|
||||||
|
ucid, agentId, callerNumber,
|
||||||
|
callType, startTime: eventTime, status: 'active',
|
||||||
|
});
|
||||||
|
this.logger.log(`Active call: ${agentId} ↔ ${callerNumber} (${ucid})`);
|
||||||
|
} else if (action === 'Disconnect') {
|
||||||
|
this.activeCalls.delete(ucid);
|
||||||
|
this.logger.log(`Call ended: ${ucid}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleAgentEvent(event: any) {
|
||||||
|
const agentId = event.agentId ?? event.agent_id ?? 'unknown';
|
||||||
|
const action = event.action ?? 'unknown';
|
||||||
|
const eventData = event.eventData ?? '';
|
||||||
|
const eventTime = event.event_time ?? event.eventTime ?? new Date().toISOString();
|
||||||
|
this.logger.log(`[AGENT-STATE] ${agentId} → ${action}${eventData ? ` (${eventData})` : ''} at ${eventTime}`);
|
||||||
|
|
||||||
|
const mapped = this.mapOzonetelAction(action, eventData);
|
||||||
|
if (mapped) {
|
||||||
|
this.agentStates.set(agentId, { state: mapped, timestamp: eventTime });
|
||||||
|
this.agentStateSubject.next({ agentId, state: mapped, timestamp: eventTime });
|
||||||
|
this.logger.log(`[AGENT-STATE] Emitted: ${agentId} → ${mapped}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private mapOzonetelAction(action: string, eventData: string): AgentOzonetelState | null {
|
||||||
|
switch (action) {
|
||||||
|
case 'release': return 'ready';
|
||||||
|
case 'IDLE': return 'ready'; // agent available after unanswered/canceled call
|
||||||
|
case 'calling': return 'calling';
|
||||||
|
case 'incall': return 'in-call';
|
||||||
|
case 'ACW': return 'acw';
|
||||||
|
case 'logout': return 'offline';
|
||||||
|
case 'AUX':
|
||||||
|
// "changeMode" is the brief AUX during login — not a real pause
|
||||||
|
if (eventData === 'changeMode') return null;
|
||||||
|
if (eventData?.toLowerCase().includes('training')) return 'training';
|
||||||
|
return 'break';
|
||||||
|
case 'login': return null; // wait for release
|
||||||
|
default: return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getAgentState(agentId: string): AgentStateEntry | null {
|
||||||
|
return this.agentStates.get(agentId) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
emitForceLogout(agentId: string) {
|
||||||
|
this.logger.log(`[AGENT-STATE] Emitting force-logout for ${agentId}`);
|
||||||
|
this.agentStates.set(agentId, { state: 'offline', timestamp: new Date().toISOString() });
|
||||||
|
// Use a special state so frontend can distinguish admin force-logout from normal Ozonetel logout
|
||||||
|
this.agentStateSubject.next({ agentId, state: 'force-logout' as any, timestamp: new Date().toISOString() });
|
||||||
|
}
|
||||||
|
|
||||||
|
getActiveCalls(): ActiveCall[] {
|
||||||
|
return Array.from(this.activeCalls.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
async getTeamPerformance(date: string): Promise<any> {
|
||||||
|
// Get all agents from platform
|
||||||
|
const agentData = await this.platform.query<any>(
|
||||||
|
`{ agents(first: 20) { edges { node {
|
||||||
|
id name ozonetelagentid npsscore
|
||||||
|
maxidleminutes minnpsthreshold minconversionpercent
|
||||||
|
} } } }`,
|
||||||
|
);
|
||||||
|
const agents = agentData?.agents?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
|
||||||
|
// Fetch Ozonetel time summary per agent
|
||||||
|
const summaries = await Promise.all(
|
||||||
|
agents.map(async (agent: any) => {
|
||||||
|
if (!agent.ozonetelagentid) return { ...agent, timeBreakdown: null };
|
||||||
|
try {
|
||||||
|
const summary = await this.ozonetel.getAgentSummary(agent.ozonetelagentid, date);
|
||||||
|
return { ...agent, timeBreakdown: summary };
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to get summary for ${agent.ozonetelagentid}: ${err}`);
|
||||||
|
return { ...agent, timeBreakdown: null };
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return { date, agents: summaries };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,16 @@ import { Controller, Post, Body, Headers, Logger } from '@nestjs/common';
|
|||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
|
// Ozonetel sends all timestamps in IST — convert to UTC for storage
|
||||||
|
function istToUtc(istDateStr: string | null): string | null {
|
||||||
|
if (!istDateStr) return null;
|
||||||
|
// Parse as-is, then subtract 5:30 to get UTC
|
||||||
|
const d = new Date(istDateStr);
|
||||||
|
if (isNaN(d.getTime())) return null;
|
||||||
|
d.setMinutes(d.getMinutes() - 330); // IST is UTC+5:30
|
||||||
|
return d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('webhooks/ozonetel')
|
@Controller('webhooks/ozonetel')
|
||||||
export class MissedCallWebhookController {
|
export class MissedCallWebhookController {
|
||||||
private readonly logger = new Logger(MissedCallWebhookController.name);
|
private readonly logger = new Logger(MissedCallWebhookController.name);
|
||||||
@@ -130,11 +140,16 @@ export class MissedCallWebhookController {
|
|||||||
callStatus: data.callStatus,
|
callStatus: data.callStatus,
|
||||||
callerNumber: { primaryPhoneNumber: `+91${data.callerPhone}` },
|
callerNumber: { primaryPhoneNumber: `+91${data.callerPhone}` },
|
||||||
agentName: data.agentName,
|
agentName: data.agentName,
|
||||||
startedAt: data.startTime ? new Date(data.startTime).toISOString() : null,
|
startedAt: istToUtc(data.startTime),
|
||||||
endedAt: data.endTime ? new Date(data.endTime).toISOString() : null,
|
endedAt: istToUtc(data.endTime),
|
||||||
durationSec: data.duration,
|
durationSec: data.duration,
|
||||||
disposition: this.mapDisposition(data.disposition),
|
disposition: this.mapDisposition(data.disposition),
|
||||||
};
|
};
|
||||||
|
// Set callback tracking fields for missed calls so they appear in the worklist
|
||||||
|
if (data.callStatus === 'MISSED') {
|
||||||
|
callData.callbackstatus = 'PENDING_CALLBACK';
|
||||||
|
callData.missedcallcount = 1;
|
||||||
|
}
|
||||||
if (data.recordingUrl) {
|
if (data.recordingUrl) {
|
||||||
callData.recording = { primaryLinkUrl: data.recordingUrl, primaryLinkLabel: 'Recording' };
|
callData.recording = { primaryLinkUrl: data.recordingUrl, primaryLinkLabel: 'Recording' };
|
||||||
}
|
}
|
||||||
|
|||||||
263
src/worklist/missed-queue.service.ts
Normal file
263
src/worklist/missed-queue.service.ts
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { OzonetelAgentService } from '../ozonetel/ozonetel-agent.service';
|
||||||
|
|
||||||
|
// Ozonetel sends all timestamps in IST — convert to UTC for storage
|
||||||
|
export function istToUtc(istDateStr: string | null): string | null {
|
||||||
|
if (!istDateStr) return null;
|
||||||
|
const d = new Date(istDateStr);
|
||||||
|
if (isNaN(d.getTime())) return null;
|
||||||
|
d.setMinutes(d.getMinutes() - 330); // IST is UTC+5:30
|
||||||
|
return d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize phone to +91XXXXXXXXXX format
|
||||||
|
export function normalizePhone(raw: string): string {
|
||||||
|
let digits = raw.replace(/[^0-9]/g, '');
|
||||||
|
// Strip leading country code variations: 0091, 91, 0
|
||||||
|
if (digits.startsWith('0091')) digits = digits.slice(4);
|
||||||
|
else if (digits.startsWith('91') && digits.length > 10) digits = digits.slice(2);
|
||||||
|
else if (digits.startsWith('0') && digits.length > 10) digits = digits.slice(1);
|
||||||
|
return `+91${digits.slice(-10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MissedQueueService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(MissedQueueService.name);
|
||||||
|
private readonly pollIntervalMs: number;
|
||||||
|
private readonly processedUcids = new Set<string>();
|
||||||
|
private assignmentMutex = false;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
private readonly platform: PlatformGraphqlService,
|
||||||
|
private readonly ozonetel: OzonetelAgentService,
|
||||||
|
) {
|
||||||
|
this.pollIntervalMs = this.config.get<number>('missedQueue.pollIntervalMs', 30000);
|
||||||
|
}
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
async ingest(): Promise<{ created: number; updated: number }> {
|
||||||
|
let created = 0;
|
||||||
|
let updated = 0;
|
||||||
|
|
||||||
|
// Ozonetel fromTime/toTime use HH:MM:SS format (time of day, filters within current day)
|
||||||
|
const now = new Date();
|
||||||
|
const fiveMinAgo = new Date(now.getTime() - 5 * 60 * 1000);
|
||||||
|
const toHHMMSS = (d: Date) => d.toTimeString().slice(0, 8);
|
||||||
|
|
||||||
|
let abandonCalls: any[];
|
||||||
|
try {
|
||||||
|
abandonCalls = await this.ozonetel.getAbandonCalls({ fromTime: toHHMMSS(fiveMinAgo), toTime: toHHMMSS(now) });
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to fetch abandon calls: ${err}`);
|
||||||
|
return { created: 0, updated: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!abandonCalls?.length) return { created: 0, updated: 0 };
|
||||||
|
|
||||||
|
for (const call of abandonCalls) {
|
||||||
|
const ucid = call.monitorUCID;
|
||||||
|
if (!ucid || this.processedUcids.has(ucid)) continue;
|
||||||
|
this.processedUcids.add(ucid);
|
||||||
|
|
||||||
|
const phone = normalizePhone(call.callerID || '');
|
||||||
|
if (!phone || phone.length < 13) continue;
|
||||||
|
|
||||||
|
const did = call.did || '';
|
||||||
|
const callTime = istToUtc(call.callTime) ?? new Date().toISOString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Look up lead by phone number — strip +91 prefix for flexible matching
|
||||||
|
const phoneDigits = phone.replace(/^\+91/, '');
|
||||||
|
let leadId: string | null = null;
|
||||||
|
let leadName: string | null = null;
|
||||||
|
try {
|
||||||
|
const leadResult = await this.platform.query<any>(
|
||||||
|
`{ leads(first: 1, filter: {
|
||||||
|
contactPhone: { primaryPhoneNumber: { like: "%${phoneDigits}" } }
|
||||||
|
}) { edges { node { id contactName { firstName lastName } patientId } } } }`,
|
||||||
|
);
|
||||||
|
const matchedLead = leadResult?.leads?.edges?.[0]?.node;
|
||||||
|
if (matchedLead) {
|
||||||
|
leadId = matchedLead.id;
|
||||||
|
const fn = matchedLead.contactName?.firstName ?? '';
|
||||||
|
const ln = matchedLead.contactName?.lastName ?? '';
|
||||||
|
leadName = `${fn} ${ln}`.trim() || null;
|
||||||
|
this.logger.log(`Matched missed call ${phone} → lead ${leadId} (${leadName})`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Lead lookup failed for ${phone}: ${err}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.platform.query<any>(
|
||||||
|
`{ calls(first: 1, filter: {
|
||||||
|
callbackstatus: { eq: PENDING_CALLBACK },
|
||||||
|
callerNumber: { primaryPhoneNumber: { eq: "${phone}" } }
|
||||||
|
}) { edges { node { id missedcallcount } } } }`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const existingNode = existing?.calls?.edges?.[0]?.node;
|
||||||
|
|
||||||
|
if (existingNode) {
|
||||||
|
const newCount = (existingNode.missedcallcount || 1) + 1;
|
||||||
|
const updateParts = [
|
||||||
|
`missedcallcount: ${newCount}`,
|
||||||
|
`startedAt: "${callTime}"`,
|
||||||
|
`callsourcenumber: "${did}"`,
|
||||||
|
];
|
||||||
|
if (leadId) updateParts.push(`leadId: "${leadId}"`);
|
||||||
|
if (leadName) updateParts.push(`leadName: "${leadName}"`);
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation { updateCall(id: "${existingNode.id}", data: { ${updateParts.join(', ')} }) { id } }`,
|
||||||
|
);
|
||||||
|
updated++;
|
||||||
|
this.logger.log(`Dedup missed call ${phone}: count now ${newCount}${leadName ? ` (${leadName})` : ''}`);
|
||||||
|
} else {
|
||||||
|
const dataParts = [
|
||||||
|
`callStatus: MISSED`,
|
||||||
|
`direction: INBOUND`,
|
||||||
|
`callerNumber: { primaryPhoneNumber: "${phone}", primaryPhoneCallingCode: "+91" }`,
|
||||||
|
`callsourcenumber: "${did}"`,
|
||||||
|
`callbackstatus: PENDING_CALLBACK`,
|
||||||
|
`missedcallcount: 1`,
|
||||||
|
`startedAt: "${callTime}"`,
|
||||||
|
];
|
||||||
|
if (leadId) dataParts.push(`leadId: "${leadId}"`);
|
||||||
|
if (leadName) dataParts.push(`leadName: "${leadName}"`);
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation { createCall(data: { ${dataParts.join(', ')} }) { id } }`,
|
||||||
|
);
|
||||||
|
created++;
|
||||||
|
this.logger.log(`Created missed call record for ${phone}${leadName ? ` → ${leadName}` : ''}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to process abandon call ${ucid}: ${err}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trim processedUcids to prevent unbounded growth
|
||||||
|
if (this.processedUcids.size > 500) {
|
||||||
|
const arr = Array.from(this.processedUcids);
|
||||||
|
this.processedUcids.clear();
|
||||||
|
arr.slice(-200).forEach(u => this.processedUcids.add(u));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (created || updated) this.logger.log(`Ingestion: ${created} created, ${updated} updated`);
|
||||||
|
return { created, updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
async assignNext(agentName: string): Promise<any | null> {
|
||||||
|
if (this.assignmentMutex) return null;
|
||||||
|
this.assignmentMutex = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find oldest unassigned PENDING_CALLBACK call (empty agentName)
|
||||||
|
let result = await this.platform.query<any>(
|
||||||
|
`{ calls(first: 1, filter: {
|
||||||
|
callbackstatus: { eq: PENDING_CALLBACK },
|
||||||
|
agentName: { eq: "" }
|
||||||
|
}, orderBy: [{ startedAt: AscNullsLast }]) {
|
||||||
|
edges { node {
|
||||||
|
id callerNumber { primaryPhoneNumber }
|
||||||
|
startedAt callsourcenumber missedcallcount
|
||||||
|
} }
|
||||||
|
} }`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let call = result?.calls?.edges?.[0]?.node;
|
||||||
|
|
||||||
|
// Also check for null agentName
|
||||||
|
if (!call) {
|
||||||
|
result = await this.platform.query<any>(
|
||||||
|
`{ calls(first: 1, filter: {
|
||||||
|
callbackstatus: { eq: PENDING_CALLBACK },
|
||||||
|
agentName: { is: NULL }
|
||||||
|
}, orderBy: [{ startedAt: AscNullsLast }]) {
|
||||||
|
edges { node {
|
||||||
|
id callerNumber { primaryPhoneNumber }
|
||||||
|
startedAt callsourcenumber missedcallcount
|
||||||
|
} }
|
||||||
|
} }`,
|
||||||
|
);
|
||||||
|
call = result?.calls?.edges?.[0]?.node;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!call) return null;
|
||||||
|
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation { updateCall(id: "${call.id}", data: { agentName: "${agentName}" }) { id } }`,
|
||||||
|
);
|
||||||
|
this.logger.log(`Assigned missed call ${call.id} to ${agentName}`);
|
||||||
|
return call;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Assignment failed: ${err}`);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
this.assignmentMutex = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateStatus(callId: string, status: string, authHeader: string): Promise<any> {
|
||||||
|
const validStatuses = ['PENDING_CALLBACK', 'CALLBACK_ATTEMPTED', 'CALLBACK_COMPLETED', 'INVALID', 'WRONG_NUMBER'];
|
||||||
|
if (!validStatuses.includes(status)) {
|
||||||
|
throw new Error(`Invalid status: ${status}. Must be one of: ${validStatuses.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataParts: string[] = [`callbackstatus: ${status}`];
|
||||||
|
if (status === 'CALLBACK_ATTEMPTED') {
|
||||||
|
dataParts.push(`callbackattemptedat: "${new Date().toISOString()}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.platform.queryWithAuth<any>(
|
||||||
|
`mutation { updateCall(id: "${callId}", data: { ${dataParts.join(', ')} }) { id callbackstatus callbackattemptedat } }`,
|
||||||
|
undefined,
|
||||||
|
authHeader,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMissedQueue(agentName: string, authHeader: string): Promise<{
|
||||||
|
pending: any[];
|
||||||
|
attempted: any[];
|
||||||
|
completed: any[];
|
||||||
|
invalid: any[];
|
||||||
|
}> {
|
||||||
|
const fields = `id name createdAt direction callStatus agentName
|
||||||
|
callerNumber { primaryPhoneNumber }
|
||||||
|
startedAt endedAt durationSec disposition leadId
|
||||||
|
callbackstatus callsourcenumber missedcallcount callbackattemptedat`;
|
||||||
|
|
||||||
|
const buildQuery = (status: string) => `{ calls(first: 50, filter: {
|
||||||
|
agentName: { eq: "${agentName}" },
|
||||||
|
callStatus: { eq: MISSED },
|
||||||
|
callbackstatus: { eq: ${status} }
|
||||||
|
}, orderBy: [{ startedAt: AscNullsLast }]) { edges { node { ${fields} } } } }`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [pending, attempted, completed, invalid, wrongNumber] = await Promise.all([
|
||||||
|
this.platform.queryWithAuth<any>(buildQuery('PENDING_CALLBACK'), undefined, authHeader),
|
||||||
|
this.platform.queryWithAuth<any>(buildQuery('CALLBACK_ATTEMPTED'), undefined, authHeader),
|
||||||
|
this.platform.queryWithAuth<any>(buildQuery('CALLBACK_COMPLETED'), undefined, authHeader),
|
||||||
|
this.platform.queryWithAuth<any>(buildQuery('INVALID'), undefined, authHeader),
|
||||||
|
this.platform.queryWithAuth<any>(buildQuery('WRONG_NUMBER'), undefined, authHeader),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const extract = (r: any) => r?.calls?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
pending: extract(pending),
|
||||||
|
attempted: extract(attempted),
|
||||||
|
completed: [...extract(completed), ...extract(wrongNumber)],
|
||||||
|
invalid: extract(invalid),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn(`Failed to fetch missed queue: ${err}`);
|
||||||
|
return { pending: [], attempted: [], completed: [], invalid: [] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Controller, Get, Headers, HttpException, Logger } from '@nestjs/common';
|
import { Controller, Get, Patch, Headers, Param, Body, HttpException, Logger } from '@nestjs/common';
|
||||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
import { WorklistService } from './worklist.service';
|
import { WorklistService } from './worklist.service';
|
||||||
|
import { MissedQueueService } from './missed-queue.service';
|
||||||
|
import { SessionService } from '../auth/session.service';
|
||||||
|
|
||||||
@Controller('api/worklist')
|
@Controller('api/worklist')
|
||||||
export class WorklistController {
|
export class WorklistController {
|
||||||
@@ -8,7 +10,9 @@ export class WorklistController {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly worklist: WorklistService,
|
private readonly worklist: WorklistService,
|
||||||
|
private readonly missedQueue: MissedQueueService,
|
||||||
private readonly platform: PlatformGraphqlService,
|
private readonly platform: PlatformGraphqlService,
|
||||||
|
private readonly session: SessionService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -23,7 +27,31 @@ export class WorklistController {
|
|||||||
return this.worklist.getWorklist(agentName, authHeader);
|
return this.worklist.getWorklist(agentName, authHeader);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('missed-queue')
|
||||||
|
async getMissedQueue(@Headers('authorization') authHeader: string) {
|
||||||
|
if (!authHeader) throw new HttpException('Authorization header required', 401);
|
||||||
|
const agentName = await this.resolveAgentName(authHeader);
|
||||||
|
return this.missedQueue.getMissedQueue(agentName, authHeader);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('missed-queue/:id/status')
|
||||||
|
async updateMissedCallStatus(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Headers('authorization') authHeader: string,
|
||||||
|
@Body() body: { status: string },
|
||||||
|
) {
|
||||||
|
if (!authHeader) throw new HttpException('Authorization header required', 401);
|
||||||
|
if (!body.status) throw new HttpException('status is required', 400);
|
||||||
|
return this.missedQueue.updateStatus(id, body.status, authHeader);
|
||||||
|
}
|
||||||
|
|
||||||
private async resolveAgentName(authHeader: string): Promise<string> {
|
private async resolveAgentName(authHeader: string): Promise<string> {
|
||||||
|
// Check cached name from login (avoids currentUser query that CC agents can't access)
|
||||||
|
const token = authHeader.replace(/^Bearer\s+/i, '');
|
||||||
|
const cached = await this.session.getCache(`agent:name:${token.slice(-16)}`);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
// Fallback: try querying platform (works for admin/supervisor tokens)
|
||||||
try {
|
try {
|
||||||
const data = await this.platform.queryWithAuth<any>(
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
`{ currentUser { workspaceMember { name { firstName lastName } } } }`,
|
`{ currentUser { workspaceMember { name { firstName lastName } } } }`,
|
||||||
@@ -34,7 +62,7 @@ export class WorklistController {
|
|||||||
const full = `${name?.firstName ?? ''} ${name?.lastName ?? ''}`.trim();
|
const full = `${name?.firstName ?? ''} ${name?.lastName ?? ''}`.trim();
|
||||||
if (full) return full;
|
if (full) return full;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to resolve agent name: ${err}`);
|
this.logger.warn(`Failed to resolve agent name via platform: ${err}`);
|
||||||
}
|
}
|
||||||
throw new HttpException('Could not determine agent identity', 400);
|
throw new HttpException('Could not determine agent identity', 400);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
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 { AuthModule } from '../auth/auth.module';
|
||||||
|
import { RulesEngineModule } from '../rules-engine/rules-engine.module';
|
||||||
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 { MissedCallWebhookController } from './missed-call-webhook.controller';
|
import { MissedCallWebhookController } from './missed-call-webhook.controller';
|
||||||
import { KookooCallbackController } from './kookoo-callback.controller';
|
import { KookooCallbackController } from './kookoo-callback.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule],
|
imports: [PlatformModule, forwardRef(() => OzonetelAgentModule), forwardRef(() => AuthModule), RulesEngineModule],
|
||||||
controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController],
|
controllers: [WorklistController, MissedCallWebhookController, KookooCallbackController],
|
||||||
providers: [WorklistService],
|
providers: [WorklistService, MissedQueueService],
|
||||||
|
exports: [MissedQueueService],
|
||||||
})
|
})
|
||||||
export class WorklistModule {}
|
export class WorklistModule {}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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 { WorklistConsumer } from '../rules-engine/consumers/worklist.consumer';
|
||||||
|
|
||||||
export type WorklistResponse = {
|
export type WorklistResponse = {
|
||||||
missedCalls: any[];
|
missedCalls: any[];
|
||||||
@@ -12,15 +13,33 @@ export type WorklistResponse = {
|
|||||||
export class WorklistService {
|
export class WorklistService {
|
||||||
private readonly logger = new Logger(WorklistService.name);
|
private readonly logger = new Logger(WorklistService.name);
|
||||||
|
|
||||||
constructor(private readonly platform: PlatformGraphqlService) {}
|
constructor(
|
||||||
|
private readonly platform: PlatformGraphqlService,
|
||||||
|
private readonly worklistConsumer: WorklistConsumer,
|
||||||
|
) {}
|
||||||
|
|
||||||
async getWorklist(agentName: string, authHeader: string): Promise<WorklistResponse> {
|
async getWorklist(agentName: string, authHeader: string): Promise<WorklistResponse> {
|
||||||
const [missedCalls, followUps, marketingLeads] = await Promise.all([
|
const [rawMissedCalls, rawFollowUps, rawMarketingLeads] = await Promise.all([
|
||||||
this.getMissedCalls(agentName, authHeader),
|
this.getMissedCalls(agentName, authHeader),
|
||||||
this.getPendingFollowUps(agentName, authHeader),
|
this.getPendingFollowUps(agentName, authHeader),
|
||||||
this.getAssignedLeads(agentName, authHeader),
|
this.getAssignedLeads(agentName, authHeader),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Tag each item with a type field for the scoring engine
|
||||||
|
const combined = [
|
||||||
|
...rawMissedCalls.map((item: any) => ({ ...item, type: 'missed' })),
|
||||||
|
...rawFollowUps.map((item: any) => ({ ...item, type: 'follow-up' })),
|
||||||
|
...rawMarketingLeads.map((item: any) => ({ ...item, type: 'lead' })),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Score and rank via rules engine
|
||||||
|
const scored = await this.worklistConsumer.scoreAndRank(combined);
|
||||||
|
|
||||||
|
// Split back into the 3 categories
|
||||||
|
const missedCalls = scored.filter((item: any) => item.type === 'missed');
|
||||||
|
const followUps = scored.filter((item: any) => item.type === 'follow-up');
|
||||||
|
const marketingLeads = scored.filter((item: any) => item.type === 'lead');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
missedCalls,
|
missedCalls,
|
||||||
followUps,
|
followUps,
|
||||||
@@ -76,13 +95,15 @@ export class WorklistService {
|
|||||||
|
|
||||||
private async getMissedCalls(agentName: string, authHeader: string): Promise<any[]> {
|
private async getMissedCalls(agentName: string, authHeader: string): Promise<any[]> {
|
||||||
try {
|
try {
|
||||||
|
// FIFO ordering (AscNullsLast) — oldest first. No agentName filter — missed calls are a shared queue.
|
||||||
const data = await this.platform.queryWithAuth<any>(
|
const data = await this.platform.queryWithAuth<any>(
|
||||||
`{ calls(first: 20, filter: { agentName: { eq: "${agentName}" }, callStatus: { eq: MISSED } }, orderBy: [{ startedAt: DescNullsLast }]) { edges { node {
|
`{ calls(first: 20, 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
|
||||||
|
callbackstatus callsourcenumber missedcallcount callbackattemptedat
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined,
|
undefined,
|
||||||
authHeader,
|
authHeader,
|
||||||
|
|||||||
Reference in New Issue
Block a user