-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathcode.js
More file actions
497 lines (409 loc) · 13.1 KB
/
code.js
File metadata and controls
497 lines (409 loc) · 13.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
// -----------------------------------------------------------------------------
// constants
// -----------------------------------------------------------------------------
const APPROVER_EMAILS = ["cfde.icc@gmail.com"];
const FROM_EMAIL = "cfde.icc@gmail.com";
const HELP_CONTACT = "Swathi Thaker at snthaker@uab.edu.";
const FORM_URL =
"https://docs.google.com/forms/d/1g1rq941ju15Zi2YMv70DDL33giW_xZ7XBrxSuMz8hi0";
const SHEET_URL =
"https://docs.google.com/spreadsheets/d/1uYt3DBp-PFNTGpBE8r1fssrFnECZ8G13yK1pstT-sgg";
const CALENDAR_URL = "https://cfdeconnect.org/calendar";
const CALENDAR_ID =
"3e81b0f8035b4e83b9394300144fcd290c034f9a5c9d1d00a3bb2a71d143785d@group.calendar.google.com";
// map of column "keys" (var names used in this script) to "names" (actual header cell values in sheet)
// (so this script can be fixed more easily if form/sheet names change)
const COLUMNS = {
// form
title: "Title",
organizer: "Organizer",
involved: "Involved",
purpose: "Purpose",
length: "Length",
start: "Start",
end: "End",
link: "Link",
tags: "Tags",
format: "Format",
location: "Location",
description: "Description",
// other
timestamp: "Timestamp",
submitter: "Email Address",
source: "Source",
status: "Status",
comments: "Comments",
id: "ID",
};
// -----------------------------------------------------------------------------
// globals
// -----------------------------------------------------------------------------
// sheet object
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0];
// get row/col counts
const lastRow = sheet.getLastRow();
const lastColumn = sheet.getLastColumn();
// columns as object
const columns = Object.fromEntries(
sheet
// get first row (headers)
.getRange(1, 1, 1, lastColumn)
.getValues()[0]
.map((name, index) => ({
// column key
key: Object.keys(COLUMNS).find((key) => COLUMNS[key] === name),
// actual column name in sheet
name,
// column number in sheet (1-indexed)
index: index + 1,
}))
// convert to object
.map((column) => [column.key, column]),
);
// rows as arrays of objects
const rows =
lastRow <= 1
? []
: sheet
// 2nd row to last row, 1st column to last column
.getRange(2, 1, lastRow - 1, lastColumn)
// get values as 2D array
.getValues()
// map each row array to an object with keys from headers
.map((row) =>
Object.fromEntries(
Object.values(columns).map(({ key, index }) => [
// make object keys match column keys
key,
// fallback if cell value null/undefined
row[index - 1] ?? "",
]),
),
)
// add more helpful properties to each row object
.map((row, rowIndex) => ({
...row,
// parse dates as date objects
start: new Date(row.start),
end: new Date(row.end),
// unique key for row, based on stable properties
key: [row.title, row.start, row.submitter].join("|"),
// row number in sheet (1-indexed)
index: rowIndex + 2,
}))
.map((row) => ({
...row,
update: (key, value) => {
// find column by key
const column = Object.values(columns).find(
(column) => column.key === key,
);
if (!column) return;
// update value in sheet
sheet.getRange(row.index, column.index).setValue(value);
// update value in object (for later use in same execution)
row[key] = value;
},
}));
// calendar object
const calendar = CalendarApp.getCalendarById(CALENDAR_ID);
// script properties
const script = PropertiesService.getScriptProperties();
// -----------------------------------------------------------------------------
// util
// -----------------------------------------------------------------------------
// send email
function sendEmail(to, from, subject, body) {
MailApp.sendEmail({
to: [to]
.flat()
.filter(Boolean)
.map((address) => address.trim())
.join(","),
from: from.trim(),
subject: subject.trim(),
body: body.trim(),
});
}
// format certain details in row as multi-line string
function formatDetails(
row,
keys,
valueSeparator = ": ",
entrySeparator = "\n",
) {
return (
keys
// label/value
.map((key) => [COLUMNS[key], row[key]])
// show that field exists but just empty
.map(([key, value]) =>
[
key,
value && value instanceof Date
? // format as date
value.toDateString()
: // format as string
(value ?? "-"),
].join(valueSeparator),
)
.join(entrySeparator)
);
}
// use script properties as simple persistent key-value store for particular row
function makeRowStore(prefix) {
const getKey = (row) => `${prefix}:${row.key}`;
const set = (row, value) => script.setProperty(getKey(row), value);
const get = (row) => script.getProperty(getKey(row));
const clear = (row) => script.deleteProperty(getKey(row));
return { get, set, clear };
}
// track approval email status for each row
const approval = makeRowStore("approval_email");
// track reminder email status for each row
const reminder = makeRowStore("reminder_email");
// look up calendar entry
function getEntry(id) {
try {
return calendar.getEventById(id);
} catch {}
}
// -----------------------------------------------------------------------------
// tasks
// -----------------------------------------------------------------------------
// initialize empty statuses
function initStatuses() {
for (const row of rows) if (!row.status) row.update("status", "Pending");
}
// send approval request emails for pending events
function sendApprovals() {
for (const row of rows) {
// only pending events
if (row.status !== "Pending") continue;
// don't resend
if (approval.get(row) === "sent") continue;
// email subject
const subject = `New Event Submission: ${row.title || "(no title)"}`;
// email body
const body = `
A new event awaits your review:
${formatDetails(row, [
"title",
"start",
"end",
"purpose",
"location",
"link",
"length",
"involved",
"submitter",
"source",
])}
Please review row # ${row.index} here:
${SHEET_URL}
Set "Approval Status" to "Approved" or "Denied," and optionally add "Approval Comments".
`;
sendEmail(APPROVER_EMAILS, FROM_EMAIL, subject, body);
// mark as sent
approval.set(row, "sent");
}
}
// send reminder emails for upcoming approved events
function sendReminders() {
// current time
const now = new Date();
// a bit in the future
const windowStart = new Date(now.getTime() + 14 * 24 * 60 * 60 * 1000);
// a bit more in the future
const windowEnd = new Date(now.getTime() + 15 * 24 * 60 * 60 * 1000);
for (const row of rows) {
// only events that start within window
if (row.start < windowStart || row.start > windowEnd) continue;
// don't resend
if (reminder.get(row) === "sent") continue;
// only approved events
if (row.status !== "Approved") continue;
// only events with particular fields
if (!row.submitter || !row.title || !row.start) continue;
// email subject
const subject = `Reminder: Upcoming Event "${row.title}"`;
// email body
const body = `
Hello event organizer,
As part of the CFDE Evaluation Core's event reporting efforts, we are asking you to gather some info during your upcoming event. Please refer back to the "POST-EVENT" part of the Google Form where you originally registered your event:
${FORM_URL}
Please prepare to survey your attendees and record notes so that you can answer these questions in detail. Once your event has concluded, we will be reminding you to fill out that section and update your response.
Thank you for helping us demonstrate the impact and value of CFDE events!
${formatDetails(row, ["title", "start", "end"])}
`;
sendEmail(row.submitter, FROM_EMAIL, subject, body);
// mark as sent
reminder.set(row, "sent");
}
}
// validate event
function validateEvent(row, status) {
// do some validation that can't easily be achieved in form
const errors = [];
// validate link
if (row.format.match(/virtual|online/i) && !row.link)
errors.push(`Missing ${columns.link}`);
// validate location
if (row.format.match(/in[- ]?person/i) && !row.location)
errors.push(`Missing ${columns.location}`);
if (!errors.length) return;
// report errors
// reset status so it can be fixed and resubmitted
row.update("status", "Pending");
approval.clear(row);
// email subject
const subject = `Approval blocked (row ${row.index})`;
// email body
const body = `
Cannot approve row ${row.index} in ${SHEET_URL}.
Errors:
${errors.map((error) => `- ${error}`).join("\n")}
`;
sendEmail(APPROVER_EMAILS, FROM_EMAIL, subject, body);
return true;
}
// handle setting status to pending
function eventPending(row, status) {
if (status !== "Pending") return;
approval.clear(row);
sendApprovals();
return true;
}
// handle denied event
function eventDenied(row, status) {
if (status !== "Denied") return;
if (row.id) {
// delete calender entry
getEntry(row.id)?.deleteEvent();
// clear id from sheet
row.update("id", "");
}
if (!row.submitter) return;
// email subject
const subject = `Your event "${row.title}" was denied`;
// email body
const body = `
Your CFDE event submission was not approved. Comments:
${row.comments}
If you have any questions, please contact ${HELP_CONTACT}.
`;
sendEmail(row.submitter, FROM_EMAIL, subject, body);
return true;
}
// handle approved event
function eventApproved(row, status) {
if (status !== "Approved") return;
// make calendar entry description to (PUBLIC, do not include private info)
const description = formatDetails(row, [
"description",
"tags",
"organizer",
"involved",
"purpose",
"length",
"link",
]);
// get location for calendar entry
const location =
row.format.match(/virtual|online/i) && row.link ? row.link : row.location;
// get existing calendar entry
let entry = getEntry(row.id);
// if exists
if (entry)
// update entry
entry
.setTitle(row.title)
.setTime(row.start, row.end)
.setDescription(description)
.setLocation(location);
else
// create new entry
entry = calendar.createEvent(row.title, row.start, row.end, {
description,
location,
});
// update sheet with calendar entry id
row.update("id", entry.getId());
// clear reminder dedupe so updated approved events can get a fresh reminder
reminder.clear(row);
if (!row.submitter) return;
// email subject
const subject = `Your event "${row.title}" was approved`;
// email body
const body = `
Your event is now live on our shared calendar: ${CALENDAR_URL}
If you have any questions, please contact ${HELP_CONTACT}.
`;
sendEmail(row.submitter, FROM_EMAIL, subject, body);
return true;
}
// handle removed events
function eventsRemoved() {
const idsKey = "cfde_event_ids";
// list of all current calendar entry ids in sheet
const ids = sheet
.getRange(2, columns.id.index, lastRow - 1, 1)
.getValues()
.flat()
.filter(Boolean);
// previous persisted list of calendar entry ids
const previousIds = JSON.parse(script.getProperty(idsKey) || "[]");
// which ids were removed from sheet
const removed = previousIds.filter((id) => !ids.includes(id));
// delete removed entries from calendar
for (const id of removed) getEntry(id)?.deleteEvent();
// persist ids for next time
script.setProperty(idsKey, JSON.stringify(ids));
}
// -----------------------------------------------------------------------------
// triggers
// -----------------------------------------------------------------------------
// run when form submitted
function onFormSubmit() {
// initialize empty statuses
initStatuses();
// send approval request emails
sendApprovals();
}
// run when spreadsheet changed
function onChange(event) {
const type = event.changeType;
if (type === "REMOVE_ROW") {
eventsRemoved();
return;
}
if (type === "EDIT") {
// range of cells just edited
const range = event.source.getActiveRange();
// make sure range is valid
if (!range || range.getSheet().getName() !== sheet.getName()) return;
// get row of edited cell
const rowIndex = range.getRow();
// get column of edited cell
const columnIndex = range.getColumn();
// get new status value
const status = range.getValue();
// get full row object
const row = rows.find((row) => row.index === rowIndex);
// if status cell edited
if (rowIndex > 1 && columnIndex === columns.status.index && row) {
if (validateEvent(row, status)) return;
if (eventPending(row, status)) return;
if (eventDenied(row, status)) return;
if (eventApproved(row, status)) return;
}
return;
}
}
// run daily
function onDaily() {
// (re)send approval request emails until admin has handled it
sendApprovals();
// send reminders dependent on date window
sendReminders();
}