North Detail / ノースディテール

BLOG ブログ

ブログ
CATEGORY
TECH

社内の座席管理システムと勤怠管理システムを連携させる

背景

こんにちは、eiyuuです。
弊社は座る席が自由、いわゆるフリーアドレスであるためその柔軟性という利点を享受できていますが、反面誰かと口頭で話をしたい場合や電話の取り次ぎの際、誰がどこにいるのかがわかりにくいという欠点も抱えています。
そこで社内の不便を見過ごせない男No.1のtacckさんと組んで、 フリーアドレス向け座席管理システム「せきとりくん」の開発をしています。
せきとりくんはこんな画面(図1)のシステムです。

図1 せきとりくんWeb UI

2020/05/18現在、ざっくり以下のような機能を有しています。

  • 会社のGoogleアカウントでログイン
  • デスクをクリックすることで着席
  • 画面左の利用者一覧で各利用者の状態を確認
  • 利用者検索による座席位置の確認

せきとりくんでは各利用者が出勤しているかどうか、しているなら着席しているかどうかを確認することができます。図1左側の利用者一覧で、Aさんが未出勤、Bさんが出勤しているが離席中、eiyuuとtacckが着席中であることを表しています。しかし、せきとりくんのWeb UI自体に出退勤の状態を操作する機能はありません。なぜなら弊社では出退勤の管理に他社製の有料Webサービス(以下「勤怠B」と呼称)を使用しているため、せきとりくん上での出退勤操作を可能にしてしまうと状態の不整合が起こりうるからです。代わりに、勤怠Bとせきとりくんを連携させることで両システム上での出退勤状態を同期させようという話になったのが、本記事の背景です。

手段

勤怠Bは自社開発の製品ではないため、出退勤のタイミングでせきとりくんにそれを通知するような機能を追加することはできません。また、勤怠Bは出退勤状態を取得できるAPIを提供していないため、定期的にせきとりくん側から勤怠Bに出退勤状態を問い合わせることもできません。そこで、勤怠BのWeb UI上での出退勤ボタンを押すイベントを検知してせきとりくんの出退勤操作APIを呼び出すChromeExtensionを作ろうということになりました。

準備

ChromeExtensionを作るのに始めにやることはなんでしょうか?はい、名前を考えることですね。このChromeExtensionは弊社スタッフ達の出勤を知らせてくれるものです。その力強さを力士のそれに例えて「dohyouiri(土俵入り)」と名付けることにしました。名付けが終わったら次に manifest.json を用意する必要があります。manifest.json の最小構成は以下の通りです。

{
  "manifest_version": 2,
  "name": "dohyouiri",
  "version": "0.1"
}

そして manifest.json さえあればそれはもう立派なChromeExtensionです。その立派なディレクトリ構成が以下です。

dohyouiri/
└── manifest.json

