LPに設置する日程調整カレンダーを自作してみた（自動メール返信、カレンダー参照/登録、スプシ書き込みあり）[無料]


## 前置き
LPにカレンダー埋め込んで、複数人のGoogleのカレンダーと同期したいことよくありますよね。
あと、LPへ申し込んでくれた方への確認メール、スプシへ転記、オンラインMTGのURLの発行/送付など全部一括でできなものかといつも私は悩んでしまいます。

Saasの日程調整サービスは[jicoo](https://www.jicoo.com/products/embed),[timerex](https://timerex.net/)などがありますが、

どのサービスもユーザー毎の課金なので複数人のGoogleのカレンダーを参照したい場合値段が上がってしまうのがどうしてもネックになり自分で作ることにしました。


## 今回実現できること
### LP側
- カレンダーを表示
- CloudRunFunctions()へGETリクエストを送り、Googleカレンダー参照（複数人数OK）する
- Googleカレンダー参照から空いている日程を表示
- formのsubmit時にCloudRunFunctionsへPOSTリクエストを送信

### CloudRunFunctions側
GETリクエスト
- Googleカレンダーの参照

POST リクエスト (form submit後の処理)
- Slack に通知を送る
- Google スプレッドシートに記録する
- Gmail でお礼メールを送信する
- Google カレンダーにイベントを登録する


## 使用する技術
- Google Cloud Functions（Node.js）
- Slack Webhook
- Google Sheets API
- Gmail API
- Google Calendar API


## 実装前準備

### GCP
- [Google Cloud プロジェクト（無料枠でOK）を作成](https://cloud.google.com/resource-manager/docs/creating-managing-projects?hl=ja)
- [Cloud Console で以下のAPIを有効](https://cloud.google.com/endpoints/docs/openapi/enable-api?hl=ja)
    - Cloud Functions API
    - Google Sheets API
    - Gmail API
    - Google Calendar API
- [サービスアカウントの認証情報を作成、ダウンロードしておく](https://developers.google.com/workspace/guides/create-credentials?hl=ja#create_credentials_for_a_service_account)
(GoogleCloudプロジェクトを作成するとサービスアカウントは自動生成される)

### SLACK
- Webhookの取得
    - [Incoming Webhooksを使い、webhookのURLを取得](https://tools.slack.dev/java-slack-sdk/ja-jp/guides/incoming-webhooks/)

### LP
- カレンダーを埋め込みたいLP

## 実装

### LP
今回はLPに埋め込むカレンダーのコードは[bolt.new](https://bolt.new/~/)で自動生成しました。
サンプルコードは記載していますが、formとsubmitがサンプルにはありません。適宜追加してください。
※今回の本筋はCloudRunFunctions側でのsubmit後の機能なので、LPに埋め込むカレンダーはあくまでも参考程度にしてください※

カレンダー画面
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/226096/368d2962-4d38-42df-a96f-3486dd3dd673.png)

```text:カレンダー機能の要約
カレンダー予約システムのコード概要
主な機能は以下の通りです：

主要機能
    利用可能時間の表示：10:00～20:00の時間枠を表示
    週間カレンダー：1週間分の日付と時間枠を一覧表示
    予約状況の可視化：記号（○△×）で予約可能状況を表示
    週切り替え：前週/次週へのナビゲーションボタン
技術的特徴
    カレンダー表示はグリッドレイアウトで実装
    各時間枠の利用可能性をビジュアルで表示（○=複数可、△=1人のみ可、×=不可）
    利用不可の時間枠は自動的に選択不可
    時間枠を選択すると担当者をドロップダウンから選択可能
データ処理
    CloudRunFunctionsから取得したカレンダーデータによるユーザーの予約状況管理
    選択した時間枠の情報をリアルタイムで表示
    過去の時間枠は自動的に選択不可
    このシステムはオンライン予約やカウンセリング予約向けに設計されており、簡潔な操作性と視覚的なフィードバックを重視しています。
```

```html:index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="stylesheet" href="style.css">
    <title>時間枠選択</title>
  </head>
  <body>
    <div id="app">
      <div class="header-container">
        <h1>時間枠選択</h1>
        <div id="selected-slots">
          <h2>選択された時間枠</h2>
          <div id="selected-slots-list"></div>
        </div>
      </div>
      <div id="calendar"></div>
    </div>
    <script type="module" src="/main.js"></script>
  </body>
</html>
```

```javascript:main.js
import './style.css';

async function fetchAvailability(timeMin, timeMax) {
  // GOOGLE CloudRunFunctionsのGETを呼び出し、カレンダーの情報を取得
  // 実際はこっちのコードを使用します。
  // try {
  //   const response = await fetch(`https://project-name.asia-northeast1.run.app/?timeMin=${timeMin}&timeMax=${timeMax}`);
  //   const data = await response.json();
  //   return data;
  // } catch (error) {
  //   console.error('Error fetching availability:', error);
  //   return null;
  // }  
  // このモックデータの例では2人分のカレンダー情報を読み込んで表示しようとしている
  return {
    "UserB": {
      "busy": [
        { "start": "2025-04-14T01:00:00Z", "end": "2025-04-14T01:15:00Z" },
        { "start": "2025-04-14T02:00:00Z", "end": "2025-04-14T02:30:00Z" },
        { "start": "2025-04-15T01:00:00Z", "end": "2025-04-15T01:15:00Z" },
        { "start": "2025-04-15T06:00:00Z", "end": "2025-04-15T07:40:00Z" },
        { "start": "2025-04-16T01:00:00Z", "end": "2025-04-16T01:15:00Z" },
        { "start": "2025-04-17T01:00:00Z", "end": "2025-04-17T01:15:00Z" },
        { "start": "2025-04-18T01:00:00Z", "end": "2025-04-18T01:15:00Z" },
        { "start": "2025-04-21T01:00:00Z", "end": "2025-04-21T01:15:00Z" },
        { "start": "2025-04-22T01:00:00Z", "end": "2025-04-22T01:15:00Z" },
        { "start": "2025-04-22T07:00:00Z", "end": "2025-04-22T07:40:00Z" },
        { "start": "2025-04-23T01:00:00Z", "end": "2025-04-23T01:15:00Z" },
        { "start": "2025-04-24T01:00:00Z", "end": "2025-04-24T01:15:00Z" },
        { "start": "2025-04-25T01:00:00Z", "end": "2025-04-25T01:15:00Z" },
        { "start": "2025-04-28T01:00:00Z", "end": "2025-04-28T03:30:00Z" },
        { "start": "2025-04-29T01:00:00Z", "end": "2025-04-29T01:15:00Z" },
        { "start": "2025-04-29T07:00:00Z", "end": "2025-04-29T07:40:00Z" }
      ]
    },
    "UserA": {
      "errors": [
        {
          "domain": "global",
          "reason": "notFound"
        }
      ],
      "busy": []
    }
  };
}

function getAvailableUsers(date, hour, busyPeriods) {
  if (!busyPeriods) return [];
  
  const slotTime = new Date(date);
  slotTime.setHours(hour, 0, 0, 0);

  // 現在時刻より過去の場合は空配列を返す
  const now = new Date();
  if (slotTime < now) {
    return [];
  }

  // 開始時間を過ぎている場合は空配列を返す
  const currentHour = now.getHours();
  if (slotTime.getDate() === now.getDate() && hour <= currentHour) {
    return [];
  }

  // エラーのないユーザーのみを対象とする
  const validUsers = Object.entries(busyPeriods).filter(([_, userData]) => !userData.errors);
  
  // 空いているユーザーを返す
  return validUsers
    .filter(([username, userData]) => {
      const isBusy = userData.busy.some(period => {
        const start = new Date(period.start);
        const end = new Date(period.end);
        return slotTime >= start && slotTime < end;
      });
      return !isBusy;
    })
    .map(([username]) => username);
}

function getAvailableUsersCount(date, hour, busyPeriods) {
  return getAvailableUsers(date, hour, busyPeriods).length;
}

function getAvailabilitySymbol(availableUsers, totalUsers) {
  if (availableUsers === 0) return '×';
  if (availableUsers === 1) return '△';
  return '○';
}

function isTimeSlotBusy(date, hour, busyPeriods) {
  return getAvailableUsersCount(date, hour, busyPeriods) === 0;
}

let currentWeekStart = new Date();
let currentAvailability = null;

async function createCalendar() {
  const calendar = document.getElementById('calendar');
  const selectedSlotsList = document.getElementById('selected-slots-list');
  calendar.innerHTML = ''; // カレンダーをクリア

  // ナビゲーションボタンを作成
  const navigationContainer = document.createElement('div');
  navigationContainer.className = 'calendar-navigation';
  
  const prevButton = document.createElement('button');
  prevButton.textContent = '前の週';
  prevButton.onclick = () => {
    currentWeekStart.setDate(currentWeekStart.getDate() - 7);
    createCalendar();
  };
  
  const nextButton = document.createElement('button');
  nextButton.textContent = '次の週';
  nextButton.onclick = () => {
    if (currentWeekStart.getTime() + (7 * 24 * 60 * 60 * 1000) <= new Date().getTime() + (14 * 24 * 60 * 60 * 1000)) {
      currentWeekStart.setDate(currentWeekStart.getDate() + 7);
      createCalendar();
    }
  };

  navigationContainer.appendChild(prevButton);
  navigationContainer.appendChild(nextButton);
  calendar.appendChild(navigationContainer);

  const grid = document.createElement('div');
  grid.className = 'calendar-grid';

  const dates = [];
  
  // 1週間分の日付を生成
  for (let i = 0; i < 7; i++) {
    const date = new Date(currentWeekStart);
    date.setDate(currentWeekStart.getDate() + i);
    dates.push(date);
  }

  // 利用可能時間をチェック
  const timeMin = dates[0].toISOString();
  const timeMax = dates[dates.length - 1].toISOString();
  currentAvailability = await fetchAvailability(timeMin, timeMax);
  const validUsers = Object.entries(currentAvailability).filter(([_, userData]) => !userData.errors);
  const totalUsers = validUsers.length;

  // ヘッダーを作成（日付）
  grid.appendChild(document.createElement('div')); // 空のセル
  dates.forEach(date => {
    const header = document.createElement('div');
    header.className = 'calendar-header';
    header.textContent = `${date.getMonth() + 1}/${date.getDate()}`;
    grid.appendChild(header);
  });

  // 時間枠を作成
  for (let hour = 10; hour < 21; hour++) {
    // 時間ラベル
    const timeLabel = document.createElement('div');
    timeLabel.className = 'time-label';
    timeLabel.textContent = `${hour}:00`;
    grid.appendChild(timeLabel);

    // 各日付の時間枠
    dates.forEach(date => {
      const timeSlot = document.createElement('div');
      timeSlot.className = 'time-slot';
      timeSlot.dataset.date = `${date.getFullYear()}/${date.getMonth() + 1}/${date.getDate()}`;
      timeSlot.dataset.time = `${hour}:00`;

      // 利用可能時間のチェック
      const availableUsers = getAvailableUsersCount(date, hour, currentAvailability);
      const symbol = getAvailabilitySymbol(availableUsers, totalUsers);
      
      // 空いているユーザー数を表示
      const availableSymbol = document.createElement('div');
      availableSymbol.className = 'availability-symbol';
      availableSymbol.textContent = symbol;
      timeSlot.appendChild(availableSymbol);

      if (availableUsers === 0) {
        timeSlot.classList.add('unavailable');
      } else {
        timeSlot.addEventListener('click', () => {
          // 他の選択された時間枠をすべて解除
          const selectedSlots = document.querySelectorAll('.time-slot.selected');
          selectedSlots.forEach(slot => {
            if (slot !== timeSlot) {
              slot.classList.remove('selected');
            }
          });

          // クリックされた時間枠の選択状態を切り替え
          timeSlot.classList.toggle('selected');
          updateSelectedSlots();
        });
      }

      grid.appendChild(timeSlot);
    });
  }

  calendar.appendChild(grid);

  // 選択された時間枠を更新する関数
  function updateSelectedSlots() {
    const selectedSlots = document.querySelectorAll('.time-slot.selected');
    selectedSlotsList.innerHTML = '';

    if (selectedSlots.length === 0) {
      selectedSlotsList.textContent = '選択された時間枠はありません';
      return;
    }

    const ul = document.createElement('ul');
    selectedSlots.forEach(slot => {
      const li = document.createElement('li');
      const slotInfo = document.createElement('div');
      slotInfo.className = 'slot-info';
      slotInfo.textContent = `${slot.dataset.date} ${slot.dataset.time}`;
      
      const usernameSelect = document.createElement('select');
      usernameSelect.className = 'username-select';
      
      // 空いているユーザーを取得
      const availableUsers = getAvailableUsers(
        new Date(slot.dataset.date),
        parseInt(slot.dataset.time),
        currentAvailability
      );

      // 選択肢を追加
      const defaultOption = document.createElement('option');
      defaultOption.value = '';
      defaultOption.textContent = 'ユーザーを選択';
      usernameSelect.appendChild(defaultOption);

      availableUsers.forEach(username => {
        const option = document.createElement('option');
        option.value = username;
        option.textContent = username;
        usernameSelect.appendChild(option);
      });
      
      li.appendChild(slotInfo);
      li.appendChild(usernameSelect);
      ul.appendChild(li);
    });

    selectedSlotsList.appendChild(ul);
  }

  // 初期状態を表示
  updateSelectedSlots();
}

// カレンダーを初期化
createCalendar().catch(error => {
  console.error('カレンダーの初期化中にエラーが発生しました:', error);
});
```

```css:style.html
:root {
  font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
  line-height: 1.5;
  font-weight: 400;
  color: #213547;
  background-color: #ffffff;
}

body {
  margin: 0;
  min-width: 320px;
  min-height: 100vh;
}

#app {
  max-width: 1280px;
  margin: 0 auto;
  padding: 2rem;
}

.header-container {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  margin-bottom: 2rem;
}

h1 {
  font-size: 2em;
  margin: 0;
}

#calendar {
  width: 100%;
}

.calendar-navigation {
  display: flex;
  justify-content: space-between;
  margin-bottom: 1rem;
}

.calendar-navigation button {
  padding: 0.8rem 2rem;
  background-color: white;
  color: #213547;
  border: 2px solid #213547;
  border-radius: 6px;
  cursor: pointer;
  font-size: 1.1rem;
  transition: all 0.2s;
}

.calendar-navigation button:hover {
  background-color: #213547;
  color: white;
}

.calendar-navigation button:disabled {
  background-color: #f0f0f0;
  border-color: #cccccc;
  color: #999;
  cursor: not-allowed;
}

#selected-slots {
  width: 300px;
  background: white;
  padding: 1.5rem;
  border-radius: 8px;
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
  border: 1px solid #eee;
  margin-left: 2rem;
}

.calendar-grid {
  display: grid;
  grid-template-columns: auto repeat(7, 1fr);
  gap: 2px;
  margin-bottom: 2em;
  overflow-x: auto;
}

.calendar-header {
  background-color: #f0f0f0;
  padding: 8px;
  text-align: center;
  font-weight: bold;
  white-space: nowrap;
}

.time-slot {
  padding: 8px;
  border: 1px solid #ddd;
  cursor: pointer;
  text-align: center;
  position: relative;
  min-height: 40px;
  display: flex;
  align-items: center;
  justify-content: center;
  transition: all 0.2s ease;
}

.time-slot:hover:not(.unavailable) {
  background-color: #f5f5f5;
  transform: scale(1.02);
}

.time-slot.selected {
  background-color: #213547;
  color: white;
  border-color: #374151;
}

.time-slot.unavailable {
  background-color: #f0f0f0;
  cursor: not-allowed;
  color: #999;
}

.time-label {
  padding: 8px;
  font-weight: bold;
  text-align: right;
  white-space: nowrap;
}

#selected-slots h2 {
  margin-top: 0;
  margin-bottom: 1rem;
  font-size: 1.25em;
  color: #333;
  padding-bottom: 0.5rem;
}

