mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 10:23:27 +00:00
docs: website widget spec + implementation plan
- Widget design spec (embeddable AI chat + booking + lead capture) - Implementation plan (6 tasks) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
979
docs/superpowers/plans/2026-04-05-website-widget.md
Normal file
979
docs/superpowers/plans/2026-04-05-website-widget.md
Normal file
@@ -0,0 +1,979 @@
|
||||
# Website Widget — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Build an embeddable website widget (AI chat + appointment booking + lead capture) served from the sidecar, with HMAC-signed site keys, captcha protection, and theme integration.
|
||||
|
||||
**Architecture:** Sidecar gets a new `widget` module with endpoints for init, chat, booking, leads, and key management. A separate Preact-based widget bundle is built with Vite in library mode, served as a static file from the sidecar. The widget renders in a shadow DOM for CSS isolation and fetches theme/config via the site key.
|
||||
|
||||
**Tech Stack:** NestJS (sidecar endpoints), Preact + Vite (widget bundle), Shadow DOM, HMAC-SHA256 (key signing), reCAPTCHA v3 (captcha)
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-04-05-website-widget-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File Map
|
||||
|
||||
### Sidecar (helix-engage-server)
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|---|---|---|
|
||||
| `src/widget/widget.module.ts` | Create | NestJS module |
|
||||
| `src/widget/widget.controller.ts` | Create | REST endpoints: init, chat, doctors, slots, book, lead |
|
||||
| `src/widget/widget.service.ts` | Create | Lead creation, appointment booking, doctor queries |
|
||||
| `src/widget/widget-keys.service.ts` | Create | HMAC key generation, validation, CRUD via Redis |
|
||||
| `src/widget/widget-key.guard.ts` | Create | NestJS guard for key + origin validation |
|
||||
| `src/widget/captcha.guard.ts` | Create | reCAPTCHA v3 token verification |
|
||||
| `src/widget/widget.types.ts` | Create | Types for widget requests/responses |
|
||||
| `src/auth/session.service.ts` | Modify | Add `setCachePersistent()` method |
|
||||
| `src/app.module.ts` | Modify | Import WidgetModule |
|
||||
| `src/main.ts` | Modify | Serve static widget.js file |
|
||||
|
||||
### Widget Bundle (new package)
|
||||
|
||||
| File | Action | Responsibility |
|
||||
|---|---|---|
|
||||
| `packages/helix-engage-widget/package.json` | Create | Package config |
|
||||
| `packages/helix-engage-widget/vite.config.ts` | Create | Library mode, IIFE output |
|
||||
| `packages/helix-engage-widget/tsconfig.json` | Create | TypeScript config |
|
||||
| `packages/helix-engage-widget/src/main.ts` | Create | Entry: read data-key, init widget |
|
||||
| `packages/helix-engage-widget/src/api.ts` | Create | HTTP client for widget endpoints |
|
||||
| `packages/helix-engage-widget/src/widget.tsx` | Create | Shadow DOM mount, theming, tab routing |
|
||||
| `packages/helix-engage-widget/src/chat.tsx` | Create | AI chatbot with streaming |
|
||||
| `packages/helix-engage-widget/src/booking.tsx` | Create | Appointment booking flow |
|
||||
| `packages/helix-engage-widget/src/contact.tsx` | Create | Lead capture form |
|
||||
| `packages/helix-engage-widget/src/captcha.ts` | Create | reCAPTCHA v3 integration |
|
||||
| `packages/helix-engage-widget/src/styles.ts` | Create | CSS-in-JS for shadow DOM |
|
||||
| `packages/helix-engage-widget/src/types.ts` | Create | Shared types |
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Widget Types + Key Service (Sidecar)
|
||||
|
||||
**Files:**
|
||||
- Create: `helix-engage-server/src/widget/widget.types.ts`
|
||||
- Create: `helix-engage-server/src/widget/widget-keys.service.ts`
|
||||
- Modify: `helix-engage-server/src/auth/session.service.ts`
|
||||
|
||||
- [ ] **Step 1: Add setCachePersistent to SessionService**
|
||||
|
||||
Add a method that sets a Redis key without TTL:
|
||||
|
||||
```typescript
|
||||
async setCachePersistent(key: string, value: string): Promise<void> {
|
||||
await this.redis.set(key, value);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create widget.types.ts**
|
||||
|
||||
```typescript
|
||||
// src/widget/widget.types.ts
|
||||
|
||||
export type WidgetSiteKey = {
|
||||
siteId: string;
|
||||
hospitalName: string;
|
||||
allowedOrigins: string[];
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type WidgetInitResponse = {
|
||||
brand: { name: string; logo: string };
|
||||
colors: { primary: string; primaryLight: string; text: string; textLight: string };
|
||||
captchaSiteKey: string;
|
||||
};
|
||||
|
||||
export type WidgetBookRequest = {
|
||||
departmentId: string;
|
||||
doctorId: string;
|
||||
scheduledAt: string;
|
||||
patientName: string;
|
||||
patientPhone: string;
|
||||
age?: string;
|
||||
gender?: string;
|
||||
chiefComplaint?: string;
|
||||
captchaToken: string;
|
||||
};
|
||||
|
||||
export type WidgetLeadRequest = {
|
||||
name: string;
|
||||
phone: string;
|
||||
interest?: string;
|
||||
message?: string;
|
||||
captchaToken: string;
|
||||
};
|
||||
|
||||
export type WidgetChatRequest = {
|
||||
messages: Array<{ role: string; content: string }>;
|
||||
captchaToken?: string;
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create widget-keys.service.ts**
|
||||
|
||||
```typescript
|
||||
// src/widget/widget-keys.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createHmac, timingSafeEqual, randomUUID } from 'crypto';
|
||||
import { SessionService } from '../auth/session.service';
|
||||
import type { WidgetSiteKey } from './widget.types';
|
||||
|
||||
const KEY_PREFIX = 'widget:keys:';
|
||||
|
||||
@Injectable()
|
||||
export class WidgetKeysService {
|
||||
private readonly logger = new Logger(WidgetKeysService.name);
|
||||
private readonly secret: string;
|
||||
|
||||
constructor(
|
||||
private config: ConfigService,
|
||||
private session: SessionService,
|
||||
) {
|
||||
this.secret = process.env.WIDGET_SECRET ?? config.get<string>('WIDGET_SECRET') ?? 'helix-widget-default-secret';
|
||||
}
|
||||
|
||||
generateKey(hospitalName: string, allowedOrigins: string[]): { key: string; siteKey: WidgetSiteKey } {
|
||||
const siteId = randomUUID().replace(/-/g, '').substring(0, 16);
|
||||
const signature = this.sign(siteId);
|
||||
const key = `${siteId}.${signature}`;
|
||||
|
||||
const siteKey: WidgetSiteKey = {
|
||||
siteId,
|
||||
hospitalName,
|
||||
allowedOrigins,
|
||||
active: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
return { key, siteKey };
|
||||
}
|
||||
|
||||
async saveKey(siteKey: WidgetSiteKey): Promise<void> {
|
||||
await this.session.setCachePersistent(`${KEY_PREFIX}${siteKey.siteId}`, JSON.stringify(siteKey));
|
||||
this.logger.log(`Widget key saved: ${siteKey.siteId} (${siteKey.hospitalName})`);
|
||||
}
|
||||
|
||||
async validateKey(rawKey: string): Promise<WidgetSiteKey | null> {
|
||||
const dotIndex = rawKey.indexOf('.');
|
||||
if (dotIndex === -1) return null;
|
||||
|
||||
const siteId = rawKey.substring(0, dotIndex);
|
||||
const signature = rawKey.substring(dotIndex + 1);
|
||||
|
||||
// Verify HMAC
|
||||
const expected = this.sign(siteId);
|
||||
try {
|
||||
if (!timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'))) return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Fetch from Redis
|
||||
const data = await this.session.getCache(`${KEY_PREFIX}${siteId}`);
|
||||
if (!data) return null;
|
||||
|
||||
const siteKey: WidgetSiteKey = JSON.parse(data);
|
||||
if (!siteKey.active) return null;
|
||||
|
||||
return siteKey;
|
||||
}
|
||||
|
||||
validateOrigin(siteKey: WidgetSiteKey, origin: string | undefined): boolean {
|
||||
if (!origin) return false;
|
||||
if (siteKey.allowedOrigins.length === 0) return true;
|
||||
return siteKey.allowedOrigins.some(allowed => origin.startsWith(allowed));
|
||||
}
|
||||
|
||||
async listKeys(): Promise<WidgetSiteKey[]> {
|
||||
const keys = await this.session.scanKeys(`${KEY_PREFIX}*`);
|
||||
const results: WidgetSiteKey[] = [];
|
||||
for (const key of keys) {
|
||||
const data = await this.session.getCache(key);
|
||||
if (data) results.push(JSON.parse(data));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async revokeKey(siteId: string): Promise<boolean> {
|
||||
const data = await this.session.getCache(`${KEY_PREFIX}${siteId}`);
|
||||
if (!data) return false;
|
||||
const siteKey: WidgetSiteKey = JSON.parse(data);
|
||||
siteKey.active = false;
|
||||
await this.session.setCachePersistent(`${KEY_PREFIX}${siteId}`, JSON.stringify(siteKey));
|
||||
this.logger.log(`Widget key revoked: ${siteId}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
private sign(data: string): string {
|
||||
return createHmac('sha256', this.secret).update(data).digest('hex');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
cd helix-engage-server
|
||||
git add src/widget/widget.types.ts src/widget/widget-keys.service.ts src/auth/session.service.ts
|
||||
git commit -m "feat: widget types + HMAC key service with Redis storage"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Widget Guards (Key + Captcha)
|
||||
|
||||
**Files:**
|
||||
- Create: `helix-engage-server/src/widget/widget-key.guard.ts`
|
||||
- Create: `helix-engage-server/src/widget/captcha.guard.ts`
|
||||
|
||||
- [ ] **Step 1: Create widget-key.guard.ts**
|
||||
|
||||
```typescript
|
||||
// src/widget/widget-key.guard.ts
|
||||
|
||||
import { CanActivate, ExecutionContext, Injectable, HttpException } from '@nestjs/common';
|
||||
import { WidgetKeysService } from './widget-keys.service';
|
||||
|
||||
@Injectable()
|
||||
export class WidgetKeyGuard implements CanActivate {
|
||||
constructor(private readonly keys: WidgetKeysService) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const key = request.query?.key ?? request.headers['x-widget-key'];
|
||||
|
||||
if (!key) throw new HttpException('Widget key required', 401);
|
||||
|
||||
const siteKey = await this.keys.validateKey(key);
|
||||
if (!siteKey) throw new HttpException('Invalid widget key', 403);
|
||||
|
||||
const origin = request.headers.origin ?? request.headers.referer;
|
||||
if (!this.keys.validateOrigin(siteKey, origin)) {
|
||||
throw new HttpException('Origin not allowed', 403);
|
||||
}
|
||||
|
||||
// Attach to request for downstream use
|
||||
request.widgetSiteKey = siteKey;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create captcha.guard.ts**
|
||||
|
||||
```typescript
|
||||
// src/widget/captcha.guard.ts
|
||||
|
||||
import { CanActivate, ExecutionContext, Injectable, HttpException, Logger } from '@nestjs/common';
|
||||
|
||||
const RECAPTCHA_VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';
|
||||
|
||||
@Injectable()
|
||||
export class CaptchaGuard implements CanActivate {
|
||||
private readonly logger = new Logger(CaptchaGuard.name);
|
||||
private readonly secretKey: string;
|
||||
|
||||
constructor() {
|
||||
this.secretKey = process.env.RECAPTCHA_SECRET_KEY ?? '';
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
if (!this.secretKey) {
|
||||
this.logger.warn('RECAPTCHA_SECRET_KEY not set — captcha disabled');
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const token = request.body?.captchaToken;
|
||||
|
||||
if (!token) throw new HttpException('Captcha token required', 400);
|
||||
|
||||
try {
|
||||
const res = await fetch(RECAPTCHA_VERIFY_URL, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: `secret=${this.secretKey}&response=${token}`,
|
||||
});
|
||||
const data = await res.json();
|
||||
|
||||
if (!data.success || (data.score != null && data.score < 0.3)) {
|
||||
this.logger.warn(`Captcha failed: score=${data.score} success=${data.success}`);
|
||||
throw new HttpException('Captcha verification failed', 403);
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (err: any) {
|
||||
if (err instanceof HttpException) throw err;
|
||||
this.logger.error(`Captcha verification error: ${err.message}`);
|
||||
return true; // Fail open if captcha service is down
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/widget/widget-key.guard.ts src/widget/captcha.guard.ts
|
||||
git commit -m "feat: widget guards — HMAC key validation + reCAPTCHA v3"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Widget Controller + Service + Module (Sidecar)
|
||||
|
||||
**Files:**
|
||||
- Create: `helix-engage-server/src/widget/widget.service.ts`
|
||||
- Create: `helix-engage-server/src/widget/widget.controller.ts`
|
||||
- Create: `helix-engage-server/src/widget/widget.module.ts`
|
||||
- Modify: `helix-engage-server/src/app.module.ts`
|
||||
- Modify: `helix-engage-server/src/main.ts`
|
||||
|
||||
- [ ] **Step 1: Create widget.service.ts**
|
||||
|
||||
```typescript
|
||||
// src/widget/widget.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import type { WidgetInitResponse, WidgetBookRequest, WidgetLeadRequest } from './widget.types';
|
||||
import { ThemeService } from '../config/theme.service';
|
||||
|
||||
@Injectable()
|
||||
export class WidgetService {
|
||||
private readonly logger = new Logger(WidgetService.name);
|
||||
private readonly apiKey: string;
|
||||
|
||||
constructor(
|
||||
private platform: PlatformGraphqlService,
|
||||
private theme: ThemeService,
|
||||
private config: ConfigService,
|
||||
) {
|
||||
this.apiKey = config.get<string>('platform.apiKey') ?? '';
|
||||
}
|
||||
|
||||
getInitData(): WidgetInitResponse {
|
||||
const t = this.theme.getTheme();
|
||||
return {
|
||||
brand: { name: t.brand.hospitalName, logo: t.brand.logo },
|
||||
colors: {
|
||||
primary: t.colors.brand['600'] ?? 'rgb(29 78 216)',
|
||||
primaryLight: t.colors.brand['50'] ?? 'rgb(219 234 254)',
|
||||
text: t.colors.brand['950'] ?? 'rgb(15 23 42)',
|
||||
textLight: t.colors.brand['400'] ?? 'rgb(100 116 139)',
|
||||
},
|
||||
captchaSiteKey: process.env.RECAPTCHA_SITE_KEY ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
async getDoctors(): Promise<any[]> {
|
||||
const auth = `Bearer ${this.apiKey}`;
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`{ doctors(first: 50) { edges { node {
|
||||
id name fullName { firstName lastName } department specialty visitingHours
|
||||
consultationFeeNew { amountMicros currencyCode }
|
||||
clinic { clinicName }
|
||||
} } } }`,
|
||||
undefined, auth,
|
||||
);
|
||||
return data.doctors.edges.map((e: any) => e.node);
|
||||
}
|
||||
|
||||
async getSlots(doctorId: string, date: string): Promise<any> {
|
||||
const auth = `Bearer ${this.apiKey}`;
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`{ appointments(first: 50, filter: { doctorId: { eq: "${doctorId}" }, scheduledAt: { gte: "${date}T00:00:00Z", lte: "${date}T23:59:59Z" } }) { edges { node { scheduledAt } } } }`,
|
||||
undefined, auth,
|
||||
);
|
||||
const booked = data.appointments.edges.map((e: any) => {
|
||||
const dt = new Date(e.node.scheduledAt);
|
||||
return `${dt.getHours().toString().padStart(2, '0')}:${dt.getMinutes().toString().padStart(2, '0')}`;
|
||||
});
|
||||
|
||||
const allSlots = ['09:00', '09:30', '10:00', '10:30', '11:00', '11:30', '14:00', '14:30', '15:00', '15:30', '16:00'];
|
||||
return allSlots.map(s => ({ time: s, available: !booked.includes(s) }));
|
||||
}
|
||||
|
||||
async bookAppointment(req: WidgetBookRequest): Promise<{ appointmentId: string; reference: string }> {
|
||||
const auth = `Bearer ${this.apiKey}`;
|
||||
const phone = req.patientPhone.replace(/[^0-9]/g, '').slice(-10);
|
||||
|
||||
// Create or find patient
|
||||
let patientId: string | null = null;
|
||||
try {
|
||||
const existing = await this.platform.queryWithAuth<any>(
|
||||
`{ patients(first: 1, filter: { phones: { primaryPhoneNumber: { like: "%${phone}" } } }) { edges { node { id } } } }`,
|
||||
undefined, auth,
|
||||
);
|
||||
patientId = existing.patients.edges[0]?.node?.id ?? null;
|
||||
} catch { /* continue */ }
|
||||
|
||||
if (!patientId) {
|
||||
const created = await this.platform.queryWithAuth<any>(
|
||||
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
|
||||
{ data: {
|
||||
fullName: { firstName: req.patientName.split(' ')[0], lastName: req.patientName.split(' ').slice(1).join(' ') || '' },
|
||||
phones: { primaryPhoneNumber: `+91${phone}` },
|
||||
patientType: 'NEW',
|
||||
} },
|
||||
auth,
|
||||
);
|
||||
patientId = created.createPatient.id;
|
||||
}
|
||||
|
||||
// Create appointment
|
||||
const appt = await this.platform.queryWithAuth<any>(
|
||||
`mutation($data: AppointmentCreateInput!) { createAppointment(data: $data) { id } }`,
|
||||
{ data: {
|
||||
scheduledAt: req.scheduledAt,
|
||||
durationMin: 30,
|
||||
appointmentType: 'CONSULTATION',
|
||||
status: 'SCHEDULED',
|
||||
doctorId: req.doctorId,
|
||||
department: req.departmentId,
|
||||
reasonForVisit: req.chiefComplaint ?? '',
|
||||
patientId,
|
||||
} },
|
||||
auth,
|
||||
);
|
||||
|
||||
// Create lead
|
||||
await this.platform.queryWithAuth<any>(
|
||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||
{ data: {
|
||||
name: req.patientName,
|
||||
contactName: { firstName: req.patientName.split(' ')[0], lastName: req.patientName.split(' ').slice(1).join(' ') || '' },
|
||||
contactPhone: { primaryPhoneNumber: `+91${phone}` },
|
||||
source: 'WEBSITE',
|
||||
status: 'APPOINTMENT_SET',
|
||||
interestedService: req.chiefComplaint ?? 'Appointment Booking',
|
||||
patientId,
|
||||
} },
|
||||
auth,
|
||||
).catch(err => this.logger.warn(`Widget lead creation failed: ${err}`));
|
||||
|
||||
const reference = appt.createAppointment.id.substring(0, 8).toUpperCase();
|
||||
this.logger.log(`Widget booking: ${req.patientName} → ${req.doctorId} at ${req.scheduledAt} (ref: ${reference})`);
|
||||
|
||||
return { appointmentId: appt.createAppointment.id, reference };
|
||||
}
|
||||
|
||||
async createLead(req: WidgetLeadRequest): Promise<{ leadId: string }> {
|
||||
const auth = `Bearer ${this.apiKey}`;
|
||||
const phone = req.phone.replace(/[^0-9]/g, '').slice(-10);
|
||||
|
||||
const data = await this.platform.queryWithAuth<any>(
|
||||
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
|
||||
{ data: {
|
||||
name: req.name,
|
||||
contactName: { firstName: req.name.split(' ')[0], lastName: req.name.split(' ').slice(1).join(' ') || '' },
|
||||
contactPhone: { primaryPhoneNumber: `+91${phone}` },
|
||||
source: 'WEBSITE',
|
||||
status: 'NEW',
|
||||
interestedService: req.interest ?? 'Website Enquiry',
|
||||
} },
|
||||
auth,
|
||||
);
|
||||
|
||||
this.logger.log(`Widget lead: ${req.name} (${phone}) — ${req.interest ?? 'general'}`);
|
||||
return { leadId: data.createLead.id };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create widget.controller.ts**
|
||||
|
||||
```typescript
|
||||
// src/widget/widget.controller.ts
|
||||
|
||||
import { Controller, Get, Post, Delete, Body, Query, Param, Req, Res, UseGuards, Logger, HttpException } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { WidgetService } from './widget.service';
|
||||
import { WidgetKeysService } from './widget-keys.service';
|
||||
import { WidgetKeyGuard } from './widget-key.guard';
|
||||
import { CaptchaGuard } from './captcha.guard';
|
||||
import { AiChatController } from '../ai/ai-chat.controller';
|
||||
import type { WidgetBookRequest, WidgetLeadRequest } from './widget.types';
|
||||
|
||||
@Controller('api/widget')
|
||||
export class WidgetController {
|
||||
private readonly logger = new Logger(WidgetController.name);
|
||||
|
||||
constructor(
|
||||
private readonly widget: WidgetService,
|
||||
private readonly keys: WidgetKeysService,
|
||||
) {}
|
||||
|
||||
@Get('init')
|
||||
@UseGuards(WidgetKeyGuard)
|
||||
init() {
|
||||
return this.widget.getInitData();
|
||||
}
|
||||
|
||||
@Get('doctors')
|
||||
@UseGuards(WidgetKeyGuard)
|
||||
async doctors() {
|
||||
return this.widget.getDoctors();
|
||||
}
|
||||
|
||||
@Get('slots')
|
||||
@UseGuards(WidgetKeyGuard)
|
||||
async slots(@Query('doctorId') doctorId: string, @Query('date') date: string) {
|
||||
if (!doctorId || !date) throw new HttpException('doctorId and date required', 400);
|
||||
return this.widget.getSlots(doctorId, date);
|
||||
}
|
||||
|
||||
@Post('book')
|
||||
@UseGuards(WidgetKeyGuard, CaptchaGuard)
|
||||
async book(@Body() body: WidgetBookRequest) {
|
||||
if (!body.patientName || !body.patientPhone || !body.doctorId || !body.scheduledAt) {
|
||||
throw new HttpException('patientName, patientPhone, doctorId, and scheduledAt required', 400);
|
||||
}
|
||||
return this.widget.bookAppointment(body);
|
||||
}
|
||||
|
||||
@Post('lead')
|
||||
@UseGuards(WidgetKeyGuard, CaptchaGuard)
|
||||
async lead(@Body() body: WidgetLeadRequest) {
|
||||
if (!body.name || !body.phone) {
|
||||
throw new HttpException('name and phone required', 400);
|
||||
}
|
||||
return this.widget.createLead(body);
|
||||
}
|
||||
|
||||
// Key management (admin only — no widget key guard, requires JWT)
|
||||
@Post('keys/generate')
|
||||
async generateKey(@Body() body: { hospitalName: string; allowedOrigins: string[] }) {
|
||||
if (!body.hospitalName) throw new HttpException('hospitalName required', 400);
|
||||
const { key, siteKey } = this.keys.generateKey(body.hospitalName, body.allowedOrigins ?? []);
|
||||
await this.keys.saveKey(siteKey);
|
||||
return { key, siteKey };
|
||||
}
|
||||
|
||||
@Get('keys')
|
||||
async listKeys() {
|
||||
return this.keys.listKeys();
|
||||
}
|
||||
|
||||
@Delete('keys/:siteId')
|
||||
async revokeKey(@Param('siteId') siteId: string) {
|
||||
const revoked = await this.keys.revokeKey(siteId);
|
||||
if (!revoked) throw new HttpException('Key not found', 404);
|
||||
return { status: 'revoked' };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create widget.module.ts**
|
||||
|
||||
```typescript
|
||||
// src/widget/widget.module.ts
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { WidgetController } from './widget.controller';
|
||||
import { WidgetService } from './widget.service';
|
||||
import { WidgetKeysService } from './widget-keys.service';
|
||||
import { PlatformModule } from '../platform/platform.module';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { ConfigThemeModule } from '../config/config-theme.module';
|
||||
|
||||
@Module({
|
||||
imports: [PlatformModule, AuthModule, ConfigThemeModule],
|
||||
controllers: [WidgetController],
|
||||
providers: [WidgetService, WidgetKeysService],
|
||||
exports: [WidgetKeysService],
|
||||
})
|
||||
export class WidgetModule {}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Register in app.module.ts**
|
||||
|
||||
Add import:
|
||||
```typescript
|
||||
import { WidgetModule } from './widget/widget.module';
|
||||
```
|
||||
|
||||
Add to imports array:
|
||||
```typescript
|
||||
WidgetModule,
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Serve static widget.js from main.ts**
|
||||
|
||||
In `src/main.ts`, after the NestJS app bootstrap, add static file serving for the widget bundle:
|
||||
|
||||
```typescript
|
||||
import { join } from 'path';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
|
||||
// After app.listen():
|
||||
app.useStaticAssets(join(__dirname, '..', 'public'), { prefix: '/' });
|
||||
```
|
||||
|
||||
Create `helix-engage-server/public/` directory for the widget bundle output.
|
||||
|
||||
- [ ] **Step 6: Build and verify**
|
||||
|
||||
```bash
|
||||
cd helix-engage-server && npm run build
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/widget/ src/app.module.ts src/main.ts public/
|
||||
git commit -m "feat: widget module — endpoints, service, key management, captcha"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Widget Bundle — Project Setup + Entry Point
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/helix-engage-widget/package.json`
|
||||
- Create: `packages/helix-engage-widget/vite.config.ts`
|
||||
- Create: `packages/helix-engage-widget/tsconfig.json`
|
||||
- Create: `packages/helix-engage-widget/src/types.ts`
|
||||
- Create: `packages/helix-engage-widget/src/api.ts`
|
||||
- Create: `packages/helix-engage-widget/src/main.ts`
|
||||
|
||||
- [ ] **Step 1: Create package.json**
|
||||
|
||||
```json
|
||||
{
|
||||
"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": "^7.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Create vite.config.ts**
|
||||
|
||||
```typescript
|
||||
import { defineConfig } from 'vite';
|
||||
import preact from '@preact/preset-vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [preact()],
|
||||
build: {
|
||||
lib: {
|
||||
entry: 'src/main.ts',
|
||||
name: 'HelixWidget',
|
||||
fileName: () => 'widget.js',
|
||||
formats: ['iife'],
|
||||
},
|
||||
outDir: '../../helix-engage-server/public',
|
||||
emptyOutDir: false,
|
||||
minify: 'terser',
|
||||
rollupOptions: {
|
||||
output: {
|
||||
inlineDynamicImports: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Create tsconfig.json**
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "preact",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"outDir": "dist",
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Create types.ts**
|
||||
|
||||
```typescript
|
||||
// src/types.ts
|
||||
|
||||
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;
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Create api.ts**
|
||||
|
||||
```typescript
|
||||
// src/api.ts
|
||||
|
||||
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;
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Create main.ts**
|
||||
|
||||
```typescript
|
||||
// src/main.ts
|
||||
|
||||
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;';
|
||||
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();
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Install dependencies and verify**
|
||||
|
||||
```bash
|
||||
cd packages/helix-engage-widget && npm install
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/helix-engage-widget/
|
||||
git commit -m "feat: widget bundle — project setup, API client, entry point"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Widget UI Components (Preact)
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/helix-engage-widget/src/styles.ts`
|
||||
- Create: `packages/helix-engage-widget/src/widget.tsx`
|
||||
- Create: `packages/helix-engage-widget/src/chat.tsx`
|
||||
- Create: `packages/helix-engage-widget/src/booking.tsx`
|
||||
- Create: `packages/helix-engage-widget/src/contact.tsx`
|
||||
- Create: `packages/helix-engage-widget/src/captcha.ts`
|
||||
|
||||
These are the Preact components rendered inside the shadow DOM. Each component is self-contained.
|
||||
|
||||
- [ ] **Step 1: Create styles.ts** — CSS string injected into shadow DOM
|
||||
- [ ] **Step 2: Create widget.tsx** — Main shell with bubble, panel, tab routing
|
||||
- [ ] **Step 3: Create chat.tsx** — AI chat with streaming, quick actions, lead capture fallback
|
||||
- [ ] **Step 4: Create booking.tsx** — Step-by-step appointment booking
|
||||
- [ ] **Step 5: Create contact.tsx** — Simple lead capture form
|
||||
- [ ] **Step 6: Create captcha.ts** — Load reCAPTCHA script, get token
|
||||
|
||||
Each component follows the pattern: fetch data from API, render form/chat, submit with captcha token.
|
||||
|
||||
- [ ] **Step 7: Build the widget**
|
||||
|
||||
```bash
|
||||
cd packages/helix-engage-widget && npm run build
|
||||
# Output: ../../helix-engage-server/public/widget.js
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/helix-engage-widget/src/
|
||||
git commit -m "feat: widget UI — chat, booking, contact, theming, shadow DOM"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Integration Test + Key Generation
|
||||
|
||||
**Files:**
|
||||
- None new — testing the full flow
|
||||
|
||||
- [ ] **Step 1: Generate a site key**
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:4100/api/widget/keys/generate \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"hospitalName":"Global Hospital","allowedOrigins":["http://localhost:3000","http://localhost:5173"]}' | python3 -m json.tool
|
||||
```
|
||||
|
||||
Save the returned `key` value.
|
||||
|
||||
- [ ] **Step 2: Test init endpoint**
|
||||
|
||||
```bash
|
||||
curl -s "http://localhost:4100/api/widget/init?key=SITE_KEY_HERE" | python3 -m json.tool
|
||||
```
|
||||
|
||||
Should return theme config with brand name, colors, captcha site key.
|
||||
|
||||
- [ ] **Step 3: Test widget.js serving**
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}" http://localhost:4100/widget.js
|
||||
```
|
||||
|
||||
Should return 200.
|
||||
|
||||
- [ ] **Step 4: Create a test HTML page**
|
||||
|
||||
Create `packages/helix-engage-widget/test.html`:
|
||||
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Widget Test</title></head>
|
||||
<body>
|
||||
<h1>Hospital Website</h1>
|
||||
<p>This is a test page for the Helix Engage widget.</p>
|
||||
<script src="http://localhost:4100/widget.js" data-key="SITE_KEY_HERE"></script>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
Open in browser, verify the floating bubble appears, themed correctly.
|
||||
|
||||
- [ ] **Step 5: Test booking flow end-to-end**
|
||||
|
||||
Click Book tab → select department → doctor → date → slot → fill name + phone → submit. Verify appointment + lead created in platform.
|
||||
|
||||
- [ ] **Step 6: Build sidecar and commit all**
|
||||
|
||||
```bash
|
||||
cd helix-engage-server && npm run build
|
||||
git add -A && git commit -m "feat: website widget — full integration (chat + booking + lead capture)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Execution Notes
|
||||
|
||||
- The widget bundle builds into `helix-engage-server/public/widget.js` — Vite outputs directly to the sidecar's public dir
|
||||
- The sidecar serves it via Express static middleware
|
||||
- Site keys use HMAC-SHA256 with `WIDGET_SECRET` env var
|
||||
- Captcha is gated by `RECAPTCHA_SECRET_KEY` env var — if not set, captcha is disabled (dev mode)
|
||||
- All widget endpoints use the server-side API key for platform queries (not the visitor's JWT)
|
||||
- The widget has no dependency on the main helix-engage frontend — completely standalone
|
||||
- Task 5 steps are intentionally less detailed — the UI components follow standard Preact patterns and depend on the API client from Task 4
|
||||
337
docs/superpowers/specs/2026-04-05-website-widget-design.md
Normal file
337
docs/superpowers/specs/2026-04-05-website-widget-design.md
Normal file
@@ -0,0 +1,337 @@
|
||||
# Website Widget — Embeddable AI Chat + Appointment Booking
|
||||
|
||||
**Date**: 2026-04-05
|
||||
**Status**: Draft
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
A single JavaScript file that hospitals embed on their website via a `<script>` tag. Renders a floating chat bubble that opens to an AI chatbot (hospital knowledge base), appointment booking flow, and lead capture form. Themed to match the hospital's branding. All write endpoints are captcha-gated.
|
||||
|
||||
---
|
||||
|
||||
## Embed Code
|
||||
|
||||
```html
|
||||
<script src="https://engage-api.srv1477139.hstgr.cloud/widget.js"
|
||||
data-key="a8f3e2b1.7c4d9e6f2a1b8c3d5e0f4a2b6c8d1e3f7a9b0c2d4e6f8a1b3c5d7e9f0a2b"></script>
|
||||
```
|
||||
|
||||
The `data-key` is an HMAC-signed token: `{siteId}.{hmacSignature}`. Cannot be guessed or forged without the server-side secret.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
Hospital Website (any tech stack)
|
||||
└─ <script data-key="xxx"> loads widget.js from sidecar
|
||||
└─ Widget initializes:
|
||||
1. GET /api/widget/init?key=xxx → validates key, returns theme + config
|
||||
2. Renders shadow DOM (CSS-isolated from host page)
|
||||
3. All interactions go to /api/widget/* endpoints
|
||||
|
||||
Sidecar (helix-engage-server):
|
||||
└─ src/widget/
|
||||
├── widget.controller.ts — REST endpoints for the widget
|
||||
├── widget.service.ts — lead creation, appointment booking, key validation
|
||||
├── widget.guard.ts — HMAC key validation + origin check
|
||||
├── captcha.guard.ts — reCAPTCHA/Turnstile verification
|
||||
└── widget-keys.service.ts — generate/validate site keys
|
||||
|
||||
Widget Bundle:
|
||||
└─ packages/helix-engage-widget/
|
||||
├── src/
|
||||
│ ├── main.ts — entry point, reads data-key, initializes
|
||||
│ ├── widget.ts — shadow DOM mount, theming, tab routing
|
||||
│ ├── chat.ts — AI chatbot (streaming)
|
||||
│ ├── booking.ts — appointment booking flow
|
||||
│ ├── contact.ts — lead capture form
|
||||
│ ├── captcha.ts — captcha integration
|
||||
│ ├── api.ts — HTTP client for widget endpoints
|
||||
│ └── styles.ts — CSS-in-JS (injected into shadow DOM)
|
||||
├── vite.config.ts — library mode, single IIFE bundle
|
||||
└── package.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sidecar Endpoints
|
||||
|
||||
All prefixed with `/api/widget/`. Public endpoints validate the site key. Write endpoints require captcha.
|
||||
|
||||
| Method | Path | Auth | Captcha | Description |
|
||||
|---|---|---|---|---|
|
||||
| GET | `/init` | Key | No | Returns theme, config, captcha site key |
|
||||
| POST | `/chat` | Key | Yes (first message only) | AI chat stream (same knowledge base as agent AI) |
|
||||
| GET | `/doctors` | Key | No | Department + doctor list with visiting hours |
|
||||
| GET | `/slots` | Key | No | Available time slots for a doctor + date |
|
||||
| POST | `/book` | Key | Yes | Create appointment + lead + patient |
|
||||
| POST | `/lead` | Key | Yes | Create lead (contact form submission) |
|
||||
| POST | `/keys/generate` | Admin JWT | No | Generate a new site key for a hospital |
|
||||
| GET | `/keys` | Admin JWT | No | List all site keys |
|
||||
| DELETE | `/keys/:siteId` | Admin JWT | No | Revoke a site key |
|
||||
|
||||
---
|
||||
|
||||
## Site Key System
|
||||
|
||||
### Generation
|
||||
|
||||
```
|
||||
siteId = uuid v4 (random)
|
||||
payload = siteId
|
||||
signature = HMAC-SHA256(payload, SERVER_SECRET)
|
||||
key = `${siteId}.${signature}`
|
||||
```
|
||||
|
||||
The `SERVER_SECRET` is an environment variable on the sidecar. Never leaves the server.
|
||||
|
||||
### Validation
|
||||
|
||||
```
|
||||
input = "a8f3e2b1.7c4d9e6f2a1b8c3d5e0f4a2b6c8d1e3f7a9b0c2d4e6f8a1b3c5d7e9f0a2b"
|
||||
[siteId, signature] = input.split('.')
|
||||
expectedSignature = HMAC-SHA256(siteId, SERVER_SECRET)
|
||||
valid = timingSafeEqual(signature, expectedSignature)
|
||||
```
|
||||
|
||||
### Storage
|
||||
|
||||
Site keys are stored in Redis (already running in the stack):
|
||||
|
||||
```
|
||||
Key: widget:keys:{siteId}
|
||||
Value: JSON { hospitalName, allowedOrigins, active, createdAt }
|
||||
TTL: none (persistent until revoked)
|
||||
```
|
||||
|
||||
Example:
|
||||
```
|
||||
widget:keys:a8f3e2b1 → {
|
||||
"hospitalName": "Global Hospital",
|
||||
"allowedOrigins": ["https://globalhospital.com", "https://www.globalhospital.com"],
|
||||
"createdAt": "2026-04-05T10:00:00Z",
|
||||
"active": true
|
||||
}
|
||||
```
|
||||
|
||||
CRUD via `SessionService` (getCache/setCache/deleteCache/scanKeys) — same pattern as caller cache and agent names.
|
||||
|
||||
### Origin Validation
|
||||
|
||||
On every widget request, the sidecar checks:
|
||||
1. Key signature is valid (HMAC)
|
||||
2. `siteId` exists and is active
|
||||
3. `Referer` or `Origin` header matches `allowedOrigins` for this site key
|
||||
4. If origin doesn't match → 403
|
||||
|
||||
---
|
||||
|
||||
## Widget UI
|
||||
|
||||
### Collapsed State (Floating Bubble)
|
||||
|
||||
- Position: fixed bottom-right, 20px margin
|
||||
- Size: 56px circle
|
||||
- Shows hospital logo (from theme)
|
||||
- Pulse animation on first load
|
||||
- Click → expands panel
|
||||
- Z-index: 999999 (above host page content)
|
||||
|
||||
### Expanded State (Panel)
|
||||
|
||||
- Size: 380px wide × 520px tall
|
||||
- Anchored bottom-right
|
||||
- Shadow DOM container (CSS isolation from host page)
|
||||
- Header: hospital logo + name + close button
|
||||
- Three tabs: Chat (default) | Book | Contact
|
||||
- All styled with brand colors from theme
|
||||
|
||||
### Chat Tab (Default)
|
||||
|
||||
- AI chatbot interface
|
||||
- Streaming responses (same endpoint as agent AI, but with widget system prompt)
|
||||
- Quick action chips: "Doctor availability", "Clinic timings", "Book appointment", "Treatment packages"
|
||||
- If AI detects it can't help → shows: "An agent will call you shortly" + lead capture fields (name, phone)
|
||||
- First message triggers captcha verification (invisible reCAPTCHA v3)
|
||||
|
||||
### Book Tab
|
||||
|
||||
Step-by-step appointment booking:
|
||||
|
||||
1. **Department** — dropdown populated from `/api/widget/doctors`
|
||||
2. **Doctor** — dropdown filtered by department, shows visiting hours
|
||||
3. **Date** — date picker (min: today, max: 30 days)
|
||||
4. **Time Slot** — grid of available slots from `/api/widget/slots`
|
||||
5. **Patient Details** — name, phone, age, gender, chief complaint
|
||||
6. **Captcha** — invisible reCAPTCHA v3 on submit
|
||||
7. **Confirmation** — "Appointment booked! Reference: ABC123. We'll send a confirmation SMS."
|
||||
|
||||
On successful booking:
|
||||
- Creates patient (if new phone number)
|
||||
- Creates lead with `source: 'WEBSITE'`
|
||||
- Creates appointment linked to patient + doctor
|
||||
- Rules engine scores the lead
|
||||
- Pushes to agent worklist
|
||||
- Real-time notification to agents
|
||||
|
||||
### Contact Tab
|
||||
|
||||
Simple lead capture form:
|
||||
- Name (required)
|
||||
- Phone (required)
|
||||
- Interest / Department (dropdown, optional)
|
||||
- Message (textarea, optional)
|
||||
- Captcha on submit
|
||||
- Success: "Thank you! An agent will call you shortly."
|
||||
|
||||
On submit:
|
||||
- Creates lead with `source: 'WEBSITE'`, `interestedService: interest`
|
||||
- Rules engine scores it
|
||||
- Pushes to agent worklist + notification
|
||||
|
||||
---
|
||||
|
||||
## Theming
|
||||
|
||||
Widget fetches theme from `/api/widget/init`:
|
||||
|
||||
```json
|
||||
{
|
||||
"brand": { "name": "Global Hospital", "logo": "https://..." },
|
||||
"colors": {
|
||||
"primary": "rgb(29 78 216)",
|
||||
"primaryLight": "rgb(219 234 254)",
|
||||
"text": "rgb(15 23 42)",
|
||||
"textLight": "rgb(100 116 139)"
|
||||
},
|
||||
"captchaSiteKey": "6Lc..."
|
||||
}
|
||||
```
|
||||
|
||||
Colors are injected as CSS variables inside the shadow DOM:
|
||||
```css
|
||||
:host {
|
||||
--widget-primary: rgb(29 78 216);
|
||||
--widget-primary-light: rgb(219 234 254);
|
||||
--widget-text: rgb(15 23 42);
|
||||
--widget-text-light: rgb(100 116 139);
|
||||
}
|
||||
```
|
||||
|
||||
All widget elements reference these variables. Changing the theme API → widget auto-updates on next load.
|
||||
|
||||
---
|
||||
|
||||
## Widget System Prompt (AI Chat)
|
||||
|
||||
Different from the agent AI prompt — tailored for website visitors:
|
||||
|
||||
```
|
||||
You are a virtual assistant for {hospitalName}.
|
||||
You help website visitors with:
|
||||
- Doctor availability and visiting hours
|
||||
- Clinic locations and timings
|
||||
- Health packages and pricing
|
||||
- Booking appointments
|
||||
- General hospital information
|
||||
|
||||
RULES:
|
||||
1. Be friendly and welcoming — this is the hospital's first impression
|
||||
2. If someone wants to book an appointment, guide them to the Book tab
|
||||
3. If you can't answer a question, say "I'd be happy to have our team call you" and ask for their name and phone number
|
||||
4. Never give medical advice
|
||||
5. Keep responses under 80 words — visitors are scanning, not reading
|
||||
6. Always mention the hospital name naturally in first response
|
||||
|
||||
KNOWLEDGE BASE:
|
||||
{same KB as agent AI — clinics, doctors, packages, insurance}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Captcha
|
||||
|
||||
- **Provider**: Google reCAPTCHA v3 (invisible) or Cloudflare Turnstile
|
||||
- **When**: On first chat message, appointment booking submit, lead form submit
|
||||
- **How**: Widget loads captcha script, gets token, sends with request. Sidecar validates via provider API before processing.
|
||||
- **Fallback**: If captcha fails to load (ad blocker), show a simple challenge or allow with rate limiting
|
||||
|
||||
---
|
||||
|
||||
## Widget Bundle
|
||||
|
||||
### Tech Stack
|
||||
|
||||
- **Preact** — 3KB, React-compatible API, sufficient for the widget UI
|
||||
- **Vite** — library mode build, outputs single IIFE bundle
|
||||
- **CSS-in-JS** — styles injected into shadow DOM (no external CSS files)
|
||||
- **Target**: ~60KB gzipped (Preact + UI + styles)
|
||||
|
||||
### Build Output
|
||||
|
||||
```
|
||||
dist/
|
||||
└── widget.js — single IIFE bundle, self-contained
|
||||
```
|
||||
|
||||
### Serving
|
||||
|
||||
Sidecar serves `widget.js` as a static file:
|
||||
```
|
||||
GET /widget.js → serves dist/widget.js with Cache-Control: public, max-age=3600
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lead Flow (all channels)
|
||||
|
||||
```
|
||||
Widget submit (chat/book/contact)
|
||||
→ POST /api/widget/lead or /api/widget/book
|
||||
→ captcha validation
|
||||
→ key + origin validation
|
||||
→ create patient (if new phone)
|
||||
→ create lead (source: WEBSITE, channel metadata)
|
||||
→ rules engine scores lead (source weight, campaign weight)
|
||||
→ push to agent worklist
|
||||
→ WebSocket notification to agents (bell + toast)
|
||||
→ response to widget: success + reference number
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
| Endpoint | Limit |
|
||||
|---|---|
|
||||
| `/init` | 60/min per IP |
|
||||
| `/chat` | 10/min per IP |
|
||||
| `/doctors`, `/slots` | 30/min per IP |
|
||||
| `/book` | 5/min per IP |
|
||||
| `/lead` | 5/min per IP |
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
**In scope:**
|
||||
- Widget JS bundle (Preact + shadow DOM + theming)
|
||||
- Sidecar widget endpoints (init, chat, doctors, slots, book, lead)
|
||||
- Site key generation + validation (HMAC)
|
||||
- Captcha integration (reCAPTCHA v3)
|
||||
- Lead creation with worklist integration
|
||||
- Appointment booking end-to-end
|
||||
- Origin validation
|
||||
- Rate limiting
|
||||
- Widget served from sidecar
|
||||
|
||||
**Out of scope:**
|
||||
- Live agent chat in widget (shows "agent will call you" instead)
|
||||
- Widget analytics/tracking dashboard
|
||||
- A/B testing widget variations
|
||||
- Multi-language widget UI
|
||||
- File upload in widget
|
||||
- Payment integration in widget
|
||||
Reference in New Issue
Block a user