GASのAPIを使った出欠管理システム

# 概要

リモートによるイベントなども開催されるようになり出欠を管理したいことがあると思います。Google Formなどを用いてもよいのですが私用のアカウントを出欠に使われたくない人もいると思います。そこで今回はGASのAPIを用いてブラウザから出席できるようなシステムを作ることにしました。

# 機能説明

## ウェブ側
ブラウザからアクセスして利用します。`名簿`シートにある`ID`を入力することで出席できます。ブラウザから位置情報と端末情報を取得してそれらの情報をGASのAPIに渡します。

[出席ページ] (https://attendant.netlify.app/)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/412322/d72c8473-392e-1702-16f5-48ee09496d56.png)

### 注意事項（実際に名簿シートのIDを入力して動作を確認する場合）
位置情報について送信されたくない場合は、位置情報の読み取りダイアログを許可しないようにしてください。PCのブラウザでは`ブロック`を選択してください。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/412322/fd16c5b1-3bf5-5ed9-705e-b864db03144b.png)



## GoogleSpreadSheet

[出席管理のスプレッドシート] (https://docs.google.com/spreadsheets/d/1x9_TDyfKjsE6MevVMzsy10tdT8gop0k6fHSYlTyTDng/edit#gid=0)

出席されると事前に作成した`名簿`シートを参照し`出席`シートに出席した時刻を追記していきます。

### 名簿シート
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/412322/cf95074f-8c97-cc06-f264-5f070e152796.png)

### 出席シート
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/412322/3e095593-2db6-ec2c-db64-e9fac112053d.png)



# ソース

## index.html

画面の表示切替については`Vue.js`を使用しております。実態は`index.html`だけで`Vue.js`は`CDN`で読み込んでいます。


```
<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="utf-8">
    <title>出欠システム</title>
    <!-- <link rel="stylesheet" type="text/css" href="style.css" media="all" /> -->
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@0.8.0/css/bulma.min.css">
</head>

<body>
    <div id="app">
        <div class="hero-body">
            <div class="container has-text-centered">
                <!-- <div class="column is-4 is-offset-4"> -->
                <div class="column">
                    <h3 class="title has-text-black">出欠システム</h3>
                    <hr class="login-hr">
                    <p class="subtitle has-text-black">IDを入力して出席登録を実行してください。</p>
                    <div class="box">
                        <figure class="avatar">
                            <!-- <img src="https://placehold.it/128x128"> -->
                        </figure>
                        <form v-if="!isClicked" onsubmit="return false;">
                            <div class="field">
                                <div class="control">
                                    <input v-model="id" class="input is-large" type="text" placeholder="IDを入力してください"
                                        autofocus="">
                                </div>
                            </div>

                            <button style="margin-top: 50px;" type="button"
                                class="button is-block is-info is-large is-fullwidth" @click="attend()">出席登録 <i
                                    class="fa fa-sign-in" aria-hidden="true"></i></button>
                        </form>
                        <div v-if="isClicked" class="loader-wrapper">
                            <div class="loader is-loading"></div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</body>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/axios-jsonp/dist/index.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/vue@2.5.16/dist/vue.js"></script>
<script>
    new Vue({
        el: "#app",
        data: {
            id: null,
            lat: 0,
            lon: 0,
            isClicked: false,
        },
        methods: {
            attend() {
                vueThis = this
                this.isClicked = true

                let apiUrl = 'https://script.google.com/macros/s/AKfycbw9akDlm3cu309t0XwnhIqhmEGKQioBnUeo2--vpPix0aYngeU/exec'
                axios.get(`${apiUrl}?id=${this.id}&lat=${this.lat}&lon=${this.lon}`).then(function (response) {
                    console.log(response);
                    alert(response.data.msg)
                    vueThis.isClicked = false
                })
            },
        },
        mounted() {
            if (navigator.geolocation) {
                navigator.geolocation.getCurrentPosition((position) => {

                    this.lat = position.coords.latitude
                    this.lon = position.coords.longitude
                });
            }
        }
    })


</script>

<style>
    .loader-wrapper {
        position: absolute;
        top: 0;
        left: 0;
        height: 100%;
        width: 100%;
        background: #fff;
        transition: opacity .3s;
        display: flex;
        justify-content: center;
        align-items: center;
        border-radius: 6px;
        opacity: 1;
        z-index: 1;
    }

    .loader-wrapper .loader {
        height: 80px;
        width: 80px;
    }

    /* .is-active {
        opacity: 1;
        z-index: 1;
    } */
</style>

</html>
```

