/** * TinyFlowLab — Lead Capture Mini v1.1 (changelog: CHANGELOG.md) * Google Form → check required fields → "Leads" sheet → a manual next step with an owner and due date. * * Paste this whole file into Extensions → Apps Script of the Google Sheet that collects your form responses. * Full guide: GUIDE.md (https://tinyflowlab.com/free) * * What it does on every form submission: * 1. Reads the answer row. * 2. Checks required fields (name, a valid email, a message). * 3. Skips it if this exact submission was already processed (same Submission ID). * 4. Adds one row to the "Leads" tab: status "New" (ready to reply) or "Needs review" (with the reason). * 5. Writes a next step, owner and due date. It never sends an email or a message. * * No API keys, no external services, no data leaves your Google account. * License: free for personal and commercial use in your own business; please don't resell the template. * * @OnlyCurrentDoc — limits the script's access to this one spreadsheet. */ // ---------------------------------------------------------------------------------------------------- // Settings — change these to match your form. Question titles must match your form exactly. // ---------------------------------------------------------------------------------------------------- var CONFIG = { responsesSheet: 'Form Responses 1', leadsSheet: 'Leads', fields: { timestamp: 'Timestamp', name: 'Your name', email: 'Email address', company: 'Company or website', need: 'What do you need help with?', }, required: ['name', 'email', 'need'], minNeedLength: 10, owner: 'Me', // who handles new leads — a name or an email replyWithinBusinessDays: 1, // Flag a lead for review when the same email has already sent a request (it is still added). flagRepeatEmails: true, }; var VERSION = '1.1.0'; var LEADS_HEADER = [ 'Submission ID', 'Received', 'Name', 'Email', 'Company', 'Need', 'Status', 'Issues', 'Next step', 'Owner', 'Due', 'Done', ]; // ---------------------------------------------------------------------------------------------------- // Pure logic (no Google services) — tested in test/code.test.mjs // ---------------------------------------------------------------------------------------------------- /** Trim, collapse inner whitespace. */ function clean_(v) { if (v === null || v === undefined) return ''; return String(v).replace(/\s+/g, ' ').trim(); } /** Practical email check: something@something.tld, no spaces. Not a deliverability check. */ function isValidEmail_(email) { return /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email); } /** * Stable ID for one submission, hashed (FNV-1a, 32-bit, hex). * With a timestamp: timestamp + email + name. Without one (pasted/imported rows): email + name + message, * so the same row still gets the same ID on every run and is never added twice. */ function submissionId_(timestampIso, email, name, need) { var parts = timestampIso ? [timestampIso, email.toLowerCase(), name.toLowerCase()] : ['no-ts', email.toLowerCase(), name.toLowerCase(), (need || '').toLowerCase()]; var s = parts.join('|'); var h = 0x811c9dc5; for (var i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; } return 'L-' + ('0000000' + h.toString(16)).slice(-8); } /** * Makes a value safe to write into a cell: text starting with = + - @ (or a tab/CR) would be run as a formula * by Google Sheets ("formula injection"), so it is prefixed with an apostrophe and shown as plain text. */ function safeCell_(v) { if (typeof v !== 'string') return v; return /^[=+\-@\t\r]/.test(v) ? "'" + v : v; } /** Adds business days (Mon–Fri) to a date. */ function addBusinessDays_(date, days) { var d = new Date(date.getTime()); var added = 0; while (added < days) { d.setDate(d.getDate() + 1); var wd = d.getDay(); if (wd !== 0 && wd !== 6) added++; } return d; } /** * Turns one answer record into a lead row. * @param {Object} answers map of question title → answer * @param {Object} ctx { knownIds: Set-like {has}, knownEmails: Set-like {has}, now: Date } * @return {{skip: boolean, reason?: string, lead?: Object}} */ function buildLead_(answers, ctx) { var f = CONFIG.fields; var rawTs = answers[f.timestamp]; var ts = rawTs instanceof Date ? rawTs : (rawTs ? new Date(rawTs) : null); if (ts && isNaN(ts.getTime())) ts = null; var received = ts || ctx.now; var lead = { name: clean_(answers[f.name]), email: clean_(answers[f.email]).toLowerCase(), company: clean_(answers[f.company]), need: clean_(answers[f.need]), received: received, }; lead.id = submissionId_(ts ? ts.toISOString() : '', lead.email, lead.name, lead.need); if (ctx.knownIds.has(lead.id)) return { skip: true, reason: 'already processed' }; var issues = []; CONFIG.required.forEach(function (key) { if (!lead[key]) issues.push('Missing ' + key); }); if (lead.email && !isValidEmail_(lead.email)) issues.push('Email looks invalid'); if (lead.need && lead.need.length < CONFIG.minNeedLength) issues.push('Message is very short'); if (CONFIG.flagRepeatEmails && lead.email && ctx.knownEmails.has(lead.email)) { issues.push('Email already sent a request'); } var ok = issues.length === 0; lead.status = ok ? 'New' : 'Needs review'; lead.issues = issues.join('; '); lead.nextStep = ok ? 'Reply to ' + lead.name + ' about: ' + lead.need.slice(0, 80) : 'Check this request and decide whether to reply'; lead.owner = CONFIG.owner; lead.due = addBusinessDays_(ctx.now, CONFIG.replyWithinBusinessDays); return { skip: false, lead: lead }; } function leadToRow_(lead) { return [ lead.id, lead.received, lead.name, lead.email, lead.company, lead.need, lead.status, lead.issues, lead.nextStep, lead.owner, lead.due, false, ].map(safeCell_); } /** Map a header row + value row into {title: value}. */ function rowToAnswers_(header, row) { var out = {}; for (var i = 0; i < header.length; i++) out[clean_(header[i])] = row[i]; return out; } // ---------------------------------------------------------------------------------------------------- // Google Sheets glue // ---------------------------------------------------------------------------------------------------- /** Adds the "Lead Capture" menu when the sheet opens. */ function onOpen() { SpreadsheetApp.getUi() .createMenu('Lead Capture') .addItem('1. Set up (create Leads tab + trigger)', 'setup') .addItem('Process all responses now', 'processAllResponses') .addItem('Check setup', 'checkSetup') .addItem('About (version ' + VERSION + ')', 'about') .addToUi(); } /** One-time setup: creates the Leads tab and the on-form-submit trigger. Safe to run twice. */ function setup() { var ss = SpreadsheetApp.getActiveSpreadsheet(); getLeadsSheet_(ss); var exists = ScriptApp.getProjectTriggers().some(function (t) { return t.getHandlerFunction() === 'onFormSubmit'; }); if (!exists) ScriptApp.newTrigger('onFormSubmit').forSpreadsheet(ss).onFormSubmit().create(); notify_('Setup done. New form responses will appear in the "' + CONFIG.leadsSheet + '" tab.'); } /** Installed trigger: runs on every new form response. */ function onFormSubmit(e) { var lock = LockService.getDocumentLock(); lock.waitLock(30000); try { var ss = SpreadsheetApp.getActiveSpreadsheet(); var sheet = e && e.range ? e.range.getSheet() : ss.getSheetByName(CONFIG.responsesSheet); var header = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0]; var row = e && e.range ? e.range.getValues()[0] : sheet.getRange(sheet.getLastRow(), 1, 1, header.length).getValues()[0]; processRows_(ss, header, [row]); } finally { lock.releaseLock(); } } /** Menu action: (re)process every response row. Already-processed rows are skipped. */ function processAllResponses() { var lock = LockService.getDocumentLock(); lock.waitLock(30000); try { var ss = SpreadsheetApp.getActiveSpreadsheet(); var sheet = ss.getSheetByName(CONFIG.responsesSheet); if (!sheet) throw new Error('No tab named "' + CONFIG.responsesSheet + '". Check CONFIG.responsesSheet.'); var values = sheet.getDataRange().getValues(); if (values.length < 2) return notify_('No responses yet.'); var res = processRows_(ss, values[0], values.slice(1)); notify_('Added ' + res.added + ' lead(s), skipped ' + res.skipped + ' already processed.'); } finally { lock.releaseLock(); } } function about() { notify_('Lead Capture Mini ' + VERSION + ' by TinyFlowLab — guide: https://tinyflowlab.com/free/guide'); } /** Checks that question titles in the responses tab match CONFIG.fields. */ function checkSetup() { var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(CONFIG.responsesSheet); if (!sheet) return notify_('Missing tab "' + CONFIG.responsesSheet + '". Link your form to this sheet first.'); var header = sheet.getRange(1, 1, 1, Math.max(1, sheet.getLastColumn())).getValues()[0].map(clean_); var missing = []; for (var k in CONFIG.fields) if (header.indexOf(CONFIG.fields[k]) === -1) missing.push(CONFIG.fields[k]); var hasTrigger = ScriptApp.getProjectTriggers().some(function (t) { return t.getHandlerFunction() === 'onFormSubmit'; }); notify_( (missing.length ? 'Columns not found: ' + missing.join(', ') + '. Rename them in CONFIG.fields. ' : 'All columns found. ') + (hasTrigger ? 'Trigger is installed.' : 'Trigger is NOT installed — run "Set up".') ); } function processRows_(ss, header, rows) { var leads = getLeadsSheet_(ss); var existing = leads.getLastRow() > 1 ? leads.getRange(2, 1, leads.getLastRow() - 1, 4).getValues() : []; var ids = {}, emails = {}; existing.forEach(function (r) { ids[r[0]] = true; if (r[3]) emails[String(r[3]).toLowerCase()] = true; }); var ctx = { knownIds: { has: function (k) { return !!ids[k]; } }, knownEmails: { has: function (k) { return !!emails[k]; } }, now: new Date(), }; var out = [], skipped = 0; rows.forEach(function (row) { if (row.every(function (c) { return c === '' || c === null; })) return; // A pasted/imported copy of the header row is not a lead. if (row.every(function (c, i) { return clean_(c) === clean_(header[i]); })) return; var res = buildLead_(rowToAnswers_(header, row), ctx); if (res.skip) { skipped++; return; } ids[res.lead.id] = true; if (res.lead.email) emails[res.lead.email] = true; out.push(leadToRow_(res.lead)); }); if (out.length) { var start = leads.getLastRow() + 1; leads.getRange(start, 1, out.length, LEADS_HEADER.length).setValues(out); leads.getRange(start, 12, out.length, 1).insertCheckboxes(); } return { added: out.length, skipped: skipped }; } function getLeadsSheet_(ss) { var sheet = ss.getSheetByName(CONFIG.leadsSheet); if (!sheet) { sheet = ss.insertSheet(CONFIG.leadsSheet); sheet.getRange(1, 1, 1, LEADS_HEADER.length).setValues([LEADS_HEADER]).setFontWeight('bold'); sheet.setFrozenRows(1); sheet.getRange('B:B').setNumberFormat('yyyy-mm-dd hh:mm'); sheet.getRange('K:K').setNumberFormat('yyyy-mm-dd'); var rules = [ SpreadsheetApp.newConditionalFormatRule().whenTextEqualTo('Needs review') .setBackground('#FFF1D6').setRanges([sheet.getRange('G2:G')]).build(), SpreadsheetApp.newConditionalFormatRule().whenTextEqualTo('New') .setBackground('#E3EAFE').setRanges([sheet.getRange('G2:G')]).build(), ]; sheet.setConditionalFormatRules(rules); } return sheet; } function notify_(msg) { try { SpreadsheetApp.getActiveSpreadsheet().toast(msg, 'Lead Capture', 8); } catch (err) { /* no UI in trigger context */ } console.log(msg); } // Node test hook (ignored by Apps Script). if (typeof module !== 'undefined') { module.exports = { CONFIG: CONFIG, clean_: clean_, isValidEmail_: isValidEmail_, submissionId_: submissionId_, addBusinessDays_: addBusinessDays_, buildLead_: buildLead_, rowToAnswers_: rowToAnswers_, leadToRow_: leadToRow_, processRows_: processRows_, LEADS_HEADER: LEADS_HEADER, safeCell_: safeCell_, VERSION: VERSION }; }