コラボフローモニタリングの二本立て！＋イタズラのアルファ


いつも短パン姿で会社に彷徨いているイツキでござる〜
Mediumとかでメモみたいな記事を書くことはちょこちょこありますが、こんな真面目なところで共有するのが初めてなので、
お手柔らかく〜

## その前に
コラボフローにGoogle Analyticsが入ってないことに文句言いたいと思ったことのある人、挙手〜 :hand:

なので、以下の2本立てでいきたいと思います。
- 文書のモニタリング：ユーザー別、経路・文書別の利用状況の可視化！
- アクション（判定）のモニタリング：自分の判定段階に届くから判定するまでどれくらい経ったのが？ユーザー別、経路・文書別で見れる！

（モニタリングなんか優しい言葉ではなく、もはや監視:flushed:）

**＋アルファ**
- スマイル申請・判定：流石に上の二つだけだと真面目すぎて、面白くないなあと思って、「お菓子をくれないとイタズラするぞ」をちなんで、「笑顔くれないと判定も申請もさせないぞ」！です


## 概要
二本立てと言っても、流れは同じです。
1. [Javscriptカスタマイズ](https://collaboflow.zendesk.com/hc/ja/sections/360000040276-コラボフロー-JavaScript-API-を利用する)で必要なデータを取得する
2. [Lambda](https://aws.amazon.com/pm/lambda/)を通して、[DynamoDB](https://aws.amazon.com/pm/dynamodb/)に書き込む
3. [QuickSight](https://aws.amazon.com/pm/quicksight)を使って、データをビジュアライズする


## 文書のモニタリング
最終的みたいものとしてはユーザー別、経路・文書別の利用状況を知りたいので、まずJavascriptを使って文書申請確定時（[request.confirm.apply](https://collaboflow.zendesk.com/hc/ja/articles/360000266875)）に以下の情報を取得します。

- イベント`request.confirm.apply`に入っている情報：
    - `app_cd`：アプリケーションコード
    - `document_id`：文書ID（申請ごと異なる）
    - `process_id`：経路ID（経路別なので、同一経路、同一番号）
    - `title`：文書タイトル
- [`collaboflow.getLoginUser()`](https://collaboflow.zendesk.com/hc/ja/articles/360000266915
)で取得する情報：
    - `username`：申請するユーザー名
    - `userid`：申請するユーザーID
- `new Date()`で申請日時をゲット：
    - `request_date`：申請日時

Javascriptのコードをお見せする前、まずDynamoDBとLambdaをセットアップしましょう。

### DynamoDB
DynamoDBに新しいTable`document_monitoring_Demo`作って、各Attributeは下のイメージを参考に。
![DynamoDB Table Attributes](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3588607/b96797ed-4d0b-b530-f5a0-1fbde656fc66.png)

### Lambda
Lambdaに一つNode.Js16か14をRuntimeとしたFunctionを作ります。AWSのJavascriptSDKは18に対応してないので、お間違いなく。
コラボフローのJavascript APIから実行できるように、`FunctionURL`をチェックして、`AuthType`を`NONE`とします。
![Function URL setup](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3588607/a180689e-9188-b350-7eae-a423bc321478.png)
作り終わりましたら、`Configuration`から`Excution role`を特定して、DynamoDBへのアクセス権限を与えます。
![Permission Set up 1](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3588607/5dfe84c1-3c79-da2d-0419-22cc0e42f0b4.png)
![Screenshot 2023-11-03 at 10.32.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3588607/ac8ca2f6-3ebe-64ff-e3e2-cbffe7011a99.png)

使っているLambdaはこちら：

```javascript: index.js
const AWS = require('aws-sdk');
const docClient = new AWS.DynamoDB.DocumentClient();
const tableName = "document_monitoring_Demo"

exports.handler = async (event) => {
    let body = event.body;    
    let bodyObject = JSON.parse(body);
    
    let appCd = bodyObject["app_cd"];
    let documentId = bodyObject["document_id"];
    let documentTitle = bodyObject["title"];
    let processId = bodyObject["processes_id"];
    let requestDate = bodyObject["requestDate"];
    let userId = bodyObject["userid"];
    let userName = bodyObject["name"];
      
    let params = {
      TableName: tableName,
      Item: {
        id: `${appCd}_${documentId}_${userId}_${requestDate}`,
        appCd,
        documentId,
        documentTitle,
        processId, 
        requestDate, 
        userId, 
        userName
      }
    };
    
    try {
        await docClient.put(params).promise();
        console.log("success");
        return { body: 'Successfully created item!' };
    } catch (err) {
        console.log("error: ", err);
        return { error: err };
    }
};
```

### カスタマイズJavscript

使っているJavascriptはこちら：
`url`は上のLambdaのFunctionURLに書き換えてくださいませ〜
```javascript: documentMonitoringDemo.js
(function() {
	'use strict';
	collaboflow.events.on('request.confirm.apply', function (e) {

		const now = new Date();
		const now_string = now.toISOString();
		// ログインしているユーザーの情報を取得する
		const loginUserInfo = collaboflow.getLoginUser();

		// POSTリクエストを送信
		let url = "your_lambda_function_url";
		let headers = {};
		let data = {
			...loginUserInfo,
			...e,
			"requestDate": now_string
		};
		
		let parseType = "json";
		collaboflow.proxy.post(url, headers, data, parseType).then(function (response) {
			console.log(response.status);
			console.log(response.body);
		});
	});
})();

```
上のJavascriptをモニタリングしたいフォームに追加したら、セットアップ完了！
![Screenshot 2023-11-04 at 12.32.34.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3588607/6aef9173-f61c-bafb-6537-d5402f39323d.png)
**文書内容（パーツ）、経路依存ではないので、現有のものにそのまま追加したら使えます！**


いざ、試しへ
![apply.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3588607/69412f2d-91bc-72aa-e6d5-267313591a93.gif)
そして、DynamoDBで確認したら、以下のItemが無事追加されました！
![Dynamo Item added](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3588607/66d2cdbb-9a44-335d-2432-c832d4371061.png)


## QuickSight
DynamoDBのItem登録を無事確認したところで、QuickSightを使って、登録したデータを見てみましょう〜
DynamoDBはNoSQLデータベースのため、Athena経由でデータを取得して、QuickSightで可視化するという流れになります。具体的なセットアップ手順はこちらの[記事](https://medium.com/aws-tip/visualize-multiple-dynamodb-tables-with-quicksight-1f722c06af98)にてご確認いただければと思います。

ユーザー別の申請数や、経路別の申請数等が簡単に見れます！
![Screenshot 2023-11-15 at 7.54.18.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3588607/5004d26e-b8a9-c6c5-311c-b7cb39f55905.png)
![Screenshot 2023-11-15 at 7.54.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3588607/ecf561c1-58ea-f464-ba39-26729ebb6c38.png)


こちらは上に登録した文書申請データのみですが、例えば、下のテーブルのようなユーザーのMetaDataをDynamoDBに登録すれば、役職や部署で絞ることもできます。
| 氏名 | ユーザーID | 部署 | 役職 |
|-----|-----------|-----|-----|
|山田太郎 | taro | 営業部 | 部長 |


もちろん、毎回DynamoDBからcsvダウンロードして、Excelでみるのもありですが、QuickSightを使うことで、その手間がなくなる上、（ほぼ）RealTimeのデータを確認することができます。




## 判定のモニタリング

ユーザーの判定が届いてから判定するまでかかった時間を知りたいので、まずJavascriptを使って判定確定する時（[request.judgement.*](https://collaboflow.zendesk.com/hc/ja/articles/360000262836)）に上の文書モニタリング時に取得しているものの他、以下の情報も併せて取得します。

- 判定時のイベントから
    - `actionType`: 確認、承認、却下、差し戻し
- コラボフローREST API: `/v1/documents/{document_id}/determs`
    - `previousActionDate`: 前の段階の判定日時
    - `phaseNumber`:　判定段階番号
    - `timeDifference`: 今の日時（`currentActionDate`）と`previousActionDate`の時間差（ms）


DynamoとLambdaのセットアップは上と同じなので、細かいところはスキップさせていただきます。

- DynamoDBに新しいTable`action_monitoring_demo`を作成
- 新しいLambdaを以下を作成。コードは以下のものを使ってください
    ```javascript: index.js 
    const AWS = require('aws-sdk');
    const docClient = new AWS.DynamoDB.DocumentClient();
    const tableName = "action_monitoring_demo"
    
    
    exports.handler = async (event) => {
        let body = event.body;
        let bodyObject = JSON.parse(body);
        
        let appCd = bodyObject["app_cd"];
        let documentId = bodyObject["document_id"];
        let documentTitle = bodyObject["title"];
        let processId = bodyObject["processes_id"];
        let currentActionDate = bodyObject["currentActionDate"];
        let previousActionDate = bodyObject["previousActionDate"];
        let userId = bodyObject["userid"];
        let userName = bodyObject["name"];
        let phaseNumber = bodyObject["phaseNumber"];
        let timeDifference = bodyObject["timeDifference"];
        let actionType = bodyObject["actionType"];
        
        
        let params = {
          TableName: tableName,
          Item: {
            id: `${appCd}_${documentId}_${phaseNumber}_${userId}`,
            appCd,
            documentId,
            documentTitle,
            processId, 
            currentActionDate, 
            previousActionDate,
            userId, 
            userName, 
            phaseNumber, 
            timeDifference, 
            actionType
          }
        };
        
    
        try {
            await docClient.put(params).promise();
            console.log("success");
            return { body: 'Successfully created item!' };
        } catch (err) {
            console.log("error: ", err);
            return { error: err };
        }
    };
    ```

そしてそして、下記のJavascriptをモニタリングしたいフォームに追加。
`url`は上のLambdaのFunctionURLに書き換えてくださいませ〜
```javascript: actionMonitoringDemo.js
(function() {
	'use strict';
    
    function getRequestDate(appCd, documentId) {
        return collaboflow.api.get(`/v1/documents/${documentId}`, {
            "app_cd": appCd,
        }).then(function(response) {
            // HTTPステータスと応答ボディ
            console.log(response.status, response.body);
            
            // API応答がエラーとなった場合の処理
            if(response.body.error) {
                console.log('失敗', response.body.messages);
                return;
            }

            const body = response.body;
            const requestDateString = body.request_date;
            console.log(requestDateString);
            // const requestDateStamp = new Date(requestDateString).getTime();
            // console.log(requestDateStamp)
            return requestDateString;
        })
        .catch(function(err) {
            console.log(err);
        });
    }

    function handleJudgement(e, actionType) {
        const now = new Date();
        const now_string = now.toISOString();

        const documentId = e.document_id;
        const appCd = e.app_cd;

        const loginUserInfo = collaboflow.getLoginUser();
        const username = loginUserInfo.name;
        var currentPhaseNumber = 0;

         // コラボフローAPIで申請書情報を取得
        collaboflow.api.get(`/v1/documents/${documentId}/determs`, {"app_cd": appCd,})
        .then(function(response) {
            // API応答がエラーとなった場合の処理
            if(response.body.error) {
                console.log('失敗', response.body.messages);
                return;
            }

            const body = response.body;
            const records = body.records;
            var currentRecord = records.filter(function (r) {
                return (r.current == true && r.determ_username == username) || (r.determ_status == "unconfirm" && r.determ_username == username)
            });

            if (currentRecord.length === 0) {
                return
            }

            var shouldGetRequestDate = false;
            var previousActionDate = undefined;
            currentPhaseNumber = currentRecord[0].phase_number;

            if (currentPhaseNumber == 1) {
                shouldGetRequestDate = true
            } else {
                for (let i = currentPhaseNumber - 1; i >= 0; i--) {
                    var previousRecords = records.filter(function (r) {
                        return (r.phase_number == i)
                    });
                    previousRecords.sort((a, b) => {
                        let determDateA = new Date(a.determ_date).getTime();
                        let determDateB = new Date(b.determ_date).getTime();
                        return determDateB - determDateA;
                    });

                    if (previousRecords.length === 0 || previousRecords[0].determ_date === undefined || previousRecords[0].determ_date == "") {
                        continue
                    } else {
                        previousActionDate = previousRecords[0].determ_date;
                        break;
                    }
                }
                if (previousActionDate === undefined) {
                    shouldGetRequestDate = true
                }
            }
            if (shouldGetRequestDate === true) {
                return getRequestDate(appCd, documentId);
            } else {
                return previousActionDate
            }
        })
        .then(function (previousActionDate) {
            let timeDifference = now.getTime() - new Date(previousActionDate).getTime()
            let url = "lambda_function_url";
            let headers = {};
            let data = {
                ...loginUserInfo,
                ...e,
                "previousActionDate": previousActionDate, 
                "currentActionDate": now_string, 
                "phaseNumber": currentPhaseNumber, 
                "timeDifference": timeDifference, 
                "actionType": actionType
            };

            let parseType = "json";
            collaboflow.proxy.post(url, headers, data, parseType).then(function (response) {
                console.log(response.status);
                console.log(response.body);

            });

        })
        .catch(function(err) {
            console.log(err);
        });
    }


	// 申請内容確認ボタンクリックイベント
	collaboflow.events.on('request.judgement.accept', function (e) {
        handleJudgement(e, "accept");
	});

    collaboflow.events.on('request.judgement.reject', function (e) {
        handleJudgement(e, "reject");

	});

    collaboflow.events.on('request.judgement.remand', function (e) {
        handleJudgement(e, "remand");
	});

    collaboflow.events.on('request.judgement.confirm', function (e) {
        handleJudgement(e, "confirm");
	});

})();

```

同じくフォームや経路依存ではないので、現有のものにそのまま追加しっちゃても大丈夫です。

QuickSightを使ってDynamoDBに登録したデータを可視化する手順は上と同じになりますので、スキップさせていただきます〜



### 注意点
このアルゴリズムを使った判定モニタリングは幾つカバーできていない点がありますので、過信は禁物〜
あくまで参考情報として見ていただければ幸いです。
- 前の段階の判定日時を取得するとき、仮に前の段階が必須でない回覧かつ確認済みの場合、その日時が`PreviousActionTime`として使われてしまいます
    - 解決するには各フォームに使われているJsに対応する経路の情報をMAPやDict `{phaseNumber}: {経路判定情報}` として渡して、日時を取得する時を参照する
    - （デメリット：Jsが使いまわせなくなる、一つ一つ設定する必要があります）
    - ```JOSN:Dict例
         {
            "1": "AND",
            "2": "CONFRIM_NOT_REQUIRED",
            "3": "CONFRIM_REQUIRED
         }
- 差し戻しや取戻しの場合、一つ前の判定履歴取得できないため、差し戻された時点を`PreviousActionTime`として設定することができなく、あくまで、前段階の判定日時を`PreviousActionTime`として使っています。
    - 今のREST APIだとこれは限界ですが、もしもっともっと使いたいという方々からの要望が多くなれば、一つ前の判定履歴を取得できるAPIを追加するかも？




## +アルファ：スマイル申請・判定

言葉だけだと何をやりたいかイメージしにくいと思いますので、まず完成したものをお見せします。
![smile.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3588607/b54aad9a-43ff-03db-c692-476e2efe58c4.gif)
（2回目の申請をする前、写真Retake忘れてしまいました。。。お気になさらず。。。）


正直、この部分DeepLearningとかAIに絡んでくるので、ここでは説明仕切れないような気がしますので、とりあえず、ロジックをスキップして、再現できるよう重要なところだけカバーします。
- 以下の権限のあるAWSのIAM Roleを作成
    - AmazonSageMakerFullAccess
    - AmazonS3FullAccess
    - AmazonElasticContainerRegistryPublicFullAccess
- [こちら](https://github.com/0Itsuki0/sagemaker_endpoint_creation_tensorflow_demo)のJupyter Notebookをダウンロードして、実行することで、SageMaker Endpointを作成する。具体的な説明が欲しい方[こちら](https://medium.com/@2018.itsuki/create-and-invoke-sagemaker-endpoint-from-pre-trained-tensorflow-model-1f78bde26c50)で〜
    - 注意点：`role_arn`を上のものに、 `endpoint_name`を`smileDemoTesorflow`に変更する
- こちらの[Repository](https://github.com/0Itsuki0/smile_demo_lambda_stack)をダウンロードし、`cdk deploy`することで、LambdaのFunctionを作成する
- LambdaのConsoleに行って、
    - Execution roleにAmazonSageMakerFullAccessを与える![Screenshot 2023-11-08 at 10.49.56.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3588607/5d80c6c4-0300-56f9-63fd-bd3b9e398390.png)
    - Function URLを取得する
- 下記のJavascriptをイタズラしたいフォームに追加（同じくフォームや経路依存ではないです〜）。`url`は上のLambdaのFunctionURLに書き換えてくださいませ〜

```javascript: smileDemo.js
(function() {
    'use strict';
    
    function openCam(){
        let All_mediaDevices=navigator.mediaDevices
        if (!All_mediaDevices || !All_mediaDevices.getUserMedia) {
            console.log("getUserMedia() not supported.");
            return;
        }
        All_mediaDevices.getUserMedia({
            audio: false,
            video: true
        })
        .then(function(vidStream) {
            var video = document.getElementById('video');
            if ("srcObject" in video) {
                video.srcObject = vidStream;
            } else {
                video.src = window.URL.createObjectURL(vidStream);
            }
            video.onloadedmetadata = function(e) {
                video.play();
            };
        })
        .catch(function(e) {
            console.log(e.name + ": " + e.message);
        });
    }

    function stopCam() {
        var video = document.getElementById('video');
        var stream = video.srcObject;
        stream.getTracks().forEach(function(track) {
            track.stop();
        });
    }


    function takePhoto() {
        console.log("taking photo");
        var canvas = document.getElementById('canvas');
        canvas.getContext('2d').drawImage(video, 0, 0, canvas.width, canvas.height);
        let image_data_url = canvas.toDataURL('image/jpeg');
        // data url of the image
        // console.log(image_data_url);
    }

    
    function createHtml() {
        var el = document.createElement("div");
        el.id = "popup";
        el.setAttribute("style","position:fixed;top:2%;right:2%;width:200px;height:400px;background-color:gray");
    
        var p = document.createElement("p");
        p.id = "smile"
        p.innerHTML = "smile"
        p.setAttribute("style","background-color:white;padding:5%");
        el.appendChild(p);

        var video = document.createElement("video");
        video.id = "video"
        video.setAttribute("style","position:relative;top:0%;width:100%;height:45%;background-color:yellow;autoplay");
        el.appendChild(video);
    
        var takePhotoButton = document.createElement("button");
        takePhotoButton.id = "takePhotoButton";
        takePhotoButton.setAttribute("style","position:relative;height:8%");
        takePhotoButton.innerText = "take photo";
        takePhotoButton.addEventListener("click", takePhoto);
        el.appendChild(takePhotoButton)
    
        var canvas = document.createElement("canvas");
        canvas.id = "canvas"
        canvas.setAttribute("style","position:relative;width:100%;height:45%;background-color:yellow");
        el.appendChild(canvas);
    
        document.body.appendChild(el)
    }

    function postPhoto(image_base64) {
        let url = "lambda_url";
        let headers = {
            'Accept': 'application/json',
            'Content-Type': 'multipart/form-data',
        };

        let parseType = "json";

        return collaboflow.proxy.post(url, headers, image_base64, parseType).then(function (response) {
            if (response.status == 200) {
                let result = response.body
                if (result == true) {
                    alert("素敵な笑顔ありがとう！");
                    stopCam();
                    return true
                } else {
                    alert("笑って〜");
                    return false
                }
            }

            else {
                console.log(response.status);
                console.log(response.body);
                stopCam();
                return true
            }

        });
    }


    // 申請
    collaboflow.events.on('request.confirm.show', function (e) {

        console.log("request.confirm.show");
        
        createHtml();
        openCam();

    });


    collaboflow.events.on('request.confirm.apply', function (e) {

        let image_base64 = document.getElementById('canvas').toDataURL('image/jpeg').replace(/^data:image\/jpeg;base64,/, "");
    
        return postPhoto(image_base64);

    });


    // 判定

    collaboflow.events.on('request.judgement.show', function (data) {
        createHtml();
        openCam();
    });

    // 申請内容確認ボタンクリックイベント
    collaboflow.events.on('request.judgement.accept', function (e) {
        let image_base64 = document.getElementById('canvas').toDataURL('image/jpeg').replace(/^data:image\/jpeg;base64,/, "");
    
        return postPhoto(image_base64);
    });

    collaboflow.events.on('request.judgement.reject', function (e) {
        let image_base64 = document.getElementById('canvas').toDataURL('image/jpeg').replace(/^data:image\/jpeg;base64,/, "");
    
        return postPhoto(image_base64);
    });

    collaboflow.events.on('request.judgement.remand', function (e) {
        let image_base64 = document.getElementById('canvas').toDataURL('image/jpeg').replace(/^data:image\/jpeg;base64,/, "");
    
        return postPhoto(image_base64);        
    });

    collaboflow.events.on('request.judgement.confirm', function (e) {
        let image_base64 = document.getElementById('canvas').toDataURL('image/jpeg').replace(/^data:image\/jpeg;base64,/, "");
    
        return postPhoto(image_base64);
    });


})();

```
ぜひ遊んでみてください！


---

楽しんでいただけますでしょうか？
Adventはまたまたここからなので、
どうぞよろしくお願いいたします〜