vrfefavr's picture
CSV-style webhook timestamps (IST), roll validation, speaker API autocomplete, venue date min today, remove diagnostics stack
9e83b37
/**
* ╔══════════════════════════════════════════════════════════════════╗
* β•‘ Alumni Feedback System β€” Advanced Google Apps Script v3.5 β•‘
* β•‘ PRO-GRADE | 100% CSV SYNC | Error-Resilient | Self-Auth β•‘
* β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
*
* SCRIPT PROPERTIES (Project Settings β†’ Script properties) β€” must match Hugging Face / backend:
* SECRET_KEY β€” same as server env APPS_SCRIPT_SECRET (default datalens2026 if unset)
* WEBHOOK_SECRET β€” same as server env WEBHOOK_SECRET (default webhook-secret-key if unset)
* WEBHOOK_URL β€” optional; used only by hourly heartbeat (not per-form webhooks)
*
* SETUP: Deploy β†’ New deployment β†’ Web app β†’ Execute as: Me β†’ Who has access: Anyone (or your choice).
* Run RUN_ME_TO_AUTHORIZE once after changes so Triggers + UrlFetch to your Space are allowed.
* Windows "Run as administrator" does not affect this script; authorization is your Google account.
*/
const CONFIG = {
SECRET_KEY: PropertiesService.getScriptProperties().getProperty("SECRET_KEY") || "datalens2026",
WEBHOOK_SECRET: PropertiesService.getScriptProperties().getProperty("WEBHOOK_SECRET") || "webhook-secret-key",
VERSION: "v3.5-Final-Sync",
DEPARTMENT_OPTIONS: [
"School of Education & Humanities",
"School of Engineering",
"School of Engineering: Computer Science & Technology",
"School of Engineering: Electronics and Communication",
"School of Engineering: Mechanical Engineering",
"School of Law",
"School of Management & Commerce"
]
};
/**
* MANUAL PERMISSION REPAIR:
* If you get "Server Error", run this function once manually in the editor!
*/
function RUN_ME_TO_AUTHORIZE() {
Logger.log("Permission Check: Verifying Forms, Properties, and Triggers...");
try {
const props = PropertiesService.getScriptProperties();
props.setProperty("AUTH_CHECK", new Date().toISOString());
ScriptApp.newTrigger('onHeartbeatTrigger').timeBased().everyHours(1).create();
Logger.log("βœ… AUTHORIZATION SUCCESSFUL. Heartbeat established.");
} catch (e) {
Logger.log("⚠️ INFO: " + e.message);
Logger.log("If asked for permissions, click REVIEW PERMISSIONS -> Select Account -> Advanced -> Allow.");
}
}
/**
* TRIGGER CLEANUP:
* Run this if you get the "Too many triggers" error!
* It will delete all existing form triggers to make room for new ones.
*/
function CLEANUP_ALL_TRIGGERS() {
Logger.log("Deleting all triggers to fix 'Too many triggers' error...");
const triggers = ScriptApp.getProjectTriggers();
triggers.forEach(t => ScriptApp.deleteTrigger(t));
Logger.log("βœ… All triggers deleted. You can now generate a new form.");
}
// ─── ENTRY POINTS ────────────────────────────────────────────────────────────
function doPost(e) {
try {
if (!e || !e.postData || !e.postData.contents) {
return _json(false, "Invalid Request: No payload received.", null, 400);
}
const payload = JSON.parse(e.postData.contents);
if (!payload || payload.secret !== CONFIG.SECRET_KEY) {
return _json(false, "Unauthorized Access: Secret key mismatch.", null, 401);
}
const action = (payload.action || "").toLowerCase();
switch (action) {
case "create_form": return _handleCreateForm(payload);
case "get_responses": return _handleGetResponses(payload);
case "close_form": return _handleCloseForm(payload);
case "diagnose": return _handleDiagnose(payload);
case "ping": return _json(true, "Connectivity Active", { version: CONFIG.VERSION });
default: return _json(false, "Unknown Action: " + action, null, 404);
}
} catch (err) {
// Return the actual error in the 'data' field so backend can log it
return _json(false, "Internal Execution Error", err.toString(), 500);
}
}
// ─── ACTION HANDLERS ─────────────────────────────────────────────────────────
/**
* Returns a full diagnostic report of the script's state
*/
function _handleDiagnose(payload) {
const props = PropertiesService.getScriptProperties().getProperties();
const triggers = ScriptApp.getProjectTriggers();
const diagnosticData = {
version: CONFIG.VERSION,
properties_count: Object.keys(props).length,
trigger_count: triggers.length,
timezone: Session.getScriptTimeZone(),
user_email: Session.getEffectiveUser().getEmail(),
webhook_url_configured: !!props["WEBHOOK_URL"],
auth_check: props["AUTH_CHECK"] || "Never verified"
};
return _json(true, "Diagnostic Data Retrieved", diagnosticData);
}
function _handleCreateForm(payload) {
// ⚑ MANUAL RUN DETECTION
if (!payload || typeof payload !== 'object') {
Logger.log("⚠️ NOTICE: You clicked 'Run' in the editor. This only works from the Dashboard.");
return _json(false, "Manual execution ignored.", null, 400);
}
const speaker = payload.speaker_name;
const date = payload.venue_date;
const eventId = payload.event_id;
const webhookUrl = payload.webhook_url;
if (!speaker || !date) return _json(false, "Data Error: Speaker and date are required.", null, 400);
// Exact matches for CSV synchronization
const form = FormApp.create(`Student Feedback: ${speaker}`);
form.setDescription(`Session: ${date} | Speaker: ${speaker}\nJoin us in providing feedback for continuous improvement.`);
form.setCollectEmail(false);
form.setAllowResponseEdits(false);
// Step 1: Student Identity (Matches CSV Headers)
form.addSectionHeaderItem().setTitle("Step 1: Your Information");
form.addTextItem().setTitle("Name of Student").setRequired(true);
form.addMultipleChoiceItem().setTitle("Department").setChoiceValues(CONFIG.DEPARTMENT_OPTIONS).setRequired(true);
const rollItem = form.addTextItem().setTitle("Roll No.").setRequired(true);
const rollPattern = "^2[Kk]\\d{2}[A-Za-z]{3,12}\\d{5}$";
rollItem.setHelpText("Format: 2K + 2-digit batch year + programme code + 5 digits (e.g. 2K25EDUN01013, 2K24ECUN03021). No spaces.");
rollItem.setValidation(
FormApp.createTextValidation()
.requireTextMatchesPattern(rollPattern)
.setHelpText("Use your official roll number format, e.g. 2K25EDUN01013.")
.build()
);
// Step 2: Session Value (Matches CSV Headers)
form.addPageBreakItem().setTitle("Step 2: Session Value");
form.addMultipleChoiceItem()
.setTitle("Did the session help you gain a better understanding of industry trends or career paths?")
.setChoiceValues(["Yes, significantly", "To some extent", "Not really"])
.setRequired(true);
form.addScaleItem()
.setTitle("How would you rate the session overall? \n(1 – Poor | 2 – Fair | 3 – Good | 4 – Very Good | 5 – Excellent)")
.setBounds(1, 5)
.setLabels("1 ⭐", "5 ⭐")
.setRequired(true);
// Step 3: Detailed Insights (Matches CSV Headers)
form.addPageBreakItem().setTitle("Step 3: Insights & Suggestions");
form.addParagraphTextItem().setTitle("What aspect of the session did you find most valuable?").setRequired(true);
form.addParagraphTextItem().setTitle("What improvements or suggestions would you recommend for future alumni sessions?").setRequired(false);
form.addParagraphTextItem().setTitle("Any specific topics or areas you’d like future alumni speakers to cover?").setRequired(false);
const formId = form.getId();
PropertiesService.getScriptProperties().setProperty(`config_${formId}`, JSON.stringify({
webhook_url: webhookUrl,
event_id: eventId,
speaker_name: speaker,
venue_date: date
}));
// ⚑ AUTOMATIC TRIGGER PRUNING (Personal accounts are limited to 20)
const allTriggers = ScriptApp.getProjectTriggers();
if (allTriggers.length > 15) {
Logger.log("⚠️ Pruning old triggers to make room (Auto-Cleanup)...");
for (let i = 0; i < 5; i++) {
if (allTriggers[i]) try { ScriptApp.deleteTrigger(allTriggers[i]); } catch(f) {}
}
}
// 1: Trigger for INSTANT webhooks on submission
ScriptApp.newTrigger('onFormSubmitTrigger').forForm(form).onFormSubmit().create();
// 2: Trigger for STRICT 24-hour closure
const timeTrigger = ScriptApp.newTrigger('onAutoCloseFormTrigger').timeBased().after(24 * 60 * 60 * 1000).create();
// Save mapping so the time trigger knows WHICH form to close
PropertiesService.getScriptProperties().setProperty(`close_${timeTrigger.getUniqueId()}`, formId);
console.log(`[SUCCESS] Form created for ${speaker} (ID: ${formId})`);
return _json(true, "Form Generated Successfully", {
form_id: formId,
form_url: form.getPublishedUrl()
}, 201);
}
// ─── TRIGGER LOGIC ───────────────────────────────────────────────────────────
function onFormSubmitTrigger(e) {
if (!e || !e.source) return;
let formId = "";
try {
formId = e.source.getId();
const config = JSON.parse(PropertiesService.getScriptProperties().getProperty(`config_${formId}`) || "{}");
if (!config.webhook_url) return;
const answers = {};
e.response.getItemResponses().forEach(ir => {
const q = ir.getItem().getTitle();
const a = ir.getResponse();
// Precision Header Mapping (Matches CSV perfectly)
if (q.includes("Name of Student")) answers.name_of_student = a;
else if (q.includes("Department")) answers.department_original = a;
else if (q.includes("Roll No.")) answers.roll_no_original = a;
else if (q.includes("industry trends")) answers.session_help_understanding = a;
else if (q.includes("rate the session")) answers.session_rating = a;
else if (q.includes("most valuable")) answers.aspect_most_valuable = a;
else if (q.includes("improvements")) answers.improvements_suggestions = a;
else if (q.includes("future alumni speakers to cover")) answers.future_topics = a;
});
const webhookPayload = JSON.stringify({
form_id: formId,
event_id: config.event_id,
timestamp: new Date().toISOString(),
responses: {
...answers,
alumni_speaker_name: config.speaker_name,
date_of_lecture: config.venue_date
}
});
const resp = UrlFetchApp.fetch(config.webhook_url, {
method: "post",
contentType: "application/json",
headers: { "Authorization": "Bearer " + CONFIG.WEBHOOK_SECRET },
payload: webhookPayload,
muteHttpExceptions: true
});
const code = resp.getResponseCode();
const text = resp.getContentText() || "";
const preview = text.length > 500 ? text.substring(0, 500) + "…" : text;
PropertiesService.getScriptProperties().setProperty(
"LAST_WEBHOOK_SYNC",
JSON.stringify({
at: new Date().toISOString(),
http_status: code,
body_preview: preview,
form_id: formId,
webhook_host: (function () {
try {
return config.webhook_url.split("/")[2] || config.webhook_url;
} catch (e) {
return "";
}
})()
})
);
if (code < 200 || code >= 300) {
console.error("Webhook HTTP " + code + ": " + preview);
} else {
console.log("Webhook OK HTTP " + code + " for form " + formId);
}
} catch (err) {
console.error("Advanced Webhook Failure:", err.toString());
try {
PropertiesService.getScriptProperties().setProperty(
"LAST_WEBHOOK_SYNC",
JSON.stringify({
at: new Date().toISOString(),
http_status: 0,
error: err.toString(),
form_id: formId || "unknown"
})
);
} catch (ignore) {}
}
}
function onAutoCloseFormTrigger(e) {
// This runs exactly 24 hours after creation
if (!e || !e.triggerUid) return;
const triggerId = e.triggerUid;
const formId = PropertiesService.getScriptProperties().getProperty(`close_${triggerId}`);
if (formId) {
try {
const form = FormApp.openById(formId);
form.setAcceptingResponses(false);
form.setCustomClosedFormMessage("Sorry this form is closed, reach your mentor");
PropertiesService.getScriptProperties().deleteProperty(`close_${triggerId}`);
} catch(err) {
console.error("Failed to auto-close form:", err);
}
}
// Cleanup the used trigger
const triggers = ScriptApp.getProjectTriggers();
for (let i = 0; i < triggers.length; i++) {
if (triggers[i].getUniqueId() === triggerId) {
ScriptApp.deleteTrigger(triggers[i]);
break;
}
}
}
// ─── DATA RETRIEVAL & CLOSURE ────────────────────────────────────────────────
function _handleGetResponses(payload) {
// ⚑ MANUAL RUN DETECTION
if (!payload || typeof payload !== 'object' || !payload.form_id) {
Logger.log("⚠️ NOTICE: You clicked 'Run' in the editor. This only works from the Dashboard.");
return _json(false, "Manual execution ignored.", null, 400);
}
const formId = payload.form_id;
try {
const form = FormApp.openById(formId);
if (!form) throw new Error("Could not access Form.");
const results = form.getResponses().map(resp => {
const answers = { timestamp: resp.getTimestamp().toISOString() };
resp.getItemResponses().forEach(ir => {
const q = ir.getItem().getTitle();
const a = ir.getResponse();
if (q.indexOf("Name of Student") > -1) answers.name_of_student = a;
else if (q.indexOf("Department") > -1) answers.department_original = a;
else if (q.indexOf("Roll No.") > -1) answers.roll_no_original = a;
else if (q.indexOf("industry trends") > -1) answers.session_help_understanding = a;
else if (q.indexOf("rate the session") > -1) answers.session_rating = a;
else if (q.indexOf("most valuable") > -1) answers.aspect_most_valuable = a;
else if (q.indexOf("improvements") > -1) answers.improvements_suggestions = a;
else if (q.indexOf("future alumni speakers to cover") > -1) answers.future_topics = a;
});
return answers;
});
return _json(true, "Responses Extracted", results);
} catch (e) {
return _json(false, "Extraction Failure", e.toString(), 404);
}
}
function _handleCloseForm(payload) {
if (!payload || typeof payload !== 'object' || !payload.form_id) {
return _json(false, "Missing form_id in payload.", null, 400);
}
const formId = payload.form_id;
try {
const form = FormApp.openById(formId);
if (!form) throw new Error("Could not access Form.");
// STRICT CLOSURE
form.setAcceptingResponses(false);
form.setCustomClosedFormMessage("Sorry this form is closed, reach your mentor");
return _json(true, "Form Strictly Closed on Google Servers", { form_id: formId });
} catch (e) {
return _json(false, "Closure Failure", e.toString(), 500);
}
}
// ─── JSON HELPER ─────────────────────────────────────────────────────────────
function _json(success, message, data, code = 200) {
const output = { success, message, data, v: CONFIG.VERSION, timestamp: new Date().toISOString() };
return ContentService.createTextOutput(JSON.stringify(output)).setMimeType(ContentService.MimeType.JSON);
}
/**
* Sends a periodic heartbeat to the backend to verify connectivity
*/
function onHeartbeatTrigger() {
const props = PropertiesService.getScriptProperties();
const webhookUrl = props.getProperty("WEBHOOK_URL");
const secret = props.getProperty("WEBHOOK_SECRET") || "webhook-secret-key";
if (!webhookUrl) {
Logger.log("Heartbeat skipped: WEBHOOK_URL not set.");
return;
}
try {
const payload = {
action: "heartbeat",
timestamp: new Date().toISOString(),
form_id: "HEARTBEAT"
};
UrlFetchApp.fetch(webhookUrl, {
method: "post",
contentType: "application/json",
headers: { "Authorization": "Bearer " + secret },
payload: JSON.stringify(payload),
muteHttpExceptions: true
});
Logger.log("Heartbeat sent to: " + webhookUrl);
} catch (e) {
Logger.log("Heartbeat failed: " + e.toString());
}
}
/**
* ONE-TIME SETUP (Project Settings script properties are read-only when you have 50+ properties):
* 1. Set the SAME WEBHOOK_SECRET in Hugging Face Space β†’ Secrets.
* 2. In the Apps Script editor, select this function β†’ Run β†’ allow permissions.
* 3. Check Executions / Logs for "Saved WEBHOOK_SECRET and SECRET_KEY".
* 4. Submit a test form; LAST_WEBHOOK_SYNC should show http_status 200, not 401.
* 5. Remove the literal strings below (or delete this whole function) after success β€” do not leave secrets in source long-term.
*/
function ONE_TIME_SET_SECRETS() {
const p = PropertiesService.getScriptProperties();
p.setProperty("WEBHOOK_SECRET", "DL_wh_9fK2mPq7vNx4Rt8sLw3");
p.setProperty("SECRET_KEY", "datalens2026");
Logger.log("Saved WEBHOOK_SECRET and SECRET_KEY (must match HF WEBHOOK_SECRET and APPS_SCRIPT_SECRET).");
}