diff --git a/docs/website-widget.md b/docs/website-widget.md new file mode 100644 index 0000000..a78f876 --- /dev/null +++ b/docs/website-widget.md @@ -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 ` +``` + +| 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 ` + + diff --git a/packages/widget-src/tsconfig.json b/packages/widget-src/tsconfig.json new file mode 100644 index 0000000..b9b69cc --- /dev/null +++ b/packages/widget-src/tsconfig.json @@ -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"] +} diff --git a/packages/widget-src/vite.config.ts b/packages/widget-src/vite.config.ts new file mode 100644 index 0000000..e8e03da --- /dev/null +++ b/packages/widget-src/vite.config.ts @@ -0,0 +1,22 @@ +import { defineConfig } from 'vite'; +import preact from '@preact/preset-vite'; + +export default defineConfig({ + plugins: [preact()], + build: { + lib: { + entry: 'src/main.tsx', + name: 'HelixWidget', + fileName: () => 'widget.js', + formats: ['iife'], + }, + outDir: '../../helix-engage-server/public', + emptyOutDir: false, + minify: 'esbuild', + rollupOptions: { + output: { + inlineDynamicImports: true, + }, + }, + }, +});