= 2) { sh.getRange(idx, 1, 1, HEADERS.length).setValues([row]); return idx; } else { sh.appendRow(row); return sh.getLastRow(); }}/****************************** * LOOKUP / MATCHING ******************************/function findRowIndexForSmartKey(sh, rec, explicitMode) { const values = sh.getDataRange().getValues(); if (values.length < 2) return -1; const headers = values[0]; const col = name => headers.indexOf(name); const iCallId = col('callId'); const iExternalId = col('externalId'); const iOrderId = col('orderId'); const iPhone = col('phone'); const iEmail = col('email'); const iCrmId = col('crm_lead_id'); const wantCall = /callId$/.test(explicitMode || '') || (!explicitMode && rec.callId); const wantExt = /externalId$/.test(explicitMode || '') || (!explicitMode && rec.externalId); const wantOrder = /orderId$/.test(explicitMode || '') || (!explicitMode && rec.orderId); const wantPhone = /phone$/.test(explicitMode || '') || (!explicitMode && rec.phone); const wantEmail = /email$/.test(explicitMode || '') || (!explicitMode && rec.email); const wantCrm = /(crm_id|crm_lead_id)$/.test(explicitMode || '') || (!explicitMode && rec.crm_lead_id); // Priority: callId → externalId → orderId → phone → email → crm_lead_id if (wantCall && rec.callId) { const r = values.findIndex((r, i) => i>0 && r[iCallId] === rec.callId); if (r !== -1) return r+1; } if (wantExt && rec.externalId) { const r = values.findIndex((r, i) => i>0 && r[iExternalId] === rec.externalId); if (r !== -1) return r+1; } if (wantOrder && rec.orderId) { const r = values.findIndex((r, i) => i>0 && r[iOrderId] === rec.orderId); if (r !== -1) return r+1; } if (wantPhone && rec.phone) { const r = values.findIndex((r, i) => i>0 && strip(String(r[iPhone])) === strip(rec.phone)); if (r !== -1) return r+1; } if (wantEmail && rec.email) { const r = values.findIndex((r, i) => i>0 && String(r[iEmail]).toLowerCase() === String(rec.email).toLowerCase()); if (r !== -1) return r+1; } if (wantCrm && rec.crm_lead_id) { const r = values.findIndex((r, i) => i>0 && String(r[iCrmId]) === String(rec.crm_lead_id)); if (r !== -1) return r+1; } return -1;}/****************************** * TRANSFORMS ******************************/function normalizeRecord(rec, payload) { const cleaned = Object.assign({}, rec); // Coerce/normalize cleaned.env = cleaned.env || payload.env || 'prod'; cleaned.email_valid = !!cleaned.email_valid; cleaned.consent = cleaned.consent === true || cleaned.consent === 'yes' ? 'yes' : (cleaned.consent === false ? 'no' : (cleaned.consent || '')); cleaned.tags = Array.isArray(cleaned.tags) ? cleaned.tags.join(',') : (cleaned.tags || ''); // Items JSON (commerce) if (!cleaned.order_items_json && cleaned.order_items) cleaned.order_items_json = JSON.stringify(cleaned.order_items); // Currency default if (cleaned.order_total_cents && !cleaned.order_currency) cleaned.order_currency = 'usd'; // Vertical hints (leave as-is if provided) // (No strict validation to remain universal.) // Notes safe if (typeof cleaned.notes !== 'string') cleaned.notes = JSON.stringify(cleaned.notes || ''); // Capture extra/unmapped fields cleaned.extra_fields_json = JSON.stringify(pickExtras(cleaned)); // Raw payload cleaned.raw_json = JSON.stringify(rec || {}); // Timestamp cleaned.ts = cleaned.ts || isoNow(); return cleaned;}function recordToRow(rec, map) { const row = new Array(HEADERS.length).fill(''); HEADERS.forEach((h, i) => { if (h in rec) row[i] = rec[h]; }); // Common fallbacks row[map['source']-1] = row[map['source']-1] || rec.source || ''; row[map['agent']-1] = row[map['agent']-1] || rec.agent || ''; row[map['name']-1] = row[map['name']-1] || rec.name || ''; row[map['phone']-1] = row[map['phone']-1] || rec.phone || ''; row[map['email']-1] = row[map['email']-1] || rec.email || ''; row[map['industry']-1] = row[map['industry']-1] || rec.industry || ''; row[map['service_type']-1] = row[map['service_type']-1] || rec.service_type || ''; return row;}/****************************** * SHEET UTIL ******************************/function ensureSheet(ss, name, headerRow) { const sh = ss.getSheetByName(name) || ss.insertSheet(name); if (sh.getLastRow() === 0) sh.appendRow(headerRow); else ensureHeaders(sh, headerRow); return sh;}function ensureHeaders(sh, headerRow) { const existing = sh.getRange(1,1,1,sh.getLastColumn()).getValues()[0]; const missing = headerRow.filter(h => existing.indexOf(h) === -1); if (missing.length) { sh.insertColumnsAfter(existing.length, missing.length); sh.getRange(1, existing.length+1, 1, missing.length).setValues([missing]); }}function headerMap(sh) { const headers = sh.getRange(1,1,1,sh.getLastColumn()).getValues()[0]; const map = {}; headers.forEach((h, i) => map[h] = i+1); return map;}/****************************** * EVENTS LOG ******************************/function logEvent(ev, event, key, details) { ev.appendRow([isoNow(), event, JSON.stringify(key || {}), JSON.stringify(details || {})]);}/****************************** * HELPERS ******************************/function isoNow() { return Utilities.formatDate(new Date(), CONFIG.TIMEZONE, "yyyy-MM-dd'T'HH:mm:ssXXX");}function strip(s) { return String(s || '').replace(/\D/g, ''); }function jsonOut(obj, code) { const out = ContentService.createTextOutput(JSON.stringify(obj)); out.setMimeType(ContentService.MimeType.JSON); return out;}function pickKey(rec) { return { callId: rec.callId || '', externalId: rec.externalId || '', orderId: rec.orderId || '', phone: rec.phone || '', email: rec.email || '', crm_lead_id: rec.crm_lead_id || '' };}function pickDetails(rec, fields) { const o = {}; fields.forEach(f => { if (rec[f] !== undefined) o[f] = rec[f]; }); return o;}function pickExtras(rec) { const known = new Set(HEADERS); const extras = {}; Object.keys(rec).forEach(k => { if (!known.has(k)) extras[k] = rec[k]; }); return extras;}