#selected-slots-list {
  margin-top: 1em;
  padding: 1em;
  border-radius: 4px;
  background: #f8f8f8;
}

#selected-slots-list ul {
  list-style: none;
  padding: 0;
  margin: 0;
}

#selected-slots-list li {
  padding: 0.75em;
  background: white;
  border-radius: 4px;
  margin-bottom: 0.5em;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}

#selected-slots-list li:last-child {
  margin-bottom: 0;
}

.slot-info {
  margin-bottom: 0.5rem;
}

.username-select {
  width: 100%;
  padding: 0.5rem;
  border: 1px solid #ddd;
  border-radius: 4px;
  font-size: 0.9rem;
  margin-top: 0.5rem;
  background-color: white;
}

.username-select:focus {
  outline: none;
  border-color: #213547;
  box-shadow: 0 0 0 2px rgba(33, 53, 71, 0.1);
}

.availability-symbol {
  font-size: 1.2em;
  color: #666;
}

.time-slot.selected .availability-symbol {
  color: white;
}

.time-slot.unavailable .availability-symbol {
  color: #999;
}

/* プライバシーポリシーページのスタイル */
.privacy-policy .content {
  max-width: 800px;
  margin: 0 auto;
  background: white;
  padding: 2rem;
  border-radius: 8px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.privacy-policy section {
  margin-bottom: 2rem;
}

.privacy-policy h2 {
  color: #213547;
  border-bottom: 2px solid #eee;
  padding-bottom: 0.5rem;
  margin-bottom: 1rem;
}

.privacy-policy ul {
  padding-left: 1.5rem;
}

.privacy-policy li {
  margin-bottom: 0.5rem;
}

.privacy-policy .contact-info {
  background: #f8f8f8;
  padding: 1rem;
  border-radius: 4px;
  margin: 1rem 0;
}

.privacy-policy .policy-footer {
  margin-top: 3rem;
  padding-top: 1rem;
  border-top: 1px solid #eee;
  text-align: right;
  color: #666;
}

.back-link {
  display: inline-block;
  padding: 0.5rem 1rem;
  background-color: #213547;
  color: white;
  text-decoration: none;
  border-radius: 4px;
  transition: background-color 0.2s;
}

.back-link:hover {
  background-color: #374151;
}

@media (max-width: 1024px) {
  .header-container {
    flex-direction: column;
  }

  #selected-slots {
    width: 100%;
    margin-left: 0;
    margin-top: 1rem;
    margin-bottom: 2rem;
  }

  .privacy-policy .content {
    padding: 1rem;
  }

  .back-link {
    margin-top: 1rem;
  }
}
```



### CloudRunFunctions
#### サービス作成
事前準備で作成したプロジェクト配下に[CloudRunFunctions](https://cloud.google.com/functions?hl=ja)の新規関数を作成する。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/226096/0e302eb1-4c13-4e84-a2dc-fe04fe1282aa.png)

##### 作成時に気を付ける設定

[ソースコード]
インライン エディタで関数を作成する(※初心者はこれ)

[リージョン]
asia-northeast1 (東京)

[ランタイム]
node.js

[認証]
LPから呼び出すため、誰でも関数を呼び出せるように「未認証の呼び出しを許可」を設定

[課金]
リクエストベース
(※インスタンスベースだとコンテナが常に稼働している状態になるため余分にお金がかかるので注意)

[Ingress]
すべて

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/226096/54a33049-e1cb-4852-9329-e7974fadc4e6.png)


関数を作成後、「新しいリビジョンの編集とデプロイをクリック」
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/226096/f8ec86fb-4891-4f93-88de-dfccb90ab2cc.png)

環境変数を設定
コンテナ>「コンテナの編集」>タブ「変数とシークレット」>環境変数

| 定数 | 例 | 説明 |
|:-:|:-:|:-:|
| CALENDAR_EMAILS  |  abc@gmail.com,abc1@example.com | カレンダーの参照/登録を行いたいメールアドレスをカンマ区切りで記載  |
| SHEET_ID  | 1tilMhefadEJz2-7nVaBO-eKlGBJNJwsMsoffbzyi4krGfal6hyo  |  操作を行いたいスプレッドシートのID |
| SERVICE_ACCOUNT_JSON  | {"type": "service_account","project_id": "your-project-id","private_key_id": "abcdefghijklmnopqrstuvwxyz1234567890","private_key": "-----BEGIN PRIVATE KEY-----\nMIIEv...snip...\n-----END PRIVATE KEY-----\n","client_email": "your-service-account@your-project-id.iam.gserviceaccount.com","client_id": "123456789012345678901","auth_uri": "https://accounts.google.com/o/oauth2/auth","token_uri": "https://oauth2.googleapis.com/token","auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs","client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/your-service-account%40your-project-id.iam.gserviceaccount.com","universe_domain": "googleapis.com"} | サービスアカウントの認証情報を作成、ダウンロードしておいた認証情報<br>(GoogleWorkSpaceを利用している場合のみ、domain-wide delegationを用いたユーザなりすましがCloudRunのデフォルトで認証情報を使用した場合、成功しないため必要)|

ソースコードの追加
CloudRun>ソースに遷移


#### ソースコード

[google-api-nodejs-client](https://github.com/googleapis/google-api-nodejs-client) を使用して、Googleの各サービスのAPIを使用しています。

**今回の具体的なサービスと機能**

GETリクエスト
- Googleカレンダーの参照 | checkBusy

POST リクエスト (form submit後の処理)
- Slack に通知を送る | _notifySlackMousikomi
- Google スプレッドシートに記録する | appendToSheet
- Gmail でお礼メールを送信する | sendEmail
- Google カレンダーにイベントを登録する | addCalendarEvent

他のサービスを使う場合は[サンプル](https://github.com/googleapis/google-api-nodejs-client/tree/main/samples
)を参照してみてください。

```javascript:index.js
const functions = require('@google-cloud/functions-framework');
const { google } = require("googleapis");

