🙄

爆速で安否確認システムを作ったった

に公開

はじめに

総務より安否確認システムを導入できないかという相談があったのを思い出したが、当時はAWSやらリッチなアーキテクチャ構成がAIにより提示されてきたので面倒くさいなと感じて放置していた

たまたま、o3 proがリリースされたので性能を確かめるのに安否確認システムを最低限の機能で実装するよう各生成AIに投げかけてみた

  • ChatGPT o3 Pro
  • ChatGPT o3
  • Gemini 2.5 Pro
  • Claude Sonnet4

上の3つのモデルは概ね同じくGASで実装するとコスト0でという内容でかつ将来的なアップデートにまで
言及していたことと、o3 Proはコードの例まで提示していて他のモデルよりも賢かった、逆にClaudeは
期待していた回答ではなかったので、回答の出来としては上から順といったところでした。

前提

  • GoogleWorkSpaceを契約していること、最低限の機能だけならコスト0円でGoogleで全て完結します。
  • GoogleAppScriptはWEBデプロイをして公開しますが、推奨は全員みたいです。安否確認の性質上社用メールアドレス以外にもGoogleメールアドレス、その他メールアドレスを想定しているのでしょう。なぜ全員に公開なのかは、対象者の回答リンクへのアクセスと回答した後のHTMLページの表示がアプリ上で表示されるからだと思います。

準備するもの

  • Google スプレッドシート(コンテナなんちゃらで、スプシの拡張機能ボタンからGASを作成しましょう、自動的に紐づけられます)
  • Google App Script

成果物

管理者用の管理画面、安否確認メールの送信と回答状況を確認できます

安否確認メールを送信すると対象者に下記メールが届きます。
URLをクリックすると回答結果に反映されます。

裏側のデータ保持はスプシ

今後のアップデート

とりあえず0日でプロトタイプを開発したので次はAIに提案された下記の通り進める予定です。

期間 スプリント目標 Deliverables

  • Day 0–1 (プロト) • シート設計• 配信・集計スクリプトの雛型 動くスクリプト(.gs) / テスト送信
  • Day 2–3 (MVP) • 管理用 UI(HTML)• 未回答リストの自動生成 シンプル UI / ステータス可視化
  • Day 4–5 (Stabilize) • 例外処理/権限設定• 二要素認証(管理者) 本番リリース
  • Week 2~ (Iterate) • Slack/LINE bot 連携• SMS (Twilio) 追加• 自動地震情報トリガー連動 バージョン 0.2, 0.3…

使い方

  1. スプシを作成します。
  2. 拡張機能ボタンからGASを新規作成します。
  3. コードを貼り付けます
  4. 初回のみinitializeSheets関数を実行してスプシを整えます
  5. デプロイからWEBアプリを選択して実行者は自分のみ、公開範囲はお好みで
  6. デプロイ後生成されたスクリプトIDをコピー
  7. fixBrokenUrlsのyourDomainにドメインを入れて、scriptIdはスクリプトIDを入れて保存
  8. スプシにテストメールを送れる正しいメールアドレスに差し替え
  9. WEBアプリ管理画面からメールを送信
  10. 対象者側で回答、回答結果を確認する

スクリプトID

