-
Notifications
You must be signed in to change notification settings - Fork 2
/
legaleseSignature.js
508 lines (407 loc) · 20.9 KB
/
legaleseSignature.js
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
498
499
500
501
502
503
504
505
506
507
508
// this is exposed as the legaleseSignature library
//
// upon loading it sets the variable legaleseSignature._loaded = true;
// this is (not publicly) available at
// M_Wuaitt08FDk5mzAwEoxpXYH5ITXFjPS
var _loaded = true;
function getDigitalSignatureService(config) {
var signatureService = config.signature_service;
if (signatureService == undefined ||
signatureService.values == undefined ||
signatureService.values[0] == undefined
) { return getEchoSignService() }
// one day, maybe taking the default position here will be like taking the default search engine in Mozilla.
signatureService = signatureService.values[0].toLowerCase();
if (signatureService == "echosign" ) { return getEchoSignService() }
if (signatureService == "docusign" ) { return getDocuSignService() }
if (signatureService == "hellosign") { return getHelloSignService() }
// new signature backends welcome here
}
function getDocuSignService() { }
function getHelloSignService() { }
// ---------------------------------------------------------------------------------------------------------------- getEchoSignService_
// oAuth integration with EchoSign
// EchoSign uses OAuth 2
// so we grabbed https://github.com/googlesamples/apps-script-oauth2
// and we turned on the library.
//
// the redirect url is https://script.google.com/macros/d/{PROJECT KEY}/usercallback
// TODO:
// generalize this to getDigitalSignatureService
// let there be a config in the spreadsheet for the end-user to specify the desired digital signature service backend.
function getEchoSignService() {
// Create a new service with the given name. The name will be used when
// persisting the authorized token, so ensure it is unique within the
// scope of the property store.
var toreturn = OAuth2.createService('echosign')
// Set the endpoint URLs
.setAuthorizationBaseUrl('https://secure.echosign.com/public/oauth')
.setTokenUrl('https://secure.echosign.com/oauth/token')
// Set the name of the callback function in the script referenced
// above that should be invoked to complete the OAuth flow.
.setCallbackFunction('legaleseSignature.authCallback')
// Set the property store where authorized tokens should be persisted.
.setPropertyStore(PropertiesService.getDocumentProperties())
// Set the scopes to request (space-separated for Google services).
.setScope('agreement_read agreement_send agreement_write user_login');
var ssid = SpreadsheetApp.getActiveSpreadsheet().getId();
var ssname = SpreadsheetApp.getActiveSpreadsheet().getName();
// TODO: see line 1254 of showSidebar. refactor this chunk so that it's available for showSidebar's purposes.
var esApps = BUILD_INCLUDE(echosign-api-keys.json);
// the BUILD_INCLUDE gets filled by the Makefile from an echosign-api-keys.json file resident under the build/ dir.
if (esApps[ssid] != undefined) { ssname = ssid }
if (esApps[ssname] == undefined) {
legaleseMain.lsLog("unable to identify EchoSign OAuth credentials for this spreadsheet / project.");
return null;
}
legaleseMain.lsLog("ssname has become %s", ssname);
toreturn
// Set the client ID and secret
.setClientId(esApps[ssname].clientId)
.setClientSecret(esApps[ssname].clientSecret)
// from https://secure.echosign.com/account/application -- do this as a CUSTOMER not a PARTNER application.
.setProjectKey(esApps[ssname].projectKey);
// see https://secure.echosign.com/public/static/oauthDoc.jsp#scopes
toreturn.APIbaseUrl = 'https://secure.echosign.com/api/rest/v2';
// var oAuthConfig = UrlFetchApp.addOAuthService("echosign");
// oAuthConfig.setAccessTokenUrl(toreturn.tokenUrl_);
// oAuthConfig.setRequestTokenUrl(toreturn.tokenUrl_);
// oAuthConfig.setAuthorizationUrl(toreturn.tokenUrl_);
// oAuthConfig.setConsumerKey(toreturn.clientId_);
// oAuthConfig.setConsumerSecret(toreturn.clientSecret_);
return toreturn;
}
// ---------------------------------------------------------------------------------------------------------------- showSidebar
function showSidebar(sheet) {
var echosignService = getEchoSignService();
if (echosignService == null) { legaleseMain.lsLog("showSidebar: no echosignService, so not doing anything."); return } // don't show the sidebar if we're not associated with an echosign api.
echosignService.reset();
// blow away the previous oauth, because there's a problem with using the refresh token after the access token expires after the first hour.
// TODO: don't show the sidebar if our spreadsheet's project doesn't have an associated openid at the echosign end.
// because sometimes the controller does the thing, and this version of code.gs is only used for the form submit callback,
// but not for Send to EchoSign.
if (echosignService.hasAccess()) {
legaleseMain.lsLog("showSidebar: we have access. doing nothing.");
} else {
legaleseMain.lsLog("showSidebar: we lack access. showing sidebar");
var authorizationUrl = echosignService.getAuthorizationUrl();
var myTemplate = '<p><a href="<?= authorizationUrl ?>" target="_blank">Authorize EchoSign</a>. ' +
'Close this sidebar when authorization completes.</p>';
var template = HtmlService.createTemplate(myTemplate);
template.authorizationUrl = authorizationUrl;
var page = template.evaluate();
page
.setSandboxMode(HtmlService.SandboxMode.IFRAME)
.setTitle('OAuth to EchoSign')
.setWidth(300);
SpreadsheetApp.getUi() // Or DocumentApp or FormApp.
.showSidebar(page);
}
}
// ---------------------------------------------------------------------------------------------------------------- authCallback
function authCallback(request) {
var echosignService = getEchoSignService();
var isAuthorized = echosignService.handleCallback(request);
if (isAuthorized) {
return HtmlService.createHtmlOutput('<p>Success! You can close this tab.</p><p>👍</p><p>BTW the token property is ' + PropertiesService.getDocumentProperties().getProperty("oauth2.echosign")+'</p>');
} else {
return HtmlService.createHtmlOutput('Denied. You can close this tab.');
}
}
// ---------------------------------------------------------------------------------------------------------------- getLibraryDocuments_
function getLibraryDocuments_() {
var api = getEchoSignService();
var response = UrlFetchApp.fetch(api.APIbaseUrl + '/libraryDocuments',
{ headers: { "Access-Token": api.getAccessToken() } });
SpreadsheetApp.getUi().alert(response.getContentText());
}
// ---------------------------------------------------------------------------------------------------------------- fauxMegaUpload_
// upload a document to the template library
function fauxMegaUpload_() {
// we do this using the web UI
}
// ---------------------------------------------------------------------------------------------------------------- fauxMegaSign_
// send a particular document from the template library for faux megasign
function fauxMegaSign(sheet) {
var sheetPassedIn = ! (sheet == undefined);
sheet = sheet || SpreadsheetApp.getActiveSheet();
var entitiesByName = {};
var readRows_ = new legaleseMain.readRows(sheet, entitiesByName);
var terms = readRows_.terms;
var config = readRows_.config;
var parties = terms.parties;
var to_list = [];
var cc_list = parties._allparties.filter(function(party){return party.legalese_status.toLowerCase()=="cc"}); // TODO: get this a different way
var cc2_list = [];
var commit_updates_to = [];
var commit_updates_cc = [];
// is the desired document in the library?
var libTemplateName = config.echosign.tree.libTemplateName != undefined ? config.echosign.tree.libTemplateName : undefined;
if (libTemplateName == undefined) {
legaleseMain.lsLog("libTemplateName not defined in README. not uploading agreement.");
return;
}
var now = Utilities.formatDate(new Date(), sheet.getParent().getSpreadsheetTimeZone(), "yyyyMMdd-HHmmss");
for (var p in parties._unmailed) {
var party = parties._unmailed[p];
// if multi-address, then first address is To: and subsequent addresses are CC
var to_cc = legaleseMain.email_to_cc(party.email);
if (to_cc[0] != undefined && to_cc[0].length > 0) {
party._email_to = to_cc[0];
to_list.push(party);
party._commit_update_to = legaleseMain.getPartyCells(sheet, terms, party);
}
if (to_cc[1].length > 0) {
cc2_list = cc2_list.concat(to_cc[1]);
}
}
legaleseMain.lsLog("we shall be emailing to %s", to_list.join(", "));
if (to_list.length == 0) {
SpreadsheetApp.getUi().alert("There doesn't seem to be anybody for us to mail this to! Check the Legalese Status column.");
return;
}
// TODO: who shall we cc to? everybody whose legalese status == "cc".
for (var p in cc_list) {
var party = cc_list[p];
party._commit_update_cc = legaleseMain.getPartyCells(sheet, terms, party);
}
cc_list = cc_list.map(function(party){return party.email});
cc_list = cc_list.concat(cc2_list);
legaleseMain.lsLog("To: %s", to_list.join(", "));
legaleseMain.lsLog("CC: %s", cc_list.join(", "));
var ss = sheet.getParent();
for (var p in to_list) {
var party = to_list[p];
var emailInfo = [{email:party._email_to, role:"SIGNER"}];
var acr = postAgreement_( { "libraryDocumentName": libTemplateName },
emailInfo,
config.echosign.tree.message,
config.echosign.tree.title,
cc_list,
terms,
config,
null
);
party._commit_update_to.legalese_status.setValue("mailed echosign " + now);
legaleseMain.lsLog("fauxMegaSign: well, that seems to have worked!");
}
legaleseMain.lsLog("fauxMegaSign: that's all, folks!");
}
function templateTitles(templates) {
if (templates.length == 1) { return templates[0].title }
return templates.map(function(t){return t.sequence}).join(", ");
}
// ---------------------------------------------------------------------------------------------------------------- uploadAgreement
// send PDFs to echosign.
// if the PDFs don't exist, send them to InDesign for creation and wait.
// for extra credit, define a usercallback and associate it with a StateToken so InDesign can proactively trigger a pickup.
// for now, just looking for the PDFs in the folder seems to be good enough.
function uploadAgreement(sheet, interactive) {
// TODO: we need to confirm that the docs generated match the current sheet.
// exploded docs need to have a different set of email recipients for each document.
var echosignService = getEchoSignService();
// blow away the previous oauth, because there's a problem with using the refresh token after the access token expires after the first hour.
if (!echosignService.hasAccess()) {
SpreadsheetApp.getUi().alert("we don't have echosign access. Reload this page so the sidebar appears, then click on the OAuth link.");
return "echosign fail";
}
else {
legaleseMain.lsLog("uploadAgreement: we have echosignService hasAccess = true");
}
var sheetPassedIn = ! (sheet == undefined);
if (interactive == undefined || interactive) {
var ui = SpreadsheetApp.getUi();
var response = ui.alert("Send to EchoSign?",
"Are you sure you want to send to EchoSign?\nMaybe you clicked a menu option by mistake.",
ui.ButtonSet.YES_NO);
if (response == ui.Button.NO) return;
}
if (! sheetPassedIn && SpreadsheetApp.getActiveSpreadsheet().getName().toLowerCase() == "legalese controller") {
legaleseMain.lsLog("in controller mode, switching to uploadOtherAgreements()");
uploadOtherAgreements_(false);
return;
}
sheet = sheet || SpreadsheetApp.getActiveSheet();
var ss = sheet.getParent();
var entitiesByName = {};
var readRows_ = new legaleseMain.readRows(sheet, entitiesByName);
var terms = readRows_.terms;
var config = readRows_.config;
var readmeDoc = legaleseMain.getReadme(sheet);
// TODO: be more organized about this. in the same way that we generated one or more output PDFs for each input template
// we now need to upload exactly that number of PDFs as transientdocuments, then we need to uploadAgreement once for each PDF.
var parties = legaleseMain.roles2parties(readRows_);
var suitables = legaleseMain.suitableTemplates(readRows_, parties);
legaleseMain.lsLog("resolved suitables = %s", suitables.map(function(e){return e.url}).join(", "));
var docsetEmails = new legaleseMain.docsetEmails(sheet, readRows_, parties, suitables);
// we need to establish:
// an AGREEMENT contains one or more transientDocuments
// an AGREEMENT has one list of To and CCs
//
// an Agreement is keyed on one or more sourcetemplate filenames
// corresponding exactly to the docsetEmails Rcpts function
//
var transientDocumentIds = {}; // pdf filename : transientDocumentId
var uploadTransientDocument = function(sourceTemplates, entity, rcpts) {
var sourceTemplate = sourceTemplates[0];
var filename = legaleseMain.filenameFor(sourceTemplate, entity) + ".pdf";
var api = getEchoSignService();
var o = { headers: { "Access-Token": api.getAccessToken() } };
o.method = "post";
var folderId = legaleseMain.getDocumentProperty(sheet, "folder.id");
var folderName = legaleseMain.getDocumentProperty(sheet, "folder.name");
legaleseMain.lsLog("uploadTransientDocument: folder.id = %s", folderId);
if (folderId == undefined) {
throw("can't find folder for PDFs. try Generate PDFs.");
}
var folder = DriveApp.getFolderById(folderId);
var pdf = folder.getFilesByName(filename);
var pdfs = [];
while (pdf.hasNext()) { pdfs.push(pdf.next()) }
if (pdfs.length == 0) { throw("can't find PDF named " + filename) }
if (pdfs.length > 1) { throw("multiple PDFs are named " + filename) }
var pdfdoc = pdfs[0];
o.payload = {
"File-Name": pdfdoc.getName(),
"File": pdfdoc.getBlob(),
"Mime-Type": pdfdoc.getMimeType(), // hope that's application/pdf
};
legaleseMain.lsLog("uploadTransientDocument: uploading to EchoSign: %s %s", pdfdoc.getId(), pdfdoc.getName());
if (o.payload['Mime-Type'] != "application/pdf") {
legaleseMain.lsLog("WARNING: mime-type of document %s (%s) is not application/pdf ... weird, eh.", pdfdoc.getId(), pdfdoc.getName());
}
var response = UrlFetchApp.fetch(api.APIbaseUrl + '/transientDocuments', o);
var r = JSON.parse(response.getContentText());
legaleseMain.lsLog("uploadTransientDocument: %s has transientDocumentId=%s", pdfdoc.getName(), r.transientDocumentId);
transientDocumentIds[filename] = r.transientDocumentId;
legaleseMain.lsLog("uploadTransientDocument: recipients for %s = %s", pdfdoc.getName(), rcpts);
};
var multiTitles = function(templates, entity) { var ts = templates.constructor.name == "Array" ? templates : [templates];
return ts.map(function(t){return legaleseMain.filenameFor(t, entity)+".pdf"}).join(",") };
var createAgreement = function(templates, entity, rcpts) {
legaleseMain.lsLog("at this point we would call postAgreement for %s to %s",
multiTitles(templates, entity),
rcpts);
if (entity && entity.skip_echosign) {
legaleseMain.lsLog("entity %s wants to skip echosign. so, not creating agreement.", entity.name);
return "skipping echosign as requested by entity";
}
var tDocIds = templates.map(function(t){return transientDocumentIds[legaleseMain.filenameFor(t,entity)+".pdf"]});
if (tDocIds == undefined || tDocIds.length == 0) {
legaleseMain.lsLog("transient documents were not uploaded to EchoSign. not uploading agreement.");
readmeDoc.getBody().appendParagraph("nothing uploaded to EchoSign. not uploading agreement.");
return "no docs found!";
}
var emailInfo = rcpts[0].map(function(e) { return {email:e, role:"SIGNER"}});
var cc_list = rcpts[1];
if (emailInfo.length == 0) {
// SpreadsheetApp.getUi().alert("no recipients for " + multiTitles(templates, entity) + " ... skipping.");
return "no recipients!";
}
legaleseMain.lsLog("To: %s", emailInfo.map(function(party){return party.email}));
legaleseMain.lsLog("CC: %s", cc_list);
readmeDoc.appendHorizontalRule();
readmeDoc.appendParagraph("To: " + emailInfo.map(function(party){return party.email}).join(", "));
readmeDoc.appendParagraph("CC: " + cc_list.join(", "));
// the exploded version needs a more specific title so the filenames don't clobber
var esTitle = config.echosign.tree.title + " - " + templateTitles(templates);
if (entity) esTitle += " - " + entity.name;
var acr = postAgreement_( tDocIds.map(function(t){return { "transientDocumentId": t } }),
emailInfo,
config.echosign.tree.message,
esTitle,
cc_list,
terms,
config,
readmeDoc,
null
);
legaleseMain.lsLog("createAgreement: well, that seems to have worked!");
};
legaleseMain.lsLog("uploadAgreements(): we upload the non-exploded normal templates as transientDocuments");
docsetEmails.normal(uploadTransientDocument);
legaleseMain.lsLog("uploadAgreements(): we upload the exploded templates as a transientDocument");
docsetEmails.explode(uploadTransientDocument);
// TODO: does this do the right thing when the constituent documents each have different to and cc parties?
legaleseMain.lsLog("uploadAgreements(): we post the non-exploded normal transientDocuments as Agreements");
if (config.concatenate_pdfs && config.concatenate_pdfs.values[0] == true) {
docsetEmails.normal(function(){legaleseMain.lsLog("individual callback doing nothing")}, createAgreement );
} else {
docsetEmails.normal(createAgreement, function(){legaleseMain.lsLog("group callback doing nothing")});
}
legaleseMain.lsLog("uploadAgreements(): we post the exploded transientDocuments as Agreements");
docsetEmails.explode(createAgreement);
return "sent";
}
// ---------------------------------------------------------------------------------------------------------------- uploadOtherAgreements_
function uploadOtherAgreements_(interactive) {
var sheets = legaleseMain.otherSheets();
for (var i = 0; i < sheets.length; i++) {
var sheet = sheets[i];
var myRow = SpreadsheetApp.getActiveSheet().getRange(SpreadsheetApp.getActiveRange().getRow()+i, 1, 1, 10);
var result = uploadAgreement(sheet, interactive);
if (result == "sent") {
myRow.getCell(1,5).setValue("sent at "+ Utilities.formatDate(new Date(), sheet.getParent().getSpreadsheetTimeZone(), "yyyyMMdd-HHmmss"));
SpreadsheetApp.flush();
}
else {
myRow.getCell(1,5).setValue(result);
}
}
}
// ---------------------------------------------------------------------------------------------------------------- postAgreement_
function postAgreement_(fileInfos, recipients, message, name, cc_list, terms, config, readmeDoc, agreementCreationInfo) {
var api = getEchoSignService();
if (agreementCreationInfo == undefined) {
agreementCreationInfo = {
"documentCreationInfo": {
"signatureType": "ESIGN",
"recipients": recipients,
"ccs": cc_list , // everyone whose legalese status is cc
"signatureFlow": "PARALLEL", // only available for paid accounts. we may need to check the user info and switch this to SENDER_SIGNATURE_NOT_REQUIRED if the user is in the free tier.
"message": message,
"fileInfos": fileInfos,
"name": name,
},
"options": {
"authoringRequested": false,
}
};
// TODO: if the data.expiry_date is defined then add 24 hours to it and stick it in
// but also set the configuration option that decides if we should honour it or not.
if (config.echosign_expires != undefined && config.echosign_expires.values[0]
&& terms.expiry_date != undefined) {
var days_until = ((new Date(terms._orig_expiry_date)).getTime() - (new Date()).getTime()) / (24 * 60 * 60 * 1000);
legaleseMain.lsLog("expiry date is %s days in the future. will give an extra day for leeway", days_until);
agreementCreationInfo.daysUntilSigningDeadline = days_until + 1;
}
}
if (readmeDoc != undefined) readmeDoc.appendParagraph("agreementCreationInfo = " + JSON.stringify(agreementCreationInfo));
var o = { headers: { "Access-Token": api.getAccessToken() },
method: "post",
};
// o.oAuthServiceName = "echosign";
// o.oAuthUseToken = "always";
// this works in the postTransientDocument, but doesn't work here. how weird!
// see https://developers.google.com/apps-script/reference/url-fetch/url-fetch-app
// o.payload = agreementCreationInfo;
o.contentType = 'application/json';
o.payload = JSON.stringify(agreementCreationInfo);
// this is fucked up. we shouldn't have to do this manually.
// in postTransientDocument I don't have to. what a huge mystery!
// https://developers.google.com/apps-script/reference/url-fetch/url-fetch-app
legaleseMain.lsLog("about to dump %s", JSON.stringify(o));
if (config.skip_echosign && config.skip_echosign.values[0] == true) {
legaleseMain.lsLog("skipping the sending to echosign");
} else {
legaleseMain.lsLog("actually posting to echosign");
var response = UrlFetchApp.fetch(api.APIbaseUrl + '/agreements', o);
if (response.getResponseCode() >= 400) {
legaleseMain.lsLog("got response %s", response.getContentText());
legaleseMain.lsLog("dying");
return;
}
legaleseMain.lsLog("got back %s", response.getContentText());
return JSON.parse(response.getContentText());
}
}