【個人開発／LINE Messaging API】Node.js, TypeScriptで現在地から美味しいお店を探すアプリを作ってみた（②）

LINE Messaging APIを使って現在地から美味しいお店を探すアプリを作ってみました。
完成形としては以下の通りです。

![名称未設定.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838372/5fd1fb94-0e4c-4112-e1cc-383516e75b2f.png)

![名称未設定2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838372/7f16f3d8-e960-a45a-a1ed-8c442c138ed8.png)

## アーキテクチャ
今回はサーバーレスアーキテクチャの根幹の機能である、**「AWS Lambda」**, **「Amazon API Gateway」**, **「Amazon DynamoDB」**の3つを使用してアプリを作成します。

![スクリーンショット 2021-06-26 15.43.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838372/4d71d546-5839-0784-8cd0-d8d4466e915c.png)

また、プロビジョニングやデプロイに関しては**AWS SAM(Serverless Application Model)**を使用します。

## 対象読者
・ Node.jsを勉強中の方
・ TypeScriptを勉強中の方
・ インフラ初心者の方
・ ポートフォリオのデプロイ先をどうするか迷っている方

**作成の難易度は低めです。**
理由は、必要なパッケージも少ないため要件が複雑ではないからです。
また、DynamoDBの操作は、CRUD（作成・読み込み・更新・削除）のうち、C・R・Uの3つを使用するので、初学者の踏み台アプリとして優秀かと思います。

## 記事
今回は2つの記事に分かれています。
お気に入り店の登録や解除などを今回の記事で行っています。

お店の検索を行うところまでを前の記事で行っています。
前回の記事をやっていないと今回の記事は全く理解できないのでこちらからご確認ください。

https://qiita.com/Ryo9597/items/660d988858c573c3d58e

## Github
完成形のコードは以下となります。

https://github.com/ssk9597/LINE-SAM-Lambda-Node-TypeScript-Gourmet

## アプリQR
こちらから触ってみてください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838372/8fcc0fbb-572b-ccd6-744f-cf8d290c2b4b.png)


## アプリの機能
今回は3つの機能を足していきます。

### お気に入り登録

| クライアント              | LINE Messaging API（バックエンド）                 |
| ------------------------- | -------------------------------------------------- |
|                           | ①メッセージを編集し、「行きつけ」ボタンを追加する |
| ②「行きつけ」をタップする |                                                    |
|                           | ③DynamoDBを作成する                               |
|                           | ④ポストバックのデータを元にDynamoDBに登録を行う |

### お気に入り店を探す

| クライアント              | LINE Messaging API（バックエンド）      |
| ------------------------- | --------------------------------------- |
| ①「行きつけ」をタップする |                                         |
|                           | ②user_idを元にDynamoDB から検索を行う |
|                           | ③FlexMessageを作成する                 |
|                           | ④お店の情報をFlexMessageで送る       |

### お気に入り店の解除

| クライアント                    | LINE Messaging API（バックエンド）                         |
| ------------------------------- | ---------------------------------------------------------- |
| ①「行きつけを解除」をタップする |                                                            |
|                                 | ②user_idとtimestampを元にDynamoDBからデータを削除する |

## ハンズオン！
### お気に入り登録を行う
#### 機能
これだけじゃイメージがつきにくいと思うので完成図を先に見せます。

![iOS の画像.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838372/663c6ef1-7434-9835-52fb-21f7e5ca32d5.png)

![スクリーンショット 2021-08-09 14.33.40.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838372/12cf4a7e-5e32-e759-b14c-c5482526eff1.png)

#### ①メッセージを編集し、「行きつけ」ボタンを追加する
「行きつけ」をタップすることで、お店の情報を渡したいので**ポストバックアクション**を使用します。

https://developers.line.biz/ja/reference/messaging-api/#postback-action

こちらを使うことで、`data`プロパティの値を受け取ることができます。
普通にメッセージとして送ってもいいのですが、送られる返信がお店の情報になってしまいます。
あまりよろしくないので、採用を見送りました。

ということでやっていきましょう。
今回は前回の記事で作成した`CreateFlexMessage.ts`に追加していきます。

