12 Commits

Author SHA1 Message Date
a837c95d8c fix: contact form creates Lead only, not Patient
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Widget contact + chat-start were creating Patient records for new
visitors. Patient should only be created during appointment booking.
Added createPatient flag to findOrCreateLeadByPhone — defaults to
false, only bookAppointment passes true.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 16:41:11 +05:30
ac76ef5487 feat: add telephonyEnabled to ui-flags endpoint
TELEPHONY_ENABLED env var (default true). When set to false, frontend
hides call center nav and shows CRM-only mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 16:38:18 +05:30
99954c1ff2 fix: widget build outputs to ./dist instead of sidecar public/
Prevents accidental overwrites of the working widget.js. Use
'npm run deploy' to explicitly build + copy to sidecar public/.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 14:26:03 +05:30
4b84792619 fix: instant widget lead assignment + SSE notification
Widget leads were invisible to agents for up to 90s (60s auto-assign
poll + 30s worklist poll). Now triggers immediate auto-assign after
lead creation and emits SSE worklistUpdate so agents see new widget
leads and appointments instantly.

Also excluded packages/ from tsconfig build to prevent widget source
compilation errors.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 13:08:59 +05:30
9890559ec1 fix: append IST offset (+05:30) to bare datetime in appointment booking
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Widget and WhatsApp flows send scheduledAt without timezone offset,
causing platform to interpret as UTC (10:00 shows as 3:30 PM IST).
Server now appends +05:30 if no timezone indicator present. Also fixed
in WhatsApp slot ID generation and widget source.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 11:14:24 +05:30
9cb4d1c122 docs: website widget operations guide + archive widget source
- Comprehensive docs: embed snippet, key management, API endpoints,
  chat/booking/contact flows, lead dedup, reCAPTCHA, branding, deploy
  checklist, troubleshooting
- Widget Preact source archived in packages/widget-src/ (was only on
  local machine, not tracked in any repo)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 06:39:23 +05:30
014b27cf90 fix: restore full widget.js with chat-start flow from aa41a2a
The rebuild from packages/helix-engage-widget/ produced an older version
without chat-start/leadId support. Restored the working version from
commit aa41a2a which has the complete chat flow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 06:21:17 +05:30
826ced1e62 feat: include widget.js in Docker image for embed script serving
Added COPY public ./public to Dockerfile so the Preact embed widget
is served via NestJS static assets at /widget.js.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-21 05:51:30 +05:30
bbea12185d feat: Claude skill for generating WhatsApp flow JSON definitions
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
Skill documents the full flow schema (Groups, Blocks, Edges, Variables),
all available tools, WhatsApp constraints, system variables, and
deployment steps. Enables generating new flows from natural language
descriptions — e.g., "create a prescription refill flow".

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 21:31:00 +05:30
f1c026cf7a fix(flow): serialize per-phone execution to prevent concurrent flows
Two messages arriving close together could start two parallel flow
executions for the same phone. The second would create a new session
while the first was mid-AI-block, causing duplicate greetings and
race conditions. Per-phone async lock ensures sequential execution.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 20:48:55 +05:30
d819888351 feat: appointment QR code — generated and sent via WhatsApp after booking
- QrService: generates QR PNG from appointment data, cached in-memory
- GET /api/messaging/qr/:appointmentId serves the image (Gupshup needs URL)
- sendImage added to MessagingProvider + GupshupProvider
- send_appointment_qr tool registered in ToolRegistry
- Flow JSON updated: QR sent after booking confirmation
- Variable interpolation now supports dot notation ({{result.field}})
- SIDECAR_PUBLIC_URL env var for the QR image URL

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 20:23:06 +05:30
300fff25c1 feat(flow): handle 'Choose Another Date' with AI date parsing
Added g4t (tomorrow), g4c (custom date) groups. Custom date asks
patient to type a date, AI block parses it to YYYY-MM-DD. Three
condition branches now: tomorrow, day_after, other.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-20 19:32:31 +05:30
33 changed files with 1711 additions and 48 deletions

View 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`

View File

@@ -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"]

239
docs/website-widget.md Normal file
View 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.

188
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View 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"
}
}

View 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;
};

View 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>
);
};

View 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>
);
};

View 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>
);
};

View 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}}`);
};

View 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();
}