### 位置情報の読み取り
`mounted`で位置情報の機能である`navigator.geolocation`を読み込みます。最初ブラウザで開くと許可が求められ許可されれば`Vue`の変数である`lat`と`lon`に位置情報を格納します。

```
        mounted() {
            if (navigator.geolocation) {
                navigator.geolocation.getCurrentPosition((position) => {

                    this.lat = position.coords.latitude
                    this.lon = position.coords.longitude
                });
            }
        }
```


## Google Action Script

### Google Spread Sheet側の設定

#### IDの設定

`spreadId`は`GoogleSpreadSheetのIDを設定します。以下のURLからIDを設定します。

`https://docs.google.com/spreadsheets/d/
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
/edit#gid=597941997`

d/と/editの間の「XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX」がスプレッドシートIDになります。


#### GASのソース
```
//書込先スプレッドシートのIDを入力
const spreadId = 'XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX';
const sheetAttend = '出席'
const sheetMaster = '名簿'

const idCol = 1
const updateCol = 5
const execMarginMin = 30

function findRow(sheet,col,val){
  var dat = sheet.getDataRange().getValues(); //受け取ったシートのデータを二次元配列に取得
  let row = 0
  for(var i=1;i<dat.length;i++){
    if(dat[i][col-1] == val){
      // 一番下まで検索
      row = i + 1;
      //// 上から見つかったら処理中断
//      return i+1;
    }
  }
  return row;
}

function idExistCheck(id){
  let sheetMs = SpreadsheetApp.openById(spreadId).getSheetByName(sheetMaster);
  if(findRow(sheetMs,idCol,id) == 0){
    return false
  }
  return true
}

function updateCheck(id){
  let sheetAt = SpreadsheetApp.openById(spreadId).getSheetByName(sheetAttend);
  let checkRow = findRow(sheetAt,idCol,id)
  
  if(checkRow > 0){
    let date = new Date();
    let beforeUpdate = sheetAt.getRange(checkRow,updateCol).getValue()
    let diffTimeMin = (date - beforeUpdate) / (1000 * 60)
    return diffTimeMin > execMarginMin
  }
  return true
}

function test(){
//  sheet = SpreadsheetApp.openById(spreadId).getSheetByName(sheetAttend);
  
  if(idExistCheck(11111)){
    Logger.log("aaaaa")
  }else{
    Logger.log("eeeee")}
}


function doGet(e) {
  //doPost(e)にするとformからのpostデータを書き込むことが出来る
  //応用すれば、スプレッドシートをRestAPIもどきにしたり、フォームのDBにしたり、いろいろ出来ると思う
  //別名で使いたい場合の例
  //var name = e.parameter.p1;
  //var mail = e.parameter.p2;

  //JSONオブジェクト格納用の入れ物
  let resultData = {} 

  if (!e.parameter || !e.parameter.id) {
    resultData.msg = "パラメーターが不正です";
    return ContentService.createTextOutput(JSON.stringify(resultData));
  }
    
  if(!idExistCheck(e.parameter.id)){
    resultData.msg = "IDが登録されていません"
    return ContentService.createTextOutput(JSON.stringify(resultData));
  }
  
  if(!updateCheck(e.parameter.id)){
    resultData.msg = "先ほど出席されていることが確認できました。時間を空けてから実行してください。"
    return ContentService.createTextOutput(JSON.stringify(resultData));
  }
  
  let sheetAt = SpreadsheetApp.openById(spreadId).getSheetByName(sheetAttend)
  const userAgent = HtmlService.getUserAgent();
  var addArray = [ e.parameter.id , e.parameter.lat , e.parameter.lon , userAgent ,new Date() ];
  sheetAt.appendRow(addArray);
  
  resultData.msg = "出席情報を登録しました！"
  
  return ContentService.createTextOutput(JSON.stringify(resultData));

}
```

# 課題

基本的にはウェブからの情報は偽装される可能性があります。例えば位置情報の場合ではGoogle Chromeのデベロッパーツールの`Sensor`で違う位置情報として送信することができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/412322/4a754e48-2003-876b-1394-7910c5b6ece4.png)

以下の場合だとロンドンの位置情報で送信されます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/412322/f1a87e5c-05b4-10e3-076f-fbf6830a89ca.png)

今回の場合ではあくまで参考程度で利用するモノでよりセキュリティーを厳密にしたい場合は、ネイティブアプリで作成して認証キーなどを設定してキーなしでは更新できない仕組みが必要になりそうです。

とはいいつつ手軽に出席管理を行える仕組みができましたのでお手軽に使う分には良いかもしれません。