お問い合わせフォームの項目を変える

管理画面でフォームの項目を編集し、問い合わせを一覧で読み、CSV で書き出します。

お問い合わせフォームの項目は、管理画面で追加、削除、並べ替えできます。保存すると、30 秒ほどのうちにWebサイトに反映されます。コードの編集もデプロイもいりません。

このページでは、問い合わせ一覧、詳細、CSV の書き出しも説明します。

フォームを編集する

  1. 管理画面にログインし、メニューの フォーム(英語表示では Form)を開きます。お問い合わせフォーム のページに、項目が順番に並んでいます。
  2. 項目を編集します。できることは、画面の画像の後にまとめています。
  3. バージョン N として保存 を押します。項目が新しいバージョンとして保存されます。
フォームの編集画面。固定のメールアドレス、並べ替えボタン付きの 5 項目、固定のプライバシーポリシーへの同意、項目を追加ボタン、プレビューフォームの編集画面。固定のメールアドレス、並べ替えボタン付きの 5 項目、固定のプライバシーポリシーへの同意、項目を追加ボタン、プレビュー
フォームのバージョン 1。右上の保存ボタンは、変更するまで押せません。

編集画面でできること:

  • 項目を追加、削除、上下の並べ替え
  • 種類、項目名、必須にする の設定
  • 入力例と説明 の設定(任意。それぞれ日本語と英語)
  • テキストの項目の 最大文字数 の設定
  • プルダウン、ラジオボタン、チェックボックスの 選択肢 の追加、削除、並べ替え
  • お問い合わせ一覧に表示する の設定(一覧に列として出すかどうか)
  • 日本語と英語での プレビュー

問い合わせページは、訪問者が開いたときに現在のバージョンを読み込みます。フォームは最大 30 秒キャッシュされるので、変更は 30 秒ほどのうちに訪問者に届きます。site.config.ts の編集はいりません。

最初のバージョンは、starter に付いているフォームと同じです。お名前(必須)、会社名、電話番号、お問い合わせの種類(選択肢 5 つのプルダウン)、お問い合わせ内容(必須)です。

変えられない部分

次の部分はいつもフォームにあり、編集できません。

部分固定している理由
メールアドレス(必須)返信先のアドレスで、どの問い合わせも専用の列に保存します。ほかの項目がどうであっても、一覧、詳細、CSV、返信ボタンはこれを使います。
プライバシーポリシーへの同意同意のない送信は API が受け付けません。同意した時刻を保存します。
honeypot の欄ボットだけが入力する、見えない欄です。問い合わせフォームの保護を参照してください。
Turnstile保存の前にサーバーが確かめる、ボット対策です。

編集画面では、メールアドレスと同意の行に 固定 の印が付き、理由が添えてあります。

項目の種類と上限

種類管理画面での表示保存する値補足
text1 行のテキスト1 行のテキスト制御文字、目に見えない書式用の文字、前後の空白を取り除きます
textarea複数行のテキスト改行を含むテキスト
tel電話番号テキスト数字、+、-、(、)、空白。全角の数字も受け付けます
urlURLテキストhttp:// か https:// だけ
number数値数値
date日付YYYY-MM-DD実在する日付であること
selectプルダウン選択肢の値 1 つその項目の選択肢のどれかであること
radioラジオボタン選択肢の値 1 つその項目の選択肢のどれかであること
checkboxチェックボックス選択肢の値のリスト(1 つ以上)それぞれがその項目の選択肢のどれかであること
上限値
1 バージョンの項目数(メールアドレスと同意を除く)30
1 項目の選択肢の数50
項目名・選択肢の名前(言語ごと)100 文字
入力例(言語ごと)100 文字
説明(言語ごと)200 文字
text、tel、url の最大文字数1〜2,000 文字
textarea の最大文字数1〜10,000 文字
問い合わせ 1 件の保存する値(JSON、UTF-8)16 KB
POST /api/contact のリクエスト本文(読む前に確認)32 KB

項目名は、少なくとも一方の言語で入力します。片方の言語が空のときは、もう一方を表示します。

16 KB の上限は文字数ではなくバイト数で、問い合わせ 1 件の全項目の回答を合わせて数えます。日本語は 1 文字 3 バイトなので、合計でおよそ 5,400 文字です。textarea の最大文字数を 10,000 にしていても、この上限が先に効きます。超えた送信は 413 payload_too_large になり、スターターのフォームは文字数を減らすよう表示します。管理画面では、textarea の最大文字数の設定の下にこの上限を表示します。

項目の ID

項目の名前を変えても、回答はそのままつながります。値を項目名ではなく、変わらない ID で保存するからです。