function fixBrokenUrls() {
  // あなたのケースの正しいURL例
  const yourDomain = '';
  const scriptId = ''; // 実際のスクリプトIDに置き換え

コード

開発はClaude Desktopで行いました。
中々一筋縄でなく、一番やっかいだったのが

ScriptIDがApp ScritpプロジェクトのIDじゃないのにプロジェクトIDを指定させられ上手く動作しなくて解決まで少し時間がかかったこと、スプシの社員番号がINT型なのだが、判定ロジックはStringを想定していた等、一発では上手く行かなかったです。

// 安否確認システム Day0-1 プロトタイプ
// Google Apps Script (GAS) で実装

// ========================================
// 1. 初期設定・定数
// ========================================

const CONFIG = {
  // スプレッドシートの設定
  SHEET_NAME: '社員リスト',
  RESPONSE_SHEET_NAME: '回答状況',
  
  // メール設定
  MAIL_SUBJECT: '【緊急】安否確認にご協力ください',
  SENDER_NAME: '総務部 安否確認システム',
  
  // 回答用URL(デプロイ時に自動取得)
  BASE_URL: '', // 動的に設定
  
  // 回答選択肢
  RESPONSES: {
    SAFE: '無事',
    NEED_HELP: '要支援'
  }
};

/**
 * WebアプリのURLを動的に取得
 */
function getWebAppUrl() {
  // デプロイされたWebアプリのURLを取得
  try {
    const scriptId = ScriptApp.getScriptId();
    return `https://script.google.com/macros/s/${scriptId}/exec`;
  } catch (error) {
    Logger.log('スクリプトID取得エラー。手動でURLを設定してください。');
    // フォールバック:手動で設定する場合
    return 'https://script.google.com/macros/s/YOUR_SCRIPT_ID/exec';
  }
}

// ========================================
// 2. スプレッドシート初期化
// ========================================

/**
 * 初回実行時:スプレッドシートの構造を作成
 */
function initializeSheets() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  
  // 社員リストシートの作成
  let employeeSheet = ss.getSheetByName(CONFIG.SHEET_NAME);
  if (!employeeSheet) {
    employeeSheet = ss.insertSheet(CONFIG.SHEET_NAME);
    employeeSheet.getRange('A1:D1').setValues([['社員ID', '氏名', '社用メール', '私用メール']]);
    employeeSheet.getRange('A1:D1').setFontWeight('bold');
    
    // サンプルデータ
    employeeSheet.getRange('A2:D4').setValues([
      ['EMP001', '田中太郎', 'tanaka@company.com', 'tanaka.private@gmail.com'],
      ['EMP002', '佐藤花子', 'sato@company.com', 'sato.private@gmail.com'],
      ['EMP003', '鈴木次郎', 'suzuki@company.com', 'suzuki.private@gmail.com']
    ]);
  }
  
  // 回答状況シートの作成
  let responseSheet = ss.getSheetByName(CONFIG.RESPONSE_SHEET_NAME);
  if (!responseSheet) {
    responseSheet = ss.insertSheet(CONFIG.RESPONSE_SHEET_NAME);
    responseSheet.getRange('A1:G1').setValues([['送信日時', '社員ID', '氏名', 'メールアドレス', '回答', '回答日時', 'IPアドレス']]);
    responseSheet.getRange('A1:G1').setFontWeight('bold');
  }
  
  Logger.log('シートの初期化が完了しました');
}

// ========================================
// 3. 安否確認メール一斉送信
// ========================================

/**
 * 管理者用:安否確認メールを一斉送信
 */
function sendSafetyCheckEmails() {
  try {
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    const employeeSheet = ss.getSheetByName(CONFIG.SHEET_NAME);
    const responseSheet = ss.getSheetByName(CONFIG.RESPONSE_SHEET_NAME);
    
    if (!employeeSheet) {
      throw new Error('社員リストシートが見つかりません。先にinitializeSheets()を実行してください。');
    }
    
    // 社員リストを取得
    const employees = getEmployeeList();
    const sendTime = new Date();
    
    let successCount = 0;
    let errorCount = 0;
    
    // 各社員にメール送信
    employees.forEach(employee => {
      try {
        sendSafetyCheckEmail(employee, sendTime);
        
        // 送信ログを記録
        responseSheet.appendRow([
          sendTime,
          employee.id,
          employee.name,
          employee.email,
          '', // 回答(空)
          '', // 回答日時(空)
          ''  // IPアドレス(空)
        ]);
        
        successCount++;
      } catch (error) {
        Logger.log(`メール送信エラー - ${employee.name}: ${error.message}`);
        errorCount++;
      }
    });
    
    const message = `安否確認メール送信完了\n成功: ${successCount}件\nエラー: ${errorCount}`;
    Logger.log(message);
    
    // 管理者に送信完了通知
    const adminEmail = Session.getActiveUser().getEmail();
    MailApp.sendEmail({
      to: adminEmail,
      subject: '【システム】安否確認メール送信完了',
      body: message
    });
    
    return message;
    
  } catch (error) {
    Logger.log(`一斉送信エラー: ${error.message}`);
    throw error;
  }
}

