HexabaseとNext.js、blastengineを使ってお問い合わせフォームを作る

[Hexabase](https://www.hexabase.com/)はエンタープライズ企業向けのBaaS（Backend as a Service）を提供しています。認証やデータストア（データベース）、ファイルストレージ、FaaS（Function as a Service）といった機能があります。

![Untitled.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/bde99500-5f11-02ad-7d57-cc918d4a96be.png)


今回はそんなHexabaseとblastengineを使ってお問い合わせフォームを実装します。

## コード

今回のコードはhttps://github.com/blastengineMania/hexabase-nextjs-contact にアップしてあります。実装時の参考にしてください。

## アーキテクチャ

今回のアーキテクチャは以下のようになります。Hexabaseのデータストアにお問い合わせ内容を登録した際に、それをフックとしてアクションスクリプト（Hexbaseの提供するFaaS）を呼び出します。その内容をblastengineに送ることで、お問い合わせ主への確認メールや自分たちへの通知を行います。

![Untitled 1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/94c8c30a-6e3e-113f-afb7-50f54315dc2e.png)

## blastengineの準備

### ユーザ登録する

blastengineにユーザ登録します。管理画面に入るためのユーザID、パスワードが手に入るので、ログインします（ユーザIDは後で使います）。

![Untitled.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/dd84066c-7735-d55d-9e53-c782974fca71.png)

### 送信元ドメインのSPFを設定する

送信元として利用するドメイン（自分で持っているもの）の設定をします。これは任意のドメイン管理サービスで設定できますが、TXTレコードに以下のSPFを追加します。

```
txt @ v=spf1 include:spf.besender.jp ~all
```

### APIキーを取得する

ログイン後、管理画面の右上にある設定メニューに移動します。

![https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/47b2b2ac-2e41-77fe-d84f-0ae0b7541f36.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/47b2b2ac-2e41-77fe-d84f-0ae0b7541f36.jpeg)

そして設定の中で、APIキーを取得します。

![https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/2b5ca124-3020-1d39-84e4-911cd4db27e2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/2b5ca124-3020-1d39-84e4-911cd4db27e2.png)

## Hexabaseの準備

まずHexabase側の準備です。以下を順番に作成します。

- ワークスペース
任意の名前でOKです。ワークスペースIDはメモしておきます。
- プロジェクト
任意の名前でOKです。プロジェクトIDはメモしておきます。
- データストア
日本語名「お問い合わせ」、英語名はContactとします。データストアIDもContactとします。

### データストアの設計

お問い合わせテーブルは、以下の項目を持っています。

| 項目 | 型 | 英語名&ID |
| --- | --- | --- |
| 会社名 | テキスト | company |
| 名前 | テキスト | name |
| メールアドレス | テキスト | mailaddress |
| 問い合わせ種別 | 選択 | type |
| 問い合わせ内容 | テキスト | body |

![Untitled 2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/02ebbb51-4b9e-e749-641e-df65f663f4f0.png)


### 選択肢の用意

問い合わせ種別の選択肢を、以下のように作成します。英語名とIDは同じにしておきます。

| 日本語名 | 英語名&ID |
| --- | --- |
| 会社について | Company |
| 製品について | Product |
| 採用について | Recruitment |
| PR&広報 | PR |

![Untitled 3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/c4f7291c-1340-e0c0-e641-8d0e55f5193c.png)


### パブリックトークンの準備

Hexabaseでは認証して利用するのが基本ですが、お問い合わせの場合は認証は使わないでしょう。そこで、特定のユーザー情報に紐付くパブリックトークンを生成します。なお、執筆現在ではワークスペースごとの設定が必要です（要問い合わせ）。

![Untitled 4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/68d73dbd-26d7-9648-7488-ffdffb94581a.png)


## 実装について

### フロントエンド

フロントエンドはNext.jsで実装します。入力項目は以下の通りです。これは先ほどのデータストアの設計と合わせています。

| 項目 | 型 | 変数名 |
| --- | --- | --- |
| 会社名 | テキスト | company |
| 名前 | テキスト | name |
| メールアドレス | テキスト | mailaddress |
| 問い合わせ種別 | 選択 | type |
| 問い合わせ内容 | テキスト | body |

![Untitled 1.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/265cf767-1036-afd9-cce2-cc81d74b34d6.jpeg)


コードは以下のようになります。

### ステートの準備

今回は以下のステートを用意しています。

```tsx
// フォームの型
type formData = {
  company: string;
  name: string;
  email: string;
  type: string;
  message: string;
};

// 選択項目の型
type SelectOption = {
  label: string;
  value: string;
};

// フォームのデフォルト値
const defaultValue: formData = {
	company: '',
	name: '',
	email: '',
	type: '',
	message: '',
};
const [form, setForm] = useState<formData>(defaultValue);
const [options, setOptions] = useState<SelectOption[]>([]);
const [project, setProject] = useState<Project>(null);
```

### 表示されたタイミングの処理

表示された際に、選択肢「問い合わせ種別」を作成します。

```tsx
useEffect(() => {
	initialize();
}, []);

const initialize = async () => {
	// パブリックトークンをセット
	await client.setToken(process.env.NEXT_PUBLIC_PUBLIC_TOKEN!);
	// 利用するワークスペースをセット
	await client.setWorkspace(process.env.NEXT_PUBLIC_WORKSPACE_ID!);
	// 利用するプロジェクトをセット
	const project = await client.currentWorkspace.project(
		process.env.NEXT_PUBLIC_PROJECT_ID!
	);
	// プロジェクトはuseStateを使って更新
	setProject(project);
	// 利用するデータストアをセット
	const datastore = await project.datastore(
		process.env.NEXT_PUBLIC_DATASTORE_ID!
	);
	// フィールドデータを取得
	const field = await datastore.field('type');
	// フィールドデータから選択肢を取得
	const options = await field.options();
	// フォームの初期値をセット
	setForm({ ...form, ...{ type: options[0].id } });
	// 選択肢をセット
	setOptions(
		options.map((option) => {
			return {
				value: option.id,
				label: option.value.ja,
			};
		})
	);
};

```

これで `options` を使って選択肢を表示できます。

```html
<div className="block type">
	<label htmlFor="frm-type">お問い合わせ種別</label>
	<select
		onChange={(e) =>
			setForm({ ...form, ...{ type: e.target.value } })
		}
	>
		{options.map((option) => (
			<option value={option.value}>{option.label}</option>
		))}
	</select>
</div>

```

### フォームについて

フォームは以下のようになります。入力されたら、その値をフォームに反映しています。ボタンやフォームの送信時には `send` 関数を呼んでいます。

```html
<>
	<div className={styles.container}>
		<Head>
			<title>お問い合わせ</title>
		</Head>

		<main className={styles.main}>
			<h3 className={styles.title}>お問い合わせ</h3>
			<form className="container" onSubmit={send}>
				<div className="company block">
					<label htmlFor="frm-company">会社名</label>
					<input
						id="frm-company"
						type="text"
						value={form.company}
						autoComplete="company"
						onChange={(e) =>
							setForm({ ...form, ...{ company: e.target.value } })
						}
						required
					/>
				</div>
				<div className="account block">
					<label htmlFor="frm-name">名前</label>
					<input
						id="frm-name"
						type="text"
						value={form.name}
						autoComplete="name"
						onChange={(e) =>
							setForm({ ...form, ...{ name: e.target.value } })
						}
						required
					/>
				</div>
				<div className="email block">
					<label htmlFor="frm-email">メールアドレス</label>
					<input
						id="frm-email"
						type="email"
						value={form.email}
						autoComplete="email"
						onChange={(e) =>
							setForm({ ...form, ...{ email: e.target.value } })
						}
						required
					/>
				</div>
				<div className="block type">
					<label htmlFor="frm-type">お問い合わせ種別</label>
					<select
						onChange={(e) =>
							setForm({ ...form, ...{ type: e.target.value } })
						}
					>
						{options.map((option) => (
							<option value={option.value}>{option.label}</option>
						))}
					</select>
				</div>
				<div className="message block">
					<label htmlFor="frm-message">Message</label>
					<textarea
						id="frm-message"
						rows={6}
						value={form.message}
						onChange={(e) =>
							setForm({ ...form, ...{ message: e.target.value } })
						}
					>
					</textarea>
				</div>
				<button onClick={send}>送信</button>
			</form>
		</main>
	</div>
</>

```

また、CSS（global.css）は以下のようになります。

```css
html,
body {
  padding: 0;
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen,
    Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
  background: #1e1e1e;
  min-height: 100vh;
  display: flex;
  color: rgb(243, 241, 239);
  justify-content: center;
  align-items: center;
}

.block {
  display: flex;
  flex-direction: column;
}

.name {
  display: flex;
  flex-direction: row;
  justify-content: space-between;
}

.container {
  font-size: 1.3rem;
  border-radius: 10px;
  width: 85%;
  padding: 50px;
  box-shadow: 0 54px 55px rgb(78 78 78 / 25%), 0 -12px 30px rgb(78 78 78 / 25%),
    0 4px 6px rgb(78 78 78 / 25%), 0 12px 13px rgb(78 78 78 / 25%),
    0 -3px 5px rgb(78 78 78 / 25%);
}

.container input {
  font-size: 1.2rem;
  margin: 10px 0 10px 0px;
  border-color: rgb(31, 28, 28);
  padding: 10px;
  border-radius: 5px;
  background-color: #e8f0fe;
}

.container select {
  font-size: 1.2rem;
  margin: 10px 0 10px 0px;
  border-color: rgb(31, 28, 28);
  padding: 10px;
  border-radius: 5px;
  background-color: #e8f0fe;
}

.container textarea {
  margin: 10px 0 10px 0px;
  padding: 5px;
  border-color: rgb(31, 28, 28);
  border-radius: 5px;
  background-color: #e8f0fe;
  font-size: 20px;
}

.container h1 {
  text-align: center;
  font-weight: 600;
}

.name div {
  display: flex;
  flex-direction: column;
}

.block button {
  padding: 10px;
  font-size: 20px;
  width: 30%;
  border: 3px solid black;
  border-radius: 5px;
}

.button {
  display: flex;
  align-items: center;
}

textarea {
  resize: none;
}

```

![Untitled.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/d71e1f93-b154-f50f-69cc-c68fb6c38316.jpeg)


### 送信した際の処理

送信ボタンを押した際の処理として、Hexabaseにレコードを保存します。これはHexabaseのTypeScript SDKを使って実装しています。

```tsx
const send = async (e) => {
	e.preventDefault();
	// お問い合わせが入るテーブル
	const datastore = await project.datastore(
		process.env.NEXT_PUBLIC_DATASTORE_ID!
	);
	// お問い合わせ種別のフィールドを取得
	const field = await datastore.field('type');
	// お問い合わせの選択肢を取得
	const options = await field.options();
	// 該当する選択肢を取得
	const option = options.find((option) => option.id === form.type);
	// レコードを作成
	const item = await datastore.item();
	// レコードにフォームの入力値を適用して、保存
	await item
		.set('company', form.company)
		.set('name', form.name)
		.set('type', option.id)
		.set('email', form.email)
		.set('message', form.message)
		.save();
	// 送信完了のアラート
	alert('送信しました');
	// 入力値を初期化
	setForm(defaultValue);
};

```

![Untitled 5.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/d48e3dc1-40c2-cc6f-b890-05be0d238785.png)


### アクションスクリプト

データストアへの保存処理ができたので、保存をトリガーとしたアクションスクリプトを作成します。まず、blastengineに関連する環境設定を2つ追加します。これはアプリケーション設定のプログラム拡張より行います。

| 環境設定名 | 値 |
| --- | --- |
| BLASTENGINE_USER_ID | 先ほど作成したAPIユーザー名 |
| BLASTENGINE_API_KEY | 先ほど作成したAPIキー |

![Untitled 1.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/5d03a9ec-33af-653d-52b6-98388a4e5ae0.jpeg)


### データストアのアクションスクリプトを追加

続けて、新規保存処理時のアクションスクリプトを追加します。これはHexabaseの管理画面、データストアの設定画面で作成します。

![Untitled 6.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/197026/583a1e89-f69d-dfaa-245b-7e951faa152f.png)


実際のコードは以下のようになります。このコードは `ポスト` （データ保存後）の処理として実行します。

### コードの基本

Hexabaseのアクションスクリプトでは、main関数が呼ばれます。つまり、以下のようになります。この時、レコードデータが引数として渡されます。

```tsx
async function main(params) {

}
```

今回は以下のようにし、 `sendByBlastengine` 関数を呼んでいます。

```tsx
async function main(params) {
    try {
        const res = await sendByBlastengine(params);
        logger.log(res);
        return res;
    } catch (err) {
        logger.log(err);
    }
}

async function sendByBlastengine(params) {
}

```

以下は `sendByBlastengine` 関数内の実装です。

### トークンを生成する

先ほど環境変数に指定したAPIユーザー名、APIキーを使ってトークンを生成します。このトークンはHTTPリクエストのヘッダーに対して、利用します。

```tsx
const apiKey = '{BLASTENGINE_API_KEY}';
const apiUser = '{BLASTENGINE_USER_ID}';
const str = `${apiUser}${apiKey}`;
const hashHex = crypto
	.createHash('sha256')
	.update(str, 'utf8')
	.digest('hex');
const token = Buffer
	.from(hashHex.toLowerCase())
	.toString('base64');
const headers = {
		Authorization: `Bearer ${token}`,
	'Content-Type': 'application/json',
};
```

### 件名・本文を用意する

続けてメール送信する内容を準備します。

```tsx
const text_part = `${params.fields_data.name.value}様

お問い合わせいただきありがとうございます。内容を確認し、追ってご連絡いたします。

会社名：
${params.fields_data.company.value}
お名前：
${params.fields_data.name.value}
お問い合わせ内容：
${params.fields_data.message.value}
`;
const data = {
from: {
	email: 'no-reply@blastengine.jp',
	name: '管理者'
},
to: params.fields_data.email.value || 'info@blastengine.jp',
subject: 'お問い合わせありがとうございます',
encode: 'UTF-8',
text_part,
};

```

最後にネットワークライブラリの axios を使って、blastengineにメール送信を行います。

```tsx
return axios.post('<https://app.engn.jp/api/v1/deliveries/transaction>', data, { headers });
```

スクリプト全体は以下のようになります。

```tsx
async function main(params) {
    try {
        const res = await sendByBlastengine(params);
        logger.log(res);
        return res;
    } catch (err) {
        logger.log(err);
    }
}

async function sendByBlastengine(params) {
    const apiKey = '{BLASTENGINE_API_KEY}';
    const apiUser = '{BLASTENGINE_USER_ID}';
    const str = `${apiUser}${apiKey}`;
    const hashHex = crypto
    	.createHash('sha256')
    	.update(str, 'utf8')
    	.digest('hex');
    const token = Buffer
    	.from(hashHex.toLowerCase())
    	.toString('base64');

    const headers = {
        Authorization: `Bearer ${token}`,
    	'Content-Type': 'application/json',
    };

    const text_part = `${params.fields_data.name.value}様

    お問い合わせいただきありがとうございます。内容を確認し、追ってご連絡いたします。

    会社名：
    ${params.fields_data.company.value}
    お名前：
    ${params.fields_data.name.value}
    お問い合わせ内容：
    ${params.fields_data.message.value}
    `;
    const data = {
		from: {
			email: 'no-reply@blastengine.jp',
			name: '管理者'
		},
		to: params.fields_data.email.value || 'info@blastengine.jp',
		subject: 'お問い合わせありがとうございます',
		encode: 'UTF-8',
		text_part,
	};

    return axios.post('<https://app.engn.jp/api/v1/deliveries/transaction>', data, { headers });
};
```

## まとめ

今回はHexabaseとblastengine、Next.jsを使ってお問い合わせフォームを作成しました。入力項目の選択肢を含め、Hexabase側で管理できるので修正も容易です。

今回はメール本文を直書きしていますが、Hexabaseのデータストアに保存しておいても良いでしょう。その場合は、アクションスクリプト側でデータストアから取得して利用することになります。

[エンジニア向けメール配信システム「ブラストエンジン（blastengine）」](https://blastengine.jp/)