GoogleAppsScriptで社内ポータルサイトを作る

**AWS Lambda**に代表される**サーバレスアーキテクチャ（FaaS）**が熱い。開発屋としては筆者が今後もっとも楽しみな分野であり、対応フレームワークの動向に目が離せない。

無料で使える Google Apps Script（以降GAS）もサーバレスと表現して良いかと思う。
GASでちょっとしたWebアプリケーション（社内ポータルサイト）を書いてみたので、一部だが、コードを改変した上で晒してみる。

# 画面イメージ
掲示板と会議室予約状況をGASで実装する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/236222/59f73066-b2ba-ac2a-4549-e43c02e50484.png)

# コードをざっくりと
GASだけでも頑張れば本格的なWebアプリが書けることを証明したいだけなので、コードの突っ込んだ解説は割愛する。ざっくりとスキームだけ感じ取ってもらえれば。

## HTMLコード
全体の雛型レイアウトと個々のページのHTMLファイルを作る。
`<?!=` ... `?>` がGASにおける**スクリプトレットタグ**だ。`<?!= include('css'); ?>` は main.gs で定義されたオリジナルの関数で、読み込んだファイルの内容を挿入するもの。

### app.html
Laravelで例えるなら、Bladeテンプレートのマスターページレイアウトに相当するもの。
コード量が増えてきたら、ヘッダーとフッターを別ファイルにする等のリファクタリングを進めた方が良いだろう。

```html:app.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <base target="_top">
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <meta name="author" itemprop="author" content="MindWood">
    <title>社内ポータル</title>
    <?!= include('css'); ?>
  </head>
  <body>
  <div id="wrapper">
    <!-- ナビゲーションバー -->
    <nav class="navbar navbar-expand-sm">
      <a class="navbar-brand" href="<?=ScriptApp.getService().getUrl() ?>">社内ポータル</a>
      <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarNav4" aria-controls="navbarNav4" aria-expanded="false" aria-label="Toggle navigation">
        <span class="navbar-toggler-icon"></span>
      </button>
      <div class="collapse navbar-collapse justify-content-end">
        <ul class="navbar-nav">
          <li class="nav-item active">
            <a class="nav-link" href="#"><i class="fas fa-home mr-1"></i>トップ</a>
          </li>
          <li class="nav-item">
            <a class="nav-link" href="https://foo" target="_blank"><i class="fas fa-file-import mr-1"></i>勤怠システム</a>
          </li>
          <li class="nav-item">
            <a class="nav-link" href="https://bar" target="_blank"><i class="fas fa-yen-sign mr-1"></i>Web明細システム</a>
          </li>
          <li class="nav-item">
            <a class="nav-link" href="<?=ScriptApp.getService().getUrl() ?>?p=doc"><i class="fas fa-download mr-1"></i>様式集ダウンロード</a>
          </li>
        </ul>
      </div>
      <!-- Google検索窓 -->
      <form method="get" action="http://www.google.co.jp/search">
        <input type="text" name="q" size="35" maxlength="255" value="">
        <input type="hidden" name="ie" value="UTF-8">
        <input type="hidden" name="oe" value="UTF-8">
        <input type="hidden" name="hl" value="ja">
        <input type="submit" name="btnG" value="Google 検索">
      </form>      
    </nav>
    <hr/>
    <!-- メイン画面 -->
    <div class="content">
      <?!= content ?>
    </div>
    <!-- フッター -->
    <footer class="footer hidden-print">
      <div class="container">
        <p><a href="https://mindwood.jp">Copyright &copy; 2020 by MindWood</a></p>
      </div>
    </footer>
    <?!= include('js'); ?>
  </div>
  </body>
</html>
```

### top.html
トップ画面HTML。掲示板と会議室予約状況のViewになる。