```diff_typescript:api/src/Common/TemplateMessage/Gourmet/CreateFlexMessage.ts
// Load the package
import { FlexMessage, FlexCarousel, FlexBubble } from '@line/bot-sdk';

// Load the module
import { sortRatingGourmetArray } from './SortRatingGourmetArray';

// types
import { Gourmet, RatingGourmetArray } from './types/CreateFlexMessage.type';

export const createFlexMessage = async (
  user_id: string | undefined,
  googleMapApi: string
): Promise<FlexMessage | undefined> => {
  return new Promise(async (resolve, reject) => {
    try {
      // modules sortRatingGourmetArray
      const ratingGourmetArray: RatingGourmetArray = await sortRatingGourmetArray(
        user_id,
        googleMapApi
      );

      // FlexMessage
      const FlexMessageContents: FlexBubble[] = await ratingGourmetArray.map((gourmet: Gourmet) => {
        // Create a URL for a store photo
        const photoURL = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=400&photoreference=${gourmet.photo_reference}&key=${googleMapApi}`;

        // Create a URL for the store details
        const encodeURI = encodeURIComponent(`${gourmet.name} ${gourmet.vicinity}`);
        const storeDetailsURL = `https://maps.google.co.jp/maps?q=${encodeURI}&z=15&iwloc=A`;

        // Create a URL for store routing information
        const storeRoutingURL = `https://www.google.com/maps/dir/?api=1&destination=${gourmet.geometry_location_lat},${gourmet.geometry_location_lng}`;

        const flexBubble: FlexBubble = {
          type: 'bubble',
          hero: {
            type: 'image',
            url: photoURL,
            size: 'full',
            aspectMode: 'cover',
            aspectRatio: '20:13',
          },
          body: {
            type: 'box',
            layout: 'vertical',
            contents: [
              {
                type: 'text',
                text: gourmet.name,
                size: 'xl',
                weight: 'bold',
              },
              {
                type: 'box',
                layout: 'baseline',
                contents: [
                  {
                    type: 'icon',
                    url:
                      'https://scdn.line-apps.com/n/channel_devcenter/img/fx/review_gold_star_28.png',
                    size: 'sm',
                  },
                  {
                    type: 'text',
                    text: `${gourmet.rating}`,
                    size: 'sm',
                    margin: 'md',
                    color: '#999999',
                  },
                ],
                margin: 'md',
              },
            ],
          },
          footer: {
            type: 'box',
            layout: 'vertical',
            contents: [
              {
                type: 'button',
                action: {
                  type: 'uri',
                  label: '店舗詳細',
                  uri: storeDetailsURL,
                },
              },
              {
                type: 'button',
                action: {
                  type: 'uri',
                  label: '店舗案内',
                  uri: storeRoutingURL,
                },
              },
+             {
+               type: 'button',
+               action: {
+                 type: 'postback',
+                 label: '行きつけ',
+                 data: `lat=${gourmet.geometry_location_lat}&lng=${gourmet.geometry_location_lng}&name=${gourmet.name}&photo=${gourmet.photo_reference}&rating=${gourmet.rating}&vicinity=${gourmet.vicinity}`,
+                 displayText: '行きつけにする',
+               },
+             },
            ],
            spacing: 'sm',
          },
        };

        return flexBubble;
      });

      const flexContainer: FlexCarousel = {
        type: 'carousel',
        contents: FlexMessageContents,
      };

      const flexMessage: FlexMessage = {
        type: 'flex',
        altText: '近隣の美味しいお店10店ご紹介',
        contents: flexContainer,
      };

      resolve(flexMessage);
    } catch (err) {
      reject(err);
    }
  });
};
```

#### ②「行きつけ」をタップする
こちらはクライアント側での操作なのでやることはありません。

#### ③DynamoDBを作成する
前回の記事では、SAMテンプレートでDynamoDBを作成したのですが、今回は手動で作成します。
DBは以下のような値を持つレコードを作成していきます。

| PK          | SK             | K          | K          | K          | K                 | K                 |
| ----------- | -------------- | ---------- | ---------- | ---------- | ----------------- | ----------------- |
| user_id     | timestamp      | photo_url  | name       | rating     | store_details_url | store_routing_url |
| ユーザー ID | タイムスタンプ | 店舗の写真 | 店舗の名前 | 店舗の評価 | 店舗詳細          | 店舗案内          |

ソートキーを使う場合どのようにSAMを使うのかの記載が見つからなかったので手動とします。**（SAMテンプレートでのやり方を知っている方がいましたらお教えいただけますと幸いです。）**

DynamoDBを作ったことがない人もいると思うので、一応画像で説明します。

名前は何でもいいです。
一応自分は、`Gourmets_Favorite`で作成しています。
先に作成しているのでエラーメッセージ出てますが気にしないでください。

![スクリーンショット 2021-08-09 14.10.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838372/05e74c03-8b97-33fb-3e0b-de5cf61317c7.png)

![スクリーンショット 2021-08-09 14.11.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838372/382ea65a-259e-3fef-e30e-4ad043272abd.png)

#### ④ポストバックのデータを元にDynamoDBに登録を行う
まずは関数を呼び出している`index.ts`から記載していきます。

```diff_typescript:api/src/index.ts
// Load the package
import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk';
import aws from 'aws-sdk';

