🚧

[GAS][LINE]道路通報アプリ

に公開

本記事の分類

  • 学習ノート

機能

  • LINE公式アカウントのリッチメニューから投稿ページを開く
  • 道路の不具合情報(内容、写真、位置)を投稿する
  • 道路の不具合情報をGoogleSpreadSheetに保存する
  • 投稿者のラインアカウントへ投稿内容を送信する
  • 登録メールアドレスへ投稿内容を送信する

想定シーン

  • 道路の穴ボコ等をみつけた人が土木事務所にラインから通報する

仕様

システム仕様は下図のとおり

スマホ画面の仕様は下図のとおり

GoogleSpreadSheetの仕様は下図のとおり

code(frontend)

  • editorは何でも良いです。作成ファイルはindex.html、script.js、style.css。(下図には余計なファイルも表示されています。)

index.html

  • 以下のとおり
createStaffTable

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>道路の不具合通報フォーム</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
<link rel="stylesheet" href="style.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
<!-- LIFF SDK追加 -->
<script defer charset="utf-8" src="https://static.line-scdn.net/liff/edge/2/sdk.js"></script>
<script defer src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<script defer src="script.js"></script>
</head>
<body>

<!-- ローディング画面 -->
<div id="loader" class="loader-overlay hidden">
    <div class="loader"></div>
    <p id="loader-text" class="loader-text">処理中です...</p>
</div>

<!-- メインコンテンツ -->
<div class="container">
    <form id="report-form" novalidate>
        <h2>道路の不具合を通報</h2>
        
        <!-- LINE連携状態表示 -->
        <div id="line-status" class="line-status hidden">
            <div class="line-status-content">
                <i class="fab fa-line line-icon"></i>
                <span id="line-status-text">LINE連携を確認中...</span>
            </div>
        </div>
        
        <div class="form-group">
            <label class="form-label">1. 通報内容</label>
            <div class="radio-group">
                <div class="radio-item">
                    <input type="radio" id="type1" name="type" value="雑草" required>
                    <label for="type1">雑草</label>
                </div>
                <div class="radio-item">
                    <input type="radio" id="type2" name="type" value="倒木">
                    <label for="type2">倒木</label>
                </div>
                <div class="radio-item">
                    <input type="radio" id="type3" name="type" value="路面の穴ぼこ・段差">
                    <label for="type3">路面の穴ぼこ・段差</label>
                </div>
                <div class="radio-item">
                    <input type="radio" id="type4" name="type" value="落下物・汚れ">
                    <label for="type4">落下物・汚れ</label>
                </div>
                <div class="radio-item">
                    <input type="radio" id="type-other" name="type" value="その他">
                    <label for="type-other">その他</label>
                </div>
            </div>
        </div>
        
        <div class="form-group">
          <label for="details" class="form-label">
           詳細(100文字まで) <span class="label-note" id="details-required-note"></span>
          </label>
          <textarea id="details" name="details" rows="4" placeholder="例:直径20cm程度の穴ぼこ" maxlength="100"></textarea>
        </div>
        <div class="form-group">
            <label for="photo">3. 現場の写真 (任意)</label>
            <div class="photo-controls">
              <label for="photo" class="button-like-input">
                <i class="fas fa-file-image"></i> ファイルを選択
              </label>
              <input type="file" id="photo" name="photo" accept="image/*" style="display: none;">
            </div>
            <img id="image-preview" src="#" alt="写真プレビュー"/>
        </div>
        
        <div class="form-group">
            <label>4. 現場の場所</label>
            <div id="map-wrapper">
                <div id="map"></div>
                <i id="center-pin" class="fas fa-map-marker-alt"></i>
            </div>
            <div id="coords-display">地図を動かして位置を合わせてください</div>
            <input type="hidden" id="latitude" name="latitude" required>
            <input type="hidden" id="longitude" name="longitude" required>
            <!-- LINEアクセストークン用の隠しフィールド -->
            <input type="hidden" id="accessToken" name="accessToken">
            <input type="hidden" id="userId" name="userId">
        </div>
        
        <button type="submit" id="btn-submit" disabled>不具合の種類を選択してください</button>
    </form>
</div>

</body>
</html>

script.js

  • 以下のとおり
createStaffTable
// script.js - LINE Login channel対応版


// ▼▼▼【重要】設定値を更新してください ▼▼▼
const APP_SETTINGS = {
  MAX_RETRY_ATTEMPTS: 3,
  RETRY_DELAY: 1000,
  REQUEST_TIMEOUT: 30000,
  MAX_FILE_SIZE: 5 * 1024 * 1024,
  ALLOWED_FILE_TYPES: ['image/jpeg', 'image/png', 'image/gif', 'image/webp'],
  DETAILS_MAX_LENGTH: 100
};
// ▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲

// グローバル変数
let currentPhoto = { data: null, mimeType: null };
let lineAccessToken = null;
let lineUserId = null;
let CONFIG = {};
let elements = {};