// Cloud Function ハンドラ定義
functions.http('calendarHandler', async (req, res) => {
  // CORSヘッダー設定
  res.setHeader('Access-Control-Allow-Origin', 'https://abc.co.jp'); // 許可するLPのドメインを指定
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');

  // メソッドのみで処理を振り分け
  const method = req.method;

  try {
    switch(method) {
      case 'GET':
        // GET：googleカレンダー参照
        await checkBusy(req, res);
        break;
        
      case 'POST':
        // POST：イベント追加に使用
        await addEvent(req, res);
        break;
        
      case 'OPTIONS':
        // プリフライトリクエスト対応
        return res.status(204).send('No Content');
        
      default:
        // 対応していないメソッド
        res.status(405).send('Method Not Allowed');
    }
  } catch (err) {
    console.error(err);
    res.status(500).json({ error: 'Internal Server Error' });
  }
});

// googleカレンダー参照
async function checkBusy(req, res) {
 // 環境変数から取得（カンマ区切りで複数メール）
  const emails = process.env.CALENDAR_EMAILS?.split(',').map(e => e.trim());

  if (!emails || emails.length === 0) {
    res.status(500).json({ error: "No calendar emails defined in environment." });
    return;
  }

  // GET パラメータチェック
  // timeMin:検索対象の開始時間,timeMax検索対象の終わり時間
  const { timeMin, timeMax } = req.query;

  if (!timeMin || !timeMax) {
    return res.status(400).json({ error: "Missing timeMin or timeMax" });
    }

  const auth = new google.auth.GoogleAuth({
    scopes: ["https://www.googleapis.com/auth/calendar.readonly"],
  });

  const authClient = await auth.getClient();
  const calendar = google.calendar({ version: "v3", auth: authClient });

  // 一連のカレンダーの予定の有無に関する情報を返します
　//https://developers.google.com/workspace/calendar/api/v3/reference/freebusy/query?hl=ja
  const result = await calendar.freebusy.query({
    requestBody: {
      timeMin,
      timeMax,
      items: emails.map(email => ({ id: email }))
    }
  });
  const calendars = result.data.calendars;

  // レスポンス情報のメールアドレスのドメインを削除してユーザー名だけに変更
  const simplified = {};
  for (const email in calendars) {
    const username = email.split('@')[0];
    simplified[username] = calendars[email];
  }

  res.json(simplified);
}