```html:top.html
<div class="container-fluid">
  <div class="row">
    <div class="col-8">
      <h1>掲示板</h1>
      <div class="row">
      <div class="col-2">
        <div class="alert alert-secondary" role="alert">
          <i class="fas fa-building mr-2"></i>全社<br/>
          <i class="fas fa-arrow-circle-right ml-3 mr-2"></i>開発部<br/>
          <i class="fas fa-arrow-circle-right ml-3 mr-2"></i>営業部<br/>
          <i class="fas fa-arrow-circle-right ml-3 mr-2"></i>総務部<br/>
          <i class="fas fa-arrow-circle-right ml-3 mr-2"></i>販売部
        </div>
        <form method="GET" action="<?=ScriptApp.getService().getUrl() ?>">
          <button type="submit" class="btn btn-primary" name="p" value="post"><i class="fas fa-edit mr-2"></i>掲示板に投稿</button>
        </form>
      </div>
      <div class="col-10">
      <div class="table-responsive table-clickable">
        <table class="table table-bordered table-striped table-hover table-sm">
          <thead>
            <tr>
              <th><i class="fas fa-comment mr-2"></i>タイトル</th>
              <th><i class="fas fa-building mr-2"></i>部署</th>
              <th><i class="fas fa-user mr-2"></i>投稿者</th>
              <th><i class="fas fa-pen-alt mr-2"></i>更新日</th>
            </tr>
          </thead>
          <tbody>
          <form method="POST" action="<?=ScriptApp.getService().getUrl() ?>" id="view">
          <?!=hideVal('p', 'view') ?>
          <? for(var row in boards) { var data = boards[row]; ?>
            <tr data-row="<?= row ?>" data-form_id="view">
              <td><?=data[0] ?></td>
              <td><?=data[1] ?></td>
              <td><?=data[3] ?></td>
              <td><?=Utilities.formatDate(new Date(data[4]),'GMT-7', 'yyyy年M月d日 H:mm') ?></td>
            </tr>
          <? } ?>
          </form>
          </tbody>
        </table>
      </div>
    </div>
  </div>
</div>
```

## CSSコード
GASはCSSファイルを置けないので、cssを埋め込んだHTMLファイルを作る。
CSSフレームワークに`Bootstrap4`、Webアイコンフォントに`FontAwesome`を使うだけでも、なんかプロっぽいデザインになる。

```css:css.html
<link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/css/bootstrap.min.css" integrity="sha384-GJzZqFGwb1QTTN6wy59ffF1BuGJpLSa9DkKMp0DgiMDm4iYMj70gZWKYbI706tWS" crossorigin="anonymous">
<link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.7.1/css/all.css" integrity="sha384-fnmOCqbTlWIlj8LyTjo7mOUStjsKC4pOpQbqyi7RrhN7udi9RwhKkMHpvLbHG9Sr" crossorigin="anonymous">

<style>
.content {
    padding: 0 12px 20px 12px;
}
.content > .container,
.content > .container-fluid {
    background: #fff;
    min-height: 70vh;
}
h1 {
    margin: 12px 0 9px;
    padding: 6px 9px;
    font-size: 27px;
    color: #555;
    background: #eee;
    border-left: solid 9px #d22027;
    border-bottom: 1px solid #ccc;
}
footer {
    margin-top: auto;
    background-color: #333;
    height: 20px;
    bottom: 0;
    text-align: center;
}
footer p {
    margin: 0;
    font-size: 12px;
    line-height: 20px;
}
footer a {
    color: #666;
}
footer a:hover {
    color: #993;
}
</style>
```

## JavaScriptコード
GASはJavaScriptファイルを置けないので、JavaScriptを埋め込んだHTMLファイルを作る。
JavaScriptフレームワークには、筆者が昔から馴染んでいるという理由だけで`jQuery`を採用。サーバレスは**シングルページアプリケーション（SPA）**と相性が良さそうな気がするので、実際には`Vue`や`React`の方が向いていると思う。

```javascript:js.html
<script src="https://code.jquery.com/jquery-3.3.1.slim.min.js" integrity="sha384-q8i/X+965DzO0rT7abK41JStQIAqVgRVzpbzo5smXKp4YfRvH+8abtTE1Pi6jizo" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.6/umd/popper.min.js" integrity="sha384-wHAiFfRlMFy6i5SRaxvfOCifBUQy1xHdJ/yoi7FRNXMRBu5WHdZYu1hA6ZOblgut" crossorigin="anonymous"></script>
<script src="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/js/bootstrap.min.js" integrity="sha384-B0UglyR+jN6CkvvICOB2joaf5I4l3gm9GU6Hc1og6Ls7i6U/mkkaduKaBhlAXv9k" crossorigin="anonymous"></script>
<script>
$(function(){
  $('tr[data-row]', '.table-clickable').on('click', function(){
    var row = $(this).data('row');
    var form = $('#' + $(this).data('form_id'));
    $('<input>').attr({
      'type': 'hidden',
      'name': 'row',
      'value': row
    }).appendTo(form);
    form.submit();
  });
});
</script>
```