document.addEventListener('DOMContentLoaded', async function() {
  try {
    // 1. Cloudflareなどのエンドポイントから環境依存の設定値を取得(無ければデフォルトで続行)
    let envConfig = {};
    try {
      const response = await fetch('/api/config', { cache: 'no-store' });
      if (response.ok) {
        envConfig = await response.json();
      } else {
        console.warn(`設定エンドポイントが見つかりませんでした: ${response.status} ${response.statusText} - デフォルト設定で起動します。`);
      }
    } catch (e) {
      console.warn('設定エンドポイントの取得に失敗しました(オフライン/ローカル想定): デフォルト設定で起動します。', e);
    }

    // 2. 固定的な設定値とマージして、最終的なCONFIGオブジェクトを完成させる。
    CONFIG = { ...APP_SETTINGS, ...envConfig };

    console.log('アプリケーション設定が完了しました。:', CONFIG);

    // 要素の取得
    elements = {
      map: L.map('map').setView([36.871, 140.016], 16),
      coordsDisplay: document.getElementById('coords-display'),
      latInput: document.getElementById('latitude'),
      lngInput: document.getElementById('longitude'),
      form: document.getElementById('report-form'),
      btnSubmit: document.getElementById('btn-submit'),
      loader: document.getElementById('loader'),
      photoInput: document.getElementById('photo'),
      imagePreview: document.getElementById('image-preview'),
      lineStatus: document.getElementById('line-status'),
      lineStatusText: document.getElementById('line-status-text'),
      accessTokenInput: document.getElementById('accessToken'),
      userIdInput: document.getElementById('userId'),
      detailsTextarea: document.getElementById('details'), // 詳細テキストエリア
      detailsRequiredNote: document.getElementById('details-required-note'), // 注釈用span
      detailsOverlay: document.getElementById('details-overlay'), // 詳細ハイライト用オーバーレイ
      typeRadios: document.querySelectorAll('input[name="type"]') // 異常の種類ラジオボタン(すべて)
    };

    // === LIFF初期化 ===
    initializeLIFF();

    // === 地図の初期化 ===
    initializeMap(elements);

    // === フォーム機能の初期化 ===
    initializeFormFeatures(elements);

    // 初期の送信ボタン状態を更新
    updateSubmitButtonState();
  } catch (error) {
    console.error('初期化エラー:', error);
    showNotification(error.message, 'error');
  }
  // === LIFF初期化関数(修正版) ===
  async function initializeLIFF() {
    try {
      console.log('LIFF初期化開始');

      if (CONFIG.LIFF_ID === 'LINE Login channelで作成したLIFF ID') {
        console.warn('LIFF_IDが設定されていません');
        updateLineStatus('warning', 'LIFF設定が必要です');
        return;
      }

      await liff.init({ liffId: CONFIG.LIFF_ID });
      console.log('LIFF初期化成功');

      if (liff.isLoggedIn()) {
        // アクセストークンを取得
        lineAccessToken = liff.getAccessToken();

        // プロフィール情報を取得
        const profile = await liff.getProfile();
        lineUserId = profile.userId;

        // ↓↓↓ この一行を追加する ↓↓↓
        console.log('【デバッグ用】取得したアクセストークン:', lineAccessToken);
        // ↑↑↑ この一行を追加する ↑↑↑

        // 隠しフィールドに設定
        elements.accessTokenInput.value = lineAccessToken;
        elements.userIdInput.value = lineUserId;

        updateLineStatus('success', `LINE連携済み: ${profile.displayName}`);
        console.log('LINEユーザー情報取得成功:', profile);
      } else {
        updateLineStatus('error', 'LINEログインが必要です');
        console.log('LINEログインが必要');

        // 自動ログインを試行
        try {
          await liff.login();
        } catch (loginError) {
          console.error('自動ログイン失敗:', loginError);
        }
      }
    } catch (error) {
      console.error('LIFF初期化エラー:', error);
      updateLineStatus('error', 'LINE連携エラー');
    }
  }

  // === LINE連携状態表示関数 ===
  function updateLineStatus(status, message) {
    if (!elements.lineStatus || !elements.lineStatusText) return;

    elements.lineStatus.className = `line-status ${status}`;
    elements.lineStatusText.textContent = message;
    elements.lineStatus.classList.remove('hidden');

    // 5秒後に非表示(成功時のみ)
    if (status === 'success') {
      setTimeout(() => {
        elements.lineStatus.classList.add('hidden');
      }, 5000);
    }
  }

  // === 地図初期化関数 ===
  function initializeMap(elements) {
    L.tileLayer('https://cyberjapandata.gsi.go.jp/xyz/std/{z}/{x}/{y}.png', {
      attribution: "地理院タイル(GSI)",
      maxZoom: 18
    }).addTo(elements.map);

    function updateCenterCoords() {
      const center = elements.map.getCenter();
      elements.coordsDisplay.innerText = `緯度: ${center.lat.toFixed(6)} 経度: ${center.lng.toFixed(6)}`;
      elements.latInput.value = center.lat;
      elements.lngInput.value = center.lng;
      // 位置が更新されたら送信ボタンの状態も更新
      updateSubmitButtonState();
    }

    elements.map.on('move', updateCenterCoords);
    updateCenterCoords();

    // 現在位置の取得
    if (navigator.geolocation) {
      navigator.geolocation.getCurrentPosition(
        function(pos) {
          elements.map.setView([pos.coords.latitude, pos.coords.longitude], 18);
        },
        function(error) {
          console.warn('位置情報の取得に失敗しました:', error);
          showNotification('位置情報の取得に失敗しました。手動で位置を調整してください。', 'warning');
        }
      );
    }
  }

  // === フォーム機能初期化 ===
  function initializeFormFeatures(elements) {

    // 「その他」選択時に詳細を必須にするためのイベントリスナー
    elements.typeRadios.forEach(radio => {
      radio.addEventListener('change', () => {
        handleTypeChange();
        updateSubmitButtonState();
      });
    });

    // 詳細の入力を日本語換算(コードポイント)で上限制御
    if (elements.detailsTextarea) {
      const limit = CONFIG.DETAILS_MAX_LENGTH ?? 100;
      // 基本的なブラウザ側の制限(UTF-16単位)も設定
      elements.detailsTextarea.setAttribute('maxlength', String(limit));
      elements.detailsTextarea.addEventListener('input', () => {
        const chars = Array.from(elements.detailsTextarea.value || '');
        if (chars.length > limit) {
          elements.detailsTextarea.value = chars.slice(0, limit).join('');
        }
        updateSubmitButtonState();
      });
    }

    // 詳細文字数ハイライト・注記
    if (elements.detailsTextarea && elements.detailsOverlay) {
      elements.detailsTextarea.addEventListener('input', () => {
        updateDetailsOverlayAndNote();
      });
      elements.detailsTextarea.addEventListener('scroll', () => {
        // スクロール同期(transformで追従させる)
        const st = elements.detailsTextarea.scrollTop;
        const sl = elements.detailsTextarea.scrollLeft;
        elements.detailsOverlay.style.transform = `translate(${-sl}px, ${-st}px)`;
      });
      // 初期描画
      updateDetailsOverlayAndNote();
    }

    // 初期状態のチェックも実行(必須表示含めて更新)
    handleTypeChange();

    // 写真プレビュー
    elements.photoInput.addEventListener('change', function() {
      handlePhotoInput(this, elements);
    });

    // フォーム送信
    elements.form.addEventListener('submit', function(e) {
      e.preventDefault();
      if (!elements.loader.classList.contains('sending')) {
        const formData = new FormData(this);
        handleFormSubmission(formData, elements);
      }
    });

    // フォーム全体の変化でもボタン状態を更新(保険)
    elements.form.addEventListener('input', updateSubmitButtonState);
    elements.form.addEventListener('change', updateSubmitButtonState);
  }

  // 「異常の種類」が変更されたときのハンドラ関数
  function handleTypeChange() {
    const elements = { // この関数内で使う要素を再定義
      detailsTextarea: document.getElementById('details'),
      detailsRequiredNote: document.getElementById('details-required-note'),
      otherRadio: document.getElementById('type-other') // 「その他」のラジオボタン
    };

    if (elements.otherRadio && elements.otherRadio.checked) {
      // 「その他」が選択されている場合
      elements.detailsTextarea.required = true;
      // 文字数注記を含めて更新
      updateDetailsNote();
    } else {
      // 「その他」以外が選択されている場合
      elements.detailsTextarea.required = false;
      // 文字数注記を含めて更新
      updateDetailsNote();
    }
  }

  // 詳細のオーバーレイ更新と注記更新
  function updateDetailsOverlayAndNote() {
    const textarea = document.getElementById('details');
    const overlay = document.getElementById('details-overlay');
    if (!textarea || !overlay) return;

    const limit = CONFIG.DETAILS_MAX_LENGTH ?? 100;
    const chars = Array.from(textarea.value || '');
    const count = chars.length;

    // HTMLエスケープ
    const esc = (s) => s
      .replace(/&/g, '&amp;')
      .replace(/</g, '&lt;')
      .replace(/>/g, '&gt;')
      .replace(/\n/g, '<br>')
      .replace(/ /g, '&nbsp;');

    if (count <= limit) {
      overlay.innerHTML = esc(chars.join(''));
    } else {
      const normal = chars.slice(0, limit).join('');
      const over = chars.slice(limit).join('');
      overlay.innerHTML = esc(normal) + '<span class="overflow">' + esc(over) + '</span>';
    }

    // スクロール位置を同期(入力時)
    const st = textarea.scrollTop;
    const sl = textarea.scrollLeft;
    overlay.style.transform = `translate(${-sl}px, ${-st}px)`;

    // ラベル注記の更新
    updateDetailsNote(count > limit);
  }

  // ラベル注記の更新(必須と100文字注記の併記対応)
  function updateDetailsNote(exceeded) {
    const note = document.getElementById('details-required-note');
    const otherRadio = document.getElementById('type-other');
    if (!note) return;

    const parts = [];
    if (otherRadio && otherRadio.checked) parts.push('(必須入力)');
    if (typeof exceeded === 'undefined') {
      // exceededが未指定なら、現在の入力から判定
      const len = Array.from((document.getElementById('details')?.value) || '').length;
      const limit = CONFIG.DETAILS_MAX_LENGTH ?? 100;
      if (len > limit) parts.push('(100文字以内)');
    } else if (exceeded) {
      parts.push('(100文字以内)');
    }

    note.textContent = parts.join('');
  }


  // === 共通ユーティリティ関数 ===

  // 通知表示(統合版)
  function showNotification(message, type = 'info') {
    const existingNotification = document.querySelector('.notification');
    if (existingNotification) existingNotification.remove();

    const notification = document.createElement('div');
    notification.className = `notification notification-${type}`;
    notification.textContent = message;

    const colors = {
      success: '#10b981',
      error: '#ef4444',
      warning: '#f59e0b',
      info: '#3b82f6'
    };

    notification.style.cssText = `
      position: fixed; top: 20px; right: 20px; padding: 12px 20px;
      border-radius: 4px; color: white; font-weight: bold; z-index: 10000;
      max-width: 300px; word-wrap: break-word; overflow-wrap: break-word; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
      background-color: ${colors[type] || colors.info};
    `;

    document.body.appendChild(notification);
    setTimeout(() => notification.remove(), 5000);
  }

  // 画像をビットマップ化してから再エンコード(JPEG)する共通関数
  async function bitmapizeAndEncode(fileOrDataUrl, options = {}) {
    const {
      maxWidth = 1280,
      maxHeight = 1280,
      quality = 0.85,
      mimeType = 'image/jpeg',
      background = '#fff'
    } = options;

    // 入力をBlobに正規化
    let srcBlob;
    if (fileOrDataUrl instanceof Blob) {
      srcBlob = fileOrDataUrl;
    } else if (typeof fileOrDataUrl === 'string') {
      // dataURLやHTTP URL想定(dataURLのみを主に想定)
      const res = await fetch(fileOrDataUrl);
      srcBlob = await res.blob();
    } else {
      throw new Error('bitmapizeAndEncode: 未対応の入力タイプです');
    }

    // デコードしてビットマップへ
    let bmp, width, height;
    try {
      // createImageBitmapが使える場合はEXIFの向き適用に期待
      // Safari等ではオプション未対応のためtry-catchでフォールバック
      bmp = await createImageBitmap(srcBlob, { imageOrientation: 'from-image' });
      width = bmp.width;
      height = bmp.height;
    } catch {
      // フォールバック: Image要素 + ObjectURL
      const url = URL.createObjectURL(srcBlob);
      try {
        const img = await new Promise((resolve, reject) => {
          const i = new Image();
          i.onload = () => resolve(i);
          i.onerror = reject;
          i.src = url;
        });
        width = img.naturalWidth || img.width;
        height = img.naturalHeight || img.height;
        // Canvasに描画してビットマップ化
        const cvs = document.createElement('canvas');
        cvs.width = width; cvs.height = height;
        const ctx = cvs.getContext('2d');
        ctx.drawImage(img, 0, 0);
        // CanvasからImageBitmapへ(対応ブラウザのみ)
        if (window.createImageBitmap) {
          bmp = await createImageBitmap(cvs);
        } else {
          // 最低限、既にキャンバスにラスタライズ済みなのでこのまま扱う
          bmp = cvs;
        }
      } finally {
        URL.revokeObjectURL(url);
      }
    }

    // サイズ調整(アスペクト比維持)
    let targetW = width;
    let targetH = height;
    if (targetW > targetH) {
      if (targetW > maxWidth) {
        targetH = Math.round((maxWidth / targetW) * targetH);
        targetW = maxWidth;
      }
    } else {
      if (targetH > maxHeight) {
        targetW = Math.round((maxHeight / targetH) * targetW);
        targetH = maxHeight;
      }
    }

    // 描画用キャンバス(OffscreenCanvasがあれば利用)
    const hasOffscreen = typeof OffscreenCanvas !== 'undefined';
    const cvs = hasOffscreen ? new OffscreenCanvas(targetW, targetH) : document.createElement('canvas');
    if (!hasOffscreen) {
      cvs.width = targetW; cvs.height = targetH;
    }
    const ctx = cvs.getContext('2d');

    // 透明PNG/GIF対策で背景を塗る
    ctx.clearRect(0, 0, targetW, targetH);
    ctx.globalCompositeOperation = 'source-over';
    ctx.fillStyle = background;
    ctx.fillRect(0, 0, targetW, targetH);
    ctx.drawImage(bmp, 0, 0, targetW, targetH);

    // エンコード(JPEG)
    if (cvs.convertToBlob) {
      // OffscreenCanvas
      const blob = await cvs.convertToBlob({ type: mimeType, quality });
      return await blobToDataURL(blob);
    } else {
      // HTMLCanvasElement
      const dataUrl = cvs.toDataURL(mimeType, quality);
      return dataUrl;
    }
  }

  function blobToDataURL(blob) {
    return new Promise((resolve, reject) => {
      const fr = new FileReader();
      fr.onload = () => resolve(fr.result);
      fr.onerror = reject;
      fr.readAsDataURL(blob);
    });
  }

  // 写真データ更新(統合版)
  function updatePhoto(data, mimeType, elements) {
    currentPhoto.data = data;
    currentPhoto.mimeType = mimeType;

    if (data && mimeType) {
      elements.imagePreview.src = data;
      elements.imagePreview.style.display = 'block';
    } else {
      elements.imagePreview.src = '#';
      elements.imagePreview.style.display = 'none';
    }
    elements.photoInput.value = '';
  }
  // === 写真入力処理(画像圧縮機能付き) ===
  function handlePhotoInput(input, elements) {
    if (input.files && input.files[0]) {
      const file = input.files[0];

      // 元ファイルサイズのチェックはそのまま活かす
      if (file.size > CONFIG.MAX_FILE_SIZE) {
        showNotification('ファイルサイズが大きすぎます。5MB以下のファイルを選択してください。', 'error');
        updatePhoto(null, null, elements);
        return;
      }

      // ファイル形式のチェックもそのまま活かす
      if (!CONFIG.ALLOWED_FILE_TYPES.includes(file.type)) {
        showNotification('対応していないファイル形式です。', 'error');
        updatePhoto(null, null, elements);
        return;
      }

      // 必ずビットマップ化→再エンコード
      bitmapizeAndEncode(file, { maxWidth: 1280, maxHeight: 1280, quality: 0.85, mimeType: 'image/jpeg', background: '#fff' })
        .then((compressedBase64) => {
          updatePhoto(compressedBase64, 'image/jpeg', elements);
          console.log(`画像再エンコード完了(bitmap→jpeg): ${Math.round(compressedBase64.length / 1024)} KB`);
        })
        .catch((err) => {
          console.error(err);
          showNotification('画像の再エンコードに失敗しました。', 'error');
          updatePhoto(null, null, elements);
        });
    }
  }

  // === フォーム送信処理(修正版) ===
  async function handleFormSubmission(formData, elements) {
    try {
      setSubmissionState(true, elements);

      // バリデーション
      const validation = validateFormData(formData);
      if (!validation.isValid) {
        throw new Error(validation.message);
      }

      // データ送信
      const result = await sendDataWithRetry(formData);

      // 成功処理
      showNotification('通報を受け付けました。ご協力ありがとうございます。', 'success');
      elements.form.reset();
      updatePhoto(null, null, elements);

    } catch (error) {
      console.error('送信エラー:', error);
      showNotification(`送信に失敗しました: ${error.message}`, 'error');
    } finally {
      setSubmissionState(false, elements);
    }
  }

  function validateFormData(formData) {
    const requiredFields = [
      { name: 'latitude', label: '場所' },
      { name: 'longitude', label: '場所' },
      { name: 'type', label: '異常の種類' }
    ];

    for (const field of requiredFields) {
      const value = formData.get(field.name);
      if (!value || value.trim() === '') {
        return {
          isValid: false,
          message: field.name.includes('itude')
            ? '場所が指定されていません。地図を動かして位置を合わせてください。'
            : `${field.label}が入力されていません。`
        };
      }
    }

    // 「その他」が選択されている場合のみ、詳細を必須チェックする
    if (formData.get('type') === 'その他') {
      const details = formData.get('details');
      if (!details || details.trim() === '') {
        return {
          isValid: false,
          message: '「その他」を選択した場合は、詳細を必ず入力してください。'
        };
      }
    }

    // 詳細の文字数上限チェック(入力がある場合)
    const detailsAll = formData.get('details') || '';
    const detailsLength = Array.from(detailsAll).length;
    const limit = CONFIG.DETAILS_MAX_LENGTH ?? 100;
    if (detailsLength > limit) {
      return { isValid: false, message: '詳細は100文字以内で入力してください。' };
    }

    const lat = parseFloat(formData.get('latitude'));
    const lng = parseFloat(formData.get('longitude'));

    if (isNaN(lat) || lat < -90 || lat > 90) {
      return { isValid: false, message: '緯度の値が正しくありません。' };
    }
    if (isNaN(lng) || lng < -180 || lng > 180) {
      return { isValid: false, message: '経度の値が正しくありません。' };
    }

    return { isValid: true };
  }

  async function sendDataWithRetry(formData, attempt = 1) {
    try {
      if (!liff) {
        throw new Error('LIFFが初期化されていません。');
      }
      const currentAccessToken = liff.getAccessToken();
      if (!currentAccessToken) {
        throw new Error('LINEの認証情報が取得できませんでした。')
      }
      const payload = {
        latitude: formData.get('latitude'),
        longitude: formData.get('longitude'),
        type: formData.get('type'),
        details: formData.get('details'),
        photoData: currentPhoto.data,
        photoMimeType: currentPhoto.mimeType,
        accessToken: currentAccessToken, // アクセストークンを送信
        userId: lineUserId, // ユーザーIDも送信(参考用)
        timestamp: new Date().toISOString()
      };

      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), CONFIG.REQUEST_TIMEOUT);

      const response = await fetch(CONFIG.GAS_WEB_APP_URL, {
        method: 'POST',
        body: JSON.stringify(payload),
        // headers: { 'Content-Type': 'text/plain' },
        // mode: 'cors',
        signal: controller.signal
      });

      clearTimeout(timeoutId);

      if (!response.ok) {
        throw new Error(`サーバーエラー: ${response.status} ${response.statusText}`);
      }

      const data = JSON.parse(await response.text());
      if (data.status === 'success') {
        return data;
      } else {
        throw new Error(data.message || 'サーバーでエラーが発生しました。');
      }
    } catch (error) {
      if (attempt < CONFIG.MAX_RETRY_ATTEMPTS && shouldRetry(error)) {
        showNotification(`送信に失敗しました。${CONFIG.RETRY_DELAY / 1000}秒後に再試行します... (${attempt}/${CONFIG.MAX_RETRY_ATTEMPTS})`, 'warning');
        await new Promise(resolve => setTimeout(resolve, CONFIG.RETRY_DELAY));
        return sendDataWithRetry(formData, attempt + 1);
      }
      throw error;
    }
  }

  function shouldRetry(error) {
    return error.name === 'AbortError' ||
      error.message.includes('fetch') ||
      error.message.includes('network') ||
      error.message.includes('timeout');
  }

  function setSubmissionState(isSending, elements) {
    if (isSending) {
      elements.loader.classList.remove('hidden');
      elements.loader.classList.add('sending');
    } else {
      elements.loader.classList.add('hidden');
      elements.loader.classList.remove('sending');
    }

    const formElements = elements.form.querySelectorAll('input, select, textarea, button');
    formElements.forEach(el => el.disabled = isSending);

    // 送信状態変更後にもボタン表示テキストを適切に更新
    if (!isSending) updateSubmitButtonState();
  }

  // 送信ボタンの活性/非活性とテキストを更新
  function updateSubmitButtonState() {
    if (!elements?.form || !elements?.btnSubmit) return;
    // 送信中は一律で制御しない
    if (elements.loader?.classList.contains('sending')) return;

    const formData = new FormData(elements.form);
    const selectedType = formData.get('type');
    const isOther = selectedType === 'その他';
    const detailsVal = (elements.detailsTextarea?.value || '').trim();

    const canSubmit = selectedType && (!isOther || (isOther && detailsVal.length > 0));

    elements.btnSubmit.disabled = !canSubmit;
    elements.btnSubmit.textContent = canSubmit
      ? 'この内容で通報する'
      : '不具合の種類を選択してください';
  }

});