ID は f_k3v9q2m8x1b7c4d6 のような文字列です。項目を最初に保存したときにサーバーが作り、その後は変わりません。最初のバージョンの項目だけは、f_name のような読める ID です。データの形はフォームの定義にあります。

編集画面では、保存済みの項目に ID f_… と小さく表示されます。入力や変更はできません。まだ保存していない項目には、ID の代わりに 新規 の印が付きます。

追加した項目の設定。日本語と英語の項目名、種類、最大文字数、必須にする、お問い合わせ一覧に表示する、削除追加した項目の設定。日本語と英語の項目名、種類、最大文字数、必須にする、お問い合わせ一覧に表示する、削除
追加したばかりの「ご予算」。ID は保存したときに決まります。

ID がこのように決まるので、次のようになります。

  • 項目名を変えてもデータはそのまま。 「会社名」を「団体名」に変えても ID は同じです。変更の前と後の回答は同じ列に入ります。
  • 新しい項目が古い項目の代わりになることはない。 「ご予算」を削除し、後で「ご予算」という項目を追加すると、新しい項目には新しい ID が付きます。項目名が同じでも、古い回答と新しい回答は分かれたままです。
  • ID はサーバーが決める。 編集画面は新しい項目を ID なしで送り、サーバーが ID を付けます。フォームのどのバージョンにもなかった ID は、400 で拒否します。
  • 種類は、値の形が同じものの間でだけ変えられる。 text、textarea、tel、url は互いに変えられます。select と radio も同じです。それ以外(たとえば text から number)は拒否します。その ID で保存済みの値が、新しい種類に合わなくなるからです。その場合は新しい項目を追加してください。
  • 選択肢の値も自動で作る。 新しい選択肢には o_p4n8w2k6c1 のような内部の値が付きます。編集するのは選択肢の名前だけです。

バージョンと履歴

保存するたびに、バージョンが 1、2、3 と増えます。項目名、種類、選択肢、順番、設定のどれを変えても、新しいバージョンになります。何も変えずに保存したときは増えません。

  • 変更履歴 に、バージョン番号、日時、保存した管理者が並びます。
  • 復元 は、古いバージョンの項目を新しいバージョンとして保存します。履歴を書き換えたり、何かを消したりはしません。復元した項目は ID がそのままなので、前の回答と新しい回答がまたつながります。
  • 編集中にほかの人が保存していたら、保存は拒否されます(409 form_conflict)。再読み込みして、もう一度変更してください。

保存したバージョンは、後から書き換えられません。form_definitions テーブルは追記だけで、データベースのトリガーが UPDATE と DELETE を拒否します。

項目を削除する、元に戻す

項目を削除すると、保存したときにアーカイブされます。何も消えません。

  • その項目は古いバージョンに残ります。保存済みの回答も、問い合わせに残ります。
  • 定義には、アーカイブした項目の一覧が残ります。最後の項目名と、最後にその項目があったバージョンも記録します。
  • 編集画面の アーカイブした項目 に並びます。元に戻す を押すと、前と同じ ID でフォームに戻ります。

問い合わせ一覧

管理画面の お問い合わせ で、届いた問い合わせを一覧で見られます。

未読タブを開いた問い合わせ一覧。未読 3 件、タブ、メールアドレスの検索欄、CSV をダウンロードのボタン未読タブを開いた問い合わせ一覧。未読 3 件、タブ、メールアドレスの検索欄、CSV をダウンロードのボタン
未読のタブを選んだところ。スパムの疑いがあるものは、別のタブに分かれています。

未読と既読

問い合わせの状態は 未読 と 既読 の 2 つだけです。

  • 新しい問い合わせは未読です。詳細を開くと既読になります。
  • 一覧の状態の横にある封筒のボタン(既読にする / 未読に戻す)か、詳細で切り替えられます。あとで対応したいものは、未読に戻しておけます。
  • ほかの状態はありません。対応の記録が必要なら、メールで返信するか、CSV に書き出してください。

スパムの疑い は状態ではなく、別の印です。honeypot かスパムスコア(問い合わせフォームの保護を参照)で自動で付くほか、管理者が詳細の スパムとして扱う で付けることもできます。本物の問い合わせなら、詳細で スパムではない を選ぶと印が外れます。スパムの疑いがある問い合わせにも、未読と既読があります。

ヘッダーの未読件数は、スパムの疑いがない未読の問い合わせの数です。

列

