mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
Compare commits
32 Commits
master
...
hardening/
| Author | SHA1 | Date | |
|---|---|---|---|
| a837c95d8c | |||
| ac76ef5487 | |||
| 99954c1ff2 | |||
| 4b84792619 | |||
| 9890559ec1 | |||
| 9cb4d1c122 | |||
| 014b27cf90 | |||
| 826ced1e62 | |||
| bbea12185d | |||
| f1c026cf7a | |||
| d819888351 | |||
| 300fff25c1 | |||
| 9ee087b898 | |||
| 963cf28d23 | |||
| 903e82b536 | |||
| 2e0527e1d8 | |||
| 4549241b78 | |||
| 6a3834a7eb | |||
| 6847f5de95 | |||
| d857a0b270 | |||
| 214cc60917 | |||
| c4c437abd6 | |||
| b1922809d0 | |||
| 8aae95e8cc | |||
| 2c947517af | |||
|
|
473183869a | ||
| 3bb4315925 | |||
| 350fcdd926 | |||
| 7402590969 | |||
| 3f22166ac0 | |||
| 8c8b1e78b0 | |||
| 77b3e917db |
203
.claude/skills/generate-whatsapp-flow.md
Normal file
203
.claude/skills/generate-whatsapp-flow.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# Generate WhatsApp Flow
|
||||||
|
|
||||||
|
Generate a config-driven WhatsApp conversation flow JSON for the Helix Engage flow runtime engine.
|
||||||
|
|
||||||
|
## When to use
|
||||||
|
|
||||||
|
When the user asks to create a new WhatsApp flow, chatbot flow, or conversation automation — e.g., "create a WhatsApp flow for prescription refills", "build a feedback collection flow", "add a lab report flow".
|
||||||
|
|
||||||
|
## Flow Runtime Architecture
|
||||||
|
|
||||||
|
The flow engine reads JSON flow definitions from `src/messaging/flow/default-flows/` and executes them at runtime. Each flow is a graph of **Groups** (containers) containing **Blocks** (steps), connected by **Edges**.
|
||||||
|
|
||||||
|
### Execution Model
|
||||||
|
|
||||||
|
```
|
||||||
|
Inbound WhatsApp message → match flow by trigger → create/resume session
|
||||||
|
→ walk forward through Groups → Blocks:
|
||||||
|
MessageBlock → send text/buttons/list to patient
|
||||||
|
InputBlock → PAUSE, wait for next message
|
||||||
|
ConditionBlock → evaluate variable, follow matching edge
|
||||||
|
SetVariableBlock → assign/transform variable
|
||||||
|
ToolCallBlock → call registered tool
|
||||||
|
AIBlock → generate LLM response
|
||||||
|
JumpBlock → jump to another group
|
||||||
|
→ End of group → follow outgoing edge → next group
|
||||||
|
→ No more edges → flow complete, session cleared
|
||||||
|
```
|
||||||
|
|
||||||
|
Session state stored in Redis with 24h TTL. Per-phone execution lock prevents concurrent flows.
|
||||||
|
|
||||||
|
### Flow JSON Schema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type Flow = {
|
||||||
|
id: string; // "flow-{kebab-name}"
|
||||||
|
name: string; // Human-readable name
|
||||||
|
description: string; // Admin-facing description
|
||||||
|
trigger: FlowTrigger; // What starts this flow
|
||||||
|
groups: Group[]; // Ordered containers of blocks
|
||||||
|
edges: Edge[]; // Connections between blocks/groups
|
||||||
|
variables: VariableDefinition[];// Flow-scoped variables
|
||||||
|
version: number; // Start at 1
|
||||||
|
status: 'draft' | 'published'; // Only published flows execute
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlowTrigger =
|
||||||
|
| { type: 'message'; conditions?: { keywords?: string[]; regex?: string } }
|
||||||
|
| { type: 'default' }; // Catch-all when no other flow matches
|
||||||
|
|
||||||
|
type Group = {
|
||||||
|
id: string; // "g1", "g2", etc.
|
||||||
|
title: string; // "Greeting", "Department Selection"
|
||||||
|
blocks: Block[]; // Executed in order
|
||||||
|
};
|
||||||
|
|
||||||
|
type Edge = {
|
||||||
|
id: string; // "e1", "e2", etc.
|
||||||
|
from: { blockId: string; conditionId?: string };
|
||||||
|
to: { groupId: string; blockId?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
type VariableDefinition = {
|
||||||
|
id: string; // "v1", "v2", etc.
|
||||||
|
name: string; // "selectedDepartment"
|
||||||
|
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||||
|
defaultValue?: any;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Block Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Send text, buttons, or list to patient
|
||||||
|
type MessageBlock = {
|
||||||
|
id: string; type: 'message';
|
||||||
|
content:
|
||||||
|
| { format: 'text'; text: string } // Supports {{variables}}
|
||||||
|
| { format: 'buttons'; text: string; buttons: { id: string; title: string }[] } // Max 3 buttons, title max 20 chars
|
||||||
|
| { format: 'list'; text: string; buttonText: string; sections: { title: string; rows: { id: string; title: string; description?: string }[] }[] }; // Section title max 24 chars, row title max 24 chars, max 10 rows total
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wait for patient reply — PAUSES execution
|
||||||
|
type InputBlock = {
|
||||||
|
id: string; type: 'input';
|
||||||
|
inputType: 'text' | 'interactive_reply' | 'any';
|
||||||
|
variableId: string; // Store reply in this variable
|
||||||
|
validation?: { regex?: string; errorMessage?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Branch based on variable value
|
||||||
|
type ConditionBlock = {
|
||||||
|
id: string; type: 'condition';
|
||||||
|
conditions: {
|
||||||
|
id: string; // "c1" — used in edge.from.conditionId
|
||||||
|
variableId: string;
|
||||||
|
operator: 'equals' | 'contains' | 'exists' | 'not_exists' | 'gt' | 'lt' | 'starts_with';
|
||||||
|
value?: string; // Supports {{variables}}
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Assign or transform a variable
|
||||||
|
type SetVariableBlock = {
|
||||||
|
id: string; type: 'set_variable';
|
||||||
|
variableId: string;
|
||||||
|
value: string;
|
||||||
|
expression?: 'extract_id' | 'extract_datetime' | 'date_tomorrow' | 'date_day_after';
|
||||||
|
// extract_id: "doc:{uuid}:{name}" → uuid (second segment)
|
||||||
|
// extract_datetime: "slot:{id}:{datetime}" → datetime (third+ segments, rejoined with :)
|
||||||
|
// date_tomorrow/date_day_after: computes date string YYYY-MM-DD
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute a registered tool
|
||||||
|
type ToolCallBlock = {
|
||||||
|
id: string; type: 'tool_call';
|
||||||
|
toolName: string; // Must be a registered tool (see below)
|
||||||
|
inputs: Record<string, string>; // Values support {{variables}} and {{var.field}} dot notation
|
||||||
|
outputVariableId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate dynamic LLM response
|
||||||
|
type AIBlock = {
|
||||||
|
id: string; type: 'ai';
|
||||||
|
prompt: string; // Supports {{variables}}
|
||||||
|
outputVariableId?: string;
|
||||||
|
sendToPatient: boolean; // true = send as WhatsApp message
|
||||||
|
};
|
||||||
|
|
||||||
|
// Jump to another group
|
||||||
|
type JumpBlock = {
|
||||||
|
id: string; type: 'jump';
|
||||||
|
targetGroupId: string;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Available Tools (ToolRegistry)
|
||||||
|
|
||||||
|
| Tool Name | Description | Inputs | Output |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `resolve_caller` | Phone → Lead + Patient | phone? (defaults to current) | { leadId, patientId, isNew, phone } |
|
||||||
|
| `send_department_list` | Interactive department list | (none) | { sent, departments[] } |
|
||||||
|
| `send_doctor_list` | Interactive doctor list | department | { sent, count } |
|
||||||
|
| `send_slot_list` | Time slots for doctor+date | doctorId, doctorName, date | { sent, slots } |
|
||||||
|
| `send_confirm_buttons` | Confirm/Cancel buttons | summary | { sent } |
|
||||||
|
| `book_appointment` | Book with conflict check | patientName, phoneNumber, department, doctorName, scheduledAt, reason | { booked, appointmentId, reference } |
|
||||||
|
| `lookup_appointments` | Check existing appointments | (none — uses current caller) | { appointments[] } |
|
||||||
|
| `send_appointment_qr` | Generate and send QR code | appointmentId, reference, patientName, doctorName, department, scheduledAt | { sent, qrUrl } |
|
||||||
|
|
||||||
|
### System Variables (auto-injected)
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|---|---|
|
||||||
|
| `_initialMessage` | The first message the patient sent |
|
||||||
|
| `_senderName` | WhatsApp profile name |
|
||||||
|
| `_phone` | Phone number (E.164 without +) |
|
||||||
|
| `_callerName` | Resolved patient name from platform |
|
||||||
|
| `_leadId` | Lead ID if exists |
|
||||||
|
| `_patientId` | Patient ID if exists |
|
||||||
|
| `_isNew` | true if no prior records |
|
||||||
|
|
||||||
|
### Variable Interpolation
|
||||||
|
|
||||||
|
- `{{variableName}}` — simple substitution
|
||||||
|
- `{{result.fieldName}}` — dot notation for object fields (e.g., `{{bookingResult.appointmentId}}`)
|
||||||
|
- Interactive reply IDs stored in `variableId`, display titles in `variableId_title`
|
||||||
|
|
||||||
|
### WhatsApp Constraints
|
||||||
|
|
||||||
|
- Button title: max 20 characters
|
||||||
|
- List section title: max 24 characters
|
||||||
|
- List row title: max 24 characters
|
||||||
|
- List row description: max 72 characters
|
||||||
|
- Max 3 buttons per message
|
||||||
|
- Max 10 list rows total across all sections
|
||||||
|
- No markdown in text messages (plain text only)
|
||||||
|
- Interactive messages only work within 24h session window
|
||||||
|
|
||||||
|
## How to Generate
|
||||||
|
|
||||||
|
1. **Ask the user** what the flow should do — purpose, steps, what data to collect
|
||||||
|
2. **Design the groups** — each logical phase is a group (Greeting, Selection, Confirmation, etc.)
|
||||||
|
3. **Define variables** — what data flows through the conversation
|
||||||
|
4. **Build blocks** — MessageBlocks for output, InputBlocks to pause for reply, ConditionBlocks for branching, ToolCallBlocks for platform operations, AIBlocks for dynamic responses
|
||||||
|
5. **Wire edges** — connect groups via edges, condition edges for branching
|
||||||
|
6. **Write the JSON** to `src/messaging/flow/default-flows/{flow-name}.json`
|
||||||
|
7. **Register new tools** if needed in `src/messaging/flow/tool-registry.ts`
|
||||||
|
|
||||||
|
## Reference
|
||||||
|
|
||||||
|
See `src/messaging/flow/default-flows/appointment-booking.json` for a complete working example with:
|
||||||
|
- AI greeting
|
||||||
|
- Intent routing (book / check / question)
|
||||||
|
- Interactive lists (departments, doctors, slots)
|
||||||
|
- Date selection with custom date AI parsing
|
||||||
|
- Confirmation buttons
|
||||||
|
- Booking with conflict check
|
||||||
|
- QR code generation
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
After creating the flow JSON:
|
||||||
|
1. `npm run build` — verifies the JSON is copied to dist (via nest-cli.json assets)
|
||||||
|
2. Deploy to EC2 — the flow store auto-seeds on first run if `data/flows/` is empty
|
||||||
|
3. If updating an existing flow: `docker exec sidecar cp /app/dist/.../flow.json /app/data/flows/flow-id.json && docker compose restart sidecar`
|
||||||
@@ -54,5 +54,8 @@ COPY --from=builder /app/dist ./dist
|
|||||||
COPY --from=builder /app/node_modules ./node_modules
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
COPY --from=builder /app/package.json ./
|
COPY --from=builder /app/package.json ./
|
||||||
|
|
||||||
|
# Widget embed script (pre-built, served via NestJS static assets)
|
||||||
|
COPY public ./public
|
||||||
|
|
||||||
EXPOSE 4100
|
EXPOSE 4100
|
||||||
CMD ["node", "dist/main.js"]
|
CMD ["node", "dist/main.js"]
|
||||||
|
|||||||
901
docs/plans/2026-04-20-whatsapp-ai-assistant.md
Normal file
901
docs/plans/2026-04-20-whatsapp-ai-assistant.md
Normal file
@@ -0,0 +1,901 @@
|
|||||||
|
# WhatsApp AI Assistant — Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Provider-agnostic WhatsApp AI assistant that handles inbound patient messages — answers questions from KB, books appointments via interactive buttons, and creates/updates leads automatically.
|
||||||
|
|
||||||
|
**Architecture:** A `MessagingModule` with a provider interface (Gupshup first, swappable to Ozonetel/Meta later). Inbound webhook → caller resolution → AI conversation with tools (reuses existing `book_appointment`, `lookup_doctor`, etc.) → outbound replies via provider. Conversation history stored in Redis with 24h TTL. Interactive WhatsApp buttons/lists for structured selection steps.
|
||||||
|
|
||||||
|
**Tech Stack:** NestJS, Vercel AI SDK (`generateText` with tools), Redis, Gupshup WhatsApp API (`POST https://api.gupshup.io/wa/api/v1/msg`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
src/messaging/
|
||||||
|
├── messaging.module.ts — NestJS module, wires everything
|
||||||
|
├── messaging.controller.ts — POST /api/messaging/webhook (inbound)
|
||||||
|
├── messaging.service.ts — Conversation orchestration (resolve caller, build prompt, call AI, send reply)
|
||||||
|
├── messaging-conversation.service.ts — Redis conversation history (store/load/clear, 24h TTL)
|
||||||
|
├── providers/
|
||||||
|
│ ├── messaging-provider.interface.ts — Provider contract (sendText, sendList, sendButtons, parseInbound)
|
||||||
|
│ └── gupshup.provider.ts — Gupshup implementation
|
||||||
|
└── types.ts — NormalizedMessage, ConversationEntry, InteractiveButton, ListSection
|
||||||
|
```
|
||||||
|
|
||||||
|
**Modified files:**
|
||||||
|
- `src/config/configuration.ts` — add `messaging` config block
|
||||||
|
- `src/app.module.ts` — import MessagingModule
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Types and Provider Interface
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/messaging/types.ts`
|
||||||
|
- Create: `src/messaging/providers/messaging-provider.interface.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create types**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/messaging/types.ts
|
||||||
|
|
||||||
|
export type NormalizedMessage = {
|
||||||
|
phone: string; // E.164 without +, e.g. "919949879837"
|
||||||
|
name: string; // sender name from WhatsApp profile
|
||||||
|
text: string; // message text (or button reply title)
|
||||||
|
type: 'text' | 'interactive_reply' | 'location' | 'image' | 'unknown';
|
||||||
|
interactiveReply?: { // populated when user taps a button or list item
|
||||||
|
id: string; // button/row ID set by us
|
||||||
|
title: string; // display text
|
||||||
|
};
|
||||||
|
rawPayload: any; // original provider payload for debugging
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConversationEntry = {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InteractiveButton = {
|
||||||
|
id: string;
|
||||||
|
title: string; // max 20 chars for WhatsApp
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListSection = {
|
||||||
|
title: string;
|
||||||
|
rows: { id: string; title: string; description?: string }[];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create provider interface**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/messaging/providers/messaging-provider.interface.ts
|
||||||
|
|
||||||
|
import { NormalizedMessage, InteractiveButton, ListSection } from '../types';
|
||||||
|
|
||||||
|
export interface MessagingProvider {
|
||||||
|
/** Parse raw webhook payload into normalized message */
|
||||||
|
parseInbound(body: any): NormalizedMessage | null;
|
||||||
|
|
||||||
|
/** Send a plain text message */
|
||||||
|
sendText(to: string, text: string): Promise<void>;
|
||||||
|
|
||||||
|
/** Send interactive buttons (max 3 for WhatsApp) */
|
||||||
|
sendButtons(to: string, body: string, buttons: InteractiveButton[]): Promise<void>;
|
||||||
|
|
||||||
|
/** Send interactive list (max 10 rows total across sections) */
|
||||||
|
sendList(to: string, body: string, buttonText: string, sections: ListSection[]): Promise<void>;
|
||||||
|
|
||||||
|
/** Validate that inbound webhook is authentic */
|
||||||
|
validateWebhook(body: any): boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/messaging/types.ts src/messaging/providers/messaging-provider.interface.ts
|
||||||
|
git commit -m "feat(messaging): types and provider interface"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Gupshup Provider
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/messaging/providers/gupshup.provider.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement Gupshup provider**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/messaging/providers/gupshup.provider.ts
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { MessagingProvider } from './messaging-provider.interface';
|
||||||
|
import { NormalizedMessage, InteractiveButton, ListSection } from '../types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GupshupProvider implements MessagingProvider {
|
||||||
|
private readonly logger = new Logger(GupshupProvider.name);
|
||||||
|
private readonly apiKey: string;
|
||||||
|
private readonly appId: string;
|
||||||
|
private readonly sourceNumber: string;
|
||||||
|
private readonly apiUrl = 'https://api.gupshup.io/wa/api/v1/msg';
|
||||||
|
|
||||||
|
constructor(private config: ConfigService) {
|
||||||
|
this.apiKey = config.get<string>('messaging.gupshup.apiKey') ?? '';
|
||||||
|
this.appId = config.get<string>('messaging.gupshup.appId') ?? '';
|
||||||
|
this.sourceNumber = config.get<string>('messaging.gupshup.sourceNumber') ?? '';
|
||||||
|
if (this.apiKey) {
|
||||||
|
this.logger.log(`Gupshup provider configured: appId=${this.appId} source=${this.sourceNumber}`);
|
||||||
|
} else {
|
||||||
|
this.logger.warn('Gupshup provider not configured — missing API key');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validateWebhook(body: any): boolean {
|
||||||
|
// Gupshup doesn't sign webhooks — validate by app name match
|
||||||
|
return body?.app === this.appId || !this.appId;
|
||||||
|
}
|
||||||
|
|
||||||
|
parseInbound(body: any): NormalizedMessage | null {
|
||||||
|
// Gupshup sends: { app, timestamp, version, type, payload }
|
||||||
|
if (body?.type !== 'message') return null;
|
||||||
|
|
||||||
|
const payload = body.payload;
|
||||||
|
if (!payload?.sender?.phone) return null;
|
||||||
|
|
||||||
|
const phone = payload.sender.phone.replace(/\D/g, '');
|
||||||
|
const name = payload.sender.name ?? '';
|
||||||
|
const msgType = payload.type;
|
||||||
|
|
||||||
|
// Text message
|
||||||
|
if (msgType === 'text') {
|
||||||
|
return {
|
||||||
|
phone, name,
|
||||||
|
text: payload.payload?.text ?? payload.text ?? '',
|
||||||
|
type: 'text',
|
||||||
|
rawPayload: body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interactive reply (button tap or list selection)
|
||||||
|
if (msgType === 'button_reply' || msgType === 'list_reply') {
|
||||||
|
return {
|
||||||
|
phone, name,
|
||||||
|
text: payload.payload?.title ?? '',
|
||||||
|
type: 'interactive_reply',
|
||||||
|
interactiveReply: {
|
||||||
|
id: payload.payload?.id ?? '',
|
||||||
|
title: payload.payload?.title ?? '',
|
||||||
|
},
|
||||||
|
rawPayload: body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Location
|
||||||
|
if (msgType === 'location') {
|
||||||
|
return {
|
||||||
|
phone, name,
|
||||||
|
text: `Location: ${payload.payload?.latitude}, ${payload.payload?.longitude}`,
|
||||||
|
type: 'location',
|
||||||
|
rawPayload: body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image/document/audio — acknowledge but treat as text
|
||||||
|
if (['image', 'audio', 'video', 'document', 'sticker'].includes(msgType)) {
|
||||||
|
return {
|
||||||
|
phone, name,
|
||||||
|
text: `[Sent ${msgType}]`,
|
||||||
|
type: 'image',
|
||||||
|
rawPayload: body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.warn(`[GUPSHUP] Unknown message type: ${msgType}`);
|
||||||
|
return { phone, name, text: '', type: 'unknown', rawPayload: body };
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendText(to: string, text: string): Promise<void> {
|
||||||
|
await this.send(to, JSON.stringify({ type: 'text', text }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendButtons(to: string, body: string, buttons: InteractiveButton[]): Promise<void> {
|
||||||
|
const message = {
|
||||||
|
type: 'quick_reply',
|
||||||
|
content: { type: 'text', text: body },
|
||||||
|
options: buttons.map(b => ({ type: 'text', title: b.title, postbackText: b.id })),
|
||||||
|
};
|
||||||
|
await this.send(to, JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendList(to: string, body: string, buttonText: string, sections: ListSection[]): Promise<void> {
|
||||||
|
const message = {
|
||||||
|
type: 'list',
|
||||||
|
title: buttonText,
|
||||||
|
body: body,
|
||||||
|
globalButtons: [{ type: 'text', title: buttonText }],
|
||||||
|
items: sections.map(s => ({
|
||||||
|
title: s.title,
|
||||||
|
options: s.rows.map(r => ({
|
||||||
|
type: 'text',
|
||||||
|
title: r.title,
|
||||||
|
description: r.description ?? '',
|
||||||
|
postbackText: r.id,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
await this.send(to, JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async send(to: string, message: string): Promise<void> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('channel', 'whatsapp');
|
||||||
|
params.append('source', this.sourceNumber);
|
||||||
|
params.append('destination', to);
|
||||||
|
params.append('message', message);
|
||||||
|
params.append('src.name', this.appId);
|
||||||
|
|
||||||
|
this.logger.log(`[GUPSHUP] Sending to ${to}: ${message.substring(0, 100)}...`);
|
||||||
|
|
||||||
|
const resp = await fetch(this.apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'apikey': this.apiKey,
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: params.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resp.json().catch(() => resp.text());
|
||||||
|
if (!resp.ok) {
|
||||||
|
this.logger.error(`[GUPSHUP] Send failed (${resp.status}): ${JSON.stringify(result)}`);
|
||||||
|
throw new Error(`Gupshup send failed: ${resp.status}`);
|
||||||
|
}
|
||||||
|
this.logger.log(`[GUPSHUP] Sent: ${JSON.stringify(result)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/messaging/providers/gupshup.provider.ts
|
||||||
|
git commit -m "feat(messaging): gupshup provider implementation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Conversation History Service
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/messaging/messaging-conversation.service.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Implement Redis-backed conversation store**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/messaging/messaging-conversation.service.ts
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { ConversationEntry } from './types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MessagingConversationService {
|
||||||
|
private readonly logger = new Logger(MessagingConversationService.name);
|
||||||
|
private readonly redis: Redis;
|
||||||
|
private readonly ttlSec = 24 * 60 * 60; // 24 hours — matches WhatsApp session window
|
||||||
|
private readonly maxHistory = 20; // keep last 20 message pairs
|
||||||
|
|
||||||
|
constructor(config: ConfigService) {
|
||||||
|
const redisUrl = config.get<string>('redis.url') ?? 'redis://localhost:6379';
|
||||||
|
this.redis = new Redis(redisUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private key(phone: string): string {
|
||||||
|
return `wa:conv:${phone}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHistory(phone: string): Promise<ConversationEntry[]> {
|
||||||
|
const raw = await this.redis.get(this.key(phone));
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMessages(phone: string, entries: ConversationEntry[]): Promise<void> {
|
||||||
|
const existing = await this.getHistory(phone);
|
||||||
|
const updated = [...existing, ...entries].slice(-this.maxHistory);
|
||||||
|
await this.redis.setex(this.key(phone), this.ttlSec, JSON.stringify(updated));
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(phone: string): Promise<void> {
|
||||||
|
await this.redis.del(this.key(phone));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/messaging/messaging-conversation.service.ts
|
||||||
|
git commit -m "feat(messaging): redis conversation history service"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Messaging Service (Conversation Orchestration)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/messaging/messaging.service.ts`
|
||||||
|
|
||||||
|
This is the core — resolves the caller, builds AI context, runs the AI with tools, sends the reply back.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create messaging service**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/messaging/messaging.service.ts
|
||||||
|
|
||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { generateText, tool } from 'ai';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { MessagingProvider } from './providers/messaging-provider.interface';
|
||||||
|
import { MessagingConversationService } from './messaging-conversation.service';
|
||||||
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
|
import { CallerContextService } from '../caller/caller-context.service';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { createAiModel } from '../ai/ai-provider';
|
||||||
|
import { AiConfigService } from '../config/ai-config.service';
|
||||||
|
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils';
|
||||||
|
import type { NormalizedMessage, InteractiveButton, ListSection } from './types';
|
||||||
|
import type { LanguageModel } from 'ai';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MessagingService {
|
||||||
|
private readonly logger = new Logger(MessagingService.name);
|
||||||
|
private readonly aiModel: LanguageModel | null;
|
||||||
|
private readonly auth: string; // server-to-server API key auth
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private config: ConfigService,
|
||||||
|
private provider: MessagingProvider,
|
||||||
|
private conversation: MessagingConversationService,
|
||||||
|
private caller: CallerResolutionService,
|
||||||
|
private callerContext: CallerContextService,
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
private aiConfig: AiConfigService,
|
||||||
|
) {
|
||||||
|
const cfg = aiConfig.getConfig();
|
||||||
|
this.aiModel = createAiModel({
|
||||||
|
provider: cfg.provider,
|
||||||
|
model: cfg.model,
|
||||||
|
anthropicApiKey: config.get<string>('ai.anthropicApiKey'),
|
||||||
|
openaiApiKey: config.get<string>('ai.openaiApiKey'),
|
||||||
|
});
|
||||||
|
|
||||||
|
// WhatsApp AI uses server-to-server auth (no user JWT)
|
||||||
|
const apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
|
this.auth = apiKey ? `Bearer ${apiKey}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleInbound(message: NormalizedMessage): Promise<void> {
|
||||||
|
const { phone, name, text } = message;
|
||||||
|
this.logger.log(`[WA] Inbound from ${phone} (${name}): ${text.substring(0, 100)}`);
|
||||||
|
|
||||||
|
if (!this.aiModel) {
|
||||||
|
await this.provider.sendText(phone, 'Our assistant is temporarily unavailable. Please call us directly.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Resolve caller
|
||||||
|
const resolved = await this.caller.resolve(phone, this.auth).catch(err => {
|
||||||
|
this.logger.error(`[WA] Caller resolution failed: ${err.message}`);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Build context
|
||||||
|
let callerContextPrompt = '';
|
||||||
|
if (resolved && !resolved.isNew && resolved.leadId) {
|
||||||
|
const ctx = await this.callerContext.getOrBuild(resolved.leadId, resolved.patientId ?? '', this.auth).catch(() => null);
|
||||||
|
if (ctx) {
|
||||||
|
callerContextPrompt = this.callerContext.renderForPrompt(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Load conversation history
|
||||||
|
const history = await this.conversation.getHistory(phone);
|
||||||
|
const messages = [
|
||||||
|
...history.map(h => ({ role: h.role as 'user' | 'assistant', content: h.content })),
|
||||||
|
{ role: 'user' as const, content: text },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 4. Build system prompt
|
||||||
|
const systemPrompt = this.buildSystemPrompt(callerContextPrompt, name, phone, resolved?.isNew ?? true);
|
||||||
|
|
||||||
|
// 5. Build tools — provider is injected so tools can send interactive messages
|
||||||
|
const tools = this.buildTools(phone);
|
||||||
|
|
||||||
|
// 6. Run AI
|
||||||
|
try {
|
||||||
|
const result = await generateText({
|
||||||
|
model: this.aiModel,
|
||||||
|
system: systemPrompt,
|
||||||
|
messages,
|
||||||
|
tools,
|
||||||
|
maxSteps: 5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const reply = result.text?.trim();
|
||||||
|
if (reply) {
|
||||||
|
await this.provider.sendText(phone, reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Persist conversation
|
||||||
|
await this.conversation.addMessages(phone, [
|
||||||
|
{ role: 'user', content: text, timestamp: Date.now() },
|
||||||
|
...(reply ? [{ role: 'assistant' as const, content: reply, timestamp: Date.now() }] : []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[WA] AI error: ${err.message}`);
|
||||||
|
await this.provider.sendText(phone, 'Sorry, I encountered an error. Please try again or call us directly.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSystemPrompt(callerContext: string, name: string, phone: string, isNew: boolean): string {
|
||||||
|
return `You are a friendly WhatsApp assistant for a hospital. You help patients with:
|
||||||
|
- Answering questions about departments, doctors, timings, fees
|
||||||
|
- Booking appointments
|
||||||
|
- Checking existing appointments
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- Be concise — WhatsApp messages should be short (2-3 sentences max per message).
|
||||||
|
- No markdown formatting (no **, ##, bullets). Plain text only.
|
||||||
|
- When booking an appointment, collect: department, doctor preference, preferred date/time, reason for visit.
|
||||||
|
- Use the send_department_list tool to show available departments as a WhatsApp list.
|
||||||
|
- Use the send_doctor_list tool to show available doctors as a WhatsApp list.
|
||||||
|
- Use the send_slot_list tool to show available time slots as a WhatsApp list.
|
||||||
|
- Use the send_confirm_buttons tool to let the patient confirm or cancel before booking.
|
||||||
|
- After booking, send a confirmation with doctor name, date, time, and reference number.
|
||||||
|
- If the patient asks something you can't help with, suggest they call the hospital directly.
|
||||||
|
- Always be warm and professional. Use the patient's name when known.
|
||||||
|
- Reply in the same language the patient uses. Button/list labels stay in English.
|
||||||
|
|
||||||
|
CURRENT PATIENT:
|
||||||
|
Name: ${name || 'Unknown'}
|
||||||
|
Phone: ${phone}
|
||||||
|
${isNew ? 'New patient — no prior records.' : ''}
|
||||||
|
${callerContext ? `\n${callerContext}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildTools(phone: string) {
|
||||||
|
const provider = this.provider;
|
||||||
|
const platform = this.platform;
|
||||||
|
const auth = this.auth;
|
||||||
|
const logger = this.logger;
|
||||||
|
|
||||||
|
return {
|
||||||
|
lookup_appointments: tool({
|
||||||
|
description: 'Look up existing appointments for the current patient.',
|
||||||
|
parameters: z.object({
|
||||||
|
patientId: z.string().optional().describe('Patient ID — omit to use current caller context'),
|
||||||
|
}),
|
||||||
|
execute: async ({ patientId }) => {
|
||||||
|
// Resolve patient from phone if not provided
|
||||||
|
let pid = patientId;
|
||||||
|
if (!pid) {
|
||||||
|
const resolved = await this.caller.resolve(phone, auth).catch(() => null);
|
||||||
|
pid = resolved?.patientId;
|
||||||
|
}
|
||||||
|
if (!pid) return { appointments: [], message: 'No patient record found.' };
|
||||||
|
|
||||||
|
const data = await platform.query<any>(
|
||||||
|
`{ appointments(first: 10, filter: { patientId: { eq: "${pid}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||||
|
id scheduledAt appointmentStatus doctorName department reasonForVisit
|
||||||
|
} } } }`,
|
||||||
|
);
|
||||||
|
return { appointments: data.appointments.edges.map((e: any) => e.node) };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
send_department_list: tool({
|
||||||
|
description: 'Send an interactive WhatsApp list of available departments for the patient to choose from. Call this when the patient wants to book but hasn\'t specified a department.',
|
||||||
|
parameters: z.object({}),
|
||||||
|
execute: async () => {
|
||||||
|
const data = await platform.query<any>(
|
||||||
|
`{ doctors(first: 50) { edges { node { department } } } }`,
|
||||||
|
);
|
||||||
|
const departments = [...new Set(
|
||||||
|
data.doctors.edges.map((e: any) => e.node.department).filter(Boolean),
|
||||||
|
)] as string[];
|
||||||
|
|
||||||
|
if (!departments.length) return { sent: false, message: 'No departments available.' };
|
||||||
|
|
||||||
|
const sections: ListSection[] = [{
|
||||||
|
title: 'Departments',
|
||||||
|
rows: departments.slice(0, 10).map(d => ({
|
||||||
|
id: `dept:${d}`,
|
||||||
|
title: d.substring(0, 24),
|
||||||
|
})),
|
||||||
|
}];
|
||||||
|
await provider.sendList(phone, 'Which department would you like to visit?', 'View Departments', sections);
|
||||||
|
return { sent: true, departments };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
send_doctor_list: tool({
|
||||||
|
description: 'Send an interactive WhatsApp list of doctors in a specific department. Call this after the patient selects a department.',
|
||||||
|
parameters: z.object({
|
||||||
|
department: z.string().describe('Department name'),
|
||||||
|
}),
|
||||||
|
execute: async ({ department }) => {
|
||||||
|
const data = await platform.query<any>(
|
||||||
|
`{ doctors(first: 50) { edges { node {
|
||||||
|
id fullName { firstName lastName }
|
||||||
|
department specialty
|
||||||
|
consultationFeeNew { amountMicros currencyCode }
|
||||||
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||||
|
} } } }`,
|
||||||
|
);
|
||||||
|
const allDocs = normalizeDoctors(data.doctors.edges.map((e: any) => e.node));
|
||||||
|
const deptDocs = allDocs.filter((d: any) =>
|
||||||
|
d.department?.toLowerCase() === department.toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!deptDocs.length) return { sent: false, message: `No doctors found in ${department}.` };
|
||||||
|
|
||||||
|
const sections: ListSection[] = [{
|
||||||
|
title: department,
|
||||||
|
rows: deptDocs.slice(0, 10).map((d: any) => {
|
||||||
|
const name = `Dr. ${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''}`.trim();
|
||||||
|
const fee = d.consultationFeeNew?.amountMicros
|
||||||
|
? `₹${(d.consultationFeeNew.amountMicros / 1000000).toFixed(0)}`
|
||||||
|
: '';
|
||||||
|
return {
|
||||||
|
id: `doc:${d.id}:${name}`,
|
||||||
|
title: name.substring(0, 24),
|
||||||
|
description: fee ? `${d.specialty ?? department} — ${fee}` : (d.specialty ?? department),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}];
|
||||||
|
await provider.sendList(phone, `Doctors in ${department}:`, 'View Doctors', sections);
|
||||||
|
return { sent: true, count: deptDocs.length };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
send_slot_list: tool({
|
||||||
|
description: 'Send available time slots for a doctor as a WhatsApp list. Call this after the patient selects a doctor.',
|
||||||
|
parameters: z.object({
|
||||||
|
doctorId: z.string().describe('Doctor ID from the doctor list selection'),
|
||||||
|
doctorName: z.string().describe('Doctor name for display'),
|
||||||
|
date: z.string().optional().describe('Date in YYYY-MM-DD format. Defaults to tomorrow.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ doctorId, doctorName, date }) => {
|
||||||
|
const targetDate = date ?? new Date(Date.now() + 86400000).toISOString().split('T')[0];
|
||||||
|
|
||||||
|
const data = await platform.query<any>(
|
||||||
|
`{ doctors(first: 50) { edges { node {
|
||||||
|
id fullName { firstName lastName }
|
||||||
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||||
|
} } } }`,
|
||||||
|
);
|
||||||
|
const allDocs = normalizeDoctors(data.doctors.edges.map((e: any) => e.node));
|
||||||
|
const doctor = allDocs.find((d: any) => d.id === doctorId);
|
||||||
|
const slots = doctor?.availableSlots ?? [];
|
||||||
|
|
||||||
|
if (!slots.length) {
|
||||||
|
return { sent: false, message: `No slots available for Dr. ${doctorName} on ${targetDate}.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections: ListSection[] = [{
|
||||||
|
title: `${doctorName} — ${targetDate}`,
|
||||||
|
rows: slots.slice(0, 10).map((s: any, i: number) => ({
|
||||||
|
id: `slot:${doctorId}:${targetDate}T${s.time}:00`,
|
||||||
|
title: s.time,
|
||||||
|
description: s.clinic ?? '',
|
||||||
|
})),
|
||||||
|
}];
|
||||||
|
await provider.sendList(phone, `Available slots for ${doctorName}:`, 'View Slots', sections);
|
||||||
|
return { sent: true, slots: slots.length };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
send_confirm_buttons: tool({
|
||||||
|
description: 'Send confirmation buttons before booking the appointment. Call this after all details are collected.',
|
||||||
|
parameters: z.object({
|
||||||
|
summary: z.string().describe('Appointment summary text to show the patient'),
|
||||||
|
}),
|
||||||
|
execute: async ({ summary }) => {
|
||||||
|
const buttons: InteractiveButton[] = [
|
||||||
|
{ id: 'confirm_booking', title: 'Confirm' },
|
||||||
|
{ id: 'cancel_booking', title: 'Cancel' },
|
||||||
|
];
|
||||||
|
await provider.sendButtons(phone, summary, buttons);
|
||||||
|
return { sent: true };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
book_appointment: tool({
|
||||||
|
description: 'Book the appointment after patient confirms. Only call this AFTER the patient taps the Confirm button.',
|
||||||
|
parameters: z.object({
|
||||||
|
patientName: z.string().describe('Patient name'),
|
||||||
|
phoneNumber: z.string().describe('Patient phone number'),
|
||||||
|
department: z.string().describe('Department'),
|
||||||
|
doctorName: z.string().describe('Doctor name'),
|
||||||
|
scheduledAt: z.string().describe('ISO datetime for the appointment'),
|
||||||
|
reason: z.string().describe('Reason for visit'),
|
||||||
|
}),
|
||||||
|
execute: async ({ patientName, phoneNumber, department, doctorName, scheduledAt, reason }) => {
|
||||||
|
logger.log(`[WA-BOOK] Booking: ${patientName} → ${doctorName} @ ${scheduledAt}`);
|
||||||
|
try {
|
||||||
|
// Ensure lead exists
|
||||||
|
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
||||||
|
const resolved = await this.caller.resolve(cleanPhone, auth).catch(() => null);
|
||||||
|
|
||||||
|
if (resolved?.isNew) {
|
||||||
|
// Create patient + lead
|
||||||
|
const firstName = patientName.split(' ')[0];
|
||||||
|
const lastName = patientName.split(' ').slice(1).join(' ') || '';
|
||||||
|
try {
|
||||||
|
const p = await platform.query<any>(
|
||||||
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
|
{ data: { fullName: { firstName, lastName }, phones: { primaryPhoneNumber: `+91${cleanPhone}` }, patientType: 'NEW' } },
|
||||||
|
);
|
||||||
|
const patientId = p?.createPatient?.id;
|
||||||
|
await platform.query<any>(
|
||||||
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
|
{ data: { name: `WhatsApp — ${patientName}`, contactName: { firstName, lastName }, contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` }, source: 'WHATSAPP', status: 'NEW', interestedService: department, ...(patientId ? { patientId } : {}) } },
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.warn(`[WA-BOOK] Lead/patient creation failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await platform.query<any>(
|
||||||
|
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
||||||
|
{ data: { name: `WhatsApp Booking — ${patientName} (${department})`, scheduledAt, appointmentStatus: 'SCHEDULED', doctorName, department, reasonForVisit: reason } },
|
||||||
|
);
|
||||||
|
const id = result?.createAppointment?.id;
|
||||||
|
if (id) {
|
||||||
|
return { booked: true, appointmentId: id, message: `Appointment booked! Reference: ${id.substring(0, 8)}` };
|
||||||
|
}
|
||||||
|
return { booked: false, message: 'Booking failed. Please try again.' };
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error(`[WA-BOOK] Failed: ${err.message}`);
|
||||||
|
return { booked: false, message: 'Booking failed. Please call us directly.' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/messaging/messaging.service.ts
|
||||||
|
git commit -m "feat(messaging): conversation orchestration service with AI tools"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Webhook Controller
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/messaging/messaging.controller.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create the webhook controller**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/messaging/messaging.controller.ts
|
||||||
|
|
||||||
|
import { Controller, Post, Body, Logger } from '@nestjs/common';
|
||||||
|
import { MessagingProvider } from './providers/messaging-provider.interface';
|
||||||
|
import { MessagingService } from './messaging.service';
|
||||||
|
|
||||||
|
@Controller('api/messaging')
|
||||||
|
export class MessagingController {
|
||||||
|
private readonly logger = new Logger(MessagingController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly provider: MessagingProvider,
|
||||||
|
private readonly messaging: MessagingService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('webhook')
|
||||||
|
async webhook(@Body() body: any) {
|
||||||
|
this.logger.log(`[WA-WEBHOOK] Received: ${JSON.stringify(body).substring(0, 300)}`);
|
||||||
|
|
||||||
|
// Validate webhook source
|
||||||
|
if (!this.provider.validateWebhook(body)) {
|
||||||
|
this.logger.warn('[WA-WEBHOOK] Validation failed — ignoring');
|
||||||
|
return { status: 'ignored', reason: 'validation failed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse inbound message
|
||||||
|
const message = this.provider.parseInbound(body);
|
||||||
|
if (!message) {
|
||||||
|
this.logger.log('[WA-WEBHOOK] Non-message event — skipped');
|
||||||
|
return { status: 'ok', type: body?.type ?? 'unknown' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle asynchronously — don't block the webhook response
|
||||||
|
this.messaging.handleInbound(message).catch(err => {
|
||||||
|
this.logger.error(`[WA-WEBHOOK] handleInbound failed: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/messaging/messaging.controller.ts
|
||||||
|
git commit -m "feat(messaging): webhook controller"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Module Wiring and Configuration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `src/messaging/messaging.module.ts`
|
||||||
|
- Modify: `src/config/configuration.ts`
|
||||||
|
- Modify: `src/app.module.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add messaging config**
|
||||||
|
|
||||||
|
Add to `src/config/configuration.ts`, after the `ai` block:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
messaging: {
|
||||||
|
provider: process.env.MESSAGING_PROVIDER ?? 'gupshup',
|
||||||
|
gupshup: {
|
||||||
|
apiKey: process.env.GUPSHUP_API_KEY ?? '',
|
||||||
|
appId: process.env.GUPSHUP_APP_ID ?? '',
|
||||||
|
sourceNumber: process.env.GUPSHUP_SOURCE_NUMBER ?? '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create module**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// src/messaging/messaging.module.ts
|
||||||
|
|
||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
||||||
|
import { MessagingController } from './messaging.controller';
|
||||||
|
import { MessagingService } from './messaging.service';
|
||||||
|
import { MessagingConversationService } from './messaging-conversation.service';
|
||||||
|
import { GupshupProvider } from './providers/gupshup.provider';
|
||||||
|
import { MessagingProvider } from './providers/messaging-provider.interface';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [ConfigModule, PlatformModule, CallerResolutionModule],
|
||||||
|
controllers: [MessagingController],
|
||||||
|
providers: [
|
||||||
|
MessagingService,
|
||||||
|
MessagingConversationService,
|
||||||
|
{
|
||||||
|
provide: MessagingProvider,
|
||||||
|
useFactory: (config: ConfigService) => {
|
||||||
|
const provider = config.get<string>('messaging.provider');
|
||||||
|
// Future: switch on provider to return OzonetelProvider, MetaProvider, etc.
|
||||||
|
return new GupshupProvider(config);
|
||||||
|
},
|
||||||
|
inject: [ConfigService],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class MessagingModule {}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Register in app.module.ts**
|
||||||
|
|
||||||
|
Add import at the top:
|
||||||
|
```typescript
|
||||||
|
import { MessagingModule } from './messaging/messaging.module';
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `MessagingModule` to the `imports` array.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/messaging/messaging.module.ts src/config/configuration.ts src/app.module.ts
|
||||||
|
git commit -m "feat(messaging): module wiring and configuration"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: Environment Variables and Deployment
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: Ramaiah sidecar env on EC2
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add env vars to Ramaiah sidecar**
|
||||||
|
|
||||||
|
SSH into EC2 and add to the sidecar-ramaiah environment in docker-compose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
SSHPASS='SasiSuman@2007' sshpass -P "Enter passphrase" -e \
|
||||||
|
ssh -i ~/Downloads/fortytwoai_hostinger -o StrictHostKeyChecking=no ubuntu@13.234.31.194
|
||||||
|
|
||||||
|
cd /opt/fortytwo
|
||||||
|
# Edit docker-compose.yml — add to sidecar-ramaiah environment:
|
||||||
|
# MESSAGING_PROVIDER=gupshup
|
||||||
|
# GUPSHUP_API_KEY=sk_c6dd2ff65d4f4e2d967cf7bbc2f620ed
|
||||||
|
# GUPSHUP_APP_ID=f6196887-ed08-4c4e-9049-e4e4ec59b254
|
||||||
|
# GUPSHUP_SOURCE_NUMBER=<the WhatsApp Business number registered with Gupshup>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Configure Gupshup webhook**
|
||||||
|
|
||||||
|
In the Gupshup dashboard, set the callback URL to:
|
||||||
|
```
|
||||||
|
https://ramaiah.engage.healix360.net/api/messaging/webhook
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build, push, and deploy sidecar**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd helix-engage-server
|
||||||
|
aws ecr get-login-password --region ap-south-1 | docker login --username AWS --password-stdin 043728036361.dkr.ecr.ap-south-1.amazonaws.com
|
||||||
|
docker buildx build --platform linux/amd64 -t 043728036361.dkr.ecr.ap-south-1.amazonaws.com/fortytwo-eap/helix-engage-sidecar:alpha --push .
|
||||||
|
```
|
||||||
|
|
||||||
|
On EC2:
|
||||||
|
```bash
|
||||||
|
cd /opt/fortytwo && sudo docker compose pull sidecar-ramaiah && sudo docker compose up -d sidecar-ramaiah
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Test end-to-end**
|
||||||
|
|
||||||
|
Send a WhatsApp message to the Gupshup-registered number. Verify:
|
||||||
|
1. Webhook received (check sidecar logs)
|
||||||
|
2. AI response sent back
|
||||||
|
3. Department list renders as interactive WhatsApp list
|
||||||
|
4. Doctor selection works
|
||||||
|
5. Slot selection works
|
||||||
|
6. Confirm/cancel buttons render
|
||||||
|
7. Appointment appears in platform
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit env docs**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/plans/2026-04-20-whatsapp-ai-assistant.md
|
||||||
|
git commit -m "docs: whatsapp AI assistant implementation plan"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Missing: Source Number
|
||||||
|
|
||||||
|
The `GUPSHUP_SOURCE_NUMBER` env var needs the WhatsApp Business number registered with Gupshup. This is the number patients will message. Check the Gupshup dashboard under App Settings → WhatsApp Number.
|
||||||
|
|
||||||
|
## Provider Swap (Future)
|
||||||
|
|
||||||
|
To add Ozonetel or Meta Cloud API:
|
||||||
|
1. Create `src/messaging/providers/ozonetel.provider.ts` implementing `MessagingProvider`
|
||||||
|
2. Add config block in `configuration.ts`
|
||||||
|
3. Update the `useFactory` in `messaging.module.ts` to switch on `config.get('messaging.provider')`
|
||||||
|
4. Set `MESSAGING_PROVIDER=ozonetel` in env
|
||||||
|
|
||||||
|
No other files change — the controller, service, and conversation store are provider-agnostic.
|
||||||
270
docs/specs/2026-04-20-flow-runtime-design.md
Normal file
270
docs/specs/2026-04-20-flow-runtime-design.md
Normal file
@@ -0,0 +1,270 @@
|
|||||||
|
# WhatsApp Flow Runtime — Design Spec
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Config-driven conversation engine that reads flow definitions (JSON) and executes them at runtime. Replaces the hardcoded system prompt + tools in `messaging.service.ts`. Hospital admins define flows via API/file — no code changes needed.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Inbound WhatsApp message
|
||||||
|
→ MessagingController (existing)
|
||||||
|
→ FlowExecutionService (NEW — replaces MessagingService AI logic)
|
||||||
|
→ Load/create FlowSession from Redis
|
||||||
|
→ Match flow by trigger (or resume existing session)
|
||||||
|
→ Walk forward through Groups → Blocks
|
||||||
|
→ Pause at InputBlock, resume on next message
|
||||||
|
→ Send messages via MessagingProvider (existing)
|
||||||
|
→ Call tools via ToolRegistry (NEW)
|
||||||
|
→ Reply sent to patient
|
||||||
|
```
|
||||||
|
|
||||||
|
## Flow Definition Schema
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type Flow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
trigger: FlowTrigger;
|
||||||
|
groups: Group[];
|
||||||
|
edges: Edge[];
|
||||||
|
variables: VariableDefinition[];
|
||||||
|
version: number;
|
||||||
|
status: 'draft' | 'published';
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlowTrigger =
|
||||||
|
| { type: 'message'; conditions?: { keywords?: string[]; regex?: string } }
|
||||||
|
| { type: 'default' };
|
||||||
|
|
||||||
|
type VariableDefinition = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||||
|
defaultValue?: any;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Groups and Edges
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type Group = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
blocks: Block[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Edge = {
|
||||||
|
id: string;
|
||||||
|
from: { blockId: string; conditionId?: string };
|
||||||
|
to: { groupId: string; blockId?: string };
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Block Types
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type Block =
|
||||||
|
| MessageBlock
|
||||||
|
| InputBlock
|
||||||
|
| ConditionBlock
|
||||||
|
| SetVariableBlock
|
||||||
|
| ToolCallBlock
|
||||||
|
| AIBlock
|
||||||
|
| JumpBlock;
|
||||||
|
|
||||||
|
// Send text/list/buttons to patient
|
||||||
|
type MessageBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'message';
|
||||||
|
content:
|
||||||
|
| { format: 'text'; text: string }
|
||||||
|
| { format: 'buttons'; text: string; buttons: { id: string; title: string }[] }
|
||||||
|
| { format: 'list'; text: string; buttonText: string; sections: { title: string; rows: { id: string; title: string; description?: string }[] }[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wait for patient reply
|
||||||
|
type InputBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'input';
|
||||||
|
inputType: 'text' | 'interactive_reply' | 'any';
|
||||||
|
variableId: string;
|
||||||
|
validation?: { regex?: string; errorMessage?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Branch based on variable value
|
||||||
|
type ConditionBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'condition';
|
||||||
|
conditions: {
|
||||||
|
id: string;
|
||||||
|
variableId: string;
|
||||||
|
operator: 'equals' | 'contains' | 'exists' | 'not_exists' | 'gt' | 'lt' | 'starts_with';
|
||||||
|
value?: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// Assign/transform a variable
|
||||||
|
type SetVariableBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'set_variable';
|
||||||
|
variableId: string;
|
||||||
|
value: string;
|
||||||
|
expression?: 'extract_id';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Execute a registered tool
|
||||||
|
type ToolCallBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'tool_call';
|
||||||
|
toolName: string;
|
||||||
|
inputs: Record<string, string>; // values support {{variables}}
|
||||||
|
outputVariableId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Generate dynamic LLM response
|
||||||
|
type AIBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'ai';
|
||||||
|
prompt: string; // supports {{variables}}
|
||||||
|
outputVariableId?: string;
|
||||||
|
sendToPatient: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Jump to another group
|
||||||
|
type JumpBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'jump';
|
||||||
|
targetGroupId: string;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## Session State (Redis)
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
type FlowSession = {
|
||||||
|
flowId: string;
|
||||||
|
currentGroupId: string;
|
||||||
|
currentBlockIndex: number;
|
||||||
|
variables: Record<string, any>;
|
||||||
|
history: ConversationEntry[];
|
||||||
|
startedAt: number;
|
||||||
|
lastActiveAt: number;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Key: `wa:flow:{phone}`, TTL: 24 hours (WhatsApp session window).
|
||||||
|
|
||||||
|
## Execution Loop
|
||||||
|
|
||||||
|
```
|
||||||
|
On inbound message:
|
||||||
|
1. Load session from Redis (or create new → match flow by trigger)
|
||||||
|
2. If paused at InputBlock → store reply in variable, advance
|
||||||
|
3. Walk forward:
|
||||||
|
- MessageBlock → send via provider, advance
|
||||||
|
- InputBlock → save session, STOP (wait for next message)
|
||||||
|
- ConditionBlock → evaluate, follow matching edge (or fall through)
|
||||||
|
- SetVariableBlock → assign value, advance
|
||||||
|
- ToolCallBlock → execute tool, store result, advance
|
||||||
|
- AIBlock → call LLM, optionally send, advance
|
||||||
|
- JumpBlock → jump to target group
|
||||||
|
- End of group → follow outgoing edge to next group
|
||||||
|
4. If no more blocks/edges → flow complete, clear session
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tool Registry
|
||||||
|
|
||||||
|
Existing tools from messaging.service.ts become registered tools:
|
||||||
|
|
||||||
|
| Tool Name | Description | Inputs | Output |
|
||||||
|
|---|---|---|---|
|
||||||
|
| resolve_caller | Phone → Lead + Patient | phone | { leadId, patientId, isNew, name } |
|
||||||
|
| send_department_list | Send interactive department list | (none — reads from platform) | { departments[] } |
|
||||||
|
| send_doctor_list | Send interactive doctor list | department | { doctors[] } |
|
||||||
|
| send_slot_list | Send time slots for doctor+date | doctorId, doctorName, date | { slots[] } |
|
||||||
|
| send_confirm_buttons | Send confirm/cancel buttons | summary | { sent: true } |
|
||||||
|
| book_appointment | Book appointment (with conflict check) | patientName, phoneNumber, department, doctorName, scheduledAt, reason | { booked, appointmentId } |
|
||||||
|
| lookup_appointments | Check existing appointments | patientId? | { appointments[] } |
|
||||||
|
| create_lead | Create lead + patient | name, phoneNumber, interest | { leadId } |
|
||||||
|
|
||||||
|
## Example Flow: Appointment Booking
|
||||||
|
|
||||||
|
```
|
||||||
|
Group: "Greeting" (g1)
|
||||||
|
→ AIBlock: greet using patient name + context
|
||||||
|
→ MessageBlock: buttons ["Book Appointment", "Check Appointment", "Ask a Question"]
|
||||||
|
→ InputBlock: store in {{intent}}
|
||||||
|
Edges: g1 → ConditionBlock routes to g2 (book) / g7 (check) / g8 (question)
|
||||||
|
|
||||||
|
Group: "Department Selection" (g2)
|
||||||
|
→ ToolCallBlock: send_department_list
|
||||||
|
→ InputBlock: store in {{selectedDepartment}}
|
||||||
|
Edge: g2 → g3
|
||||||
|
|
||||||
|
Group: "Doctor Selection" (g3)
|
||||||
|
→ ToolCallBlock: send_doctor_list, input: department={{selectedDepartment}}
|
||||||
|
→ InputBlock: store in {{selectedDoctor}}
|
||||||
|
→ SetVariableBlock: extract doctorId from {{selectedDoctor}}
|
||||||
|
Edge: g3 → g4
|
||||||
|
|
||||||
|
Group: "Date Selection" (g4)
|
||||||
|
→ MessageBlock: "When would you like to visit?"
|
||||||
|
→ MessageBlock: buttons ["Tomorrow", "Day After", "Choose Date"]
|
||||||
|
→ InputBlock: store in {{dateChoice}}
|
||||||
|
→ ConditionBlock: tomorrow → SetVariable, day_after → SetVariable, else → AI parse
|
||||||
|
Edge: g4 → g5
|
||||||
|
|
||||||
|
Group: "Slot Selection" (g5)
|
||||||
|
→ ToolCallBlock: send_slot_list, inputs: doctorId={{doctorId}}, date={{selectedDate}}
|
||||||
|
→ InputBlock: store in {{selectedSlot}}
|
||||||
|
Edge: g5 → g6
|
||||||
|
|
||||||
|
Group: "Confirmation" (g6)
|
||||||
|
→ MessageBlock: buttons ["Confirm", "Cancel"], summary text
|
||||||
|
→ InputBlock: store in {{confirmation}}
|
||||||
|
→ ConditionBlock: confirm → g7, cancel → g8
|
||||||
|
Edges: confirm → "Booking" group, cancel → "Cancelled" group
|
||||||
|
|
||||||
|
Group: "Booking" (g7)
|
||||||
|
→ ToolCallBlock: book_appointment with all collected variables
|
||||||
|
→ MessageBlock: confirmation with reference number
|
||||||
|
|
||||||
|
Group: "Cancelled" (g8)
|
||||||
|
→ MessageBlock: "No problem! Let me know if you need anything else."
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Structure (Implementation)
|
||||||
|
|
||||||
|
```
|
||||||
|
src/messaging/
|
||||||
|
├── flow/
|
||||||
|
│ ├── flow-types.ts — All types above
|
||||||
|
│ ├── flow-execution.service.ts — Main execution loop
|
||||||
|
│ ├── flow-session.service.ts — Redis session CRUD
|
||||||
|
│ ├── flow-store.service.ts — Load/save flow definitions (file/Redis)
|
||||||
|
│ ├── flow-variable.service.ts — Variable interpolation + expressions
|
||||||
|
│ ├── tool-registry.ts — Tool name → handler mapping
|
||||||
|
│ └── default-flows/
|
||||||
|
│ └── appointment-booking.json — Seeded default flow
|
||||||
|
├── providers/ (existing, unchanged)
|
||||||
|
├── messaging.module.ts — Wire new services
|
||||||
|
├── messaging.controller.ts — Unchanged (webhook still here)
|
||||||
|
├── messaging.service.ts — Delegates to FlowExecutionService
|
||||||
|
└── types.ts — Existing types (unchanged)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration Path
|
||||||
|
|
||||||
|
1. Build FlowExecutionService alongside existing MessagingService
|
||||||
|
2. Seed default appointment-booking.json (equivalent to current hardcoded flow)
|
||||||
|
3. MessagingService checks: if flow config exists → delegate to FlowExecutionService, else → current AI behavior (backward compatible)
|
||||||
|
4. Once validated, remove hardcoded AI flow from MessagingService
|
||||||
|
|
||||||
|
## Not in Scope
|
||||||
|
|
||||||
|
- Visual builder UI (future, maybe never)
|
||||||
|
- Flow versioning/rollback (v2)
|
||||||
|
- Flow analytics/metrics (v2)
|
||||||
|
- Multi-flow routing (v2 — for now, one active flow per trigger type)
|
||||||
239
docs/website-widget.md
Normal file
239
docs/website-widget.md
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
# Website Chat Widget — Operations Guide
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A floating chat/booking/contact widget that hospitals embed on their website via a single `<script>` tag. Visitors can:
|
||||||
|
- **Chat** with an AI assistant (powered by OpenAI/Anthropic)
|
||||||
|
- **Book** appointments (department → doctor → date → slot)
|
||||||
|
- **Contact** the hospital (name, phone, interest — creates a lead in the CRM)
|
||||||
|
|
||||||
|
All interactions create or update leads in Helix Engage, so CC agents see the full visitor journey when they call back.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Embed Snippet
|
||||||
|
|
||||||
|
Add this to any page on the hospital website (before `</body>`):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script src="https://ramaiah.engage.healix360.net/widget.js"
|
||||||
|
data-key="956018d178194fb9.313657fbc8a912b9cf8c93b9a51dfb209022fcd9910bd5abc7aa16dfaacf98a3">
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
| Parameter | Description |
|
||||||
|
|---|---|
|
||||||
|
| `src` | The sidecar URL + `/widget.js` |
|
||||||
|
| `data-key` | Site key — generated and rotatable from the admin portal |
|
||||||
|
|
||||||
|
The widget renders in a **shadow DOM** — its styles don't leak into or get affected by the host website's CSS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin Configuration
|
||||||
|
|
||||||
|
### Settings Page
|
||||||
|
|
||||||
|
URL: `https://ramaiah.engage.healix360.net/settings/widget` (login as admin/supervisor)
|
||||||
|
|
||||||
|
| Setting | Description |
|
||||||
|
|---|---|
|
||||||
|
| **Enabled** | Master kill switch — when off, widget.js no-ops |
|
||||||
|
| **Site Key** | Read-only HMAC-signed key. Copy-to-clipboard for the embed snippet |
|
||||||
|
| **Site ID** | Read-only identifier |
|
||||||
|
| **Rotate Key** | Generates a new key, invalidates the old embed snippet |
|
||||||
|
| **Hosting URL** | Public base URL for widget.js. Leave blank to use same origin as sidecar |
|
||||||
|
| **Allowed Origins** | Whitelist of domains allowed to embed. Empty = any origin (test mode) |
|
||||||
|
| **Show on Login Page** | Toggle to display widget on the Helix Engage login page |
|
||||||
|
|
||||||
|
### Configuration API
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Read config (public — no auth)
|
||||||
|
curl https://ramaiah.engage.healix360.net/api/config/widget
|
||||||
|
|
||||||
|
# Read full config (admin)
|
||||||
|
curl https://ramaiah.engage.healix360.net/api/config/widget/admin
|
||||||
|
|
||||||
|
# Update config
|
||||||
|
curl -X PUT https://ramaiah.engage.healix360.net/api/config/widget \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"enabled": true, "allowedOrigins": ["https://ramaiahmedical.com"]}'
|
||||||
|
|
||||||
|
# Rotate site key (invalidates old embeds)
|
||||||
|
curl -X POST https://ramaiah.engage.healix360.net/api/config/widget/rotate-key
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widget Key Security
|
||||||
|
|
||||||
|
- Key format: `{siteId}.{hmacSignature}`
|
||||||
|
- HMAC computed as: `sha256(siteId, secret=WIDGET_SECRET env var)`
|
||||||
|
- Validated with `timingSafeEqual()` to prevent timing attacks
|
||||||
|
- Origin checked against `allowedOrigins` whitelist
|
||||||
|
- Empty whitelist = test mode (any origin allowed)
|
||||||
|
|
||||||
|
### For Production
|
||||||
|
|
||||||
|
Before going live on a real hospital website:
|
||||||
|
1. Set `allowedOrigins` to the hospital's domain(s): `["https://ramaiahmedical.com", "https://www.ramaiahmedical.com"]`
|
||||||
|
2. Ensure `WIDGET_SECRET` env var is set in the sidecar (auto-generated on first run if missing)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widget API Endpoints
|
||||||
|
|
||||||
|
All endpoints require `WidgetKeyGuard` (key as query param `?key=...` or header `X-Widget-Key`).
|
||||||
|
|
||||||
|
| Endpoint | Method | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| `/api/widget/init` | GET | Returns brand name, logo, colors, reCAPTCHA key |
|
||||||
|
| `/api/widget/doctors` | GET | All doctors with departments, specialties, fees, visit slots |
|
||||||
|
| `/api/widget/slots?doctorId=X&date=YYYY-MM-DD` | GET | Available time slots for a doctor on a date |
|
||||||
|
| `/api/widget/book` | POST | Book appointment (requires captcha token) |
|
||||||
|
| `/api/widget/lead` | POST | Create lead from contact form (requires captcha token) |
|
||||||
|
| `/api/widget/chat-start` | POST | Start chat session — body: `{name, phone}`, returns `{leadId}` |
|
||||||
|
| `/api/widget/chat` | POST | Stream AI reply — body: `{leadId, messages[], branch?}`, returns SSE stream |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How the Chat Flow Works
|
||||||
|
|
||||||
|
1. Visitor opens widget → Chat tab shows name + phone form
|
||||||
|
2. Visitor enters name + phone → `POST /api/widget/chat-start` → returns `leadId`
|
||||||
|
- Creates or finds existing lead by phone (deduplication)
|
||||||
|
3. Visitor types a message → `POST /api/widget/chat` with `leadId` + message history
|
||||||
|
- AI streams a reply via Server-Sent Events
|
||||||
|
- AI has tools: branch selection, doctor search, slot lookup, booking suggestions
|
||||||
|
4. After conversation ends, transcript is saved to lead activity timeline
|
||||||
|
5. CC agent sees the WhatsApp/chat history when calling the patient back
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How the Booking Flow Works
|
||||||
|
|
||||||
|
1. Visitor opens widget → Book tab
|
||||||
|
2. Selects department → fetches doctor list
|
||||||
|
3. Selects doctor → fetches available dates/slots
|
||||||
|
4. Enters patient name, phone, chief complaint
|
||||||
|
5. `POST /api/widget/book` with captcha token
|
||||||
|
6. Creates patient + lead + appointment in the platform
|
||||||
|
7. Shows confirmation with reference number
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## How Lead Capture Works
|
||||||
|
|
||||||
|
1. Visitor opens widget → Contact tab
|
||||||
|
2. Enters name, phone, interest, optional message
|
||||||
|
3. `POST /api/widget/lead` with captcha token
|
||||||
|
4. Creates lead with source "Website Widget"
|
||||||
|
5. Shows success confirmation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lead Deduplication
|
||||||
|
|
||||||
|
All three flows (chat, book, contact) use `CallerResolutionService` to find existing leads by phone number. If a visitor chats, then books, then contacts — all within 24 hours — they create ONE lead, not three. Activities are appended to the same lead.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## reCAPTCHA Protection
|
||||||
|
|
||||||
|
- Booking and lead endpoints use reCAPTCHA v3 (invisible — no user friction)
|
||||||
|
- Chat endpoints do NOT use reCAPTCHA (session already verified by name+phone gate)
|
||||||
|
- reCAPTCHA site key returned by `/api/widget/init`
|
||||||
|
- Server validates tokens via Google reCAPTCHA API
|
||||||
|
- Captcha can be bypassed for webhooks using `captchaToken: "webhook-bypass"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Branding & Theming
|
||||||
|
|
||||||
|
The widget pulls branding from the sidecar theme config:
|
||||||
|
- **Hospital name** — displayed in the widget header
|
||||||
|
- **Logo** — shown in the header
|
||||||
|
- **Brand color** — applied to buttons, links, active states
|
||||||
|
- **Location** — shown under the hospital name
|
||||||
|
|
||||||
|
Configure via: `https://ramaiah.engage.healix360.net/settings` → Theme section
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Widget Source Code
|
||||||
|
|
||||||
|
| Location | Description |
|
||||||
|
|---|---|
|
||||||
|
| `packages/widget-src/` | Preact source (chat.tsx, booking.tsx, contact.tsx, api.ts, etc.) |
|
||||||
|
| `public/widget.js` | Compiled IIFE bundle (served by NestJS static assets) |
|
||||||
|
| `src/widget/` | Backend API (controller, service, chat service, key guard) |
|
||||||
|
| `src/config/widget-keys.service.ts` | Key generation + HMAC validation |
|
||||||
|
| `src/config/widget-config.service.ts` | Config file management (data/widget.json) |
|
||||||
|
|
||||||
|
### Rebuilding widget.js
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd packages/widget-src
|
||||||
|
npm install
|
||||||
|
npm run build
|
||||||
|
# Output goes to ../../public/widget.js (configured in vite.config.ts)
|
||||||
|
```
|
||||||
|
|
||||||
|
After rebuilding:
|
||||||
|
1. Commit `public/widget.js`
|
||||||
|
2. Build Docker image and deploy sidecar
|
||||||
|
3. Widget auto-updates on next page load (1h cache)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Deployment Checklist
|
||||||
|
|
||||||
|
### First-time setup on a new tenant:
|
||||||
|
|
||||||
|
1. **Sidecar serves widget.js** — verify `https://{tenant}.engage.healix360.net/widget.js` returns JS, not HTML
|
||||||
|
2. **Caddy routing** — `/widget.js` must route to sidecar, not frontend. Add to the `@api path` matcher in Caddyfile:
|
||||||
|
```
|
||||||
|
@api path /api/* /widget.js /graphql /auth/* ...
|
||||||
|
```
|
||||||
|
3. **Widget config exists** — `GET /api/config/widget` should return `{enabled: true, key: "..."}`
|
||||||
|
4. **Generate key if needed** — `POST /api/widget/keys/generate`
|
||||||
|
5. **Set allowed origins** (for production) — `PUT /api/config/widget` with `allowedOrigins: ["https://hospital.com"]`
|
||||||
|
6. **Test embed** — paste the `<script>` tag into a test HTML page and verify the widget appears
|
||||||
|
7. **Verify AI** — start a chat, confirm AI responds (requires `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` in sidecar env)
|
||||||
|
|
||||||
|
### Current deployment (Ramaiah):
|
||||||
|
|
||||||
|
| Item | Value |
|
||||||
|
|---|---|
|
||||||
|
| Widget URL | `https://ramaiah.engage.healix360.net/widget.js` |
|
||||||
|
| Site Key | `956018d178194fb9.313657fbc8a912b9cf8c93b9a51dfb209022fcd9910bd5abc7aa16dfaacf98a3` |
|
||||||
|
| Site ID | `956018d178194fb9` |
|
||||||
|
| Allowed Origins | Empty (test mode — any origin) |
|
||||||
|
| Login Page Widget | Enabled |
|
||||||
|
| reCAPTCHA | Configured (key returned by `/api/widget/init`) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Widget doesn't appear
|
||||||
|
1. Check browser console for errors
|
||||||
|
2. Verify `VITE_API_URL` in the frontend build points to the sidecar URL (not `localhost`)
|
||||||
|
3. Verify `/api/config/widget` returns `enabled: true` and `embed.loginPage: true`
|
||||||
|
4. Verify `/widget.js` returns actual JavaScript (not HTML from the frontend catch-all)
|
||||||
|
|
||||||
|
### "leadId required" error
|
||||||
|
The chat requires a `chat-start` call first (name + phone → leadId). If the widget skips this step, it's using an old `widget.js`. Clear browser cache or deploy the correct version from commit `aa41a2a`.
|
||||||
|
|
||||||
|
### Chat returns "AI not configured"
|
||||||
|
Missing `OPENAI_API_KEY` or `ANTHROPIC_API_KEY` in sidecar environment. Check with:
|
||||||
|
```bash
|
||||||
|
docker exec sidecar-ramaiah env | grep -i 'OPENAI\|ANTHROPIC\|AI_PROVIDER'
|
||||||
|
```
|
||||||
|
|
||||||
|
### CORS errors
|
||||||
|
The widget key guard validates the request origin against `allowedOrigins`. If empty, any origin is allowed. If set, the host website's domain must be in the list.
|
||||||
|
|
||||||
|
### Widget shows on login page but not on hospital website
|
||||||
|
The login page injection code is in `helix-engage/src/pages/login.tsx`. For external hospital websites, the embed snippet must be manually added to their HTML. There's no automatic injection for third-party sites.
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
"collection": "@nestjs/schematics",
|
"collection": "@nestjs/schematics",
|
||||||
"sourceRoot": "src",
|
"sourceRoot": "src",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"deleteOutDir": true
|
"deleteOutDir": true,
|
||||||
|
"assets": [
|
||||||
|
{ "include": "messaging/flow/default-flows/*.json", "watchAssets": true }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
188
package-lock.json
generated
188
package-lock.json
generated
@@ -21,11 +21,13 @@
|
|||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/platform-socket.io": "^11.1.17",
|
"@nestjs/platform-socket.io": "^11.1.17",
|
||||||
"@nestjs/websockets": "^11.1.17",
|
"@nestjs/websockets": "^11.1.17",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"ai": "^6.0.116",
|
"ai": "^6.0.116",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
"json-rules-engine": "^6.6.0",
|
"json-rules-engine": "^6.6.0",
|
||||||
"kafkajs": "^2.2.4",
|
"kafkajs": "^2.2.4",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
@@ -5160,6 +5162,15 @@
|
|||||||
"integrity": "sha512-MIiyZI4/MK9UGUXWt0jJcCZhVw7YdhBuTOuqP/BjuLDLZ2PmmViMIQgZiWxtaMicQfAz/kMrZ5T7PKxFSkTeUA==",
|
"integrity": "sha512-MIiyZI4/MK9UGUXWt0jJcCZhVw7YdhBuTOuqP/BjuLDLZ2PmmViMIQgZiWxtaMicQfAz/kMrZ5T7PKxFSkTeUA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/qrcode": {
|
||||||
|
"version": "1.5.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||||
|
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/qs": {
|
"node_modules/@types/qs": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.0.tgz",
|
||||||
@@ -6223,7 +6234,6 @@
|
|||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -6233,7 +6243,6 @@
|
|||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-convert": "^2.0.1"
|
"color-convert": "^2.0.1"
|
||||||
@@ -6728,7 +6737,6 @@
|
|||||||
"version": "5.3.1",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -7013,7 +7021,6 @@
|
|||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
@@ -7026,7 +7033,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/colorette": {
|
"node_modules/colorette": {
|
||||||
@@ -7266,6 +7272,15 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decamelize": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dedent": {
|
"node_modules/dedent": {
|
||||||
"version": "1.7.2",
|
"version": "1.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
|
||||||
@@ -7434,6 +7449,12 @@
|
|||||||
"node": ">=0.3.1"
|
"node": ">=0.3.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dijkstrajs": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/dotenv": {
|
"node_modules/dotenv": {
|
||||||
"version": "17.2.3",
|
"version": "17.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.2.3.tgz",
|
||||||
@@ -7533,7 +7554,6 @@
|
|||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/encodeurl": {
|
"node_modules/encodeurl": {
|
||||||
@@ -8656,7 +8676,6 @@
|
|||||||
"version": "2.0.5",
|
"version": "2.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "6.* || 8.* || >= 10.*"
|
"node": "6.* || 8.* || >= 10.*"
|
||||||
@@ -9255,7 +9274,6 @@
|
|||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -11240,7 +11258,6 @@
|
|||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
@@ -11298,7 +11315,6 @@
|
|||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
@@ -11657,6 +11673,15 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pngjs": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@@ -11822,6 +11847,127 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/qrcode": {
|
||||||
|
"version": "1.5.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||||
|
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dijkstrajs": "^1.0.1",
|
||||||
|
"pngjs": "^5.0.0",
|
||||||
|
"yargs": "^15.3.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"qrcode": "bin/qrcode"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.13.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/cliui": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"strip-ansi": "^6.0.0",
|
||||||
|
"wrap-ansi": "^6.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/find-up": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"locate-path": "^5.0.0",
|
||||||
|
"path-exists": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/locate-path": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-locate": "^4.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/p-limit": {
|
||||||
|
"version": "2.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||||
|
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-try": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/p-locate": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"p-limit": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/y18n": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs": {
|
||||||
|
"version": "15.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||||
|
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cliui": "^6.0.0",
|
||||||
|
"decamelize": "^1.2.0",
|
||||||
|
"find-up": "^4.1.0",
|
||||||
|
"get-caller-file": "^2.0.1",
|
||||||
|
"require-directory": "^2.1.1",
|
||||||
|
"require-main-filename": "^2.0.0",
|
||||||
|
"set-blocking": "^2.0.0",
|
||||||
|
"string-width": "^4.2.0",
|
||||||
|
"which-module": "^2.0.0",
|
||||||
|
"y18n": "^4.0.0",
|
||||||
|
"yargs-parser": "^18.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/qrcode/node_modules/yargs-parser": {
|
||||||
|
"version": "18.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||||
|
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"camelcase": "^5.0.0",
|
||||||
|
"decamelize": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/qs": {
|
"node_modules/qs": {
|
||||||
"version": "6.15.0",
|
"version": "6.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz",
|
||||||
@@ -11954,7 +12100,6 @@
|
|||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
@@ -11984,6 +12129,12 @@
|
|||||||
"node": ">=8.6.0"
|
"node": ">=8.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-main-filename": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@@ -12269,6 +12420,12 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/set-blocking": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/setprototypeof": {
|
"node_modules/setprototypeof": {
|
||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||||
@@ -12676,7 +12833,6 @@
|
|||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"emoji-regex": "^8.0.0",
|
"emoji-regex": "^8.0.0",
|
||||||
@@ -12707,7 +12863,6 @@
|
|||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-regex": "^5.0.1"
|
"ansi-regex": "^5.0.1"
|
||||||
@@ -13929,6 +14084,12 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/which-module": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
@@ -13950,7 +14111,6 @@
|
|||||||
"version": "6.2.0",
|
"version": "6.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ansi-styles": "^4.0.0",
|
"ansi-styles": "^4.0.0",
|
||||||
|
|||||||
@@ -32,11 +32,13 @@
|
|||||||
"@nestjs/platform-express": "^11.0.1",
|
"@nestjs/platform-express": "^11.0.1",
|
||||||
"@nestjs/platform-socket.io": "^11.1.17",
|
"@nestjs/platform-socket.io": "^11.1.17",
|
||||||
"@nestjs/websockets": "^11.1.17",
|
"@nestjs/websockets": "^11.1.17",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"ai": "^6.0.116",
|
"ai": "^6.0.116",
|
||||||
"axios": "^1.13.6",
|
"axios": "^1.13.6",
|
||||||
"ioredis": "^5.10.1",
|
"ioredis": "^5.10.1",
|
||||||
"json-rules-engine": "^6.6.0",
|
"json-rules-engine": "^6.6.0",
|
||||||
"kafkajs": "^2.2.4",
|
"kafkajs": "^2.2.4",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"socket.io": "^4.8.3",
|
"socket.io": "^4.8.3",
|
||||||
|
|||||||
18
packages/widget-src/package.json
Normal file
18
packages/widget-src/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "helix-engage-widget",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"preact": "^10.25.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@preact/preset-vite": "^2.9.0",
|
||||||
|
"typescript": "^5.7.0",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
57
packages/widget-src/src/api.ts
Normal file
57
packages/widget-src/src/api.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import type { WidgetConfig, Doctor, TimeSlot } from './types';
|
||||||
|
|
||||||
|
let baseUrl = '';
|
||||||
|
let widgetKey = '';
|
||||||
|
|
||||||
|
export const initApi = (url: string, key: string) => {
|
||||||
|
baseUrl = url;
|
||||||
|
widgetKey = key;
|
||||||
|
};
|
||||||
|
|
||||||
|
const headers = () => ({
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Widget-Key': widgetKey,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const fetchInit = async (): Promise<WidgetConfig> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/init?key=${widgetKey}`);
|
||||||
|
if (!res.ok) throw new Error('Widget init failed');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchDoctors = async (): Promise<Doctor[]> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/doctors?key=${widgetKey}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to load doctors');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const fetchSlots = async (doctorId: string, date: string): Promise<TimeSlot[]> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/slots?key=${widgetKey}&doctorId=${doctorId}&date=${date}`);
|
||||||
|
if (!res.ok) throw new Error('Failed to load slots');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const submitBooking = async (data: any): Promise<{ appointmentId: string; reference: string }> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/book?key=${widgetKey}`, {
|
||||||
|
method: 'POST', headers: headers(), body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Booking failed');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const submitLead = async (data: any): Promise<{ leadId: string }> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/lead?key=${widgetKey}`, {
|
||||||
|
method: 'POST', headers: headers(), body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('Submission failed');
|
||||||
|
return res.json();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const streamChat = async (messages: any[], captchaToken?: string): Promise<ReadableStream> => {
|
||||||
|
const res = await fetch(`${baseUrl}/api/widget/chat?key=${widgetKey}`, {
|
||||||
|
method: 'POST', headers: headers(),
|
||||||
|
body: JSON.stringify({ messages, captchaToken }),
|
||||||
|
});
|
||||||
|
if (!res.ok || !res.body) throw new Error('Chat failed');
|
||||||
|
return res.body;
|
||||||
|
};
|
||||||
199
packages/widget-src/src/booking.tsx
Normal file
199
packages/widget-src/src/booking.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
import { fetchDoctors, fetchSlots, submitBooking } from './api';
|
||||||
|
import type { Doctor, TimeSlot } from './types';
|
||||||
|
|
||||||
|
type Step = 'department' | 'doctor' | 'datetime' | 'details' | 'success';
|
||||||
|
|
||||||
|
export const Booking = () => {
|
||||||
|
const [step, setStep] = useState<Step>('department');
|
||||||
|
const [doctors, setDoctors] = useState<Doctor[]>([]);
|
||||||
|
const [departments, setDepartments] = useState<string[]>([]);
|
||||||
|
const [selectedDept, setSelectedDept] = useState('');
|
||||||
|
const [selectedDoctor, setSelectedDoctor] = useState<Doctor | null>(null);
|
||||||
|
const [selectedDate, setSelectedDate] = useState('');
|
||||||
|
const [slots, setSlots] = useState<TimeSlot[]>([]);
|
||||||
|
const [selectedSlot, setSelectedSlot] = useState('');
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [complaint, setComplaint] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [reference, setReference] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchDoctors().then(docs => {
|
||||||
|
setDoctors(docs);
|
||||||
|
setDepartments([...new Set(docs.map(d => d.department).filter(Boolean))]);
|
||||||
|
}).catch(() => setError('Failed to load doctors'));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const filteredDoctors = selectedDept ? doctors.filter(d => d.department === selectedDept) : [];
|
||||||
|
|
||||||
|
const handleDoctorSelect = (doc: Doctor) => {
|
||||||
|
setSelectedDoctor(doc);
|
||||||
|
setSelectedDate(new Date().toISOString().split('T')[0]);
|
||||||
|
setStep('datetime');
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedDoctor && selectedDate) {
|
||||||
|
fetchSlots(selectedDoctor.id, selectedDate).then(setSlots).catch(() => {});
|
||||||
|
}
|
||||||
|
}, [selectedDoctor, selectedDate]);
|
||||||
|
|
||||||
|
const handleBook = async () => {
|
||||||
|
if (!selectedDoctor || !selectedSlot || !name || !phone) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const scheduledAt = `${selectedDate}T${selectedSlot}:00`;
|
||||||
|
const result = await submitBooking({
|
||||||
|
departmentId: selectedDept,
|
||||||
|
doctorId: selectedDoctor.id,
|
||||||
|
scheduledAt,
|
||||||
|
patientName: name,
|
||||||
|
patientPhone: phone,
|
||||||
|
chiefComplaint: complaint,
|
||||||
|
captchaToken: 'dev-bypass',
|
||||||
|
});
|
||||||
|
setReference(result.reference);
|
||||||
|
setStep('success');
|
||||||
|
} catch {
|
||||||
|
setError('Booking failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stepIndex = { department: 0, doctor: 1, datetime: 2, details: 3, success: 4 };
|
||||||
|
const currentStep = stepIndex[step];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{step !== 'success' && (
|
||||||
|
<div class="widget-steps">
|
||||||
|
{[0, 1, 2, 3].map(i => (
|
||||||
|
<div key={i} class={`widget-step ${i < currentStep ? 'done' : i === currentStep ? 'active' : ''}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div style={{ color: '#dc2626', fontSize: '12px', marginBottom: '8px' }}>{error}</div>}
|
||||||
|
|
||||||
|
{step === 'department' && (
|
||||||
|
<div>
|
||||||
|
<div class="widget-label" style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600 }}>Select Department</div>
|
||||||
|
{departments.map(dept => (
|
||||||
|
<button
|
||||||
|
key={dept}
|
||||||
|
class="widget-btn widget-btn-secondary"
|
||||||
|
style={{ marginBottom: '6px', textAlign: 'left' }}
|
||||||
|
onClick={() => { setSelectedDept(dept); setStep('doctor'); }}
|
||||||
|
>
|
||||||
|
{dept.replace(/_/g, ' ')}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'doctor' && (
|
||||||
|
<div>
|
||||||
|
<div class="widget-label" style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600 }}>
|
||||||
|
Select Doctor — {selectedDept.replace(/_/g, ' ')}
|
||||||
|
</div>
|
||||||
|
{filteredDoctors.map(doc => (
|
||||||
|
<button
|
||||||
|
key={doc.id}
|
||||||
|
class="widget-btn widget-btn-secondary"
|
||||||
|
style={{ marginBottom: '6px', textAlign: 'left' }}
|
||||||
|
onClick={() => handleDoctorSelect(doc)}
|
||||||
|
>
|
||||||
|
<div style={{ fontWeight: 600 }}>{doc.name}</div>
|
||||||
|
<div style={{ fontSize: '11px', color: '#6b7280' }}>
|
||||||
|
{doc.visitingHours ?? ''} {doc.clinic?.clinicName ? `• ${doc.clinic.clinicName}` : ''}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button class="widget-btn widget-btn-secondary" style={{ marginTop: '8px' }} onClick={() => setStep('department')}>
|
||||||
|
← Back
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'datetime' && (
|
||||||
|
<div>
|
||||||
|
<div class="widget-label" style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600 }}>
|
||||||
|
{selectedDoctor?.name} — Pick Date & Time
|
||||||
|
</div>
|
||||||
|
<div class="widget-field">
|
||||||
|
<label class="widget-label">Date</label>
|
||||||
|
<input
|
||||||
|
class="widget-input"
|
||||||
|
type="date"
|
||||||
|
value={selectedDate}
|
||||||
|
min={new Date().toISOString().split('T')[0]}
|
||||||
|
onInput={(e: any) => { setSelectedDate(e.target.value); setSelectedSlot(''); }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{slots.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label class="widget-label">Available Slots</label>
|
||||||
|
<div class="widget-slots">
|
||||||
|
{slots.map(s => (
|
||||||
|
<button
|
||||||
|
key={s.time}
|
||||||
|
class={`widget-slot ${s.time === selectedSlot ? 'selected' : ''} ${!s.available ? 'unavailable' : ''}`}
|
||||||
|
onClick={() => s.available && setSelectedSlot(s.time)}
|
||||||
|
disabled={!s.available}
|
||||||
|
>
|
||||||
|
{s.time}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
|
||||||
|
<button class="widget-btn widget-btn-secondary" style={{ flex: 1 }} onClick={() => setStep('doctor')}>← Back</button>
|
||||||
|
<button class="widget-btn" style={{ flex: 1 }} disabled={!selectedSlot} onClick={() => setStep('details')}>Next →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'details' && (
|
||||||
|
<div>
|
||||||
|
<div class="widget-label" style={{ marginBottom: '8px', fontSize: '13px', fontWeight: 600 }}>Your Details</div>
|
||||||
|
<div class="widget-field">
|
||||||
|
<label class="widget-label">Full Name *</label>
|
||||||
|
<input class="widget-input" placeholder="Your name" value={name} onInput={(e: any) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div class="widget-field">
|
||||||
|
<label class="widget-label">Phone Number *</label>
|
||||||
|
<input class="widget-input" placeholder="+91 9876543210" value={phone} onInput={(e: any) => setPhone(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div class="widget-field">
|
||||||
|
<label class="widget-label">Chief Complaint</label>
|
||||||
|
<textarea class="widget-input widget-textarea" placeholder="Describe your concern..." value={complaint} onInput={(e: any) => setComplaint(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '8px' }}>
|
||||||
|
<button class="widget-btn widget-btn-secondary" style={{ flex: 1 }} onClick={() => setStep('datetime')}>← Back</button>
|
||||||
|
<button class="widget-btn" style={{ flex: 1 }} disabled={!name || !phone || loading} onClick={handleBook}>
|
||||||
|
{loading ? 'Booking...' : 'Book Appointment'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'success' && (
|
||||||
|
<div class="widget-success">
|
||||||
|
<div class="widget-success-icon">✅</div>
|
||||||
|
<div class="widget-success-title">Appointment Booked!</div>
|
||||||
|
<div class="widget-success-text">
|
||||||
|
Reference: <strong>{reference}</strong><br />
|
||||||
|
{selectedDoctor?.name} • {selectedDate} at {selectedSlot}<br /><br />
|
||||||
|
We'll send a confirmation SMS to your phone.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
94
packages/widget-src/src/chat.tsx
Normal file
94
packages/widget-src/src/chat.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'preact/hooks';
|
||||||
|
import { streamChat } from './api';
|
||||||
|
import type { ChatMessage } from './types';
|
||||||
|
|
||||||
|
const QUICK_ACTIONS = [
|
||||||
|
'Doctor availability',
|
||||||
|
'Clinic timings',
|
||||||
|
'Book appointment',
|
||||||
|
'Health packages',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const Chat = () => {
|
||||||
|
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||||
|
const [input, setInput] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [messages]);
|
||||||
|
|
||||||
|
const sendMessage = async (text: string) => {
|
||||||
|
if (!text.trim() || loading) return;
|
||||||
|
|
||||||
|
const userMsg: ChatMessage = { role: 'user', content: text.trim() };
|
||||||
|
const updated = [...messages, userMsg];
|
||||||
|
setMessages(updated);
|
||||||
|
setInput('');
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await streamChat(updated);
|
||||||
|
const reader = stream.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let assistantText = '';
|
||||||
|
|
||||||
|
setMessages([...updated, { role: 'assistant', content: '' }]);
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
assistantText += decoder.decode(value, { stream: true });
|
||||||
|
setMessages([...updated, { role: 'assistant', content: assistantText }]);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setMessages([...updated, { role: 'assistant', content: 'Sorry, I encountered an error. Please try again.' }]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||||
|
<div class="chat-messages" ref={scrollRef}>
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||||
|
<div style={{ fontSize: '24px', marginBottom: '8px' }}>👋</div>
|
||||||
|
<div style={{ fontSize: '14px', fontWeight: 600, color: '#1f2937', marginBottom: '4px' }}>
|
||||||
|
How can we help you?
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#6b7280', marginBottom: '16px' }}>
|
||||||
|
Ask about doctors, clinics, packages, or book an appointment.
|
||||||
|
</div>
|
||||||
|
<div class="quick-actions">
|
||||||
|
{QUICK_ACTIONS.map(q => (
|
||||||
|
<button key={q} class="quick-action" onClick={() => sendMessage(q)}>{q}</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{messages.map((msg, i) => (
|
||||||
|
<div key={i} class={`chat-msg ${msg.role}`}>
|
||||||
|
<div class="chat-bubble">{msg.content || '...'}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div class="chat-input-row">
|
||||||
|
<input
|
||||||
|
class="widget-input chat-input"
|
||||||
|
placeholder="Type a message..."
|
||||||
|
value={input}
|
||||||
|
onInput={(e: any) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e: any) => e.key === 'Enter' && sendMessage(input)}
|
||||||
|
disabled={loading}
|
||||||
|
/>
|
||||||
|
<button class="chat-send" onClick={() => sendMessage(input)} disabled={loading || !input.trim()}>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
85
packages/widget-src/src/contact.tsx
Normal file
85
packages/widget-src/src/contact.tsx
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { useState } from 'preact/hooks';
|
||||||
|
import { submitLead } from './api';
|
||||||
|
|
||||||
|
export const Contact = () => {
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [phone, setPhone] = useState('');
|
||||||
|
const [interest, setInterest] = useState('');
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [success, setSuccess] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!name.trim() || !phone.trim()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
await submitLead({
|
||||||
|
name: name.trim(),
|
||||||
|
phone: phone.trim(),
|
||||||
|
interest: interest.trim() || undefined,
|
||||||
|
message: message.trim() || undefined,
|
||||||
|
captchaToken: 'dev-bypass',
|
||||||
|
});
|
||||||
|
setSuccess(true);
|
||||||
|
} catch {
|
||||||
|
setError('Submission failed. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
return (
|
||||||
|
<div class="widget-success">
|
||||||
|
<div class="widget-success-icon">🙏</div>
|
||||||
|
<div class="widget-success-title">Thank you!</div>
|
||||||
|
<div class="widget-success-text">
|
||||||
|
An agent will call you shortly on {phone}.<br />
|
||||||
|
We typically respond within 30 minutes during business hours.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: '13px', fontWeight: 600, color: '#1f2937', marginBottom: '12px' }}>
|
||||||
|
Get in touch
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '12px', color: '#6b7280', marginBottom: '16px' }}>
|
||||||
|
Leave your details and we'll call you back.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div style={{ color: '#dc2626', fontSize: '12px', marginBottom: '8px' }}>{error}</div>}
|
||||||
|
|
||||||
|
<div class="widget-field">
|
||||||
|
<label class="widget-label">Full Name *</label>
|
||||||
|
<input class="widget-input" placeholder="Your name" value={name} onInput={(e: any) => setName(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div class="widget-field">
|
||||||
|
<label class="widget-label">Phone Number *</label>
|
||||||
|
<input class="widget-input" placeholder="+91 9876543210" value={phone} onInput={(e: any) => setPhone(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div class="widget-field">
|
||||||
|
<label class="widget-label">Interested In</label>
|
||||||
|
<select class="widget-select" value={interest} onChange={(e: any) => setInterest(e.target.value)}>
|
||||||
|
<option value="">Select (optional)</option>
|
||||||
|
<option value="Consultation">General Consultation</option>
|
||||||
|
<option value="Health Checkup">Health Checkup</option>
|
||||||
|
<option value="Surgery">Surgery</option>
|
||||||
|
<option value="Second Opinion">Second Opinion</option>
|
||||||
|
<option value="Other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="widget-field">
|
||||||
|
<label class="widget-label">Message</label>
|
||||||
|
<textarea class="widget-input widget-textarea" placeholder="How can we help? (optional)" value={message} onInput={(e: any) => setMessage(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<button class="widget-btn" disabled={!name.trim() || !phone.trim() || loading} onClick={handleSubmit}>
|
||||||
|
{loading ? 'Sending...' : 'Send Message'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
27
packages/widget-src/src/icons.ts
Normal file
27
packages/widget-src/src/icons.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// FontAwesome Pro 6.7.2 Duotone SVGs — bundled as inline strings
|
||||||
|
// License: https://fontawesome.com/license (Commercial License)
|
||||||
|
|
||||||
|
export const icons = {
|
||||||
|
chat: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M0 64C0 28.7 28.7 0 64 0L448 0c35.3 0 64 28.7 64 64l0 288c0 35.3-28.7 64-64 64l-138.7 0L185.6 508.8c-4.8 3.6-11.3 4.2-16.8 1.5s-8.8-8.2-8.8-14.3l0-80-96 0c-35.3 0-64-28.7-64-64L0 64zM96 208a32 32 0 1 0 64 0 32 32 0 1 0 -64 0zm128 0a32 32 0 1 0 64 0 32 32 0 1 0 -64 0zm128 0a32 32 0 1 0 64 0 32 32 0 1 0 -64 0z"/><path class="fa-primary" d="M96 208a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm128 0a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zm160-32a32 32 0 1 1 0 64 32 32 0 1 1 0-64z"/></svg>`,
|
||||||
|
|
||||||
|
calendar: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M0 192l448 0 0 272c0 26.5-21.5 48-48 48L48 512c-26.5 0-48-21.5-48-48L0 192zM119 319c-9.4 9.4-9.4 24.6 0 33.9l64 64c4.7 4.7 10.8 7 17 7s12.3-2.3 17-7L329 305c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-95 95-47-47c-9.4-9.4-24.6-9.4-33.9 0z"/><path class="fa-primary" d="M128 0C110.3 0 96 14.3 96 32l0 32L48 64C21.5 64 0 85.5 0 112l0 80 448 0 0-80c0-26.5-21.5-48-48-48l-48 0 0-32c0-17.7-14.3-32-32-32s-32 14.3-32 32l0 32L160 64l0-32c0-17.7-14.3-32-32-32zM329 305c9.4-9.4 9.4-24.6 0-33.9s-24.6-9.4-33.9 0l-95 95-47-47c-9.4-9.4-24.6-9.4-33.9 0s-9.4 24.6 0 33.9l64 64c9.4 9.4 24.6 9.4 33.9 0L329 305z"/></svg>`,
|
||||||
|
|
||||||
|
phone: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M0 64C0 311.4 200.6 512 448 512c18 0 33.8-12.1 38.6-29.5l24-88c1-3.5 1.4-7 1.4-10.5c0-15.8-9.4-30.6-24.6-36.9l-96-40c-16.3-6.8-35.2-2.1-46.3 11.6L304.7 368C234.3 334.7 177.3 277.7 144 207.3L193.3 167c13.7-11.2 18.4-30 11.6-46.3l-40-96C158.6 9.4 143.8 0 128 0c-3.5 0-7 .5-10.5 1.4l-88 24C12.1 30.2 0 46 0 64z"/><path class="fa-primary" d="M295 217c-9.4-9.4-9.4-24.6 0-33.9l135-135L384 48c-13.3 0-24-10.7-24-24s10.7-24 24-24L488 0c13.3 0 24 10.7 24 24l0 104c0 13.3-10.7 24-24 24s-24-10.7-24-24l0-46.1L329 217c-9.4 9.4-24.6 9.4-33.9 0z"/></svg>`,
|
||||||
|
|
||||||
|
send: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M1.4 72.3c0 6.1 1.4 12.4 4.7 18.6l70 134.6c63.3 7.9 126.6 15.8 190 23.7c3.4 .4 6 3.3 6 6.7s-2.6 6.3-6 6.7l-190 23.7L6.1 421.1c-14.6 28.1 7.3 58.6 35.2 58.6c5.3 0 10.8-1.1 16.3-3.5L492.9 285.3c11.6-5.1 19.1-16.6 19.1-29.3s-7.5-24.2-19.1-29.3L57.6 35.8C29.5 23.5 1.4 45.6 1.4 72.3z"/><path class="fa-primary" d="M76.1 286.5l190-23.7c3.4-.4 6-3.3 6-6.7s-2.6-6.3-6-6.7l-190-23.7 8.2 15.7c4.8 9.3 4.8 20.3 0 29.5l-8.2 15.7z"/></svg>`,
|
||||||
|
|
||||||
|
close: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M297.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L237.3 256 342.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L192 210.7 86.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L146.7 256 41.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L192 301.3 297.4 406.6z"/></svg>`,
|
||||||
|
|
||||||
|
check: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M0 256a256 256 0 1 0 512 0A256 256 0 1 0 0 256zm136 0c0-6.1 2.3-12.3 7-17c9.4-9.4 24.6-9.4 33.9 0l47 47c37-37 74-74 111-111c4.7-4.7 10.8-7 17-7s12.3 2.3 17 7c2.3 2.3 4.1 5 5.3 7.9c.6 1.5 1 2.9 1.3 4.4c.2 1.1 .3 2.2 .3 2.2c.1 1.2 .1 1.2 .1 2.5c-.1 1.5-.1 1.9-.1 2.3c-.1 .7-.2 1.5-.3 2.2c-.3 1.5-.7 3-1.3 4.4c-1.2 2.9-2.9 5.6-5.3 7.9c-42.7 42.7-85.3 85.3-128 128c-4.7 4.7-10.8 7-17 7s-12.3-2.3-17-7c-21.3-21.3-42.7-42.7-64-64c-4.7-4.7-7-10.8-7-17z"/><path class="fa-primary" d="M369 175c9.4 9.4 9.4 24.6 0 33.9L241 337c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L335 175c9.4-9.4 24.6-9.4 33.9 0z"/></svg>`,
|
||||||
|
|
||||||
|
sparkles: `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><defs><style>.fa-secondary{opacity:.4}</style></defs><path class="fa-secondary" d="M320 96c0 4.8 3 9.1 7.5 10.8L384 128l21.2 56.5c1.7 4.5 6 7.5 10.8 7.5s9.1-3 10.8-7.5L448 128l56.5-21.2c4.5-1.7 7.5-6 7.5-10.8s-3-9.1-7.5-10.8L448 64 426.8 7.5C425.1 3 420.8 0 416 0s-9.1 3-10.8 7.5L384 64 327.5 85.2c-4.5 1.7-7.5 6-7.5 10.8zm0 320c0 4.8 3 9.1 7.5 10.8L384 448l21.2 56.5c1.7 4.5 6 7.5 10.8 7.5s9.1-3 10.8-7.5L448 448l56.5-21.2c4.5-1.7 7.5-6 7.5-10.8s-3-9.1-7.5-10.8L448 384l-21.2-56.5c-1.7-4.5-6-7.5-10.8-7.5s-9.1 3-10.8 7.5L384 384l-56.5 21.2c-4.5 1.7-7.5 6-7.5 10.8z"/><path class="fa-primary" d="M205.1 73.3c-2.6-5.7-8.3-9.3-14.5-9.3s-11.9 3.6-14.5 9.3L123.4 187.4 9.3 240C3.6 242.6 0 248.3 0 254.6s3.6 11.9 9.3 14.5l114.1 52.7L176 435.8c2.6 5.7 8.3 9.3 14.5 9.3s11.9-3.6 14.5-9.3l52.7-114.1 114.1-52.7c5.7-2.6 9.3-8.3 9.3-14.5s-3.6-11.9-9.3-14.5L257.8 187.4 205.1 73.3z"/></svg>`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Render an icon as an HTML string with given size and color
|
||||||
|
export const icon = (name: keyof typeof icons, size = 16, color = 'currentColor'): string => {
|
||||||
|
const svg = icons[name];
|
||||||
|
return svg
|
||||||
|
.replace('<svg', `<svg width="${size}" height="${size}" style="fill:${color};vertical-align:middle;"`)
|
||||||
|
.replace(/\.fa-primary/g, '.p')
|
||||||
|
.replace(/\.fa-secondary\{opacity:\.4\}/g, `.s{opacity:.4;fill:${color}}`);
|
||||||
|
};
|
||||||
40
packages/widget-src/src/main.tsx
Normal file
40
packages/widget-src/src/main.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { render } from 'preact';
|
||||||
|
import { initApi, fetchInit } from './api';
|
||||||
|
import { Widget } from './widget';
|
||||||
|
import type { WidgetConfig } from './types';
|
||||||
|
|
||||||
|
const init = async () => {
|
||||||
|
const script = document.querySelector('script[data-key]') as HTMLScriptElement | null;
|
||||||
|
if (!script) { console.error('[HelixWidget] Missing data-key attribute'); return; }
|
||||||
|
|
||||||
|
const key = script.getAttribute('data-key') ?? '';
|
||||||
|
const baseUrl = script.src.replace(/\/widget\.js.*$/, '');
|
||||||
|
|
||||||
|
initApi(baseUrl, key);
|
||||||
|
|
||||||
|
let config: WidgetConfig;
|
||||||
|
try {
|
||||||
|
config = await fetchInit();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[HelixWidget] Init failed:', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create shadow DOM host
|
||||||
|
const host = document.createElement('div');
|
||||||
|
host.id = 'helix-widget-host';
|
||||||
|
host.style.cssText = 'position:fixed;bottom:20px;right:20px;z-index:999999;font-family:-apple-system,sans-serif;';
|
||||||
|
document.body.appendChild(host);
|
||||||
|
|
||||||
|
const shadow = host.attachShadow({ mode: 'open' });
|
||||||
|
const mountPoint = document.createElement('div');
|
||||||
|
shadow.appendChild(mountPoint);
|
||||||
|
|
||||||
|
render(<Widget config={config} shadow={shadow} />, mountPoint);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
} else {
|
||||||
|
init();
|
||||||
|
}
|
||||||
138
packages/widget-src/src/styles.ts
Normal file
138
packages/widget-src/src/styles.ts
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
import type { WidgetConfig } from './types';
|
||||||
|
|
||||||
|
export const getStyles = (config: WidgetConfig) => `
|
||||||
|
:host { all: initial; font-family: -apple-system, 'Segoe UI', Roboto, sans-serif; }
|
||||||
|
|
||||||
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||||
|
|
||||||
|
.widget-bubble {
|
||||||
|
width: 56px; height: 56px; border-radius: 50%;
|
||||||
|
background: ${config.colors.primary}; color: #fff;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
cursor: pointer; box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
transition: transform 0.2s; border: none; outline: none;
|
||||||
|
}
|
||||||
|
.widget-bubble:hover { transform: scale(1.08); }
|
||||||
|
.widget-bubble img { width: 28px; height: 28px; border-radius: 6px; }
|
||||||
|
.widget-bubble svg { width: 24px; height: 24px; fill: currentColor; }
|
||||||
|
|
||||||
|
.widget-panel {
|
||||||
|
width: 380px; height: 520px; border-radius: 16px;
|
||||||
|
background: #fff; box-shadow: 0 8px 32px rgba(0,0,0,0.12);
|
||||||
|
display: flex; flex-direction: column; overflow: hidden;
|
||||||
|
border: 1px solid #e5e7eb; position: absolute; bottom: 68px; right: 0;
|
||||||
|
animation: slideUp 0.25s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes slideUp {
|
||||||
|
from { opacity: 0; transform: translateY(12px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-header {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 14px 16px; background: ${config.colors.primary}; color: #fff;
|
||||||
|
}
|
||||||
|
.widget-header img { width: 32px; height: 32px; border-radius: 8px; }
|
||||||
|
.widget-header-text { flex: 1; }
|
||||||
|
.widget-header-name { font-size: 14px; font-weight: 600; }
|
||||||
|
.widget-header-sub { font-size: 11px; opacity: 0.8; }
|
||||||
|
.widget-close {
|
||||||
|
background: none; border: none; color: #fff; cursor: pointer;
|
||||||
|
font-size: 18px; padding: 4px; opacity: 0.8;
|
||||||
|
}
|
||||||
|
.widget-close:hover { opacity: 1; }
|
||||||
|
|
||||||
|
.widget-tabs {
|
||||||
|
display: flex; border-bottom: 1px solid #e5e7eb; background: #fafafa;
|
||||||
|
}
|
||||||
|
.widget-tab {
|
||||||
|
flex: 1; padding: 10px 0; text-align: center; font-size: 12px;
|
||||||
|
font-weight: 500; cursor: pointer; border: none; background: none;
|
||||||
|
color: #6b7280; border-bottom: 2px solid transparent;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.widget-tab.active {
|
||||||
|
color: ${config.colors.primary}; border-bottom-color: ${config.colors.primary};
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-body { flex: 1; overflow-y: auto; padding: 16px; }
|
||||||
|
|
||||||
|
.widget-input {
|
||||||
|
width: 100%; padding: 10px 12px; border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px; font-size: 13px; outline: none;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.widget-input:focus { border-color: ${config.colors.primary}; }
|
||||||
|
.widget-textarea { resize: vertical; min-height: 60px; font-family: inherit; }
|
||||||
|
.widget-select {
|
||||||
|
width: 100%; padding: 10px 12px; border: 1px solid #d1d5db;
|
||||||
|
border-radius: 8px; font-size: 13px; background: #fff; outline: none;
|
||||||
|
}
|
||||||
|
.widget-label { font-size: 12px; font-weight: 500; color: #374151; margin-bottom: 4px; display: block; }
|
||||||
|
.widget-field { margin-bottom: 12px; }
|
||||||
|
|
||||||
|
.widget-btn {
|
||||||
|
width: 100%; padding: 10px 16px; border: none; border-radius: 8px;
|
||||||
|
font-size: 13px; font-weight: 600; cursor: pointer;
|
||||||
|
transition: opacity 0.15s; color: #fff; background: ${config.colors.primary};
|
||||||
|
}
|
||||||
|
.widget-btn:hover { opacity: 0.9; }
|
||||||
|
.widget-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.widget-btn-secondary { background: #f3f4f6; color: #374151; }
|
||||||
|
|
||||||
|
.widget-slots {
|
||||||
|
display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; margin: 8px 0;
|
||||||
|
}
|
||||||
|
.widget-slot {
|
||||||
|
padding: 8px; text-align: center; font-size: 12px; border-radius: 6px;
|
||||||
|
border: 1px solid #e5e7eb; cursor: pointer; background: #fff;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.widget-slot:hover { border-color: ${config.colors.primary}; }
|
||||||
|
.widget-slot.selected { background: ${config.colors.primary}; color: #fff; border-color: ${config.colors.primary}; }
|
||||||
|
.widget-slot.unavailable { opacity: 0.4; cursor: not-allowed; text-decoration: line-through; }
|
||||||
|
|
||||||
|
.widget-success {
|
||||||
|
text-align: center; padding: 24px 16px;
|
||||||
|
}
|
||||||
|
.widget-success-icon { font-size: 40px; margin-bottom: 12px; }
|
||||||
|
.widget-success-title { font-size: 16px; font-weight: 600; color: #059669; margin-bottom: 8px; }
|
||||||
|
.widget-success-text { font-size: 13px; color: #6b7280; }
|
||||||
|
|
||||||
|
.chat-messages { flex: 1; overflow-y: auto; padding: 12px 0; }
|
||||||
|
.chat-msg { margin-bottom: 10px; display: flex; }
|
||||||
|
.chat-msg.user { justify-content: flex-end; }
|
||||||
|
.chat-bubble {
|
||||||
|
max-width: 80%; padding: 10px 14px; border-radius: 12px;
|
||||||
|
font-size: 13px; line-height: 1.5;
|
||||||
|
}
|
||||||
|
.chat-msg.user .chat-bubble { background: ${config.colors.primary}; color: #fff; border-bottom-right-radius: 4px; }
|
||||||
|
.chat-msg.assistant .chat-bubble { background: #f3f4f6; color: #1f2937; border-bottom-left-radius: 4px; }
|
||||||
|
|
||||||
|
.chat-input-row { display: flex; gap: 8px; padding-top: 8px; border-top: 1px solid #e5e7eb; }
|
||||||
|
.chat-input { flex: 1; }
|
||||||
|
.chat-send {
|
||||||
|
width: 36px; height: 36px; border-radius: 8px;
|
||||||
|
background: ${config.colors.primary}; color: #fff;
|
||||||
|
border: none; cursor: pointer; display: flex;
|
||||||
|
align-items: center; justify-content: center; font-size: 16px;
|
||||||
|
}
|
||||||
|
.chat-send:disabled { opacity: 0.5; }
|
||||||
|
|
||||||
|
.quick-actions { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
|
||||||
|
.quick-action {
|
||||||
|
padding: 6px 12px; border-radius: 16px; font-size: 11px;
|
||||||
|
border: 1px solid ${config.colors.primary}; color: ${config.colors.primary};
|
||||||
|
background: ${config.colors.primaryLight}; cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.quick-action:hover { background: ${config.colors.primary}; color: #fff; }
|
||||||
|
|
||||||
|
.widget-steps { display: flex; gap: 4px; margin-bottom: 16px; }
|
||||||
|
.widget-step {
|
||||||
|
flex: 1; height: 3px; border-radius: 2px; background: #e5e7eb;
|
||||||
|
}
|
||||||
|
.widget-step.active { background: ${config.colors.primary}; }
|
||||||
|
.widget-step.done { background: #059669; }
|
||||||
|
`;
|
||||||
26
packages/widget-src/src/types.ts
Normal file
26
packages/widget-src/src/types.ts
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
export type WidgetConfig = {
|
||||||
|
brand: { name: string; logo: string };
|
||||||
|
colors: { primary: string; primaryLight: string; text: string; textLight: string };
|
||||||
|
captchaSiteKey: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Doctor = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
fullName: { firstName: string; lastName: string };
|
||||||
|
department: string;
|
||||||
|
specialty: string;
|
||||||
|
visitingHours: string;
|
||||||
|
consultationFeeNew: { amountMicros: number; currencyCode: string } | null;
|
||||||
|
clinic: { clinicName: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimeSlot = {
|
||||||
|
time: string;
|
||||||
|
available: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ChatMessage = {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
};
|
||||||
78
packages/widget-src/src/widget.tsx
Normal file
78
packages/widget-src/src/widget.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useState, useEffect } from 'preact/hooks';
|
||||||
|
import type { WidgetConfig } from './types';
|
||||||
|
import { getStyles } from './styles';
|
||||||
|
import { icon } from './icons';
|
||||||
|
import { Chat } from './chat';
|
||||||
|
import { Booking } from './booking';
|
||||||
|
import { Contact } from './contact';
|
||||||
|
|
||||||
|
type Tab = 'chat' | 'book' | 'contact';
|
||||||
|
|
||||||
|
type WidgetProps = {
|
||||||
|
config: WidgetConfig;
|
||||||
|
shadow: ShadowRoot;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Widget = ({ config, shadow }: WidgetProps) => {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [tab, setTab] = useState<Tab>('chat');
|
||||||
|
|
||||||
|
// Inject styles into shadow DOM
|
||||||
|
useEffect(() => {
|
||||||
|
const style = document.createElement('style');
|
||||||
|
style.textContent = getStyles(config);
|
||||||
|
shadow.appendChild(style);
|
||||||
|
return () => { shadow.removeChild(style); };
|
||||||
|
}, [config, shadow]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Floating bubble */}
|
||||||
|
{!open && (
|
||||||
|
<button class="widget-bubble" onClick={() => setOpen(true)}>
|
||||||
|
{config.brand.logo ? (
|
||||||
|
<img src={config.brand.logo} alt={config.brand.name} />
|
||||||
|
) : (
|
||||||
|
<svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v18l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm0 14H6l-2 2V4h16v12z"/></svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
|
{open && (
|
||||||
|
<div class="widget-panel">
|
||||||
|
{/* Header */}
|
||||||
|
<div class="widget-header">
|
||||||
|
{config.brand.logo && <img src={config.brand.logo} alt="" />}
|
||||||
|
<div class="widget-header-text">
|
||||||
|
<div class="widget-header-name">{config.brand.name}</div>
|
||||||
|
<div class="widget-header-sub">We're here to help</div>
|
||||||
|
</div>
|
||||||
|
<button class="widget-close" onClick={() => setOpen(false)}>✕</button>
|
||||||
|
{/* Icons bundled from FontAwesome Pro SVGs — static, not user input */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div class="widget-tabs">
|
||||||
|
<button class={`widget-tab ${tab === 'chat' ? 'active' : ''}`} onClick={() => setTab('chat')}>
|
||||||
|
<span innerHTML={icon('chat', 14)} /> Chat
|
||||||
|
</button>
|
||||||
|
<button class={`widget-tab ${tab === 'book' ? 'active' : ''}`} onClick={() => setTab('book')}>
|
||||||
|
<span innerHTML={icon('calendar', 14)} /> Book
|
||||||
|
</button>
|
||||||
|
<button class={`widget-tab ${tab === 'contact' ? 'active' : ''}`} onClick={() => setTab('contact')}>
|
||||||
|
<span innerHTML={icon('phone', 14)} /> Contact
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<div class="widget-body">
|
||||||
|
{tab === 'chat' && <Chat />}
|
||||||
|
{tab === 'book' && <Booking />}
|
||||||
|
{tab === 'contact' && <Contact />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
41
packages/widget-src/test.html
Normal file
41
packages/widget-src/test.html
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Global Hospital — Widget Test</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, sans-serif; max-width: 800px; margin: 0 auto; padding: 40px; color: #1f2937; }
|
||||||
|
h1 { font-size: 28px; margin-bottom: 8px; }
|
||||||
|
p { color: #6b7280; line-height: 1.6; }
|
||||||
|
.hero { background: #f0f9ff; border-radius: 12px; padding: 40px; margin: 40px 0; }
|
||||||
|
.hero h2 { color: #1e40af; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>🏥 Global Hospital, Bangalore</h1>
|
||||||
|
<p>Welcome to Global Hospital — Bangalore's leading multi-specialty healthcare provider.</p>
|
||||||
|
|
||||||
|
<div class="hero">
|
||||||
|
<h2>Book Your Appointment Online</h2>
|
||||||
|
<p>Click the chat bubble in the bottom-right corner to talk to our AI assistant, book an appointment, or request a callback.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>Our Departments</h3>
|
||||||
|
<ul>
|
||||||
|
<li>Cardiology</li>
|
||||||
|
<li>Orthopedics</li>
|
||||||
|
<li>Gynecology</li>
|
||||||
|
<li>ENT</li>
|
||||||
|
<li>General Medicine</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<p style="margin-top: 40px; font-size: 12px; color: #9ca3af;">
|
||||||
|
This is a test page for the Helix Engage website widget.
|
||||||
|
The widget loads from the sidecar and renders in a shadow DOM.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Replace SITE_KEY with the generated key -->
|
||||||
|
<script src="http://localhost:4100/widget.js" data-key="8197d39c9ad946ef.31e3b1f492a7380f77ea0c90d2f86d5d4b1ac8f70fd01423ac3d85b87d9aa511"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
14
packages/widget-src/tsconfig.json
Normal file
14
packages/widget-src/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "preact",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
22
packages/widget-src/vite.config.ts
Normal file
22
packages/widget-src/vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig } from 'vite';
|
||||||
|
import preact from '@preact/preset-vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [preact()],
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: 'src/main.tsx',
|
||||||
|
name: 'HelixWidget',
|
||||||
|
fileName: () => 'widget.js',
|
||||||
|
formats: ['iife'],
|
||||||
|
},
|
||||||
|
outDir: './dist',
|
||||||
|
emptyOutDir: false,
|
||||||
|
minify: 'esbuild',
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
inlineDynamicImports: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -773,8 +773,8 @@ export class AiChatController {
|
|||||||
undefined, auth,
|
undefined, auth,
|
||||||
);
|
);
|
||||||
const packages = pkgData.healthPackages.edges.map((e: any) => e.node);
|
const packages = pkgData.healthPackages.edges.map((e: any) => e.node);
|
||||||
|
sections.push('\n## Health Packages');
|
||||||
if (packages.length) {
|
if (packages.length) {
|
||||||
sections.push('\n## Health Packages');
|
|
||||||
for (const p of packages) {
|
for (const p of packages) {
|
||||||
const price = p.price ? `₹${p.price.amountMicros / 1_000_000}` : '';
|
const price = p.price ? `₹${p.price.amountMicros / 1_000_000}` : '';
|
||||||
const disc = p.discountedPrice?.amountMicros ? ` (discounted: ₹${p.discountedPrice.amountMicros / 1_000_000})` : '';
|
const disc = p.discountedPrice?.amountMicros ? ` (discounted: ₹${p.discountedPrice.amountMicros / 1_000_000})` : '';
|
||||||
@@ -791,6 +791,8 @@ export class AiChatController {
|
|||||||
sections.push(` Includes: ${p.inclusions}`);
|
sections.push(` Includes: ${p.inclusions}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
sections.push('No packages available.');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.warn(`Failed to fetch health packages: ${err}`);
|
this.logger.warn(`Failed to fetch health packages: ${err}`);
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { WidgetModule } from './widget/widget.module';
|
|||||||
import { TeamModule } from './team/team.module';
|
import { TeamModule } from './team/team.module';
|
||||||
import { MasterdataModule } from './masterdata/masterdata.module';
|
import { MasterdataModule } from './masterdata/masterdata.module';
|
||||||
import { LeadsModule } from './leads/leads.module';
|
import { LeadsModule } from './leads/leads.module';
|
||||||
|
import { MessagingModule } from './messaging/messaging.module';
|
||||||
import { TelephonyRegistrationService } from './telephony-registration.service';
|
import { TelephonyRegistrationService } from './telephony-registration.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -53,6 +54,7 @@ import { TelephonyRegistrationService } from './telephony-registration.service';
|
|||||||
TeamModule,
|
TeamModule,
|
||||||
MasterdataModule,
|
MasterdataModule,
|
||||||
LeadsModule,
|
LeadsModule,
|
||||||
|
MessagingModule,
|
||||||
],
|
],
|
||||||
providers: [TelephonyRegistrationService],
|
providers: [TelephonyRegistrationService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -78,6 +78,13 @@ export class CallerContextService {
|
|||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async invalidateCache(leadId: string): Promise<void> {
|
||||||
|
if (!leadId) return;
|
||||||
|
const cacheKey = `${CACHE_KEY_PREFIX}${leadId}`;
|
||||||
|
await this.session.deleteCache(cacheKey).catch(() => {});
|
||||||
|
this.logger.log(`[CALLER-CTX] Cache invalidated for ${leadId}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Fire-and-forget pre-warm — called from caller resolution
|
// Fire-and-forget pre-warm — called from caller resolution
|
||||||
// so the cache is hot when the AI stream fires seconds later.
|
// so the cache is hot when the AI stream fires seconds later.
|
||||||
prewarm(leadId: string, patientId: string, auth: string): void {
|
prewarm(leadId: string, patientId: string, auth: string): void {
|
||||||
@@ -89,19 +96,34 @@ export class CallerContextService {
|
|||||||
|
|
||||||
private async build(leadId: string, patientId: string, auth: string): Promise<CallerContext | null> {
|
private async build(leadId: string, patientId: string, auth: string): Promise<CallerContext | null> {
|
||||||
try {
|
try {
|
||||||
const [leadData, appointmentsData, callsData, activitiesData] = await Promise.all([
|
// Step 1: Fetch lead first to get the authoritative patientId
|
||||||
this.platform.queryWithAuth<any>(
|
const leadData = await this.platform.queryWithAuth<any>(
|
||||||
`{ lead(filter: { id: { eq: "${leadId}" } }) {
|
`{ lead(filter: { id: { eq: "${leadId}" } }) {
|
||||||
id contactName { firstName lastName }
|
id contactName { firstName lastName }
|
||||||
contactPhone { primaryPhoneNumber }
|
contactPhone { primaryPhoneNumber }
|
||||||
source status interestedService
|
source status interestedService
|
||||||
aiSummary contactAttempts lastContacted
|
aiSummary contactAttempts lastContacted
|
||||||
utmCampaign patientId
|
utmCampaign patientId
|
||||||
} }`,
|
} }`,
|
||||||
undefined, auth,
|
undefined, auth,
|
||||||
),
|
);
|
||||||
patientId ? this.platform.queryWithAuth<any>(
|
|
||||||
`{ appointments(first: 10, filter: { patientId: { eq: "${patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
const lead = leadData?.lead;
|
||||||
|
if (!lead) return null;
|
||||||
|
|
||||||
|
// Use Lead's patientId as authoritative source — the input
|
||||||
|
// param may be empty if caller resolution just linked them.
|
||||||
|
const resolvedPatientId = patientId || lead.patientId || '';
|
||||||
|
this.logger.log(`[CALLER-CTX] Resolved patientId=${resolvedPatientId} (input=${patientId}, lead=${lead.patientId ?? '∅'})`);
|
||||||
|
|
||||||
|
const firstName = lead.contactName?.firstName ?? '';
|
||||||
|
const lastName = lead.contactName?.lastName ?? '';
|
||||||
|
|
||||||
|
// Step 2: Fetch appointments, calls, activities in parallel
|
||||||
|
// using the resolved patientId from the Lead record.
|
||||||
|
const [appointmentsData, callsData, activitiesData] = await Promise.all([
|
||||||
|
resolvedPatientId ? this.platform.queryWithAuth<any>(
|
||||||
|
`{ appointments(first: 10, filter: { patientId: { eq: "${resolvedPatientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||||
scheduledAt status doctorName department reasonForVisit
|
scheduledAt status doctorName department reasonForVisit
|
||||||
} } } }`,
|
} } } }`,
|
||||||
undefined, auth,
|
undefined, auth,
|
||||||
@@ -120,12 +142,6 @@ export class CallerContextService {
|
|||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const lead = leadData?.lead;
|
|
||||||
if (!lead) return null;
|
|
||||||
|
|
||||||
const firstName = lead.contactName?.firstName ?? '';
|
|
||||||
const lastName = lead.contactName?.lastName ?? '';
|
|
||||||
|
|
||||||
const appointments = (appointmentsData?.appointments?.edges ?? []).map((e: any) => e.node);
|
const appointments = (appointmentsData?.appointments?.edges ?? []).map((e: any) => e.node);
|
||||||
const calls = (callsData?.calls?.edges ?? []).map((e: any) => ({
|
const calls = (callsData?.calls?.edges ?? []).map((e: any) => ({
|
||||||
startedAt: e.node.startedAt,
|
startedAt: e.node.startedAt,
|
||||||
@@ -148,7 +164,7 @@ export class CallerContextService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
leadId,
|
leadId,
|
||||||
patientId: patientId || lead.patientId || '',
|
patientId: resolvedPatientId,
|
||||||
name: `${firstName} ${lastName}`.trim() || 'Unknown',
|
name: `${firstName} ${lastName}`.trim() || 'Unknown',
|
||||||
phone: lead.contactPhone?.primaryPhoneNumber ?? '',
|
phone: lead.contactPhone?.primaryPhoneNumber ?? '',
|
||||||
isNew: false,
|
isNew: false,
|
||||||
|
|||||||
@@ -33,4 +33,13 @@ export class CallerResolutionController {
|
|||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('invalidate-context')
|
||||||
|
async invalidateContext(@Body('leadId') leadId: string) {
|
||||||
|
if (!leadId) {
|
||||||
|
throw new HttpException('leadId is required', HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
await this.callerContext.invalidateCache(leadId);
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,6 @@ RULES:
|
|||||||
5. For clinic info, timings, packages, insurance → answer directly from the knowledge base below. If the knowledge base is empty for that section (e.g. no packages configured), say the feature isn't set up yet instead of "I couldn't find that".
|
5. For clinic info, timings, packages, insurance → answer directly from the knowledge base below. If the knowledge base is empty for that section (e.g. no packages configured), say the feature isn't set up yet instead of "I couldn't find that".
|
||||||
6. Be concise — agents are on live calls. Under 100 words unless asked for detail.
|
6. Be concise — agents are on live calls. Under 100 words unless asked for detail.
|
||||||
7. NEVER give medical advice, diagnosis, or treatment recommendations.
|
7. NEVER give medical advice, diagnosis, or treatment recommendations.
|
||||||
8. Format with bullet points for easy scanning.
|
|
||||||
|
|
||||||
RESPONSE FORMAT (STRICT):
|
RESPONSE FORMAT (STRICT):
|
||||||
You MUST respond with valid JSON in this exact format — no markdown fences, no extra text, just raw JSON:
|
You MUST respond with valid JSON in this exact format — no markdown fences, no extra text, just raw JSON:
|
||||||
|
|||||||
@@ -38,4 +38,13 @@ export default () => ({
|
|||||||
openaiApiKey: process.env.OPENAI_API_KEY ?? '',
|
openaiApiKey: process.env.OPENAI_API_KEY ?? '',
|
||||||
model: process.env.AI_MODEL ?? 'gpt-4o-mini',
|
model: process.env.AI_MODEL ?? 'gpt-4o-mini',
|
||||||
},
|
},
|
||||||
|
sidecarUrl: process.env.SIDECAR_PUBLIC_URL ?? '',
|
||||||
|
messaging: {
|
||||||
|
provider: process.env.MESSAGING_PROVIDER ?? 'gupshup',
|
||||||
|
gupshup: {
|
||||||
|
apiKey: process.env.GUPSHUP_API_KEY ?? '',
|
||||||
|
appId: process.env.GUPSHUP_APP_ID ?? '',
|
||||||
|
sourceNumber: process.env.GUPSHUP_SOURCE_NUMBER ?? '',
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ export class SetupStateController {
|
|||||||
uiFlags() {
|
uiFlags() {
|
||||||
return {
|
return {
|
||||||
setupManaged: process.env.HELIX_SETUP_MANAGED === 'true',
|
setupManaged: process.env.HELIX_SETUP_MANAGED === 'true',
|
||||||
|
telephonyEnabled: process.env.TELEPHONY_ENABLED !== 'false', // default true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
377
src/messaging/flow/default-flows/appointment-booking.json
Normal file
377
src/messaging/flow/default-flows/appointment-booking.json
Normal file
@@ -0,0 +1,377 @@
|
|||||||
|
{
|
||||||
|
"id": "flow-appointment-booking",
|
||||||
|
"name": "Appointment Booking",
|
||||||
|
"description": "AI-driven appointment booking via WhatsApp with interactive department, doctor, date, and slot selection.",
|
||||||
|
"trigger": { "type": "default" },
|
||||||
|
"version": 1,
|
||||||
|
"status": "published",
|
||||||
|
"variables": [
|
||||||
|
{ "id": "v1", "name": "intent", "type": "string" },
|
||||||
|
{ "id": "v2", "name": "selectedDepartment", "type": "string" },
|
||||||
|
{ "id": "v3", "name": "selectedDepartmentTitle", "type": "string" },
|
||||||
|
{ "id": "v4", "name": "selectedDoctor", "type": "string" },
|
||||||
|
{ "id": "v5", "name": "selectedDoctorTitle", "type": "string" },
|
||||||
|
{ "id": "v6", "name": "doctorId", "type": "string" },
|
||||||
|
{ "id": "v7", "name": "dateChoice", "type": "string" },
|
||||||
|
{ "id": "v8", "name": "selectedDate", "type": "string" },
|
||||||
|
{ "id": "v9", "name": "selectedSlot", "type": "string" },
|
||||||
|
{ "id": "v10", "name": "confirmation", "type": "string" },
|
||||||
|
{ "id": "v11", "name": "bookingResult", "type": "object" },
|
||||||
|
{ "id": "v12", "name": "deptListResult", "type": "object" },
|
||||||
|
{ "id": "v13", "name": "docListResult", "type": "object" },
|
||||||
|
{ "id": "v14", "name": "slotListResult", "type": "object" },
|
||||||
|
{ "id": "v15", "name": "aiGreeting", "type": "string" },
|
||||||
|
{ "id": "v16", "name": "reason", "type": "string" },
|
||||||
|
{ "id": "v17", "name": "scheduledDateTime", "type": "string" }
|
||||||
|
],
|
||||||
|
"groups": [
|
||||||
|
{
|
||||||
|
"id": "g1",
|
||||||
|
"title": "Greeting",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "b1",
|
||||||
|
"type": "ai",
|
||||||
|
"prompt": "Greet the patient {{_senderName}} warmly in 1-2 sentences. They messaged: \"{{_initialMessage}}\". You are a WhatsApp assistant for Ramaiah Hospital. Be concise, no markdown.",
|
||||||
|
"outputVariableId": "aiGreeting",
|
||||||
|
"sendToPatient": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b2",
|
||||||
|
"type": "message",
|
||||||
|
"content": {
|
||||||
|
"format": "buttons",
|
||||||
|
"text": "How can I help you today?",
|
||||||
|
"buttons": [
|
||||||
|
{ "id": "intent:book", "title": "Book Appointment" },
|
||||||
|
{ "id": "intent:check", "title": "Check Appointment" },
|
||||||
|
{ "id": "intent:question", "title": "Ask a Question" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b3",
|
||||||
|
"type": "input",
|
||||||
|
"inputType": "any",
|
||||||
|
"variableId": "intent"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b4",
|
||||||
|
"type": "condition",
|
||||||
|
"conditions": [
|
||||||
|
{ "id": "c1", "variableId": "intent", "operator": "contains", "value": "book" },
|
||||||
|
{ "id": "c2", "variableId": "intent", "operator": "contains", "value": "check" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g2",
|
||||||
|
"title": "Department Selection",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "b5",
|
||||||
|
"type": "tool_call",
|
||||||
|
"toolName": "send_department_list",
|
||||||
|
"inputs": {},
|
||||||
|
"outputVariableId": "deptListResult"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b6",
|
||||||
|
"type": "input",
|
||||||
|
"inputType": "any",
|
||||||
|
"variableId": "selectedDepartment"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b7",
|
||||||
|
"type": "set_variable",
|
||||||
|
"variableId": "selectedDepartmentTitle",
|
||||||
|
"value": "selectedDepartment",
|
||||||
|
"expression": "extract_id"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g3",
|
||||||
|
"title": "Doctor Selection",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "b8",
|
||||||
|
"type": "tool_call",
|
||||||
|
"toolName": "send_doctor_list",
|
||||||
|
"inputs": { "department": "{{selectedDepartmentTitle}}" },
|
||||||
|
"outputVariableId": "docListResult"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b9",
|
||||||
|
"type": "input",
|
||||||
|
"inputType": "any",
|
||||||
|
"variableId": "selectedDoctor"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b10",
|
||||||
|
"type": "set_variable",
|
||||||
|
"variableId": "doctorId",
|
||||||
|
"value": "selectedDoctor",
|
||||||
|
"expression": "extract_id"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g4",
|
||||||
|
"title": "Date Selection",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "b11",
|
||||||
|
"type": "message",
|
||||||
|
"content": {
|
||||||
|
"format": "buttons",
|
||||||
|
"text": "When would you like to visit?",
|
||||||
|
"buttons": [
|
||||||
|
{ "id": "date:tomorrow", "title": "Tomorrow" },
|
||||||
|
{ "id": "date:day_after", "title": "Day After Tomorrow" },
|
||||||
|
{ "id": "date:other", "title": "Choose Another Date" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b12",
|
||||||
|
"type": "input",
|
||||||
|
"inputType": "any",
|
||||||
|
"variableId": "dateChoice"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b13",
|
||||||
|
"type": "condition",
|
||||||
|
"conditions": [
|
||||||
|
{ "id": "c3", "variableId": "dateChoice", "operator": "contains", "value": "tomorrow" },
|
||||||
|
{ "id": "c4", "variableId": "dateChoice", "operator": "contains", "value": "day_after" },
|
||||||
|
{ "id": "c7", "variableId": "dateChoice", "operator": "contains", "value": "other" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g4t",
|
||||||
|
"title": "Date - Tomorrow",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "b14",
|
||||||
|
"type": "set_variable",
|
||||||
|
"variableId": "selectedDate",
|
||||||
|
"value": "",
|
||||||
|
"expression": "date_tomorrow"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g4a",
|
||||||
|
"title": "Date - Day After",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "b15",
|
||||||
|
"type": "set_variable",
|
||||||
|
"variableId": "selectedDate",
|
||||||
|
"value": "",
|
||||||
|
"expression": "date_day_after"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g4c",
|
||||||
|
"title": "Date - Custom",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "b15a",
|
||||||
|
"type": "message",
|
||||||
|
"content": {
|
||||||
|
"format": "text",
|
||||||
|
"text": "Please type your preferred date (e.g., April 25 or 25/04/2026)."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b15b",
|
||||||
|
"type": "input",
|
||||||
|
"inputType": "text",
|
||||||
|
"variableId": "customDateText"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b15c",
|
||||||
|
"type": "ai",
|
||||||
|
"prompt": "The patient typed this date: \"{{customDateText}}\". Convert it to YYYY-MM-DD format. The current year is 2026. Reply with ONLY the date in YYYY-MM-DD format, nothing else.",
|
||||||
|
"outputVariableId": "selectedDate",
|
||||||
|
"sendToPatient": false
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g5",
|
||||||
|
"title": "Slot Selection",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "b16",
|
||||||
|
"type": "tool_call",
|
||||||
|
"toolName": "send_slot_list",
|
||||||
|
"inputs": {
|
||||||
|
"doctorId": "{{doctorId}}",
|
||||||
|
"doctorName": "{{selectedDoctor_title}}",
|
||||||
|
"date": "{{selectedDate}}"
|
||||||
|
},
|
||||||
|
"outputVariableId": "slotListResult"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b17",
|
||||||
|
"type": "input",
|
||||||
|
"inputType": "any",
|
||||||
|
"variableId": "selectedSlot"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b17a",
|
||||||
|
"type": "set_variable",
|
||||||
|
"variableId": "scheduledDateTime",
|
||||||
|
"value": "selectedSlot",
|
||||||
|
"expression": "extract_datetime"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g6",
|
||||||
|
"title": "Reason",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "b18",
|
||||||
|
"type": "message",
|
||||||
|
"content": {
|
||||||
|
"format": "text",
|
||||||
|
"text": "What is the reason for your visit? (e.g., General Consultation, Follow-up, etc.)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b19",
|
||||||
|
"type": "input",
|
||||||
|
"inputType": "text",
|
||||||
|
"variableId": "reason"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g7",
|
||||||
|
"title": "Confirmation",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "b20",
|
||||||
|
"type": "tool_call",
|
||||||
|
"toolName": "send_confirm_buttons",
|
||||||
|
"inputs": {
|
||||||
|
"summary": "Appointment Summary:\nDoctor: {{selectedDoctor_title}}\nDate: {{selectedDate}}\nTime: {{selectedSlot_title}}\nReason: {{reason}}\n\nShall I confirm this booking?"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b21",
|
||||||
|
"type": "input",
|
||||||
|
"inputType": "any",
|
||||||
|
"variableId": "confirmation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b22",
|
||||||
|
"type": "condition",
|
||||||
|
"conditions": [
|
||||||
|
{ "id": "c5", "variableId": "confirmation", "operator": "contains", "value": "confirm" },
|
||||||
|
{ "id": "c6", "variableId": "confirmation", "operator": "contains", "value": "cancel" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g8",
|
||||||
|
"title": "Booking",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "b23",
|
||||||
|
"type": "tool_call",
|
||||||
|
"toolName": "book_appointment",
|
||||||
|
"inputs": {
|
||||||
|
"patientName": "{{_senderName}}",
|
||||||
|
"phoneNumber": "{{_phone}}",
|
||||||
|
"department": "{{selectedDepartmentTitle}}",
|
||||||
|
"doctorName": "{{selectedDoctor_title}}",
|
||||||
|
"scheduledAt": "{{scheduledDateTime}}",
|
||||||
|
"reason": "{{reason}}"
|
||||||
|
},
|
||||||
|
"outputVariableId": "bookingResult"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b24",
|
||||||
|
"type": "message",
|
||||||
|
"content": {
|
||||||
|
"format": "text",
|
||||||
|
"text": "Your appointment is confirmed!\n\nDoctor: {{selectedDoctor_title}}\nDate: {{selectedDate}}\nTime: {{selectedSlot_title}}\nReason: {{reason}}\n\nThank you for choosing Ramaiah Hospital. See you soon!"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b24a",
|
||||||
|
"type": "tool_call",
|
||||||
|
"toolName": "send_appointment_qr",
|
||||||
|
"inputs": {
|
||||||
|
"appointmentId": "{{bookingResult.appointmentId}}",
|
||||||
|
"reference": "{{bookingResult.reference}}",
|
||||||
|
"patientName": "{{_senderName}}",
|
||||||
|
"doctorName": "{{selectedDoctor_title}}",
|
||||||
|
"department": "{{selectedDepartmentTitle}}",
|
||||||
|
"scheduledAt": "{{scheduledDateTime}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g9",
|
||||||
|
"title": "Cancelled",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "b25",
|
||||||
|
"type": "message",
|
||||||
|
"content": {
|
||||||
|
"format": "text",
|
||||||
|
"text": "No problem! Your booking has been cancelled. Feel free to message us again whenever you'd like to book an appointment."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "g10",
|
||||||
|
"title": "Check Appointments",
|
||||||
|
"blocks": [
|
||||||
|
{
|
||||||
|
"id": "b26",
|
||||||
|
"type": "tool_call",
|
||||||
|
"toolName": "lookup_appointments",
|
||||||
|
"inputs": {},
|
||||||
|
"outputVariableId": "existingAppts"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "b27",
|
||||||
|
"type": "ai",
|
||||||
|
"prompt": "The patient {{_senderName}} asked to check their appointments. Here are their appointments: {{existingAppts}}. Summarize them in a friendly WhatsApp message. If no appointments, say they have none and offer to book one. Be concise, no markdown.",
|
||||||
|
"outputVariableId": "apptSummary",
|
||||||
|
"sendToPatient": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"edges": [
|
||||||
|
{ "id": "e1", "from": { "blockId": "b4", "conditionId": "c1" }, "to": { "groupId": "g2" } },
|
||||||
|
{ "id": "e2", "from": { "blockId": "b4", "conditionId": "c2" }, "to": { "groupId": "g10" } },
|
||||||
|
{ "id": "e3", "from": { "blockId": "b7" }, "to": { "groupId": "g3" } },
|
||||||
|
{ "id": "e4", "from": { "blockId": "b10" }, "to": { "groupId": "g4" } },
|
||||||
|
{ "id": "e5", "from": { "blockId": "b13", "conditionId": "c3" }, "to": { "groupId": "g4t" } },
|
||||||
|
{ "id": "e6", "from": { "blockId": "b13", "conditionId": "c4" }, "to": { "groupId": "g4a" } },
|
||||||
|
{ "id": "e6a", "from": { "blockId": "b13", "conditionId": "c7" }, "to": { "groupId": "g4c" } },
|
||||||
|
{ "id": "e7", "from": { "blockId": "b14" }, "to": { "groupId": "g5" } },
|
||||||
|
{ "id": "e8", "from": { "blockId": "b15" }, "to": { "groupId": "g5" } },
|
||||||
|
{ "id": "e8a", "from": { "blockId": "b15c" }, "to": { "groupId": "g5" } },
|
||||||
|
{ "id": "e9", "from": { "blockId": "b17a" }, "to": { "groupId": "g6" } },
|
||||||
|
{ "id": "e10", "from": { "blockId": "b19" }, "to": { "groupId": "g7" } },
|
||||||
|
{ "id": "e11", "from": { "blockId": "b22", "conditionId": "c5" }, "to": { "groupId": "g8" } },
|
||||||
|
{ "id": "e12", "from": { "blockId": "b22", "conditionId": "c6" }, "to": { "groupId": "g9" } }
|
||||||
|
]
|
||||||
|
}
|
||||||
344
src/messaging/flow/flow-execution.service.ts
Normal file
344
src/messaging/flow/flow-execution.service.ts
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { generateText, stepCountIs } from 'ai';
|
||||||
|
import { createAiModel } from '../../ai/ai-provider';
|
||||||
|
import { AiConfigService } from '../../config/ai-config.service';
|
||||||
|
import { CallerResolutionService } from '../../caller/caller-resolution.service';
|
||||||
|
import { CallerContextService } from '../../caller/caller-context.service';
|
||||||
|
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
|
||||||
|
import { MessagingProvider } from '../providers/messaging-provider.interface';
|
||||||
|
import { FlowSessionService } from './flow-session.service';
|
||||||
|
import { FlowStoreService } from './flow-store.service';
|
||||||
|
import { FlowVariableService } from './flow-variable.service';
|
||||||
|
import { ToolRegistry } from './tool-registry';
|
||||||
|
import type { Flow, FlowSession, Group, Block, ConditionBlock, ToolContext } from './flow-types';
|
||||||
|
import type { NormalizedMessage } from '../types';
|
||||||
|
import type { LanguageModel } from 'ai';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FlowExecutionService {
|
||||||
|
private readonly logger = new Logger(FlowExecutionService.name);
|
||||||
|
private readonly aiModel: LanguageModel | null;
|
||||||
|
private readonly auth: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private config: ConfigService,
|
||||||
|
private provider: MessagingProvider,
|
||||||
|
private sessions: FlowSessionService,
|
||||||
|
private store: FlowStoreService,
|
||||||
|
private variables: FlowVariableService,
|
||||||
|
private tools: ToolRegistry,
|
||||||
|
private caller: CallerResolutionService,
|
||||||
|
private callerContext: CallerContextService,
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
private aiConfig: AiConfigService,
|
||||||
|
) {
|
||||||
|
const cfg = aiConfig.getConfig();
|
||||||
|
this.aiModel = createAiModel({
|
||||||
|
provider: cfg.provider,
|
||||||
|
model: cfg.model,
|
||||||
|
anthropicApiKey: config.get<string>('ai.anthropicApiKey'),
|
||||||
|
openaiApiKey: config.get<string>('ai.openaiApiKey'),
|
||||||
|
});
|
||||||
|
const apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
|
this.auth = apiKey ? `Bearer ${apiKey}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-phone lock to prevent concurrent flow executions
|
||||||
|
private readonly locks = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
|
async handleMessage(message: NormalizedMessage): Promise<void> {
|
||||||
|
const { phone } = message;
|
||||||
|
|
||||||
|
// Serialize executions per phone — prevent two concurrent flows
|
||||||
|
const existing = this.locks.get(phone);
|
||||||
|
const execute = async () => {
|
||||||
|
if (existing) await existing.catch(() => {});
|
||||||
|
await this._handleMessage(message);
|
||||||
|
};
|
||||||
|
const promise = execute();
|
||||||
|
this.locks.set(phone, promise);
|
||||||
|
await promise.finally(() => {
|
||||||
|
if (this.locks.get(phone) === promise) this.locks.delete(phone);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async _handleMessage(message: NormalizedMessage): Promise<void> {
|
||||||
|
const { phone } = message;
|
||||||
|
|
||||||
|
// 1. Load existing session or start new flow
|
||||||
|
let session = await this.sessions.load(phone);
|
||||||
|
let flow: Flow | null = null;
|
||||||
|
|
||||||
|
if (session) {
|
||||||
|
flow = this.store.getById(session.flowId);
|
||||||
|
if (!flow) {
|
||||||
|
this.logger.warn(`[FLOW] Flow ${session.flowId} not found — clearing session`);
|
||||||
|
await this.sessions.clear(phone);
|
||||||
|
session = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
flow = this.store.matchFlow(message.text);
|
||||||
|
if (!flow) {
|
||||||
|
this.logger.log(`[FLOW] No matching flow for: ${message.text.substring(0, 50)}`);
|
||||||
|
await this.provider.sendText(phone, 'Sorry, I didn\'t understand. Please try again.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize session
|
||||||
|
const firstGroup = flow.groups[0];
|
||||||
|
if (!firstGroup) {
|
||||||
|
this.logger.error(`[FLOW] Flow ${flow.id} has no groups`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
session = {
|
||||||
|
flowId: flow.id,
|
||||||
|
currentGroupId: firstGroup.id,
|
||||||
|
currentBlockIndex: 0,
|
||||||
|
variables: this.initializeVariables(flow, message),
|
||||||
|
startedAt: Date.now(),
|
||||||
|
lastActiveAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve caller and inject context variables
|
||||||
|
const resolved = await this.caller.resolve(phone, this.auth).catch(() => null);
|
||||||
|
if (resolved) {
|
||||||
|
session.variables['_callerName'] = `${resolved.firstName} ${resolved.lastName}`.trim();
|
||||||
|
session.variables['_leadId'] = resolved.leadId;
|
||||||
|
session.variables['_patientId'] = resolved.patientId;
|
||||||
|
session.variables['_isNew'] = resolved.isNew;
|
||||||
|
session.variables['_phone'] = phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[FLOW] Started flow "${flow.name}" for ${phone}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. If paused at an InputBlock, process the reply
|
||||||
|
const currentGroup = flow!.groups.find(g => g.id === session!.currentGroupId);
|
||||||
|
if (currentGroup) {
|
||||||
|
const currentBlock = currentGroup.blocks[session!.currentBlockIndex];
|
||||||
|
if (currentBlock?.type === 'input') {
|
||||||
|
const value = message.interactiveReply?.id ?? message.text;
|
||||||
|
session!.variables[currentBlock.variableId] = value;
|
||||||
|
|
||||||
|
// Also store the display title for interactive replies
|
||||||
|
if (message.interactiveReply?.title) {
|
||||||
|
session!.variables[currentBlock.variableId + '_title'] = message.interactiveReply.title;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`[FLOW] Input received: ${currentBlock.variableId}=${value}`);
|
||||||
|
session!.currentBlockIndex++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Walk forward
|
||||||
|
await this.walkForward(phone, session!, flow!);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async walkForward(phone: string, session: FlowSession, flow: Flow): Promise<void> {
|
||||||
|
let iterations = 0;
|
||||||
|
const maxIterations = 50; // safety valve
|
||||||
|
|
||||||
|
while (iterations++ < maxIterations) {
|
||||||
|
const group = flow.groups.find(g => g.id === session.currentGroupId);
|
||||||
|
if (!group) {
|
||||||
|
this.logger.log(`[FLOW] Group ${session.currentGroupId} not found — flow complete`);
|
||||||
|
await this.sessions.clear(phone);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// End of group — follow outgoing edge
|
||||||
|
if (session.currentBlockIndex >= group.blocks.length) {
|
||||||
|
const edge = this.findGroupEdge(flow, group);
|
||||||
|
if (!edge) {
|
||||||
|
this.logger.log(`[FLOW] No outgoing edge from group "${group.title}" — flow complete`);
|
||||||
|
await this.sessions.clear(phone);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.currentGroupId = edge.to.groupId;
|
||||||
|
session.currentBlockIndex = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const block = group.blocks[session.currentBlockIndex];
|
||||||
|
this.logger.log(`[FLOW] Executing block ${block.id} (${block.type}) in group "${group.title}"`);
|
||||||
|
|
||||||
|
const shouldStop = await this.executeBlock(block, phone, session, flow);
|
||||||
|
if (shouldStop) {
|
||||||
|
await this.sessions.save(phone, session);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.error(`[FLOW] Max iterations reached for ${phone} — possible infinite loop`);
|
||||||
|
await this.sessions.clear(phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns true if execution should pause (InputBlock)
|
||||||
|
private async executeBlock(block: Block, phone: string, session: FlowSession, flow: Flow): Promise<boolean> {
|
||||||
|
const ctx: ToolContext = {
|
||||||
|
phone,
|
||||||
|
session,
|
||||||
|
provider: this.provider,
|
||||||
|
platform: this.platform,
|
||||||
|
auth: this.auth,
|
||||||
|
};
|
||||||
|
|
||||||
|
switch (block.type) {
|
||||||
|
case 'message': {
|
||||||
|
const content = block.content;
|
||||||
|
if (content.format === 'text') {
|
||||||
|
const text = this.variables.interpolate(content.text, session.variables);
|
||||||
|
await this.provider.sendText(phone, text);
|
||||||
|
} else if (content.format === 'buttons') {
|
||||||
|
const text = this.variables.interpolate(content.text, session.variables);
|
||||||
|
await this.provider.sendButtons(phone, text, content.buttons);
|
||||||
|
} else if (content.format === 'list') {
|
||||||
|
const text = this.variables.interpolate(content.text, session.variables);
|
||||||
|
await this.provider.sendList(phone, text, content.buttonText, content.sections);
|
||||||
|
}
|
||||||
|
session.currentBlockIndex++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'input': {
|
||||||
|
// Pause — wait for next message
|
||||||
|
this.logger.log(`[FLOW] Waiting for input → ${block.variableId}`);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'condition': {
|
||||||
|
const matched = this.evaluateConditions(block, session);
|
||||||
|
if (matched) {
|
||||||
|
const edge = flow.edges.find(e =>
|
||||||
|
e.from.blockId === block.id && e.from.conditionId === matched.id,
|
||||||
|
);
|
||||||
|
if (edge) {
|
||||||
|
session.currentGroupId = edge.to.groupId;
|
||||||
|
session.currentBlockIndex = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// No match — fall through to next block
|
||||||
|
session.currentBlockIndex++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'set_variable': {
|
||||||
|
if (block.expression) {
|
||||||
|
const rawValue = session.variables[block.value] ?? block.value;
|
||||||
|
session.variables[block.variableId] = this.variables.evaluateExpression(
|
||||||
|
block.expression, String(rawValue), session.variables,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
session.variables[block.variableId] = this.variables.interpolate(block.value, session.variables);
|
||||||
|
}
|
||||||
|
this.logger.log(`[FLOW] Set ${block.variableId}=${session.variables[block.variableId]}`);
|
||||||
|
session.currentBlockIndex++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'tool_call': {
|
||||||
|
const inputs = this.variables.interpolateObject(block.inputs, session.variables);
|
||||||
|
const result = await this.tools.execute(block.toolName, inputs, ctx);
|
||||||
|
if (block.outputVariableId) {
|
||||||
|
session.variables[block.outputVariableId] = result;
|
||||||
|
}
|
||||||
|
session.currentBlockIndex++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'ai': {
|
||||||
|
if (!this.aiModel) {
|
||||||
|
session.currentBlockIndex++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const prompt = this.variables.interpolate(block.prompt, session.variables);
|
||||||
|
try {
|
||||||
|
const result = await generateText({
|
||||||
|
model: this.aiModel,
|
||||||
|
prompt,
|
||||||
|
stopWhen: stepCountIs(1),
|
||||||
|
});
|
||||||
|
const text = result.text?.trim() ?? '';
|
||||||
|
if (block.outputVariableId) {
|
||||||
|
session.variables[block.outputVariableId] = text;
|
||||||
|
}
|
||||||
|
if (block.sendToPatient && text) {
|
||||||
|
await this.provider.sendText(phone, text);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[FLOW] AI block failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
session.currentBlockIndex++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'jump': {
|
||||||
|
session.currentGroupId = block.targetGroupId;
|
||||||
|
session.currentBlockIndex = 0;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
this.logger.warn(`[FLOW] Unknown block type: ${(block as any).type}`);
|
||||||
|
session.currentBlockIndex++;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private evaluateConditions(block: ConditionBlock, session: FlowSession) {
|
||||||
|
for (const cond of block.conditions) {
|
||||||
|
const value = session.variables[cond.variableId];
|
||||||
|
const target = cond.value ? this.variables.interpolate(cond.value, session.variables) : undefined;
|
||||||
|
|
||||||
|
let match = false;
|
||||||
|
switch (cond.operator) {
|
||||||
|
case 'equals': match = String(value) === target; break;
|
||||||
|
case 'contains': match = String(value ?? '').toLowerCase().includes((target ?? '').toLowerCase()); break;
|
||||||
|
case 'exists': match = value !== undefined && value !== null && value !== ''; break;
|
||||||
|
case 'not_exists': match = value === undefined || value === null || value === ''; break;
|
||||||
|
case 'starts_with': match = String(value ?? '').startsWith(target ?? ''); break;
|
||||||
|
case 'gt': match = Number(value) > Number(target); break;
|
||||||
|
case 'lt': match = Number(value) < Number(target); break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (match) return cond;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private findGroupEdge(flow: Flow, group: Group) {
|
||||||
|
// Find edge from the last block in the group (default outgoing)
|
||||||
|
const lastBlock = group.blocks[group.blocks.length - 1];
|
||||||
|
if (lastBlock) {
|
||||||
|
const edge = flow.edges.find(e => e.from.blockId === lastBlock.id && !e.from.conditionId);
|
||||||
|
if (edge) return edge;
|
||||||
|
}
|
||||||
|
// Fallback: any edge from any block in this group without conditionId
|
||||||
|
for (const block of group.blocks) {
|
||||||
|
const edge = flow.edges.find(e => e.from.blockId === block.id && !e.from.conditionId);
|
||||||
|
if (edge) return edge;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private initializeVariables(flow: Flow, message: NormalizedMessage): Record<string, any> {
|
||||||
|
const vars: Record<string, any> = {};
|
||||||
|
for (const v of flow.variables) {
|
||||||
|
vars[v.name] = v.defaultValue ?? null;
|
||||||
|
}
|
||||||
|
// Inject message context
|
||||||
|
vars['_initialMessage'] = message.text;
|
||||||
|
vars['_senderName'] = message.name;
|
||||||
|
return vars;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if flow engine has any published flows
|
||||||
|
hasFlows(): boolean {
|
||||||
|
return this.store.getAll().some(f => f.status === 'published');
|
||||||
|
}
|
||||||
|
}
|
||||||
39
src/messaging/flow/flow-session.service.ts
Normal file
39
src/messaging/flow/flow-session.service.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import type { FlowSession } from './flow-types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FlowSessionService {
|
||||||
|
private readonly logger = new Logger(FlowSessionService.name);
|
||||||
|
private readonly redis: Redis;
|
||||||
|
private readonly ttlSec = 24 * 60 * 60; // 24h
|
||||||
|
|
||||||
|
constructor(config: ConfigService) {
|
||||||
|
const redisUrl = config.get<string>('redis.url') ?? 'redis://localhost:6379';
|
||||||
|
this.redis = new Redis(redisUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private key(phone: string): string {
|
||||||
|
return `wa:flow:${phone}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async load(phone: string): Promise<FlowSession | null> {
|
||||||
|
const raw = await this.redis.get(this.key(phone));
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async save(phone: string, session: FlowSession): Promise<void> {
|
||||||
|
session.lastActiveAt = Date.now();
|
||||||
|
await this.redis.setex(this.key(phone), this.ttlSec, JSON.stringify(session));
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(phone: string): Promise<void> {
|
||||||
|
await this.redis.del(this.key(phone));
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/messaging/flow/flow-store.service.ts
Normal file
102
src/messaging/flow/flow-store.service.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||||
|
import { existsSync, readFileSync, writeFileSync, readdirSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import type { Flow } from './flow-types';
|
||||||
|
|
||||||
|
const FLOWS_DIR = join(process.cwd(), 'data', 'flows');
|
||||||
|
const DEFAULTS_DIR = join(__dirname, 'default-flows');
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FlowStoreService implements OnModuleInit {
|
||||||
|
private readonly logger = new Logger(FlowStoreService.name);
|
||||||
|
private flows: Map<string, Flow> = new Map();
|
||||||
|
|
||||||
|
onModuleInit() {
|
||||||
|
this.ensureDirectory();
|
||||||
|
this.seedDefaults();
|
||||||
|
this.loadAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ensureDirectory() {
|
||||||
|
const { mkdirSync } = require('fs');
|
||||||
|
if (!existsSync(FLOWS_DIR)) {
|
||||||
|
mkdirSync(FLOWS_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private seedDefaults() {
|
||||||
|
// Copy default flows if data/flows/ is empty
|
||||||
|
if (!existsSync(DEFAULTS_DIR)) return;
|
||||||
|
const existing = readdirSync(FLOWS_DIR).filter(f => f.endsWith('.json'));
|
||||||
|
if (existing.length > 0) return;
|
||||||
|
|
||||||
|
const defaults = readdirSync(DEFAULTS_DIR).filter(f => f.endsWith('.json'));
|
||||||
|
for (const file of defaults) {
|
||||||
|
const src = join(DEFAULTS_DIR, file);
|
||||||
|
const dest = join(FLOWS_DIR, file);
|
||||||
|
const content = readFileSync(src, 'utf-8');
|
||||||
|
writeFileSync(dest, content);
|
||||||
|
this.logger.log(`[FLOW-STORE] Seeded default flow: ${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private loadAll() {
|
||||||
|
this.flows.clear();
|
||||||
|
const files = readdirSync(FLOWS_DIR).filter(f => f.endsWith('.json'));
|
||||||
|
for (const file of files) {
|
||||||
|
try {
|
||||||
|
const raw = readFileSync(join(FLOWS_DIR, file), 'utf-8');
|
||||||
|
const flow: Flow = JSON.parse(raw);
|
||||||
|
this.flows.set(flow.id, flow);
|
||||||
|
this.logger.log(`[FLOW-STORE] Loaded flow: ${flow.name} (${flow.id}) status=${flow.status}`);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[FLOW-STORE] Failed to load ${file}: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.logger.log(`[FLOW-STORE] ${this.flows.size} flow(s) loaded`);
|
||||||
|
}
|
||||||
|
|
||||||
|
getById(id: string): Flow | null {
|
||||||
|
return this.flows.get(id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match inbound message to a published flow by trigger
|
||||||
|
matchFlow(messageText: string): Flow | null {
|
||||||
|
let defaultFlow: Flow | null = null;
|
||||||
|
|
||||||
|
for (const flow of this.flows.values()) {
|
||||||
|
if (flow.status !== 'published') continue;
|
||||||
|
|
||||||
|
if (flow.trigger.type === 'default') {
|
||||||
|
defaultFlow = flow;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flow.trigger.type === 'message' && flow.trigger.conditions) {
|
||||||
|
const { keywords, regex } = flow.trigger.conditions;
|
||||||
|
const lower = messageText.toLowerCase();
|
||||||
|
|
||||||
|
if (keywords?.some(k => lower.includes(k.toLowerCase()))) {
|
||||||
|
return flow;
|
||||||
|
}
|
||||||
|
if (regex && new RegExp(regex, 'i').test(messageText)) {
|
||||||
|
return flow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultFlow;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CRUD for admin API (future)
|
||||||
|
getAll(): Flow[] {
|
||||||
|
return Array.from(this.flows.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
save(flow: Flow): void {
|
||||||
|
this.flows.set(flow.id, flow);
|
||||||
|
const file = join(FLOWS_DIR, `${flow.id}.json`);
|
||||||
|
writeFileSync(file, JSON.stringify(flow, null, 2));
|
||||||
|
this.logger.log(`[FLOW-STORE] Saved flow: ${flow.name} (${flow.id})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
133
src/messaging/flow/flow-types.ts
Normal file
133
src/messaging/flow/flow-types.ts
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
// ── Flow Definition ──
|
||||||
|
|
||||||
|
export type Flow = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
trigger: FlowTrigger;
|
||||||
|
groups: Group[];
|
||||||
|
edges: Edge[];
|
||||||
|
variables: VariableDefinition[];
|
||||||
|
version: number;
|
||||||
|
status: 'draft' | 'published';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FlowTrigger =
|
||||||
|
| { type: 'message'; conditions?: { keywords?: string[]; regex?: string } }
|
||||||
|
| { type: 'default' };
|
||||||
|
|
||||||
|
export type VariableDefinition = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
type: 'string' | 'number' | 'boolean' | 'object' | 'array';
|
||||||
|
defaultValue?: any;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Groups & Edges ──
|
||||||
|
|
||||||
|
export type Group = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
blocks: Block[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Edge = {
|
||||||
|
id: string;
|
||||||
|
from: { blockId: string; conditionId?: string };
|
||||||
|
to: { groupId: string; blockId?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Blocks ──
|
||||||
|
|
||||||
|
export type Block =
|
||||||
|
| MessageBlock
|
||||||
|
| InputBlock
|
||||||
|
| ConditionBlock
|
||||||
|
| SetVariableBlock
|
||||||
|
| ToolCallBlock
|
||||||
|
| AIBlock
|
||||||
|
| JumpBlock;
|
||||||
|
|
||||||
|
export type MessageBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'message';
|
||||||
|
content:
|
||||||
|
| { format: 'text'; text: string }
|
||||||
|
| { format: 'buttons'; text: string; buttons: { id: string; title: string }[] }
|
||||||
|
| { format: 'list'; text: string; buttonText: string; sections: { title: string; rows: { id: string; title: string; description?: string }[] }[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InputBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'input';
|
||||||
|
inputType: 'text' | 'interactive_reply' | 'any';
|
||||||
|
variableId: string;
|
||||||
|
validation?: { regex?: string; errorMessage?: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConditionBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'condition';
|
||||||
|
conditions: {
|
||||||
|
id: string;
|
||||||
|
variableId: string;
|
||||||
|
operator: 'equals' | 'contains' | 'exists' | 'not_exists' | 'gt' | 'lt' | 'starts_with';
|
||||||
|
value?: string;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SetVariableBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'set_variable';
|
||||||
|
variableId: string;
|
||||||
|
value: string;
|
||||||
|
expression?: 'extract_id' | 'extract_datetime' | 'date_tomorrow' | 'date_day_after';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ToolCallBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'tool_call';
|
||||||
|
toolName: string;
|
||||||
|
inputs: Record<string, string>;
|
||||||
|
outputVariableId?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AIBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'ai';
|
||||||
|
prompt: string;
|
||||||
|
outputVariableId?: string;
|
||||||
|
sendToPatient: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type JumpBlock = {
|
||||||
|
id: string;
|
||||||
|
type: 'jump';
|
||||||
|
targetGroupId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Session State ──
|
||||||
|
|
||||||
|
export type FlowSession = {
|
||||||
|
flowId: string;
|
||||||
|
currentGroupId: string;
|
||||||
|
currentBlockIndex: number;
|
||||||
|
variables: Record<string, any>;
|
||||||
|
startedAt: number;
|
||||||
|
lastActiveAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ── Tool Registry ──
|
||||||
|
|
||||||
|
export type ToolHandler = (
|
||||||
|
inputs: Record<string, any>,
|
||||||
|
context: ToolContext,
|
||||||
|
) => Promise<any>;
|
||||||
|
|
||||||
|
export type ToolContext = {
|
||||||
|
phone: string;
|
||||||
|
session: FlowSession;
|
||||||
|
provider: import('../providers/messaging-provider.interface').MessagingProvider;
|
||||||
|
platform: import('../../platform/platform-graphql.service').PlatformGraphqlService;
|
||||||
|
auth: string;
|
||||||
|
};
|
||||||
56
src/messaging/flow/flow-variable.service.ts
Normal file
56
src/messaging/flow/flow-variable.service.ts
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class FlowVariableService {
|
||||||
|
// Replace {{variableName}} with values from session variables
|
||||||
|
interpolate(template: string, variables: Record<string, any>): string {
|
||||||
|
return template.replace(/\{\{([\w.]+)\}\}/g, (match, path) => {
|
||||||
|
// Support dot notation: {{bookingResult.appointmentId}}
|
||||||
|
const parts = path.split('.');
|
||||||
|
let value: any = variables;
|
||||||
|
for (const part of parts) {
|
||||||
|
value = value?.[part];
|
||||||
|
if (value === undefined) return match;
|
||||||
|
}
|
||||||
|
if (value === null) return match;
|
||||||
|
if (typeof value === 'object') return JSON.stringify(value);
|
||||||
|
return String(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interpolate all string values in an object
|
||||||
|
interpolateObject(obj: Record<string, string>, variables: Record<string, any>): Record<string, any> {
|
||||||
|
const result: Record<string, any> = {};
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
result[key] = this.interpolate(value, variables);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Execute expressions for SetVariableBlock
|
||||||
|
evaluateExpression(expression: string, value: string, variables: Record<string, any>): any {
|
||||||
|
switch (expression) {
|
||||||
|
case 'extract_id': {
|
||||||
|
// Extract second segment: "doc:{uuid}:{name}" → uuid, "dept:{name}" → name
|
||||||
|
const parts = value.split(':');
|
||||||
|
return parts.length >= 2 ? parts[1] : value;
|
||||||
|
}
|
||||||
|
case 'extract_datetime': {
|
||||||
|
// Extract datetime from "slot:{doctorId}:{datetime}" → "2026-04-21T14:00:00"
|
||||||
|
const parts = value.split(':');
|
||||||
|
// Rejoin from index 2 onwards (datetime contains colons: 2026-04-21T14:00:00)
|
||||||
|
return parts.length >= 3 ? parts.slice(2).join(':') : value;
|
||||||
|
}
|
||||||
|
case 'date_tomorrow': {
|
||||||
|
const d = new Date(Date.now() + 86400000);
|
||||||
|
return d.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
case 'date_day_after': {
|
||||||
|
const d = new Date(Date.now() + 2 * 86400000);
|
||||||
|
return d.toISOString().split('T')[0];
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return this.interpolate(value, variables);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
244
src/messaging/flow/tool-registry.ts
Normal file
244
src/messaging/flow/tool-registry.ts
Normal file
@@ -0,0 +1,244 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
|
||||||
|
import { CallerResolutionService } from '../../caller/caller-resolution.service';
|
||||||
|
import { QrService } from '../qr.service';
|
||||||
|
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../../shared/doctor-utils';
|
||||||
|
import type { ToolHandler, ToolContext } from './flow-types';
|
||||||
|
import type { ListSection, InteractiveButton } from '../types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class ToolRegistry {
|
||||||
|
private readonly logger = new Logger(ToolRegistry.name);
|
||||||
|
private readonly tools: Map<string, ToolHandler> = new Map();
|
||||||
|
|
||||||
|
private readonly sidecarUrl: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
private caller: CallerResolutionService,
|
||||||
|
private qr: QrService,
|
||||||
|
private config: ConfigService,
|
||||||
|
) {
|
||||||
|
this.sidecarUrl = config.get<string>('sidecarUrl') ?? '';
|
||||||
|
this.registerDefaults();
|
||||||
|
}
|
||||||
|
|
||||||
|
register(name: string, handler: ToolHandler) {
|
||||||
|
this.tools.set(name, handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(name: string, inputs: Record<string, any>, context: ToolContext): Promise<any> {
|
||||||
|
const handler = this.tools.get(name);
|
||||||
|
if (!handler) {
|
||||||
|
this.logger.error(`[TOOL] Unknown tool: ${name}`);
|
||||||
|
return { error: `Unknown tool: ${name}` };
|
||||||
|
}
|
||||||
|
this.logger.log(`[TOOL] ${name} inputs=${JSON.stringify(inputs).substring(0, 200)}`);
|
||||||
|
const result = await handler(inputs, context);
|
||||||
|
this.logger.log(`[TOOL] ${name} result=${JSON.stringify(result).substring(0, 200)}`);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private registerDefaults() {
|
||||||
|
this.register('resolve_caller', async (inputs, ctx) => {
|
||||||
|
const phone = inputs.phone ?? ctx.phone;
|
||||||
|
const resolved = await this.caller.resolve(phone, ctx.auth).catch(() => null);
|
||||||
|
return resolved ?? { isNew: true, leadId: '', patientId: '', phone };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.register('send_department_list', async (_inputs, ctx) => {
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ doctors(first: 50) { edges { node { department } } } }`,
|
||||||
|
);
|
||||||
|
const departments = [...new Set(
|
||||||
|
data.doctors.edges.map((e: any) => e.node.department).filter(Boolean),
|
||||||
|
)] as string[];
|
||||||
|
|
||||||
|
if (!departments.length) return { sent: false, message: 'No departments available.' };
|
||||||
|
|
||||||
|
const sections: ListSection[] = [{
|
||||||
|
title: 'Departments',
|
||||||
|
rows: departments.slice(0, 10).map(d => ({
|
||||||
|
id: `dept:${d}`,
|
||||||
|
title: d.substring(0, 24),
|
||||||
|
})),
|
||||||
|
}];
|
||||||
|
await ctx.provider.sendList(ctx.phone, 'Which department would you like to visit?', 'View Departments', sections);
|
||||||
|
return { sent: true, departments };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.register('send_doctor_list', async (inputs, ctx) => {
|
||||||
|
const department = inputs.department;
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ doctors(first: 50) { edges { node {
|
||||||
|
id fullName { firstName lastName }
|
||||||
|
department specialty
|
||||||
|
consultationFeeNew { amountMicros currencyCode }
|
||||||
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||||
|
} } } }`,
|
||||||
|
);
|
||||||
|
const allDocs = normalizeDoctors(data.doctors.edges.map((e: any) => e.node));
|
||||||
|
const deptDocs = allDocs.filter((d: any) =>
|
||||||
|
d.department?.toLowerCase() === department.toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!deptDocs.length) return { sent: false, message: `No doctors found in ${department}.` };
|
||||||
|
|
||||||
|
const sections: ListSection[] = [{
|
||||||
|
title: department.substring(0, 24),
|
||||||
|
rows: deptDocs.slice(0, 10).map((d: any) => {
|
||||||
|
const docName = `Dr. ${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''}`.trim();
|
||||||
|
const fee = d.consultationFeeNew?.amountMicros
|
||||||
|
? `₹${(d.consultationFeeNew.amountMicros / 1000000).toFixed(0)}`
|
||||||
|
: '';
|
||||||
|
return {
|
||||||
|
id: `doc:${d.id}:${docName}`,
|
||||||
|
title: docName.substring(0, 24),
|
||||||
|
description: fee ? `${d.specialty ?? department} — ${fee}` : (d.specialty ?? department),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}];
|
||||||
|
await ctx.provider.sendList(ctx.phone, `Doctors in ${department}:`, 'View Doctors', sections);
|
||||||
|
return { sent: true, count: deptDocs.length };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.register('send_slot_list', async (inputs, ctx) => {
|
||||||
|
const { doctorId, doctorName, date } = inputs;
|
||||||
|
const targetDate = date ?? new Date(Date.now() + 86400000).toISOString().split('T')[0];
|
||||||
|
const dayNames = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'];
|
||||||
|
const targetDay = dayNames[new Date(targetDate + 'T00:00:00+05:30').getDay()];
|
||||||
|
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ doctors(first: 50) { edges { node {
|
||||||
|
id fullName { firstName lastName }
|
||||||
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||||
|
} } } }`,
|
||||||
|
);
|
||||||
|
const rawDocs = data.doctors.edges.map((e: any) => e.node);
|
||||||
|
const doctor = rawDocs.find((d: any) => d.id === doctorId);
|
||||||
|
if (!doctor) return { sent: false, message: 'Doctor not found.' };
|
||||||
|
|
||||||
|
const rawSlots = doctor.visitSlots?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
const daySlots = rawSlots.filter((s: any) => s.dayOfWeek === targetDay);
|
||||||
|
|
||||||
|
if (!daySlots.length) {
|
||||||
|
const dayLabel = targetDay.charAt(0) + targetDay.slice(1).toLowerCase();
|
||||||
|
return { sent: false, message: `${doctorName} is not available on ${dayLabel} (${targetDate}).` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeSlots: { time: string; clinic: string }[] = [];
|
||||||
|
for (const ds of daySlots) {
|
||||||
|
const startHour = parseInt(ds.startTime?.split(':')[0] ?? '9', 10);
|
||||||
|
const endHour = parseInt(ds.endTime?.split(':')[0] ?? '17', 10);
|
||||||
|
const clinicName = ds.clinic?.clinicName ?? '';
|
||||||
|
for (let h = startHour; h < endHour && timeSlots.length < 10; h++) {
|
||||||
|
timeSlots.push({ time: `${String(h).padStart(2, '0')}:00`, clinic: clinicName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!timeSlots.length) return { sent: false, message: `No slots for ${doctorName} on ${targetDate}.` };
|
||||||
|
|
||||||
|
const sections: ListSection[] = [{
|
||||||
|
title: targetDate,
|
||||||
|
rows: timeSlots.map(s => ({
|
||||||
|
id: `slot:${doctorId}:${targetDate}T${s.time}:00+05:30`,
|
||||||
|
title: s.time,
|
||||||
|
description: s.clinic || undefined,
|
||||||
|
})),
|
||||||
|
}];
|
||||||
|
await ctx.provider.sendList(ctx.phone, `Available slots for ${doctorName}:`, 'View Slots', sections);
|
||||||
|
return { sent: true, slots: timeSlots.length };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.register('send_confirm_buttons', async (inputs, ctx) => {
|
||||||
|
const buttons: InteractiveButton[] = [
|
||||||
|
{ id: 'confirm_booking', title: 'Confirm' },
|
||||||
|
{ id: 'cancel_booking', title: 'Cancel' },
|
||||||
|
];
|
||||||
|
await ctx.provider.sendButtons(ctx.phone, inputs.summary, buttons);
|
||||||
|
return { sent: true };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.register('book_appointment', async (inputs, ctx) => {
|
||||||
|
const { patientName, phoneNumber, department, doctorName, scheduledAt, reason } = inputs;
|
||||||
|
const cleanPhone = (phoneNumber ?? ctx.phone).replace(/[^0-9]/g, '').slice(-10);
|
||||||
|
|
||||||
|
// Conflict check
|
||||||
|
const bookingDate = scheduledAt.split('T')[0];
|
||||||
|
const existingAppts = await this.platform.query<any>(
|
||||||
|
`{ appointments(first: 50, filter: { doctorName: { eq: "${doctorName}" } }, orderBy: [{ scheduledAt: AscNullsLast }]) { edges { node { id scheduledAt status patientName } } } }`,
|
||||||
|
).catch(() => ({ appointments: { edges: [] } }));
|
||||||
|
|
||||||
|
const conflicts = existingAppts.appointments.edges
|
||||||
|
.map((e: any) => e.node)
|
||||||
|
.filter((a: any) => a.status === 'SCHEDULED' && a.scheduledAt?.startsWith(bookingDate));
|
||||||
|
|
||||||
|
const slotConflicts = conflicts.filter((a: any) => a.scheduledAt === scheduledAt);
|
||||||
|
if (slotConflicts.length >= 3) {
|
||||||
|
return { booked: false, message: `${doctorName} is fully booked at this time.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve caller — creates lead/patient if new
|
||||||
|
const resolved = await this.caller.resolve(cleanPhone, ctx.auth).catch(() => null);
|
||||||
|
let patientId = resolved?.patientId;
|
||||||
|
|
||||||
|
if (resolved?.isNew && patientName) {
|
||||||
|
const firstName = patientName.split(' ')[0];
|
||||||
|
const lastName = patientName.split(' ').slice(1).join(' ') || '';
|
||||||
|
try {
|
||||||
|
const p = await this.platform.query<any>(
|
||||||
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
|
{ data: { fullName: { firstName, lastName }, phones: { primaryPhoneNumber: `+91${cleanPhone}` }, patientType: 'NEW' } },
|
||||||
|
);
|
||||||
|
patientId = p?.createPatient?.id;
|
||||||
|
await this.platform.query<any>(
|
||||||
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
|
{ data: { name: `WhatsApp — ${patientName}`, contactName: { firstName, lastName }, contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` }, source: 'WHATSAPP', status: 'NEW', interestedService: department, ...(patientId ? { patientId } : {}) } },
|
||||||
|
);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Book — include patientId so appointment is linked to patient record
|
||||||
|
const result = await this.platform.query<any>(
|
||||||
|
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
||||||
|
{ data: { name: `WhatsApp Booking — ${patientName} (${department})`, scheduledAt: scheduledAt.includes('+') || scheduledAt.includes('Z') ? scheduledAt : `${scheduledAt}+05:30`, status: 'SCHEDULED', doctorName, department, reasonForVisit: reason ?? 'General Consultation', ...(patientId ? { patientId } : {}) } },
|
||||||
|
);
|
||||||
|
const id = result?.createAppointment?.id;
|
||||||
|
if (id) {
|
||||||
|
return { booked: true, appointmentId: id, reference: id.substring(0, 8) };
|
||||||
|
}
|
||||||
|
return { booked: false, message: 'Booking failed.' };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.register('lookup_appointments', async (inputs, ctx) => {
|
||||||
|
const resolved = await this.caller.resolve(ctx.phone, ctx.auth).catch(() => null);
|
||||||
|
if (!resolved?.patientId) return { appointments: [], message: 'No patient record found.' };
|
||||||
|
|
||||||
|
const data = await this.platform.query<any>(
|
||||||
|
`{ appointments(first: 10, filter: { patientId: { eq: "${resolved.patientId}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||||
|
id scheduledAt status doctorName department reasonForVisit
|
||||||
|
} } } }`,
|
||||||
|
);
|
||||||
|
return { appointments: data.appointments.edges.map((e: any) => e.node) };
|
||||||
|
});
|
||||||
|
|
||||||
|
this.register('send_appointment_qr', async (inputs, ctx) => {
|
||||||
|
const { appointmentId, reference, patientName, doctorName, department, scheduledAt } = inputs;
|
||||||
|
if (!appointmentId) return { sent: false, message: 'No appointment ID.' };
|
||||||
|
|
||||||
|
await this.qr.generate(appointmentId, {
|
||||||
|
reference: reference ?? appointmentId.substring(0, 8),
|
||||||
|
patientName: patientName ?? '',
|
||||||
|
doctorName: doctorName ?? '',
|
||||||
|
department: department ?? '',
|
||||||
|
scheduledAt: scheduledAt ?? '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const qrUrl = `${this.sidecarUrl}/api/messaging/qr/${appointmentId}`;
|
||||||
|
await ctx.provider.sendImage(ctx.phone, qrUrl, `Your appointment QR code — show this at the hospital reception desk.`);
|
||||||
|
this.logger.log(`[TOOL] send_appointment_qr: sent QR for ${reference ?? appointmentId}`);
|
||||||
|
return { sent: true, qrUrl };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/messaging/messaging-conversation.service.ts
Normal file
41
src/messaging/messaging-conversation.service.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import Redis from 'ioredis';
|
||||||
|
import { ConversationEntry } from './types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MessagingConversationService {
|
||||||
|
private readonly logger = new Logger(MessagingConversationService.name);
|
||||||
|
private readonly redis: Redis;
|
||||||
|
private readonly ttlSec = 24 * 60 * 60; // 24h — matches WhatsApp session window
|
||||||
|
private readonly maxHistory = 20;
|
||||||
|
|
||||||
|
constructor(config: ConfigService) {
|
||||||
|
const redisUrl = config.get<string>('redis.url') ?? 'redis://localhost:6379';
|
||||||
|
this.redis = new Redis(redisUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private key(phone: string): string {
|
||||||
|
return `wa:conv:${phone}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getHistory(phone: string): Promise<ConversationEntry[]> {
|
||||||
|
const raw = await this.redis.get(this.key(phone));
|
||||||
|
if (!raw) return [];
|
||||||
|
try {
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async addMessages(phone: string, entries: ConversationEntry[]): Promise<void> {
|
||||||
|
const existing = await this.getHistory(phone);
|
||||||
|
const updated = [...existing, ...entries].slice(-this.maxHistory);
|
||||||
|
await this.redis.setex(this.key(phone), this.ttlSec, JSON.stringify(updated));
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(phone: string): Promise<void> {
|
||||||
|
await this.redis.del(this.key(phone));
|
||||||
|
}
|
||||||
|
}
|
||||||
52
src/messaging/messaging.controller.ts
Normal file
52
src/messaging/messaging.controller.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import { Controller, Post, Get, Body, Param, Res, Logger } from '@nestjs/common';
|
||||||
|
import type { Response } from 'express';
|
||||||
|
import { MessagingProvider } from './providers/messaging-provider.interface';
|
||||||
|
import { MessagingService } from './messaging.service';
|
||||||
|
import { QrService } from './qr.service';
|
||||||
|
|
||||||
|
@Controller('api/messaging')
|
||||||
|
export class MessagingController {
|
||||||
|
private readonly logger = new Logger(MessagingController.name);
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly provider: MessagingProvider,
|
||||||
|
private readonly messaging: MessagingService,
|
||||||
|
private readonly qr: QrService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Post('webhook')
|
||||||
|
async webhook(@Body() body: any) {
|
||||||
|
this.logger.log(`[WA-WEBHOOK] Received: ${JSON.stringify(body).substring(0, 500)}`);
|
||||||
|
|
||||||
|
if (!this.provider.validateWebhook(body)) {
|
||||||
|
this.logger.warn('[WA-WEBHOOK] Validation failed — ignoring');
|
||||||
|
return { status: 'ignored', reason: 'validation failed' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = this.provider.parseInbound(body);
|
||||||
|
if (!message) {
|
||||||
|
this.logger.log('[WA-WEBHOOK] Non-message event — skipped');
|
||||||
|
return { status: 'ok', type: body?.type ?? 'unknown' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle async — don't block webhook response
|
||||||
|
this.messaging.handleInbound(message).catch(err => {
|
||||||
|
this.logger.error(`[WA-WEBHOOK] handleInbound failed: ${err.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { status: 'ok' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve QR code images — Gupshup needs a public URL to send images
|
||||||
|
@Get('qr/:appointmentId')
|
||||||
|
async serveQr(@Param('appointmentId') appointmentId: string, @Res() res: Response) {
|
||||||
|
const png = this.qr.get(appointmentId);
|
||||||
|
if (!png) {
|
||||||
|
res.status(404).json({ error: 'QR code not found or expired' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.set('Content-Type', 'image/png');
|
||||||
|
res.set('Cache-Control', 'public, max-age=86400');
|
||||||
|
res.send(png);
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/messaging/messaging.module.ts
Normal file
38
src/messaging/messaging.module.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { PlatformModule } from '../platform/platform.module';
|
||||||
|
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
||||||
|
import { MessagingController } from './messaging.controller';
|
||||||
|
import { MessagingService } from './messaging.service';
|
||||||
|
import { MessagingConversationService } from './messaging-conversation.service';
|
||||||
|
import { GupshupProvider } from './providers/gupshup.provider';
|
||||||
|
import { MessagingProvider } from './providers/messaging-provider.interface';
|
||||||
|
import { FlowExecutionService } from './flow/flow-execution.service';
|
||||||
|
import { FlowSessionService } from './flow/flow-session.service';
|
||||||
|
import { FlowStoreService } from './flow/flow-store.service';
|
||||||
|
import { FlowVariableService } from './flow/flow-variable.service';
|
||||||
|
import { ToolRegistry } from './flow/tool-registry';
|
||||||
|
import { QrService } from './qr.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PlatformModule, CallerResolutionModule],
|
||||||
|
controllers: [MessagingController],
|
||||||
|
providers: [
|
||||||
|
MessagingService,
|
||||||
|
MessagingConversationService,
|
||||||
|
FlowExecutionService,
|
||||||
|
FlowSessionService,
|
||||||
|
FlowStoreService,
|
||||||
|
FlowVariableService,
|
||||||
|
ToolRegistry,
|
||||||
|
QrService,
|
||||||
|
{
|
||||||
|
provide: MessagingProvider,
|
||||||
|
useFactory: (config: ConfigService) => {
|
||||||
|
return new GupshupProvider(config);
|
||||||
|
},
|
||||||
|
inject: [ConfigService],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class MessagingModule {}
|
||||||
420
src/messaging/messaging.service.ts
Normal file
420
src/messaging/messaging.service.ts
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
import { Injectable, Inject, Logger, Optional } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { generateText, tool, stepCountIs } from 'ai';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { MessagingProvider } from './providers/messaging-provider.interface';
|
||||||
|
import { MessagingConversationService } from './messaging-conversation.service';
|
||||||
|
import { FlowExecutionService } from './flow/flow-execution.service';
|
||||||
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
|
import { CallerContextService } from '../caller/caller-context.service';
|
||||||
|
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||||
|
import { createAiModel } from '../ai/ai-provider';
|
||||||
|
import { AiConfigService } from '../config/ai-config.service';
|
||||||
|
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../shared/doctor-utils';
|
||||||
|
import type { NormalizedMessage, ListSection, InteractiveButton } from './types';
|
||||||
|
import type { LanguageModel } from 'ai';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MessagingService {
|
||||||
|
private readonly logger = new Logger(MessagingService.name);
|
||||||
|
private readonly aiModel: LanguageModel | null;
|
||||||
|
private readonly auth: string;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private config: ConfigService,
|
||||||
|
private provider: MessagingProvider,
|
||||||
|
private conversation: MessagingConversationService,
|
||||||
|
private caller: CallerResolutionService,
|
||||||
|
private callerContext: CallerContextService,
|
||||||
|
private platform: PlatformGraphqlService,
|
||||||
|
private aiConfig: AiConfigService,
|
||||||
|
@Optional() private flowExecution: FlowExecutionService,
|
||||||
|
) {
|
||||||
|
const cfg = aiConfig.getConfig();
|
||||||
|
this.aiModel = createAiModel({
|
||||||
|
provider: cfg.provider,
|
||||||
|
model: cfg.model,
|
||||||
|
anthropicApiKey: config.get<string>('ai.anthropicApiKey'),
|
||||||
|
openaiApiKey: config.get<string>('ai.openaiApiKey'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
|
this.auth = apiKey ? `Bearer ${apiKey}` : '';
|
||||||
|
|
||||||
|
if (this.aiModel) {
|
||||||
|
this.logger.log(`WhatsApp AI configured: ${cfg.provider}/${cfg.model}`);
|
||||||
|
} else {
|
||||||
|
this.logger.warn('WhatsApp AI not configured — will send fallback replies');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleInbound(message: NormalizedMessage): Promise<void> {
|
||||||
|
const { phone, name, text } = message;
|
||||||
|
const replyId = message.interactiveReply?.id;
|
||||||
|
this.logger.log(`[WA] Inbound from ${phone} (${name}): ${text.substring(0, 100)}${replyId ? ` [reply_id=${replyId}]` : ''}`);
|
||||||
|
|
||||||
|
// Delegate to flow engine if published flows exist
|
||||||
|
if (this.flowExecution?.hasFlows()) {
|
||||||
|
this.logger.log(`[WA] Delegating to flow engine`);
|
||||||
|
await this.flowExecution.handleMessage(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: hardcoded AI chat (legacy — will be removed once flows are validated)
|
||||||
|
if (!this.aiModel) {
|
||||||
|
await this.provider.sendText(phone, 'Our assistant is temporarily unavailable. Please call us directly.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Resolve caller
|
||||||
|
const resolved = await this.caller.resolve(phone, this.auth).catch(err => {
|
||||||
|
this.logger.error(`[WA] Caller resolution failed: ${err.message}`);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Build context
|
||||||
|
let callerContextPrompt = '';
|
||||||
|
if (resolved && !resolved.isNew && resolved.leadId) {
|
||||||
|
const ctx = await this.callerContext.getOrBuild(resolved.leadId, resolved.patientId ?? '', this.auth).catch(() => null);
|
||||||
|
if (ctx) {
|
||||||
|
callerContextPrompt = this.callerContext.renderForPrompt(ctx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Load conversation history
|
||||||
|
const history = await this.conversation.getHistory(phone);
|
||||||
|
// For interactive replies, include the selection ID so the AI can
|
||||||
|
// extract structured data (e.g. "doc:{uuid}:{name}" → doctorId)
|
||||||
|
let userContent = text;
|
||||||
|
if (message.type === 'interactive_reply' && message.interactiveReply?.id) {
|
||||||
|
userContent = `[Selected: ${message.interactiveReply.title}] (selection_id: ${message.interactiveReply.id})`;
|
||||||
|
}
|
||||||
|
const messages = [
|
||||||
|
...history.map(h => ({ role: h.role as 'user' | 'assistant', content: h.content })),
|
||||||
|
{ role: 'user' as const, content: userContent },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 4. Build system prompt
|
||||||
|
const systemPrompt = this.buildSystemPrompt(callerContextPrompt, name, phone, resolved?.isNew ?? true);
|
||||||
|
|
||||||
|
// 5. Build tools
|
||||||
|
const tools = this.buildTools(phone);
|
||||||
|
|
||||||
|
// 6. Run AI
|
||||||
|
try {
|
||||||
|
const result = await generateText({
|
||||||
|
model: this.aiModel,
|
||||||
|
system: systemPrompt,
|
||||||
|
messages,
|
||||||
|
tools,
|
||||||
|
stopWhen: stepCountIs(5),
|
||||||
|
});
|
||||||
|
|
||||||
|
const reply = result.text?.trim();
|
||||||
|
if (reply) {
|
||||||
|
await this.provider.sendText(phone, reply);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Persist conversation
|
||||||
|
await this.conversation.addMessages(phone, [
|
||||||
|
{ role: 'user', content: text, timestamp: Date.now() },
|
||||||
|
...(reply ? [{ role: 'assistant' as const, content: reply, timestamp: Date.now() }] : []),
|
||||||
|
]);
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[WA] AI error: ${err.message}`);
|
||||||
|
await this.provider.sendText(phone, 'Sorry, I encountered an error. Please try again or call us directly.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildSystemPrompt(callerContext: string, name: string, phone: string, isNew: boolean): string {
|
||||||
|
// Pull hospital name from theme config if available
|
||||||
|
const hospitalName = this.config.get<string>('theme.hospitalName') ?? 'our hospital';
|
||||||
|
|
||||||
|
return `You are a friendly WhatsApp assistant for ${hospitalName}. You help patients with:
|
||||||
|
- Answering questions about departments, doctors, timings, fees
|
||||||
|
- Booking appointments
|
||||||
|
- Checking existing appointments
|
||||||
|
|
||||||
|
APPOINTMENT BOOKING FLOW — follow this exact sequence:
|
||||||
|
1. When the patient wants to book, IMMEDIATELY call send_department_list. Do NOT ask "which department" in text.
|
||||||
|
2. When the patient picks a department (selection_id starts with "dept:"), IMMEDIATELY call send_doctor_list with the department name after "dept:".
|
||||||
|
3. When the patient picks a doctor (selection_id starts with "doc:"), IMMEDIATELY call send_slot_list. Extract the doctorId from the selection_id format "doc:{doctorId}:{doctorName}" — use the UUID between the first and second colon as doctorId, and the text after the second colon as doctorName.
|
||||||
|
4. When the patient picks a slot (selection_id starts with "slot:"), call send_confirm_buttons with a summary. Extract the datetime from "slot:{doctorId}:{datetime}".
|
||||||
|
5. When the patient taps Confirm (selection_id = "confirm_booking"), call book_appointment with all collected details.
|
||||||
|
6. After booking, send a confirmation with doctor name, date, time, and reference number.
|
||||||
|
|
||||||
|
CRITICAL: Always use the interactive list/button tools. Never ask questions in text when a tool exists. When a user message contains "selection_id:", parse it and call the appropriate tool immediately.
|
||||||
|
|
||||||
|
OTHER RULES:
|
||||||
|
- Be concise — WhatsApp messages should be short (2-3 sentences max).
|
||||||
|
- No markdown formatting (no **, ##, bullets). Plain text only.
|
||||||
|
- If the patient mentions a specific department or doctor upfront, skip ahead in the flow.
|
||||||
|
- If the patient asks something you can't help with, suggest they call ${hospitalName} directly.
|
||||||
|
- Always be warm and professional. Use the patient's name when known.
|
||||||
|
- Reply in the same language the patient uses. Button/list labels stay in English.
|
||||||
|
|
||||||
|
CURRENT PATIENT:
|
||||||
|
Name: ${name || 'Unknown'}
|
||||||
|
Phone: ${phone}
|
||||||
|
${isNew ? 'New patient — no prior records.' : ''}
|
||||||
|
${callerContext ? `\n${callerContext}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private buildTools(phone: string) {
|
||||||
|
const provider = this.provider;
|
||||||
|
const platform = this.platform;
|
||||||
|
const auth = this.auth;
|
||||||
|
const logger = this.logger;
|
||||||
|
const callerService = this.caller;
|
||||||
|
|
||||||
|
return {
|
||||||
|
lookup_appointments: tool({
|
||||||
|
description: 'Look up existing appointments for the current patient.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
patientId: z.string().optional().describe('Patient ID — omit to use current caller'),
|
||||||
|
}),
|
||||||
|
execute: async ({ patientId }) => {
|
||||||
|
let pid = patientId;
|
||||||
|
if (!pid) {
|
||||||
|
const resolved = await callerService.resolve(phone, auth).catch(() => null);
|
||||||
|
pid = resolved?.patientId;
|
||||||
|
}
|
||||||
|
if (!pid) return { appointments: [], message: 'No patient record found.' };
|
||||||
|
|
||||||
|
const data = await platform.query<any>(
|
||||||
|
`{ appointments(first: 10, filter: { patientId: { eq: "${pid}" } }, orderBy: [{ scheduledAt: DescNullsLast }]) { edges { node {
|
||||||
|
id scheduledAt appointmentStatus doctorName department reasonForVisit
|
||||||
|
} } } }`,
|
||||||
|
);
|
||||||
|
const appts = data.appointments.edges.map((e: any) => e.node);
|
||||||
|
logger.log(`[WA-TOOL] lookup_appointments: ${appts.length} found`);
|
||||||
|
return { appointments: appts };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
send_department_list: tool({
|
||||||
|
description: 'Send an interactive WhatsApp list of available departments. Call when patient wants to book but hasn\'t specified a department.',
|
||||||
|
inputSchema: z.object({}),
|
||||||
|
execute: async () => {
|
||||||
|
const data = await platform.query<any>(
|
||||||
|
`{ doctors(first: 50) { edges { node { department } } } }`,
|
||||||
|
);
|
||||||
|
const departments = [...new Set(
|
||||||
|
data.doctors.edges.map((e: any) => e.node.department).filter(Boolean),
|
||||||
|
)] as string[];
|
||||||
|
|
||||||
|
if (!departments.length) return { sent: false, message: 'No departments available.' };
|
||||||
|
|
||||||
|
const sections: ListSection[] = [{
|
||||||
|
title: 'Departments',
|
||||||
|
rows: departments.slice(0, 10).map(d => ({
|
||||||
|
id: `dept:${d}`,
|
||||||
|
title: d.substring(0, 24),
|
||||||
|
})),
|
||||||
|
}];
|
||||||
|
await provider.sendList(phone, 'Which department would you like to visit?', 'View Departments', sections);
|
||||||
|
logger.log(`[WA-TOOL] send_department_list: ${departments.length} departments`);
|
||||||
|
return { sent: true, departments };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
send_doctor_list: tool({
|
||||||
|
description: 'Send an interactive WhatsApp list of doctors in a department. Call after patient selects a department.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
department: z.string().describe('Department name'),
|
||||||
|
}),
|
||||||
|
execute: async ({ department }) => {
|
||||||
|
const data = await platform.query<any>(
|
||||||
|
`{ doctors(first: 50) { edges { node {
|
||||||
|
id fullName { firstName lastName }
|
||||||
|
department specialty
|
||||||
|
consultationFeeNew { amountMicros currencyCode }
|
||||||
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||||
|
} } } }`,
|
||||||
|
);
|
||||||
|
const allDocs = normalizeDoctors(data.doctors.edges.map((e: any) => e.node));
|
||||||
|
const deptDocs = allDocs.filter((d: any) =>
|
||||||
|
d.department?.toLowerCase() === department.toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!deptDocs.length) return { sent: false, message: `No doctors found in ${department}.` };
|
||||||
|
|
||||||
|
const sections: ListSection[] = [{
|
||||||
|
title: department.substring(0, 24),
|
||||||
|
rows: deptDocs.slice(0, 10).map((d: any) => {
|
||||||
|
const docName = `Dr. ${d.fullName?.firstName ?? ''} ${d.fullName?.lastName ?? ''}`.trim();
|
||||||
|
const fee = d.consultationFeeNew?.amountMicros
|
||||||
|
? `₹${(d.consultationFeeNew.amountMicros / 1000000).toFixed(0)}`
|
||||||
|
: '';
|
||||||
|
return {
|
||||||
|
id: `doc:${d.id}:${docName}`,
|
||||||
|
title: docName.substring(0, 24),
|
||||||
|
description: fee ? `${d.specialty ?? department} — ${fee}` : (d.specialty ?? department),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
}];
|
||||||
|
await provider.sendList(phone, `Doctors in ${department}:`, 'View Doctors', sections);
|
||||||
|
logger.log(`[WA-TOOL] send_doctor_list: ${deptDocs.length} doctors in ${department}`);
|
||||||
|
return { sent: true, count: deptDocs.length };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
send_slot_list: tool({
|
||||||
|
description: 'Send available time slots for a doctor as a WhatsApp list. Call after patient selects a doctor.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
doctorId: z.string().describe('Doctor ID from the list selection'),
|
||||||
|
doctorName: z.string().describe('Doctor name for display'),
|
||||||
|
date: z.string().optional().describe('Date in YYYY-MM-DD. Defaults to tomorrow.'),
|
||||||
|
}),
|
||||||
|
execute: async ({ doctorId, doctorName, date }) => {
|
||||||
|
// Default to tomorrow, use IST for day-of-week matching
|
||||||
|
const targetDate = date ?? new Date(Date.now() + 86400000).toISOString().split('T')[0];
|
||||||
|
const dayNames = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'];
|
||||||
|
const targetDay = dayNames[new Date(targetDate + 'T00:00:00+05:30').getDay()];
|
||||||
|
|
||||||
|
const data = await platform.query<any>(
|
||||||
|
`{ doctors(first: 50) { edges { node {
|
||||||
|
id fullName { firstName lastName }
|
||||||
|
${DOCTOR_VISIT_SLOTS_FRAGMENT}
|
||||||
|
} } } }`,
|
||||||
|
);
|
||||||
|
const rawDocs = data.doctors.edges.map((e: any) => e.node);
|
||||||
|
const doctor = rawDocs.find((d: any) => d.id === doctorId);
|
||||||
|
if (!doctor) {
|
||||||
|
return { sent: false, message: `Doctor not found.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find visit slots for the target day-of-week
|
||||||
|
const rawSlots = doctor.visitSlots?.edges?.map((e: any) => e.node) ?? [];
|
||||||
|
const daySlots = rawSlots.filter((s: any) => s.dayOfWeek === targetDay);
|
||||||
|
|
||||||
|
if (!daySlots.length) {
|
||||||
|
return { sent: false, message: `${doctorName} is not available on ${targetDay.charAt(0) + targetDay.slice(1).toLowerCase()} (${targetDate}). Please choose a different date.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate hourly time slots from startTime-endTime
|
||||||
|
const timeSlots: { time: string; clinic: string }[] = [];
|
||||||
|
for (const ds of daySlots) {
|
||||||
|
const startHour = parseInt(ds.startTime?.split(':')[0] ?? '9', 10);
|
||||||
|
const endHour = parseInt(ds.endTime?.split(':')[0] ?? '17', 10);
|
||||||
|
const clinicName = ds.clinic?.clinicName ?? '';
|
||||||
|
for (let h = startHour; h < endHour && timeSlots.length < 10; h++) {
|
||||||
|
timeSlots.push({ time: `${String(h).padStart(2, '0')}:00`, clinic: clinicName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!timeSlots.length) {
|
||||||
|
return { sent: false, message: `No slots available for ${doctorName} on ${targetDate}.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections: ListSection[] = [{
|
||||||
|
title: targetDate, // section title max 24 chars
|
||||||
|
rows: timeSlots.map((s) => ({
|
||||||
|
id: `slot:${doctorId}:${targetDate}T${s.time}:00`,
|
||||||
|
title: s.time, // row title max 24 chars
|
||||||
|
description: s.clinic || undefined,
|
||||||
|
})),
|
||||||
|
}];
|
||||||
|
await provider.sendList(phone, `Available slots for ${doctorName}:`, 'View Slots', sections);
|
||||||
|
logger.log(`[WA-TOOL] send_slot_list: ${timeSlots.length} slots for ${doctorName} on ${targetDate} (${targetDay})`);
|
||||||
|
return { sent: true, slots: timeSlots.length };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
send_confirm_buttons: tool({
|
||||||
|
description: 'Send confirmation buttons before booking. Call after all details are collected.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
summary: z.string().describe('Appointment summary to show the patient'),
|
||||||
|
}),
|
||||||
|
execute: async ({ summary }) => {
|
||||||
|
const buttons: InteractiveButton[] = [
|
||||||
|
{ id: 'confirm_booking', title: 'Confirm' },
|
||||||
|
{ id: 'cancel_booking', title: 'Cancel' },
|
||||||
|
];
|
||||||
|
await provider.sendButtons(phone, summary, buttons);
|
||||||
|
logger.log(`[WA-TOOL] send_confirm_buttons`);
|
||||||
|
return { sent: true };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
|
||||||
|
book_appointment: tool({
|
||||||
|
description: 'Book the appointment after patient confirms. Only call AFTER the patient taps Confirm.',
|
||||||
|
inputSchema: z.object({
|
||||||
|
patientName: z.string().describe('Patient name'),
|
||||||
|
phoneNumber: z.string().describe('Patient phone number'),
|
||||||
|
department: z.string().describe('Department'),
|
||||||
|
doctorName: z.string().describe('Doctor name'),
|
||||||
|
scheduledAt: z.string().describe('ISO datetime'),
|
||||||
|
reason: z.string().describe('Reason for visit'),
|
||||||
|
}),
|
||||||
|
execute: async ({ patientName, phoneNumber, department, doctorName, scheduledAt, reason }) => {
|
||||||
|
logger.log(`[WA-BOOK] Booking: ${patientName} → ${doctorName} @ ${scheduledAt}`);
|
||||||
|
try {
|
||||||
|
const cleanPhone = phoneNumber.replace(/[^0-9]/g, '').slice(-10);
|
||||||
|
const resolved = await callerService.resolve(cleanPhone, auth).catch(() => null);
|
||||||
|
|
||||||
|
// Conflict check: same doctor + same date
|
||||||
|
const bookingDate = scheduledAt.split('T')[0];
|
||||||
|
const existingAppts = await platform.query<any>(
|
||||||
|
`{ appointments(first: 50, filter: { doctorName: { eq: "${doctorName}" } }, orderBy: [{ scheduledAt: AscNullsLast }]) { edges { node { id scheduledAt status patientName } } } }`,
|
||||||
|
).catch(() => ({ appointments: { edges: [] } }));
|
||||||
|
|
||||||
|
const conflicts = existingAppts.appointments.edges
|
||||||
|
.map((e: any) => e.node)
|
||||||
|
.filter((a: any) => a.status === 'SCHEDULED' && a.scheduledAt?.startsWith(bookingDate));
|
||||||
|
|
||||||
|
// Check if this patient already has a booking with this doctor on the same date
|
||||||
|
const patientConflict = conflicts.find((a: any) =>
|
||||||
|
a.patientName?.toLowerCase().includes(patientName.split(' ')[0].toLowerCase()),
|
||||||
|
);
|
||||||
|
if (patientConflict) {
|
||||||
|
logger.log(`[WA-BOOK] Conflict: patient already booked with ${doctorName} on ${bookingDate}`);
|
||||||
|
return { booked: false, message: `You already have an appointment with ${doctorName} on ${bookingDate}. Would you like to choose a different date?` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the doctor has too many appointments at this exact time
|
||||||
|
const slotConflicts = conflicts.filter((a: any) => a.scheduledAt === scheduledAt);
|
||||||
|
if (slotConflicts.length >= 3) {
|
||||||
|
logger.log(`[WA-BOOK] Conflict: ${doctorName} fully booked at ${scheduledAt} (${slotConflicts.length} existing)`);
|
||||||
|
return { booked: false, message: `${doctorName} is fully booked at this time. Please choose a different slot.` };
|
||||||
|
}
|
||||||
|
|
||||||
|
let patientId = resolved?.patientId;
|
||||||
|
if (resolved?.isNew) {
|
||||||
|
const firstName = patientName.split(' ')[0];
|
||||||
|
const lastName = patientName.split(' ').slice(1).join(' ') || '';
|
||||||
|
try {
|
||||||
|
const p = await platform.query<any>(
|
||||||
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
|
{ data: { fullName: { firstName, lastName }, phones: { primaryPhoneNumber: `+91${cleanPhone}` }, patientType: 'NEW' } },
|
||||||
|
);
|
||||||
|
const patientId = p?.createPatient?.id;
|
||||||
|
await platform.query<any>(
|
||||||
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
|
{ data: { name: `WhatsApp — ${patientName}`, contactName: { firstName, lastName }, contactPhone: { primaryPhoneNumber: `+91${cleanPhone}` }, source: 'WHATSAPP', status: 'NEW', interestedService: department, ...(patientId ? { patientId } : {}) } },
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.warn(`[WA-BOOK] Lead/patient creation failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await platform.query<any>(
|
||||||
|
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
||||||
|
{ data: { name: `WhatsApp Booking — ${patientName} (${department})`, scheduledAt, status: 'SCHEDULED', doctorName, department, reasonForVisit: reason, ...(patientId ? { patientId } : {}) } },
|
||||||
|
);
|
||||||
|
const id = result?.createAppointment?.id;
|
||||||
|
if (id) {
|
||||||
|
logger.log(`[WA-BOOK] Success: appointmentId=${id}`);
|
||||||
|
return { booked: true, appointmentId: id, message: `Appointment booked! Reference: ${id.substring(0, 8)}` };
|
||||||
|
}
|
||||||
|
return { booked: false, message: 'Booking failed. Please try again.' };
|
||||||
|
} catch (err: any) {
|
||||||
|
logger.error(`[WA-BOOK] Failed: ${err.message}`);
|
||||||
|
return { booked: false, message: 'Booking failed. Please call us directly.' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
154
src/messaging/providers/gupshup.provider.ts
Normal file
154
src/messaging/providers/gupshup.provider.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { MessagingProvider } from './messaging-provider.interface';
|
||||||
|
import { NormalizedMessage, InteractiveButton, ListSection } from '../types';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GupshupProvider extends MessagingProvider {
|
||||||
|
private readonly logger = new Logger(GupshupProvider.name);
|
||||||
|
private readonly apiKey: string;
|
||||||
|
private readonly appId: string;
|
||||||
|
private readonly sourceNumber: string;
|
||||||
|
private readonly apiUrl = 'https://api.gupshup.io/wa/api/v1/msg';
|
||||||
|
|
||||||
|
constructor(private config: ConfigService) {
|
||||||
|
super();
|
||||||
|
this.apiKey = config.get<string>('messaging.gupshup.apiKey') ?? '';
|
||||||
|
this.appId = config.get<string>('messaging.gupshup.appId') ?? '';
|
||||||
|
this.sourceNumber = config.get<string>('messaging.gupshup.sourceNumber') ?? '';
|
||||||
|
if (this.apiKey) {
|
||||||
|
this.logger.log(`Gupshup configured: appId=${this.appId} source=${this.sourceNumber}`);
|
||||||
|
} else {
|
||||||
|
this.logger.warn('Gupshup not configured — missing API key');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
validateWebhook(body: any): boolean {
|
||||||
|
return body?.app === this.appId || !this.appId;
|
||||||
|
}
|
||||||
|
|
||||||
|
parseInbound(body: any): NormalizedMessage | null {
|
||||||
|
if (body?.type !== 'message') return null;
|
||||||
|
|
||||||
|
const payload = body.payload;
|
||||||
|
if (!payload?.sender?.phone) return null;
|
||||||
|
|
||||||
|
const phone = payload.sender.phone.replace(/\D/g, '');
|
||||||
|
const name = payload.sender.name ?? '';
|
||||||
|
const msgType = payload.type;
|
||||||
|
|
||||||
|
if (msgType === 'text') {
|
||||||
|
return {
|
||||||
|
phone, name,
|
||||||
|
text: payload.payload?.text ?? payload.text ?? '',
|
||||||
|
type: 'text',
|
||||||
|
rawPayload: body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msgType === 'button_reply' || msgType === 'list_reply') {
|
||||||
|
// Gupshup sends postbackText (our ID), id can be empty string
|
||||||
|
const replyId = payload.payload?.postbackText || payload.payload?.id || payload.payload?.reply || '';
|
||||||
|
return {
|
||||||
|
phone, name,
|
||||||
|
text: payload.payload?.title ?? '',
|
||||||
|
type: 'interactive_reply',
|
||||||
|
interactiveReply: {
|
||||||
|
id: replyId,
|
||||||
|
title: payload.payload?.title ?? '',
|
||||||
|
},
|
||||||
|
rawPayload: body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msgType === 'location') {
|
||||||
|
return {
|
||||||
|
phone, name,
|
||||||
|
text: `Location: ${payload.payload?.latitude}, ${payload.payload?.longitude}`,
|
||||||
|
type: 'location',
|
||||||
|
rawPayload: body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (['image', 'audio', 'video', 'document', 'sticker'].includes(msgType)) {
|
||||||
|
return {
|
||||||
|
phone, name,
|
||||||
|
text: `[Sent ${msgType}]`,
|
||||||
|
type: 'image',
|
||||||
|
rawPayload: body,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.warn(`[GUPSHUP] Unknown message type: ${msgType}`);
|
||||||
|
return { phone, name, text: '', type: 'unknown', rawPayload: body };
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendText(to: string, text: string): Promise<void> {
|
||||||
|
await this.send(to, JSON.stringify({ type: 'text', text }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendButtons(to: string, body: string, buttons: InteractiveButton[]): Promise<void> {
|
||||||
|
const message = {
|
||||||
|
type: 'quick_reply',
|
||||||
|
content: { type: 'text', text: body },
|
||||||
|
options: buttons.map(b => ({ type: 'text', title: b.title, postbackText: b.id })),
|
||||||
|
};
|
||||||
|
await this.send(to, JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendImage(to: string, imageUrl: string, caption?: string): Promise<void> {
|
||||||
|
const message: any = {
|
||||||
|
type: 'image',
|
||||||
|
originalUrl: imageUrl,
|
||||||
|
previewUrl: imageUrl,
|
||||||
|
};
|
||||||
|
if (caption) message.caption = caption;
|
||||||
|
await this.send(to, JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendList(to: string, body: string, buttonText: string, sections: ListSection[]): Promise<void> {
|
||||||
|
const message = {
|
||||||
|
type: 'list',
|
||||||
|
title: buttonText,
|
||||||
|
body: body,
|
||||||
|
globalButtons: [{ type: 'text', title: buttonText }],
|
||||||
|
items: sections.map(s => ({
|
||||||
|
title: s.title,
|
||||||
|
options: s.rows.map(r => ({
|
||||||
|
type: 'text',
|
||||||
|
title: r.title,
|
||||||
|
description: r.description ?? '',
|
||||||
|
postbackText: r.id,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
await this.send(to, JSON.stringify(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async send(to: string, message: string): Promise<void> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.append('channel', 'whatsapp');
|
||||||
|
params.append('source', this.sourceNumber);
|
||||||
|
params.append('destination', to);
|
||||||
|
params.append('message', message);
|
||||||
|
params.append('src.name', this.appId);
|
||||||
|
|
||||||
|
this.logger.log(`[GUPSHUP] Sending to ${to}: ${message.substring(0, 500)}`);
|
||||||
|
|
||||||
|
const resp = await fetch(this.apiUrl, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'apikey': this.apiKey,
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
},
|
||||||
|
body: params.toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await resp.json().catch(() => resp.text());
|
||||||
|
if (!resp.ok) {
|
||||||
|
this.logger.error(`[GUPSHUP] Send failed (${resp.status}): ${JSON.stringify(result)}`);
|
||||||
|
throw new Error(`Gupshup send failed: ${resp.status}`);
|
||||||
|
}
|
||||||
|
this.logger.log(`[GUPSHUP] Sent: ${JSON.stringify(result)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/messaging/providers/messaging-provider.interface.ts
Normal file
21
src/messaging/providers/messaging-provider.interface.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { NormalizedMessage, InteractiveButton, ListSection } from '../types';
|
||||||
|
|
||||||
|
export abstract class MessagingProvider {
|
||||||
|
/** Parse raw webhook payload into normalized message */
|
||||||
|
abstract parseInbound(body: any): NormalizedMessage | null;
|
||||||
|
|
||||||
|
/** Send a plain text message */
|
||||||
|
abstract sendText(to: string, text: string): Promise<void>;
|
||||||
|
|
||||||
|
/** Send interactive buttons (max 3 for WhatsApp) */
|
||||||
|
abstract sendButtons(to: string, body: string, buttons: InteractiveButton[]): Promise<void>;
|
||||||
|
|
||||||
|
/** Send interactive list (max 10 rows total across sections) */
|
||||||
|
abstract sendList(to: string, body: string, buttonText: string, sections: ListSection[]): Promise<void>;
|
||||||
|
|
||||||
|
/** Send an image with optional caption */
|
||||||
|
abstract sendImage(to: string, imageUrl: string, caption?: string): Promise<void>;
|
||||||
|
|
||||||
|
/** Validate that inbound webhook is authentic */
|
||||||
|
abstract validateWebhook(body: any): boolean;
|
||||||
|
}
|
||||||
57
src/messaging/qr.service.ts
Normal file
57
src/messaging/qr.service.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import * as QRCode from 'qrcode';
|
||||||
|
|
||||||
|
// In-memory cache for generated QR images. Each entry expires after 24h.
|
||||||
|
// Key: appointmentId, Value: { png: Buffer, expiresAt: number }
|
||||||
|
const qrCache = new Map<string, { png: Buffer; expiresAt: number }>();
|
||||||
|
const TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class QrService {
|
||||||
|
private readonly logger = new Logger(QrService.name);
|
||||||
|
|
||||||
|
// Generate a QR code PNG for an appointment
|
||||||
|
async generate(appointmentId: string, data: {
|
||||||
|
reference: string;
|
||||||
|
patientName: string;
|
||||||
|
doctorName: string;
|
||||||
|
department: string;
|
||||||
|
scheduledAt: string;
|
||||||
|
}): Promise<Buffer> {
|
||||||
|
// QR content — JSON with appointment details for kiosk scanning
|
||||||
|
const qrContent = JSON.stringify({
|
||||||
|
type: 'helix-appointment',
|
||||||
|
id: appointmentId,
|
||||||
|
ref: data.reference,
|
||||||
|
patient: data.patientName,
|
||||||
|
doctor: data.doctorName,
|
||||||
|
department: data.department,
|
||||||
|
scheduledAt: data.scheduledAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const png = await QRCode.toBuffer(qrContent, {
|
||||||
|
type: 'png',
|
||||||
|
width: 400,
|
||||||
|
margin: 2,
|
||||||
|
color: { dark: '#000000', light: '#FFFFFF' },
|
||||||
|
errorCorrectionLevel: 'M',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cache for the image hosting endpoint
|
||||||
|
qrCache.set(appointmentId, { png, expiresAt: Date.now() + TTL_MS });
|
||||||
|
this.logger.log(`[QR] Generated for appointment ${data.reference} (${png.length} bytes)`);
|
||||||
|
|
||||||
|
return png;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve a cached QR image (for the hosting endpoint)
|
||||||
|
get(appointmentId: string): Buffer | null {
|
||||||
|
const entry = qrCache.get(appointmentId);
|
||||||
|
if (!entry) return null;
|
||||||
|
if (Date.now() > entry.expiresAt) {
|
||||||
|
qrCache.delete(appointmentId);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry.png;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
src/messaging/types.ts
Normal file
27
src/messaging/types.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
export type NormalizedMessage = {
|
||||||
|
phone: string; // E.164 without +, e.g. "919949879837"
|
||||||
|
name: string; // sender name from WhatsApp profile
|
||||||
|
text: string; // message text (or button reply title)
|
||||||
|
type: 'text' | 'interactive_reply' | 'location' | 'image' | 'unknown';
|
||||||
|
interactiveReply?: { // populated when user taps a button or list item
|
||||||
|
id: string; // button/row ID set by us
|
||||||
|
title: string; // display text
|
||||||
|
};
|
||||||
|
rawPayload: any; // original provider payload for debugging
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConversationEntry = {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
timestamp: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type InteractiveButton = {
|
||||||
|
id: string;
|
||||||
|
title: string; // max 20 chars for WhatsApp
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListSection = {
|
||||||
|
title: string;
|
||||||
|
rows: { id: string; title: string; description?: string }[];
|
||||||
|
};
|
||||||
@@ -156,11 +156,13 @@ export class OzonetelAgentController {
|
|||||||
this.logger.error(`[DISPOSE] FAILED: ${message} ${responseData}`);
|
this.logger.error(`[DISPOSE] FAILED: ${message} ${responseData}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create call record for outbound calls. Inbound calls are
|
// Create call record at dispose time for ALL answered calls
|
||||||
// created by the webhook — but we skip outbound in the webhook
|
// (inbound + outbound). The dispose endpoint fires BEFORE the
|
||||||
// (they're not "missed calls"). So the dispose endpoint is the
|
// CDR webhook, so creating here gives us the correct agent-side
|
||||||
// only place that creates the call record for outbound dials.
|
// UCID and the agent's chosen disposition immediately. The webhook
|
||||||
if (body.direction === 'OUTBOUND' && body.callerPhone) {
|
// arrives ~5s later and enriches with recording URL + chain name.
|
||||||
|
if (body.callerPhone) {
|
||||||
|
const isInbound = body.direction !== 'OUTBOUND';
|
||||||
try {
|
try {
|
||||||
const durationSec = body.durationSec ?? 0;
|
const durationSec = body.durationSec ?? 0;
|
||||||
const endedAt = new Date().toISOString();
|
const endedAt = new Date().toISOString();
|
||||||
@@ -168,8 +170,8 @@ export class OzonetelAgentController {
|
|||||||
? new Date(Date.now() - durationSec * 1000).toISOString()
|
? new Date(Date.now() - durationSec * 1000).toISOString()
|
||||||
: endedAt;
|
: endedAt;
|
||||||
const callData: Record<string, any> = {
|
const callData: Record<string, any> = {
|
||||||
name: `Outbound — ${body.callerPhone}`,
|
name: isInbound ? `Inbound — ${body.callerPhone}` : `Outbound — ${body.callerPhone}`,
|
||||||
direction: 'OUTBOUND',
|
direction: isInbound ? 'INBOUND' : 'OUTBOUND',
|
||||||
callStatus: 'COMPLETED',
|
callStatus: 'COMPLETED',
|
||||||
callerNumber: { primaryPhoneNumber: `+91${body.callerPhone.replace(/^\+?91/, '')}` },
|
callerNumber: { primaryPhoneNumber: `+91${body.callerPhone.replace(/^\+?91/, '')}` },
|
||||||
agentName: agentId,
|
agentName: agentId,
|
||||||
@@ -196,7 +198,7 @@ export class OzonetelAgentController {
|
|||||||
{ data: callData },
|
{ data: callData },
|
||||||
`Bearer ${apiKey}`,
|
`Bearer ${apiKey}`,
|
||||||
);
|
);
|
||||||
this.logger.log(`[DISPOSE] Created outbound call record: ${result.createCall.id}`);
|
this.logger.log(`[DISPOSE] Created ${isInbound ? 'inbound' : 'outbound'} call record: ${result.createCall.id} ucid=${body.ucid} disposition=${body.disposition} phone=${body.callerPhone}`);
|
||||||
|
|
||||||
// Fetch recording URL from CDR after a delay (Ozonetel needs time to process)
|
// Fetch recording URL from CDR after a delay (Ozonetel needs time to process)
|
||||||
const callId = result.createCall.id;
|
const callId = result.createCall.id;
|
||||||
@@ -278,33 +280,9 @@ export class OzonetelAgentController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update disposition on answered inbound calls. The webhook creates
|
// Inbound disposition is now handled by the call record creation
|
||||||
// the Call record with the Ozonetel default disposition ("General
|
// above — the dispose endpoint creates the record with the correct
|
||||||
// Enquiry" → INFO_PROVIDED) before the agent disposes. Now that the
|
// disposition. No separate update-by-UCID needed.
|
||||||
// agent has submitted their actual disposition, write it back to the
|
|
||||||
// platform Call record by matching on UCID.
|
|
||||||
//
|
|
||||||
// Skipped for outbound (already created with correct disposition
|
|
||||||
// above) and for missed-call callbacks (handled in the block above).
|
|
||||||
if (!body.missedCallId && body.direction !== 'OUTBOUND' && body.ucid) {
|
|
||||||
try {
|
|
||||||
const callData = await this.platform.query<any>(
|
|
||||||
`{ calls(first: 1, filter: { ucid: { eq: "${body.ucid}" } }) { edges { node { id } } } }`,
|
|
||||||
);
|
|
||||||
const callId = callData?.calls?.edges?.[0]?.node?.id;
|
|
||||||
if (callId) {
|
|
||||||
await this.platform.query<any>(
|
|
||||||
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
|
||||||
{ id: callId, data: { disposition: body.disposition } },
|
|
||||||
);
|
|
||||||
this.logger.log(`[DISPOSE] Updated inbound call ${callId} disposition → ${body.disposition}`);
|
|
||||||
} else {
|
|
||||||
this.logger.warn(`[DISPOSE] No Call found for ucid=${body.ucid} — disposition not persisted`);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
this.logger.warn(`[DISPOSE] Failed to update inbound call disposition: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-assign next missed call to this agent
|
// Auto-assign next missed call to this agent
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
private readonly activeCalls = new Map<string, ActiveCall>();
|
private readonly activeCalls = new Map<string, ActiveCall>();
|
||||||
private readonly agentStates = new Map<string, AgentStateEntry>();
|
private readonly agentStates = new Map<string, AgentStateEntry>();
|
||||||
private readonly acwTimers = new Map<string, NodeJS.Timeout>();
|
private readonly acwTimers = new Map<string, NodeJS.Timeout>();
|
||||||
|
// monitorUCID → agentUCID. Real-time events carry both; CDR webhook only has monitorUCID.
|
||||||
|
private readonly ucidMap = new Map<string, string>();
|
||||||
readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState | string; timestamp: string }>();
|
readonly agentStateSubject = new Subject<{ agentId: string; state: AgentOzonetelState | string; timestamp: string }>();
|
||||||
readonly activeCallSubject = new Subject<{ type: 'update' | 'remove'; call?: ActiveCall; ucid: string }>();
|
readonly activeCallSubject = new Subject<{ type: 'update' | 'remove'; call?: ActiveCall; ucid: string }>();
|
||||||
// Worklist update stream — emitted when a missed call is created or
|
// Worklist update stream — emitted when a missed call is created or
|
||||||
@@ -78,9 +80,14 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolveAgentUcid(monitorUcid: string): string | null {
|
||||||
|
return this.ucidMap.get(monitorUcid) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
handleCallEvent(event: any) {
|
handleCallEvent(event: any) {
|
||||||
const action = event.action;
|
const action = event.action;
|
||||||
const ucid = event.ucid ?? event.monitorUCID;
|
const ucid = event.ucid ?? event.monitorUCID;
|
||||||
|
const monitorUcid = event.monitor_ucid ?? event.monitorUCID;
|
||||||
const agentId = event.agent_id ?? event.agentID;
|
const agentId = event.agent_id ?? event.agentID;
|
||||||
const callerNumber = event.caller_id ?? event.callerID;
|
const callerNumber = event.caller_id ?? event.callerID;
|
||||||
const callType = event.call_type ?? event.Type;
|
const callType = event.call_type ?? event.Type;
|
||||||
@@ -89,6 +96,12 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
|
|
||||||
if (!ucid) return;
|
if (!ucid) return;
|
||||||
|
|
||||||
|
if (monitorUcid && ucid !== monitorUcid) {
|
||||||
|
this.ucidMap.set(monitorUcid, ucid);
|
||||||
|
this.logger.log(`[UCID-MAP] monitor=${monitorUcid} → agent=${ucid}`);
|
||||||
|
setTimeout(() => this.ucidMap.delete(monitorUcid), 600_000);
|
||||||
|
}
|
||||||
|
|
||||||
if (action === 'Answered' || action === 'Calling') {
|
if (action === 'Answered' || action === 'Calling') {
|
||||||
// Don't show calls for offline agents (ghost calls)
|
// Don't show calls for offline agents (ghost calls)
|
||||||
const agentState = this.agentStates.get(agentId);
|
const agentState = this.agentStates.get(agentId);
|
||||||
@@ -163,26 +176,30 @@ export class SupervisorService implements OnModuleInit {
|
|||||||
|
|
||||||
const priorState = this.agentStates.get(agentId)?.state;
|
const priorState = this.agentStates.get(agentId)?.state;
|
||||||
const mapped = this.mapOzonetelAction(action, eventData, pauseReason);
|
const mapped = this.mapOzonetelAction(action, eventData, pauseReason);
|
||||||
|
|
||||||
|
// Persist to AgentEvent table regardless of state mapping.
|
||||||
|
// login returns null for state (UI waits for release/ready) but
|
||||||
|
// the history pipeline needs LOGIN to compute loginDuration.
|
||||||
|
const historyEventType = this.mapToHistoryEventType(action, priorState);
|
||||||
|
if (historyEventType) {
|
||||||
|
const resolvedPauseReason = (pauseReason || eventData || '') || null;
|
||||||
|
this.logger.log(`[AGENT-HISTORY] ${agentId} action=${action} → eventType=${historyEventType} priorState=${priorState ?? 'none'} mapped=${mapped ?? 'null'}`);
|
||||||
|
this.history.persistAgentEvent({
|
||||||
|
ozonetelAgentId: agentId,
|
||||||
|
eventType: historyEventType,
|
||||||
|
eventAt: this.parseOzonetelTime(eventTime),
|
||||||
|
pauseReason: historyEventType === 'PAUSE' ? resolvedPauseReason : null,
|
||||||
|
}).catch((err) => {
|
||||||
|
this.logger.warn(`[AGENT-HISTORY] Failed to persist ${historyEventType} for ${agentId}: ${err?.message ?? err}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.logger.log(`[AGENT-HISTORY] ${agentId} action=${action} → no history event (priorState=${priorState ?? 'none'} mapped=${mapped ?? 'null'})`);
|
||||||
|
}
|
||||||
|
|
||||||
if (mapped) {
|
if (mapped) {
|
||||||
this.agentStates.set(agentId, { state: mapped, timestamp: eventTime });
|
this.agentStates.set(agentId, { state: mapped, timestamp: eventTime });
|
||||||
this.agentStateSubject.next({ agentId, state: mapped, timestamp: eventTime });
|
this.agentStateSubject.next({ agentId, state: mapped, timestamp: eventTime });
|
||||||
this.logger.log(`[AGENT-STATE] Emitted: ${agentId} → ${mapped}`);
|
this.logger.log(`[AGENT-STATE] ${agentId} ${priorState ?? 'none'} → ${mapped} (action=${action})`);
|
||||||
|
|
||||||
// Persist to AgentEvent table. CALL_START/CALL_END are
|
|
||||||
// handled in handleCallEvent (they arrive via a separate
|
|
||||||
// Ozonetel webhook). Everything else is captured here.
|
|
||||||
// Pass priorState so 'release' → RESUME / ACW_END / READY can
|
|
||||||
// be disambiguated for the session rollup.
|
|
||||||
const historyEventType = this.mapToHistoryEventType(action, priorState);
|
|
||||||
if (historyEventType) {
|
|
||||||
const resolvedPauseReason = (pauseReason || eventData || '') || null;
|
|
||||||
this.history.persistAgentEvent({
|
|
||||||
ozonetelAgentId: agentId,
|
|
||||||
eventType: historyEventType,
|
|
||||||
eventAt: this.parseOzonetelTime(eventTime),
|
|
||||||
pauseReason: historyEventType === 'PAUSE' ? resolvedPauseReason : null,
|
|
||||||
}).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Layer 3: ACW auto-dispose safety net
|
// Layer 3: ACW auto-dispose safety net
|
||||||
if (mapped === 'acw') {
|
if (mapped === 'acw') {
|
||||||
|
|||||||
@@ -7,12 +7,11 @@ import { PlatformModule } from '../platform/platform.module';
|
|||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { ConfigThemeModule } from '../config/config-theme.module';
|
import { ConfigThemeModule } from '../config/config-theme.module';
|
||||||
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
import { CallerResolutionModule } from '../caller/caller-resolution.module';
|
||||||
|
import { LeadsModule } from '../leads/leads.module';
|
||||||
|
import { SupervisorModule } from '../supervisor/supervisor.module';
|
||||||
|
|
||||||
// WidgetKeysService lives in ConfigThemeModule now — injected here via the
|
|
||||||
// module's exports. This module only owns the widget-facing API endpoints
|
|
||||||
// (init / chat / book / lead) plus the NestJS guards that consume the keys.
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PlatformModule, AuthModule, ConfigThemeModule, forwardRef(() => CallerResolutionModule)],
|
imports: [PlatformModule, AuthModule, ConfigThemeModule, forwardRef(() => CallerResolutionModule), LeadsModule, SupervisorModule],
|
||||||
controllers: [WidgetController, WebhooksController],
|
controllers: [WidgetController, WebhooksController],
|
||||||
providers: [WidgetService, WidgetChatService],
|
providers: [WidgetService, WidgetChatService],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import type { WidgetInitResponse, WidgetBookRequest, WidgetLeadRequest } from '.
|
|||||||
import { ThemeService } from '../config/theme.service';
|
import { ThemeService } from '../config/theme.service';
|
||||||
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors, type NormalizedDoctor } from '../shared/doctor-utils';
|
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors, type NormalizedDoctor } from '../shared/doctor-utils';
|
||||||
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
import { CallerResolutionService } from '../caller/caller-resolution.service';
|
||||||
|
import { LeadAutoAssignService } from '../leads/lead-auto-assign.service';
|
||||||
|
import { SupervisorService } from '../supervisor/supervisor.service';
|
||||||
|
|
||||||
// Dedup window: any lead created for this phone within the last 24h is
|
// Dedup window: any lead created for this phone within the last 24h is
|
||||||
// considered the same visitor's lead — chat + book + contact by the same
|
// considered the same visitor's lead — chat + book + contact by the same
|
||||||
@@ -15,6 +17,7 @@ export type FindOrCreateLeadOpts = {
|
|||||||
source?: string;
|
source?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
interestedService?: string;
|
interestedService?: string;
|
||||||
|
createPatient?: boolean; // default false — only booking creates patients
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -27,6 +30,8 @@ export class WidgetService {
|
|||||||
private theme: ThemeService,
|
private theme: ThemeService,
|
||||||
private config: ConfigService,
|
private config: ConfigService,
|
||||||
private caller: CallerResolutionService,
|
private caller: CallerResolutionService,
|
||||||
|
private autoAssign: LeadAutoAssignService,
|
||||||
|
private supervisor: SupervisorService,
|
||||||
) {
|
) {
|
||||||
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||||
}
|
}
|
||||||
@@ -56,26 +61,27 @@ export class WidgetService {
|
|||||||
const lastName = name.split(' ').slice(1).join(' ') || '';
|
const lastName = name.split(' ').slice(1).join(' ') || '';
|
||||||
|
|
||||||
if (resolved.isNew) {
|
if (resolved.isNew) {
|
||||||
// Net-new visitor — create Patient + Lead with the widget-
|
// Net-new visitor — create Lead. Patient is only created
|
||||||
// collected name. Both records get the real name from the
|
// when explicitly requested (e.g., booking an appointment).
|
||||||
// first moment they exist.
|
|
||||||
let patientId: string | undefined;
|
let patientId: string | undefined;
|
||||||
try {
|
if (opts.createPatient) {
|
||||||
const p = await this.platform.queryWithAuth<any>(
|
try {
|
||||||
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
const p = await this.platform.queryWithAuth<any>(
|
||||||
{
|
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||||
data: {
|
{
|
||||||
name: `${firstName} ${lastName}`.trim() || 'Unknown',
|
data: {
|
||||||
fullName: { firstName, lastName },
|
name: `${firstName} ${lastName}`.trim() || 'Unknown',
|
||||||
phones: { primaryPhoneNumber: `+91${phone}` },
|
fullName: { firstName, lastName },
|
||||||
patientType: 'NEW',
|
phones: { primaryPhoneNumber: `+91${phone}` },
|
||||||
|
patientType: 'NEW',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
this.auth,
|
||||||
this.auth,
|
);
|
||||||
);
|
patientId = p?.createPatient?.id;
|
||||||
patientId = p?.createPatient?.id;
|
} catch (err) {
|
||||||
} catch (err) {
|
this.logger.warn(`Widget patient create failed (${phone}): ${err}`);
|
||||||
this.logger.warn(`Widget patient create failed (${phone}): ${err}`);
|
}
|
||||||
}
|
}
|
||||||
const created = await this.platform.queryWithAuth<any>(
|
const created = await this.platform.queryWithAuth<any>(
|
||||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||||
@@ -235,7 +241,10 @@ export class WidgetService {
|
|||||||
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
||||||
{ data: {
|
{ data: {
|
||||||
name: `${req.patientName.trim() || 'Patient'} — ${new Date(req.scheduledAt).toISOString().slice(0, 10)}`,
|
name: `${req.patientName.trim() || 'Patient'} — ${new Date(req.scheduledAt).toISOString().slice(0, 10)}`,
|
||||||
scheduledAt: req.scheduledAt,
|
// Ensure IST offset — widget may send bare datetime without timezone
|
||||||
|
scheduledAt: req.scheduledAt.includes('+') || req.scheduledAt.includes('Z')
|
||||||
|
? req.scheduledAt
|
||||||
|
: `${req.scheduledAt}+05:30`,
|
||||||
durationMin: 30,
|
durationMin: 30,
|
||||||
appointmentType: 'CONSULTATION',
|
appointmentType: 'CONSULTATION',
|
||||||
status: 'SCHEDULED',
|
status: 'SCHEDULED',
|
||||||
@@ -256,6 +265,7 @@ export class WidgetService {
|
|||||||
source: 'WEBSITE',
|
source: 'WEBSITE',
|
||||||
status: 'APPOINTMENT_SET',
|
status: 'APPOINTMENT_SET',
|
||||||
interestedService: req.chiefComplaint ?? 'Appointment Booking',
|
interestedService: req.chiefComplaint ?? 'Appointment Booking',
|
||||||
|
createPatient: true,
|
||||||
});
|
});
|
||||||
// Idempotent upgrade: if the lead was reused from an earlier chat/
|
// Idempotent upgrade: if the lead was reused from an earlier chat/
|
||||||
// contact, promote its status and reflect the new interest.
|
// contact, promote its status and reflect the new interest.
|
||||||
@@ -271,6 +281,13 @@ export class WidgetService {
|
|||||||
const reference = appt.createAppointment.id.substring(0, 8).toUpperCase();
|
const reference = appt.createAppointment.id.substring(0, 8).toUpperCase();
|
||||||
this.logger.log(`Widget booking: ${req.patientName} → ${req.doctorId} at ${req.scheduledAt} (ref: ${reference})`);
|
this.logger.log(`Widget booking: ${req.patientName} → ${req.doctorId} at ${req.scheduledAt} (ref: ${reference})`);
|
||||||
|
|
||||||
|
// Emit SSE so agents see the new appointment immediately
|
||||||
|
this.supervisor.emitWorklistUpdate({
|
||||||
|
type: 'widget-appointment',
|
||||||
|
callerPhone: this.normalizePhone(req.patientPhone),
|
||||||
|
callerName: req.patientName,
|
||||||
|
});
|
||||||
|
|
||||||
return { appointmentId: appt.createAppointment.id, reference };
|
return { appointmentId: appt.createAppointment.id, reference };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +298,18 @@ export class WidgetService {
|
|||||||
interestedService: req.interest ?? 'Website Enquiry',
|
interestedService: req.interest ?? 'Website Enquiry',
|
||||||
});
|
});
|
||||||
this.logger.log(`Widget contact: ${req.name} (${this.normalizePhone(req.phone)}) — ${req.interest ?? 'general'}`);
|
this.logger.log(`Widget contact: ${req.name} (${this.normalizePhone(req.phone)}) — ${req.interest ?? 'general'}`);
|
||||||
|
|
||||||
|
// Trigger immediate auto-assign + SSE so agent sees the lead instantly
|
||||||
|
this.autoAssign.runOnce().then((result) => {
|
||||||
|
if (result.assigned > 0) {
|
||||||
|
this.supervisor.emitWorklistUpdate({
|
||||||
|
type: 'widget-lead',
|
||||||
|
callerPhone: this.normalizePhone(req.phone),
|
||||||
|
callerName: req.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch(() => {});
|
||||||
|
|
||||||
return { leadId };
|
return { leadId };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,7 +50,15 @@ export class MissedCallWebhookController {
|
|||||||
const duration = this.parseDuration(payload.CallDuration ?? '00:00:00');
|
const duration = this.parseDuration(payload.CallDuration ?? '00:00:00');
|
||||||
const agentName = payload.AgentName ?? null;
|
const agentName = payload.AgentName ?? null;
|
||||||
const recordingUrl = payload.AudioFile ?? null;
|
const recordingUrl = payload.AudioFile ?? null;
|
||||||
const ucid = payload.monitorUCID ?? null;
|
const monitorUcid = payload.monitorUCID ?? null;
|
||||||
|
// Resolve agent-side UCID from real-time event mapping.
|
||||||
|
// The dispose endpoint creates Call records with the agent UCID;
|
||||||
|
// this lets us find and enrich that record instead of duplicating.
|
||||||
|
const agentUcid = monitorUcid ? this.supervisor.resolveAgentUcid(monitorUcid) : null;
|
||||||
|
const ucid = agentUcid ?? monitorUcid;
|
||||||
|
if (agentUcid) {
|
||||||
|
this.logger.log(`[WEBHOOK] Resolved monitorUCID ${monitorUcid} → agent UCID ${agentUcid}`);
|
||||||
|
}
|
||||||
const disposition = payload.Disposition ?? null;
|
const disposition = payload.Disposition ?? null;
|
||||||
const hangupBy = payload.HangupBy ?? null;
|
const hangupBy = payload.HangupBy ?? null;
|
||||||
|
|
||||||
@@ -109,24 +117,54 @@ export class MissedCallWebhookController {
|
|||||||
this.logger.warn(`[WEBHOOK] Caller resolution failed for ${callerPhone}: ${err}`);
|
this.logger.warn(`[WEBHOOK] Caller resolution failed for ${callerPhone}: ${err}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Create call record with leadId + leadName baked in so
|
// Step 2: For answered calls, the dispose endpoint creates the
|
||||||
// the worklist row renders the patient name immediately.
|
// Call record ~5s before this webhook fires. Check if it already
|
||||||
const callId = await this.createCall({
|
// exists and enrich it instead of creating a duplicate.
|
||||||
callerPhone,
|
let callId: string;
|
||||||
direction,
|
if (callStatus === 'COMPLETED' && ucid) {
|
||||||
callStatus,
|
const existing = await this.platform.queryWithAuth<any>(
|
||||||
agentName,
|
`{ calls(first: 1, filter: { ucid: { eq: "${ucid}" } }) { edges { node { id } } } }`,
|
||||||
startTime,
|
undefined, authHeader,
|
||||||
endTime,
|
).catch(() => null);
|
||||||
duration,
|
const existingId = existing?.calls?.edges?.[0]?.node?.id;
|
||||||
recordingUrl,
|
if (existingId) {
|
||||||
disposition,
|
// Enrich existing record with webhook data (recording, chain name, timing)
|
||||||
ucid,
|
const enrichData: Record<string, any> = {};
|
||||||
leadId: resolved.leadId || null,
|
if (agentName) enrichData.agentName = agentName;
|
||||||
leadName: resolved.leadName,
|
if (recordingUrl) enrichData.recording = { primaryLinkUrl: recordingUrl, primaryLinkLabel: 'Recording' };
|
||||||
}, authHeader);
|
if (resolved.leadId) enrichData.leadId = resolved.leadId;
|
||||||
|
if (resolved.leadName) enrichData.leadName = resolved.leadName;
|
||||||
this.logger.log(`Created call record: ${callId} (${callStatus})${resolved.leadName ? ` linked to ${resolved.leadName}` : ''}`);
|
if (startTime) enrichData.startedAt = istToUtc(startTime);
|
||||||
|
if (endTime) enrichData.endedAt = istToUtc(endTime);
|
||||||
|
if (duration) enrichData.durationSec = duration;
|
||||||
|
if (Object.keys(enrichData).length > 0) {
|
||||||
|
await this.platform.queryWithAuth<any>(
|
||||||
|
`mutation($id: UUID!, $data: CallUpdateInput!) { updateCall(id: $id, data: $data) { id } }`,
|
||||||
|
{ id: existingId, data: enrichData },
|
||||||
|
authHeader,
|
||||||
|
).catch(err => this.logger.warn(`[WEBHOOK] Failed to enrich call ${existingId}: ${err}`));
|
||||||
|
}
|
||||||
|
callId = existingId;
|
||||||
|
this.logger.log(`[WEBHOOK] Enriched existing call ${callId} with recording=${recordingUrl ? 'yes' : 'no'} agentName=${agentName}`);
|
||||||
|
} else {
|
||||||
|
// Fallback: dispose didn't create it (edge case) — create normally
|
||||||
|
this.logger.log(`[WEBHOOK] No existing call found for ucid=${ucid} — creating new record`);
|
||||||
|
callId = await this.createCall({
|
||||||
|
callerPhone, direction, callStatus, agentName,
|
||||||
|
startTime, endTime, duration, recordingUrl, disposition, ucid,
|
||||||
|
leadId: resolved.leadId || null, leadName: resolved.leadName,
|
||||||
|
}, authHeader);
|
||||||
|
this.logger.log(`Created call record: ${callId} (${callStatus})${resolved.leadName ? ` linked to ${resolved.leadName}` : ''}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Missed calls — always create (no dispose fires for unanswered)
|
||||||
|
callId = await this.createCall({
|
||||||
|
callerPhone, direction, callStatus, agentName,
|
||||||
|
startTime, endTime, duration, recordingUrl, disposition, ucid,
|
||||||
|
leadId: resolved.leadId || null, leadName: resolved.leadName,
|
||||||
|
}, authHeader);
|
||||||
|
this.logger.log(`Created call record: ${callId} (${callStatus})${resolved.leadName ? ` linked to ${resolved.leadName}` : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Push worklist SSE so agents see new calls instantly
|
// Push worklist SSE so agents see new calls instantly
|
||||||
// instead of waiting for the 30s frontend poll.
|
// instead of waiting for the 30s frontend poll.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{
|
{
|
||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"exclude": ["node_modules", "test", "dist", "widget-src", "public", "data", "**/*spec.ts"]
|
"exclude": ["node_modules", "test", "dist", "widget-src", "packages", "public", "data", "**/*spec.ts"]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,5 +22,5 @@
|
|||||||
"strictBindCallApply": true,
|
"strictBindCallApply": true,
|
||||||
"noFallthroughCasesInSwitch": true
|
"noFallthroughCasesInSwitch": true
|
||||||
},
|
},
|
||||||
"exclude": ["widget-src", "public", "data"]
|
"exclude": ["widget-src", "packages", "public", "data"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user