style.css

  • 以下のとおり
createStaffTable
/* ===== リセットとベーススタイル ===== */
* {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}

body {
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
  line-height: 1.6;
  color: #333;
  background-color: #f8f9fa;
  font-size: 16px;
}

/* ===== コンテナとレイアウト ===== */
.container {
  max-width: 600px;
  margin: 0 auto;
  padding: 20px;
  background-color: #ffffff;
  border-radius: 12px;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  margin-top: 20px;
  margin-bottom: 20px;
}

/* ===== ヘッダー ===== */
h2 {
  text-align: center;
  margin-bottom: 30px;
  color: #2c3e50;
  font-size: 24px;
  font-weight: 700;
  padding-bottom: 15px;
  border-bottom: 3px solid #3498db;
}

/* ===== LINE連携状態表示(修正版) ===== */
.line-status {
  margin-bottom: 24px;
  padding: 12px 16px;
  border-radius: 8px;
  font-size: 14px;
  font-weight: 600;
  display: flex;
  align-items: center;
  gap: 10px;
  border: 1px solid;
  animation: slideDown 0.3s ease-out;
}

@keyframes slideDown {
  from {
    transform: translateY(-10px);
    opacity: 0;
  }
  to {
    transform: translateY(0);
    opacity: 1;
  }
}

.line-status.success {
  background-color: #d4edda;
  color: #155724;
  border-color: #c3e6cb;
}

.line-status.error {
  background-color: #f8d7da;
  color: #721c24;
  border-color: #f5c6cb;
}

.line-status.warning {
  background-color: #fff3cd;
  color: #856404;
  border-color: #ffeaa7;
}

.line-icon {
  font-size: 16px;
  color: #00c300;
  flex-shrink: 0;
}

.line-status-content {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
}

.hidden {
  display: none !important;
}

/* ===== フォームグループ ===== */
.form-group {
  margin-bottom: 30px;
}

.form-label {
  display: block;
  margin-bottom: 12px;
  font-weight: 600;
  color: #2c3e50;
  font-size: 16px;
}

.label-note {
  font-size: 12px;
  font-weight: normal;
  color: #e74c3c; /* 少し目立つ色に */
  margin-left: 8px;
}