// LPでFORMをsubmitした時に呼ばれる
// 後続処理をディスパッチのための関数
async function addEvent(req, res) {
  let hangoutLink = ''
  
  // Slack 通知（申込内容）
  try {
    await _notifySlackMousikomi(req.body);
  } catch (error) {
    console.error(_formatError(error, 'Slack申込通知'));
  }

  // スプレッドシートへの追加
  try {
    await appendToSheet(req.body);
  } catch (error) {
    console.error(_formatError(error, 'スプレッドシート書き込み'));
    errors.push(errMsg);
  }

  // Googleカレンダー登録
  try {
    const response = await addCalendarEvent(req.body);
    hangoutLink = response?.hangoutLink;
  } catch (error) {
    console.error(_formatError(error, 'Googleカレンダー登録'));
  }

  // メール送信
  try {
    await sendEmail(req.body,hangoutLink);
  } catch (error) {
    console.error(_formatError(error, 'メール送信'));
  }

  res.status(200).json({ message: 'Received add-event request', data: req.body });
}

// ----private------

// slackに申し込みの情報を通知
// リクエストのデータはLPで自分で設定したものを参照して変更してください。
async function _notifySlackMousikomi(data) {

  const message = `📥 新しい申し込みがありました！
*名前*: ${data.name}
*希望時間*: ${data.interview_time}`;
  await _notifySlack(message)
}

