/**
* BBB MRANTI — Cloud Functions (extended)
* Node 18+ | Express
* Features:
* – POST /contact
* – POST /ambassador
* – GET /verify?token=…
* – GET /exportVerified (protected by admin bearer key)
* – reCAPTCHA server-side verify
* – HubSpot push on verification
*
* BEFORE DEPLOY:
* firebase functions:config:set sendgrid.api_key=”SG_xxx” \
* email.from=”hello@bbb-official.com” email.partners=”partnerships@bbb-official.com” \
* email.ambassador=”ambassador@bbb-official.com” public.base_url=”https://mranti.bbb-official.com” \
* hubspot.api_key=”YOUR_HUBSPOT_PRIVATE_APP_TOKEN” recaptcha.secret_key=”RECAPTCHA_SECRET” \
* admin.key=”LONG_RANDOM_ADMIN_KEY” sheets.spreadsheet_id=”SHEET_ID” \
* sheets.service_account_json=”$(cat service-account.json | sed ‘s/\”/\\\\\”/g’)”
*/const functions = require(‘firebase-functions’);
const admin = require(‘firebase-admin’);
const express = require(‘express’);
const cors = require(‘cors’);
const { nanoid } = require(‘nanoid’);
const validator = require(‘validator’);
const sgMail = require(‘@sendgrid/mail’);
const fetch = require(‘node-fetch’); // if not available, npm install node-fetch@2
const { google } = require(‘googleapis’);admin.initializeApp();const CFG = {
PUBLIC_BASE_URL: functions.config().public?.base_url || ‘https://your-domain.com’,
SENDGRID_KEY: functions.config().sendgrid?.api_key || ”,
FROM_EMAIL: functions.config().email?.from || ‘hello@bbb-official.com’,
PARTNERS_EMAIL: functions.config().email?.partners || ‘partnerships@bbb-official.com’,
AMB_EMAIL: functions.config().email?.ambassador || ‘ambassador@bbb-official.com’,
HUBSPOT_TOKEN: functions.config().hubspot?.api_key || ”,
RECAPTCHA_SECRET: functions.config().recaptcha?.secret_key || ”,
ADMIN_KEY: functions.config().admin?.key || ”,
SHEETS_SPREADSHEET_ID: functions.config().sheets?.spreadsheet_id || ”,
SHEETS_SA_JSON: functions.config().sheets?.service_account_json || ”
};// init SendGrid
if (CFG.SENDGRID_KEY) sgMail.setApiKey(CFG.SENDGRID_KEY);// Sheets client lazy init
let sheetsClient = null;
async function initSheets() {
if (!CFG.SHEETS_SPREADSHEET_ID || !CFG.SHEETS_SA_JSON) return null;
if (sheetsClient) return sheetsClient;
try {
const serviceAccount = JSON.parse(CFG.SHEETS_SA_JSON);
const jwt = new google.auth.JWT(
serviceAccount.client_email,
null,
(serviceAccount.private_key || ”).replace(/\\n/g, ‘\n’),
[‘https://www.googleapis.com/auth/spreadsheets’]
);
await jwt.authorize();
sheetsClient = google.sheets({ version: ‘v4’, auth: jwt });
return sheetsClient;
} catch (e) {
console.warn(‘Sheets init failed’, e.message || e);
return null;
}
}
async function appendSheetRow(row) {
try {
const c = await initSheets();
if (!c) return;
await c.spreadsheets.values.append({
spreadsheetId: CFG.SHEETS_SPREADSHEET_ID,
range: ‘Sheet1!A1’,
valueInputOption: ‘USER_ENTERED’,
requestBody: { values: [row] }
});
} catch (e) {
console.error(‘appendSheetRow error’, e.message || e);
}
}// mail helper (SendGrid)
async function sendMail(to, subject, text, html) {
if (!CFG.SENDGRID_KEY) { console.warn(‘No SendGrid configured’); return null; }
const msg = { to, from: CFG.FROM_EMAIL, subject, text, html };
return sgMail.send(msg);
}// hubspot push (private app token)
async function pushToHubSpot(lead) {
try {
if (!CFG.HUBSPOT_TOKEN) return null;
const url = ‘https://api.hubapi.com/crm/v3/objects/contacts’;
const body = {
properties: {
email: lead.email,
firstname: (lead.name || ”).split(‘ ‘)[0] || lead.name || ”,
company: lead.company || ”,
bbb_case_id: lead.caseId || ”,
bbb_verified: String(lead.verified || false),
bbb_source: lead.source || ‘MRANTI’
}
};
const res = await fetch(url, {
method: ‘POST’,
headers: { ‘Content-Type’: ‘application/json’, Authorization: `Bearer ${CFG.HUBSPOT_TOKEN}` },
body: JSON.stringify(body)
});
if (!res.ok) {
const txt = await res.text();
console.warn(‘HubSpot push failed’, res.status, txt);
return null;
}
const json = await res.json();
return json;
} catch (e) {
console.error(‘pushToHubSpot error’, e);
return null;
}
}// utilities
function generateCaseId(prefix=’PART’) {
const d = new Date();
const date = d.toISOString().slice(0,10).replace(/-/g,”);
return `BBB-${prefix}-${date}-${nanoid(6).toUpperCase()}`;
}
function sanitizePayload(body = {}) {
return {
name: (body.name || ”).trim(),
company: (body.company || ”).trim(),
email: (body.email || ”).trim().toLowerCase(),
phone: (body.phone || ”).trim(),
budget: (body.budget || ”).trim(),
message: (body.message || body.short || ”).trim(),
type: (body.type || ‘other’).trim(),
source: (body.source || ‘WEB_FORM’).trim(),
recaptcha: body.recaptcha || ”
};
}// reCAPTCHA verify
async function verifyRecaptcha(token, remoteip) {
if (!CFG.RECAPTCHA_SECRET) return true; // allow if not configured (but configure in prod)
const params = new URLSearchParams();
params.append(‘secret’, CFG.RECAPTCHA_SECRET);
params.append(‘response’, token);
if (remoteip) params.append(‘remoteip’, remoteip);
try {
const resp = await fetch(‘https://www.google.com/recaptcha/api/siteverify’, { method: ‘POST’, body: params });
const j = await resp.json();
// tune score threshold as needed (v3 gives score)
const OK = j.success && (typeof j.score === ‘undefined’ ? true : j.score >= 0.4);
if (!OK) console.warn(‘recaptcha failed’, j);
return OK;
} catch (e) {
console.warn(‘recaptcha error’, e);
return false;
}
}// Express app
const app = express();
app.use(cors({ origin: true }));
app.use(express.json({ limit: ‘256kb’ }));// Basic in-memory rate limiter
const rateMap = new Map();
function rateLimit(req, res, next) {
const ip = req.headers[‘x-forwarded-for’] || req.ip || ‘unknown’;
const windowMs = 60 * 1000;
const max = 120; // 120 req/min per IP
const now = Date.now();
const info = rateMap.get(ip) || { t: now, c: 0 };
if (now – info.t > windowMs) { info.t = now; info.c = 1; } else { info.c++; }
rateMap.set(ip, info);
if (info.c > max) return res.status(429).json({ ok:false, error:’rate_limited’ });
next();
}
app.use(rateLimit);// POST /contact
app.post(‘/contact’, async (req, res) => {
try {
const raw = sanitizePayload(req.body || {});
if (!raw.name || !raw.email || !validator.isEmail(raw.email)) return res.status(400).json({ ok:false, error:’invalid_input’ });// reCAPTCHA verify
const recaptchaOk = await verifyRecaptcha(raw.recaptcha, req.ip);
if (!recaptchaOk) return res.status(400).json({ ok:false, error:’recaptcha_failed’ });const caseId = generateCaseId(‘PART’);
const token = nanoid(28);
const doc = {
caseId,
type: raw.type || ‘partnership’,
name: raw.name,
company: raw.company || ”,
email: raw.email,
phone: raw.phone || ”,
budget: raw.budget || ”,
message: raw.message || ”,
source: raw.source || ‘WEB_FORM’,
tempId: ”,
verified: false,
verifyToken: token,
createdAt: admin.firestore.FieldValue.serverTimestamp()
};await admin.firestore().collection(‘inbound’).doc(caseId).set(doc);
appendSheetRow([ new Date().toISOString(), caseId, doc.type, doc.name, doc.email, doc.company, doc.phone, doc.budget, doc.message, ‘false’ ]);const verifyUrl = `${CFG.PUBLIC_BASE_URL.replace(/\\/$/, ”)}/api/verify?token=${token}`;
const subject = `Confirm your BBB submission — ${caseId}`;
const text = `Hi ${doc.name},\\n\\nPlease confirm your submission (Case: ${caseId}) by clicking:\\n${verifyUrl}\\n\\nIf you did not submit, ignore this email.\\n\\n— BBB`;
const html = `
Hi ${doc.name},
Please confirm your submission (Case: ${caseId}) by clicking this link.
`;await sendMail(doc.email, subject, text, html);
await sendMail(CFG.PARTNERS_EMAIL, `INBOUND: ${caseId}`, `New inbound: ${caseId}\\nType: ${doc.type}\\nName: ${doc.name}\\nEmail: ${doc.email}\\nMessage:\\n${doc.message}`, `
New inbound: ${caseId}\\nType: ${doc.type}`);return res.json({ ok:true, caseId });
} catch (e) {
console.error(‘contact error’, e);
return res.status(500).json({ ok:false, error:’server_error’ });
}
});// POST /ambassador
app.post(‘/ambassador’, async (req, res) => {
try {
const raw = sanitizePayload(req.body || {});
if (!raw.name || !raw.email || !validator.isEmail(raw.email)) return res.status(400).json({ ok:false, error:’invalid_input’ });const recaptchaOk = await verifyRecaptcha(raw.recaptcha, req.ip);
if (!recaptchaOk) return res.status(400).json({ ok:false, error:’recaptcha_failed’ });const caseId = generateCaseId(‘AMB’);
const token = nanoid(28);
const doc = {
caseId,
type: ‘ambassador’,
name: raw.name,
company: raw.company || ”,
email: raw.email,
phone: raw.phone || ”,
budget: ”,
message: raw.message || ”,
source: raw.source || ‘MRANTI-PILOT-PORTAL’,
tempId: ”,
verified: false,
verifyToken: token,
createdAt: admin.firestore.FieldValue.serverTimestamp()
};await admin.firestore().collection(‘inbound’).doc(caseId).set(doc);
appendSheetRow([ new Date().toISOString(), caseId, doc.type, doc.name, doc.email, doc.company, doc.phone, ”, doc.message, ‘false’ ]);const verifyUrl = `${CFG.PUBLIC_BASE_URL.replace(/\\/$/, ”)}/api/verify?token=${token}`;
const subject = `Confirm your Ambassador application — ${caseId}`;
await sendMail(doc.email, subject, `Please confirm: ${verifyUrl}`, `
Please confirm your application here
`);
await sendMail(CFG.AMB_EMAIL, `AMB APP: ${caseId}`, `Ambassador application: ${caseId}\\nName: ${doc.name}\\nEmail: ${doc.email}`, `
Ambassador: ${caseId}`);return res.json({ ok:true, caseId });
} catch (e) {
console.error(‘ambassador error’, e);
return res.status(500).json({ ok:false, error:’server_error’ });
}
});// GET /verify?token=…
app.get(‘/verify’, async (req, res) => {
try {
const token = String(req.query.token || ”).trim();
if (!token) return res.status(400).send(‘Invalid token’);const q = await admin.firestore().collection(‘inbound’).where(‘verifyToken’,’==’,token).limit(1).get();
if (q.empty) return res.status(404).send(‘Token not found or already used’);const docSnap = q.docs[0];
const data = docSnap.data();
if (data.verified) return res.send(‘Already verified’);const verifiedAt = admin.firestore.FieldValue.serverTimestamp();
const updates = { verified: true, verifyToken: ”, verifiedAt };
let tempId = ”;
if (data.type === ‘ambassador’) {
tempId = `BBB-${new Date().getFullYear()}-${nanoid(6).toUpperCase()}`;
updates.tempId = tempId;
await sendMail(data.email, `BBB Ambassador Verified — ${data.caseId}`, `Your ambassador ID: ${tempId}`, `
Temp ID: ${tempId}`);
} else {
await sendMail(data.email, `BBB Inquiry Confirmed — ${data.caseId}`, `Your submission ${data.caseId} is verified.`, `
Case: ${data.caseId}`);
}await docSnap.ref.update(updates);
appendSheetRow([ new Date().toISOString(), data.caseId, ‘VERIFIED’, data.name, data.email, data.company || ”, data.phone || ”, ”, data.message || ”, ‘true’, updates.tempId || ” ]);// push to HubSpot (best-effort)
await pushToHubSpot({ email: data.email, name: data.name, company: data.company, caseId: data.caseId, verified: true, source: data.source });await sendMail(CFG.PARTNERS_EMAIL, `VERIFIED: ${data.caseId}`, `Case ${data.caseId} verified by ${data.email}`);
return res.send(`Thank you. Your submission ${data.caseId} has been verified. ${tempId ? ‘Temp ID: ‘+tempId : ”}`);
} catch (e) {
console.error(‘verify error’, e);
return res.status(500).send(‘Server error’);
}
});// GET /exportVerified (protected by admin key)
app.get(‘/exportVerified’, async (req, res) => {
try {
const auth = req.get(‘Authorization’) || ”;
if (!auth.startsWith(‘Bearer ‘) || auth.split(‘ ‘)[1] !== CFG.ADMIN_KEY) return res.status(401).send(‘Unauthorized’);const snapshot = await admin.firestore().collection(‘inbound’).where(‘verified’,’==’,true).orderBy(‘verifiedAt’,’desc’).get();
let csv = ‘caseId,name,email,company,verifiedAt,tempId\\n’;
snapshot.forEach(doc => {
const d = doc.data();
const vAt = d.verifiedAt ? (d.verifiedAt.toDate ? d.verifiedAt.toDate().toISOString() : String(d.verifiedAt)) : ”;
csv += `${d.caseId},”${(d.name||”).replace(/”/g,'””‘)}”,${d.email},”${(d.company||”).replace(/”/g,'””‘)}”,${vAt},${d.tempId||”}\\n`;
});
res.setHeader(‘Content-Type’,’text/csv’);
res.setHeader(‘Content-Disposition’, `attachment; filename=bbb_verified_${new Date().toISOString().slice(0,10)}.csv`);
res.send(csv);
} catch (e) {
console.error(‘exportVerified error’, e);
return res.status(500).send(‘Server error’);
}
});exports.api = functions.region(‘asia-southeast2’).https.onRequest(app);