mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
Compare commits
27 Commits
3bb4315925
...
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 | ||
| 350fcdd926 |
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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 }[];
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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