京都のテイクアウト情報が集まるサービスを作った（GAS）

## 更新

2020/05/05　更新

- ありがたいことに、思っていたよりもLGTMしていただけたため、流石に色々省きすぎだなと思い少し追記しました。
- 変更点
  - webpackについて追記
  - Firestoreへの投入部分の追記


## はじめに

大学の友人数名と京都のテイクアウト情報が集まるサービスを作りました！
https://kyoto-take-out.web.app/
京都に住んでいる人はぜひ使ってみてください👘
ツイッターも拡散していただけると幸いです🙇‍♂️
https://twitter.com/LN_shono/status/1253324958601637890

このサービスで使用したものはざっくりとこんな感じです。

- Google スプレッドシート
- Google フォーム
- GAS
- React
- TypeScript
- Firestore

今回私は、GASを使ったお店情報の収集・編集・firestoreへの投入などの開発を担当したので、やったことを簡単にまとめます。

併せてスプレッドシートとGitHubもご覧ください。
[スプレッドシート](https://docs.google.com/spreadsheets/d/1URnpPvpg9MMhjakG3AZ2AruXJBngfDdm9dwWGxK4yX8/edit?usp=sharing)
[GitHub](https://github.com/hasehiro0828/-QP-kyoto-takeout)

## サービス構成

このサービスではお店の情報をGoogleフォームを用いて収集しています。
そしてフォームで集まった回答を、GASで集計・firestoreに投入し、
フロントエンドはfirestoreの情報を表示しています。
つまり、情報の流れは以下のようになっています。

1. お店がフォームに回答
2. スプレッドシートに回答が反映
3. GASで回答をfirestoreに投入する形に整形
   1. 緯度経度を取得等もここ
4. GASでfirestoreを更新
5. フロントエンドはfirestoreのデータを表示

## スプレッドシート & GAS

### 概要

スプレッドシート(GAS)は主に以下のような役割を担っています。

- ログの保存
- 住所からの緯度経度の取得
- 編集機能
- firestoreへのデータ投入

### 各シートの説明

#### フォームの回答 1

これは名前のままで、フォームの回答が反映されるシートです。
上のスプレッドシートではフォームを作成していないのでただのシートですが、
実際に使っているものはフォームとリンクしており、
フォームが回答されるとリアルタイムで反映されます。
また、フォームの設定では回答の編集を許可しており、フォームの回答が編集されると該当する部分の値が変更されます。

#### 入力データログ

このシートはフォームへの入力をログとして保存しています。
基本的には`フォームの回答 1`と同じ内容にはなるのですが、編集された場合に前のデータが消えてしまうことを防ぐ目的で作成しています。
トリガーを設定しており、フォームから新しい回答が来たり編集されたりするとこのシートが更新されます（後述）。

#### firestore投入用シート

このシートは最終的にfirestoreに保存されるデータを表示しています。
そして同時に、スプレッドシートを編集することで、firestoreに入力するデータを編集することができるようにしています。
このようにしている理由としては、フォームの入力が誤っていたり、少し見た目を整形したい場合などに、開発側でデータを確認・編集したいことがあるからです。

スプレッドシートを見ていただけるとわかるのですが、一部のカラムに`同じ名前+(編集済み)`や`同じ名前+(過去の編集)`といったカラムを用意することで編集を可能にしています。

詳しくは後述します。

### シートの更新

#### 入力データログの更新

`index.ts`の`createInputLogSheet`に対応します。
やっていることは簡単で、`フォームの回答 1`と`入力データログ`の内容をそれぞれ取得しIDを比較して、

- `入力データログ`になく`フォームの回答 1`にあるデータ（つまり新しい回答）
- `入力データログ`にもあるが`フォームの回答 1`の方が新しいデータ（つまり編集された回答）

を`入力データログ`に挿入します。
フォームの回答が編集されるとタイムスタンプも更新されることを利用しています。

#### firestore投入用シートの更新

`index.ts`の`createStoredSheet`に対応します。
こちらも上と基本的にやっていることは変わりませんが、

- 挿入ではなく、シートを毎回作り直している（正確には全てのデータを消した後に挿入している）
  - 最新版のみ表示したい & firestoreに入っているデータと同期させていたいのでこのような形にしています。
- 緯度経度取得の処理を挟んでいる
- 編集用の処理を挟んでいる

などの違いがあります。

##### 編集用の処理について補足します。

基本的に、`(編集済み)`とついた列に入力があればそちらを優先してfirestoreに投入するようにしています。
そして、`(編集済み)`の列はフォームに更新があった際もそのまま残るようにしています。

しかし、このままですとテイクアウトメニューや営業日時に変更があった場合にデータが更新されないことになってしまいます。そこで、`(過去の編集)`といった列も用意し、フォームに更新があった際には`(編集済み)`の値を`(過去の編集)`に移動させることで、店舗による新しい入力を保存する & 編集履歴として確認できるようにする、といったことを可能にしています。

画像URLや緯度経度などは店舗側が更新することがほとんどないだろうと思い`(編集済み)`のみになっています。

また、TwitterURLなどは`(編集済み)`の列はないのですが、編集したデータを更新時も優先するようになっています。
これも更新されることがまずないだろうという考えでそうしています。

### Firestoreへのデータ投入(2020/05/05 追記)

`index.ts`の`sheetToFirestore`に対応しています。
以下のことをしています。

- `firestore投入用シート`のデータを取得
- Firestore用のデータへと整形
- `isNew`,`needUpdating`の値によって追加 or 更新
  - `isNew`がtrueの時は追加、`isNew`がfalse かつ `needUpdating`がtrueの時は更新、どちらもfalseであれば何もしない
- `isNew`と`needUpdating`を全てfalseに

コードの話は後述します。

## コードの補足

### スプレッドシートからのデータの取得


スプレッドシートからのデータの取得はJSONなどのように綺麗にはできないので、どうすれば一番綺麗で楽かなぁ、と悩みました。
最終的にはこのような形が一番楽かなと思いました。
スプレッドシートに列を追加する際は

1. スプレッドシートを変更する
2. モデルに要素を追加する
3. `(1)`でエラーが出るので修正
4. `(2)`を修正

とすることで、いい感じに修正できます。

ただこれでも、列名を変えてしまうと動きません。
また、処理時間の制限が厳しいときには使えないです。
（スプレッドシートからの取得に時間がかかる）

<details><summary>コード</summary><div>


```typescript:spreadsheetService.ts
export class SpreadsheetService {
  spreadsheetID: string;
  constructor() {
    this.spreadsheetID = OriginalUtilities.getProperty("SPREADSHEET_ID");
  }

  // (2)
  getIndexesOfFormAnswer = (firstRow: string[]) => {
    return {
      timestamp: firstRow.indexOf("タイムスタンプ"),
      email: firstRow.indexOf("メールアドレス"),
      storeName: firstRow.indexOf("店舗名"),
      ...
    };
  };

...

  getDataFromFormAnswer = (): SpreadsheetModel.FormAnswer[] => {
    const sheetName = "フォームの回答 1";
    const sheet = this.getSheetByName(sheetName);

    const firstRow = this.getFirstRowFromSheet(sheet);

    const data = sheet
      .getRange(2, 1, sheet.getLastRow() - 1, firstRow.length)
      .getValues();

    const indexOf = this.getIndexesOfFormAnswer(firstRow);

    for (const key in indexOf) {
      if (indexOf[key] < 0) {
        console.log(`[${sheetName}]に[${key}]が存在しません`);
      }
    }

    const arrayOfAnswer: SpreadsheetModel.FormAnswer[] = [];
  	// (1)
    data.forEach((row, index) => {
      arrayOfAnswer.push({
        ID: index + 1,
        timestamp: row[indexOf.timestamp],
        email: row[indexOf.email],
        storeName: row[indexOf.storeName],
        ...
      });
    });
    return arrayOfAnswer;
  };

...

  getFirstRowFromSheet = (sheet: GoogleAppsScript.Spreadsheet.Sheet) => {
    const indexOfLastColumn = sheet
      .getRange(1, 1)
      .getNextDataCell(SpreadsheetApp.Direction.NEXT)
      .getColumn();
    const firstRow = sheet.getRange(1, 1, 1, indexOfLastColumn).getValues()[0];

    return firstRow;
  };
}
```

```typescript:SpreadsheetModel.ts
export interface FormAnswer {
  ID: number;
  timestamp: string;
  email: string;
  storeName: string;
  address: string;
  telephoneNumber: string;
  takeoutMenu: string;
  twitterURL: string;
  instagramURL: string;
  tabelogURL: string;
  onlineSalesURL: string;
  imageURL: string;
  businessHour: string;
  facebookURL: string;
  homepageURL: string;
}
```

</div></details>

### .dev-clasp

普段GASの開発では[clasp](https://github.com/google/clasp)を使っています。
普通に使うと一つのフォルダが一つのGASプロジェクトに対応するのですが、テスト環境で使ってから本番に反映したい、といったときもありますよね。
そこで、`.dev-clasp.json`と`pushDev.sh`というスクリプトを用いて、`package.json`の`scripts`でdev用と本番用でスクリプトを分けました。


```bash:pushDev.sh
#!/bin/sh
cd `dirname $0`

mv .clasp.json .clasp-renamed.json
mv .dev-clasp.json .clasp.json

clasp push

mv .clasp.json .dev-clasp.json
mv .clasp-renamed.json .clasp.json
```

### webpack(2020/05/05 追記）

#### 参考

- [Google Apps Script をローカル環境で快適に開発するためのテンプレートを作りました](https://qiita.com/howdy39/items/0e799a9bfc1d3bccf6e5)
- [【GAS】webpackでテンプレートを作成してGit/Local開発しよう](https://qiita.com/ykhirao/items/9de38a012b5f3f3e81f6)

claspはwebpackでbuildしなくてもTypescriptを変換してpushしてくれるのですが、`node_modules`を自動的に含めるといったことはしてくれません。（そもそもimportという概念がGASになく全てグローバルといった感じ(のはず))
そこで、`moment`などのパッケージを使うときはwebpackを使ってbuildするようにするとうまく出来ます。
GASでもライブラリが使える(`moment`もあり同じように使える）のですが、ローカルで開発する際は補完等出来なかったり、エラーが表示されたりしてしまいます。
ただ「絶対webpackを使用すべき」という訳ではないと思っていて、それぞれ一長一短あるので使い分けかなと思っています。
基本的にはclasp単体で事足りると思います。
（むしろ事足りないのであれば、GASでやらない方が良さそう）
今回は実際やってみたらどんな感じなのか知りたくて使ってみました。

|                  | clasp単体                                                   | webpack併用                                                  |
| ---------------- | ----------------------------------------------------------- | ------------------------------------------------------------ |
| 難易度           | 簡単                                                        | 少し面倒                                                     |
| node_module      | 使えない（ただしGASのライブラリがある）                     | 使える<br />ただしGASで使えない機能を使っているものは使えない |
| ライブラリの補完 | 基本的に使えない<br />後述する型定義ファイルを用意すれば使える      | 使える                                                       |
| pushにかかる時間 | 短い                                                        | buildを挟むので少し長い                                      |
| GAS上での見た目  | 基本的にそのままのコードなので、GASエディタ上でも編集できる | bundleされたファイルなので、GASエディタ上では見にくい<br />ファイルサイズも大きくなる |


### Firestoreへのデータ投入(2020/05/05 追記)

`sheetToFirestore`について、もう少し具体的に解説します。
上でも少し触れているのですが、GASには便利なライブラリが数多く存在しています。
実際に、`sheetToFirestore`でも`FirestoreApp`（[FirestoreGoogleAppsScript](https://github.com/grahamearley/FirestoreGoogleAppsScript))というライブラリを使用しています。
使用方法は簡単で、

1. GASエディタにアクセス
2. リソース＞ライブラリを選択
3. `1VUSl4b1r1eoNcRWotZM3e87ygkxvXltOgyDZhixqncz9lQ3MjfT1iKFw`を入力し追加

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/209015/39cf11ba-0c0a-d3bd-42ac-ad1102fb8fc6.png)


をするだけで、使えるようになります。
ただ、これだけですとローカル環境ではこのライブラリの存在を知らないので、補完等も出来ずエラーが出てしまいます。
そこで、型定義ファイルを作成することでライブラリを使用しつつTypescriptの利点を活かした開発を可能にします。
と言ってもやることは簡単で、`src`フォルダに`types`フォルダを作成し(typesフォルダは必須ではない)、以下の`FirestoreApp.d.ts`を作成すると、補完が出てくるようになり、GASでもちゃんと動きます。

他のGASライブラリを使用する際も同じように、GASライブラリの名前と同一(正確にはGASエディタ上のライブラリに設定した名前?)の型定義を作成することで、開発がやりやすくなると思います。

```typescript:/src/types/FirestoreApp.d.ts
declare class FirestoreApp {
  static getFirestore(email: string, key: string, projectId: string): Firestore;
}

declare class Firestore {
  getDocuments(path: string): number;
  createDocument(path: string, fields: object): any;
  updateDocument(path: string, fields: object, mask: boolean): any;
}
```

## おわりに

今回はスプレッドシート、フォーム、GASを用いてデータ収集・編集・投入をするものを作成しました。
このまま使うことはないでしょうが、色々と応用はできるのではないかなと思います。

何かお役に立てれば幸いです。
ありがとうございました。