さあGoogle Chromeで拡張機能ページ(chrome://extensions/)にアクセスして右上のデベロッパーモードをONにしてdohyouiriフォルダをウィンドウにドラッグ&ドロップしましょう。

図2のように見事にインストールされるはずです。次のセクションから、今はまだHello Worldよりもシンプルなこのdohyouiriに機能を付け加えていきましょう。

図2 dohyouiriの土俵入り

出退勤イベント検出

まずはdohyouiriが勤怠B上での出退勤アクションを検知できるようにしましょう。そのためには勤怠BのWeb UIの出退勤の仕組みを知る必要があります。勤怠Bの出退勤画面は図3のようになっています。

図3 勤怠Bの打刻画面

出勤時は出勤タブの「打刻」ボタン、退勤時は退出タブの「打刻」ボタンを押すことでそれぞれ出勤・退勤扱いになります。
これらのボタンのonclickイベントを取得できれば良いわけですね。図4・図5のように、出勤タブと退出タブの「打刻」ボタンは同一のエレメント button#js-punchButton を共有しているようですね。幸い、開いているタブによって適用されているクラスが異なる(.punchButton--clockIn.punchButton--clockOut)ようですので、ここを参照して区別できそうです。

図4 打刻ボタン@出勤タブ
図5 打刻ボタン@退出タブ

勤怠Bについて十分知れたことですので、プログラムを書いていきましょう。dohyouiri.js という名前にします。

function onClick(event) {
  const class_name = event.currentTarget.className
  if (/punchButton--clockIn/.test(class_name)) {
    console.log('出勤')
  } else if (/punchButton--clockOut/.test(class_name)) {
    console.log('退勤')
  }
}

const punch_button = document.getElementById('js-punchButton')
punch_button.onclick = onClick

更に、この dohyouiri.js を使うという宣言を manifest.json 内で行います。

{
  "manifest_version": 2,
  "name": "dohyouiri",
  "version": "0.1",
  "content_scripts": [
    {
      "matches": [
        "https://kintaib.example.jp/xxx/yyy/timeclock/punchmark/"
      ],
      "js": [
        "dohyouiri.js"
      ],
      "run_at": "document_end"
    }
  ]
}

matches は、このページの時にスクリプトを実行するというフィルターです。今回は勤怠Bの打刻画面のURLを設定します(書いてあるのはダミーのURL)。js には実行するスクリプトを設定します。run_at はスクリプトを実行するタイミングです。今回はDOM構築直後を意味する document_end を設定します。

現在のディレクトリ構成は以下の通りです。

dohyouiri/
├── dohyouiri.js
└── manifest.json

この状態で再び拡張機能ページにドラッグ&ドロップして打刻画面を一度リロードし、出勤タブと退出タブの「打刻」ボタンを押すと、図6のようにログが吐かれ意図どおりに動作していることがわかります。

図6 イベント取得実験ログ

せきとりくんに出退勤を通知する

イベントを検出できるようになったので、次はせきとりくんにそのことを教えてあげましょう。せきとりくんのAPIはAWS AppSync + Amazon DynamoDBで構築したGraphQL APIとして提供しています。GraphQLという言葉を初めて聞いたという人もいるかと思いますので、少し触れておきます。

GraphQL

GraphQLは、Web APIのためのクエリ言語と、保存されたデータに対する操作を実現するランタイムです。具体例を見てみましょう。GraphQLはこんな見た目の言語です。

query {
  getMember(id: "abcdefgh-ijkl-mnop-qrst-uvwxyz123") {
    id
    name
    state {
      deskId
      isWorking
    }
  }
}

これをGraphQL APIエンドポイントへのGETリクエストのパラメータとして送信すると、以下のようなレスポンスが得られます。

{
  "data": {
    "getMember": {
      "id": "abcdefgh-ijkl-mnop-qrst-uvwxyz123",
      "name": "eiyuu",
      "state": {
        "deskId": "desk-1",
        "isWorking": true
      }
    }
  }
}

クエリとレスポンスが似たような構造を持っているのがわかると思います。このように、GraphQLはクエリで指定した構造に対して過不足なくレスポンスを返すということを規定しています。実際クエリからnameのフィールドを消すと、レスポンスとしてnameは返されなくなります。この性質は複雑な構造を持つデータに対しても効率的にアクセスすることができることを意味し、「複雑な構造を持つデータの取得のためにAPIを何度も呼び出す必要がありがち」というREST APIの弱点に対するGraphQL APIの長所とも言えます(この記事では活かせてないけど!)。

せきとりくんが提供するGraphQL API

せきとりくんは利用者の状態を保持するために、内部的にMemberState型を持っており、以下のような構造を持っています。GraphQLのスキーマ記述言語により記述されています。AWS AppSyncはこのスキーマ記述言語を解釈してAmazon DynamoDB上にテーブルを作成したり、クライアントから送信されてきたGraphQLを解釈して適切に処理するGraphQL APIエンドポイントを作成してくれます。

type MemberState @aws_api_key
@aws_cognito_user_pools {
    id: ID!
    deskId: String
    isWorking: Boolean
}

id はただのIDです。deskId はどの座席に着席しているかを表します。isWorkingtrueの時勤務中、false の時退勤済を表します。要はこの isWorking を操作できれば良いわけですね。そのためには、以下のようなGraphQLを投げれば良いです。

mutation {
  updateMemberState(input: {id: "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxx", isWorking: true}) {
    id
    deskId
    isWorking
  }
}

先に紹介した getMember とは違って、クエリの始まりが mutation となっていますね。データを単に取得したい場合は query から、データの変更を行いたい時には mutation から始める決まりになっています。また、mutation の時にはGETではなくPOSTを使います。"xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxx"には操作したい対象のMemberStateレコードのIDを指定します。ここは実際の使用者によって変わるものなので、パラメータとして渡せるように後から改良します。

実装

まず manifest.json に手を加えます。ChromeExtensionでリクエストを発行する場合には、その旨とリクエスト先のURLを宣言する必要があります。具体的には以下のように書きます。

{
  "manifest_version": 2,
  "name": "dohyouiri",
  "version": "0.1",
  "permissions": ["webRequest", "https://xxxxxxxxxxxxxxxxxxxxxxxxxx.appsync-api.ap-northeast-1.amazonaws.com/graphql"],
  "background": {
    "scripts": ["background.js"]
  },
  "content_scripts": [
    {
      "matches": [
        "https://kintaib.example.jp/xxx/yyy/timeclock/punchmark/"
      ],
      "js": [
        "dohyouiri.js"
      ],
      "run_at": "document_end"
    }
  ]
}

permissions の項目が増えています。webRequest はリクエストを発行する旨の宣言で、続くURLはAppSyncのGraphQL APIエンドポイントです。これはAppSyncの設定画面から確認できます(書いてあるのはダミーのURL)。

また、background の項目も増えています。実はChromeExtensionはこの background に登録したスクリプトからしかリクエストを発行することができません。messagingという機能を使って dohyouiri.js から background.js にリクエストの発行を依頼するという手順を踏む必要があります。ということで、backgroud.js を書いていきます。

chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
  const xhr = new XMLHttpRequest()

  xhr.open('POST', request.url, true)

  if (request.hasOwnProperty('headers')) {
    request.headers.forEach(function(header) {
      xhr.setRequestHeader(header[0], header[1])
    })
  }

  xhr.onload = function (e) {
    if (xhr.readyState === 4) {
      if (xhr.status === 200) {
        sendResponse({message: 'OK', content: xhr.responseText, request: request, responseUrl: xhr.responseURL})
      } else {
        sendResponse({message: xhr.statusText})
      }
    }
  }

  xhr.onerror = function (e) {
    sendResponse({message: xhr.statusText})
  }

  xhr.send(request.request_body)
  return true
})

