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

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 };
}
}
}