/** * 見守り・安否連携ハブ コアロジック(Core.gs) * * ・Google Apps Script(V8)とブラウザデモ/Node テストで「同じファイル」を使います。 * ・外部への依存(SpreadsheetApp / UrlFetchApp 等)は持たず、store・http を引数で受け取ります。 * → Sheets 版は Main.gs の SheetStore、デモ・テストは MemoryStore を渡します。 */ var STATUS = { SAFE: 'safe', HELP: 'need_help', UNKNOWN: 'unknown' }; var SOURCE = { LINE: 'LINE', DEVICE: 'DEVICE' }; function isoNow_(now) { return (now || new Date()).toISOString(); } /** 緊急モード送信ペイロード(対象を指定)。area を省略すると端末を持つ全員が対象。 */ function buildEmergencyPayload(event, residents, targetArea, envName, now) { var targets = residents.filter(function (r) { return r.device_id && (!targetArea || r.area === targetArea); }); return { event_id: event.id, mode: 'emergency', env: envName, issued_at: isoNow_(now), targets: targets.map(function (r) { return { device_id: r.device_id, resident_id: r.id }; }) }; } /** * 外部端末ベンダーへ送信。HTTP 失敗は maxRetry 回まで再送、端末単位の拒否は dispatch に記録。 * http(url, options) は { code, body } を返す関数(GAS では UrlFetchApp のラッパー)。 * 想定レスポンス: { accepted: [device_id...], rejected: [{ device_id, reason }] } */ function dispatchEmergency(cfg, payload, http, store, log) { var ep = cfg.endpoints[cfg.env].send; var attempt = 0, res = null, lastErr = ''; while (attempt < cfg.maxRetry) { attempt++; try { res = http(ep, { method: 'post', headers: { 'Content-Type': 'application/json', 'X-Api-Key': cfg.apiKey }, payload: JSON.stringify(payload) }); if (res.code >= 200 && res.code < 300) break; lastErr = 'HTTP ' + res.code; } catch (e) { res = null; lastErr = String(e && e.message ? e.message : e); } log && log('send retry ' + attempt + '/' + cfg.maxRetry + ' : ' + lastErr); } var ok = !!(res && res.code >= 200 && res.code < 300); var rejected = {}; if (ok) { var body = typeof res.body === 'string' ? safeParse_(res.body) : res.body; ((body && body.rejected) || []).forEach(function (x) { rejected[x.device_id] = x.reason || 'rejected'; }); } payload.targets.forEach(function (t) { var failed = !ok || rejected[t.device_id]; store.addDispatch({ event_id: payload.event_id, resident_id: t.resident_id, device_id: t.device_id, result: failed ? 'failed' : 'sent', reason: failed ? (!ok ? lastErr : rejected[t.device_id]) : '', attempts: attempt, at: payload.issued_at }); }); return { ok: ok, attempts: attempt, rejected: rejected, error: ok ? '' : lastErr }; } function safeParse_(s) { try { return JSON.parse(s); } catch (e) { return null; } } /** 端末からのコールバック(受信)。認証 → 検証 → 住民特定 → recordAnswer。 */ function handleDeviceCallback(raw, auth, cfg, store, now) { if (!auth || auth.key !== cfg.callbackKey) return { code: 401, error: 'unauthorized' }; var body = typeof raw === 'string' ? safeParse_(raw) : raw; if (!body) return { code: 400, error: 'invalid_json' }; var miss = ['event_id', 'device_id', 'status'].filter(function (k) { return !body[k]; }); if (miss.length) return { code: 422, error: 'missing:' + miss.join(',') }; var allowed = [STATUS.SAFE, STATUS.HELP, STATUS.UNKNOWN]; if (allowed.indexOf(body.status) < 0) return { code: 422, error: 'invalid_status' }; var r = store.findResidentByDevice(body.device_id); if (!r) return { code: 404, error: 'unknown_device' }; var out = recordAnswer(store, { event_id: body.event_id, resident_id: r.id, status: body.status, source: SOURCE.DEVICE, answered_at: body.answered_at || isoNow_(now), ref: body.message_id || '' }, now); return { code: 200, result: out.result, role: out.role }; } /** * 回答の取り込み(LINE・端末の共通入口)。 * ルール: 先に「届いた」回答を採用(primary)、後着は追記(late)。同一メッセージの再送は無視(冪等)。 * 先着と後着の内容が食い違う場合は conflict を立てる(例: 先着=無事/後着=要支援)。 */ function recordAnswer(store, a, now) { var list = store.listAnswers(a.event_id, a.resident_id); var dup = list.filter(function (x) { return x.source === a.source && x.status === a.status && ((a.ref && x.ref === a.ref) || (!a.ref && x.answered_at === a.answered_at)); }); if (dup.length) return { result: 'duplicate_ignored', role: dup[0].role }; var primary = null; list.forEach(function (x) { if (x.role === 'primary') primary = x; }); var rec = { event_id: a.event_id, resident_id: a.resident_id, status: a.status, source: a.source, answered_at: a.answered_at, received_at: isoNow_(now), ref: a.ref || '', role: primary ? 'late' : 'primary', conflict: !!(primary && primary.status !== a.status) }; store.addAnswer(rec); return { result: primary ? 'appended_late' : 'adopted', role: rec.role, conflict: rec.conflict }; } /** 一覧・地図へ最小限に反映するための集計。 */ function computeBoard(event, residents, answers, dispatches) { var rows = residents.map(function (r) { var mine = answers.filter(function (x) { return x.event_id === event.id && x.resident_id === r.id; }); var primary = mine.filter(function (x) { return x.role === 'primary'; })[0] || null; var lates = mine.filter(function (x) { return x.role === 'late'; }); var dp = dispatches.filter(function (d) { return d.event_id === event.id && d.resident_id === r.id; }); var failed = dp.length && dp[dp.length - 1].result === 'failed'; var state = primary ? 'answered' : (failed ? 'send_failed' : 'no_answer'); return { resident_id: r.id, name: r.name, area: r.area, lat: r.lat, lng: r.lng, state: state, status: primary ? primary.status : null, source: primary ? primary.source : null, answered_at: primary ? primary.answered_at : null, late: lates.map(function (x) { return { source: x.source, status: x.status, at: x.received_at, conflict: x.conflict }; }), conflict: lates.some(function (x) { return x.conflict; }), alert: state === 'send_failed' || (primary && primary.status === STATUS.HELP) || lates.some(function (x) { return x.status === STATUS.HELP; }) }; }); var sum = { total: rows.length, safe: 0, need_help: 0, unknown: 0, no_answer: 0, send_failed: 0, conflict: 0 }; rows.forEach(function (x) { if (x.state === 'answered') sum[x.status]++; else sum[x.state]++; if (x.conflict) sum.conflict++; }); return { event_id: event.id, rows: rows, summary: sum }; } /** 未回答・送信失敗の抽出(通知対象)。minutes 経過後も未回答のみ。 */ function findNotifyTargets(board, issuedAtIso, minutes, now) { var elapsed = ((now || new Date()).getTime() - new Date(issuedAtIso).getTime()) / 60000; return board.rows.filter(function (x) { return x.state === 'send_failed' || (x.state === 'no_answer' && elapsed >= minutes) || x.conflict; }); } /** 連携先プラットフォームへ送る集約データ。個人情報は最小化(氏名は既定で送らない)。 */ function buildPlatformPayload(board, cfg, now) { return { event_id: board.event_id, env: cfg.env, generated_at: isoNow_(now), summary: board.summary, residents: board.rows.map(function (x) { var o = { resident_ref: x.resident_id, area: x.area, state: x.state, status: x.status, source: x.source, answered_at: x.answered_at, conflict: x.conflict }; if (cfg.sendNames) o.name = x.name; return o; }) }; } /** ブラウザデモ/テスト用のメモリストア(Sheets 版と同じインターフェース)。 */ function MemoryStore(residents) { this.residents = residents; this.answers = []; this.dispatches = []; } MemoryStore.prototype.findResidentByDevice = function (id) { return this.residents.filter(function (r) { return r.device_id === id; })[0] || null; }; MemoryStore.prototype.listAnswers = function (e, rid) { return this.answers.filter(function (x) { return x.event_id === e && x.resident_id === rid; }); }; MemoryStore.prototype.addAnswer = function (r) { this.answers.push(r); }; MemoryStore.prototype.addDispatch = function (r) { this.dispatches.push(r); }; if (typeof module !== 'undefined' && module.exports) { module.exports = { STATUS: STATUS, SOURCE: SOURCE, buildEmergencyPayload: buildEmergencyPayload, dispatchEmergency: dispatchEmergency, handleDeviceCallback: handleDeviceCallback, recordAnswer: recordAnswer, computeBoard: computeBoard, findNotifyTargets: findNotifyTargets, buildPlatformPayload: buildPlatformPayload, MemoryStore: MemoryStore }; }