// Load the module
// TemplateMessage
import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation';
import { errorTemplate } from './Common/TemplateMessage/Error';
import { isCarTemplate } from './Common/TemplateMessage/IsCar';
import { createFlexMessage } from './Common/TemplateMessage/Gourmet/CreateFlexMessage';
// Database
import { putLocation } from './Common/Database/PutLocation';
import { updateIsCar } from './Common/Database/UpdateIsCar';
+ import { putFavorite } from './Common/Database/PutFavorite';

// SSM
const ssm = new aws.SSM();
const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = {
  Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN',
  WithDecryption: false,
};
const LINE_GOURMET_CHANNEL_SECRET = {
  Name: 'LINE_GOURMET_CHANNEL_SECRET',
  WithDecryption: false,
};
const LINE_GOURMET_GOOGLE_MAP_API = {
  Name: 'LINE_GOURMET_GOOGLE_MAP_API',
  WithDecryption: false,
};

exports.handler = async (event: any, context: any) => {
  // Retrieving values in the SSM parameter store
  const CHANNEL_ACCESS_TOKEN: any = await ssm
    .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN)
    .promise();
  const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise();
  const GOOGLE_MAP_API: any = await ssm.getParameter(LINE_GOURMET_GOOGLE_MAP_API).promise();

  const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value;
  const channelSecret: string = CHANNEL_SECRET.Parameter.Value;
  const googleMapApi: string = GOOGLE_MAP_API.Parameter.Value;

  // Create a client using the SSM parameter store
  const clientConfig: ClientConfig = {
    channelAccessToken: channelAccessToken,
    channelSecret: channelSecret,
  };
  const client = new Client(clientConfig);

  // body
  const body: any = JSON.parse(event.body);
  const response: WebhookEvent = body.events[0];

  // action
  try {
    await actionLocationOrError(client, response);
    await actionIsCar(client, response);
    await actionFlexMessage(client, response, googleMapApi);
+   await actionPutFavoriteShop(response, googleMapApi);
  } catch (err) {
    console.log(err);
  }
};

// 位置情報もしくはエラーメッセージを送る
const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => {
  try {
    // If the message is different from the target, returned
    if (event.type !== 'message' || event.message.type !== 'text') {
      return;
    }

    // Retrieve the required items from the event
    const replyToken = event.replyToken;
    const text = event.message.text;

    // modules
    const yourLocation = await yourLocationTemplate();
    const error = await errorTemplate();

    // Perform a conditional branch
    if (text === 'お店を探す') {
      await client.replyMessage(replyToken, yourLocation);
    } else if (text === '車' || text === '徒歩') {
      return;
    } else if (text === '行きつけのお店') {
      return;
    } else {
      await client.replyMessage(replyToken, error);
    }
  } catch (err) {
    console.log(err);
  }
};

// 移動手段の「車もしくは徒歩」かを尋ねるメッセージを送る
const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => {
  try {
    // If the message is different from the target, returned
    if (event.type !== 'message' || event.message.type !== 'location') {
      return;
    }

    // Retrieve the required items from the event
    const replyToken = event.replyToken;
    const userId = event.source.userId;
    const latitude: string = String(event.message.latitude);
    const longitude: string = String(event.message.longitude);

    // Register userId, latitude, and longitude in DynamoDB
    await putLocation(userId, latitude, longitude);

    // modules
    const isCar = await isCarTemplate();

    // Send a two-choice question
    await client.replyMessage(replyToken, isCar);
  } catch (err) {
    console.log(err);
  }
};

// 上記の選択を経て、おすすめのお店をFlex Messageにして送る
const actionFlexMessage = async (client: Client, event: WebhookEvent, googleMapApi: string) => {
  try {
    // If the message is different from the target, returned
    if (event.type !== 'message' || event.message.type !== 'text') {
      return;
    }

    // Retrieve the required items from the event
    const replyToken = event.replyToken;
    const userId = event.source.userId;
    const isCar = event.message.text;

    // Perform a conditional branch
    if (isCar === '車' || isCar === '徒歩') {
      // Register userId, isCar in DynamoDB
      await updateIsCar(userId, isCar);
      const flexMessage = await createFlexMessage(userId, googleMapApi);
      if (flexMessage === undefined) {
        return;
      }
      await client.replyMessage(replyToken, flexMessage);
    } else {
      return;
    }
  } catch (err) {
    console.log(err);
  }
};