一覧には、固定の列が 3 つあります。受信日時、メールアドレス、状態(当てはまればスパムの疑いの印も)です。その後に、現在のバージョンの項目が続きます。

  • お問い合わせ一覧に表示する がオンの項目は、最初から列に出ます。バージョン 1 では、お名前、会社名、お問い合わせ内容です。長い文は 120 文字で切ります。
  • 表示する列 で、列ごとに表示と非表示を切り替えられます。この選択は、そのブラウザにだけ保存します(localStorage)。
  • アーカイブした項目は、一覧には出ません。

タブと絞り込み

上部のタブで、表示する問い合わせを選びます。

タブ表示する問い合わせ
未読スパムの疑いがない未読の問い合わせ
既読スパムの疑いがない既読の問い合わせ
すべて(最初に開くタブ)スパムの疑いがないすべての問い合わせ
スパムの疑いスパムの疑いがあるものだけ(未読と既読)

未読 と スパムの疑い のタブには件数が出ます。未読、既読、すべて のタブには、スパムの疑いを別のタブに分けているという案内と、スパムの疑い のタブへのリンクが出ます。

スパムの疑いを分けるのは、混ぜると本物の問い合わせが埋もれ、未読件数も役に立たなくなるからです。消えるわけではありません。保存され、件数も出ていて、1 回の操作で見られます。

タブの中で、さらに次の条件で絞り込めます。

  • フォームのバージョン
  • 回答で絞り込む: 現在のバージョンにあるプルダウンかラジオボタンの選択肢

並び順

並び順は受信日時だけです。最初は新しい順で、受信日時 の列見出しを選ぶと古い順になります。

メールアドレス、状態、フォームの項目では並べ替えられません。問い合わせは、届いた順に読むことが多いからです。特定の問い合わせを探すときは、タブ、絞り込み、メールアドレスの検索を使ってください。

メールアドレスで検索

検索欄に入れた文字を含むメールアドレスの問い合わせを探します。大文字と小文字は区別しません。たとえば example.co で、taro@example.com も info@example.co.jp も見つかります。

  • 検索するのは固定のメールアドレスの列だけです。フォームの項目は検索しません。
  • 検索する文字は 254 文字(メールアドレスの最大の長さ)までです。
  • 選んでいるタブと絞り込みの中で検索します。CSV の書き出しも同じ条件になります。

フォームの項目の値で探すときは、回答で絞り込む か CSV の書き出しを使ってください。

問い合わせの詳細

一覧で問い合わせを選ぶと、詳細が開きます。開いた問い合わせは既読になります。

問い合わせの詳細画面。入力内容、お問い合わせ内容、連絡先と、未読に戻す・スパムとして扱う・メールで返信・削除のボタン問い合わせの詳細画面。入力内容、お問い合わせ内容、連絡先と、未読に戻す・スパムとして扱う・メールで返信・削除のボタン

右側のボタンでできること:

  • 未読に戻す: あとで対応するときに使います。
  • スパムとして扱う: スパムの疑いの印を付けます。印が付いた問い合わせでは スパムではない になります。
  • メールで返信: 固定のメールアドレスあてに、メールアプリで返信を書きます。
  • 削除: 確認のあとで削除します。プライバシーについてを参照してください。

詳細では、送信されたときのバージョンの項目名と順番で問い合わせを表示します。後で項目名を変えても、古い問い合わせの見え方は変わりません。

現在のフォームにない項目の値も表示します。その項目には「バージョン N の項目・アーカイブ済み」と表示します。

CSV の書き出し

一覧の CSV をダウンロード で、問い合わせを CSV で書き出します。

CSV の列は、まず固定の列です。id、received_at、status(unread か read)、suspected_spam(1 か 0)、email、locale、form_version、page_path、country、spam_score、spam_reasons の順です。

その後に、フォームにこれまであったすべての項目 ID の列が続きます。現在の項目(フォームの順)、アーカイブした項目の順です。

  • 見出しは、表示中の言語(日本語か英語)の項目名です。
  • アーカイブした項目の見出しには、最後にその項目があったバージョンを付けます。たとえば ご予算 (v3) です。同じ名前の新しい項目は、別の列 ご予算 になります。
  • それでも見出しが重なるときは、項目 ID を角かっこで付けます。
  • プルダウン、ラジオボタン、チェックボックスの値は、選択肢の名前で書き出します。
  • 表計算アプリが値を数式として実行しないよう、目に見える最初の文字が =、+、-、@ のセルと、タブや復帰文字で始まるセルには、先頭に ' を付けます。全角の =、+、-、@ も対象です。
  • 書き出しは、一覧のタブ、メールアドレスの検索、絞り込みに従います。「すべて」にはスパムの疑いが入りません。必要なら「スパムの疑い」のタブで書き出してください。
  • 200 行ずつ読んで順に送り出します。大きなテーブルでも、全体をメモリに載せません。