/**
 * 個別の安否確認メール送信
 */
function sendSafetyCheckEmail(employee, sendTime) {
  // ユニークトークンを生成(簡易版)
  const token = Utilities.computeDigest(
    Utilities.DigestAlgorithm.SHA_1, 
    employee.id + sendTime.getTime().toString()
  ).map(byte => (byte + 256).toString(16).substr(-2)).join('');
  
  // 動的にWebアプリURLを取得
  const baseUrl = getWebAppUrl();
  
  // 回答用URL
  const safeUrl = `${baseUrl}?action=respond&token=${token}&empId=${employee.id}&response=safe`;
  const needHelpUrl = `${baseUrl}?action=respond&token=${token}&empId=${employee.id}&response=need_help`;
  
  // メール本文
  const body = `
${employee.name} 様

お疲れ様です。
緊急時の安否確認を実施しております。

下記のいずれかのリンクをクリックして、現在の状況をお知らせください。

■ 無事です
${safeUrl}

■ 支援が必要です
${needHelpUrl}

※このメールは自動送信です。返信は不要です。
※リンクは1回のみ有効です。

${CONFIG.SENDER_NAME}
  `.trim();
  
  // メール送信
  MailApp.sendEmail({
    to: employee.email,
    subject: CONFIG.MAIL_SUBJECT,
    body: body
  });
  
  Logger.log(`メール送信: ${employee.name} (${employee.email})`);
}

// ========================================
// 4. 社員リスト管理
// ========================================

/**
 * 社員リストを取得
 */
function getEmployeeList() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName(CONFIG.SHEET_NAME);
  
  const data = sheet.getDataRange().getValues();
  const employees = [];
  
  // ヘッダー行をスキップして処理
  for (let i = 1; i < data.length; i++) {
    const row = data[i];
    if (row[0] && row[1] && (row[2] || row[3])) { // ID、名前、いずれかのメールが必要
      employees.push({
        id: row[0],
        name: row[1],
        email: row[2] || row[3], // 社用メール優先、なければ私用メール
        companyEmail: row[2],
        privateEmail: row[3]
      });
    }
  }
  
  return employees;
}

// ========================================
// 5. Web アプリケーション(回答受付)
// ========================================

/**
 * Webアプリのエントリーポイント
 */
function doGet(e) {
  const action = e.parameter.action;
  
  if (action === 'respond') {
    return handleResponse(e);
  } else if (action === 'dashboard') {
    return showDashboard();
  }
  
  // デフォルトは管理画面
  return showAdminPanel();
}

/**
 * 安否確認回答を処理
 */