/* ===== ラジオボタン(改善版) ===== */
.radio-group {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.radio-item {
  display: flex;
  align-items: center;
  padding: 12px 16px;
  border: 2px solid #e9ecef;
  border-radius: 8px;
  cursor: pointer;
  transition: all 0.3s ease;
  background-color: #ffffff;
}

.radio-item:hover {
  border-color: #3498db;
  background-color: #f8f9fa;
}

.radio-item input[type="radio"] {
  margin: 0;
  margin-right: 12px;
  width: 18px;
  height: 18px;
  accent-color: #3498db;
  cursor: pointer;
}

.radio-item label {
  cursor: pointer;
  font-size: 15px;
  font-weight: 500;
  color: #2c3e50;
  flex: 1;
}

.radio-item input[type="radio"]:checked + label,
.radio-item:has(input[type="radio"]:checked) {
  color: #3498db;
  font-weight: 600;
}

.radio-item:has(input[type="radio"]:checked) {
  border-color: #3498db;
  background-color: #e3f2fd;
}

/* ===== テキストエリア ===== */
textarea {
  width: 100%;
  min-height: 100px;
  padding: 12px 16px;
  border: 2px solid #e9ecef;
  border-radius: 8px;
  font-size: 15px;
  font-family: inherit;
  resize: vertical;
  transition: border-color 0.3s ease;
}

/* === テキストエリアのハイライト用オーバーレイ === */
.textarea-wrapper {
  position: relative;
}

.textarea-wrapper textarea {
  position: relative;
  background: transparent; /* オーバーレイと重ねるため */
  z-index: 1;
}

.textarea-overlay {
  position: absolute;
  inset: 0;
  z-index: 2;
  padding: 12px 16px; /* textareaと合わせる */
  border: 2px solid transparent; /* レイアウト合わせ用 */
  border-radius: 8px;
  font-size: 15px;
  font-family: inherit;
  line-height: 1.6;
  white-space: pre-wrap;
  word-wrap: break-word;
  color: transparent; /* 通常文字は見えない(下のtextareaを見せる) */
  pointer-events: none; /* 操作はtextareaに通す */
  overflow: hidden;
}

.textarea-overlay .overflow {
  color: #e74c3c; /* 上限超過部分を赤く表示 */
}

textarea:focus {
  outline: none;
  border-color: #3498db;
  box-shadow: 0 0 0 3px rgba(52, 152, 219, 0.1);
}

textarea::placeholder {
  color: #6c757d;
  font-style: italic;
}

/* ===== 写真コントロール ===== */
.photo-controls {
  display: flex;
  gap: 12px;
  margin-bottom: 15px;
  flex-wrap: wrap;
}

.button-like-input {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 10px 16px;
  background-color: #6c757d;
  color: white;
  border: none;
  border-radius: 6px;
  font-size: 14px;
  font-weight: 600;
  cursor: pointer;
  transition: all 0.3s ease;
  text-decoration: none;
}

.button-like-input:hover {
  background-color: #5a6268;
  transform: translateY(-1px);
}

/* ===== 画像プレビュー ===== */
#image-preview {
  display: none;
  max-width: 100%;
  height: auto;
  border-radius: 8px;
  border: 2px solid #e9ecef;
  margin-top: 15px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

/* ===== 地図セクション(修正版) ===== */
#map-wrapper {
  position: relative;
  width: 100%;
  height: 300px;
  border: 2px solid #e9ecef;
  border-radius: 8px;
  overflow: hidden;
  margin-bottom: 15px;
  background-color: #f8f9fa;
}

#map {
  width: 100%;
  height: 100%;
  z-index: 1;
}

#center-pin {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -100%);
  font-size: 24px;
  color: #e74c3c;
  z-index: 1000;
  pointer-events: none;
  text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
}

#coords-display {
  padding: 10px 12px;
  background-color: #f8f9fa;
  border: 1px solid #e9ecef;
  border-radius: 6px;
  font-size: 14px;
  color: #495057;
  text-align: center;
  font-family: 'Courier New', monospace;
}

/* ===== 送信ボタン ===== */
#btn-submit {
  width: 100%;
  padding: 18px 28px;
  min-height: 56px;
  background: linear-gradient(135deg, #3498db 0%, #2980b9 100%);
  color: white;
  border: none;
  border-radius: 8px;
  font-size: 17px;
  font-weight: 700;
  cursor: pointer;
  transition: all 0.3s ease;
  margin-top: 20px;
  box-shadow: 0 4px 6px rgba(52, 152, 219, 0.3);
}

#btn-submit:hover:not(:disabled) {
  background: linear-gradient(135deg, #2980b9 0%, #1f5f8b 100%);
  transform: translateY(-2px);
  box-shadow: 0 6px 12px rgba(52, 152, 219, 0.4);
}

#btn-submit:disabled {
  background: linear-gradient(135deg, #95a5a6 0%, #7f8c8d 100%);
  cursor: not-allowed;
  transform: none;
  box-shadow: none;
}

/* ===== ローディング画面 ===== */
.loader-overlay {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background-color: rgba(0, 0, 0, 0.7);
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  z-index: 9999;
}

.loader {
  width: 50px;
  height: 50px;
  border: 5px solid #f3f3f3;
  border-top: 5px solid #3498db;
  border-radius: 50%;
  animation: spin 1s linear infinite;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

.loader-text {
  margin-top: 20px;
  color: white;
  font-size: 16px;
  font-weight: 600;
}

/* ===== レスポンシブ対応 ===== */
@media (max-width: 768px) {
  .container {
    margin: 10px;
    padding: 16px;
    border-radius: 8px;
  }
  
  h2 {
    font-size: 20px;
    margin-bottom: 24px;
  }
  
  .form-group {
    margin-bottom: 24px;
  }
  
  .photo-controls {
    flex-direction: column;
  }
  
  .button-like-input {
    width: 100%;
    justify-content: center;
  }
  
  #map-wrapper {
    height: 250px;
  }
}

@media (max-width: 480px) {
  .container {
    margin: 5px;
    padding: 12px;
  }
  
  h2 {
    font-size: 18px;
  }
  
  .form-label {
    font-size: 15px;
  }
  
  .radio-item {
    padding: 10px 12px;
  }
  
  .radio-item label {
    font-size: 14px;
  }
  
  #map-wrapper {
    height: 200px;
  }
  
  #btn-submit {
    padding: 16px 24px;
    min-height: 56px;
    font-size: 16px;
  }
}

/* ===== 通知スタイル ===== */
.notification {
  position: fixed;
  top: 20px;
  right: 20px;
  padding: 12px 20px;
  border-radius: 6px;
  color: white;
  font-weight: 600;
  z-index: 10000;
  max-width: 300px;
  word-wrap: break-word;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  animation: slideInRight 0.3s ease-out;
}

@keyframes slideInRight {
  from {
    transform: translateX(100%);
    opacity: 0;
  }
  to {
    transform: translateX(0);
    opacity: 1;
  }
}

.notification-success {
  background-color: #28a745;
}

.notification-error {
  background-color: #dc3545;
}

.notification-warning {
  background-color: #ffc107;
  color: #212529;
}

.notification-info {
  background-color: #17a2b8;
}

