mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
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:
@@ -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 {}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
14
src/main.ts
14
src/main.ts
@@ -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}`);
|
||||
|
||||
45
src/widget/captcha.guard.ts
Normal file
45
src/widget/captcha.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
226
src/widget/webhooks.controller.ts
Normal file
226
src/widget/webhooks.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
25
src/widget/widget-key.guard.ts
Normal file
25
src/widget/widget-key.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
94
src/widget/widget-keys.service.ts
Normal file
94
src/widget/widget-keys.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
74
src/widget/widget.controller.ts
Normal file
74
src/widget/widget.controller.ts
Normal 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' };
|
||||
}
|
||||
}
|
||||
16
src/widget/widget.module.ts
Normal file
16
src/widget/widget.module.ts
Normal 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 {}
|
||||
152
src/widget/widget.service.ts
Normal file
152
src/widget/widget.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
38
src/widget/widget.types.ts
Normal file
38
src/widget/widget.types.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user