feat: website widget + omnichannel lead webhooks

Widget (embeddable):
- Preact + Vite library mode → 35KB IIFE bundle served from sidecar
- Shadow DOM for CSS isolation, themed from sidecar theme API
- AI chatbot (streaming), appointment booking (4-step wizard), lead capture form
- FontAwesome Pro duotone SVGs bundled as inline strings
- HMAC-signed site keys (Redis storage, origin validation)
- Captcha guard (Cloudflare Turnstile ready)

Sidecar endpoints:
- GET/PUT/DELETE /api/widget/keys/* — site key management
- GET /api/widget/init — theme + config (key-gated)
- GET /api/widget/doctors, /slots — doctor list + availability
- POST /api/widget/book — appointment booking (captcha-gated)
- POST /api/widget/lead — lead capture (captcha-gated)

Omnichannel webhooks:
- POST /api/webhook/facebook — Meta Lead Ads (verification + lead ingestion)
- POST /api/webhook/google — Google Ads lead form extension
- POST /api/webhook/whatsapp — Ozonetel WhatsApp callback (receiver ready)
- POST /api/webhook/sms — Ozonetel SMS callback (receiver ready)

Infrastructure:
- SessionService.setCachePersistent() for non-expiring Redis keys
- Static file serving from /public (widget.js)
- WidgetModule registered in AppModule

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-06 06:49:02 +05:30
parent 8cc1bdc812
commit 76fa6f51de
13 changed files with 866 additions and 1 deletions

41
public/test.html Normal file
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>

136
public/widget.js Normal file

File diff suppressed because one or more lines are too long

View File

@@ -19,6 +19,7 @@ import { EventsModule } from './events/events.module';
import { CallerResolutionModule } from './caller/caller-resolution.module';
import { RulesEngineModule } from './rules-engine/rules-engine.module';
import { ConfigThemeModule } from './config/config-theme.module';
import { WidgetModule } from './widget/widget.module';
@Module({
imports: [
@@ -44,6 +45,7 @@ import { ConfigThemeModule } from './config/config-theme.module';
CallerResolutionModule,
RulesEngineModule,
ConfigThemeModule,
WidgetModule,
],
})
export class AppModule {}

View File

@@ -60,6 +60,10 @@ export class SessionService implements OnModuleInit {
await this.redis.set(key, value, 'EX', ttlSeconds);
}
async setCachePersistent(key: string, value: string): Promise<void> {
await this.redis.set(key, value);
}
async deleteCache(key: string): Promise<void> {
await this.redis.del(key);
}

View File

@@ -1,9 +1,11 @@
import { NestFactory } from '@nestjs/core';
import type { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path';
import { AppModule } from './app.module';
import { ConfigService } from '@nestjs/config';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const config = app.get(ConfigService);
app.enableCors({
@@ -11,6 +13,16 @@ async function bootstrap() {
credentials: true,
});
// Serve widget.js and other static files from /public
app.useStaticAssets(join(__dirname, '..', 'public'), {
setHeaders: (res, path) => {
if (path.endsWith('.js')) {
res.setHeader('Cache-Control', 'public, max-age=3600');
res.setHeader('Access-Control-Allow-Origin', '*');
}
},
});
const port = config.get('port');
await app.listen(port);
console.log(`Helix Engage Server running on port ${port}`);

View File

@@ -0,0 +1,45 @@
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;
}
}
}

View File

@@ -0,0 +1,226 @@
import { Controller, Post, Get, Body, Query, Logger, HttpException } from '@nestjs/common';
import { WidgetService } from './widget.service';
import { createHmac } from 'crypto';
@Controller('api/webhook')
export class WebhooksController {
private readonly logger = new Logger(WebhooksController.name);
private readonly googleWebhookKey: string;
private readonly fbVerifyToken: string;
constructor(private readonly widget: WidgetService) {
this.googleWebhookKey = process.env.GOOGLE_WEBHOOK_KEY ?? '';
this.fbVerifyToken = process.env.FB_VERIFY_TOKEN ?? 'helix-engage-verify';
}
// ─── Facebook / Instagram Lead Ads ───
// Webhook verification (Meta sends GET to verify endpoint)
@Get('facebook')
verifyFacebook(
@Query('hub.mode') mode: string,
@Query('hub.verify_token') token: string,
@Query('hub.challenge') challenge: string,
) {
if (mode === 'subscribe' && token === this.fbVerifyToken) {
this.logger.log('[FB] Webhook verified');
return challenge;
}
throw new HttpException('Verification failed', 403);
}
// Receive leads from Facebook/Instagram
@Post('facebook')
async facebookLead(@Body() body: any) {
this.logger.log(`[FB] Webhook received: ${JSON.stringify(body).substring(0, 200)}`);
if (body.object !== 'page') {
return { status: 'ignored', reason: 'not a page event' };
}
let leadsCreated = 0;
for (const entry of body.entry ?? []) {
for (const change of entry.changes ?? []) {
if (change.field !== 'leadgen') continue;
const leadData = change.value;
const leadgenId = leadData.leadgen_id;
const formId = leadData.form_id;
const pageId = leadData.page_id;
this.logger.log(`[FB] Lead received: leadgen_id=${leadgenId} form_id=${formId} page_id=${pageId}`);
// Fetch full lead data from Meta Graph API
const lead = await this.fetchFacebookLead(leadgenId);
if (!lead) {
this.logger.warn(`[FB] Could not fetch lead data for ${leadgenId}`);
continue;
}
const name = this.extractFbField(lead, 'full_name') ?? 'Facebook Lead';
const phone = this.extractFbField(lead, 'phone_number') ?? '';
const email = this.extractFbField(lead, 'email') ?? '';
try {
await this.widget.createLead({
name,
phone: phone.replace(/[^0-9+]/g, ''),
interest: `Facebook Ad (form: ${formId})`,
message: email ? `Email: ${email}` : undefined,
captchaToken: 'webhook-bypass',
});
leadsCreated++;
this.logger.log(`[FB] Lead created: ${name} (${phone})`);
} catch (err: any) {
this.logger.error(`[FB] Lead creation failed: ${err.message}`);
}
}
}
return { status: 'ok', leadsCreated };
}
private async fetchFacebookLead(leadgenId: string): Promise<any | null> {
const accessToken = process.env.FB_PAGE_ACCESS_TOKEN;
if (!accessToken) {
this.logger.warn('[FB] FB_PAGE_ACCESS_TOKEN not set — cannot fetch lead details');
return null;
}
try {
const res = await fetch(`https://graph.facebook.com/v21.0/${leadgenId}?access_token=${accessToken}`);
if (!res.ok) return null;
return res.json();
} catch {
return null;
}
}
private extractFbField(lead: any, fieldName: string): string | null {
const fields = lead.field_data ?? [];
const field = fields.find((f: any) => f.name === fieldName);
return field?.values?.[0] ?? null;
}
// ─── Google Ads Lead Form ───
@Post('google')
async googleLead(@Body() body: any) {
this.logger.log(`[GOOGLE] Webhook received: ${JSON.stringify(body).substring(0, 200)}`);
// Verify webhook key if configured
if (this.googleWebhookKey && body.google_key) {
const expected = createHmac('sha256', this.googleWebhookKey)
.update(body.lead_id ?? '')
.digest('hex');
// Google sends the key directly, not as HMAC — just compare
if (body.google_key !== this.googleWebhookKey) {
this.logger.warn('[GOOGLE] Invalid webhook key');
throw new HttpException('Invalid webhook key', 403);
}
}
const isTest = body.is_test === true;
const leadId = body.lead_id;
const campaignId = body.campaign_id;
const formId = body.form_id;
// Extract user data from column data
const userData = body.user_column_data ?? [];
const name = this.extractGoogleField(userData, 'FULL_NAME')
?? this.extractGoogleField(userData, 'FIRST_NAME')
?? 'Google Ad Lead';
const phone = this.extractGoogleField(userData, 'PHONE_NUMBER') ?? '';
const email = this.extractGoogleField(userData, 'EMAIL') ?? '';
const city = this.extractGoogleField(userData, 'CITY') ?? '';
this.logger.log(`[GOOGLE] Lead: ${name} | ${phone} | campaign=${campaignId} | test=${isTest}`);
try {
const result = await this.widget.createLead({
name,
phone: phone.replace(/[^0-9+]/g, ''),
interest: `Google Ad${isTest ? ' (TEST)' : ''} (campaign: ${campaignId ?? 'unknown'})`,
message: [email, city].filter(Boolean).join(', ') || undefined,
captchaToken: 'webhook-bypass',
});
this.logger.log(`[GOOGLE] Lead created: ${result.leadId}${isTest ? ' (test)' : ''}`);
return { status: 'ok', leadId: result.leadId, isTest };
} catch (err: any) {
this.logger.error(`[GOOGLE] Lead creation failed: ${err.message}`);
throw new HttpException('Lead creation failed', 500);
}
}
private extractGoogleField(columnData: any[], fieldName: string): string | null {
const field = columnData.find((f: any) => f.column_id === fieldName);
return field?.string_value ?? null;
}
// ─── Ozonetel WhatsApp Callback ───
// Configure in Ozonetel: Chat Callback URL → https://engage-api.srv1477139.hstgr.cloud/api/webhook/whatsapp
// Payload format will be adapted once Ozonetel confirms their schema
@Post('whatsapp')
async whatsappLead(@Body() body: any) {
this.logger.log(`[WHATSAPP] Webhook received: ${JSON.stringify(body).substring(0, 300)}`);
const phone = body.from ?? body.caller_id ?? body.phone ?? body.customerNumber ?? '';
const name = body.name ?? body.customerName ?? '';
const message = body.message ?? body.text ?? body.body ?? '';
if (!phone) {
this.logger.warn('[WHATSAPP] No phone number in payload');
return { status: 'ignored', reason: 'no phone number' };
}
try {
const result = await this.widget.createLead({
name: name || 'WhatsApp Lead',
phone: phone.replace(/[^0-9+]/g, ''),
interest: 'WhatsApp Enquiry',
message: message || undefined,
captchaToken: 'webhook-bypass',
});
this.logger.log(`[WHATSAPP] Lead created: ${result.leadId} (${phone})`);
return { status: 'ok', leadId: result.leadId };
} catch (err: any) {
this.logger.error(`[WHATSAPP] Lead creation failed: ${err.message}`);
return { status: 'error', message: err.message };
}
}
// ─── Ozonetel SMS Callback ───
// Configure in Ozonetel: SMS Callback URL → https://engage-api.srv1477139.hstgr.cloud/api/webhook/sms
@Post('sms')
async smsLead(@Body() body: any) {
this.logger.log(`[SMS] Webhook received: ${JSON.stringify(body).substring(0, 300)}`);
const phone = body.from ?? body.caller_id ?? body.phone ?? body.senderNumber ?? '';
const name = body.name ?? '';
const message = body.message ?? body.text ?? body.body ?? '';
if (!phone) {
this.logger.warn('[SMS] No phone number in payload');
return { status: 'ignored', reason: 'no phone number' };
}
try {
const result = await this.widget.createLead({
name: name || 'SMS Lead',
phone: phone.replace(/[^0-9+]/g, ''),
interest: 'SMS Enquiry',
message: message || undefined,
captchaToken: 'webhook-bypass',
});
this.logger.log(`[SMS] Lead created: ${result.leadId} (${phone})`);
return { status: 'ok', leadId: result.leadId };
} catch (err: any) {
this.logger.error(`[SMS] Lead creation failed: ${err.message}`);
return { status: 'error', message: err.message };
}
}
}

View File

@@ -0,0 +1,25 @@
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);
}
request.widgetSiteKey = siteKey;
return true;
}
}

View File

@@ -0,0 +1,94 @@
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);
const expected = this.sign(siteId);
try {
if (!timingSafeEqual(Buffer.from(signature, 'hex'), Buffer.from(expected, 'hex'))) return null;
} catch {
return null;
}
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 true; // Allow no-origin for dev/testing
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');
}
}

View File

@@ -0,0 +1,74 @@
import { Controller, Get, Post, Delete, Body, Query, Param, UseGuards, Logger, HttpException } from '@nestjs/common';
import { WidgetService } from './widget.service';
import { WidgetKeysService } from './widget-keys.service';
import { WidgetKeyGuard } from './widget-key.guard';
import { CaptchaGuard } from './captcha.guard';
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 endpoints)
@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' };
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { WidgetController } from './widget.controller';
import { WebhooksController } from './webhooks.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, WebhooksController],
providers: [WidgetService, WidgetKeysService],
exports: [WidgetKeysService],
})
export class WidgetModule {}

View File

@@ -0,0 +1,152 @@
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') ?? '';
}
private get auth() {
return `Bearer ${this.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 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, this.auth,
);
return data.doctors.edges.map((e: any) => e.node);
}
async getSlots(doctorId: string, date: string): Promise<any> {
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, this.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 phone = req.patientPhone.replace(/[^0-9]/g, '').slice(-10);
// Find or create 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, this.auth,
);
patientId = existing.patients.edges[0]?.node?.id ?? null;
} catch { /* continue */ }
if (!patientId) {
const firstName = req.patientName.split(' ')[0];
const lastName = req.patientName.split(' ').slice(1).join(' ') || '';
const created = await this.platform.queryWithAuth<any>(
`mutation($data: PatientCreateInput!) { createPatient(data: $data) { id } }`,
{ data: {
fullName: { firstName, lastName },
phones: { primaryPhoneNumber: `+91${phone}` },
patientType: 'NEW',
} },
this.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,
} },
this.auth,
);
// Create lead
const firstName = req.patientName.split(' ')[0];
const lastName = req.patientName.split(' ').slice(1).join(' ') || '';
await this.platform.queryWithAuth<any>(
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
{ data: {
name: req.patientName,
contactName: { firstName, lastName },
contactPhone: { primaryPhoneNumber: `+91${phone}` },
source: 'WEBSITE',
status: 'APPOINTMENT_SET',
interestedService: req.chiefComplaint ?? 'Appointment Booking',
patientId,
} },
this.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 phone = req.phone.replace(/[^0-9]/g, '').slice(-10);
const firstName = req.name.split(' ')[0];
const lastName = req.name.split(' ').slice(1).join(' ') || '';
const data = await this.platform.queryWithAuth<any>(
`mutation($data: LeadCreateInput!) { createLead(data: $data) { id } }`,
{ data: {
name: req.name,
contactName: { firstName, lastName },
contactPhone: { primaryPhoneNumber: `+91${phone}` },
source: 'WEBSITE',
status: 'NEW',
interestedService: req.interest ?? 'Website Enquiry',
} },
this.auth,
);
this.logger.log(`Widget lead: ${req.name} (${phone}) — ${req.interest ?? 'general'}`);
return { leadId: data.createLead.id };
}
}

View File

@@ -0,0 +1,38 @@
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;
};