Linked Forms feel convenient until one typo in “Email” breaks every nightly job that trusts the master tab. The fix isn’t a sterner form description — it’s a staging sheet: onFormSubmit lands there, validators run, and only clean rows graduate into Master.
TL;DR
- Form responses → Staging (not Master)
- Validate required fields, formats (regex), and allow-lists
- Approve → append to Master; Reject → flag + optional email
- Jobs and reports read Master only
- Soft assist: Co-Pilot can draft validators from plain-English rules
Using the Apps Script editor a lot? NitroGAS drops free themes & snippets right into script.google.com — optional Co-Pilot when you want a boost.
Why “Form → Master” bites
Google Forms already has required questions. That’s not enough when:
- Downstream scripts assume a real email / SKU / site code
- Operators paste free text into “Other”
- One bad row makes
UrlFetchor joins fail for everyone
Your master sheet is an API contract. Treat inbound Form rows like untrusted POST bodies.
What not to do
- Don’t validate after Master is already polluted (cleanup theater)
- Don’t delete rejects silently — keep them on Staging with a reason
- Don’t put business rules only in the Form UI; scripts should enforce the same rules
- Don’t run overnight jobs against the raw Form response sheet
Pattern: Staging → validate → Master
Form submit
→ onFormSubmit appends to Staging (status = PENDING)
→ validateRow_()
OK → copy to Master, status = APPROVED
BAD → status = REJECTED, reason = …
Sheet layout
Staging headers (example):
timestamp | email | siteCode | qty | status | reason | rawJson
Master — same business columns, no status/reason noise (or keep a thin audit column if you prefer).
Helpers
var STAGING = 'Staging';
var MASTER = 'Master';
function onFormSubmit(e) {
var ss = SpreadsheetApp.getActive();
var staging = ss.getSheetByName(STAGING);
var named = (e && e.namedValues) || {};
var row = {
timestamp: new Date(),
email: first_(named['Email']),
siteCode: first_(named['Site code']),
qty: first_(named['Quantity']),
status: 'PENDING',
reason: '',
rawJson: JSON.stringify(named)
};
var result = validateRow_(row);
row.status = result.ok ? 'APPROVED' : 'REJECTED';
row.reason = result.ok ? '' : result.reason;
staging.appendRow([
row.timestamp, row.email, row.siteCode, row.qty,
row.status, row.reason, row.rawJson
]);
if (result.ok) {
approveIntoMaster_(ss, row);
} else {
console.warn('rejected form row:', row.reason, row.email);
// optional: emailErrorAlert('Form reject: ' + row.reason);
}
}
function first_(arr) {
return arr && arr.length ? String(arr[0]).trim() : '';
}
function validateRow_(row) {
if (!row.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(row.email)) {
return { ok: false, reason: 'invalid email' };
}
var allowedSites = { NYC: 1, LAX: 1, ORD: 1 };
if (!allowedSites[row.siteCode]) {
return { ok: false, reason: 'siteCode not in allow-list' };
}
var qty = Number(row.qty);
if (!isFinite(qty) || qty < 1 || qty > 999) {
return { ok: false, reason: 'qty out of range' };
}
return { ok: true, reason: '' };
}
function approveIntoMaster_(ss, row) {
var master = ss.getSheetByName(MASTER);
master.appendRow([row.timestamp, row.email, row.siteCode, Number(row.qty)]);
}
Install the trigger once (form submit → onFormSubmit). Keep validators pure: input object → { ok, reason } so you can unit-test them with a dummy row.
Manual re-approve
Sometimes Staging is right and the rule was wrong. Add a menu action that re-runs validateRow_ on the active Staging row and, if OK, appends to Master and flips status. Don’t hand-edit Master to “fix” a reject — you’ll lose the audit trail.
function revalidateActiveStagingRow() {
var sheet = SpreadsheetApp.getActiveSheet();
if (sheet.getName() !== STAGING) {
SpreadsheetApp.getUi().alert('Switch to Staging first');
return;
}
var r = sheet.getActiveRange().getRow();
if (r < 2) return;
var values = sheet.getRange(r, 1, 1, 7).getValues()[0];
var row = {
timestamp: values[0],
email: values[1],
siteCode: values[2],
qty: values[3]
};
var result = validateRow_(row);
sheet.getRange(r, 5, 1, 2).setValues([[
result.ok ? 'APPROVED' : 'REJECTED',
result.ok ? '' : result.reason
]]);
if (result.ok) approveIntoMaster_(SpreadsheetApp.getActive(), row);
}
Failure modes worth naming
- Trigger not installed → rows only land in the Form’s default response sheet; Master stays empty
- Header drift between Form titles and
namedValueskeys → empty fields, silent rejects - Allow-list in code only → update Script Properties / a Config tab when sites change
- Jobs reading Staging → point every consumer at Master; Staging is quarantine
Soft Co-Pilot note
NitroGAS ships the free editor tools you already use; Co-Pilot can draft validateRow_ rules from a plain-English list (“email required, site in NYC/LAX/ORD, qty 1–999”) — you still own Staging vs Master and the trigger. Pay-when-you-need-it; core stays free.
Closing checklist
- Staging tab with status + reason
-
onFormSubmitnever writes Master first - Validators cover required / regex / allow-list
- Nightly jobs read Master only
- Reject path is visible (sheet or alert)
Happy Coding!