// メールを送信する
async function sendEmail(data,hangoutLink) {

  const serviceAccount = JSON.parse(process.env.SERVICE_ACCOUNT_JSON);

  const auth = new google.auth.GoogleAuth({
  clientOptions: { subject:'a.b.c@example.co.jp'},  //google workspaceの人は必須、送信元のユーザになりすます設定
  credentials: serviceAccount,  //subject(なりすまし)を使う際はデフォルトの認証情報ではできないため設定
  scopes: [
    'https://mail.google.com/',
    'https://www.googleapis.com/auth/gmail.modify',
    'https://www.googleapis.com/auth/gmail.compose',
    'https://www.googleapis.com/auth/gmail.send',
    ],
  });

  const authClient = await auth.getClient();
  const gmail = google.gmail({ version: "v1", auth: authClient });

  const subject = '件名';
  const utf8Subject = `=?utf-8?B?${Buffer.from(subject).toString('base64')}?=`;
  const from_mail = `送信元名称`;
  const utf8from = `=?utf-8?B?${Buffer.from(from_mail).toString('base64')}?=`;

  const body = `
  <p>${data.name}様</p>

  <p>このたびは、お申し込みいただき、誠にありがとうございます。<br>
  本メールは自動返信となります。</p>

  <p>以下の内容で日程を確定させていただきました。</p>

  <p>【お名前】${data.name}<br>
  【ご希望の日時】${data.interview_time}</p>

  <p>面談は以下の Google Meet リンクからご参加ください：<br>
  ${hangoutLink}</p>

  <p>なお、当日は開始時間の5分前を目安にご入室いただけますと幸いです。</p>

  <p>ご不明点などがございましたら、お気軽にご連絡くださいませ。</p>

  <p>今後ともどうぞよろしくお願いいたします。</p>
  `;

  const messageParts = [
    `From: ${utf8from} <a.b.c@example.co.jp>`,
    `To: <${data.email}>`,
    'Content-Type: text/html; charset=utf-8',
    'MIME-Version: 1.0',
    `Subject: ${utf8Subject}`,
    '',
    body,
  ];
  const message = messageParts.join('\n');

  // The body needs to be base64url encoded.
  const encodedMessage = Buffer.from(message)
    .toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
  
  //https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages/send?hl=ja
  const res = await gmail.users.messages.send({
      userId: 'me',
      requestBody: {
        raw: encodedMessage,
      },
    });

  return res.data;
}