function handleResponse(e) {
  try {
    // デバッグログ
    Logger.log('=== 回答処理開始 ===');
    Logger.log('受信パラメータ:', JSON.stringify(e.parameter));
    
    const token = e.parameter.token;
    const empId = e.parameter.empId;
    const response = e.parameter.response;
    const userIP = getClientIpAddress(e);
    
    Logger.log(`トークン: ${token}, 社員ID: ${empId}, 回答: ${response}, IP: ${userIP}`);
    
    if (!token || !empId || !response) {
      Logger.log('エラー: 必須パラメータが不足しています');
      return HtmlService.createHtmlOutput('無効なリクエストです。必須パラメータが不足しています。');
    }
    
    // 回答を記録
    const responseText = response === 'safe' ? CONFIG.RESPONSES.SAFE : CONFIG.RESPONSES.NEED_HELP;
    const responseTime = new Date();
    
    Logger.log(`回答テキスト: ${responseText}, 回答時刻: ${responseTime}`);
    
    const ss = SpreadsheetApp.getActiveSpreadsheet();
    const responseSheet = ss.getSheetByName(CONFIG.RESPONSE_SHEET_NAME);
    
    if (!responseSheet) {
      Logger.log('エラー: 回答状況シートが見つかりません');
      return HtmlService.createHtmlOutput('システムエラー: 回答シートが見つかりません。');
    }
    
    // 回答記録方法を改善(より確実な方法)
    let updated = false;
    
    try {
      // 方法1: 既存の送信記録を更新
      const data = responseSheet.getDataRange().getValues();
      Logger.log(`シートデータ行数: ${data.length}`);
      
      for (let i = 1; i < data.length; i++) {
        Logger.log(`${i}: 社員ID=${data[i][1]}, 既存回答=${data[i][4]}`);
        
        if (String(data[i][1]) === String(empId) && !data[i][4]) { // 社員IDが一致し、まだ回答していない
          responseSheet.getRange(i + 1, 5, 1, 3).setValues([[responseText, responseTime, userIP]]);
          Logger.log(`${i + 1}を更新しました`);
          updated = true;
          break;
        }
      }
      
      // 方法2: 送信記録が見つからない場合は新規行を追加
      if (!updated) {
        Logger.log('既存記録が見つからないため、新規行を追加します');
        
        // 社員情報を取得
        const employees = getEmployeeList();
        const employee = employees.find(emp => emp.id === empId);
        const employeeName = employee ? employee.name : '不明';
        const employeeEmail = employee ? employee.email : '不明';
        
        // 新規行を追加
        responseSheet.appendRow([
          new Date(), // 送信日時(回答時刻で代用)
          empId,
          employeeName,
          employeeEmail,
          responseText,
          responseTime,
          userIP
        ]);
        
        Logger.log('新規行を追加しました');
        updated = true;
      }
      
    } catch (sheetError) {
      Logger.log('シート更新エラー:', sheetError.toString());
      return HtmlService.createHtmlOutput('システムエラー: データ記録に失敗しました。');
    }
    
    if (!updated) {
      Logger.log(`回答記録失敗: 社員ID ${empId} の処理に失敗しました`);
      return HtmlService.createHtmlOutput('エラー: 回答の記録に失敗しました。管理者にお問い合わせください。');
    }
    
    Logger.log('=== 回答処理完了 ===');
    
    // 回答完了画面
    const html = `
    <!DOCTYPE html>
    <html>
    <head>
      <meta charset="utf-8">
      <title>回答完了</title>
      <style>
        body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
        .success { color: #28a745; font-size: 18px; }
        .response { font-weight: bold; color: #007bff; }
      </style>
    </head>
    <body>
      <h2 class="success">✓ 回答を受け付けました</h2>
      <p>状況: <span class="response">${responseText}</span></p>
      <p>回答日時: ${responseTime.toLocaleString('ja-JP')}</p>
      <p>ご協力ありがとうございました。</p>
    </body>
    </html>
    `;
    
    return HtmlService.createHtmlOutput(html);
    
  } catch (error) {
    Logger.log(`回答処理エラー: ${error.message}`);
    return HtmlService.createHtmlOutput('エラーが発生しました。管理者にお問い合わせください。');
  }
}

/**
 * 管理者用ダッシュボード
 */