+ // FlexMessageの「行きつけ」をタップしたらそのお店が登録される
+ const actionPutFavoriteShop = async (event: WebhookEvent, googleMapApi: + string) => {
+  try {
+    // If the message is different from the target, returned
+    if (event.type !== 'postback') {
+      return;
+    }
+
+    // Retrieve the required items from the event
+    const data = event.postback.data;
+    const timestamp = event.timestamp;
+    const userId = event.source.userId;
+
+    // conditional branching
+    const isFavorite = data.indexOf('timestamp');
+    if (isFavorite === -1) {
+      // Register data, userId in DynamoDB
+      await putFavorite(data, timestamp, userId, googleMapApi);
+    }
+  } catch (err) {
+    console.log(err);
+  }
+ };
```

では、DynamoDBにデータを追加するコードを書いていきましょう。
データの追加は`put`を使用します。

https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB/DocumentClient.html#put-property

また、次にポストバックのデータの使用方法に関してです。

```diff_json:
{
   "type":"postback",
   "label":"Buy",
+  "data":"action=buy&itemid=111",
   "text":"Buy"
}
```

データはこのように渡されます。
この値をどのように取得するかお分かりでしょうか？
JavaScriptに慣れている方であればすぐにお分かりでしょうね！

**指定した区切り文字で分割して文字列の配列にしましょう。**

ということで使うのは、**`split`**ですね。

https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/String/split

ということでやっていきましょう。

```typescript:api/src/Common/Database/PutFavorite.ts
// Load the package
import aws from 'aws-sdk';

// Create DynamoDB document client
const docClient = new aws.DynamoDB.DocumentClient();

export const putFavorite = (
  data: string,
  timestamp: number,
  userId: string | undefined,
  googleMapApi: string
) => {
  return new Promise((resolve, reject) => {
    // data
    const dataArray = data.split('&');
    const lat = dataArray[0].split('=')[1];
    const lng = dataArray[1].split('=')[1];
    const name = dataArray[2].split('=')[1];
    const photo = dataArray[3].split('=')[1];
    const rating = dataArray[4].split('=')[1];
    const vicinity = dataArray[5].split('=')[1];

    // Create a URL for a store photo
    const photoURL = `https://maps.googleapis.com/maps/api/place/photo?maxwidth=400&photoreference=${photo}&key=${googleMapApi}`;

    // Create a URL for the store details
    const encodeURI = encodeURIComponent(`${name} ${vicinity}`);
    const storeDetailsURL = `https://maps.google.co.jp/maps?q=${encodeURI}&z=15&iwloc=A`;

    // Create a URL for store routing information
    const storeRoutingURL = `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`;

    const params = {
      Item: {
        user_id: userId,
        timestamp: timestamp,
        photo_url: photoURL,
        name: name,
        rating: rating,
        store_details_url: storeDetailsURL,
        store_routing_url: storeRoutingURL,
      },
      ReturnConsumedCapacity: 'TOTAL',
      TableName: 'Gourmets_Favorite',
    };

    docClient.put(params, (err, data) => {
      if (err) {
        reject(err);
      } else {
        resolve(data);
      }
    });
  });
};
```

これで完了です。

それでは次に、お気に入りのお店を探しましょう。

### お気に入り店を探す
#### 機能
こちらも先にどのような機能かお見せします。
「行きつけ」をタップしたら、お気に入り登録したお店の一覧が表示されます。

![iOS の画像 (1).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838372/ae83428e-d717-ced9-2ed4-1a92425350ca.png)

#### ①「行きつけ」をタップする
こちらはクライアント側の操作なので特にすることはありません。

#### ②`user_id`を元にDynamoDBから検索を行う
DynamoDBからお気に入りのお店の情報を取得しましょう。
今回は複数取得する可能性が高いので`query`を使用します。

https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB/DocumentClient.html#query-property

ということでやっていきましょう。

```typescript:api/src/Common/TemplateMessage/Favorite/QueryDatabaseInfo.ts
// Load the package
import aws from 'aws-sdk';

// Create DynamoDB document client
const docClient = new aws.DynamoDB.DocumentClient();

export const queryDatabaseInfo = async (userId: string | undefined) => {
  return new Promise((resolve, reject) => {
    const params = {
      TableName: 'Gourmets_Favorite',
      ExpressionAttributeNames: { '#u': 'user_id' },
      ExpressionAttributeValues: { ':val': userId },
      KeyConditionExpression: '#u = :val',
    };

    docClient.query(params, (err, data) => {
      if (err) {
        reject(err);
      } else {
        resolve(data);
      }
    });
  });
};
```

#### ③FlexMessageを作成する
DynamoDBから取得した値を使用して`FlexMessage`を作成していきましょう。

```typescript:api/src/Common/TemplateMessage/Favorite/MakeFlexMessage.ts
// Load the package
import { FlexMessage, FlexCarousel, FlexBubble } from '@line/bot-sdk';

// Load the module
import { queryDatabaseInfo } from './QueryDatabaseInfo';

// types
import { Item, QueryItem } from './types/MakeFlexMessage.type';