書き出しに含まれるのは、書き出しを始めたときのフォームのバージョンと、それより前のバージョンで送られた問い合わせです。途中で新しいバージョンが保存されたら、そのバージョンで送られた問い合わせは含めません。見出しに、そのバージョンで増えた項目の列がないからです。次の書き出しには含まれます。

なぜこう作るか

  • 定義は D1 にあるので、コードを書かない人でも変えられる。 小さな会社では、エンジニアがそばにいないことがよくあります。「ご予算」の欄を足すのに、コードの変更、ビルド、デプロイが必要であるべきではありません。
  • サーバーは D1 にある定義だけを信じる。 GET /api/contact/form の定義は、フォームを表示するためのものです。送信のときは、サーバーが D1 から定義を読み直して確かめます。書き換えたページや手で作ったリクエストで、項目を増やしたり確認を飛ばしたりはできません。
  • 項目ごとの列ではなく、1 つの JSON の列。 項目ごとに列を作ると、フォームを変えるたびにスキーマのマイグレーションが必要です。マイグレーションはパッケージのリリースでしか届きません。項目 ID をキーにした JSON の列なら、マイグレーションはいりません。
  • 変わらない ID とバージョンで、データを混ぜない。 項目名は変わり、項目は増えたり減ったりします。値は使い回さない ID で保存し、問い合わせごとにバージョンを記録します。だから、別の質問への回答が同じ列に入ることはなく、どの問い合わせも訪問者が見たフォームのとおりに表示できます。
  • メールアドレスは固定。 どの問い合わせにも返信の手段が必要です。普通の列として持つので、フォームの中身に関係なく、一覧で検索でき、詳細に メールで返信 のボタンを出せます。
  • Slack に個人情報は送らない。 フォームにどんな項目があっても、通知に含むのはサイト名、ページのパス、受信日時、リンクだけです。Slack 通知を参照してください。

開発者向けの詳細

フォームの定義

項目 ID の形と、バージョンごとに保存する定義の形です。項目名、入力例、説明は、ja と en の文を持ちます。

packages/server/src/features/contact-form/form-definition.ts
export const FIELD_ID_PATTERN = /^f_[a-z0-9]{1,32}$/;
packages/server/src/features/contact-form/form-definition.ts
/** A field of the contact form. */
export interface FormField {
  /** Immutable id, generated by the server. Matches {@link FIELD_ID_PATTERN}. */
  id: string;
  type: FormFieldType;
  label: LocalizedText;
  placeholder?: LocalizedText;
  /** Help text shown under the field. */
  hint?: LocalizedText;
  required: boolean;
  /** Text types only. Defaults to {@link DEFAULT_MAX_LENGTH}. */
  maxLength?: number;
  /** Choice types only: 1 to {@link FORM_LIMITS}.options entries. */
  options?: FormFieldOption[];
  /** Shown as a column in the admin list by default. */
  showInList: boolean;
}
packages/server/src/features/contact-form/form-definition.ts
/** The stored definition of one version. */
export interface FormDefinition {
  /** The fields on the form, in order. */
  fields: FormField[];
  /** Fields removed in this or an earlier version, newest removal first. */
  archived: ArchivedFormField[];
}

一覧の絞り込みと検索のしくみ

選択肢での絞り込みは、保存した JSON に json_extract() を使います。項目の ID はまず保存済みの定義から探します。JSON のパスと値は、どちらもバインドされたパラメーターとして渡します。

packages/server/src/features/submissions/submission-store.ts
choiceIs = sql`json_extract(${submissions.fields}, ${`$.${choice.fieldId}`}) = ${choice.value}`;

並び順を受信日時だけにする理由は、もう 1 つあります。フォームの項目の値は 1 つの JSON の列に入っています。項目で並べ替えると、項目ごとに別のクエリプランになってしまいます。

メールアドレスの検索は、lower(email) に Drizzle の like を使い、値を 1 つのバインドされたパラメーターで渡します。検索する文字の中の %、_、\ はエスケープするので、ただの文字として一致します。

packages/server/src/features/submissions/submission-store.ts
export const escapeLike = (term: string): string =>
  term.replace(/[\\%_]/g, (char) => `\\${char}`);

/**
 * Case-insensitive partial match on `email`. The pattern is one bound
 * parameter; only the `ESCAPE` character is part of the SQL text.
 */
const emailContains = (term: string): SQL =>
  sql`${like(
    sql`lower(${submissions.email})`,
    `%${escapeLike(term.toLowerCase())}%`,
  )} escape '\\'`;