// スプシに書き込み
async function appendToSheet(data) {
    const auth = new google.auth.GoogleAuth({
    scopes: ['https://www.googleapis.com/auth/spreadsheets'],
    });

    const client = await auth.getClient();
    const sheets = google.sheets({ version: 'v4', auth: client });

    const spreadsheetId = process.env.SHEET_ID;
    const range = 'A2';
    const now = new Date();
    const jstDateTime = now.toLocaleString('ja-JP', {timeZone: 'Asia/Tokyo',hour12: false,});

    const values = [[
    jstDateTime,
    data.name,
    data.interview_time
    ]];

    //https://developers.google.com/workspace/sheets/api/reference/rest/v4/spreadsheets.values/append?hl=ja
    await sheets.spreadsheets.values.append({
    spreadsheetId,
    range,
    valueInputOption: 'USER_ENTERED',
    insertDataOption: 'INSERT_ROWS',
    requestBody: { values }
    });
}

// カレンダーに登録(meetのURL追加)
async function addCalendarEvent(eventData) {
  const serviceAccount = JSON.parse(process.env.SERVICE_ACCOUNT_JSON);

  const auth = new google.auth.GoogleAuth({
    clientOptions: { subject:'a.b.c@example.co.jp'},  //google workspaceの人は必須、送信元のユーザになりすます設定
    credentials: serviceAccount,  //subject(なりすまし)を使う際はデフォルトの認証情報ではできないため設定
    scopes: ['https://www.googleapis.com/auth/calendar',
    "https://www.googleapis.com/auth/calendar.events"],
  });

  const authClient = await auth.getClient();
  const calendar = google.calendar({ version: 'v3', auth: authClient });

  const users = eventData.available_users
  .split(',')
  .map(user => user.trim())
  .filter(user => user); // 空文字を除去

  const randomUser = users[Math.floor(Math.random() * users.length)];
  const calendarId = `${randomUser}example.co.jp.co.jp`; // カレンダーIDはメールアドレス
  const now = new Date();
  const jstDateTime = now.toLocaleString('ja-JP', {timeZone: 'Asia/Tokyo',hour12: false,});
  const description = `申し込み情報
    申し込み日時：${jstDateTime}
    氏名：${eventData.name}
    面談日：${eventData.interview_time}
  
  ` 
  // Date オブジェクトに変換
  const interviewTime = eventData.interview_time;
  const [datePart, timePart] = interviewTime.split(" ");
  const [year, month, day] = datePart.split("/").map(Number);
  const [hour, minute] = timePart.split(":").map(Number);

    // JSTの時間をISO文字列に変換（+09:00をつける）
  const jstIsoString = new Date(
    Date.UTC(year, month - 1, day, hour - 9, minute)
  ).toISOString();

  // 月は0始まりなので -1 する必要あり
  const startDate = new Date(jstIsoString);
  const endDate = new Date(startDate.getTime() + 60 * 60 * 1000); // +1時間

  const randomStr = Math.random().toString(36).slice(-6);
  // 現在のUNIX時間（ミリ秒）
  const timestamp = Date.now();
  //requestIdを作成
  const requestId = `meet-${randomStr}-${timestamp}-pl`;

  // Date オブジェクトを ISO 8601 文字列に変換（タイムゾーンを維持したいなら元の形式を使う）
  const event = {
    summary: `[面談]${eventData.name}様`,
    description: description,
    start: {
      dateTime: startDate.toISOString(), // Google Calendar 用 ISO 形式 (UTC)
      timeZone: 'Asia/Tokyo',
    },
    end: {
      dateTime: endDate.toISOString(),
      timeZone: 'Asia/Tokyo',
    },
    attendees: [],
    attendees: eventData.email ? [{ email: eventData.email }] : [],
    conferenceData: {
      createRequest: {
        requestId: requestId, // 一意のID（毎回変えること）
        conferenceSolutionKey: {
          type: "hangoutsMeet"
        },
      },
    },
    reminders: {
        useDefault: false,
        overrides: [],
    },
    guestsCanModify: true,
    visibility: 'default',
  }

  //https://developers.google.com/workspace/calendar/api/v3/reference/events/insert?hl=ja
  const response = await calendar.events.insert({
    calendarId,
    requestBody: event,
    conferenceDataVersion: 1,
  });

  return response.data;
}