そして、dohyouiri.js からmessagingを使って background.js にURL・GraphQL・リクエストヘッダを送ります。

function createRequestBody(member_state_id, is_working) {
  const obj = {
    query: `
      mutation UpdateMemberState($id: ID!, $isWorking: Boolean!) {
        updateMemberState(input: {id: $id, isWorking: $isWorking}) {
          id
          deskId
          isWorking
        }
      }
    `,
    variables: {
      id: user_id,
      isWorking: is_working,
    },
    operationName: 'UpdateMemberState',
  }
  return JSON.stringify(obj)
}

function onClick(event) {
  const class_name = event.currentTarget.className

  const url = 'https://xxxxxxxxxxxxxxxxxxxxxxxxxx.appsync-api.ap-northeast-1.amazonaws.com/graphql'
  const api_key = 'xxxxxxxx'
  const member_state_id = 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxx'

  let request_body = null

  if (/punchButton--clockIn/.test(class_name)) {
    console.log('出勤')
    request_body = createRequestBody(member_state_id, true)
  } else if (/punchButton--clockOut/.test(class_name)) {
    console.log('退勤')
    request_body = createRequestBody(member_state_id, false)
  }

  if (request_body) {
    const message = {
      url: url,
      headers: [
        ["Content-Type", "application/json"],
        ["x-api-key", api_key],
      ],
      request_body: request_body,
    }

    chrome.runtime.sendMessage(
      message,
      response => console.log(response.content)
    )
  }
}

const punch_button = document.getElementById('js-punchButton')
punch_button.onclick = onClick

AppSyncへのリクエストにはAPIキーが必要な設定になっているので、x-api-key ヘッダにAPIキーを載せます。APIキーはAppSyncの設定画面で確認できます。
また、GraphQLの内容が少し変わっていますね。

mutation UpdateMemberState($id: ID!, $isWorking: Boolean!) {
  updateMemberState(input: {id: $id, isWorking: $isWorking}) {
    id
    deskId
    isWorking
  }
}

このように書くことで、idisWorking の値をパラメータ化することができます。実際の値は variables に記載します。実際に「打刻」ボタンを押すと図7のようなレスポンスが得られ、意図どおりに状態が変更されていることがわかります。

図7 GraphQL API呼び出し実験ログ

ハードコーディングの解消

先の例では、urlapi_keymember_state_id がハードコーディングされていますね。urlmanifest.json にも同様のものを書く必要があるためある程度仕方ありませんが、api_key の内容がソース上に存在するのはあまり良くありませんし、member_state_id に至ってはそもそも1人しか使えないものになってしまいます。ということで、この2つの値をChromeExtensionの設定画面から変更できるようにしましょう。設定画面を追加するには、毎度のごとく、manifest.json に追記する必要があります。