/* ===== アクセシビリティ改善 ===== */
@media (prefers-reduced-motion: reduce) {
  * {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

/* ===== フォーカス状態の改善 ===== */
button:focus,
input:focus,
textarea:focus,
.radio-item:focus-within {
  outline: 2px solid #3498db;
  outline-offset: 2px;
}

/* ===== 印刷スタイル ===== */
@media print {
  .photo-controls,
  #btn-submit,
  .loader-overlay {
    display: none !important;
  }
  
  .container {
    box-shadow: none;
    border: 1px solid #000;
  }
  
  #map-wrapper {
    border: 1px solid #000;
    background-color: #f0f0f0;
  }
}

code(backend)

  • gasです。

code.gs

  • 以下のとおり
createStaffTable

/**
 * 道路通報システム用Google Apps Script(ログ確認しやすい版)
 * レスポンスにログを含める + スプレッドシートログ出力対応
 * 【改修版】console.logをlog関数に統一
 */

const scriptProperties = PropertiesService.getScriptProperties();

// ▼▼▼【重要】設定値を更新してください ▼▼▼
const CONFIG = {
  SPREADSHEET_ID: scriptProperties.getProperty('SPREADSHEET_ID'),
  // Messaging APIチャネルのアクセストークン
  LINE_MESSAGING_ACCESS_TOKEN: scriptProperties.getProperty('LINE_CHANNEL_ACCESS_TOKEN'),
  // LINE Login channelの設定
  LINE_LOGIN_CHANNEL_ID: scriptProperties.getProperty('LINE_LOGIN_CHANNEL_ID'), // ← 実際のチャネルIDに変更
  LINE_LOGIN_CHANNEL_SECRET: scriptProperties.getProperty('LINE_LOGIN_CHANNEL_SECRET'), // ← 実際のシークレットに変更
  // API URL
  LINE_MESSAGING_API_URL: 'https://api.line.me/v2/bot/message/push',
  LINE_PROFILE_API_URL: 'https://api.line.me/v2/profile',
  LINE_VERIFY_TOKEN_URL: 'https://api.line.me/oauth2/v2.1/verify',
  // Google Drive設定
  DRIVE_FOLDER_ID: scriptProperties.getProperty('DRIVE_FOLDER_ID'), // ← 実際のDriveフォルダIDに変更
  // ログ出力設定
  LOG_TO_RESPONSE: true, // レスポンスにログを含める
  LOG_TO_SPREADSHEET: true, // スプレッドシートにログ出力(オプション )
};
// ▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲▲

// ログ収集用配列
let debugLogs = [];

/**
 * ログ出力関数(console.log + 配列に保存)
 * 【改修】エラーレベル対応を追加
 */
function log(message, data = null, level = 'INFO') {
  const timestamp = new Date().toISOString();
  const logEntry = {
    timestamp: timestamp,
    level: level,
    message: message,
    data: data
  };
  
  // レベルに応じてコンソール出力を使い分け
  if (level === 'ERROR') {
    if (data) {
      console.error(`[${timestamp}] ${message}:`, data);
    } else {
      console.error(`[${timestamp}] ${message}`);
    }
  } else if (level === 'WARN') {
    if (data) {
      console.warn(`[${timestamp}] ${message}:`, data);
    } else {
      console.warn(`[${timestamp}] ${message}`);
    }
  } else {
    // INFO レベル(デフォルト)
    if (data) {
      console.log(`[${timestamp}] ${message}:`, data);
    } else {
      console.log(`[${timestamp}] ${message}`);
    }
  }
  
  // 配列にも保存
  debugLogs.push(logEntry);
  
  // スプレッドシートにログ出力(オプション)
  if (CONFIG.LOG_TO_SPREADSHEET) {
    try {
      logToSpreadsheet(message, data, level);
    } catch (error) {
      // logToSpreadsheet内でのエラーは console.error を直接使用(無限再帰回避)
      console.error('スプレッドシートログ出力エラー:', error);
      MailApp.sendEmail("vacation555@gmail.com", "logToSpreadsheet", "logToSpreadsheet");
    }
  }
}

/**
 * GETリクエストの処理(設定確認機能付き)
 */
function doGet(e) {
  try {
    debugLogs = []; // ログ配列をリセット
    
    log('1. === GET request received ===');
    
    // 設定状況を確認
    const configStatus = {
      hasSpreadsheetId: !!CONFIG.SPREADSHEET_ID,
      hasLineToken: CONFIG.LINE_MESSAGING_ACCESS_TOKEN !== 'LINE_CHANNEL_ACCESS_TOKEN',
      hasChannelId: CONFIG.LINE_LOGIN_CHANNEL_ID !== 'LINE_LOGIN_CHANNEL_ID',
      hasChannelSecret: CONFIG.LINE_LOGIN_CHANNEL_SECRET !== 'LINE_LOGIN_CHANNEL_SECRET',
      hasDriveFolderId: CONFIG.DRIVE_FOLDER_ID !== 'DRIVE_FOLDER_ID'
    };
    
    log('2. 設定状況確認', configStatus);
    
    // 詳細設定情報
    const detailConfig = {
      spreadsheetId: CONFIG.SPREADSHEET_ID,
      lineTokenConfigured: CONFIG.LINE_MESSAGING_ACCESS_TOKEN !== 'LINE_CHANNEL_ACCESS_TOKEN',
      lineTokenPrefix: CONFIG.LINE_MESSAGING_ACCESS_TOKEN.substring(0, 10) + '...',
      channelId: CONFIG.LINE_LOGIN_CHANNEL_ID,
      channelIdConfigured: CONFIG.LINE_LOGIN_CHANNEL_ID !== 'LINE_LOGIN_CHANNEL_ID',
      driveFolderId: CONFIG.DRIVE_FOLDER_ID,
      driveFolderConfigured: CONFIG.DRIVE_FOLDER_ID !== 'DRIVE_FOLDER_ID'
    };
    
    log('3. 詳細設定情報', detailConfig);
    
    return ContentService.createTextOutput(JSON.stringify({
      status: 'success',
      message: 'API is working',
      timestamp: new Date().toISOString(),
      config: configStatus,
      detailConfig: detailConfig,
      logs: CONFIG.LOG_TO_RESPONSE ? debugLogs : []
    })).setMimeType(ContentService.MimeType.JSON);
    
  } catch (error) {
    log('4. GET request error', error.toString(), 'ERROR');
    
    return ContentService.createTextOutput(JSON.stringify({
      status: 'error',
      message: error.toString(),
      logs: CONFIG.LOG_TO_RESPONSE ? debugLogs : []
    }))
    .setMimeType(ContentService.MimeType.JSON)
    .addHeader("Access-Control-Allow-Origin", "https://report-app-about-road.pages.dev" );
  }
}

/**
 * 受け取ったデータを検証し、安全な形にサニタイズする
 * @param {object} rawData - JSON.parseされた生のデータ
 * @returns {object} 検証・サニタイズ済みのデータ
 */
function validateAndSanitizeData(rawData) {
  // --- 必須フィールドの検証 ---
  const latitude = parseFloat(rawData.latitude);
  const longitude = parseFloat(rawData.longitude);
  
  if (isNaN(latitude) || isNaN(longitude) || !rawData.type) {
    throw new Error('必須フィールド(緯度、経度、種別)が無効または不足しています。');
  }

  // --- photoDataの検証 ---
  if (rawData.photoData) {
    // 1. データサイズのチェック (5MBを上限とする)
    const MAX_PHOTO_LENGTH = 5 * 1024 * 1024 * 1.4; // Base64はサイズが約1.37倍になるため少し余裕を持たせる
    if (rawData.photoData.length > MAX_PHOTO_LENGTH) {
      throw new Error('画像サイズが大きすぎます。5MB以下の画像をアップロードしてください。');
    }

    // 2. MIMEタイプのチェック (JPEG, PNG, GIFのみ許可)
    if (!rawData.photoData.startsWith('data:image/')) {
      throw new Error('無効な画像データ形式です。');
    }
    const mimeType = rawData.photoData.substring(5, rawData.photoData.indexOf(';'));
    const allowedMimes = ['image/jpeg', 'image/png', 'image/gif'];
    if (!allowedMimes.includes(mimeType)) {
      throw new Error(`許可されていない画像形式です: ${mimeType}`);
    }
  }

  // --- サニタイズ処理 ---
  let photoMimeType = null;
  if (rawData.photoData) {
    photoMimeType = rawData.photoData.substring(5, rawData.photoData.indexOf(';'));
  }

  const sanitizedData = {
    latitude: latitude,
    longitude: longitude,
    type: sanitizeText(rawData.type),
    details: rawData.details ? sanitizeText(rawData.details) : '',
    photoData: rawData.photoData || null,
    photoMimeType: photoMimeType, 
    accessToken: rawData.accessToken || null
  };

  return sanitizedData;
}

/**
 * 文字列をサニタイズして、HTMLタグとして解釈されないようにする
 * @param {string} text - サニタイズする文字列
 * @returns {string} サニタイズ後の文字列
 */
function sanitizeText(text) {
  if (typeof text !== 'string') {
    return text;
  }
  return text.replace(/&/g, '&amp;')
             .replace(/</g, '&lt;')
             .replace(/>/g, '&gt;')
             .replace(/"/g, '&quot;')
             .replace(/'/g, '&#39;');
}

/**
 * POSTリクエストを処理するメイン関数
 */
function doPost(e) {
  // ログシートをクリアする(LOG_TO_SPREADSHEETがtrueの場合のみ)
  if (CONFIG.LOG_TO_SPREADSHEET) {
    try {
      const spreadsheet = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
      const logSheet = spreadsheet.getSheetByName('ログ');
      if (logSheet) {
        // ヘッダー行(1行目)を残して、2行目以降の全データをクリア
        logSheet.getRange(2, 1, logSheet.getLastRow(), logSheet.getLastColumn()).clearContent();
        log('ログシートをクリアしました。');
      }
    } catch (clearError) {
      log('ログシートのクリアに失敗しました', { error: clearError.toString() }, 'ERROR');
      // クリアに失敗してもメインの処理は続行する
    }
  }
  try {
    debugLogs = []; // ログ配列をリセット
    log('5. === POST request received ===');

    // POSTデータの取得
    if (!e.postData || !e.postData.contents) {
      throw new Error('POSTデータが見つかりません');
    }
    
    const rawData = JSON.parse(e.postData.contents);
    log('6. Request data received (raw)', { /* ... */ });

    // ★ 1. データの検証とサニタイズ
    log('7. === 1. データ検証とサニタイズ開始 ===');
    const validatedData = validateAndSanitizeData(rawData);
    log('8. データ検証・サニタイズ完了');

    let userId = null;
    let lineResult = null;

    // --- 2. 先にユーザー認証を行う ---
    const hasAccessToken = validatedData.accessToken && validatedData.accessToken.trim() !== '';
    const canAttemptLineAuth = hasAccessToken && CONFIG.LINE_MESSAGING_ACCESS_TOKEN && CONFIG.LINE_LOGIN_CHANNEL_ID;

    if(canAttemptLineAuth){
      log('14. === LINEユーザー認証を開始します ===');
      try{
        userId = getUserIdFromAccessToken(validatedData.accessToken);
        log('16. ユーザーID取得成功',{userId: userId});
      }catch(authError){
        log('20a. LINEユーザー認証エラー', authError.toString(), 'ERROR');
        // 認証に失敗した場合、ここで処理を中断させる
        throw new Error('ユーザー認証に失敗しました。有効なアクセストークンが必要です。');
      }
    }else{
      // アクセストークンがない場合も処理を中断
      throw new Error('アクセストークンが見つかりません。認証が必要です。');
    }

    // ___ 3. 認証成功後にデータベース処理を行う ___
    // 認証で取得したuserIdをデータに追加
    validatedData.userId = userId;

    log('9. ===2.スプレッドシートに保存開始 ===');
    const saveResult = saveToSpreadsheet(validatedData);
    log('10. スプレッドシート保存結果', saveResult);

    // ___ 4. 認証成功後にLINE通知を行う ___
    log('17. LINE投稿開始');
    lineResult = sendLineMessage(userId, validatedData, saveResult);
    log('18. LINE投稿成功', lineResult);

    // ___5. 認証成功後にメール通知を行う___
    try{
      sendNotificationEmail(validatedData, saveResult);
    }catch(mailError){
      log('21d. メール送信エラー', {error: mailError.toString()}, 'ERROR');
    }

    // 成功レスポンス
    const response = {
      status: 'success',
      message: '通報を受け付けました。ご協力ありがとうございます。',
      timestamp: new Date().toISOString(),
      id: saveResult.id,
      lineNotified: lineResult !== null,
      imageUploaded: !!saveResult.photoUrl,
      debug: { /* ... */ },
      logs: CONFIG.LOG_TO_RESPONSE ? debugLogs : []
    };
    
    log('22. 最終レスポンス準備完了', { /* ... */ });
    
    const responseOutput = ContentService.createTextOutput(JSON.stringify(response))
    .setMimeType(ContentService.MimeType.JSON);

    // ★★★ 最終的にこの変数を返すように変更 ★★★
    return responseOutput; // この行を追加
    
  } catch (error) {
    log('23. doPost エラー', error.toString(), 'ERROR');
    
    const errorResponse = {
      status: 'error',
      message: 'データの処理に失敗しました: ' + error.toString(),
      timestamp: new Date().toISOString(),
      logs: CONFIG.LOG_TO_RESPONSE ? debugLogs : []
    };
    const errorOutput = ContentService.createTextOutput(JSON.stringify(errorResponse))
      .setMimeType(ContentService.MimeType.JSON);

    // ★★★ 最終的にこの変数を返すように変更 ★★★
    return errorOutput; // この行を追加

  }
}

function sendNotificationEmail(validatedData, saveResult) {
  try{
    const spreadSheet = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
    const mailSheet = spreadSheet.getSheetByName('メール');
    if(!mailSheet){
      throw new Error('「メール」シートがみつかりません。');
    }
    const lastRow = mailSheet.getLastRow();
    let recipients = [];
    if(lastRow > 1){
      recipients = mailSheet.getRange(2, 2, lastRow -1, 1) // 1. B列のアドレス範囲を取得
      .getValues()                                         // 2. 値を二次元配列として取得
      .flat()                                              // 3. 配列を一次元化(平坦化)
      .filter(email => email && email.includes('@'));     // 4. 有効なアドレスだけを抽出
    }
    if(recipients.length > 0){
      const subject = `【道路通報】新規通報(種別:${validatedData.type})`;
         let mailBody = "新しい道路通報がありましたので、お知らせします。\n\n";
      mailBody += "----------------------------------------\n";
      mailBody += "■ 通報内容\n";
      mailBody += "----------------------------------------\n";
      mailBody += `・受付日時: ${new Date(saveResult.timestamp).toLocaleString('ja-JP')}\n`;
      mailBody += `・通報種別: ${validatedData.type}\n`;
      mailBody += `・詳細: ${validatedData.details || '記載なし'}\n\n`;
      mailBody += `・場所の確認(Googleマップ):\n${saveResult.googleMapLink}\n\n`;
      
      if (saveResult.photoUrl && !saveResult.photoUrl.includes('エラー')) {
        mailBody += `・写真の確認:\n${saveResult.photoUrl}\n\n`;
      } else {
        mailBody += "・写真: なし\n\n";
      }
      mailBody += "----------------------------------------\n";

      // メールを送信
      MailApp.sendEmail(recipients.join(','), subject, mailBody);
      log('21b. 通知メールを送信しました。', { recipients: recipients });
    } else {
      log('21c. メールの宛先が設定されていないため、通知をスキップしました。');
    }
  }catch(mailError){
    log('21d. メール送信エラー', {error: mailError.toString()}, 'ERROR');
    throw mailError;
  }
}

/**
 * アクセストークンからユーザーIDを取得(ログ強化版)
 */
function getUserIdFromAccessToken(accessToken) {
  try {
    log('24. === ユーザーID取得開始 ===');
    
    // 1. アクセストークンの検証
    log('25. アクセストークン検証開始');
    const verifyResponse = UrlFetchApp.fetch(
      `${CONFIG.LINE_VERIFY_TOKEN_URL}?access_token=${accessToken}`,
      {
        method: 'GET',
        headers: {
          'Content-Type': 'application/x-www-form-urlencoded'
        }
      }
    );
    
    const verifyResponseCode = verifyResponse.getResponseCode();
    log('26. 検証レスポンスコード', verifyResponseCode);
    
    if (verifyResponseCode !== 200) {
      const errorText = verifyResponse.getContentText();
      log('27. アクセストークン検証失敗', errorText, 'ERROR');
      throw new Error('アクセストークンの検証に失敗しました: ' + errorText);
    }
    
    const verifyData = JSON.parse(verifyResponse.getContentText());
    log('28. 検証結果', verifyData);
    
    // チャネルIDの確認
    if (verifyData.client_id !== CONFIG.LINE_LOGIN_CHANNEL_ID) {
      const mismatchInfo = {
        expected: CONFIG.LINE_LOGIN_CHANNEL_ID,
        actual: verifyData.client_id
      };
      log('29. チャネルID不一致', mismatchInfo, 'ERROR');
      throw new Error(`チャネルIDが一致しません。期待値: ${CONFIG.LINE_LOGIN_CHANNEL_ID}, 実際: ${verifyData.client_id}`);
    }
    
    log('30. ユーザープロフィール取得開始');
    const profileResponse = UrlFetchApp.fetch(CONFIG.LINE_PROFILE_API_URL, {
      method: 'GET',
      headers: {
        'Authorization': 'Bearer ' + accessToken
      }
    });
    
    const profileResponseCode = profileResponse.getResponseCode();
    log('31. プロフィールレスポンスコード', profileResponseCode);
    
    if (profileResponseCode !== 200) {
      const errorText = profileResponse.getContentText();
      log('32. プロフィール取得失敗', errorText, 'ERROR');
      throw new Error('ユーザープロフィールの取得に失敗しました: ' + errorText);
    }
    
    const profileData = JSON.parse(profileResponse.getContentText());
    log('33. プロフィール取得成功', profileData);
    
    return profileData.userId;
    
  } catch (error) {
    log('34. ユーザーID取得エラー', error.toString(), 'ERROR');
    throw error;
  }
}

/**
 * スプレッドシートにデータを保存(ログ強化版)
 */
function saveToSpreadsheet(data) {
  try {
    log('35. スプレッドシート保存開始');
    
    const spreadsheet = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);
    const sheet = spreadsheet.getSheetByName('通報データ');
    
    // ヘッダー行が存在しない場合は作成
    if (sheet.getLastRow() === 0) {
      const headers = [
        '受付日時',
        '緯度',
        '経度',
        '地図',
        '通報種別',
        '詳細',
        '写真',
        'LINEユーザーID'
      ];
      sheet.getRange(1, 1, 1, headers.length).setValues([headers]);
      log('36. ヘッダー行を作成');
    }
    
    // 写真データの処理(DRIVE_FOLDER_ID対応)
    let photoUrl = '';
    if (data.photoData && data.photoMimeType) {
      try {
        log('37. 写真データ処理開始', {
          mimeType: data.photoMimeType,
          dataLength: data.photoData.length
        });
        
        // Base64データからBlobを作成
        const base64Data = data.photoData.split(',')[1] || data.photoData;
        const blob = Utilities.newBlob(
          Utilities.base64Decode(base64Data),
          data.photoMimeType,
          'report_photo_' + new Date().getTime() + '.jpg'
        );
        
        let file;
        
        // DRIVE_FOLDER_IDが設定されている場合は指定フォルダに保存
        if (CONFIG.DRIVE_FOLDER_ID && CONFIG.DRIVE_FOLDER_ID !== 'DRIVE_FOLDER_ID') {
          try {
            const folder = DriveApp.getFolderById(CONFIG.DRIVE_FOLDER_ID);
            file = folder.createFile(blob);
            log('38. 写真をフォルダに保存', { folderName: folder.getName() });
          } catch (folderError) {
            log('39. フォルダアクセス失敗、ルートフォルダに保存', folderError.toString(), 'WARN');
            file = DriveApp.createFile(blob);
          }
        } else {
          // DRIVE_FOLDER_IDが設定されていない場合はルートフォルダに保存
          file = DriveApp.createFile(blob);
          log('40. 写真をルートフォルダに保存');
        }
        
        // ファイルを公開設定
        file.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);
        
        // 公開URLを取得
        photoUrl = file.getUrl();
        log('41. 写真保存成功', { url: photoUrl });
        
      } catch (photoError) {
        log('42. 写真保存エラー', photoError.toString(), 'ERROR');
        photoUrl = '写真保存エラー: ' + photoError.toString();
      }
    }
    
    // Googleマップのリンク
    const googleMapLink = `https://www.google.com/maps/search/?api=1&query=${data.latitude},${data.longitude}`;

    // データ行を追加
    const rowData = [
      new Date( ),
      data.latitude,
      data.longitude,
      googleMapLink,
      data.type,
      data.details || '',
      photoUrl,
      data.userId || ''
    ];
    
    const newRow = sheet.getLastRow() + 1;
    sheet.getRange(newRow, 1, 1, rowData.length).setValues([rowData]);
    
    log('43. スプレッドシート保存完了', { row: newRow });
    
    return {
      id: newRow,
      timestamp: new Date(),
      photoUrl: photoUrl,
      googleMapLink: googleMapLink 
    };
    
  } catch (error) {
    log('44. スプレッドシート保存エラー', error.toString(), 'ERROR');
    throw new Error('スプレッドシートへの保存に失敗しました: ' + error.toString());
  }
}

/**
 * LINEにプッシュメッセージを送信(ログ強化版)
 */
function sendLineMessage(userId, reportData, saveResult) {
  try {
    log('45. LINE投稿開始', { userId: userId });
    
    const headers = {
      'Content-Type': 'application/json',
      'Authorization': 'Bearer ' + CONFIG.LINE_MESSAGING_ACCESS_TOKEN
    };
    
    // メッセージ配列を作成
    const messages = [];
    
    // 1. Flex Message(構造化メッセージ)
    const flexMessage = createFlexMessage(reportData, saveResult.photoUrl);
    messages.push(flexMessage);
    log('46. Flex Message追加');
    
    // 2. 位置情報メッセージ
    const locationMessage = {
      type: 'location',
      title: '通報場所',
      address: `緯度: ${reportData.latitude}, 経度: ${reportData.longitude}`,
      latitude: parseFloat(reportData.latitude),
      longitude: parseFloat(reportData.longitude)
    };
    messages.push(locationMessage);
    log('47. 位置情報メッセージ追加');
    
    // 3. 写真メッセージ(ある場合)
    if (saveResult.photoUrl && !saveResult.photoUrl.includes('エラー')) {
      // Google DriveのURLを画像表示用に変換
      const imageUrl = convertDriveUrlToImageUrl(saveResult.photoUrl);
      const imageMessage = {
        type: 'image',
        originalContentUrl: imageUrl,
        previewImageUrl: imageUrl
      };
      messages.push(imageMessage);
      log('48. 画像メッセージ追加', { imageUrl: imageUrl });
    }
    
    // 4. 補足テキストメッセージ
    const textMessage = {
      type: 'text',
      text: createLineTextMessage(reportData, saveResult.googleMapLink, saveResult.photoUrl)
    };
    messages.push(textMessage);
    log('49. テキストメッセージ追加');
    
    const payload = {
      'to': userId,
      'messages': messages
    };
    
    log('50. 送信準備完了', { messageCount: messages.length });
    
    const options = {
      'method': 'POST',
      'headers': headers,
      'payload': JSON.stringify(payload)
    };
    
    const response = UrlFetchApp.fetch(CONFIG.LINE_MESSAGING_API_URL, options);
    const responseCode = response.getResponseCode();
    
    log('51. LINE APIレスポンス', { code: responseCode });
    
    if (responseCode === 200) {
      log('52. LINE API成功');
      return { success: true, messageCount: messages.length };
    } else {
      const errorText = response.getContentText();
      log('53. LINE APIエラー', { code: responseCode, error: errorText }, 'ERROR');
      throw new Error(`LINE API エラー: ${responseCode} - ${errorText}`);
    }
    
  } catch (error) {
    log('54. LINE送信エラー', error.toString(), 'ERROR');
    throw error;
  }
}

/**
 * Flex Message(構造化メッセージ)を作成
 */
function createFlexMessage(data, photoUrl) {
  return {
    type: 'flex',
    altText: '道路異状通報を受け付けました',
    contents: {
      type: 'bubble',
      header: {
        type: 'box',
        layout: 'vertical',
        contents: [
          {
            type: 'text',
            text: '🚧 道路異状通報',
            weight: 'bold',
            color: '#ffffff',
            size: 'lg'
          },
          {
            type: 'text',
            text: '受付完了',
            color: '#ffffff',
            size: 'sm'
          }
        ],
        backgroundColor: '#3498db',
        paddingAll: 'lg'
      },
      body: {
        type: 'box',
        layout: 'vertical',
        contents: [
          {
            type: 'box',
            layout: 'vertical',
            contents: [
              {
                type: 'text',
                text: '受付日時',
                color: '#666666',
                size: 'sm'
              },
              {
                type: 'text',
                text: new Date().toLocaleString('ja-JP'),
                weight: 'bold',
                size: 'md',
                margin: 'xs'
              }
            ],
            margin: 'md'
          },
          {
            type: 'box',
            layout: 'vertical',
            contents: [
              {
                type: 'text',
                text: '通報種別',
                color: '#666666',
                size: 'sm'
              },
              {
                type: 'text',
                text: data.type,
                weight: 'bold',
                size: 'md',
                margin: 'xs',
                color: '#e74c3c'
              }
            ],
            margin: 'md'
          },
          {
            type: 'box',
            layout: 'vertical',
            contents: [
              {
                type: 'text',
                text: '詳細情報',
                color: '#666666',
                size: 'sm'
              },
              {
                type: 'text',
                text: data.details || '記載なし',
                size: 'md',
                margin: 'xs',
                wrap: true
              }
            ],
            margin: 'md'
          }
        ]
      },
      footer: {
        type: 'box',
        layout: 'vertical',
        contents: [
          {
            type: 'button',
            style: 'primary',
            action: {
              type: 'uri',
              label: '🗺️ 地図で確認',
              uri: `https://www.google.com/maps?q=${data.latitude},${data.longitude}`
            },
            color: '#27ae60'
          }
        ],
        margin: 'md'
      }
    }
  };
}

/**
 * LINEテキストメッセージの内容を作成
 */
function createLineTextMessage(data, mapLink, photoLink ) {
  const timestamp = new Date().toLocaleString('ja-JP', {
    year: 'numeric',
    month: '2-digit',
    day: '2-digit',
    hour: '2-digit',
    minute: '2-digit'
  });
  
  let message = `📋 通報詳細\n\n`;
  message += `🔸 種別: ${data.type}\n`;
  message += `🔸 詳細: ${data.details || '記載なし'}\n`;
  message += `🔸 受付日時: ${timestamp}\n\n`;
  if (mapLink) {
    message += `📍 場所の確認:\n${mapLink}\n\n`;
  }
  if (photoLink && !photoLink.includes('エラー')) {
    message += `📷 写真の確認:\n${photoLink}\n\n`;
  }
  message += `📍 この通報は担当部署で確認し、適切に対応いたします。\n`;
  message += `ご協力ありがとうございました。`;
  
  return message;
}

/**
 * Google DriveのURLを画像表示用URLに変換
 */
function convertDriveUrlToImageUrl(driveUrl) {
  try {
    // Google DriveのファイルIDを抽出
    const fileIdMatch = driveUrl.match(/\/d\/([a-zA-Z0-9-_]+)/);
    if (fileIdMatch) {
      const fileId = fileIdMatch[1];
      return `https://drive.google.com/uc?id=${fileId}`;
      // return `https://drive.google.com/uc?export=view&id=${fileId}`;
    }
    
    // 既に変換済みの場合はそのまま返す
    if (driveUrl.includes('drive.google.com/uc?id=' )) {
      return driveUrl;
    }
    
    // その他の場合はそのまま返す
    return driveUrl;
    
  } catch (error) {
    log('55. URL変換エラー', error.toString(), 'ERROR');
    return driveUrl;
  }
}

/**
 * スプレッドシートにログ出力(オプション機能)
 * 【改修】エラーレベル対応を追加
 */
function logToSpreadsheet(message, data, level = 'INFO') {
  try {
    if(!CONFIG.SPREADSHEET_ID){
      // logToSpreadsheet内では console.log を直接使用(無限再帰回避)
      console.log('スプレッドシートID未設定のため、ログ出力をスキップします。');
      return;
    }
    
    const spreadsheet = SpreadsheetApp.openById(CONFIG.SPREADSHEET_ID);

    let logSheet = spreadsheet.getSheetByName('ログ');
    
    // ログシートが存在しない場合は作成
    if (logSheet === null) {
      logSheet = spreadsheet.insertSheet('ログ');
      const headers = ['日時', 'レベル', 'メッセージ', 'データ'];
      logSheet.getRange(1, 1, 1, headers.length).setValues([headers]);
    }
    
    const rowData = [
      new Date(),
      level,
      message,
      data ? JSON.stringify(data) : ''
    ];
    
    logSheet.appendRow(rowData);
    
  } catch (error) {
    // logToSpreadsheet内でのエラーは console.error を直接使用(無限再帰回避)
    console.error('スプレッドシートログ出力エラー:', error);
    try {
      const recipient = "vacation555@gmail.com"; // ★★★ あなたのメールアドレスに変更 ★★★
      const subject = "【GAS緊急エラー】logToSpreadsheetでエラー発生";
      let body = "スプレッドシートへのログ書き込み中にエラーが発生しました。\n\n";
      body += "----------------------------------------\n";
      body += "エラー詳細\n";
      body += "----------------------------------------\n";
      body += "メッセージ: " + error.message + "\n\n";
      body += "スタックトレース:\n" + error.stack + "\n\n";
      body += "書き込もうとしたデータ:\n";
      body += "メッセージ: " + message + "\n";
      body += "データ: " + JSON.stringify(data) + "\n";
      body += "レベル: " + level + "\n";
      
      MailApp.sendEmail(recipient, subject, body);
      
    } catch (mailError) {
      // メール送信自体に失敗した場合のログ
      console.error('エラー通知メールの送信に失敗しました:', mailError);
    }
  }
}

/**
 * 設定確認用関数(ログ強化版)
 */
function checkConfiguration() {
  debugLogs = []; // ログ配列をリセット
  
  log('56. === 設定確認開始 ===');
  
  const config = {
    spreadsheetId: CONFIG.SPREADSHEET_ID,
    lineTokenConfigured: CONFIG.LINE_MESSAGING_ACCESS_TOKEN !== 'LINE_CHANNEL_ACCESS_TOKEN',
    lineTokenPrefix: CONFIG.LINE_MESSAGING_ACCESS_TOKEN.substring(0, 10) + '...',
    channelId: CONFIG.LINE_LOGIN_CHANNEL_ID,
    channelIdConfigured: CONFIG.LINE_LOGIN_CHANNEL_ID !== 'LINE_LOGIN_CHANNEL_ID',
    channelSecret: CONFIG.LINE_LOGIN_CHANNEL_SECRET !== 'LINE_LOGIN_CHANNEL_SECRET',
    driveFolderId: CONFIG.DRIVE_FOLDER_ID,
    driveFolderConfigured: CONFIG.DRIVE_FOLDER_ID !== 'DRIVE_FOLDER_ID'
  };
  
  log('57. 設定状況', config);
  
  // フォルダアクセステスト
  if (CONFIG.DRIVE_FOLDER_ID !== 'DRIVE_FOLDER_ID') {
    try {
      const folder = DriveApp.getFolderById(CONFIG.DRIVE_FOLDER_ID);
      log('58. Driveフォルダアクセス: OK', { folderName: folder.getName() });
    } catch (error) {
      log('59. Driveフォルダアクセス: NG', error.toString(), 'ERROR');
    }
  } else {
    log('60. DRIVE_FOLDER_IDが設定されていません', null, 'WARN');
  }
  
  // ログを出力(console.logを直接使用)
  console.log('=== 設定確認結果 ===');
  debugLogs.forEach(logEntry => {
    console.log(`[${logEntry.timestamp}] [${logEntry.level}] ${logEntry.message}`, logEntry.data || '');
  });
  
  return {
    timestamp: new Date().toISOString(),
    configured: config,
    logs: debugLogs
  };
}

/**
 * テスト用関数(ログ強化版)
 */
/**
 * doPostをテストするための、より本番に近いテスト関数
 */
function testDoPost() {
  // ★★★ 有効なアクセストークンをここに貼り付ける ★★★
  const freshAccessToken = 'eyJhbGciOiJIUzI1NiJ9.MRXRtWPTczpo0Iccgbv-qW2Yo_uxvlhIqYqCErrRF6pP_5VASVBt1Qkd-JdIXVHFyQW1DYS6Zh3NuRkbHuAsimmiMs8-dBosIeEshsvfYtKxa7DDm50S_LgGkdQZ7jm-La-BiNGCDUt5ZaQblpjwsi--JZf9mmtO_ks0t6Puk2U.8rmaltJ1Dz-YRLjVXbL4rrltU9WpTomDz5TXDEhBaRM';

  // ★★★ テスト用の写真データ(短いサンプル) ★★★
  // これは1x1ピクセルの赤い点の画像データです。テストに最適です。
  const testPhotoData = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/wcAAwAB/epv2AAAAABJRU5ErkJggg==';

  const testEvent = {
    postData: {
      contents: JSON.stringify({
        latitude: 35.681236,
        longitude: 139.767125,
        type: '写真付きテスト',
        details: 'このテストで写真URLが保存されるはずです。',
        accessToken: freshAccessToken,
        photoData: testPhotoData // ★写真データを追加
      })
    }
  };
  
  log('=== 写真付きテスト実行開始 ===');
  
  // doPostを直接呼び出す
  const result = doPost(testEvent);
  const resultData = JSON.parse(result.getContent());
  
  log('=== テスト結果 ===', resultData);
  
  // ログが有効な場合は、ログも表示(console.logを直接使用)
  if (resultData.logs && resultData.logs.length > 0) {
    console.log('=== 詳細ログ ===');
    resultData.logs.forEach(logEntry => {
      console.log(`[${logEntry.timestamp}] [${logEntry.level}] ${logEntry.message}`, logEntry.data || '');
    });
  }
}

/**
 * 簡単な設定確認(ブラウザで確認可能)
 */
function quickConfigCheck() {
  const result = checkConfiguration();
  
  // 結果をわかりやすく表示
  let summary = '=== 設定確認結果 ===\n';
  summary += `スプレッドシートID: ${result.configured.spreadsheetId}\n`;
  summary += `LINEトークン設定: ${result.configured.lineTokenConfigured ? 'OK' : 'NG'}\n`;
  summary += `チャネルID設定: ${result.configured.channelIdConfigured ? 'OK' : 'NG'}\n`;
  summary += `Driveフォルダ設定: ${result.configured.driveFolderConfigured ? 'OK' : 'NG'}\n`;
  
  // console.logを直接使用
  console.log(summary);
  return summary;
}

/**************************************************************
 * 【一番簡単なデバッグ用】
 * doPostの中身をテスト実行し、ログをメールで送信する関数
 */
function testDoPostAndMail() {
  // ▼▼▼ ここにあなたのメールアドレスを設定 ▼▼▼
  const myEmail = "vacation555@gmail.com"; 
  
  // フォームから送られてくるデータのダミーを作成
  const dummyEvent = {
    postData: {
      contents: JSON.stringify({
        latitude: 35.681236,
        longitude: 139.767125,
        type: 'テスト通報',
        details: 'これはメールデバッグ用のテストです。',
        // liff.getAccessToken()で取得した本物のアクセストークンをここに貼り付けると、
        // LINE APIのテストもできます。
        accessToken: 'eyJhbGciOiJIUzI1NiJ9.MRXRtWPTczpo0Iccgbv-qW2Yo_uxvlhIqYqCErrRF6pP_5VASVBt1Qkd-JdIXVHFyQW1DYS6Zh3NuRkbHuAsimmiMs8-dBosIeEshsvfYtKxa7DDm50S_LgGkdQZ7jm-La-BiNGCDUt5ZaQblpjwsi--JZf9mmtO_ks0t6Puk2U.8rmaltJ1Dz-YRLjVXbL4rrltU9WpTomDz5TXDEhBaRM', 
        photoData: 'data:image/jpeg;base64,/9j/4AAQSkZJRgABAQ...', // ← テスト用のBase64文字列
        photoMimeType: 'image/jpeg' // ← MIMEタイプも忘れずに
        // photoData: null, // 写真なしでテスト
        // photoMimeType: null
      })
    }
  };

  let mailBody = "====== GASデバッグログ ======\n\n";

  try {
    // doPostの処理をここから再現
    const e = dummyEvent;
    
    mailBody += "=== POSTリクエスト受信 ===\n";
    
    if (!e.postData || !e.postData.contents) {
      throw new Error('POSTデータが見つかりません');
    }
    const data = JSON.parse(e.postData.contents);
    mailBody += "リクエストデータ解析成功\n";
    mailBody += "accessTokenの有無: " + (!!data.accessToken) + "\n\n";
    
    // 必須フィールドの検証
    if (!data.latitude || !data.longitude || !data.type) {
      throw new Error('必須フィールドが不足しています');
    }
    mailBody += "必須フィールド検証OK\n\n";
    
    // スプレッドシートへの保存はスキップ(またはコメントアウト解除)
    // const saveResult = saveToSpreadsheet(data);
    // mailBody += "スプレッドシート保存完了\n\n";
    const saveResult = { photoUrl: '' }; // ダミーの結果

    // LINE投稿の条件チェック
    mailBody += "=== LINE投稿チェック開始 ===\n";
    const hasAccessToken = data.accessToken && data.accessToken.trim() !== '' && data.accessToken !== 'ここにフロントエンドで取得した本物のアクセストークンを貼り付ける';
    const hasMessagingToken = CONFIG.LINE_MESSAGING_ACCESS_TOKEN && CONFIG.LINE_MESSAGING_ACCESS_TOKEN !== 'LINE_CHANNEL_ACCESS_TOKEN';
    const hasChannelId = CONFIG.LINE_LOGIN_CHANNEL_ID && CONFIG.LINE_LOGIN_CHANNEL_ID !== 'LINE_LOGIN_CHANNEL_ID';
    
    mailBody += "アクセストークンあり: " + hasAccessToken + "\n";
    mailBody += "Messagingトークン設定済み: " + hasMessagingToken + "\n";
    mailBody += "チャネルID設定済み: " + hasChannelId + "\n\n";

    if (hasAccessToken && hasMessagingToken && hasChannelId) {
      mailBody += ">>> LINE投稿の条件を満たしました。API通信を開始します。\n";
      try {
        const userId = getUserIdFromAccessToken(data.accessToken);
        mailBody += "ユーザーID取得成功: " + userId + "\n";
        
        if (userId) {
          const lineResult = sendLineMessage(userId, data, saveResult.photoUrl);
          mailBody += "LINEプッシュメッセージ送信成功\n";
        } else {
          mailBody += "ユーザーIDが取得できませんでした。\n";
        }
      } catch (lineError) {
        mailBody += "!!! LINE API通信でエラーが発生 !!!\n";
        mailBody += "エラー内容: " + lineError.toString() + "\n";
      }
    } else {
      mailBody += ">>> LINE投稿の条件を満たさなかったため、スキップしました。\n";
    }

    mailBody += "\n=== 処理正常終了 ===\n";

  } catch (error) {
    mailBody += "\n!!! 全体処理でエラーが発生 !!!\n";
    mailBody += "エラー内容: " + error.toString() + "\n";
  }

  // 最後に、収集したログをメールで送信
  MailApp.sendEmail(myEmail, "GASデバッグレポート", mailBody);
}

LINE公式アカウント等作成

https://entry.line.biz/start/jp/

LINEプロバイダー作成

https://developers.line.biz/ja/

  • LINE developeアクセス(上のリンクをクリック)

  • 右上の”コンソール”をクリック

  • ”作成”をクリック

  • ”+新規チャンネル作成”をクリック

  • ”LINEログイン”をクリック
  • 必要事項を入力
  • アプリタイプは”ウェブアプリ”
  • ”作成”をクリック
  • ”MessagingAPI"をクリック
  • ”LINE公式アカウントを作成する”をクリック
  • 必要事項を入力し”完了”をクリック
  • ”LINE Official Managerへ”をクリック
  • ”同意”等をクリックして進める
  • ”ホーム画面へ移動”をクリック

  • 右上の”設定”をクリック

  • ”MessagingAPI”をクリック
  • ”MessagingAPIを利用する”をクリック
  • 作成したプロバイダーを選択し”同意する”をクリック
  • 何も入力せず”OK”をクリック
  • ”OK"をクリック

Cloudflare pages 作成(netlify等他のサービスでも良い)

https://www.cloudflare.com/ja-jp/

  • ”ログイン”をクリック

  • ”Workers & Pages”をクリック

  • "アプリケーションを作成する"をクリック

  • ”Pages”タブをクリック

  • どちらか一方の”始める”をクリック(筆者は既存のGitリポジトリをインストールするで進めた)

  • 公開対象のリポジトリを選択し”セットアップの開始”をクリック

LINEリッチメニュー作成

  • 作成した”MessagingAPI"をクリック(筆者は”作業班dev"と命名)

  • ”LINE Official Account Manager"をクリック

  • ”ホーム”タブをクリック
  • ”リッチメニュー”をクリック

  • 作成をクリック

  • ”メニュー”の上部エリアをクリック

  • 左下の添付レートを選択
  • ”選択”をクリック

  • ”タイプ”は”リンク”を選択

  • webapliをdeployしたCloudflare等のサービスサイトを開く
  • webapliを表示する

  • deployしたwebpageのURLをコピーする。

  • コピーしたURLをペースト
  • ”保存”をクリック