## GASコード
最後にGAS本体。
`doGet()`がGETリクエストで最初に呼び出される関数。URLパラメータを取得し、ページ遷移を疑似的に実装している。
`doPost()`は掲示板の投稿時に呼び出され、投稿文をGoogleスプレッドシート（`SpreadsheetApp`）に保存する。
会議室予約状況は、組織が運用しているGoogleカレンダー（`CalendarApp`）から取得している。

```javascript:main.gs
var spreadSheetApp = SpreadsheetApp.openById('XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX');
var boardSheet = spreadSheetApp.getSheetByName('board');
var html_app = HtmlService.createTemplateFromFile('app');
var lastRow;

//  HTTP GET request endpoint
function doGet(e) {
    var page = e.parameter.p;
    lastRow = boardSheet.getLastRow();

    switch (page) {
    default:
    case 'top':     // トップ画面
        html_app.content = topPage();
        break;
    case 'post':    // 掲示板の新規投稿画面
        html_app.content = HtmlService.createTemplateFromFile('post').evaluate().getContent();
        break;
    case 'xxxxx':
        break;
    }
    return html_app.evaluate();
}

//  HTTP POST request endpoint
function doPost(e) {
    var page = e.parameter.p;

    lastRow = boardSheet.getLastRow();

    switch (page) {
    case 'confirm':    // 投稿内容確認画面
        var title = e.parameter.title;
        var sec   = e.parameter.sec;
        var owner = e.parameter.owner;
        var body  = e.parameter.body;
        
        var html_confirm = HtmlService.createTemplateFromFile('confirm');
        html_confirm.title = title;
        html_confirm.sec   = sec;
        html_confirm.owner = owner;
        html_confirm.body  = body;
        html_app.content = html_confirm.evaluate().getContent();
        break;
    case 'post':       // 投稿を実行し、トップ画面に戻る
        var title = e.parameter.title;
        var sec   = e.parameter.sec;
        var owner = e.parameter.owner;
        var body  = e.parameter.body;
        
        upTime = Utilities.formatDate(new Date(), 'JST', 'yyyy/MM/dd H:mm:ss');
        
        var values = [
            [title, sec, body, owner, upTime],
        ];
        lastRow ++;
        boardSheet.getRange(lastRow, 1, 1, 5).setValues(values);
        html_app.content = topPage();
        break;
    case 'view':    // 投稿内容表示画面
        var row = parseInt(e.parameter.row);
        var values = boardSheet.getDataRange().getValues();
        var html_view = HtmlService.createTemplateFromFile('view');
        html_view.title = values[1 + row][0];
        html_view.sec   = values[1 + row][1];
        html_view.body  = values[1 + row][2];
        html_view.owner = values[1 + row][3];
        
        html_app.content = html_view.evaluate().getContent();
        break;
    default:
        return 'XXXXX';
    }
    return html_app.evaluate();
}

//  トップページ用
function topPage() {
  var html_top = HtmlService.createTemplateFromFile('top');
  
  //  掲示板
  html_top.boards = boardSheet.getRange(2, 1, lastRow - 1, 5).getValues();
  
  //  会議室予約
  html_top.events = null;
  var cal = CalendarApp.getCalendarById('XXXXXXXXXXXXXX@resource.calendar.google.com');
  if (cal != null) {
    var startDate = new Date();
    var endDate = new Date(startDate);
    for (var i = 0; i < 7; i++) {  // 1週間
      endDate.setDate(endDate.getDate() + 1);
    }
    html_top.events = cal.getEvents(startDate, endDate);
  }
  return html_top.evaluate().getContent();
}

//  ファイル内容を差し込む汎用関数
function include(filename) {
    return HtmlService.createHtmlOutputFromFile(filename).getContent();
}

//  改行コードをbrタグに変換する汎用関数
function br(str) {
    return str.replace(/[\n]/g, '<br/>');
}

//  hiddenパラメータ挿入
function hideVal(name, val) {
    return '<input type="hidden" name="' + name + '" value="' + val + '">';
}
```

# アクセス許可
Googleに初回ログイン時、アカウントごとに、スプレッドシートやカレンダーにアクセスすることの許可を求める画面になる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/236222/5b46adfd-4526-3401-97f2-5b0e8a4ebde4.png)