// slackに通知
async function _notifySlack(message) {
  const webhookUrl = process.env.SLACK_WEBHOOK_URL;

  const payload = {
    text: message,
  };

  const response = await fetch(webhookUrl, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(payload),
  });

  // エラーハンドリングしたければここでチェック
  if (!response.ok) {
    throw new Error(`Slack通知に失敗しました: ${response.statusText}`);
  }

  return response;
}

```

```javascript:package.json
{
  "dependencies": {
    "@google-cloud/functions-framework": "^3.0.0",
    "googleapis": "^126.0.1"
  }
}

```

## 補足説明
GoogleWorkSpaceを使用している場合は「[Google Workspaceの権限をサービスアカウントへ委譲](https://developers.google.com/workspace/cloud-search/docs/guides/delegation?hl=ja)」の設定が必要になります。
文字だとわかりにくい場合は[画像付き参考記事](https://tech-blog.talentio.co.jp/entry/2025/03/04/120000)を参照してみてください。

## おわりに
今後[google-api-nodejs-client](https://github.com/googleapis/google-api-nodejs-client)を使用している細かい実装の解説記事を追記して出していきますので本記事を保存してチェックしてください。

ここまで読んでいただけた方はいいねとストックよろしくお願いします。
@huton338 をフォローいただけるととてもうれしく思います。

## 一緒にPLAYLANDをつくっていく仲間を募集中です！

**プログラミング未経験の方へ**
まずは「つくる楽しさ」を体験してみませんか？
PLAYLANDプログラミングスクールで、ゼロから学べます。
▶︎ https://school.playland.co.jp/

**エンジニアの方へ**
私たちと一緒に、学びと成長の場を広げていきませんか？
PLAYLANDでは、仲間として加わってくれるエンジニアを募集しています。
▶︎ https://playland.co.jp/recruit