📣

Googleフォームの未対応回答を毎朝Slackに出す前に決めること

に公開

Googleフォームの回答をSlackに通知していても、対応漏れは起きます。

フォーム送信時にSlackへ流れた。

誰かが見た。

でも、翌週にGoogle Sheetsを見たら 未対応 のまま残っていた。

これは珍しくありません。

この記事で扱うのは、フォーム送信直後のSlack通知ではありません。

毎朝、未対応の回答だけをSlackに出して、滞留している回答を回収する設計です。

先に結論

毎朝Slackに出す前に、次の列を決めます。

対応状況
担当者
最終更新日
次アクション
集計対象
除外理由

そして、ダイジェストの対象を固定します。

対応状況 = 未対応
または 担当者が空
または 対応中のまま7日以上更新なし
ただし 対応状況 = 除外 は出さない

ここを決めずに毎朝通知だけ作ると、Slackにノイズが増えます。

大事なのは「回答を通知すること」ではなく、「止まっている回答を戻すこと」です。

即時通知と朝のダイジェストは役割が違う

Googleフォームの回答をSlackへ即時通知する構成はよくあります。

Googleフォーム
-> Google Sheets
-> Apps Script
-> Slack Incoming Webhook

これは新しい回答に気づくための仕組みです。

一方で、毎朝の未対応ダイジェストは、滞留を回収するための仕組みです。

仕組み 目的
送信時のSlack通知 新しい回答に気づく
毎朝の未対応ダイジェスト 止まっている回答を戻す
週次レビュー 状態設計やフォーム導線を見直す

同じSlack通知でも、目的が違います。

送信直後の通知は、流れていきます。

朝のダイジェストは、残っているものを拾います。

未対応の定義を決める

最初に決めるのは、未対応の意味です。

おすすめは、次の4状態から始めることです。

未対応
対応中
完了
除外

このうち、朝のダイジェストに出すのは原則 未対応 です。

ただし、実務では 対応中 のまま止まっている回答も見たいことがあります。

そこで、対象を3種類に分けます。

対象 条件
未対応 対応状況 = 未対応
担当者なし 対応状況 != 除外 かつ 担当者 が空
長期滞留 対応状況 = 対応中 かつ 最終更新日 が7日以上前

除外 は出しません。

営業メール、テスト回答、重複、対象外は、通常の対応対象ではないからです。

除外理由の扱いは、別記事の Googleフォームの営業メール・テスト回答を削除せず「除外」で管理する に分けています。

Sheetsの列を固定する

Google Sheets側は、自由に列を足せます。

ただし、Apps Scriptで読むなら、列名を固定したほうが安全です。

Timestamp
お名前
メールアドレス
お問い合わせ種別
お問い合わせ内容
対応状況
担当者
最終更新日
次アクション
集計対象
除外理由

重要なのは、列番号ではなくヘッダー名で読むことです。

列の順番が変わっても壊れにくくなります。

担当者と対応状況の基本設計は、Googleフォームの回答に「担当者」と「対応状況」を持たせる設計 にまとめています。

毎朝のダイジェストを作るApps Script例

ここでは、回答先スプレッドシートに紐づくApps Scriptを想定します。

SlackのWebhook URLはスクリプトプロパティに入れておきます。

SLACK_WEBHOOK_URL = https://hooks.slack.com/services/...

最小例は次のような形です。

const SHEET_NAME = "フォームの回答 1";
const SLACK_WEBHOOK_URL =
  PropertiesService.getScriptProperties().getProperty("SLACK_WEBHOOK_URL");