function showAdminPanel() {
  const html = `
  <!DOCTYPE html>
  <html>
  <head>
    <meta charset="utf-8">
    <title>安否確認システム 管理画面</title>
    <style>
      body { font-family: Arial, sans-serif; margin: 20px; }
      .button { 
        background-color: #007bff; color: white; padding: 10px 20px; 
        border: none; border-radius: 4px; cursor: pointer; margin: 5px;
        font-size: 14px;
      }
      .button:hover { background-color: #0056b3; }
      .danger { background-color: #dc3545; }
      .danger:hover { background-color: #c82333; }
      .section { margin: 20px 0; padding: 15px; border: 1px solid #ddd; border-radius: 4px; }
    </style>
  </head>
  <body>
    <h1>安否確認システム 管理画面</h1>
    
    <div class="section">
      <h3>システム初期化</h3>
      <p>初回利用時のみ実行してください。</p>
      <button class="button" onclick="runFunction('initializeSheets')">シート初期化</button>
    </div>
    
    <div class="section">
      <h3>安否確認送信</h3>
      <p>全社員に安否確認メールを送信します。</p>
      <button class="button danger" onclick="sendEmails()">安否確認メール送信</button>
    </div>
    
    <div class="section">
      <h3>結果確認</h3>
      <button class="button" onclick="openDashboard()">
        回答状況ダッシュボード
      </button>
    </div>
    
    <div class="section">
      <h3>システム情報</h3>
      <p>WebアプリURL: <span id="webAppUrl">取得中...</span></p>
      <button class="button" onclick="getSystemInfo()">URL確認</button>
    </div>
    
    <script>
      function runFunction(functionName) {
        if (confirm('実行してもよろしいですか?')) {
          google.script.run
            .withSuccessHandler(function(result) {
              alert('完了: ' + result);
            })
            .withFailureHandler(function(error) {
              alert('エラー: ' + error.message);
            })[functionName]();
        }
      }
      
      function sendEmails() {
        if (confirm('安否確認メールを送信してもよろしいですか?\\n\\n※全社員にメールが送信されます。')) {
          google.script.run
            .withSuccessHandler(function(result) {
              alert(result);
            })
            .withFailureHandler(function(error) {
              alert('送信エラー: ' + error.message);
            })
            .sendSafetyCheckEmails();
        }
      }
      
      function openDashboard() {
        google.script.run
          .withSuccessHandler(function(url) {
            window.open(url + '?action=dashboard', '_blank');
          })
          .withFailureHandler(function(error) {
            alert('エラー: ' + error.message);
          })
          .getWebAppUrl();
      }
      
      function getSystemInfo() {
        google.script.run
          .withSuccessHandler(function(url) {
            document.getElementById('webAppUrl').textContent = url;
          })
          .withFailureHandler(function(error) {
            document.getElementById('webAppUrl').textContent = 'エラー: ' + error.message;
          })
          .getWebAppUrl();
      }
      
      // ページ読み込み時にURL取得
      window.onload = function() {
        getSystemInfo();
      };
    </script>
  </body>
  </html>
  `;
  
  return HtmlService.createHtmlOutput(html).setTitle('安否確認システム');
}

/**
 * 回答状況ダッシュボード
 */
function showDashboard() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const responseSheet = ss.getSheetByName(CONFIG.RESPONSE_SHEET_NAME);
  
  if (!responseSheet) {
    return HtmlService.createHtmlOutput('回答データがありません。');
  }
  
  const data = responseSheet.getDataRange().getValues();
  const stats = analyzeResponses(data);
  
  const html = `
  <!DOCTYPE html>
  <html>
  <head>
    <meta charset="utf-8">
    <title>安否確認 回答状況</title>
    <style>
      body { font-family: Arial, sans-serif; margin: 20px; }
      .stats { display: flex; gap: 20px; margin: 20px 0; }
      .stat-card { 
        background: #f8f9fa; padding: 15px; border-radius: 8px; 
        text-align: center; min-width: 120px;
      }
      .stat-number { font-size: 24px; font-weight: bold; margin: 5px 0; }
      .safe { color: #28a745; }
      .need-help { color: #dc3545; }
      .pending { color: #ffc107; }
      table { width: 100%; border-collapse: collapse; margin: 20px 0; }
      th, td { border: 1px solid #ddd; padding: 8px; text-align: left; }
      th { background-color: #f8f9fa; }
      .status-safe { color: #28a745; font-weight: bold; }
      .status-help { color: #dc3545; font-weight: bold; }
      .status-pending { color: #ffc107; font-weight: bold; }
    </style>
  </head>
  <body>
    <h1>安否確認 回答状況</h1>
    <p>最終更新: ${new Date().toLocaleString('ja-JP')}</p>
    
    <div class="stats">
      <div class="stat-card">
        <div>送信数</div>
        <div class="stat-number">${stats.total}</div>
      </div>
      <div class="stat-card">
        <div>回答数</div>
        <div class="stat-number">${stats.responded}</div>
      </div>
      <div class="stat-card">
        <div class="safe">無事</div>
        <div class="stat-number safe">${stats.safe}</div>
      </div>
      <div class="stat-card">
        <div class="need-help">要支援</div>
        <div class="stat-number need-help">${stats.needHelp}</div>
      </div>
      <div class="stat-card">
        <div class="pending">未回答</div>
        <div class="stat-number pending">${stats.pending}</div>
      </div>
    </div>
    
    <h3>詳細一覧</h3>
    <table>
      <thead>
        <tr>
          <th>社員ID</th>
          <th>氏名</th>
          <th>送信日時</th>
          <th>回答</th>
          <th>回答日時</th>
        </tr>
      </thead>
      <tbody>
        ${stats.details.map(row => `
          <tr>
            <td>${row.empId}</td>
            <td>${row.name}</td>
            <td>${row.sendTime}</td>
            <td class="status-${row.statusClass}">${row.response}</td>
            <td>${row.responseTime}</td>
          </tr>
        `).join('')}
      </tbody>
    </table>
    
    <button onclick="location.reload()">更新</button>
  </body>
  </html>
  `;
  
  return HtmlService.createHtmlOutput(html).setTitle('回答状況');
}

