mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +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:
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user