export const makeFlexMessage = async (userId: string | undefined): Promise<FlexMessage> => {
  return new Promise(async (resolve, reject) => {
    try {
      // modules queryDatabaseInfo
      const query: any = await queryDatabaseInfo(userId);
      const queryItem: QueryItem = query.Items;

      // FlexMessage
      const FlexMessageContents: FlexBubble[] = await queryItem.map((item: Item) => {
        const flexBubble: FlexBubble = {
          type: 'bubble',
          hero: {
            type: 'image',
            url: item.photo_url,
            size: 'full',
            aspectMode: 'cover',
            aspectRatio: '20:13',
          },
          body: {
            type: 'box',
            layout: 'vertical',
            contents: [
              {
                type: 'text',
                text: item.name,
                size: 'xl',
                weight: 'bold',
              },
              {
                type: 'box',
                layout: 'baseline',
                contents: [
                  {
                    type: 'icon',
                    url:
                      'https://scdn.line-apps.com/n/channel_devcenter/img/fx/review_gold_star_28.png',
                    size: 'sm',
                  },
                  {
                    type: 'text',
                    text: `${item.rating}`,
                    size: 'sm',
                    margin: 'md',
                    color: '#999999',
                  },
                ],
                margin: 'md',
              },
            ],
          },
          footer: {
            type: 'box',
            layout: 'vertical',
            contents: [
              {
                type: 'button',
                action: {
                  type: 'uri',
                  label: '店舗詳細',
                  uri: item.store_details_url,
                },
              },
              {
                type: 'button',
                action: {
                  type: 'uri',
                  label: '店舗案内',
                  uri: item.store_routing_url,
                },
              },
              {
                type: 'button',
                action: {
                  type: 'postback',
                  label: '行きつけを解除',
                  data: `timestamp=${item.timestamp}`,
                  displayText: '行きつけを解除する',
                },
              },
            ],
            spacing: 'sm',
          },
        };

        return flexBubble;
      });

      const flexContainer: FlexCarousel = {
        type: 'carousel',
        contents: FlexMessageContents,
      };

      const flexMessage: FlexMessage = {
        type: 'flex',
        altText: 'お気に入りのお店',
        contents: flexContainer,
      };

      resolve(flexMessage);
    } catch (err) {
      reject(err);
    }
  });
};
```

独自の型定義があるのでファイルを作成しましょう。

```typescript:api/src/Common/TemplateMessage/Favorite/types/MakeFlexMessage.type.ts
export type Item = {
  user_id: string;
  photo_url: string;
  rating: string;
  timestamp: number;
  name: string;
  store_routing_url: string;
  store_details_url: string;
};

export type QueryItem = Item[];
```

#### ④お店の情報をFlexMessageで送る
最後にFlexMessageで送信しましょう。

```diff_typescript:api/src/index.ts
// Load the package
import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk';
import aws from 'aws-sdk';

// Load the module
// TemplateMessage
import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation';
import { errorTemplate } from './Common/TemplateMessage/Error';
import { isCarTemplate } from './Common/TemplateMessage/IsCar';
import { createFlexMessage } from './Common/TemplateMessage/Gourmet/CreateFlexMessage';
+ import { makeFlexMessage } from './Common/TemplateMessage/Favorite/MakeFlexMessage';
// Database
import { putLocation } from './Common/Database/PutLocation';
import { updateIsCar } from './Common/Database/UpdateIsCar';
import { putFavorite } from './Common/Database/PutFavorite';

// SSM
const ssm = new aws.SSM();
const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = {
  Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN',
  WithDecryption: false,
};
const LINE_GOURMET_CHANNEL_SECRET = {
  Name: 'LINE_GOURMET_CHANNEL_SECRET',
  WithDecryption: false,
};
const LINE_GOURMET_GOOGLE_MAP_API = {
  Name: 'LINE_GOURMET_GOOGLE_MAP_API',
  WithDecryption: false,
};

exports.handler = async (event: any, context: any) => {
  // Retrieving values in the SSM parameter store
  const CHANNEL_ACCESS_TOKEN: any = await ssm
    .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN)
    .promise();
  const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise();
  const GOOGLE_MAP_API: any = await ssm.getParameter(LINE_GOURMET_GOOGLE_MAP_API).promise();

  const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value;
  const channelSecret: string = CHANNEL_SECRET.Parameter.Value;
  const googleMapApi: string = GOOGLE_MAP_API.Parameter.Value;

  // Create a client using the SSM parameter store
  const clientConfig: ClientConfig = {
    channelAccessToken: channelAccessToken,
    channelSecret: channelSecret,
  };
  const client = new Client(clientConfig);

  // body
  const body: any = JSON.parse(event.body);
  const response: WebhookEvent = body.events[0];
  // console.log(JSON.stringify(response));

  // action
  try {
    await actionLocationOrError(client, response);
    await actionIsCar(client, response);
    await actionFlexMessage(client, response, googleMapApi);
    await actionPutFavoriteShop(response, googleMapApi);
+   await actionTapFavoriteShop(client, response);
  } catch (err) {
    console.log(err);
  }
};