メールアドレスの検索にインデックスを作らない理由。 LIKE '%文字%' の検索はインデックスを使えず、D1 は submissions のすべての行を読みます。問い合わせフォームなら、これで問題ありません。

  • 問い合わせの多い小さな会社でも、件数は年に数千件です。
  • 保持期間(既定は 180 日)を過ぎた行は削除されます。
  • 短い行を数千件読むのは数ミリ秒で、D1 の読み取り行数もわずかです。

インデックスを増やすと INSERT のたびに遅くなるうえ、部分一致には役に立ちません。並び順と保持期間による削除には、created_at のインデックスを使います。

フォームの項目を検索しない理由。 自由に書かれた文を検索するには、保存したすべての値への LIKE か、フォームの変更のたびに追従する全文検索のインデックスが必要です。よくある用途は、絞り込みと CSV の書き出しで足ります。

問い合わせページがフォームを読み込む流れ

ページは、公開されている読み取り専用のエンドポイントから現在のフォームを読みます。

GET /api/contact/form

応答に入っているもの:

  • バージョン
  • 項目(ID、種類、項目名、入力例、説明、必須、最大文字数、選択肢)
  • 固定のメールアドレス欄
  • フォームが使えるかどうか(Turnstile のサイトキー、action、honeypot の名前)

シークレットは含みません。応答は 30 秒までキャッシュされることがあります(Cache-Control: public, max-age=30)。

starter は、読み込み中の表示を出しながら、ブラウザでフォームの項目を表示します。読み込みに失敗したら、site.config.ts の会社のメールアドレスを添えた短いメッセージを出します。ブラウザ用のヘルパーは、@goodnight-web/server/client の getContactForm() です。

ページは、表示したフォームのバージョンを formVersion として送ります。その間にフォームが変わっていたら、API は 409 form_outdated を返します。starter はフォームを読み込み直し、残っている項目の入力内容はそのままにして、もう一度送信するよう案内します。

サーバーが送信内容を確かめる流れ

サーバーは、D1 にある現在のバージョンから zod のスキーマを組み立て、fields を確かめます。ブラウザから送られた定義は使いません。

組み立てたスキーマは、Worker の isolate ごと、バージョンごとにキャッシュします。リクエストごとに読むのは、現在のバージョン番号だけです。

  • 受け付けるのは、現在の項目の ID だけです。それ以外のキーは、__proto__ も含めて 400 で拒否します。
  • 必須、文字数、形式、選択肢の値を確かめます。テキストからは制御文字、双方向テキストの制御文字、ゼロ幅スペース、ワードジョイナー、バイトオーダーマーク、前後の空白を取り除きます(複数行のテキストは改行とタブを残します)。ゼロ幅接合子、ゼロ幅非接合子、異体字セレクタは、絵文字やペルシア語、インドの文字に必要なので残します。対になっていないサロゲートは U+FFFD に置き換えます。
  • 空の任意項目は、保存する JSON に入れません。

ルートは現在のバージョンを読み、古いページからの送信を拒否し、そのバージョンのスキーマで値を確かめます。

packages/server/src/features/contact/contact-routes.ts
const data = parsed.data;
const form = await getCurrentForm(env.DB);
if (data.formVersion !== undefined && data.formVersion !== form.version) {
  return apiError(c, 409, 'form_outdated');
}
const fields = form.definition.fields;
const values = valuesSchemaFor(env.DB, form.version, fields).safeParse(
  data.fields,
);
if (!values.success) {
  return apiError(
    c,
    400,
    'invalid_input',
    contactFieldErrors(values.error, input, 'fields'),
  );
}

スキーマは、何かをコピーする前に、パースした JSON そのものでまずキーを確かめます。

packages/server/src/features/contact-form/form-values-schema.ts
const shape: Record<string, z.ZodType> = {};
for (const field of fields) shape[field.id] = valueSchema(field);
const ids = new Set(fields.map((field) => field.id));
// Checked by own keys first, on the parsed JSON itself: JSON.parse()
// creates `__proto__` as an own key, which copying into a new object or a
// key lookup with `in` would silently drop or accept.
return z
  .custom<Record<string, unknown>>(
    (value) =>
      typeof value === 'object' && value !== null && !Array.isArray(value),
  )
  .superRefine((values, ctx) => {
    const unknown = Object.keys(values).filter((key) => !ids.has(key));
    if (unknown.length > 0) {
      ctx.addIssue({
        code: 'unrecognized_keys',
        keys: unknown,
        input: values,
      });
    }
  })
  .pipe(z.strictObject(shape))

問い合わせは、値を項目 ID をキーにした JSON として submissions.fields に、バージョンを submissions.form_version に保存します。データベースを参照してください。

目次