変数の設定(Cloudflare)

  • ”設定”をクリック

  • ”追加”をクリック

  • ”テキスト”を選択
  • ”変数名”はGAS_WEB_APP_URLとする。

  • "AppScript"を開き、"デプロイ"をクリック
  • ”デプロイを管理”をクリック

  • ”ウェブアプリ”下の”コピー”をクリック

  • "Cloudflare"の”値”欄にペースト

  • ”追加”をクリック
  • ”テキスト”を選択
  • ”変数名”はLIFF_IDとする。

  • LINE Developers にアクセス
  • LINE login の LIFF タブをクリック

  • ”LIFFアプリ名”を任意に設定
  • ”サイズ”はFull

  • CloudflareでdeployしたpageのURLをコピー
  • ”エンドポイントURL”欄に貼り付け
  • "Scope"は"profile"を選択
  • ”友達追加オプション”は”On(normal)"を選択
  • ”追加”をクリック

  • 作成したLINEログインチャンネルの”LIFF”タブをクリック
  • LIFF ID をコピー

  • ”追加”をクリック

  • ”タイプ”はテキストを選択
  • ”変数名”はLIFF_ID
  • 先程コピーしたLIFF ID をペースト

変数の設定(GAS)

  • GASを開く
  • ”settings"アイコン(歯車マーク)をクリック
  • ”スクリプトプロパティを編集”をクリック

  • ”プロパティ”に DRIVE_FOLDER_ID と入力
  • google drive に写真格納用フォルダーを作成
  • フォルダーURL ...folders/ 以降をコピー
  • ”値”に ペースト

  • ”プロパティ”に LINE_CHANNEL_ACCESS_TOKEN と入力
  • LINE Messaging API を開く
  • Messaging API設定 タブを開く
  • チャンネルアクセストークン(長期) をコピーする
  • ”値”にペースト

  • ”プロパティ”に LINE_LOGIN_CHANNEL_ID と入力
  • ”LINEログイン” チャンネルを開く
  • ”チャンネル基本設定”タブを開く
  • ”チャンネルID”をコピー
  • ”値”にペースト

  • ”プロパティ”に LINE_LOGIN_CHANNEL_SECRET と入力
  • ”チャンネル基本設定”タブを開く
  • ”チャンネルシークレット” をコピー
  • ”値”にペースト

  • ”プロパティ”に LINE_TO_ID と入力
  • ”チャンネル基本設定”タブを開く
  • ”あなたのユーザーID” をコピー
  • ”値”にペースト

  • ”プロパティ”に SPREADSHEET_ID と入力
  • 作成したGoogle spread sheet を開く
  • URLの d/ と /edit との間をコピー
  • ”値”にペースト

