mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-05-18 20:08:19 +00:00
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>
227 lines
9.1 KiB
TypeScript
227 lines
9.1 KiB
TypeScript
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 };
|
|
}
|
|
}
|
|
}
|