/** * Main.gs — GAS 側のつなぎ込み(スプレッドシート/Web アプリ/トリガー) * コアロジックは Core.gs(テスト済み)。ここは入出力だけを担当します。 * * 【GAS の制約を踏まえた設計】 * - Web アプリ(doPost)ではリクエストヘッダを読めません → 認証はクエリ ?k=… で受けます(必要なら本文HMACも追加可能)。 * - HTTP ステータスコードを自由に返せません → 応答本文に code を含めます(連携先と事前合意)。 * - 同時受信の競合は LockService で直列化します。 * - テスト/本番の切替は スクリプトプロパティ ENV(test|prod)だけで行います(コード変更なし)。 */ function getConfig_() { var p = PropertiesService.getScriptProperties(); return { env: p.getProperty('ENV') || 'test', apiKey: p.getProperty('API_KEY'), callbackKey: p.getProperty('CALLBACK_KEY'), maxRetry: 3, notifyMinutes: Number(p.getProperty('NOTIFY_MINUTES') || 10), sendNames: p.getProperty('SEND_NAMES') === 'true', endpoints: { test: { send: p.getProperty('SEND_URL_TEST'), platform: p.getProperty('PLATFORM_URL_TEST') }, prod: { send: p.getProperty('SEND_URL_PROD'), platform: p.getProperty('PLATFORM_URL_PROD') } } }; } function http_(url, opt) { var r = UrlFetchApp.fetch(url, Object.assign({ muteHttpExceptions: true, followRedirects: false }, opt)); return { code: r.getResponseCode(), body: r.getContentText() }; } /** 既存の安否データ(Residents/Answers/Dispatch シート)への薄いアダプタ */ function SheetStore() { this.ss = SpreadsheetApp.getActiveSpreadsheet(); } SheetStore.prototype.rows_ = function (name) { var sh = this.ss.getSheetByName(name), v = sh.getDataRange().getValues(), h = v.shift(); return v.map(function (r) { var o = {}; h.forEach(function (k, i) { o[k] = r[i]; }); return o; }); }; SheetStore.prototype.residents = function () { return this.rows_('Residents'); }; SheetStore.prototype.findResidentByDevice = function (id) { return this.residents().filter(function (r) { return String(r.device_id) === String(id); })[0] || null; }; SheetStore.prototype.listAnswers = function (e, rid) { return this.rows_('Answers').filter(function (x) { return x.event_id === e && x.resident_id === rid; }); }; SheetStore.prototype.allAnswers = function () { return this.rows_('Answers'); }; SheetStore.prototype.allDispatches = function () { return this.rows_('Dispatch'); }; SheetStore.prototype.addAnswer = function (r) { this.ss.getSheetByName('Answers').appendRow([r.event_id, r.resident_id, r.status, r.source, r.answered_at, r.received_at, r.ref, r.role, r.conflict]); }; SheetStore.prototype.addDispatch = function (r) { this.ss.getSheetByName('Dispatch').appendRow([r.event_id, r.resident_id, r.device_id, r.result, r.reason, r.attempts, r.at]); }; /** ① 外部端末へ緊急モードを送る(対象地区を指定。メニュー/既存画面/トリガーから呼べる) */ function startEmergency(eventId, area) { var cfg = getConfig_(), store = new SheetStore(); var payload = buildEmergencyPayload({ id: eventId }, store.residents(), area, cfg.env); var r = dispatchEmergency(cfg, payload, http_, store, function (m) { console.log(m); }); PropertiesService.getScriptProperties().setProperty('ISSUED_' + eventId, payload.issued_at); return r; } /** ② 端末からの安否応答を受け取る(Web アプリ doPost:連携先にはこの URL ?k=… を伝える) */ function doPost(e) { var lock = LockService.getScriptLock(); lock.waitLock(20000); try { var res = handleDeviceCallback(e.postData && e.postData.contents, { key: e.parameter && e.parameter.k }, getConfig_(), new SheetStore()); return ContentService.createTextOutput(JSON.stringify(res)).setMimeType(ContentService.MimeType.JSON); } finally { lock.releaseLock(); } } /** ③ 既存の LINE 受信処理から 1 行呼ぶだけで、端末回答との重複統合が効く */ function onLineAnswer(eventId, residentId, status, messageId) { var lock = LockService.getScriptLock(); lock.waitLock(20000); try { return recordAnswer(new SheetStore(), { event_id: eventId, resident_id: residentId, status: status, source: SOURCE.LINE, answered_at: new Date().toISOString(), ref: messageId }); } finally { lock.releaseLock(); } } /** ④ 未回答・送信失敗の通知(時間主導トリガー:5分おき)+ 既存の地図・一覧シートへ最小限反映 */ function checkUnanswered() { var cfg = getConfig_(), store = new SheetStore(), props = PropertiesService.getScriptProperties(); var eventId = props.getProperty('ACTIVE_EVENT'); if (!eventId) return; var board = computeBoard({ id: eventId }, store.residents(), store.allAnswers(), store.allDispatches()); writeBoard_(board); var targets = findNotifyTargets(board, props.getProperty('ISSUED_' + eventId), cfg.notifyMinutes); var notified = JSON.parse(props.getProperty('NOTIFIED_' + eventId) || '[]'); var fresh = targets.filter(function (x) { return notified.indexOf(x.resident_id + ':' + x.state) < 0; }); if (fresh.length) { MailApp.sendEmail(props.getProperty('STAFF_MAIL'), '【安否確認】未回答・送信失敗 ' + fresh.length + '件 (' + eventId + ')', fresh.map(function (x) { return x.area + ' ' + x.name + ' : ' + x.state + (x.conflict ? '(回答に食い違い)' : ''); }).join('\n')); props.setProperty('NOTIFIED_' + eventId, JSON.stringify(notified.concat(fresh.map(function (x) { return x.resident_id + ':' + x.state; })))); } pushToPlatform(board); } /** 既存の地図・一覧が参照している「Board」シートに状態列だけを書く(最小限の反映) */ function writeBoard_(board) { var sh = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Board'); sh.clearContents(); sh.appendRow(['resident_id', 'area', 'state', 'status', 'source', 'answered_at', 'conflict']); board.rows.forEach(function (x) { sh.appendRow([x.resident_id, x.area, x.state, x.status, x.source, x.answered_at, x.conflict]); }); } /** ⑤ 連携先プラットフォームへ集約データを送る(テスト/本番は ENV で自動切替) */ function pushToPlatform(board) { var cfg = getConfig_(); var url = cfg.endpoints[cfg.env].platform; if (!url) return; return http_(url, { method: 'post', headers: { 'Content-Type': 'application/json', 'X-Api-Key': cfg.apiKey }, payload: JSON.stringify(buildPlatformPayload(board, cfg)) }); }