function postUnhandledResponseDigest() {
  if (!SLACK_WEBHOOK_URL) {
    throw new Error("SLACK_WEBHOOK_URL is not set.");
  }

  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName(SHEET_NAME);
  if (!sheet) {
    throw new Error(`Sheet not found: ${SHEET_NAME}`);
  }

  const values = sheet.getDataRange().getValues();
  const [headers, ...rows] = values;
  const index = buildHeaderIndex(headers);
  const today = new Date();

  const targets = rows
    .map((row, offset) => toResponseRecord(row, offset + 2, index))
    .filter((record) => shouldDigest(record, today))
    .slice(0, 20);

  if (targets.length === 0) {
    postToSlack("未対応のフォーム回答はありません。");
    return;
  }

  const lines = targets.map((record) => {
    return [
      `#${record.rowNumber}`,
      record.status || "状態なし",
      record.owner || "担当者なし",
      record.category || "種別なし",
      record.name || "名前なし",
      record.nextAction ? `次: ${record.nextAction}` : "次アクションなし",
    ].join(" / ");
  });

  postToSlack([
    "*未対応フォーム回答の朝ダイジェスト*",
    ...lines,
    "",
    "対応を始めたら、対応状況と担当者を更新してください。",
  ].join("\n"));
}

function buildHeaderIndex(headers) {
  return headers.reduce((acc, header, index) => {
    acc[String(header).trim()] = index;
    return acc;
  }, {});
}

function toResponseRecord(row, rowNumber, index) {
  return {
    rowNumber,
    name: getCell(row, index, "お名前"),
    category: getCell(row, index, "お問い合わせ種別"),
    status: getCell(row, index, "対応状況"),
    owner: getCell(row, index, "担当者"),
    lastUpdatedAt: getRawCell(row, index, "最終更新日"),
    nextAction: getCell(row, index, "次アクション"),
    reportable: getCell(row, index, "集計対象"),
    exclusionReason: getCell(row, index, "除外理由"),
  };
}

function getCell(row, index, headerName) {
  const column = index[headerName];
  if (column === undefined) return "";
  return String(row[column] ?? "").trim();
}

function getRawCell(row, index, headerName) {
  const column = index[headerName];
  if (column === undefined) return "";
  return row[column];
}

function shouldDigest(record, today) {
  if (record.status === "除外") return false;

  if (record.status === "未対応") return true;

  if (!record.owner) return true;

  if (record.status === "対応中") {
    return daysSince(record.lastUpdatedAt, today) >= 7;
  }

  return false;
}

function daysSince(value, today) {
  const date = value instanceof Date ? value : new Date(value);
  if (Number.isNaN(date.getTime())) return 0;
  return Math.floor((today.getTime() - date.getTime()) / 86400000);
}

function postToSlack(text) {
  const response = UrlFetchApp.fetch(SLACK_WEBHOOK_URL, {
    method: "post",
    contentType: "application/json",
    payload: JSON.stringify({ text }),
    muteHttpExceptions: true,
  });

  const status = response.getResponseCode();
  if (status < 200 || status >= 300) {
    throw new Error(`Slack webhook failed: ${status} ${response.getContentText()}`);
  }
}

これは本番用の完成コードではなく、考え方を示す最小例です。

実際には、管理画面URL、スプレッドシートURL、担当者別メンション、通知先チャンネル、エラー通知先を足します。

ただし、最初から全部を入れないほうがいいです。

まず、未対応が毎朝見える状態を作ります。

時間主導トリガーで毎朝動かす

Apps Scriptのインストール型トリガーには、フォーム送信などのイベントで動くものと、時間で動くものがあります。

毎朝の未対応ダイジェストは、時間主導トリガーで動かします。

手動で設定するなら、Apps Scriptの画面から次のようにします。

トリガー
-> トリガーを追加
-> 実行する関数: postUnhandledResponseDigest
-> イベントのソース: 時間主導型
-> 時間ベースのトリガーのタイプ: 日付ベースのタイマー
-> 時刻: 午前8時から9時

プログラムで作るなら、次のような形です。

function createMorningDigestTrigger() {
  ScriptApp.newTrigger("postUnhandledResponseDigest")
    .timeBased()
    .everyDays(1)
    .atHour(9)
    .create();
}