デプロイ再実行

  • GASを再度デプロイ
  • Cloudflareの変数を新しいURLに置き換える

  • Cloudflareを開く
  • ”詳細を表示”をクリック

  • ”デプロイを管理”をクリック
  • ”デプロイを再試行”をクリック

シートの準備

  • Google spread sheet を開く
  • シートの名前を 通報データ とする
  • 通報データシート1行目は 上図のとおりとする
  • ログ シートを追加する
  • メール シートを追加する

  • メール シートの1行目は図のとおりとする。
  • 氏名 メールアドレスを入力する(通報時のメール受信用)

QRコード配布

  • LINE MessagingAPIを開く
  • QRコード上で右クリックしてメニューを開く
  • 画像を保存を選択
  • QRコードを配布

(参考ページ)
https://line-sm.com/blog/lineofficial_qr/?inflow_code=gpm&creative=&keyword=&matchtype=&network=x&device=c&cpid=20491370837&adgrid=&type=pmax&gad_source=1&gad_campaignid=20495665360&gbraid=0AAAAACUbiTgF2TxOa6zGG4HFbhKTDrrc_&gclid=Cj0KCQjwo63HBhCKARIsAHOHV_XFFU3bo2yJvsEfZBwQYsJNnWQ7fXd61mYEjiFfQBkbXQSVMILXf3YaAqiJEALw_wcB

Discussion