/**
 * 回答データを分析
 */
function analyzeResponses(data) {
  const stats = {
    total: 0,
    responded: 0,
    safe: 0,
    needHelp: 0,
    pending: 0,
    details: []
  };
  
  // ヘッダー行をスキップ
  for (let i = 1; i < data.length; i++) {
    const row = data[i];
    if (!row[1]) continue; // 社員IDがない行はスキップ
    
    stats.total++;
    
    const response = row[4] || '';
    const hasResponse = response !== '';
    
    if (hasResponse) {
      stats.responded++;
      if (response === CONFIG.RESPONSES.SAFE) {
        stats.safe++;
      } else if (response === CONFIG.RESPONSES.NEED_HELP) {
        stats.needHelp++;
      }
    } else {
      stats.pending++;
    }
    
    // 詳細データ
    stats.details.push({
      empId: row[1],
      name: row[2],
      sendTime: row[0] ? new Date(row[0]).toLocaleString('ja-JP') : '',
      response: response || '未回答',
      responseTime: row[5] ? new Date(row[5]).toLocaleString('ja-JP') : '',
      statusClass: hasResponse ? 
        (response === CONFIG.RESPONSES.SAFE ? 'safe' : 'help') : 'pending'
    });
  }
  
  return stats;
}

// ========================================
// 6. ユーティリティ関数
// ========================================

/**
 * クライアントIPアドレスを取得
 */
function getClientIpAddress(e) {
  try {
    // Google Apps ScriptでのクライアントIP取得方法
    return e.parameter.userip || 
           e.headers['X-Forwarded-For'] || 
           e.headers['x-forwarded-for'] || 
           'unknown';
  } catch (error) {
    return 'unknown';
  }
}

/**
 * デバッグ用:回答状況の詳細ログ
 */
function debugResponseStatus() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const responseSheet = ss.getSheetByName(CONFIG.RESPONSE_SHEET_NAME);
  
  if (!responseSheet) {
    Logger.log('回答状況シートが見つかりません');
    return;
  }
  
  const data = responseSheet.getDataRange().getValues();
  Logger.log('=== 回答状況デバッグ ===');
  Logger.log('総行数:', data.length);
  
  for (let i = 0; i < Math.min(data.length, 10); i++) {
    Logger.log(`${i}: [${data[i].join(', ')}]`);
  }
}

/**
 * 強制的に回答を記録(デバッグ用)
 */
function forceRecordResponse(empId, responseType) {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const responseSheet = ss.getSheetByName(CONFIG.RESPONSE_SHEET_NAME);
  
  const responseText = responseType === 'safe' ? CONFIG.RESPONSES.SAFE : CONFIG.RESPONSES.NEED_HELP;
  const responseTime = new Date();
  
  // 既存記録を検索して更新
  const data = responseSheet.getDataRange().getValues();
  let updated = false;
  
  for (let i = 1; i < data.length; i++) {
    if (data[i][1] === empId) {
      responseSheet.getRange(i + 1, 5, 1, 3).setValues([[responseText, responseTime, 'manual-debug']]);
      Logger.log(`強制更新完了: 行${i + 1}, 社員ID: ${empId}, 回答: ${responseText}`);
      updated = true;
      break;
    }
  }
  
  if (!updated) {
    Logger.log(`社員ID ${empId} の記録が見つかりませんでした`);
  }
  
  return updated;
}
function getNoResponseList() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const responseSheet = ss.getSheetByName(CONFIG.RESPONSE_SHEET_NAME);
  
  const data = responseSheet.getDataRange().getValues();
  const noResponseList = [];
  
  for (let i = 1; i < data.length; i++) {
    const row = data[i];
    if (row[1] && !row[4]) { // 社員IDがあり、回答がない
      noResponseList.push({
        empId: row[1],
        name: row[2],
        email: row[3],
        sendTime: row[0]
      });
    }
  }
  
  return noResponseList;
}

