/* * File: kikoworks/08_case_studies/case_j_agapay_volunteers/intake.gs * Project: KikoWorks (Case J, practice project) * Purpose: Google Apps Script that processes each volunteer sign-up from the form. * Author: John Francis Sagadraca * Created: 2026-10-03 (first commit) * Notes: Tested with stand-ins, not yet run in a real account. Setup: automation_setup.md. */ /** * Agapay Volunteers: sign-up intake automation (Google Apps Script) * KikoWorks · Case Study J * * WHAT IT DOES * Each time someone submits the volunteer Google Form, this script: * 1. checks and tidies the answers (name, email, PH mobile, shift) * 2. skips duplicates (same email, same shift) * 3. saves a clean row to the "Signups" sheet with an ID and timestamp * 4. confirms the spot, or waitlists the person if the shift is full * 5. emails the volunteer their confirmation or waitlist notice * 6. alerts the coordinator only when needed: shift full, waitlist, * or a first-aider signed up * 7. writes every step to the "Log" sheet, including errors * A daily summary (optional trigger) emails the coordinator the counts. * * FILES IN THE APPS SCRIPT PROJECT * intake.gs this file (Google Sheets, Gmail, triggers) * intake-core.gs paste the contents of intake-core.js here (the rules) * * ONE-TIME SETUP: see automation_setup.md. In short: * 1. Make the Google Form; link it to a new Sheet. * 2. In the Sheet: Extensions → Apps Script. Add both files. Save. * 3. Edit CONFIG below (coordinator email, form question titles). * 4. Run `setup` once and approve the permission prompt. * 5. Keep DRY_RUN = true, submit a test answer, check the Log sheet. * 6. Set DRY_RUN = false when the emails look right. * * SAFETY * - DRY_RUN writes rows and logs but sends no email. * - A script lock stops two submissions from taking the same ID or spot. * - The daily email quota is checked before sending; if it runs out, the * row is still saved and the Log says which emails were not sent. * - Any error is caught and logged, and the coordinator is told once. */ var CONFIG = { DRY_RUN: true, // true = no emails sent; change after testing COORDINATOR_EMAIL: 'coordinator@example.org', TIMEZONE: 'Asia/Manila', SIGNUPS_SHEET: 'Signups', LOG_SHEET: 'Log', // Google Form question titles → field names used by intake-core. FORM_FIELDS: { 'Full name': 'name', 'Email': 'email', 'Mobile number': 'mobile', 'Which shift?': 'shift', 'I have first-aid training': 'firstAid', 'Anything we should know?': 'notes', 'You may contact me about this shift': 'consent', }, // Form answer text → shift id. Must match the choices in the form. SHIFT_CHOICES: { 'Saturday feeding program (8:00 to 11:00 AM)': 'sat-am', 'Saturday relief-pack sorting (1:00 to 4:00 PM)': 'sat-pm', 'Sunday reading buddies (9:00 to 11:00 AM)': 'sun-am', }, }; var SIGNUP_HEADERS = ['ID', 'Timestamp', 'Name', 'Email', 'Mobile', 'Shift', 'First aid', 'Notes', 'Status']; var LOG_HEADERS = ['Time', 'ID', 'Step', 'Result', 'Detail']; /** Run once: creates the two sheets and the form-submit trigger. */ function setup() { var ss = SpreadsheetApp.getActive(); ensureSheet_(ss, CONFIG.SIGNUPS_SHEET, SIGNUP_HEADERS); ensureSheet_(ss, CONFIG.LOG_SHEET, LOG_HEADERS); var exists = ScriptApp.getProjectTriggers().some(function (t) { return t.getHandlerFunction() === 'onFormSubmit'; }); if (!exists) ScriptApp.newTrigger('onFormSubmit').forSpreadsheet(ss).onFormSubmit().create(); writeLog_([{ time: now_(), id: '', step: 'setup', result: 'ok', detail: 'sheets and trigger ready' }]); } /** Form-submit trigger. `e.namedValues` maps question titles to answers. */ function onFormSubmit(e) { var lock = LockService.getScriptLock(); if (!lock.tryLock(30000)) { writeLog_([{ time: now_(), id: '', step: 'lock', result: 'error', detail: 'busy for 30s; submission not processed' }]); notifyError_('A sign-up could not be processed (busy). Check the form responses sheet.'); return; } try { var input = mapAnswers_(e && e.namedValues ? e.namedValues : {}); var rows = readRows_(); var outcome = processSignup(rows, input, intakeConfig_(), now_()); if (outcome.result === 'added') appendRow_(outcome.row); var log = outcome.log.slice(); if (outcome.result === 'added') { log = log.concat(send_(outcome.email.to, outcome.email.subject, outcome.email.body, outcome.row.id, 'volunteer email')); outcome.alerts.forEach(function (a) { log = log.concat(send_(CONFIG.COORDINATOR_EMAIL, a.subject, a.body, outcome.row.id, 'coordinator alert')); }); } writeLog_(log); } catch (err) { writeLog_([{ time: now_(), id: '', step: 'error', result: 'error', detail: String(err && err.message || err) }]); notifyError_('The volunteer sign-up script hit an error: ' + (err && err.message || err)); } finally { lock.releaseLock(); } } /** Optional daily trigger: counts per shift, emailed to the coordinator. */ function dailySummary() { var rows = readRows_(); var cfg = intakeConfig_(); var lines = cfg.SHIFTS.map(function (s) { var waiting = rows.filter(function (r) { return r.shift === s.id && r.status === 'Waitlisted'; }).length; return s.label + ': ' + countConfirmed(rows, s.id) + ' of ' + s.capacity + ' confirmed, ' + waiting + ' waitlisted'; }); var log = send_(CONFIG.COORDINATOR_EMAIL, 'Volunteer sign-ups: daily summary', lines.join('\n'), '', 'daily summary'); writeLog_(log); } /* ── helpers ─────────────────────────────────────────────── */ function intakeConfig_() { return INTAKE_DEFAULTS; // shifts and capacities live in intake-core } function mapAnswers_(named) { var input = {}; Object.keys(CONFIG.FORM_FIELDS).forEach(function (title) { var answer = named[title]; input[CONFIG.FORM_FIELDS[title]] = Array.isArray(answer) ? answer.join(', ') : (answer || ''); }); input.shift = CONFIG.SHIFT_CHOICES[input.shift] || input.shift; return input; } function readRows_() { var sheet = SpreadsheetApp.getActive().getSheetByName(CONFIG.SIGNUPS_SHEET); var values = sheet.getDataRange().getValues().slice(1); return values.filter(function (v) { return v[0]; }).map(function (v) { return { id: String(v[0]), timestamp: String(v[1]), name: String(v[2]), email: String(v[3]).toLowerCase(), mobile: String(v[4]), shift: String(v[5]), firstAid: v[6] === 'Yes', notes: String(v[7]), status: String(v[8]), }; }); } function appendRow_(row) { SpreadsheetApp.getActive().getSheetByName(CONFIG.SIGNUPS_SHEET).appendRow([ row.id, row.timestamp, row.name, row.email, row.mobile, row.shift, row.firstAid ? 'Yes' : 'No', row.notes, row.status, ].map(sheetText_)); } /** Sends one email (or pretends to, in DRY_RUN) and returns log entries. */ function send_(to, subject, body, id, step) { var time = now_(); if (CONFIG.DRY_RUN) return [{ time: time, id: id, step: step, result: 'dry run', detail: 'not sent: ' + subject }]; if (MailApp.getRemainingDailyQuota() < 1) { return [{ time: time, id: id, step: step, result: 'error', detail: 'daily email quota used up; not sent: ' + subject }]; } MailApp.sendEmail({ to: to, subject: subject, body: body, name: INTAKE_DEFAULTS.ORG_NAME }); return [{ time: time, id: id, step: step, result: 'sent', detail: subject }]; } function writeLog_(entries) { if (!entries.length) return; var sheet = SpreadsheetApp.getActive().getSheetByName(CONFIG.LOG_SHEET); sheet.getRange(sheet.getLastRow() + 1, 1, entries.length, LOG_HEADERS.length).setValues( entries.map(function (l) { return [l.time, l.id, l.step, l.result, l.detail].map(sheetText_); }) ); } function notifyError_(message) { if (CONFIG.DRY_RUN || MailApp.getRemainingDailyQuota() < 1) return; MailApp.sendEmail(CONFIG.COORDINATOR_EMAIL, 'Volunteer sign-up script: needs a look', message); } function ensureSheet_(ss, name, headers) { var sheet = ss.getSheetByName(name) || ss.insertSheet(name); if (sheet.getLastRow() === 0) { sheet.appendRow(headers); sheet.setFrozenRows(1); } return sheet; } function now_() { return Utilities.formatDate(new Date(), CONFIG.TIMEZONE, "yyyy-MM-dd'T'HH:mm:ssXXX"); } /** Keep untrusted strings literal when Google Sheets interprets a cell. */ function sheetText_(value) { return typeof value === 'string' && /^[=\t\r\n']/.test(value) ? "'" + value : value; }