// 位置情報もしくはエラーメッセージを送る
const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => {
  try {
    // If the message is different from the target, returned
    if (event.type !== 'message' || event.message.type !== 'text') {
      return;
    }

    // Retrieve the required items from the event
    const replyToken = event.replyToken;
    const text = event.message.text;

    // modules
    const yourLocation = await yourLocationTemplate();
    const error = await errorTemplate();

    // Perform a conditional branch
    if (text === 'お店を探す') {
      await client.replyMessage(replyToken, yourLocation);
    } else if (text === '車' || text === '徒歩') {
      return;
    } else if (text === '行きつけのお店') {
      return;
    } else {
      await client.replyMessage(replyToken, error);
    }
  } catch (err) {
    console.log(err);
  }
};

// 移動手段の「車もしくは徒歩」かを尋ねるメッセージを送る
const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => {
  try {
    // If the message is different from the target, returned
    if (event.type !== 'message' || event.message.type !== 'location') {
      return;
    }

    // Retrieve the required items from the event
    const replyToken = event.replyToken;
    const userId = event.source.userId;
    const latitude: string = String(event.message.latitude);
    const longitude: string = String(event.message.longitude);

    // Register userId, latitude, and longitude in DynamoDB
    await putLocation(userId, latitude, longitude);

    // modules
    const isCar = await isCarTemplate();

    // Send a two-choice question
    await client.replyMessage(replyToken, isCar);
  } catch (err) {
    console.log(err);
  }
};

// 上記の選択を経て、おすすめのお店をFlex Messageにして送る
const actionFlexMessage = async (client: Client, event: WebhookEvent, googleMapApi: string) => {
  try {
    // If the message is different from the target, returned
    if (event.type !== 'message' || event.message.type !== 'text') {
      return;
    }

    // Retrieve the required items from the event
    const replyToken = event.replyToken;
    const userId = event.source.userId;
    const isCar = event.message.text;

    // Perform a conditional branch
    if (isCar === '車' || isCar === '徒歩') {
      // Register userId, isCar in DynamoDB
      await updateIsCar(userId, isCar);
      const flexMessage = await createFlexMessage(userId, googleMapApi);
      if (flexMessage === undefined) {
        return;
      }
      await client.replyMessage(replyToken, flexMessage);
    } else {
      return;
    }
  } catch (err) {
    console.log(err);
  }
};

// FlexMessageの「行きつけ」をタップしたらそのお店が登録される
const actionPutFavoriteShop = async (event: WebhookEvent, googleMapApi: string) => {
  try {
    // If the message is different from the target, returned
    if (event.type !== 'postback') {
      return;
    }

    // Retrieve the required items from the event
    const data = event.postback.data;
    const timestamp = event.timestamp;
    const userId = event.source.userId;

    // conditional branching
    const isFavorite = data.indexOf('timestamp');
    if (isFavorite === -1) {
      // Register data, userId in DynamoDB
      await putFavorite(data, timestamp, userId, googleMapApi);
    }
  } catch (err) {
    console.log(err);
  }
};

+ // リッチメニューの「行きつけ」をタップしたらメッセージが送られる
+ const actionTapFavoriteShop = async (client: Client, event: WebhookEvent) => {
+  // If the message is different from the target, returned
+  if (event.type !== 'message' || event.message.type !== 'text') {
+    return;
+  }
+
+  // Retrieve the required items from the event
+  const replyToken = event.replyToken;
+  const userId = event.source.userId;
+  const text = event.message.text;
+
+  if (text === '行きつけのお店') {
+    const flexMessage = await makeFlexMessage(userId);
+    if (flexMessage === undefined) {
+      return;
+    }
+    await client.replyMessage(replyToken, flexMessage);
+  } else {
+    return;
+  }
+ };
```

### お気に入り店の解除
#### 機能
「行きつけを解除」をタップするとデータが消去されます。

![iOS の画像 (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838372/5e1fdead-006f-0de3-c614-a51503dddb60.png)

![スクリーンショット 2021-08-09 15.06.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838372/a5632e04-bf2a-cbfc-d33f-4d68b603a044.png)

#### ①「行きつけを解除」をタップする
こちらはクライアント側の操作なので特にすることはありません。

#### ②`user_id`と`timestamp`を元にDynamoDBからデータを削除する
こちらも同様にポストバックを使用します。

```diff_json:
{
   "type": "postback",
   "label": "行きつけを解除",
   "data": `timestamp=${item.timestamp}`,
   "displayText": "行きつけを解除する",
}
```

こちらも`split`を使って値を取得しましょう。

次にDynamoDBの削除は、`delete`を使用します。

https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/DynamoDB/DocumentClient.html#delete-property

```typescript:api/src/Common/Database/DeleteFavorite.ts
// Load the package
import aws from 'aws-sdk';