/**
 * 未回答者にリマインドメール送信
 */
function sendReminderEmails() {
  const noResponseList = getNoResponseList();
  
  if (noResponseList.length === 0) {
    Logger.log('未回答者はいません。');
    return '未回答者はいません。';
  }
  
  let count = 0;
  noResponseList.forEach(employee => {
    try {
      sendSafetyCheckEmail(employee, new Date());
      count++;
    } catch (error) {
      Logger.log(`リマインド送信エラー - ${employee.name}: ${error.message}`);
    }
  });
  
  const message = `リマインドメール送信完了: ${count}/${noResponseList.length}`;
  Logger.log(message);
  return message;
}

// ========================================
// 8. 緊急修正・デバッグ用関数
// ========================================

/**
 * 現在のWebアプリURLを確認
 */
function checkCurrentWebAppUrl() {
  const url = getWebAppUrl();
  Logger.log('現在のWebアプリURL: ' + url);
  console.log('現在のWebアプリURL: ' + url);
  return url;
}

/**
 * 手動でWebアプリURLを設定(緊急時用)
 */
function setManualWebAppUrl(manualUrl) {
  // 一時的に手動でURLを設定する場合
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  PropertiesService.getScriptProperties().setProperty('MANUAL_WEBAPP_URL', manualUrl);
  Logger.log('手動URL設定: ' + manualUrl);
}

/**
 * 設定済みの手動URLを取得
 */
function getManualWebAppUrl() {
  return PropertiesService.getScriptProperties().getProperty('MANUAL_WEBAPP_URL');
}

/**
 * 改良版WebアプリURL取得(手動設定優先)
 */
function getWebAppUrl() {
  // 1. 手動設定されたURLを優先
  const manualUrl = getManualWebAppUrl();
  if (manualUrl) {
    return manualUrl;
  }
  
  // 2. 自動取得を試行
  try {
    const scriptId = ScriptApp.getScriptId();
    return `https://script.google.com/macros/s/${scriptId}/exec`;
  } catch (error) {
    Logger.log('スクリプトID取得エラー: ' + error.message);
    
    // 3. フォールバック:デフォルトURL
    return 'https://script.google.com/macros/s/YOUR_SCRIPT_ID/exec';
  }
}

/**
 * デプロイ情報を確認
 */
function checkDeploymentInfo() {
  try {
    const scriptId = ScriptApp.getScriptId();
    const url = getWebAppUrl();
    
    const info = {
      scriptId: scriptId,
      webAppUrl: url,
      manualUrl: getManualWebAppUrl(),
      timestamp: new Date().toISOString()
    };
    
    Logger.log('デプロイ情報: ' + JSON.stringify(info, null, 2));
    return info;
  } catch (error) {
    Logger.log('デプロイ情報取得エラー: ' + error.message);
    return { error: error.message };
  }
}

/**
 * 不正なURLからの修正テスト
 */
function fixBrokenUrls() {
  // あなたのケースの正しいURL例
  const yourDomain = '';
  const scriptId = ''; // 実際のスクリプトIDに置き換え
  
  const correctUrl = `https://script.google.com/macros/s/${scriptId}/exec`;
  
  // 手動設定
  setManualWebAppUrl(correctUrl);
  
  Logger.log('修正済みURL: ' + correctUrl);
  return correctUrl;
}

/**
 * テスト用:単一メール送信
 */
function testSendSingleEmail() {
  const testEmployee = {
    id: 'TEST001',
    name: 'テスト太郎',
    email: Session.getActiveUser().getEmail() // 自分のメールアドレス
  };
  
  sendSafetyCheckEmail(testEmployee, new Date());
  Logger.log('テストメールを送信しました: ' + testEmployee.email);
}

Discussion