-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathCode.gs
More file actions
3228 lines (2911 loc) · 142 KB
/
Code.gs
File metadata and controls
3228 lines (2911 loc) · 142 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
/* --------------------------------------------------------------------------
* 設定 & 定数定義
* -------------------------------------------------------------------------- */
const APP_NAME = '45th NUTFES 実行委員マスタ';
const APP_HEADER_COLOR = '#1a237e'; // 紺色
// SPREADSHEET_IDは関数実行時に取得(定数化によるタイミング問題を回避)
const SHEET_USERS = 'Users';
const SHEET_LOGS = 'Logs';
const SHEET_GRADE = 'grade';
const SHEET_ORG = 'org';
const SHEET_DEPT = 'dept';
const SHEET_ROLE = 'role';
const SHEET_FIELD = 'field';
const SHEET_FORMS = 'Forms';
const SHEET_TOKENS = 'Tokens'; // 新規テーブル
const SESSION_DURATION_DAYS = 3; // セッション有効期限(日)
// Usersシートの列定義 (0-based index)
const COL = {
NAME_JP: 0,
NAME_EN: 1,
STUDENT_ID: 2,
GRADE: 3,
FIELD: 4,
EMAIL: 5,
PHONE: 6,
BIRTHDAY: 7,
ALMA_MATER: 8,
RETIRED: 9,
CONTINUE_NEXT: 10,
ADMIN: 11,
CAR_OWNER: 12,
AFFILIATION_START: 13,
ORG_START: 13
};
const AFFILIATION_SLOTS = 10;
// Tokensシートの列定義 (新規)
const COL_TOKENS = {
SESSION_ID: 0,
EMAIL: 1,
SLACK_TOKEN: 2, // User Token (xoxp-...)
CREATED_AT: 3
};
const HEADER_USERS = [
'氏名', 'Name', '学籍番号', '学年', '分野', 'メールアドレス', '電話番号', '生年月日', '出身校',
'退局', '次年度継続', 'Admin', '車所有',
'所属部門1', '役職1',
'所属部門2', '役職2',
'所属部門3', '役職3',
'所属部門4', '役職4',
'所属部門5', '役職5',
'所属部門6', '役職6',
'所属部門7', '役職7',
'所属部門8', '役職8',
'所属部門9', '役職9',
'所属部門10', '役職10'
];
const HEADER_TOKENS = ['Session ID', 'Email', 'Slack Token', 'Created At'];
const HEADER_GRADE = ['pid', 'grade', 'count'];
const HEADER_ORG = ['pid', 'org', 'gen', 'status', 'not_main_org'];
const HEADER_DEPT = ['pid', 'dept', 'org', 'status', 'not_main_dept'];
const HEADER_ROLE = ['pid', 'role', 'gen', 'not_main_role'];
const HEADER_FIELD = ['pid', 'field'];
const HEADER_FORMS = ['アンケートシート', 'フォームURL', 'フォームタイトル', '担当部門', '収集中', 'スコアの名称', 'スコアの単位'];
const HEADER_LOGS = ['Time', 'Sender', 'Recipient', 'Status', 'Details'];
function _affiliationDeptCol(slotIndex) {
return COL.AFFILIATION_START + slotIndex * 2;
}
function _affiliationRoleCol(slotIndex) {
return COL.AFFILIATION_START + slotIndex * 2 + 1;
}
function getScriptProperty(key) {
return PropertiesService.getScriptProperties().getProperty(key);
}
function _normalizeSlackCredential(value) {
const s = String(value || '').trim();
if (!s) return '';
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
return s.slice(1, -1).trim();
}
return s;
}
/**
* Debug helper: probe Forms sheet rows and report whether target spreadsheet and form can be opened.
* Returns array of { rowIndex, rawA, rawB, spreadsheetId, ssOpen:bool, ssError, formOpen:bool, formError }
*/
function debugProbeForms() {
const out = [];
try {
const ssMain = SpreadsheetApp.openById(getSpreadsheetId());
const fs = ssMain.getSheetByName(SHEET_FORMS);
if (!fs) return { success: false, message: 'Forms シートが存在しません' };
const lr = fs.getLastRow();
if (lr < 2) return { success: true, probes: [] };
const cols = Math.max(3, HEADER_FORMS.length);
const rows = fs.getRange(2,1,Math.max(0, lr-1), cols).getValues();
for (let i = 0; i < rows.length; i++) {
const a = String(rows[i][0] || '').trim();
const b = String(rows[i][1] || '').trim();
const entry = { rowIndex: i+2, rawA: a, rawB: b, spreadsheetId: null, ssOpen: false, ssError: null, formOpen: false, formError: null };
try {
const sid = _extractSpreadsheetId(a) || a || null;
entry.spreadsheetId = sid;
if (sid) {
try { const targetSs = SpreadsheetApp.openById(sid); entry.ssOpen = true; }
catch (e) { entry.ssOpen = false; entry.ssError = String(e); }
} else {
entry.ssError = 'スプレッドシートIDが見つかりません';
}
if (b) {
try {
let formObj = null;
try { formObj = FormApp.openByUrl(b); } catch (ee) {
const fid = _extractFormId(b);
if (fid) formObj = FormApp.openById(fid);
}
if (formObj) entry.formOpen = true; else entry.formError = 'FormAppで開けませんでした';
} catch (e) { entry.formOpen = false; entry.formError = String(e); }
}
} catch (e) {
entry.ssError = entry.ssError || String(e);
}
out.push(entry);
}
return { success: true, probes: out };
} catch (e) {
return { success: false, message: String(e) };
}
}
// Stores in Script Properties (JSON maps)
// sessions: sessionId -> { email, created }
// tokensByEmail: email -> { slackToken, created }
const SESSIONS_PROP_KEY = 'SESSIONS_STORE';
const TOKENS_BY_EMAIL_PROP_KEY = 'TOKENS_BY_EMAIL_STORE';
const TOKENS_PROP_MAX = 200000; // safe threshold (characters). 古いものから削除して収める
function _loadStore(key) {
const raw = PropertiesService.getScriptProperties().getProperty(key);
if (!raw) return {};
try { return JSON.parse(raw); } catch (e) { return {}; }
}
function _saveStore(key, obj) {
let serialized = JSON.stringify(obj);
if (serialized.length <= TOKENS_PROP_MAX) {
PropertiesService.getScriptProperties().setProperty(key, serialized);
return;
}
// remove oldest entries until it fits
const entries = Object.keys(obj).map(k => ({ k, created: obj[k] && obj[k].created ? obj[k].created : 0 }));
entries.sort((a, b) => a.created - b.created);
for (let i = 0; i < entries.length && serialized.length > TOKENS_PROP_MAX; i++) {
delete obj[entries[i].k];
serialized = JSON.stringify(obj);
}
PropertiesService.getScriptProperties().setProperty(key, serialized);
}
function getSpreadsheetId() {
const id = getScriptProperty('SPREADSHEET_ID');
if (!id) throw new Error('SPREADSHEET_IDがScript Propertiesに設定されていません');
return id;
}
function _loadMasterMaps(ss) {
const readMap = (sheetName, keyIndex, valueIndex, statusIndex, notMainIndex) => {
const out = { byPid: {}, byName: {}, rows: [], statusByPid: {}, notMainByPid: {} };
const sh = ss.getSheetByName(sheetName);
if (!sh || sh.getLastRow() < 2) return out;
const statusIdx = (typeof statusIndex === 'number') ? statusIndex : -1;
const notMainIdx = (typeof notMainIndex === 'number') ? notMainIndex : -1;
const maxIndex = Math.max(valueIndex, statusIdx, notMainIdx);
const rows = sh.getRange(2, 1, sh.getLastRow() - 1, Math.max(sh.getLastColumn(), maxIndex + 1)).getValues();
rows.forEach(r => {
const pid = String(r[keyIndex] || '').trim();
const name = String(r[valueIndex] || '').trim();
if (!pid || !name) return;
out.byPid[pid] = name;
out.byName[name] = pid;
out.statusByPid[pid] = statusIdx >= 0 ? String(r[statusIdx] || '').trim() : '';
out.notMainByPid[pid] = notMainIdx >= 0 ? (r[notMainIdx] === true || String(r[notMainIdx] || '').toLowerCase() === 'true') : false;
out.rows.push(r);
});
return out;
};
const grade = readMap(SHEET_GRADE, 0, 1, 2, 2);
const org = readMap(SHEET_ORG, 0, 1, 3, 4);
const dept = readMap(SHEET_DEPT, 0, 1, 3, 4);
const role = readMap(SHEET_ROLE, 0, 1, null, 3);
const field = readMap(SHEET_FIELD, 0, 1, 1, 1);
const deptToOrgPid = {};
const deptByOrgAndName = {}; // {orgPid: {deptName: deptPid}} to handle multi-org dept duplicates
dept.rows.forEach(r => {
const deptPid = String(r[0] || '').trim();
const orgPid = String(r[2] || '').trim();
if (deptPid && orgPid) {
deptToOrgPid[deptPid] = orgPid;
if (!deptByOrgAndName[orgPid]) deptByOrgAndName[orgPid] = {};
const deptName = String(r[1] || '').trim();
if (deptName) deptByOrgAndName[orgPid][deptName] = deptPid;
}
});
dept.deptByOrgAndName = deptByOrgAndName;
return { grade, org, dept, role, field, deptToOrgPid };
}
function _toPidOrEmpty(value, byName) {
const s = String(value || '').trim();
if (!s) return '';
if (byName && byName[s]) return byName[s];
return s;
}
function _toLabelOrEmpty(value, byPid) {
const s = String(value || '').trim();
if (!s) return '';
return byPid && byPid[s] ? byPid[s] : s;
}
function _isMasterActive(statusVal) {
const s = String(statusVal || '').trim();
return s === '' || s === '0' || s.toLowerCase() === 'false';
}
function _assertActiveSelection(label, pid, master, kind) {
const key = String(pid || '').trim();
if (!key) return;
if (!master || !master.byPid || !master.byPid[key]) {
throw new Error(label + 'が見つかりません');
}
if (!master.statusByPid || !_isMasterActive(master.statusByPid[key])) {
throw new Error('無効な' + kind + 'は選択できません');
}
}
function _buildAffiliationCode(orgLabel, deptLabel, masters) {
const orgPid = _toPidOrEmpty(orgLabel, masters && masters.org ? masters.org.byName : null);
const deptPid = _toPidOrEmpty(deptLabel, masters && masters.dept ? masters.dept.byName : null);
if (deptPid) return deptPid;
if (orgPid) return orgPid;
return '';
}
function _parseAffiliationCode(code, masters) {
const raw = String(code || '').trim();
if (!raw) return { code: '', orgPid: '', org: '', deptPid: '', dept: '' };
// dept code (4桁想定) が優先
if (masters && masters.dept && masters.dept.byPid && masters.dept.byPid[raw]) {
const deptPid = raw;
const orgPid = (masters.deptToOrgPid && masters.deptToOrgPid[deptPid]) ? masters.deptToOrgPid[deptPid] : '';
return {
code: raw,
orgPid: orgPid,
org: _toLabelOrEmpty(orgPid, masters.org.byPid),
deptPid: deptPid,
dept: _toLabelOrEmpty(deptPid, masters.dept.byPid)
};
}
if (masters && masters.dept && masters.dept.byName && masters.dept.byName[raw]) {
const deptPid = masters.dept.byName[raw];
const orgPid = (masters.deptToOrgPid && masters.deptToOrgPid[deptPid]) ? masters.deptToOrgPid[deptPid] : '';
return {
code: deptPid,
orgPid: orgPid,
org: _toLabelOrEmpty(orgPid, masters.org.byPid),
deptPid: deptPid,
dept: _toLabelOrEmpty(deptPid, masters.dept.byPid)
};
}
// org code (2桁想定)
if (masters && masters.org && masters.org.byPid && masters.org.byPid[raw]) {
return {
code: raw,
orgPid: raw,
org: _toLabelOrEmpty(raw, masters.org.byPid),
deptPid: '',
dept: ''
};
}
if (masters && masters.org && masters.org.byName && masters.org.byName[raw]) {
const orgPid = masters.org.byName[raw];
return {
code: orgPid,
orgPid: orgPid,
org: _toLabelOrEmpty(orgPid, masters.org.byPid),
deptPid: '',
dept: ''
};
}
// 既存データ互換: 不明値は文字列をそのままdept側に残す
return { code: raw, orgPid: '', org: '', deptPid: raw, dept: raw };
}
function _buildAffiliationStorageCode(orgValue, deptValue, masters) {
const orgPid = _toPidOrEmpty(orgValue, masters && masters.org ? masters.org.byName : null);
const deptPid = _toPidOrEmpty(deptValue, masters && masters.dept ? masters.dept.byName : null);
if (deptPid) return deptPid;
if (orgPid) return orgPid;
return '';
}
/* --------------------------------------------------------------------------
* 0. 初期セットアップ (マイグレーション & トリガー設定)
* -------------------------------------------------------------------------- */
function setupSpreadsheet() {
const ss = SpreadsheetApp.openById(getSpreadsheetId());
const ensureSheetWithHeader = (name, header) => {
let sh = ss.getSheetByName(name);
if (!sh) sh = ss.insertSheet(name);
if (sh.getLastRow() === 0) sh.getRange(1, 1, 1, header.length).setValues([header]);
else sh.getRange(1, 1, 1, header.length).setValues([header]);
return sh;
};
const sheetGrade = ensureSheetWithHeader(SHEET_GRADE, HEADER_GRADE);
const sheetOrg = ensureSheetWithHeader(SHEET_ORG, HEADER_ORG);
const sheetDept = ensureSheetWithHeader(SHEET_DEPT, HEADER_DEPT);
const sheetRole = ensureSheetWithHeader(SHEET_ROLE, HEADER_ROLE);
const sheetField = ensureSheetWithHeader(SHEET_FIELD, HEADER_FIELD);
_applyCheckboxColumn(sheetOrg, 5);
_applyCheckboxColumn(sheetDept, 5);
_applyCheckboxColumn(sheetRole, 4);
// 1b. Formsシート (アンケート情報を専用シートに移行)
let sheetForms = ss.getSheetByName(SHEET_FORMS);
if (!sheetForms) sheetForms = ss.insertSheet(SHEET_FORMS);
// Remove legacy '担当局' column in Forms if present
try {
const hdrCols = Math.max(1, sheetForms.getLastColumn());
const hdrRow = sheetForms.getRange(1, 1, 1, hdrCols).getValues()[0] || [];
const bureauIdx = hdrRow.findIndex(h => String(h || '').trim() === '担当局');
if (bureauIdx >= 0) {
sheetForms.deleteColumn(bureauIdx + 1);
}
} catch (e) {}
// Ensure header row contains our expected HEADER_FORMS columns. Preserve existing non-empty headers when possible.
try {
const existingCols = Math.max(1, sheetForms.getLastColumn());
const cols = Math.max(existingCols, HEADER_FORMS.length);
const cur = sheetForms.getRange(1, 1, 1, cols).getValues()[0] || [];
const newHeaders = [];
for (let i = 0; i < HEADER_FORMS.length; i++) {
// prefer existing header if non-empty, else use standard
newHeaders[i] = (cur[i] && String(cur[i]).trim()) ? String(cur[i]) : HEADER_FORMS[i];
}
sheetForms.getRange(1, 1, 1, newHeaders.length).setValues([newHeaders]);
// Ensure '収集中' column is formatted as checkboxes and normalize existing values
const collectingIdx = newHeaders.findIndex(h => String(h || '').trim() === '収集中');
if (collectingIdx >= 0) {
try {
const startRow = 2;
const lastRow = Math.max(sheetForms.getLastRow(), startRow);
const numRows = Math.max(1, lastRow - 1);
// convert existing TRUE/FALSE strings to booleans
try {
const range = sheetForms.getRange(startRow, collectingIdx + 1, numRows, 1);
const vals = range.getValues();
const norm = vals.map(r => {
const v = r[0];
if (v === true || v === 'TRUE' || String(v).toLowerCase() === 'true') return [true];
if (v === false || v === 'FALSE' || String(v).toLowerCase() === 'false') return [false];
return [false];
});
range.setValues(norm);
} catch (e) {
// ignore conversion errors
}
// insert checkbox formatting
try { sheetForms.getRange(startRow, collectingIdx + 1, numRows, 1).insertCheckboxes(); }
catch (e) {
// fallback: data validation to TRUE/FALSE
try { sheetForms.getRange(startRow, collectingIdx + 1, numRows, 1).setDataValidation(SpreadsheetApp.newDataValidation().requireValueInList(['TRUE','FALSE']).setAllowInvalid(true).build()); } catch (ee) {}
}
} catch (e) {
// ignore per-sheet failures
}
}
} catch (e) {
// fallback: set headers directly
try { sheetForms.getRange(1, 1, 1, HEADER_FORMS.length).setValues([HEADER_FORMS]); } catch (ee) {}
}
// 2. Usersシート
let sheetUsers = ss.getSheetByName(SHEET_USERS);
if (!sheetUsers) sheetUsers = ss.insertSheet(SHEET_USERS);
// Remove legacy stray column if it exists.
try {
const lastCol = Math.max(1, sheetUsers.getLastColumn());
const headerRow = sheetUsers.getRange(1, 1, 1, lastCol).getValues()[0] || [];
let idx = headerRow.findIndex(h => String(h || '').trim() === 'Admin (12)');
while (idx >= 0) {
sheetUsers.deleteColumn(idx + 1);
const newLastCol = Math.max(1, sheetUsers.getLastColumn());
const newHeaderRow = sheetUsers.getRange(1, 1, 1, newLastCol).getValues()[0] || [];
idx = newHeaderRow.findIndex(h => String(h || '').trim() === 'Admin (12)');
}
} catch (e) {
// ignore cleanup errors
}
sheetUsers.getRange(1, 1, 1, HEADER_USERS.length).setValues([HEADER_USERS]);
// 3. Logsシート
let sheetLogs = ss.getSheetByName(SHEET_LOGS);
if (!sheetLogs) sheetLogs = ss.insertSheet(SHEET_LOGS);
if (sheetLogs.getLastRow() === 0) sheetLogs.getRange(1, 1, 1, HEADER_LOGS.length).setValues([HEADER_LOGS]);
// 4. Collections 系シート(集金)
try { ensureCollectionsSheets(); } catch (e) { /* ignore if cannot create */ }
// Remove legacy '担当局' column in Collections if present
try {
const sheetCollections = ss.getSheetByName(SHEET_COLLECTIONS);
if (sheetCollections) {
const hdrColsC = Math.max(1, sheetCollections.getLastColumn());
const hdrRowC = sheetCollections.getRange(1, 1, 1, hdrColsC).getValues()[0] || [];
const bureauIdxC = hdrRowC.findIndex(h => String(h || '').trim() === '担当局');
if (bureauIdxC >= 0) sheetCollections.deleteColumn(bureauIdxC + 1);
}
} catch (e) {}
// Tokens は PropertiesService に移行したためシートは作成しない
// --- A. 書式設定 ---
const startRow = 2;
const numRows = 999;
sheetUsers.getRange(startRow, 3, numRows, 5).setNumberFormat('@');
sheetUsers.getRange(startRow, 8, numRows, 1).setNumberFormat('yyyy/mm/dd');
// --- B. 入力規則 ---
const rangeGradeOpt = sheetGrade.getRange('A2:A');
const rangeFieldOpt = sheetField.getRange('A2:A');
const rangeRoleOpt = sheetRole.getRange('A2:A');
const rangeDeptOpt = sheetDept.getRange('A2:A');
const buildRule = (range) => SpreadsheetApp.newDataValidation().requireValueInRange(range).setAllowInvalid(true).build();
const ruleGrade = buildRule(rangeGradeOpt);
const ruleField = buildRule(rangeFieldOpt);
const ruleRole = buildRule(rangeRoleOpt);
const ruleDept = buildRule(rangeDeptOpt);
sheetUsers.getRange(startRow, 4, numRows, 1).setDataValidation(ruleGrade);
sheetUsers.getRange(startRow, 5, numRows, 1).setDataValidation(ruleField);
for (let k = 0; k < AFFILIATION_SLOTS; k++) {
const deptCol = _affiliationDeptCol(k) + 1;
const roleCol = _affiliationRoleCol(k) + 1;
sheetUsers.getRange(startRow, deptCol, numRows, 1).setDataValidation(ruleDept);
sheetUsers.getRange(startRow, roleCol, numRows, 1).setDataValidation(ruleRole);
}
// 車所有 (Y列) と Admin列をチェックボックスに変更(フォールバックあり)
// Ensure boolean flags are checkboxes: 在籍, 次年度継続, 車所有, Admin
const boolColsToCheckbox = [COL.RETIRED, COL.CONTINUE_NEXT, COL.CAR_OWNER, COL.ADMIN];
boolColsToCheckbox.forEach(colIdx => {
try {
sheetUsers.getRange(startRow, colIdx + 1, numRows, 1).insertCheckboxes();
} catch (e) {
// fallback: set data validation to TRUE/FALSE list
try {
const rule = SpreadsheetApp.newDataValidation().requireValueInList(['TRUE', 'FALSE']).setAllowInvalid(true).build();
sheetUsers.getRange(startRow, colIdx + 1, numRows, 1).setDataValidation(rule);
} catch (ee) {
// ignore
}
}
});
// --- C. 条件付き書式 ---
sheetUsers.clearConditionalFormatRules();
const rules = [];
const getColLetter = (idx) => {
let letter = "";
while (idx > 0) {
let temp = (idx - 1) % 26;
letter = String.fromCharCode(temp + 65) + letter;
idx = (idx - temp - 1) / 26;
}
return letter;
};
const colStudentIdLet = getColLetter(3);
const rangeStudentId = sheetUsers.getRange(`${colStudentIdLet}2:${colStudentIdLet}`);
const formulaStudentId = `=AND(${colStudentIdLet}2<>"", NOT(REGEXMATCH(TO_TEXT(${colStudentIdLet}2), "^[0-9]{8}$")))`;
rules.push(SpreadsheetApp.newConditionalFormatRule().whenFormulaSatisfied(formulaStudentId).setBackground("#FFFF00").setRanges([rangeStudentId]).build());
for (let k = 0; k < AFFILIATION_SLOTS; k++) {
const colDeptIndex = _affiliationDeptCol(k) + 1;
const colDeptLet = getColLetter(colDeptIndex);
const range = sheetUsers.getRange(`${colDeptLet}2:${colDeptLet}`);
const formula = `=AND(${colDeptLet}2<>"", COUNTIF(INDIRECT("${SHEET_DEPT}!\\$A:\\$A"), ${colDeptLet}2)=0, COUNTIF(INDIRECT("${SHEET_ORG}!\\$A:\\$A"), ${colDeptLet}2)=0)`;
rules.push(SpreadsheetApp.newConditionalFormatRule().whenFormulaSatisfied(formula).setBackground("#FFFF00").setRanges([range]).build());
}
sheetUsers.setConditionalFormatRules(rules);
installTriggers();
// マイグレーション: 既存の 'TRUE'/'FALSE' 文字列を boolean に変換
try {
const lastRowUsers = sheetUsers.getLastRow();
if (lastRowUsers >= startRow) {
// convert 'TRUE'/'FALSE' strings to booleans for checkbox columns
const boolCols = [COL.CAR_OWNER, COL.ADMIN, COL.RETIRED, COL.CONTINUE_NEXT];
boolCols.forEach(colIdx => {
try {
const range = sheetUsers.getRange(startRow, colIdx + 1, lastRowUsers - startRow + 1, 1);
const vals = range.getValues().map(r => { const v = r[0]; if (v === true || v === 'TRUE') return [true]; if (v === false || v === 'FALSE') return [false]; return [false]; });
range.setValues(vals);
} catch (e) {
// ignore per-column failures
}
});
}
} catch (e) {
console.warn('Checkbox migration failed:', e.toString());
}
console.log("セットアップ完了");
}
function installTriggers() {
const ss = SpreadsheetApp.openById(getSpreadsheetId());
const triggerFuncName = 'handleSpreadsheetEdit';
const triggers = ScriptApp.getProjectTriggers();
const exists = triggers.some(t => t.getHandlerFunction() === triggerFuncName);
if (!exists) ScriptApp.newTrigger(triggerFuncName).forSpreadsheet(ss).onEdit().create();
}
function _getFrontendRedirectBaseUrl() {
const raw = String(getScriptProperty('FRONTEND_REDIRECT_URL') || '').trim();
if (!raw) throw new Error('FRONTEND_REDIRECT_URL を Script Properties に設定してください');
if (/script\.google\.com\/macros\//i.test(raw)) {
throw new Error('FRONTEND_REDIRECT_URL には ngrok フロントのURLを設定してください');
}
return raw;
}
function _getFrontendOAuthCallbackUrl() {
const base = _getFrontendRedirectBaseUrl().replace(/\/+$/, '');
return base + '/auth/slack/callback';
}
function _escapeHtmlAttribute(value) {
return String(value)
.replace(/&/g, '&')
.replace(/"/g, '"')
.replace(/</g, '<')
.replace(/>/g, '>');
}
function _buildRedirectHtml(targetUrl) {
const safeTarget = String(targetUrl || '').trim();
const metaRefreshTarget = _escapeHtmlAttribute(safeTarget);
const jsTargetLiteral = JSON.stringify(safeTarget);
return HtmlService.createHtmlOutput(`
<html>
<head>
<meta charset="UTF-8" />
<meta http-equiv="refresh" content="0; url=${metaRefreshTarget}" />
<script>
window.top.location.replace(${jsTargetLiteral});
</script>
</head>
<body>Redirecting...</body>
</html>
`).setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
/* --------------------------------------------------------------------------
* Webアプリ & OAuthエンドポイント
* -------------------------------------------------------------------------- */
function doGet(e) {
try {
const baseUrl = _getFrontendRedirectBaseUrl();
const p = (e && e.parameter) ? e.parameter : {};
if (p.code || p.error) {
const callbackUrl = _getFrontendOAuthCallbackUrl();
const query = [];
if (p.code) query.push('code=' + encodeURIComponent(String(p.code)));
if (p.state) query.push('state=' + encodeURIComponent(String(p.state)));
if (p.error) query.push('error=' + encodeURIComponent(String(p.error)));
if (p.error_description) query.push('error_description=' + encodeURIComponent(String(p.error_description)));
const target = callbackUrl + (query.length ? ('?' + query.join('&')) : '');
return _buildRedirectHtml(target);
}
return _buildRedirectHtml(baseUrl);
} catch (err) {
return HtmlService
.createHtmlOutput('FRONTEND_REDIRECT_URL を ngrok フロントURLで設定してください。')
.setTitle(APP_NAME)
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
}
/* --------------------------------------------------------------------------
* 1. ログイン & セッション管理 (Tokensシート利用)
* -------------------------------------------------------------------------- */
function getLoginUser(sessionToken) {
try {
if (!sessionToken) return { status: 'guest' };
const ss = SpreadsheetApp.openById(getSpreadsheetId());
const masters = _loadMasterMaps(ss);
const usersSheet = ss.getSheetByName(SHEET_USERS);
if (!usersSheet) return { status: 'error', message: "DB構成エラー" };
const sessions = _loadStore(SESSIONS_PROP_KEY);
const entry = sessions[sessionToken];
if (!entry) return { status: 'guest' };
const now = Date.now();
const created = entry.created || 0;
const diffDays = (now - created) / (1000 * 60 * 60 * 24);
if (diffDays > SESSION_DURATION_DAYS) {
delete sessions[sessionToken];
_saveStore(SESSIONS_PROP_KEY, sessions);
return { status: 'guest', message: 'セッション有効期限切れ' };
}
const userEmail = entry.email;
const tokensByEmail = _loadStore(TOKENS_BY_EMAIL_PROP_KEY);
const tokenEntry = tokensByEmail[userEmail] || {};
const slackToken = tokenEntry.slackToken || '';
// ユーザー情報取得
const userData = usersSheet.getDataRange().getValues();
const userRow = userData.find(r => String(r[COL.EMAIL] || '').trim().toLowerCase() === String(userEmail).trim().toLowerCase());
if (!userRow) return { status: 'error', message: "ユーザー情報が見つかりません" };
const hasToken = !!slackToken && slackToken.toString().startsWith('xox');
return {
status: 'authorized',
hasToken: hasToken,
user: { name: userRow[COL.NAME_JP], email: userEmail }
};
} catch (e) {
return { status: 'error', message: "認証エラー: " + e.toString() };
}
}
function _requireAdmin(sessionToken) {
const login = getLoginUser(sessionToken);
if (!login || login.status !== 'authorized') throw new Error('認証されていません');
const ss = SpreadsheetApp.openById(getSpreadsheetId());
const usersSheet = ss.getSheetByName(SHEET_USERS);
if (!usersSheet) throw new Error('Users シートが見つかりません');
const usersData = usersSheet.getDataRange().getValues();
const row = usersData.find(r => String(r[COL.EMAIL] || '').trim().toLowerCase() === String(login.user.email || '').trim().toLowerCase());
const isAdmin = row && (row[COL.ADMIN] === 'TRUE' || row[COL.ADMIN] === true);
if (!isAdmin) throw new Error('管理者権限が必要です');
return login;
}
function _nextNumericPid(existingPids, width) {
let max = 0;
existingPids.forEach(p => {
const n = parseInt(String(p || '').replace(/[^0-9]/g, ''), 10);
if (!isNaN(n) && n > max) max = n;
});
const next = max + 1;
return Utilities.formatString('%0' + width + 'd', next);
}
function _nextDataRowByPidColumn(sheet) {
const lastRow = Math.max(sheet.getLastRow(), 1);
if (lastRow < 2) return 2;
const values = sheet.getRange(2, 1, lastRow - 1, 1).getValues();
let lastDataRow = 1;
for (let i = 0; i < values.length; i++) {
if (String(values[i][0] || '').trim()) lastDataRow = i + 2;
}
return lastDataRow + 1;
}
function _applyCheckboxColumn(sheet, colIndex1Based) {
const lastRow = Math.max(sheet.getLastRow(), 2);
const numRows = Math.max(1, lastRow - 1);
const range = sheet.getRange(2, colIndex1Based, numRows, 1);
try {
const values = range.getValues();
const normalized = values.map(row => {
const value = row[0];
if (value === true || value === 'TRUE' || String(value).toLowerCase() === 'true') return [true];
if (value === false || value === 'FALSE' || String(value).toLowerCase() === 'false') return [false];
return [false];
});
range.setValues(normalized);
} catch (e) {}
try {
range.insertCheckboxes();
} catch (e) {
try {
const rule = SpreadsheetApp.newDataValidation().requireValueInList(['TRUE', 'FALSE']).setAllowInvalid(true).build();
range.setDataValidation(rule);
} catch (ee) {}
}
}
function listAffiliationMasters(sessionToken) {
_requireAdmin(sessionToken);
const ss = SpreadsheetApp.openById(getSpreadsheetId());
const masters = _loadMasterMaps(ss);
const isActive = (statusVal) => {
const s = String(statusVal || '').trim();
return s === '' || s === '0' || s.toLowerCase() === 'false';
};
const orgs = Object.keys(masters.org.byPid).map(pid => ({
pid: pid,
org: masters.org.byPid[pid],
status: String(masters.org.statusByPid[pid] || ''),
not_main_org: !!masters.org.notMainByPid[pid],
gen: (() => {
const row = masters.org.rows.find(r => String(r[0] || '').trim() === pid);
return row ? row[2] : '';
})(),
active: isActive(masters.org.statusByPid[pid])
})).sort((a, b) => a.pid.localeCompare(b.pid));
const depts = Object.keys(masters.dept.byPid).map(pid => {
const orgPid = masters.deptToOrgPid[pid] || '';
return {
pid: pid,
dept: masters.dept.byPid[pid],
orgPid: orgPid,
org: _toLabelOrEmpty(orgPid, masters.org.byPid),
status: String(masters.dept.statusByPid[pid] || ''),
not_main_dept: !!masters.dept.notMainByPid[pid],
active: isActive(masters.dept.statusByPid[pid])
};
}).sort((a, b) => a.pid.localeCompare(b.pid));
const roles = Object.keys(masters.role.byPid).map(pid => ({
pid: pid,
role: masters.role.byPid[pid],
gen: (() => {
const row = masters.role.rows.find(r => String(r[0] || '').trim() === pid);
return row ? row[2] : '';
})(),
status: String(masters.role.statusByPid[pid] || ''),
not_main_role: !!masters.role.notMainByPid[pid],
active: isActive(masters.role.statusByPid[pid])
})).sort((a, b) => a.pid.localeCompare(b.pid));
return { orgs: orgs, depts: depts, roles: roles };
}
function saveOrgMaster(sessionToken, payload) {
_requireAdmin(sessionToken);
const ss = SpreadsheetApp.openById(getSpreadsheetId());
const sh = ss.getSheetByName(SHEET_ORG);
if (!sh) throw new Error('org シートが見つかりません');
const data = sh.getDataRange().getValues();
const pidIn = String((payload && payload.pid) || '').trim();
const orgName = String((payload && payload.org) || '').trim();
if (!orgName) throw new Error('局名は必須です');
const genRaw = (payload && typeof payload.gen !== 'undefined') ? String(payload.gen).trim() : '';
const gen = genRaw === '' ? '' : Number(genRaw);
const status = String((payload && payload.status) || '').trim();
const notMain = !!(payload && payload.not_main_org);
// If not_main_org is true, gen is required
if (notMain && (genRaw === '' || isNaN(gen))) {
throw new Error('not_main_org が True の場合、gen は必須です');
}
if (pidIn) {
for (let i = 1; i < data.length; i++) {
if (String(data[i][0] || '').trim() === pidIn) {
sh.getRange(i + 1, 1, 1, 5).setValues([[pidIn, orgName, gen, status, notMain]]);
_applyCheckboxColumn(sh, 5);
return { success: true, pid: pidIn };
}
}
}
let nextPid = '';
if (notMain) {
// gen is required and provides first two digits; append single-digit sequence
const genStr = Utilities.formatString('%02d', gen);
const siblings = data.slice(1).map(r => String(r[0] || '').trim()).filter(pid => pid.startsWith(genStr) && pid.length === 3);
const seqs = siblings.map(pid => parseInt(pid.slice(2), 10)).filter(n => !isNaN(n));
const maxSeq = seqs.length ? Math.max.apply(null, seqs) : 0;
const nextSeq = maxSeq + 1;
nextPid = genStr + String(nextSeq);
} else {
nextPid = _nextNumericPid(data.slice(1).map(r => r[0]), 2);
}
const nextRow = _nextDataRowByPidColumn(sh);
sh.getRange(nextRow, 1, 1, 5).setValues([[nextPid, orgName, (notMain ? gen : ''), status, notMain]]);
_applyCheckboxColumn(sh, 5);
return { success: true, pid: nextPid };
}
function saveDeptMaster(sessionToken, payload) {
_requireAdmin(sessionToken);
const ss = SpreadsheetApp.openById(getSpreadsheetId());
const sh = ss.getSheetByName(SHEET_DEPT);
if (!sh) throw new Error('dept シートが見つかりません');
const masters = _loadMasterMaps(ss);
const data = sh.getDataRange().getValues();
const pidIn = String((payload && payload.pid) || '').trim();
const deptName = String((payload && payload.dept) || '').trim();
const orgInput = String((payload && payload.orgPid) || '').trim();
let orgPid = '';
if (orgInput) {
if (masters.org.byPid && masters.org.byPid[orgInput]) orgPid = orgInput;
else if (masters.org.byName && masters.org.byName[orgInput]) orgPid = masters.org.byName[orgInput];
else orgPid = orgInput;
}
if (!deptName) throw new Error('部門名は必須です');
if (!orgPid) throw new Error('所属局は必須です');
const status = String((payload && payload.status) || '').trim();
const notMain = !!(payload && payload.not_main_dept);
_assertActiveSelection('所属局', orgPid, masters.org, '所属局');
if (pidIn) {
for (let i = 1; i < data.length; i++) {
if (String(data[i][0] || '').trim() === pidIn) {
sh.getRange(i + 1, 1, 1, 5).setValues([[pidIn, deptName, orgPid, status, notMain]]);
_applyCheckboxColumn(sh, 5);
return { success: true, pid: pidIn };
}
}
}
const siblings = data.slice(1).map(r => String(r[0] || '').trim()).filter(pid => pid.startsWith(orgPid) && pid.length >= 4);
const seq = siblings.map(pid => parseInt(pid.slice(-2), 10)).filter(n => !isNaN(n));
const maxSeq = seq.length ? Math.max.apply(null, seq) : 0;
const nextPid = orgPid + Utilities.formatString('%02d', maxSeq + 1);
const nextRow = _nextDataRowByPidColumn(sh);
sh.getRange(nextRow, 1, 1, 5).setValues([[nextPid, deptName, orgPid, status, notMain]]);
_applyCheckboxColumn(sh, 5);
return { success: true, pid: nextPid };
}
function saveRoleMaster(sessionToken, payload) {
_requireAdmin(sessionToken);
const ss = SpreadsheetApp.openById(getSpreadsheetId());
const sh = ss.getSheetByName(SHEET_ROLE);
if (!sh) throw new Error('role シートが見つかりません');
const data = sh.getDataRange().getValues();
const pidIn = String((payload && payload.pid) || '').trim();
const roleName = String((payload && payload.role) || '').trim();
if (!roleName) throw new Error('役職名は必須です');
const genRaw = (payload && typeof payload.gen !== 'undefined') ? String(payload.gen).trim() : '';
const gen = genRaw === '' ? '' : Number(genRaw);
const notMain = !!(payload && payload.not_main_role);
if (pidIn) {
for (let i = 1; i < data.length; i++) {
if (String(data[i][0] || '').trim() === pidIn) {
sh.getRange(i + 1, 1, 1, 4).setValues([[pidIn, roleName, gen, notMain]]);
_applyCheckboxColumn(sh, 4);
return { success: true, pid: pidIn };
}
}
}
const nextPid = _nextNumericPid(data.slice(1).map(r => r[0]), 2);
const nextRow = _nextDataRowByPidColumn(sh);
sh.getRange(nextRow, 1, 1, 4).setValues([[nextPid, roleName, gen, notMain]]);
_applyCheckboxColumn(sh, 4);
return { success: true, pid: nextPid };
}
// 1-A. OTPリクエスト (BotからDM送信)
function requestLoginOtp(email) {
const ss = SpreadsheetApp.openById(getSpreadsheetId());
const usersSheet = ss.getSheetByName(SHEET_USERS);
const data = usersSheet.getDataRange().getValues();
const targetEmail = String(email).trim().toLowerCase();
// ユーザー存在確認
const userExists = data.some((r, i) => i > 0 && String(r[COL.EMAIL]).trim().toLowerCase() === targetEmail);
if (!userExists) return { success: false, message: "未登録のメールアドレスです。" };
// Slack Botトークン取得
const botToken = getScriptProperty('SLACK_BOT_TOKEN');
if (!botToken) return { success: false, message: "システムエラー: Bot Token未設定" };
try {
// EmailからSlack IDを特定
const lookupRes = UrlFetchApp.fetch(`https://slack.com/api/users.lookupByEmail?email=${encodeURIComponent(targetEmail)}`, {
headers: { "Authorization": "Bearer " + botToken },
muteHttpExceptions: true
});
const lookupJson = JSON.parse(lookupRes.getContentText());
if (!lookupJson.ok) {
// よくあるエラーを人間向けに説明
let userMessage = "Slackアカウントが見つかりません。(Botがワークスペースにいない可能性があります)";
const err = lookupJson.error || '';
if (err === 'users_not_found' || err === 'user_not_found') userMessage = '指定したメールアドレスのSlackユーザーが見つかりません。メールアドレスをご確認ください。';
else if (err === 'not_authed' || err === 'invalid_auth' || err === 'account_inactive') userMessage = 'Botトークンが無効です。Script Properties の SLACK_BOT_TOKEN を確認してください。';
else if (err === 'missing_scope') userMessage = 'Botに必要な権限がありません。users:read.email 権限を付与してください。';
else if (err === 'rate_limited') userMessage = 'Slack API の利用制限に達しました。しばらくしてから再試行してください。';
console.warn('users.lookupByEmail failed:', err, lookupJson);
return { success: false, message: userMessage, needBotSetup: true };
}
const slackUserId = lookupJson.user.id;
// OTP生成 (6桁数字)
const otp = Math.floor(100000 + Math.random() * 900000).toString();
// ScriptPropertiesに一時保存 (有効期限10分想定)
const otpPayload = JSON.stringify({ code: otp, created: new Date().getTime() });
PropertiesService.getScriptProperties().setProperty(`OTP_${targetEmail}`, otpPayload);
// DM送信(認証コードのみ)
const plainText = `【${APP_NAME}】認証コード: *${otp}*\nこのコードを画面に入力してください。(有効期限10分)`;
const msgRes = UrlFetchApp.fetch("https://slack.com/api/chat.postMessage", {
method: "post",
contentType: "application/json",
headers: { "Authorization": "Bearer " + botToken },
payload: JSON.stringify({ channel: slackUserId, text: plainText }),
muteHttpExceptions: true
});
const msgJson = JSON.parse(msgRes.getContentText());
if (!msgJson.ok) {
console.warn('chat.postMessage failed:', msgJson.error, msgJson);
throw new Error("Slack DM送信失敗: " + (msgJson.error || 'unknown'));
}
return { success: true };
} catch (e) {
return { success: false, message: "OTP送信エラー: " + e.message };
}
}
// 1-B. OTP検証 & セッション発行
function verifyLoginOtp(email, code) {
const targetEmail = String(email).trim().toLowerCase();
const propKey = `OTP_${targetEmail}`;
const stored = PropertiesService.getScriptProperties().getProperty(propKey);
if (!stored) return { success: false, message: "認証コードが無効か期限切れです。" };
const { code: correctCode, created } = JSON.parse(stored);
const now = new Date().getTime();
// 10分有効
if (now - created > 10 * 60 * 1000) {
PropertiesService.getScriptProperties().deleteProperty(propKey);
return { success: false, message: "認証コードの期限が切れています。" };
}
if (String(code).trim() !== String(correctCode)) {
return { success: false, message: "認証コードが間違っています。" };
}
// 認証成功: プロパティ削除
PropertiesService.getScriptProperties().deleteProperty(propKey);
// セッション発行
const newSessionToken = Utilities.getUuid();
const nowTs = Date.now();
const sessions = _loadStore(SESSIONS_PROP_KEY);
sessions[newSessionToken] = { email: targetEmail, created: nowTs };
_saveStore(SESSIONS_PROP_KEY, sessions);
return { success: true, token: newSessionToken };
}
/* --------------------------------------------------------------------------
* 2. OAuth関連 (PCからのログイン用 - Tokensシートに対応)
* -------------------------------------------------------------------------- */
function getAuthUrl() {
const clientId = _normalizeSlackCredential(getScriptProperty('SLACK_CLIENT_ID'));
const redirectUri = _getFrontendOAuthCallbackUrl();
const userScopes = ["chat:write", "users:read", "users:read.email", "channels:read", "groups:read", "channels:write", "groups:write"].join(",");
return `https://slack.com/oauth/v2/authorize?client_id=${clientId}&user_scope=${userScopes}&redirect_uri=${encodeURIComponent(redirectUri)}`;
}
function getScriptUrl() { return ScriptApp.getService().getUrl(); }
function handleSlackOAuthCode(code, redirectUri) {