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

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

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

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

## フォームを編集する

1. 管理画面にログインし、メニューの **フォーム**（英語表示では Form）を開きます。**お問い合わせフォーム** のページに、項目が順番に並んでいます。
2. 項目を編集します。できることは、画面の画像の後にまとめています。
3. **バージョン N として保存** を押します。項目が新しいバージョンとして保存されます。

<AdminScreen
  name='form-editor'
  alt='フォームの編集画面。固定のメールアドレス、並べ替えボタン付きの 5 項目、固定のプライバシーポリシーへの同意、項目を追加ボタン、プレビュー'
  caption='フォームのバージョン 1。右上の保存ボタンは、変更するまで押せません。'
/>

編集画面でできること:

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

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

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

## 変えられない部分

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

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

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

## 項目の種類と上限

| 種類       | 管理画面での表示 | 保存する値                     | 補足                                                         |
| ---------- | ---------------- | ------------------------------ | ------------------------------------------------------------ |
| `text`     | 1 行のテキスト   | 1 行のテキスト                 | 制御文字、目に見えない書式用の文字、前後の空白を取り除きます |
| `textarea` | 複数行のテキスト | 改行を含むテキスト             |                                                              |
| `tel`      | 電話番号         | テキスト                       | 数字、`+`、`-`、`(`、`)`、空白。全角の数字も受け付けます     |
| `url`      | URL              | テキスト                       | `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 です。データの形は[フォームの定義](#form-definition)にあります。

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

<AdminScreen
  name='form-field'
  alt='追加した項目の設定。日本語と英語の項目名、種類、最大文字数、必須にする、お問い合わせ一覧に表示する、削除'
  caption='追加したばかりの「ご予算」。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 でフォームに戻ります。

## 問い合わせ一覧

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

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

### 未読と既読

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

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

**スパムの疑い** は状態ではなく、別の印です。honeypot かスパムスコア（[問い合わせフォームの保護](https://goodnight-web.dev/ja/docs/how-it-works/contact-protection.md)を参照）で自動で付くほか、管理者が詳細の **スパムとして扱う** で付けることもできます。本物の問い合わせなら、詳細で **スパムではない** を選ぶと印が外れます。スパムの疑いがある問い合わせにも、未読と既読があります。

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

### 列

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

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

### タブと絞り込み

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

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

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

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

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

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

### 並び順

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

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

### メールアドレスで検索

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

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

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

## 問い合わせの詳細

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

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

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

- **未読に戻す**: あとで対応するときに使います。
- **スパムとして扱う**: スパムの疑いの印を付けます。印が付いた問い合わせでは **スパムではない** になります。
- **メールで返信**: 固定のメールアドレスあてに、メールアプリで返信を書きます。
- **削除**: 確認のあとで削除します。[プライバシーについて](https://goodnight-web.dev/ja/docs/privacy.md#deleted-data)を参照してください。

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

現在のフォームにない項目の値も表示します。その項目には「バージョン 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 通知](https://goodnight-web.dev/ja/docs/how-it-works/notifications.md)を参照してください。

## 開発者向けの詳細

### フォームの定義 [#form-definition]

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

```ts title="packages/server/src/features/contact-form/form-definition.ts"
export const FIELD_ID_PATTERN = /^f_[a-z0-9]{1,32}$/;
```

```ts title="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;
}
```

```ts title="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 のパスと値は、どちらもバインドされたパラメーターとして渡します。

```ts title="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 つのバインドされたパラメーターで渡します。検索する文字の中の `%`、`_`、`\` はエスケープするので、ただの文字として一致します。

```ts title="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 の書き出しで足ります。

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

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

```http
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 に入れません。

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

```ts title="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 そのものでまずキーを確かめます。

```ts title="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` に保存します。[データベース](https://goodnight-web.dev/ja/docs/database.md)を参照してください。
