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の運用はかなり落ち着きます。
参考
- Installable Triggers - Google Apps Script
- Event Objects - Google Apps Script
- Class UrlFetchApp - Google Apps Script
- Sending messages using incoming webhooks - Slack Developer Docs
Discussion