MRANTI Cloud Functions

/** * 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);
Translate »
滚动至顶部