// Create DynamoDB document client
const docClient = new aws.DynamoDB.DocumentClient();

export const deleteFavorite = (data: string, userId: string | undefined) => {
  return new Promise((resolve, reject) => {
    // data
    const timestamp: number = Number(data.split('=')[1]);

    const params = {
      TableName: 'Gourmets_Favorite',
      Key: {
        user_id: userId,
        timestamp: timestamp,
      },
    };

    docClient.delete(params, (err, data) => {
      if (err) {
        reject(err);
      } else {
        resolve(data);
      }
    });
  });
};
```

ではこの関数を読み込みましょう。

```diff_typescript:api/src/index.ts
// Load the package
import { ClientConfig, Client, WebhookEvent } from '@line/bot-sdk';
import aws from 'aws-sdk';

// Load the module
// TemplateMessage
import { yourLocationTemplate } from './Common/TemplateMessage/YourLocation';
import { errorTemplate } from './Common/TemplateMessage/Error';
import { isCarTemplate } from './Common/TemplateMessage/IsCar';
import { createFlexMessage } from './Common/TemplateMessage/Gourmet/CreateFlexMessage';
import { makeFlexMessage } from './Common/TemplateMessage/Favorite/MakeFlexMessage';
// Database
import { putLocation } from './Common/Database/PutLocation';
import { updateIsCar } from './Common/Database/UpdateIsCar';
import { putFavorite } from './Common/Database/PutFavorite';
+ import { deleteFavorite } from './Common/Database/DeleteFavorite';

// SSM
const ssm = new aws.SSM();
const LINE_GOURMET_CHANNEL_ACCESS_TOKEN = {
  Name: 'LINE_GOURMET_CHANNEL_ACCESS_TOKEN',
  WithDecryption: false,
};
const LINE_GOURMET_CHANNEL_SECRET = {
  Name: 'LINE_GOURMET_CHANNEL_SECRET',
  WithDecryption: false,
};
const LINE_GOURMET_GOOGLE_MAP_API = {
  Name: 'LINE_GOURMET_GOOGLE_MAP_API',
  WithDecryption: false,
};

exports.handler = async (event: any, context: any) => {
  // Retrieving values in the SSM parameter store
  const CHANNEL_ACCESS_TOKEN: any = await ssm
    .getParameter(LINE_GOURMET_CHANNEL_ACCESS_TOKEN)
    .promise();
  const CHANNEL_SECRET: any = await ssm.getParameter(LINE_GOURMET_CHANNEL_SECRET).promise();
  const GOOGLE_MAP_API: any = await ssm.getParameter(LINE_GOURMET_GOOGLE_MAP_API).promise();

  const channelAccessToken: string = CHANNEL_ACCESS_TOKEN.Parameter.Value;
  const channelSecret: string = CHANNEL_SECRET.Parameter.Value;
  const googleMapApi: string = GOOGLE_MAP_API.Parameter.Value;

  // Create a client using the SSM parameter store
  const clientConfig: ClientConfig = {
    channelAccessToken: channelAccessToken,
    channelSecret: channelSecret,
  };
  const client = new Client(clientConfig);

  // body
  const body: any = JSON.parse(event.body);
  const response: WebhookEvent = body.events[0];

  // action
  try {
    await actionLocationOrError(client, response);
    await actionIsCar(client, response);
    await actionFlexMessage(client, response, googleMapApi);
    await actionPutFavoriteShop(response, googleMapApi);
    await actionTapFavoriteShop(client, response);
+   await actionDeleteFavoriteShop(response);
  } catch (err) {
    console.log(err);
  }
};

// 位置情報もしくはエラーメッセージを送る
const actionLocationOrError = async (client: Client, event: WebhookEvent): Promise<void> => {
  try {
    // If the message is different from the target, returned
    if (event.type !== 'message' || event.message.type !== 'text') {
      return;
    }

    // Retrieve the required items from the event
    const replyToken = event.replyToken;
    const text = event.message.text;

    // modules
    const yourLocation = await yourLocationTemplate();
    const error = await errorTemplate();

    // Perform a conditional branch
    if (text === 'お店を探す') {
      await client.replyMessage(replyToken, yourLocation);
    } else if (text === '車' || text === '徒歩') {
      return;
    } else if (text === '行きつけのお店') {
      return;
    } else {
      await client.replyMessage(replyToken, error);
    }
  } catch (err) {
    console.log(err);
  }
};