ここで注意したいのは、これは厳密なジョブキューではないことです。

「9時ちょうどに必ず投稿される」前提で業務を組むより、「朝の時間帯に未対応を拾う」運用として見たほうが安全です。

Slackに出す内容を増やしすぎない

朝のダイジェストに、回答本文を全部入れたくなることがあります。

でも、長すぎる通知は読まれません。

最初はこのくらいで十分です。

行番号
対応状況
担当者
問い合わせ種別
名前または会社名
次アクション
管理画面URLまたはシートURL

個人情報や機密情報が多いフォームでは、Slackに本文を出さないほうが安全です。

Slackには要約とリンクだけを出し、詳細は権限のある場所で確認します。

Slackは通知先です。

回答の正本ではありません。

除外を毎朝出さない

営業メールやテスト回答を除外にしたあと、毎朝のダイジェストに出してしまうと、通知がすぐに汚れます。

未対応: 出す
対応中で長期滞留: 出す
担当者なし: 出す
完了: 出さない
除外: 出さない

このルールにします。

ただし、除外件数は週次で見る価値があります。

営業メールが増えているなら、フォーム導線や入口対策を見直す必要があるからです。

朝のダイジェストと週次レビューを分けます。

見る場面 見るもの
毎朝 未対応、担当者なし、長期滞留
週次 除外理由別件数、営業メール比率、テスト回答混入

FORMLOVAで見ている境界

FORMLOVAでは、フォーム回答を「届いた行」ではなく、公開後の運用対象として扱っています。

回答一覧を見る。

条件で絞る。

未対応だけを出す。

担当者を決める。

状態を進める。

営業メールやテスト回答を除外する。

この一連の流れがつながっていることが重要です。

Googleフォーム + Sheets + GASでも、考え方は同じです。

Slack通知だけを増やすのではなく、回答レコードの状態を進める仕組みにします。

既存記事との役割分担

このテーマは、近い記事が多いです。

読みたいこと 次に読む記事
Googleフォーム + Sheets + GASをどこまで続けるか判断したい Googleフォーム + スプレッドシート + GAS運用の判断基準
フォーム回答のSlack即時通知を作りたい フォーム回答をSlack通知する方法
担当者と対応状況の列設計を見たい Googleフォームの回答に「担当者」と「対応状況」を持たせる設計
営業メールやテスト回答を除外したい Googleフォームの営業メール・テスト回答を削除せず「除外」で管理する
FORMLOVAで回答一覧とステータス管理を使いたい 回答一覧を見て絞り込みとステータス管理をする方法

この記事は、未対応回答の朝ダイジェストだけを扱います。

即時通知、担当者列、除外理由、Googleフォーム運用全体の判断は、それぞれ別記事に分けます。

チェックリスト

毎朝Slackに出す前に、最低限ここを確認します。

[ ] 対応状況の値が固定されている
[ ] 担当者列がある
[ ] 最終更新日がある
[ ] 除外は朝ダイジェストに出さない
[ ] 対応中で7日以上止まった回答を拾う
[ ] Slackには本文全文ではなく要約と戻り先を出す
[ ] Webhook URLをスクリプトプロパティに入れている
[ ] トリガーの作成者とエラー通知先が分かる

まとめ

Googleフォームの回答をSlackへ流すだけでは、対応漏れはなくなりません。

即時通知は、新しい回答に気づくためのものです。

毎朝の未対応ダイジェストは、止まっている回答を戻すためのものです。

そのためには、まず回答シートに 対応状況担当者最終更新日次アクション を持たせます。

次に、未対応担当者なし対応中で7日以上更新なし を朝の対象にします。

除外 は朝の通知から外します。

Slack通知ではなく、滞留回収として設計する。

ここまで決めると、Googleフォーム + Sheets + GASの運用はかなり落ち着きます。

参考

あわせて読みたい

Discussion