-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathCode.gs
More file actions
3221 lines (3175 loc) · 254 KB
/
Copy pathCode.gs
File metadata and controls
3221 lines (3175 loc) · 254 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
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/***** AgentATS — complete Apps Script backend (Code.gs) *****/
// ---------- CONFIG ----------
// SHEET_ID / FOLDER_ID are NEVER hardcoded. They live in Script Properties and are
// created automatically the first time you run firstRun() (see ONE-TIME SETUP below).
var SHEET_ID = PropertiesService.getScriptProperties().getProperty('SHEET_ID') || '';
var FOLDER_ID = PropertiesService.getScriptProperties().getProperty('FOLDER_ID') || '';
var SEARCH_LABEL = 'Applications';
var DONE_LABEL = 'AgentATS-Done';
var APPLY_DAILY_CAP = 200;
var MAX_RESUME_MB = 5;
var GEMINI_MODEL = 'gemini-2.5-flash';
// ---------- SECURITY LAYER (fixes C-1, C-2, C-3) ----------
// The web app must stay deployed "Execute as: me / Who has access: Anyone" so the public
// careers page works. That means Session.getActiveUser() is EMPTY for most visitors, so
// identity comes from each teammate's personal ?u= token (Users sheet, column 5 "Token").
// Index.html automatically appends that token to EVERY server call as one extra final
// argument that looks like "#tok:abc123". The helpers below verify it server-side.
var TOKEN_MARK_ = '#tok:';
var AUTH_USER_ = null; // the verified caller for THIS execution (set once, reused everywhere)
// Pulls the "#tok:..." marker the web page appends as the last argument of a call.
// Internal server-to-server calls never carry the marker, so they are never mistaken for it.
function extractToken_(callerArgs) {
try {
if (callerArgs && callerArgs.length) {
var last = callerArgs[callerArgs.length - 1];
if (typeof last === 'string' && last.indexOf(TOKEN_MARK_) === 0) return last.slice(TOKEN_MARK_.length);
}
} catch (e) {}
return '';
}
// C-1: looks a token up in the Users sheet. Returns {email, name, role, active, title} or null.
// The ROLE always comes from the sheet — never from anything the browser claims.
function currentUserFromToken_(token) {
if (!token) return null;
try {
var sh = SpreadsheetApp.openById(SHEET_ID).getSheetByName('Users');
if (!sh) return null;
var d = sh.getDataRange().getValues();
for (var i = 1; i < d.length; i++) {
if (d[i][4] && (d[i][4] || '').toString() === token.toString()) {
if ((d[i][3] || '').toString().toLowerCase() === 'no') return null; // deactivated
return { email: (d[i][0] || '').toString().toLowerCase(), name: (d[i][1] || d[i][0] || '').toString(),
role: (d[i][2] || '').toString(), active: true, title: (d[i][5] || '').toString() };
}
}
} catch (e) {}
return null;
}
// Role ladder. Unknown/blank roles rank 0 = no access.
function roleRank_(role) { return { Interviewer: 1, HiringManager: 2, Recruiter: 3, Admin: 4 }[role] || 0; }
// C-1: central authorization check. Returns the verified user, or { error: '...' }.
// minRole: 'Interviewer' (any active team member) < 'HiringManager' < 'Recruiter' < 'Admin'.
function requireRole_(token, minRole) {
var u = currentUserFromToken_(token);
if (!u && AUTH_USER_) u = AUTH_USER_; // already verified earlier in this execution
if (!u) { var s = currentUser_(); if (s && s.role) u = s; } // Google sign-in fallback (owner)
if (!u || !u.role) return { error: '🔒 Please open AgentATS through your personal access link (ask your admin for it).' };
if (roleRank_(u.role) < roleRank_(minRole || 'Interviewer'))
return { error: '🔒 Sorry ' + (u.name || u.email) + ' — your role (' + u.role + ') is not allowed to do that.' };
AUTH_USER_ = u;
return u;
}
// Convenience: pass the calling function's own `arguments` straight in.
function guard_(callerArgs, minRole) { return requireRole_(extractToken_(callerArgs), minRole); }
// C-2: OWASP formula/CSV-injection guard. A value like "=IMPORTXML(...)" written to the
// sheet would EXECUTE as a live formula and could exfiltrate other tabs. Prefixing a single
// apostrophe makes Google Sheets store it as harmless literal text.
function sanitizeCell_(v) {
if (typeof v !== 'string') return v;
return /^[=+\-@\t\r]/.test(v) ? ("'" + v) : v;
}
function sanitizeRow_(arr) { return (arr || []).map(sanitizeCell_); }
// H-1: serialize ID minting + row appends. The hourly trigger, the public Apply form and
// interactive users all append rows concurrently; without a lock two executions can mint the
// same CAND-XXXX or write their ID/phone into the OTHER execution's row (appendRow+getLastRow
// is not atomic). Every intake path wraps its append+ID block in this.
function withScriptLock_(fn) {
var lock = LockService.getScriptLock();
lock.waitLock(30000); // up to 30s; throws if the sheet is that busy (caller surfaces the error)
try { return fn(); } finally { lock.releaseLock(); }
}
// H-2: ONE canonical stage vocabulary. Debrief decisions used to write 'Advanced (Debrief)' /
// 'Rejected (Debrief)' which existed nowhere in the analytics funnel or the archive terminal
// list, so those candidates were mis-counted forever. All stage READS (analytics, archive)
// normalize through this map, so legacy rows already in the sheet are counted correctly too.
function canonStage_(s) {
s = (s || '').toString().trim().toLowerCase();
var MAP = {
'advanced (debrief)': 'selected', // legacy debrief-advance → canonical 'Selected'
'rejected (debrief)': 'debrief reject', // legacy debrief-reject → canonical 'Debrief Reject'
'on hold': 'debrief', // still under debrief consideration (not terminal)
'hired': 'onboarded' // alias
};
return MAP[s] || s;
}
// H-3: authenticate inbound webhooks (Slack slash command, generic JSON, CV forwarder) BEFORE
// any read/write. The secret lives in Script Property WEBHOOK_SECRET and is sent either as a
// ?secret= query param on the webhook URL or as a "secret" field in the JSON body.
// Fails CLOSED: if the property is unset, every doPost write is rejected.
function webhookSecretOk_(e, p) {
var want = '';
try { want = PropertiesService.getScriptProperties().getProperty('WEBHOOK_SECRET') || ''; } catch (err) {}
if (!want) return false;
var got = (((e && e.parameter && e.parameter.secret) || (p && p.secret) || '')).toString();
if (!got || got.length !== want.length) return false;
var diff = 0; // constant-time compare
for (var i = 0; i < want.length; i++) diff |= want.charCodeAt(i) ^ got.charCodeAt(i);
return diff === 0;
}
// ---------- WEB APP ENTRY ----------
function doGet(e) {
var p = (e && e.parameter && e.parameter.page) || '';
var file = p === 'apply' ? 'Apply' : (p === 'source' ? 'Source' : 'Index');
return HtmlService.createHtmlOutputFromFile(file)
.setTitle('AgentATS').addMetaTag('viewport', 'width=device-width, initial-scale=1');
}
// ---------- PER-REQUISITION INTERVIEW PLAN / RUBRIC ----------
function suggestInterviewPlan(reqId) {
var _g = guard_(arguments, 'Recruiter'); if (_g.error) return _g.error; // C-1: server-side auth
var rq = SpreadsheetApp.openById(SHEET_ID).getSheetByName('Requisitions').getDataRange().getValues();
var title = '', level = '';
for (var i = 1; i < rq.length; i++) if ((rq[i][0] || '').toString() === reqId.toString()) { title = rq[i][1]; level = rq[i][6]; break; }
return callGemini('Design a structured interview loop for a ' + (level || '') + ' ' + (title || 'role') +
'. Return ONLY JSON: {"rounds":[{"name":"","type":"","competencies":["",""]}]}. 4-6 rounds in the order they ' +
'should happen, PLUS a final round named "Debrief" (type debrief, competencies: consolidate scores, hire decision). ' +
'Types from screen/technical/case/behavioral/panel/hiring_manager/debrief; 3-5 competencies per round; grounded in ' +
'I/O psychology and role norms (Stripe/FAANG style).', true);
}
function saveReqPlan(reqId, planJson) {
var _g = guard_(arguments, 'Recruiter'); if (_g.error) return _g.error; // C-1: server-side auth
var sh = SpreadsheetApp.openById(SHEET_ID).getSheetByName('Requisitions'), d = sh.getDataRange().getValues();
for (var i = 1; i < d.length; i++) if ((d[i][0] || '').toString() === reqId.toString()) { sh.getRange(i + 1, 16).setValue(planJson); return '✅ Interview plan saved for ' + reqId + '.'; }
return 'Requisition not found.';
}
function getReqPlan(reqId) {
var _g = guard_(arguments, 'Interviewer'); if (_g.error) return _g.error; // C-1: server-side auth
var d = SpreadsheetApp.openById(SHEET_ID).getSheetByName('Requisitions').getDataRange().getValues();
for (var i = 1; i < d.length; i++) if ((d[i][0] || '').toString() === reqId.toString()) return (d[i][15] || '').toString();
return '';
}
function getAppUrl() { return ScriptApp.getService().getUrl(); }
// ---------- LIVE SOURCING (Hacker News — free, official API, bot-safe) ----------
function reqJDText_(reqId) {
if (!reqId) return 'General role fit.';
var d = SpreadsheetApp.openById(SHEET_ID).getSheetByName('Requisitions').getDataRange().getValues();
for (var i = 1; i < d.length; i++) if ((d[i][0] || '').toString() === reqId.toString())
return (d[i][1] || '') + ' (' + (d[i][6] || '') + ', ' + (d[i][3] || '') + '). ' + (d[i][17] || '');
return 'General role fit.';
}
function scoreSourced_(items, jd) {
if (!items.length) return;
var list = items.map(function (p, i) { return (i + 1) + '. ' + (p.author || '') + ': ' + (p.text || '').substring(0, 500); }).join('\n\n');
try {
var arr = JSON.parse(callGemini('Score each candidate snippet 0-100 for fit against this job. Return ONLY a JSON array of numbers in order.\nJOB: ' + jd + '\n\nCANDIDATES:\n' + list, true));
items.forEach(function (p, i) { p.score = (typeof arr[i] === 'number') ? arr[i] : ''; });
} catch (e) { items.forEach(function (p) { p.score = ''; }); }
}
function sourceHN(reqId, keywords) {
try {
var s = JSON.parse(UrlFetchApp.fetch('https://hn.algolia.com/api/v1/search_by_date?query=' + encodeURIComponent('who wants to be hired') + '&tags=story&hitsPerPage=8', { muteHttpExceptions: true }).getContentText());
var thread = null;
(s.hits || []).forEach(function (h) { if (!thread && /who wants to be hired/i.test(h.title || '')) thread = h; });
if (!thread) return { error: 'No hiring thread found right now.' };
var it = JSON.parse(UrlFetchApp.fetch('https://hn.algolia.com/api/v1/items/' + thread.objectID, { muteHttpExceptions: true }).getContentText());
var kws = (keywords || '').toLowerCase().split(/[,\s]+/).filter(Boolean), posts = [];
(it.children || []).forEach(function (c) {
if (!c.text) return;
var txt = c.text.replace(/<[^>]+>/g, ' ').replace(///g, '/').replace(/&[a-z]+;/g, ' ').replace(/\s+/g, ' ').trim();
var low = txt.toLowerCase();
if (kws.length && !kws.some(function (k) { return low.indexOf(k) > -1; })) return;
posts.push({ author: c.author || '(anon)', text: txt.substring(0, 700),
url: 'https://news.ycombinator.com/item?id=' + c.id, date: (c.created_at || '').substring(0, 10), source: 'Hacker News' });
});
posts = posts.slice(0, 15);
scoreSourced_(posts, reqJDText_(reqId));
posts.sort(function (a, b) { return (b.score || 0) - (a.score || 0); });
return { thread: thread.title, count: posts.length, posts: posts };
} catch (e) { return { error: e.message }; }
}
function addSourcedCandidate(o) {
var u = currentUser_(arguments);
if (u.role !== 'Admin' && u.role !== 'Recruiter') return '🔒 Only recruiters/admins can add candidates.';
var sheet = trackerSheet_();
var r = withScriptLock_(function () { // H-1: atomic append + ID mint
sheet.appendRow(sanitizeRow_([new Date(), o.name || '(sourced)', o.email || '', '', o.source || 'Sourced', o.url || '', 'New', o.score || '', (o.notes || '').substring(0, 300), '', ''])); // C-2: sourced (HN) text
var rr = sheet.getLastRow(); sheet.getRange(rr, 31).setValue(nextCandidateId_()); return rr;
});
if (o.reqId) sheet.getRange(r, 12).setValue(sanitizeCell_(o.reqId));
bustCache_(); // M-3: new candidate must appear in the board/pipeline immediately (caches are keyed on CACHE_VER)
return '✅ Added ' + (o.name || 'sourced candidate') + ' to the pipeline.';
}
// ---------- ONE-TIME SETUP (run each once from the editor) ----------
/**
* firstRun() — run this ONCE from the Apps Script editor on a fresh install.
* Self-provisioning: if no SHEET_ID / FOLDER_ID Script Properties exist yet, it
* CREATES a new Google Spreadsheet ("AgentATS Tracker") and a Drive folder
* ("AgentATS CVs"), stores both ids in Script Properties, builds every tab,
* seeds YOU as the Admin user, and logs the new Sheet URL. No manual ids needed.
* Safe to re-run: it never overwrites an existing SHEET_ID/FOLDER_ID.
* Run firstRun() BEFORE you deploy the web app.
*/
function firstRun() {
var p = PropertiesService.getScriptProperties();
// 1) Spreadsheet: reuse the stored one, or create a fresh tracker.
var sheetId = p.getProperty('SHEET_ID') || '';
if (!sheetId) {
var created = SpreadsheetApp.create('AgentATS Tracker');
created.getSheets()[0].setName('Tracker');
sheetId = created.getId();
p.setProperty('SHEET_ID', sheetId);
}
SHEET_ID = sheetId; // repopulate the global so every openById(SHEET_ID) below works NOW
// 2) Drive folder for incoming CVs.
var folderId = p.getProperty('FOLDER_ID') || '';
if (!folderId) {
folderId = DriveApp.createFolder('AgentATS CVs').getId();
p.setProperty('FOLDER_ID', folderId);
}
FOLDER_ID = folderId;
// 3) Base tabs + headers.
var ss = SpreadsheetApp.openById(SHEET_ID);
var tracker = ss.getSheetByName('Tracker') || ss.insertSheet('Tracker');
ensureHeaders(tracker); // cols 1-9 core headers
tracker.getRange(1, 10, 1, 2).setValues([['Interview Date', 'Dossier']]).setFontWeight('bold');
var users = ss.getSheetByName('Users') || ss.insertSheet('Users');
users.getRange(1, 1, 1, 6).setValues([['Email', 'Name', 'Role', 'Active', 'Token', 'Title']]).setFontWeight('bold');
// 4) Seed the person running this as the Admin (before any guarded setup call).
var me = (Session.getActiveUser().getEmail() || Session.getEffectiveUser().getEmail() || '').toLowerCase();
if (users.getLastRow() < 2 && me) users.appendRow([me, 'Recruiting Manager', 'Admin', 'Yes', '', '']);
ensureUserTokens_(); // mint the personal ?u= access token
AUTH_USER_ = { email: me, name: me || 'Owner', role: 'Admin', active: true }; // editor run = owner
// 5) Build the full schema (requisitions, candidate columns, candidate ids).
setupAtsTabs();
addCandidateIdColumn();
var url = ss.getUrl();
Logger.log('AgentATS is ready. Your tracker: ' + url);
Logger.log('Next: set Script Property GEMINI_KEY, then Deploy > New deployment > Web app.');
return 'AgentATS is ready. Tracker: ' + url;
}
function setupAtsTabs() {
var _g = guard_(arguments, 'Admin'); if (_g.error) return _g.error; // C-1: server-side auth
var ss = SpreadsheetApp.openById(SHEET_ID);
var reqHeaders = ['Req ID','Title','Department','Line of Business','Location','Employment Type','Level',
'Hiring Manager','Recruiter','Openings','Priority','Salary Min','Salary Max','Status',
'Date Opened','Rubric Template','JD Link','Notes',
'Calibration Files','Calibration Voice','Calibration Transcript','Calibration Brief','Calibration Notes','Fit Config','Benchmark','HM Email',
'Success Profile','Success Context']; // M-2: cols 27-28 were written headerless → excluded from Supabase sync + backups
var req = ss.getSheetByName('Requisitions') || ss.insertSheet('Requisitions');
req.getRange(1, 1, 1, reqHeaders.length).setValues([reqHeaders]).setFontWeight('bold');
var tracker = ss.getSheetByName('Tracker');
if (!tracker) throw new Error('No tab named "Tracker" — rename your main tab to Tracker first.');
var newCols = ['Req ID','Phone','Current Location','Outstation','Willing to Relocate',
'Work Mode Preference','Current Company','Current Title','Total Experience (yrs)','Skills',
'Highest Qualification','Notice Period','Current CTC','Expected CTC','Offer in Hand',
'Work Authorization','Reason for Change','HR Remarks','Gender'];
tracker.getRange(1, 12, 1, newCols.length).setValues([newCols]).setFontWeight('bold');
tracker.getRange(1, 32, 1, 4).setValues([['First Name', 'Middle Name', 'Last Name', 'LinkedIn URL']]).setFontWeight('bold');
tracker.getRange(1, 36, 1, 1).setValue('Skill Graph').setFontWeight('bold');
tracker.getRange(1, 37, 1, 1).setValue('Highlights').setFontWeight('bold');
tracker.getRange(1, 38, 1, 1).setValue('GitHub').setFontWeight('bold');
tracker.getRange(1, 39, 1, 1).setValue('Patents').setFontWeight('bold');
tracker.getRange(1, 40, 1, 1).setValue('Rubric Snapshot').setFontWeight('bold');
return 'ATS tabs ready.';
}
// One-time migration: split existing full names into First/Middle/Last for candidates added before the name fields existed.
function backfillCandidates() {
var _g = guard_(arguments, 'Admin'); if (_g.error) return _g.error; // C-1: server-side auth
var sh = trackerSheet_(), d = sh.getDataRange().getValues(), n = 0;
for (var i = 1; i < d.length; i++) {
if (!d[i][1]) continue;
var first = (d[i][31] || '').toString().trim();
if (first) continue; // already split
var parts = (d[i][1] || '').toString().trim().split(/\s+/).filter(Boolean);
if (!parts.length) continue;
sh.getRange(i + 1, 32).setValue(parts[0]);
if (parts.length > 1) sh.getRange(i + 1, 34).setValue(parts[parts.length - 1]);
if (parts.length > 2) sh.getRange(i + 1, 33).setValue(parts.slice(1, -1).join(' '));
n++;
}
return '✅ Backfilled first/middle/last name for ' + n + ' candidate(s). Empty fields like email or title still need to be filled via Edit.';
}
function setupUsers() {
var _g = guard_(arguments, 'Admin'); if (_g.error) return _g.error; // C-1: server-side auth
var ss = SpreadsheetApp.openById(SHEET_ID);
var sh = ss.getSheetByName('Users') || ss.insertSheet('Users');
sh.getRange(1, 1, 1, 4).setValues([['Email', 'Name', 'Role', 'Active']]).setFontWeight('bold');
var me = Session.getActiveUser().getEmail();
if (sh.getLastRow() < 2 && me) sh.appendRow([me, 'Recruiting Manager', 'Admin', 'Yes']);
return 'Users ready: ' + me;
}
function addCandidateIdColumn() {
var _g = guard_(arguments, 'Admin'); if (_g.error) return _g.error; // C-1: server-side auth
var sh = SpreadsheetApp.openById(SHEET_ID).getSheetByName('Tracker');
sh.getRange(1, 31).setValue('Candidate ID').setFontWeight('bold');
var last = sh.getLastRow(), seq = 0;
for (var r = 2; r <= last; r++)
if (!sh.getRange(r, 31).getValue()) { seq++; sh.getRange(r, 31).setValue('CAND-' + ('0000' + seq).slice(-4)); }
PropertiesService.getScriptProperties().setProperty('CAND_SEQ', String(Math.max(seq, last - 1)));
return 'Candidate ID column ready.';
}
function repairSheets() {
var _g = guard_(arguments, 'Admin'); if (_g.error) return _g.error; // C-1: server-side auth
var ss = SpreadsheetApp.openById(SHEET_ID), feedback = null, tracker = null;
ss.getSheets().forEach(function (s) {
var lc = Math.max(1, s.getLastColumn());
var h = s.getRange(1, 1, 1, lc).getValues()[0].join('|');
if (h.indexOf('Your name (interviewer)') > -1) feedback = s;
if (h.indexOf('Date Received') > -1) tracker = s;
});
if (!tracker) return 'ERROR: could not find the real tracker (no "Date Received" header). Tell Claude.';
if (feedback && feedback.getName() === 'Tracker') feedback.setName('Form Responses');
tracker.setName('Tracker');
var cols = ['Req ID','Phone','Current Location','Outstation','Willing to Relocate','Work Mode Preference',
'Current Company','Current Title','Total Experience (yrs)','Skills','Highest Qualification','Notice Period',
'Current CTC','Expected CTC','Offer in Hand','Work Authorization','Reason for Change','HR Remarks',
'Gender','Candidate ID']; // M-1/L-1: canonical header is 'Gender' (was 'Gender (optional)' here but 'Gender' in setupAtsTabs — the drift broke chat update_details)
tracker.getRange(1, 12, 1, cols.length).setValues([cols]).setFontWeight('bold');
var cleaned = 0;
if (feedback && feedback.getLastColumn() >= 31) {
var data = feedback.getDataRange().getValues();
for (var r = data.length - 1; r >= 1; r--)
if ((data[r][30] || '').toString().indexOf('CAND-') === 0) { feedback.deleteRow(r + 1); cleaned++; }
}
return 'Repaired. "Tracker" now points to the real candidate sheet; cleaned ' + cleaned + ' misplaced rows from the feedback sheet.';
}
function createFeedbackForm() {
var _g = guard_(arguments, 'Admin'); if (_g.error) return _g.error; // C-1: server-side auth
var ss = SpreadsheetApp.openById(SHEET_ID);
var form = FormApp.create('AgentATS — Interview Feedback');
form.setDescription('Structured candidate feedback. Score on evidence, not impression.');
form.addTextItem().setTitle('Candidate name').setRequired(true);
form.addTextItem().setTitle('Candidate email').setRequired(true);
form.addTextItem().setTitle('Your name (interviewer)').setRequired(true);
form.addTextItem().setTitle('Role').setRequired(false);
var scale = ['1 - Weak', '2', '3 - Solid', '4', '5 - Outstanding'];
['Technical skill', 'Ownership', 'Communication & collaboration', 'Problem solving'].forEach(function (c) {
form.addMultipleChoiceItem().setTitle(c).setChoiceValues(scale).setRequired(true);
});
form.addParagraphTextItem().setTitle('Key strengths (with evidence)').setRequired(true);
form.addParagraphTextItem().setTitle('Concerns / risks').setRequired(false);
form.addMultipleChoiceItem().setTitle('Overall recommendation')
.setChoiceValues(['Strong Hire', 'Hire', 'No Hire', 'Strong No Hire']).setRequired(true);
form.setDestination(FormApp.DestinationType.SPREADSHEET, ss.getId());
Logger.log('SHARE THIS FORM: ' + form.getPublishedUrl());
return form.getPublishedUrl();
}
// ---------- INTAKE (Gmail -> Drive -> Tracker) : give this an hourly trigger ----------
function processApplications() {
var folder = DriveApp.getFolderById(FOLDER_ID);
var sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName('Tracker');
ensureHeaders(sheet);
var doneLabel = GmailApp.getUserLabelByName(DONE_LABEL) || GmailApp.createLabel(DONE_LABEL);
var query = 'label:' + SEARCH_LABEL.toLowerCase().replace(/ /g, '-') + ' -label:' + DONE_LABEL.toLowerCase() + ' has:attachment';
var anyAdded = false;
GmailApp.search(query, 0, 20).forEach(function (thread) {
var added = false;
thread.getMessages().forEach(function (msg) {
// M-4 FIX: one candidate row per MESSAGE (was: one row per attachment — a CV + cover
// letter, both .pdf, created 2-3 duplicate rows). The first document is the resume;
// extra files are saved to Drive and linked in the notes of the same row.
var from = msg.getFrom(), m = from.match(/^(.*?)\s*<(.+?)>/);
var candidate = m ? m[1].replace(/"/g, '').trim() : from;
var email = ((m ? m[2] : from) || '').toString().trim();
var mainRow = 0, extraFiles = [];
msg.getAttachments().forEach(function (att) {
if (!/\.(pdf|docx?|pptx?|rtf|odt|txt|pages)$/i.test(att.getName())) return;
var file = folder.createFile(att.copyBlob()).setName(att.getName());
if (mainRow) { extraFiles.push(att.getName() + ': ' + file.getUrl()); return; }
// M-4 FIX: dedupe by sender email — a reply on the same thread (or a re-send) updates
// the existing row's resume link instead of minting a duplicate candidate.
var existing = email ? findCandidateByAny_('', email) : null;
if (existing) {
var exRow = findRowById_(sheet, existing.candId);
if (exRow > 0) {
sheet.getRange(exRow, 6).setValue(file.getUrl()); // latest resume
var nc = sheet.getRange(exRow, 9), oldN = (nc.getValue() || '').toString();
nc.setValue(sanitizeCell_((oldN ? oldN + ' | ' : '') + 'New email ' + Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'dd MMM yyyy') + ': ' + (msg.getSubject() || '').slice(0, 120))); // C-2
mainRow = exRow; added = true; anyAdded = true;
return;
}
}
withScriptLock_(function () { // H-1: atomic append + ID mint (this runs on the hourly trigger)
sheet.appendRow(sanitizeRow_([new Date(), candidate, email, '', 'Email', file.getUrl(), 'New', '', msg.getSubject()])); // C-2: email intake
mainRow = sheet.getLastRow();
sheet.getRange(mainRow, 31).setValue(nextCandidateId_());
});
added = true; anyAdded = true;
});
if (mainRow && extraFiles.length) { // link the extra attachments on the same row
var xc = sheet.getRange(mainRow, 9), xo = (xc.getValue() || '').toString();
xc.setValue(sanitizeCell_((xo ? xo + ' | ' : '') + 'Also attached: ' + extraFiles.join('; '))); // C-2
}
});
if (added) thread.addLabel(doneLabel);
});
if (anyAdded) bustCache_(); // M-3: make email-intake candidates visible immediately
}
function ensureHeaders(sheet) {
if (!sheet.getRange(1, 1).getValue())
sheet.getRange(1, 1, 1, 9).setValues([['Date Received','Candidate Name','Email','Role','Source','Resume Link','Stage','Score','Notes']]);
}
// ---------- SCHEDULING (LEGACY — disabled by default; see M-9) ----------
// M-9 FIX: this old sheet-driven scheduler coexisted with scheduleInterview2 (the real system,
// which writes the Interviews tab + RSVP tracking). If its hourly trigger was still installed,
// candidates with stage "Interview" + a date in col 10 got DOUBLE-BOOKED events invisible to
// getInterviews/metrics. It now no-ops unless you explicitly set Script Property
// LEGACY_SCHEDULER=on. Use 📅 Schedule in the app (scheduleInterview2) instead.
function scheduleInterviews() {
if (PropertiesService.getScriptProperties().getProperty('LEGACY_SCHEDULER') !== 'on') {
Logger.log('scheduleInterviews: legacy scheduler disabled (M-9). Use the in-app scheduler; set LEGACY_SCHEDULER=on to re-enable.');
return;
}
var sheet = SpreadsheetApp.openById(SHEET_ID).getSheetByName('Tracker');
var data = sheet.getDataRange().getValues();
var cal = CalendarApp.getDefaultCalendar();
for (var r = 1; r < data.length; r++) {
var row = data[r], stage = (row[6] || '').toString().trim().toLowerCase(), when = row[9], email = (row[2] || '').toString().trim();
if (stage === 'interview' && when && email) {
var start = new Date(when);
if (isNaN(start.getTime())) { sheet.getRange(r + 1, 10).setNote('Bad date — use 2026-06-06 18:30'); continue; }
var end = new Date(start.getTime() + 45 * 60000);
cal.createEvent('Interview: ' + row[1] + (row[3] ? ' — ' + row[3] : ''), start, end,
{ guests: email, sendInvites: true, description: 'AgentATS interview.\nResume: ' + (row[5] || '') });
sheet.getRange(r + 1, 7).setValue('Interview Scheduled');
}
}
}
// ---------- DOSSIER EXPORT ----------
function exportDecidedCandidates() {
var _g = guard_(arguments, 'Admin'); if (_g.error) return _g.error; // C-1: server-side auth
var ss = SpreadsheetApp.openById(SHEET_ID), sheet = ss.getSheetByName('Tracker');
var data = sheet.getDataRange().getValues(), feedback = getFeedbackRows_(ss), folder = getOrCreateFolder_('AgentATS Records');
for (var r = 1; r < data.length; r++) {
var row = data[r], stage = (row[6] || '').toString().trim().toLowerCase();
if (stage === 'selected' || stage === 'rejected') {
var email = (row[2] || '').toString().trim().toLowerCase();
var fbs = feedback.filter(function (f) { return f.email.toLowerCase() === email; });
sheet.getRange(r + 1, 11).setValue(buildDossier_(row, fbs, folder));
}
}
}
function getFeedbackRows_(ss) {
var sh = null;
ss.getSheets().forEach(function (s) { if (s.getName().indexOf('Form Responses') === 0) sh = s; });
if (!sh) return [];
var d = sh.getDataRange().getValues(), h = d[0];
function I(n) { return h.indexOf(n); }
var rows = [];
for (var i = 1; i < d.length; i++) {
var x = d[i];
rows.push({ email: (x[I('Candidate email')] || '').toString(), interviewer: x[I('Your name (interviewer)')] || '',
rec: x[I('Overall recommendation')] || '', tech: x[I('Technical skill')] || '', own: x[I('Ownership')] || '',
comm: x[I('Communication & collaboration')] || '', prob: x[I('Problem solving')] || '',
strengths: x[I('Key strengths (with evidence)')] || '', concerns: x[I('Concerns / risks')] || '' });
}
return rows;
}
function getOrCreateFolder_(name) { var it = DriveApp.getFoldersByName(name); return it.hasNext() ? it.next() : DriveApp.createFolder(name); }
function buildDossier_(row, fbs, folder) {
var doc = DocumentApp.create('Dossier - ' + row[1]), b = doc.getBody();
b.appendParagraph('Candidate Dossier').setHeading(DocumentApp.ParagraphHeading.HEADING1);
b.appendParagraph(row[1] + ' (' + row[2] + ')');
b.appendParagraph('Role: ' + (row[3] || '') + ' Decision: ' + row[6] + ' Screening score: ' + (row[7] || 'n/a'));
b.appendParagraph('Resume: ' + (row[5] || ''));
b.appendParagraph('Interview Feedback').setHeading(DocumentApp.ParagraphHeading.HEADING2);
if (!fbs.length) b.appendParagraph('No feedback submitted yet.');
fbs.forEach(function (f) {
b.appendParagraph(f.interviewer + ' — ' + f.rec).setHeading(DocumentApp.ParagraphHeading.HEADING3);
b.appendParagraph('Scores — Tech: ' + f.tech + ', Ownership: ' + f.own + ', Comms: ' + f.comm + ', Problem solving: ' + f.prob);
b.appendParagraph('Strengths: ' + f.strengths);
if (f.concerns) b.appendParagraph('Concerns: ' + f.concerns);
});
doc.saveAndClose();
var file = DriveApp.getFileById(doc.getId());
var pdf = folder.createFile(file.getAs('application/pdf')).setName('Dossier - ' + row[1] + '.pdf');
file.setTrashed(true);
return pdf.getUrl();
}
// ---------- CHAT ASSISTANT ----------
function processMessage(message) {
try {
var u = currentUser_(arguments);
if (!u.role) return "🔒 Your account (" + u.email + ") doesn't have access yet. Ask your admin to add you to the Users tab.";
var parsed = JSON.parse(callGemini(buildIntentPrompt(message), true));
var intent = (parsed.intent === 'unknown' || !parsed.intent) ? 'help' : parsed.intent;
if (intent !== 'help' && !allowed_(u.role, intent))
return "🔒 Sorry " + u.name + " — your role (" + u.role + ") can't do that action.";
return routeIntent(parsed);
} catch (e) { return '⚠️ ' + e.message; }
}
function buildIntentPrompt(msg) {
var now = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), "yyyy-MM-dd'T'HH:mm:ss");
return 'You parse messages for a recruiting ATS. Current date-time is ' + now + '. Return ONLY JSON. Intents:\n' +
'- create_requisition (title, department, line_of_business, location, employment_type, level, hiring_manager, recruiter, openings, priority, salary_min, salary_max, notes)\n' +
'- add_candidate (name, email, role, notes)\n' +
'- update_details: tag candidate to a req or set HR fields. (name, email, fields). "fields" keys EXACTLY any of: "Req ID","Phone","Current Location","Outstation","Willing to Relocate","Work Mode Preference","Current Company","Current Title","Total Experience (yrs)","Skills","Highest Qualification","Notice Period","Current CTC","Expected CTC","Offer in Hand","Work Authorization","Reason for Change","HR Remarks","Gender". Only include mentioned keys.\n' + // M-1: header is 'Gender' — the old '"Gender (optional)"' key never matched, so chat could never set it
'- update_status (name, email, status). Map the phrase to ONE of: New, CV Screen Reject, Screened, Shortlist, Interview, Interview Scheduled, Interview Reject, Debrief, Debrief Reject, Selected, Offered, Offer Declined, Onboarded, Rejected, On Hold. Examples: "reject at screening"->CV Screen Reject; "reject after interview"->Interview Reject; "reject in debrief"->Debrief Reject; "select/hire"->Selected; "made an offer"->Offered; "declined the offer"->Offer Declined; "joined/onboarded"->Onboarded; "shortlist"->Shortlist; "on hold"->On Hold.\n' +
'- schedule_interview (name, email, datetime ISO 8601)\n' +
'- log_feedback (name, email, notes)\n' +
'- candidate_story (name, email): the full profile + interview-performance story for ONE candidate. Triggers: "tell me about X", "full story on X", "how did X do", "X profile", "brief on X".\n' +
'- query_status (query)\n- help\n- unknown\n' +
'JSON: {"intent":"","title":"","department":"","line_of_business":"","location":"","employment_type":"","level":"","hiring_manager":"","recruiter":"","openings":"","priority":"","salary_min":"","salary_max":"","name":"","email":"","role":"","status":"","datetime":"","notes":"","query":"","fields":{}}\n' +
'Message: ' + msg;
}
function routeIntent(o) {
switch (o.intent) {
case 'create_requisition': return createRequisition(o);
case 'add_candidate': return addCandidate(o);
case 'update_details': return updateDetails(o);
case 'update_status': return updateStatus(o);
case 'schedule_interview': return scheduleFromChat(o);
case 'log_feedback': return logFeedback(o);
case 'candidate_story': return candidateStory(o);
case 'query_status': return queryStatus(o);
case 'help': return helpText();
default: return helpText();
}
}
function trackerSheet_() { return SpreadsheetApp.openById(SHEET_ID).getSheetByName('Tracker'); }
function findRow_(sheet, name, email) {
var d = sheet.getDataRange().getValues();
for (var i = 1; i < d.length; i++) if (email && (d[i][2] || '').toString().toLowerCase() === email.toLowerCase()) return i + 1;
for (var j = 1; j < d.length; j++) if (name && (d[j][1] || '').toString().toLowerCase().indexOf(name.toLowerCase()) > -1) return j + 1;
return -1;
}
function addCandidate(o) {
var _g = guard_(arguments, 'Recruiter'); if (_g.error) return _g.error; // C-1: server-side auth
if (!o.name) return "I need at least a name to add a candidate.";
var sheet = trackerSheet_();
var id = withScriptLock_(function () { // H-1: atomic append + ID mint
sheet.appendRow(sanitizeRow_([new Date(), o.name, o.email || '', o.role || '', 'Manual', '', 'New', '', o.notes || '', '', ''])); // C-2
var cid = nextCandidateId_();
sheet.getRange(sheet.getLastRow(), 31).setValue(cid);
return cid;
});
try { sbSyncCandidate_(id); } catch (e) {}
bustCache_(); // M-3: chat-added candidates must show in the pipeline immediately
return "✅ Added " + o.name + (o.role ? " (" + o.role + ")" : "") + " as New — " + id + ".";
}
function updateStatus(o) {
var u = currentUser_(arguments), sheet = trackerSheet_(), row = findRow_(sheet, o.name, o.email);
if (row < 0) return "I couldn't find " + (o.name || o.email) + " in the tracker.";
var status = o.status || 'Updated', s = status.toLowerCase(), currentStage = (sheet.getRange(row, 7).getValue() || '').toString();
var isReject = s.indexOf('reject') > -1, isPositive = (s === 'selected' || s === 'offered');
if (u.role === 'HiringManager') {
if (!isReject) return "🔒 " + u.name + ", hiring managers can reject at the interview stage; other changes are the recruiter's.";
if (!isInterviewStage_(currentStage)) return "🔒 " + u.name + ", you can reject once the candidate is at the interview stage. They're currently \"" + currentStage + "\".";
} else if (u.role !== 'Admin' && u.role !== 'Recruiter') {
return "🔒 You don't have permission to change candidate status.";
}
sheet.getRange(row, 7).setValue(status);
recordStage_((sheet.getRange(row, 31).getValue() || '').toString(), sheet.getRange(row, 2).getValue(), status);
if (isPositive || isReject) notifyChat_((isReject ? '✖' : '✅') + ' ' + sheet.getRange(row, 2).getValue() + ' → ' + status);
var name = sheet.getRange(row, 2).getValue(), email = (sheet.getRange(row, 3).getValue() || '').toString().trim(), role = sheet.getRange(row, 4).getValue(), extra = '';
if ((isPositive || isReject) && email) {
try { var dr = draftDecisionEmail_(name, role, isReject ? 'rejected' : 'selected'); GmailApp.createDraft(email, dr.subject, dr.body);
extra = ' 📧 A ' + (isReject ? 'rejection' : 'next-steps') + ' email draft is waiting in Gmail.'; }
catch (e) { extra = ' (email draft failed: ' + e.message + ')'; }
}
return '✅ ' + name + ' is now ' + status + '.' + extra;
}
function logFeedback(o) {
var _g = guard_(arguments, 'HiringManager'); if (_g.error) return _g.error; // C-1: server-side auth
var sheet = trackerSheet_(), row = findRow_(sheet, o.name, o.email);
if (row < 0) return "I couldn't find " + (o.name || o.email) + ".";
var cell = sheet.getRange(row, 9);
cell.setValue(sanitizeCell_((cell.getValue() ? cell.getValue() + ' | ' : '') + (o.notes || ''))); // C-2
return "✅ Feedback logged for " + sheet.getRange(row, 2).getValue() + ".";
}
// ---------- INTERVIEWER FEEDBACK (AI-polished + email/Slack intake) ----------
function feedbackSheet_() {
var ss = SpreadsheetApp.openById(SHEET_ID), sh = ss.getSheetByName('Interview Feedback');
if (!sh) {
sh = ss.insertSheet('Interview Feedback');
sh.getRange(1, 1, 1, 12).setValues([['Timestamp', 'Candidate ID', 'Candidate Name', 'Candidate Email', 'Interviewer',
'Stage', 'Rating', 'Recommendation', 'Strengths', 'Concerns', 'Feedback', 'Source']]).setFontWeight('bold');
}
return sh;
}
function polishFeedback(raw, candId) {
var _g = guard_(arguments, 'Interviewer'); if (_g.error) return { error: _g.error }; // C-1: server-side auth
if (!raw || !raw.toString().trim()) return { error: 'Paste the raw feedback first.' };
var prompt = 'You are a careful copy-editor. Improve ONLY the grammar, spelling, punctuation, clarity and professional tone of the interview notes below. ' +
'Preserve every point, all the detail, the interviewer\'s own meaning, structure, ordering and length. ' +
'Do NOT summarize, shorten, condense, reorganize, add opinions, or invent anything. Keep it the same length and number of points as the original — if they wrote 15 lines, return ~15 lines. ' +
'Return ONLY the polished notes as plain text (no preamble, no JSON).\n\nINTERVIEWER NOTES:\n' + raw;
var out; try { out = callGemini(prompt, false); } catch (e) { return { error: e.message }; }
return { text: (out || '').toString().trim() };
}
function saveInterviewFeedback(o) {
var _g = guard_(arguments, 'Interviewer'); if (_g.error) return _g.error; // C-1: server-side auth
var u = currentUser_(arguments);
var sh = trackerSheet_(), row = findRowById_(sh, o.candId);
var name = o.name || '', email = o.email || '';
if (row > 0) { name = name || sh.getRange(row, 2).getValue(); email = email || sh.getRange(row, 3).getValue(); }
feedbackSheet_().appendRow(sanitizeRow_([new Date(), o.candId || '', name, email, o.interviewer || (u.name || u.email || ''), o.stage || '',
o.rating || '', o.recommendation || '',
(o.strengths instanceof Array ? o.strengths.join('; ') : (o.strengths || '')),
(o.concerns instanceof Array ? o.concerns.join('; ') : (o.concerns || '')),
o.feedback || '', o.source || 'In-app'])); // C-2: feedback text
if (o.candId) logAudit_(o.candId, 'Interview feedback added (' + (o.stage || '') + (o.recommendation ? ', ' + o.recommendation : '') + ', ' + (o.source || 'In-app') + ')');
return '✅ Feedback saved for ' + (name || o.candId) + '.';
}
function getInterviewFeedback_(candId) {
try {
var d = feedbackSheet_().getDataRange().getValues(), out = [], tz = Session.getScriptTimeZone();
for (var i = 1; i < d.length; i++) {
if ((d[i][1] || '').toString() !== candId) continue;
out.push({ when: (d[i][0] instanceof Date) ? Utilities.formatDate(d[i][0], tz, 'yyyy-MM-dd HH:mm') : String(d[i][0] || ''),
interviewer: String(d[i][4] || ''), stage: String(d[i][5] || ''), rating: String(d[i][6] || ''),
recommendation: String(d[i][7] || ''), strengths: String(d[i][8] || ''), concerns: String(d[i][9] || ''),
feedback: String(d[i][10] || ''), source: String(d[i][11] || '') });
}
return out.reverse();
} catch (e) { return []; }
}
function generateDebrief(candId) {
var _g = guard_(arguments, 'Interviewer'); if (_g.error) return { error: _g.error }; // C-1: server-side auth
var fbs = getInterviewFeedback_(candId);
if (!fbs.length) return { error: 'No interview feedback yet to debrief.' };
var name = ''; try { var sh = trackerSheet_(), row = findRowById_(sh, candId); if (row > 0) name = sh.getRange(row, 2).getValue(); } catch (e) {}
var lines = fbs.map(function (f) { return '- ' + (f.interviewer || '?') + ' [' + (f.stage || '') + '] rec=' + (f.recommendation || '-') + ' rating=' + (f.rating || '-') + ': ' + (f.feedback || '') + ' | strengths: ' + (f.strengths || '') + ' | concerns: ' + (f.concerns || ''); }).join('\n');
var prompt = 'You are facilitating a hiring debrief for ' + (name || 'a candidate') + '. Consolidate the interviewers\' feedback below into a balanced decision brief. ' +
'Return ONLY JSON: {"overall_recommendation":"","confidence":"","key_strengths":[],"key_concerns":[],"rationale":"","suggested_decision":""}. ' +
'overall_recommendation one of Strong Yes/Yes/Lean Yes/Lean No/No/Strong No; suggested_decision one of "Advance","Hold","Reject"; ' +
'rationale = 3-5 sentences citing the panel and surfacing any disagreement. Be balanced.\n\nFEEDBACK:\n' + lines;
var j = {}; try { j = JSON.parse(callGemini(prompt, true)); } catch (e) { j = { overall_recommendation: '', confidence: '', key_strengths: [], key_concerns: [], rationale: 'Could not auto-summarize — review the panel below.', suggested_decision: '' }; }
var nums = fbs.map(function (f) { return parseFloat(f.rating); }).filter(function (x) { return !isNaN(x); });
var avg = nums.length ? (nums.reduce(function (a, b) { return a + b; }, 0) / nums.length).toFixed(1) : '';
return { name: name, count: fbs.length, avgRating: avg, panel: fbs,
overall_recommendation: j.overall_recommendation || '', confidence: j.confidence || '',
key_strengths: j.key_strengths || [], key_concerns: j.key_concerns || [], rationale: j.rationale || '', suggested_decision: j.suggested_decision || '' };
}
function recordDebriefDecision(candId, decision) {
var u = currentUser_(arguments); if (u.role !== 'Admin' && u.role !== 'Recruiter' && u.role !== 'HiringManager') return '🔒 Not allowed.';
var sh = trackerSheet_(), row = findRowById_(sh, candId); if (row < 0) return 'Candidate not found.';
// H-2 FIX: write CANONICAL stages so analytics (funnel/reject counters) and archiving see
// debrief outcomes. Was 'Advanced (Debrief)' / 'Rejected (Debrief)' — strings that existed
// nowhere in getAnalytics or archiveOldCandidates. 'On Hold' is normalized by canonStage_.
var stage = decision === 'Advance' ? 'Selected' : (decision === 'Reject' ? 'Debrief Reject' : 'On Hold');
sh.getRange(row, 7).setValue(stage);
recordStage_(candId, sh.getRange(row, 2).getValue(), stage);
notifyChat_('🧑⚖️ Debrief: ' + sh.getRange(row, 2).getValue() + ' → ' + decision);
logAudit_(candId, 'Debrief decision: ' + decision + ' → ' + stage);
return '✅ Debrief decision recorded: ' + decision + '.';
}
function candidatePacketPdf(candId) {
var _g = guard_(arguments, 'Interviewer'); if (_g.error) return { error: _g.error }; // C-1: server-side auth
var v = getCandidateFull(candId); if (v.error) return { error: v.error };
var doc = DocumentApp.create('Candidate Packet — ' + (v.name || candId)), b = doc.getBody();
b.appendParagraph(v.name || 'Candidate').setHeading(DocumentApp.ParagraphHeading.TITLE);
if (v.resume) b.appendParagraph('Resume: ' + v.resume);
b.appendParagraph('Profile').setHeading(DocumentApp.ParagraphHeading.HEADING1);
// M-6 FIX: this packet goes to the interview panel / debrief. Compensation, gender and
// internal HR notes were being dumped in — contradicting the bias-mask feature and the
// sensitive-columns policy. Same deny list as SB_SENSITIVE_COLS_ (Supabase.gs).
var PACKET_HIDE = { 'current ctc': 1, 'expected ctc': 1, 'offer in hand': 1, 'gender': 1, 'hr remarks': 1, 'reason for change': 1, 'notes': 1 };
Object.keys(v.fields || {}).forEach(function (k) { if (!PACKET_HIDE[k.toString().trim().toLowerCase()]) b.appendParagraph(k + ': ' + v.fields[k]); });
b.appendParagraph('Interviews').setHeading(DocumentApp.ParagraphHeading.HEADING1);
(v.interviews || []).forEach(function (it) { b.appendParagraph('• ' + (it.stage || '') + ' — ' + (it.when || '') + ' — ' + (it.interviewers || '')); });
b.appendParagraph('Interview Feedback').setHeading(DocumentApp.ParagraphHeading.HEADING1);
(v.appFeedback || []).forEach(function (f) {
b.appendParagraph((f.stage || '') + ' · ' + (f.recommendation || '') + (f.rating ? ' · ' + f.rating + '/5' : '') + ' (' + (f.interviewer || '') + ')').setHeading(DocumentApp.ParagraphHeading.HEADING2);
if (f.feedback) b.appendParagraph(f.feedback);
if (f.strengths) b.appendParagraph('Strengths: ' + f.strengths);
if (f.concerns) b.appendParagraph('Concerns: ' + f.concerns);
});
doc.saveAndClose();
var pdf = DriveApp.getFileById(doc.getId()).getAs('application/pdf');
var file = DriveApp.getFolderById(FOLDER_ID).createFile(pdf).setName('Candidate Packet — ' + (v.name || candId) + '.pdf');
DriveApp.getFileById(doc.getId()).setTrashed(true);
return { url: file.getUrl() };
}
function findCandidateByAny_(name, email) {
var sh = trackerSheet_(), d = sh.getDataRange().getValues();
var em = (email || '').toString().trim().toLowerCase(), nm = (name || '').toString().trim().toLowerCase();
var hits = [];
for (var i = 1; i < d.length; i++) {
var e2 = (d[i][2] || '').toString().trim().toLowerCase(), n2 = (d[i][1] || '').toString().trim().toLowerCase();
var first = (d[i][31] || '').toString().trim().toLowerCase() || (n2.split(/\s+/)[0] || '');
var rec = { candId: (d[i][30] || '').toString(), name: String(d[i][1] || ''), email: String(d[i][2] || '') };
if (em && e2 && e2 === em) return rec; // email is exact → return immediately
if (nm && (n2 === nm || first === nm)) hits.push(rec); // full name or first name match
}
if (!hits.length) return null;
// M-5 FIX: with two "Asha"s in the tracker, a name-only lookup used to silently pick
// whichever row was higher — feedback could attach to the WRONG person. Flag ambiguity so
// callers (Slack, email import, webhook) can ask for a full name/email instead of guessing.
if (hits.length > 1) { hits[0].ambiguous = true; hits[0].count = hits.length; }
return hits[0];
}
function importFeedbackFromEmail() {
var u = currentUser_(arguments); if (u.role !== 'Admin' && u.role !== 'Recruiter') return '🔒 Only recruiters/admins.';
var label = 'Feedback', done = 'Feedback-Done', threads;
try { threads = GmailApp.search('label:' + label.toLowerCase() + ' -label:' + done.toLowerCase()); }
catch (e) { return '⚠️ Create a Gmail label "Feedback" and apply it to interviewer feedback emails first.'; }
if (!threads.length) return 'No new feedback emails (label "Feedback", not yet processed). Forward interviewer emails into that label.';
var doneLabel = GmailApp.getUserLabelByName(done) || GmailApp.createLabel(done);
var n = 0, skipped = 0, failed = 0; // H-4: track parse failures instead of hiding them
for (var t = 0; t < threads.length && t < 20; t++) {
var msgs = threads[t].getMessages(), m = msgs[msgs.length - 1];
var body = (m.getPlainBody() || '').slice(0, 6000), subj = m.getSubject() || '', from = m.getFrom() || '';
var j = null;
try {
j = JSON.parse(callGemini('Extract interview feedback from this email. Return ONLY JSON: ' +
'{"candidate_name":"","candidate_email":"","interviewer":"","stage":"","rating":"","recommendation":"","strengths":[],"concerns":[],"summary":""}. ' +
'recommendation one of Strong Yes/Yes/Lean Yes/Lean No/No/Strong No; rating 1-5; summary = formal 2-4 sentences. ' +
'Subject: ' + subj + '\nFrom: ' + from + '\nBody:\n' + body, true));
} catch (e) { j = null; }
// H-4 FIX: unparseable / unmatchable feedback is never destroyed any more. The raw email
// text is stored in the Interview Feedback sheet flagged for review, so a human can attach
// it to the right candidate later. (Previously the thread was labeled Done and the
// interviewer's feedback silently vanished.)
if (j) {
var match = findCandidateByAny_(j.candidate_name, j.candidate_email);
if (match && match.ambiguous) match = null; // M-5: never guess between same-named candidates — route to NEEDS REVIEW below
if (match) {
saveInterviewFeedback({ candId: match.candId, name: match.name, email: match.email, interviewer: j.interviewer || from,
stage: j.stage || '', rating: j.rating || '', recommendation: j.recommendation || '', strengths: j.strengths || [],
concerns: j.concerns || [], feedback: j.summary || '', source: 'Email' });
n++;
} else {
saveInterviewFeedback({ candId: '', name: (j.candidate_name || '(unknown candidate)') + ' — NEEDS REVIEW', email: j.candidate_email || '',
interviewer: j.interviewer || from, stage: j.stage || '', rating: j.rating || '', recommendation: j.recommendation || '',
strengths: j.strengths || [], concerns: j.concerns || [],
feedback: (j.summary || '') + '\n--- RAW EMAIL (no candidate match) ---\nSubject: ' + subj + '\nFrom: ' + from + '\n' + body.slice(0, 3000),
source: 'Email (needs review — unmatched)' });
skipped++;
}
} else {
saveInterviewFeedback({ candId: '', name: '(unparsed email) — NEEDS REVIEW', email: '',
interviewer: from, stage: '', rating: '', recommendation: '', strengths: [], concerns: [],
feedback: 'AI could not parse this feedback email — raw text preserved.\nSubject: ' + subj + '\nFrom: ' + from + '\n' + body.slice(0, 4000),
source: 'Email (needs review — unparsed)' });
failed++;
}
threads[t].addLabel(doneLabel); // safe to label now: the content is stored either way
}
return '✅ Imported ' + n + ' feedback email(s)' +
(skipped ? ' · ' + skipped + " couldn't be matched — raw text saved in Interview Feedback marked NEEDS REVIEW" : '') +
(failed ? ' · ' + failed + " couldn't be parsed — raw text saved in Interview Feedback marked NEEDS REVIEW" : '') + '.';
}
function slackMsg_(text) {
return ContentService.createTextOutput(JSON.stringify({ response_type: 'ephemeral', text: text })).setMimeType(ContentService.MimeType.JSON);
}
// Run this from the editor to prove the Slack feedback backend works WITHOUT Slack/deployment.
function testSlackFeedback() {
var _g = guard_(arguments, 'Admin'); if (_g.error) return _g.error; // C-1: server-side auth
var sh = trackerSheet_(), d = sh.getDataRange().getValues(), name = '';
for (var i = 1; i < d.length; i++) { if (d[i][1]) { name = d[i][1]; break; } }
if (!name) return 'No candidates in the tracker to test with.';
// H-3: doPost now requires WEBHOOK_SECRET — include it so the editor test still works.
var fake = { parameter: { command: '/ivfeedback', text: name + ' | TEST from editor: strong system design and communication, would advance', user_name: 'editor-test', response_url: '', secret: PropertiesService.getScriptProperties().getProperty('WEBHOOK_SECRET') || '' } };
var out = doPost(fake).getContent();
return 'Simulated a Slack /ivfeedback for "' + name + '". doPost returned: ' + out +
' → now open the "Interview Feedback" sheet; you should see a new row (source "Slack (raw)"). If yes, the backend works and the issue is the Slack URL/deploy config.';
}
function doPost(e) {
try {
var prm = (e && e.parameter) || {};
var p = {};
if (e && e.postData && e.postData.contents) { try { p = JSON.parse(e.postData.contents); } catch (_) { p = prm; } }
else p = prm;
// H-3 FIX: authenticate BEFORE any lookup or write. Set Script Property WEBHOOK_SECRET and
// put the same value on the webhook URL (…/exec?secret=YOUR_SECRET) or in the JSON body
// as {"secret":"…"}. Unauthenticated posts can no longer inject feedback or probe names.
if (!webhookSecretOk_(e, p)) {
return ContentService.createTextOutput(JSON.stringify({ ok: false, error: 'unauthorized — set Script Property WEBHOOK_SECRET and send it as ?secret=… on the webhook URL (or "secret" in the JSON body)' })).setMimeType(ContentService.MimeType.JSON);
}
// Webhook calls (Slack etc.) come from an integration, not a browser session. Give them a
// scoped service identity so the guarded feedback helpers keep working (secret verified above).
AUTH_USER_ = { email: 'webhook', name: (prm.user_name || 'Webhook'), role: 'Recruiter', webhook: true };
// --- C-5: CV intake bridge from the careers@ forwarder (CvForwarder.gs) ---
if (p.action === 'ingest_cv') {
return ContentService.createTextOutput(JSON.stringify(ingestCvFromEmail_(p))).setMimeType(ContentService.MimeType.JSON);
}
// --- Slack slash command (e.g. /feedback Asha R | strong system design...) ---
if (prm.command || prm.trigger_id) {
var cmd = prm.command || '/feedback', text = (prm.text || '').toString(), respUrl = prm.response_url || '';
var bar = text.indexOf('|');
var msg;
if (bar < 0) msg = 'Format: ' + cmd + ' Candidate Name | their feedback';
else {
var cname = text.slice(0, bar).trim(), fb = text.slice(bar + 1).trim();
if (!cname || !fb) msg = 'Format: ' + cmd + ' Candidate Name | their feedback';
else {
var mt = findCandidateByAny_(cname, '');
if (!mt) msg = 'Couldn\'t find a candidate named "' + cname + '". Check the name (first name works) and try again.';
else if (mt.ambiguous) msg = '⚠️ ' + mt.count + ' candidates match "' + cname + '" — please use the full name or their email so the feedback lands on the right person.'; // M-5
else {
saveInterviewFeedback({ candId: mt.candId, name: mt.name, email: mt.email, interviewer: prm.user_name || 'Slack', feedback: fb, source: 'Slack (raw)' });
msg = '✅ Feedback logged for ' + mt.name + '. Open AgentATS to polish the English.';
}
}
}
// Reply via response_url (30-min window) so slow cold-starts never show "operation_timeout".
if (respUrl) {
try { UrlFetchApp.fetch(respUrl, { method: 'post', contentType: 'application/json', payload: JSON.stringify({ response_type: 'ephemeral', text: msg }), muteHttpExceptions: true }); } catch (e) {}
return ContentService.createTextOutput('');
}
return slackMsg_(msg);
}
var raw = p.feedback || p.text || '';
var match = findCandidateByAny_(p.candidate || p.candidate_name || '', p.email || p.candidate_email || '');
if (!match) return ContentService.createTextOutput(JSON.stringify({ ok: false, error: 'Candidate not found' })).setMimeType(ContentService.MimeType.JSON);
if (match.ambiguous) return ContentService.createTextOutput(JSON.stringify({ ok: false, error: match.count + ' candidates match that name — include the candidate email to disambiguate' })).setMimeType(ContentService.MimeType.JSON); // M-5
// H-3 FIX: polishFeedback returns { text: … } — the old code read pol.summary/.rating/etc.
// (fields that never existed), so the paid polish call was thrown away and raw text saved.
// Now the polished text is actually stored; structured fields come from the payload itself.
var pol = null; try { pol = polishFeedback(raw, match.candId); } catch (_) { pol = null; }
var polished = (pol && pol.text) ? pol.text : raw;
saveInterviewFeedback({ candId: match.candId, name: match.name, email: match.email, interviewer: p.interviewer || p.user_name || 'Slack',
stage: p.stage || '', rating: p.rating || '', recommendation: p.recommendation || '',
strengths: p.strengths || [], concerns: p.concerns || [], feedback: polished, source: p.source || 'Slack' });
return ContentService.createTextOutput(JSON.stringify({ ok: true, candidate: match.name })).setMimeType(ContentService.MimeType.JSON);
} catch (err) {
return ContentService.createTextOutput(JSON.stringify({ ok: false, error: err.message })).setMimeType(ContentService.MimeType.JSON);
}
}
// C-5 FIX: inbound CV email intake now lands in THIS app's Tracker (was: POSTed to the new
// brain's /ingest/cv and never reached this sheet). Called from doPost (action:'ingest_cv')
// by CvForwarder.gs, which runs on the careers@ mailbox and authenticates with WEBHOOK_SECRET.
// Design rule: the CV is NEVER lost — Drive save failure returns ok:false (the forwarder
// retries and does NOT label the thread); an AI parse failure is non-fatal (the candidate is
// still created from the sender's email with the raw file attached, flagged for re-parse).
function ingestCvFromEmail_(p) {
try {
if (!p.attachment_base64) return { ok: false, error: 'no attachment' };
var mime = (p.attachment_mime || 'application/pdf').toString();
var fname = (p.attachment_name || 'resume').toString();
var fromEmail = (p.from_email || '').toString().trim().toLowerCase();
var url = '';
try { url = DriveApp.getFolderById(FOLDER_ID).createFile(Utilities.newBlob(Utilities.base64Decode(p.attachment_base64), mime, fname)).getUrl(); }
catch (e) { return { ok: false, error: 'Drive save failed: ' + e.message }; }
var parsed = {}; try { parsed = JSON.parse(parseDocument_(p.attachment_base64, mime)) || {}; } catch (e) { parsed = {}; }
if (parsed.doc_type && parsed.doc_type !== 'resume') return { ok: true, skipped: true, reason: 'not a CV (' + parsed.doc_type + ') — file kept in Drive' };
// SDET quality gate: only create a candidate when the parser actually extracted a
// résumé — a real candidate NAME plus at least one résumé detail. We NO LONGER fall
// back to the email subject as a name (that created junk candidates like
// "Hiring Pipeline & Status - Aug 24, 2026"). Non-résumés stay in Drive, no row.
var pname = (parsed.name || '').toString().trim();
var titleLike = /\b(19|20)\d\d\b|pipeline|status|report|invoice|newsletter|agenda|minutes|proposal|dashboard|statement|summary|deck|presentation/i.test(pname);
var hasDetail = !!(parsed.email || parsed.phone || parsed.total_experience || parsed.skills || parsed.current_company || parsed.current_title || parsed.linkedin);
if (!pname || titleLike || !hasDetail) return { ok: true, skipped: true, reason: 'not a résumé (no candidate details detected) — file kept in Drive' };
var candName = pname;
var email = (parsed.email || fromEmail || '').toString();
// Repeat sender → attach the new CV to the existing row instead of duplicating (mirrors H-8).
var existing = email ? findCandidateByAny_('', email) : null;
if (existing) {
var shx = trackerSheet_(), rowx = findRowById_(shx, existing.candId);
if (rowx > 0) {
shx.getRange(rowx, 6).setValue(url);
var nc = shx.getRange(rowx, 9), oldN = (nc.getValue() || '').toString();
nc.setValue(sanitizeCell_((oldN ? oldN + ' | ' : '') + 'New CV emailed ' + Utilities.formatDate(new Date(), Session.getScriptTimeZone(), 'dd MMM yyyy'))); // C-2
try { logAudit_(existing.candId, 'New CV received by email — resume link updated'); } catch (e) {}
bustCache_();
return { ok: true, candId: existing.candId, updated: true };
}
}
var sheet = trackerSheet_();
var r = withScriptLock_(function () { // H-1: atomic append + ID mint
sheet.appendRow(sanitizeRow_([new Date(), candName, email, '', 'Email (careers@)', url, 'New', '', (p.subject || '').toString().slice(0, 200), '', ''])); // C-2: email intake
var rr = sheet.getLastRow(); sheet.getRange(rr, 31).setValue(nextCandidateId_()); return rr;
});
function setP(col, val) { if (val) sheet.getRange(r, col).setValue(sanitizeCell_(val)); } // C-2
setP(13, parsed.phone); setP(14, parsed.current_location); setP(18, parsed.current_company); setP(19, parsed.current_title);
setP(20, parsed.total_experience); setP(21, parsed.skills); setP(22, parsed.highest_qualification);
setP(32, parsed.first_name); setP(33, parsed.middle_name); setP(34, parsed.last_name); setP(35, parsed.linkedin);
setP(37, parsed.highlights); setP(38, parsed.github);
var cid = (sheet.getRange(r, 31).getValue() || '').toString();
try { recordStage_(cid, candName, 'New'); } catch (e) {}
try { logAudit_(cid, 'Candidate created (Email (careers@) CV intake)' + ((!parsed.name && !parsed.email) ? ' · ⚠️ CV parse failed — raw file saved, run Re-parse' : '')); } catch (e) {}
bustCache_();
try { if (PropertiesService.getScriptProperties().getProperty('ORG_ACK') !== '0' && email) sendAck_(email, parsed.name || ''); } catch (e) {}
notifyChat_('📥 New CV by email: ' + candName + ' (careers@ inbox)');
return { ok: true, candId: cid };
} catch (err) { return { ok: false, error: err.message }; }
}
function queryStatus(o) {
var _g = guard_(arguments, 'Interviewer'); if (_g.error) return _g.error; // C-1: server-side auth
// M-8 FIX: only Recruiters/Admins get compensation in the chat context. Hiring managers /
// interviewers could previously ask the chat for every candidate's CTC even though the UI
// scopes their view — the whole pipeline (incl. comp) was shipped to Gemini for any question.
var seeComp = roleRank_(_g.role) >= roleRank_('Recruiter');
var d = trackerSheet_().getDataRange().getValues(), rows = [], counts = {};
for (var i = 1; i < d.length; i++) {
if (!d[i][1]) continue;
var stage = (d[i][6] || 'New').toString();
counts[stage] = (counts[stage] || 0) + 1;
rows.push([d[i][1], d[i][3] || '-', stage, d[i][7] || '-', d[i][11] || '-',
d[i][17] || '-', d[i][19] || '-', d[i][20] || '-',
seeComp ? (d[i][23] || '-') : '(restricted)', seeComp ? (d[i][24] || '-') : '(restricted)', // M-8
d[i][22] || '-', d[i][13] || '-'].join(' | '));
}
var summary = Object.keys(counts).map(function (k) { return k + ': ' + counts[k]; }).join(', ') || 'none';
var data = 'STAGE COUNTS: ' + summary + '\nTOTAL CANDIDATES: ' + rows.length +
'\n\nCANDIDATES (Name | Role | Stage | Score | Req | Current Company | Experience | Skills | Current CTC | Expected CTC | Notice | Location):\n' +
(rows.join('\n') || '(no candidates yet)');
return callGemini('You are a recruiting analytics assistant. Use ONLY the data below. Give specific numbers and names. ' +
'For "compare", lay the candidates side by side on the relevant fields and end with a recommendation. ' +
'For pipeline / "how many", report the per-stage counts. Be concise and friendly.\n\n' +
data + '\n\nQUESTION: ' + (o.query || 'summarize the pipeline'), false);
}
function candidateStory(o) {
var _g = guard_(arguments, 'Interviewer'); if (_g.error) return _g.error; // C-1: server-side auth
var ss = SpreadsheetApp.openById(SHEET_ID), tr = ss.getSheetByName('Tracker');
var d = tr.getDataRange().getValues(), H = d[0], ri = -1;
for (var i = 1; i < d.length; i++) {
var em = (d[i][2] || '').toString().toLowerCase(), nm = (d[i][1] || '').toString().toLowerCase();
if ((o.email && em === o.email.toLowerCase()) || (o.name && nm.indexOf(o.name.toLowerCase()) > -1)) { ri = i; break; }
}
if (ri < 0) return "I couldn't find " + (o.name || o.email) + " in the pipeline.";
var row = d[ri], email = (row[2] || '').toString().toLowerCase(), profile = {};
// M-8 FIX: comp + HR-internal fields only reach the AI context for Recruiters/Admins, and
// Gender never does when the bias guard is on (mirrors the sensitive-columns policy).
var _seeComp = roleRank_(_g.role) >= roleRank_('Recruiter');
var _biasOn = false; try { _biasOn = PropertiesService.getScriptProperties().getProperty('BIAS_MASK') === '1'; } catch (e) {}
var STORY_HIDE = { 'current ctc': !_seeComp, 'expected ctc': !_seeComp, 'offer in hand': !_seeComp, 'hr remarks': !_seeComp, 'reason for change': !_seeComp, 'gender': (!_seeComp || _biasOn) };
H.forEach(function (h, i) { if (h && row[i] !== '' && row[i] != null && !STORY_HIDE[h.toString().trim().toLowerCase()]) profile[h] = row[i]; });
var req = '(not tagged to a requisition)', reqId = row[11];
if (reqId) {
var rq = ss.getSheetByName('Requisitions').getDataRange().getValues();
for (var j = 1; j < rq.length; j++)
if ((rq[j][0] || '').toString() === reqId.toString()) { req = rq[j][1] + ' (' + rq[j][3] + ', ' + rq[j][6] + ', HM ' + rq[j][7] + ')'; break; }
}
var fbs = [];
ss.getSheets().forEach(function (s) {
if (s.getName().indexOf('Form Responses') !== 0) return;
var fd = s.getDataRange().getValues(), fh = fd[0], ec = -1;
for (var c = 0; c < fh.length; c++) if ((fh[c] || '').toString().toLowerCase().indexOf('candidate email') > -1) ec = c;
for (var k = 1; k < fd.length; k++) {
if (ec > -1 && (fd[k][ec] || '').toString().toLowerCase() === email) {
var fo = {}; fh.forEach(function (hh, ci) { if (hh && fd[k][ci] !== '') fo[hh] = fd[k][ci]; }); fbs.push(fo);
}
}
});
var ctx = 'CANDIDATE PROFILE:\n' + JSON.stringify(profile) + '\n\nREQUISITION: ' + req +
'\n\nINTERVIEW FEEDBACK (' + fbs.length + ' submitted):\n' + (fbs.length ? JSON.stringify(fbs) : 'none yet');
return callGemini('You are a senior recruiter writing a candidate brief for a hiring decision. Using ONLY the data below, ' +
'write a clear, structured story: (1) Snapshot — role, experience, current company, location, comp/notice. ' +
'(2) Fit for the requisition. (3) Interview performance — synthesise feedback across interviewers: strengths, concerns, ' +
'and any disagreement between them. (4) A clear recommendation. Be specific and evidence-based; ignore gender and other ' +
'non-job factors. If no feedback yet, say interviews are still pending.\n\n' + ctx, false);
}
function scheduleFromChat(o) {
var _g = guard_(arguments, 'Recruiter'); if (_g.error) return _g.error; // C-1: server-side auth
var sheet = trackerSheet_(), row = findRow_(sheet, o.name, o.email);
if (row < 0) return "I couldn't find " + (o.name || o.email) + " in the tracker.";
if (!o.datetime) return "When should I schedule it? e.g. \"Schedule " + (o.name || 'them') + " tomorrow 3pm\".";
var start = new Date(o.datetime);
if (isNaN(start.getTime())) return "I couldn't read that time — try \"June 10 3pm\".";
var end = new Date(start.getTime() + 45 * 60000);
var name = sheet.getRange(row, 2).getValue(), email = (sheet.getRange(row, 3).getValue() || '').toString().trim(), role = sheet.getRange(row, 4).getValue();
var ev = Calendar.Events.insert({
summary: 'Interview: ' + name + (role ? ' — ' + role : ''), description: 'AgentATS interview.',
start: { dateTime: start.toISOString() }, end: { dateTime: end.toISOString() },
attendees: email ? [{ email: email }] : [],
conferenceData: { createRequest: { requestId: Utilities.getUuid(), conferenceSolutionKey: { type: 'hangoutsMeet' } } }
}, 'primary', { conferenceDataVersion: 1, sendUpdates: 'all' });
var meet = ev.hangoutLink || '';
sheet.getRange(row, 7).setValue('Interview Scheduled');
sheet.getRange(row, 10).setValue(Utilities.formatDate(start, Session.getScriptTimeZone(), 'yyyy-MM-dd HH:mm'));
if (meet) sheet.getRange(row, 9).setValue(((sheet.getRange(row, 9).getValue() || '') + ' Meet: ' + meet).trim());
return '✅ Interview with ' + name + ' booked for ' + start.toLocaleString() + (meet ? '. Google Meet: ' + meet : '') + '. Invite sent to ' + (email || '(no email)') + '.';
}
function helpText() {
return "Here's what I can do — just say it naturally:\n" +
"• Create a requisition — or use the + Requisition button\n" +