View 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; }
`;

View 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;
};

View 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>
);
};

View 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>

View 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"]
}

View 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,
},
},
},
});

View File

@@ -38,6 +38,7 @@ 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: { messaging: {
provider: process.env.MESSAGING_PROVIDER ?? 'gupshup', provider: process.env.MESSAGING_PROVIDER ?? 'gupshup',
gupshup: { gupshup: {

View File

@@ -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
}; };
} }
} }

View File

@@ -145,9 +145,16 @@
"type": "condition", "type": "condition",
"conditions": [ "conditions": [
{ "id": "c3", "variableId": "dateChoice", "operator": "contains", "value": "tomorrow" }, { "id": "c3", "variableId": "dateChoice", "operator": "contains", "value": "tomorrow" },
{ "id": "c4", "variableId": "dateChoice", "operator": "contains", "value": "day_after" } { "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", "id": "b14",
"type": "set_variable", "type": "set_variable",
@@ -170,6 +177,33 @@
} }
] ]
}, },
{
"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", "id": "g5",
"title": "Slot Selection", "title": "Slot Selection",
@@ -273,6 +307,19 @@
"format": "text", "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!" "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}}"
}
} }
] ]
}, },
@@ -316,10 +363,12 @@
{ "id": "e2", "from": { "blockId": "b4", "conditionId": "c2" }, "to": { "groupId": "g10" } }, { "id": "e2", "from": { "blockId": "b4", "conditionId": "c2" }, "to": { "groupId": "g10" } },
{ "id": "e3", "from": { "blockId": "b7" }, "to": { "groupId": "g3" } }, { "id": "e3", "from": { "blockId": "b7" }, "to": { "groupId": "g3" } },
{ "id": "e4", "from": { "blockId": "b10" }, "to": { "groupId": "g4" } }, { "id": "e4", "from": { "blockId": "b10" }, "to": { "groupId": "g4" } },
{ "id": "e5", "from": { "blockId": "b13", "conditionId": "c3" }, "to": { "groupId": "g5" } }, { "id": "e5", "from": { "blockId": "b13", "conditionId": "c3" }, "to": { "groupId": "g4t" } },
{ "id": "e6", "from": { "blockId": "b13", "conditionId": "c4" }, "to": { "groupId": "g4a" } }, { "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": "e7", "from": { "blockId": "b14" }, "to": { "groupId": "g5" } },
{ "id": "e8", "from": { "blockId": "b15" }, "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": "e9", "from": { "blockId": "b17a" }, "to": { "groupId": "g6" } },
{ "id": "e10", "from": { "blockId": "b19" }, "to": { "groupId": "g7" } }, { "id": "e10", "from": { "blockId": "b19" }, "to": { "groupId": "g7" } },
{ "id": "e11", "from": { "blockId": "b22", "conditionId": "c5" }, "to": { "groupId": "g8" } }, { "id": "e11", "from": { "blockId": "b22", "conditionId": "c5" }, "to": { "groupId": "g8" } },

View File

@@ -44,9 +44,28 @@ export class FlowExecutionService {
this.auth = apiKey ? `Bearer ${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> { async handleMessage(message: NormalizedMessage): Promise<void> {
const { phone } = message; 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 // 1. Load existing session or start new flow
let session = await this.sessions.load(phone); let session = await this.sessions.load(phone);
let flow: Flow | null = null; let flow: Flow | null = null;

View File

@@ -4,9 +4,15 @@ import { Injectable } from '@nestjs/common';
export class FlowVariableService { export class FlowVariableService {
// Replace {{variableName}} with values from session variables // Replace {{variableName}} with values from session variables
interpolate(template: string, variables: Record<string, any>): string { interpolate(template: string, variables: Record<string, any>): string {
return template.replace(/\{\{(\w+)\}\}/g, (match, name) => { return template.replace(/\{\{([\w.]+)\}\}/g, (match, path) => {
const value = variables[name]; // Support dot notation: {{bookingResult.appointmentId}}
if (value === undefined || value === null) return match; // keep placeholder if unresolved 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); if (typeof value === 'object') return JSON.stringify(value);
return String(value); return String(value);
}); });

View File

@@ -1,6 +1,8 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PlatformGraphqlService } from '../../platform/platform-graphql.service'; import { PlatformGraphqlService } from '../../platform/platform-graphql.service';
import { CallerResolutionService } from '../../caller/caller-resolution.service'; import { CallerResolutionService } from '../../caller/caller-resolution.service';
import { QrService } from '../qr.service';
import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../../shared/doctor-utils'; import { DOCTOR_VISIT_SLOTS_FRAGMENT, normalizeDoctors } from '../../shared/doctor-utils';
import type { ToolHandler, ToolContext } from './flow-types'; import type { ToolHandler, ToolContext } from './flow-types';
import type { ListSection, InteractiveButton } from '../types'; import type { ListSection, InteractiveButton } from '../types';
@@ -10,10 +12,15 @@ export class ToolRegistry {
private readonly logger = new Logger(ToolRegistry.name); private readonly logger = new Logger(ToolRegistry.name);
private readonly tools: Map<string, ToolHandler> = new Map(); private readonly tools: Map<string, ToolHandler> = new Map();
private readonly sidecarUrl: string;
constructor( constructor(
private platform: PlatformGraphqlService, private platform: PlatformGraphqlService,
private caller: CallerResolutionService, private caller: CallerResolutionService,
private qr: QrService,
private config: ConfigService,
) { ) {
this.sidecarUrl = config.get<string>('sidecarUrl') ?? '';
this.registerDefaults(); this.registerDefaults();
} }
@@ -135,7 +142,7 @@ export class ToolRegistry {
const sections: ListSection[] = [{ const sections: ListSection[] = [{
title: targetDate, title: targetDate,
rows: timeSlots.map(s => ({ rows: timeSlots.map(s => ({
id: `slot:${doctorId}:${targetDate}T${s.time}:00`, id: `slot:${doctorId}:${targetDate}T${s.time}:00+05:30`,
title: s.time, title: s.time,
description: s.clinic || undefined, description: s.clinic || undefined,
})), })),
@@ -195,7 +202,7 @@ export class ToolRegistry {
// Book — include patientId so appointment is linked to patient record // Book — include patientId so appointment is linked to patient record
const result = await this.platform.query<any>( const result = await this.platform.query<any>(
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`, `mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
{ data: { name: `WhatsApp Booking — ${patientName} (${department})`, scheduledAt, status: 'SCHEDULED', doctorName, department, reasonForVisit: reason ?? 'General Consultation', ...(patientId ? { patientId } : {}) } }, { 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; const id = result?.createAppointment?.id;
if (id) { if (id) {
@@ -215,5 +222,23 @@ export class ToolRegistry {
); );
return { appointments: data.appointments.edges.map((e: any) => e.node) }; 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 };
});
} }
} }

View File

@@ -1,6 +1,8 @@
import { Controller, Post, Body, Logger } from '@nestjs/common'; import { Controller, Post, Get, Body, Param, Res, Logger } from '@nestjs/common';
import type { Response } from 'express';
import { MessagingProvider } from './providers/messaging-provider.interface'; import { MessagingProvider } from './providers/messaging-provider.interface';
import { MessagingService } from './messaging.service'; import { MessagingService } from './messaging.service';
import { QrService } from './qr.service';
@Controller('api/messaging') @Controller('api/messaging')
export class MessagingController { export class MessagingController {
@@ -9,6 +11,7 @@ export class MessagingController {
constructor( constructor(
private readonly provider: MessagingProvider, private readonly provider: MessagingProvider,
private readonly messaging: MessagingService, private readonly messaging: MessagingService,
private readonly qr: QrService,
) {} ) {}
@Post('webhook') @Post('webhook')
@@ -33,4 +36,17 @@ export class MessagingController {
return { status: 'ok' }; 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);
}
} }

View File

@@ -12,6 +12,7 @@ import { FlowSessionService } from './flow/flow-session.service';
import { FlowStoreService } from './flow/flow-store.service'; import { FlowStoreService } from './flow/flow-store.service';
import { FlowVariableService } from './flow/flow-variable.service'; import { FlowVariableService } from './flow/flow-variable.service';
import { ToolRegistry } from './flow/tool-registry'; import { ToolRegistry } from './flow/tool-registry';
import { QrService } from './qr.service';
@Module({ @Module({
imports: [PlatformModule, CallerResolutionModule], imports: [PlatformModule, CallerResolutionModule],
@@ -24,6 +25,7 @@ import { ToolRegistry } from './flow/tool-registry';
FlowStoreService, FlowStoreService,
FlowVariableService, FlowVariableService,
ToolRegistry, ToolRegistry,
QrService,
{ {
provide: MessagingProvider, provide: MessagingProvider,
useFactory: (config: ConfigService) => { useFactory: (config: ConfigService) => {

View File

@@ -96,6 +96,16 @@ export class GupshupProvider extends MessagingProvider {
await this.send(to, JSON.stringify(message)); 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> { async sendList(to: string, body: string, buttonText: string, sections: ListSection[]): Promise<void> {
const message = { const message = {
type: 'list', type: 'list',

View File

@@ -13,6 +13,9 @@ export abstract class MessagingProvider {
/** Send interactive list (max 10 rows total across sections) */ /** Send interactive list (max 10 rows total across sections) */
abstract sendList(to: string, body: string, buttonText: string, sections: ListSection[]): Promise<void>; 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 */ /** Validate that inbound webhook is authentic */
abstract validateWebhook(body: any): boolean; abstract validateWebhook(body: any): boolean;
} }

View 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;
}
}

View File

@@ -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],
}) })

View File

@@ -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 };
} }
} }

View File

@@ -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"]
} }

View File

@@ -22,5 +22,5 @@
"strictBindCallApply": true, "strictBindCallApply": true,
"noFallthroughCasesInSwitch": true "noFallthroughCasesInSwitch": true
}, },
"exclude": ["widget-src", "public", "data"] "exclude": ["widget-src", "packages", "public", "data"]
} }