// 移動手段の「車もしくは徒歩」かを尋ねるメッセージを送る
const actionIsCar = async (client: Client, event: WebhookEvent): Promise<void> => {
  try {
    // If the message is different from the target, returned
    if (event.type !== 'message' || event.message.type !== 'location') {
      return;
    }

    // Retrieve the required items from the event
    const replyToken = event.replyToken;
    const userId = event.source.userId;
    const latitude: string = String(event.message.latitude);
    const longitude: string = String(event.message.longitude);

    // Register userId, latitude, and longitude in DynamoDB
    await putLocation(userId, latitude, longitude);

    // modules
    const isCar = await isCarTemplate();

    // Send a two-choice question
    await client.replyMessage(replyToken, isCar);
  } catch (err) {
    console.log(err);
  }
};

// 上記の選択を経て、おすすめのお店をFlex Messageにして送る
const actionFlexMessage = async (client: Client, event: WebhookEvent, googleMapApi: string) => {
  try {
    // If the message is different from the target, returned
    if (event.type !== 'message' || event.message.type !== 'text') {
      return;
    }

    // Retrieve the required items from the event
    const replyToken = event.replyToken;
    const userId = event.source.userId;
    const isCar = event.message.text;

    // Perform a conditional branch
    if (isCar === '車' || isCar === '徒歩') {
      // Register userId, isCar in DynamoDB
      await updateIsCar(userId, isCar);
      const flexMessage = await createFlexMessage(userId, googleMapApi);
      if (flexMessage === undefined) {
        return;
      }
      await client.replyMessage(replyToken, flexMessage);
    } else {
      return;
    }
  } catch (err) {
    console.log(err);
  }
};

// FlexMessageの「行きつけ」をタップしたらそのお店が登録される
const actionPutFavoriteShop = async (event: WebhookEvent, googleMapApi: string) => {
  try {
    // If the message is different from the target, returned
    if (event.type !== 'postback') {
      return;
    }

    // Retrieve the required items from the event
    const data = event.postback.data;
    const timestamp = event.timestamp;
    const userId = event.source.userId;

    // conditional branching
    const isFavorite = data.indexOf('timestamp');
    if (isFavorite === -1) {
      // Register data, userId in DynamoDB
      await putFavorite(data, timestamp, userId, googleMapApi);
    }
  } catch (err) {
    console.log(err);
  }
};

// リッチメニューの「行きつけ」をタップしたらメッセージが送られる
const actionTapFavoriteShop = async (client: Client, event: WebhookEvent) => {
  // If the message is different from the target, returned
  if (event.type !== 'message' || event.message.type !== 'text') {
    return;
  }

  // Retrieve the required items from the event
  const replyToken = event.replyToken;
  const userId = event.source.userId;
  const text = event.message.text;

  if (text === '行きつけのお店') {
    const flexMessage = await makeFlexMessage(userId);
    if (flexMessage === undefined) {
      return;
    }
    await client.replyMessage(replyToken, flexMessage);
  } else {
    return;
  }
};

+ // FlexMessageの「行きつけを解除」をタップしたらそのお店がDBから削除される
+ const actionDeleteFavoriteShop = async (event: WebhookEvent) => {
+  try {
+    // If the message is different from the target, returned
+    if (event.type !== 'postback') {
+      return;
+    }
+
+    // Retrieve the required items from the event
+    const data = event.postback.data;
+    const userId = event.source.userId;
+
+    // conditional branching
+    const isFavorite = data.indexOf('timestamp');
+    if (isFavorite !== -1) {
+      // Delete Gourmets_Favorite
+      await deleteFavorite(data, userId);
+    }
+  } catch (err) {
+    console.log(err);
+  }
+ };
```

これで完了です。

すべての機能を盛り込みました。
これでアプリとしては十分使えると思います。

まぁまだ問題点はあります。
FlexMessageは1度で12個しかスクロールできません。
なので、お気に入り店舗が12以上になると表示する方法がありません。

12以上の場合は複数回返信を行うように設定してもいいのですが、
店舗数が増えれば増えるほど見辛くなる問題も孕んでいます。
ただでさえ1つで画面占有の６割以上です。
これを2つ、3つと増やした場合はユーザビリティの悪化に繋がります。
![iOS の画像.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/838372/565c1530-108a-770e-1945-259c10540b1b.png)

なので残念ながらこれ以上の対応は思いつかないということで、簡易的なお気に入り機能として使っていただけると幸いです。

## 終わりに
LINE Messaging APIを使うことでフロントの開発から解放されます。
LINEという身近なツールを使うことでインストールなどの手間もなく、これがあると便利だなってものを簡単に制作することができます。

ぜひ皆さんもLINE Bot開発をしてみてください。

ここまで読んでいただきありがとうございました。