{
  "manifest_version": 2,
  "name": "dohyouiri",
  "version": "0.1",
  "permissions": ["webRequest", "storage", "https://zsk7yzq6xfanjdwdfjhu7bg3gq.appsync-api.ap-northeast-1.amazonaws.com/graphql"],
  "options_ui": {
    "page": "options.html",
    "open_in_tab": false
  },
  "background": {
    "scripts": ["background.js"]
  },
  "content_scripts": [
    {
      "matches": [
        "https://kintaib.example.jp/xxx/yyy/timeclock/punchmark/"
      ],
      "js": [
        "dohyouiri.js"
      ],
      "run_at": "document_end"
    }
  ]
}

options_ui の項目が増えています。この項目の page で設定画面のhtmlファイルを指定します。open_in_tabfalse にすることで、設定画面用の新しいタブを開かないようにします。また、permissionsstorage が増えています。これはChromeのStorage APIを使う際に必要な宣言です。このStorage APIを使って api_keymember_state_id を保存します。まずは options.html から見ていきましょう。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8"/>
    <title>dohyouiri</title>
  </head>
  <body>
    <form action="">
      <input id="sekitorikun_member_state_id" type="text" placeholder="MemberStateID">
      <br>
      <input id="sekitorikun_api_key" type="text" placeholder="APIキー">
      <br>
      <button type="submit" id="confirm">更新</button>
    </form>
  </body>
  <script src="options.js"></script>
</html>

2つの入力フォームと更新ボタンのあるシンプルな設定画面(図8)です。この画面には、拡張機能ページ(chrome://extensions/)の「詳細」-> 「拡張機能のオプション」から行くことができます。実際の保存は options.js で行います。

図8 充実のオプション画面
const button = document.getElementById('confirm')
button.addEventListener('click', function() {
  const values = {}
  ;['sekitorikun_member_state_id', 'sekitorikun_api_key'].forEach(key => {
    values[key] = document.getElementById(key).value
  })
  chrome.storage.sync.set(values, function(){
    chrome.extension.getBackgroundPage().alert('更新しました')
  })
})

chrome.storage.sync.set の第1引数にkey:valueの形で保存したい値を渡すことで、その値がブラウザに保存されます。保存された値は chrome.storage.sync.get の第1引数に保存したキー名の配列を渡すことで取得できます。これを使って改良した dohyouiri.js が以下です(変更のあるonClick関数のみ記載)。

function onClick(event) {
  chrome.storage.sync.get(['sekitorikun_member_state_id', 'sekitorikun_api_key'], function(options){
    const class_name = event.currentTarget.className

    const url = 'https://zsk7yzq6xfanjdwdfjhu7bg3gq.appsync-api.ap-northeast-1.amazonaws.com/graphql'
    const api_key = options['sekitorikun_api_key']
    const member_state_id = options['sekitorikun_member_state_id']

    let request_body = null

    if (/punchButton--clockIn/.test(class_name)) {
      console.log('出勤')
      request_body = createRequestBody(member_state_id, true)
    } else if (/punchButton--clockOut/.test(class_name)) {
      console.log('退勤')
      request_body = createRequestBody(member_state_id, false)
    }

    if (request_body) {
      const message = {
        url: url,
        headers: [
          ["Content-Type", "application/json"],
          ["x-api-key", api_key],
        ],
        request_body: request_body,
      }

      chrome.runtime.sendMessage(
        message,
        response => console.log(response.content)
      )
    }
  })
}

これにてdohyouiriがめでたく完成です!やったぜ!

最終的なディレクトリ構成は以下の通りです。

dohyouiri/
├── background.js
├── dohyouiri.js
├── manifest.json
├── options.html
└── options.js

まとめ

本記事では開発中のフリーアドレス向け座席管理システム「せきとりくん」を紹介し、勤怠Bと出退勤状態を同期する方法について説明しました。また、その連携を実現するためのChromeExtension「dohyouiri」を紹介し、その実装を示しました。また、せきとりくんのAPIで使用しているGraphQLという言語について簡単に説明しました。

本記事は社内の改善活動の1つを紹介することが主目的ではありますが、簡単なChromeExtension作成の手引きとしての側面もあります。ちょっと試しに何か作ってみて、ちょっとした(大きくても良い)不便を解決できるきっかけになれれば幸いです。

eiyuu
WRITER:eiyuu
ロン毛
主な記事 一覧へ

一覧へ

IS 501383 / ISO 27001