🎄

[firebase] Function,Firestore,Authでチェックポイント通過管理システムを作る

2023/12/02に公開

こんにちは。某SIerでCAD/PLMを担当している阿部です。
今回は社内同好会の2023年アドベントカレンダーの12/3分の記事です。
https://adventar.org/calendars/8843

システム構築の経緯

ボーイスカウト東京連盟練馬地区では毎年、ボーイスカウト(小六から中三)向けに50kmハイクというプログラムを提供しています。  
おおむね5km毎にスカウトの通過チェックを待機している大人が行い、本部で情報を集約するのですが オペレーション効率化のため 今年度はFirebaseの採用に挑戦しています。

登場人物

システム概要

チェックポイント担当はLineアカウントを持っているので、記録の際の認証はLineアカウントを利用します。
各チェックポイント担当は自分がどのチェックポイントの担当なのかQRコードを使用し、チェックポイント登録ページからCloud Function経由でFirestoreに登録
スカウトがチェックポイントを通過する際はチーム名を含んだQRコードをチェックポイント担当が読み取って、通過登録ページからCloud Function経由で通過情報をFirestoreに記録します。
記録された通過情報は認証不要のページにて公開し、運営担当やスカウトの親御さんがほぼリアルタイムで把握できるようにしました。

システム概要

更改に際し気をつけたポイントは下記です。
・スカウトが携帯を持たなくても成立する仕組み(前年度踏襲)
・ポイント担当の作業はできるだけUIを排除(前年度踏襲)
・情報更新に運営チームの負荷をかけない(今回挑戦)

情報登録担当の認証方法

Firebase AuthenticationをアップグレードしOpenIDを利用できるようにします。 (2023/11現在 50アカウント以上の登録は課金されるので注意してください。)

LINE Deveropersでアプリ登録しログイン情報を取得。
Firebase Authentication側に設定します。
今回は下記記事を参考に設定しました。
https://zenn.dev/satjopg/articles/f1b14f249b5ff6

設定後、Hostingにログインページを作成します。 今回は手っ取り早くFirebaseUIを使いましたが
色々問題があって使わない人の方が多いっぽい。
パソコンではOKだけどスマホだとNG,AndroidではOKだけどiPhoneではNGという現象が発生し下記コンテンツにお世話になりました、
https://www.line-community.me/en/question/651a663c7e13080ac0725c21
https://zenn.dev/tanukikyo/books/6c03190085aea0

login.htmlの一部
   <script type="text/javascript">

        const ui = new firebaseui.auth.AuthUI(firebase.auth());
        const uiConfig = {
            callbacks: {
                signInSuccessWithAuthResult: function (authResult, redirectUrl) {
                    return true;
                },
            },
            signInFlow: 'redirect',
            signInSuccessUrl: 'login.html',
            signInOptions: [
                {
                    provider: 'oidc.line',
                    providerName: 'Line',
                    
                    fullLabel: 'Lineにログイン',
                         
                    buttonColor: '#06C755',
                    iconUrl: 'image/btn_base.png',
                   
                    customParameters: {
                         //Defaultの自動ログインだと上手くいかなかった
                         disable_auto_login: 'true',
                    }
                }
            ],
            tosUrl: 'tosUrl.html',
            privacyPolicyUrl: 'privacyPolicyUrl.html'
        };

        ui.start('#auth', uiConfig);

        firebase.auth().onAuthStateChanged(user => {
            if (user) {
                const signOutMessage = `
        <p> ${user.displayName} さんのアカウントでログインしています<\/p>
        <button type="submit"  onClick="signOut()">サインアウト<\/button>
        `;
                document.getElementById('auth').innerHTML = signOutMessage;
                console.log('ログインしています');

            }
        });

        function signOut() {
            firebase.auth().onAuthStateChanged(user => {
                firebase
                    .auth()
                    .signOut()
                    .then(() => {
                        console.log('ログアウトしました');
                        location.reload();
                    })
                    .catch((error) => {
                        console.log(`ログアウト時にエラーが発生しました (${error})`);
                    });
            });
        }

    </script>

ポイント担当情報の登録方法

QRコードに登録情報を埋め込めるよう、クエリパラメータでの登録としています。
Hostingに配置したJavascriptでLineアカウントで識別するアカウントIDとクエリパラメータを読み込み、function APIのhttpCallableを使って引数をFucnctions側に渡します。

クエリパラメータの取得はこちらの記事を参考にしました。
https://qiita.com/akinov/items/26a7fc36d7c0045dd2db

regCheckPoint.htmlの一部
   <script type="text/javascript">

    function getUrlQueries() {
        var queryStr = window.location.search.slice(1);  // 文頭?を除外
        queries = {};

        // クエリがない場合は空のオブジェクトを返す
        if (!queryStr) {
            return queries;
        }

        // クエリ文字列を & で分割して処理
        queryStr.split('&').forEach(function (queryStr) {
            // = で分割してkey,valueをオブジェクトに格納
            var queryArr = queryStr.split('=');
            queries[queryArr[0]] = queryArr[1];
        });

        return queries;
    }
        // 渡されたパラメータのスキーマをチェックする
        const validateParamsSchema2 = (params) => {
            const hasCheckNo = 'checkNo' in params;
            return hasCheckNo;
        };

        const parsed = getUrlQueries();
            console.log(parsed);



        // パラメータのスキーマのチェック
        if (!validateParamsSchema2(parsed)) {
            console.log(  'パラメータが不正です' );
            document.getElementById('mess').innerHTML = "パラメータが不正です。QRコードを読んでください";
        } else {
            const functions = firebase.app().functions('asia-northeast1');
    
            firebase.auth().onAuthStateChanged(user => {
                if (user) {


                    const regCheck = functions.httpsCallable('registerCheckTokyo');
                    const messageText = "";
                    document.getElementById('mess').innerHTML = "Loading...";
                    regCheck({ name: user.displayName, user_id: user.uid, checkNo: parsed.checkNo })
                        .then((result) => {
                            // Read result of the Cloud Function.
                            /** @type {any} */
                            const data = result.data;
                            const sanitizedMessage = data.message;
                            console.log('sanitizedMessage: ' + sanitizedMessage);

                            document.getElementById('mess').innerHTML = sanitizedMessage;
                        })
                        .catch((error) => {
                            // Getting the Error details.
                             document.getElementById('mess').innerHTML = "エラーが発生しました。もう一度試してください。";
                            const code = error.code;
                            const message = error.message;
                            const details = error.details;
                            // ...
                        });


                }
                else {
                    document.getElementById('mess').innerHTML = "ログインしていないので登録できません";
                }
            });
            

        }
  </script>

Functionは受信したパラメータを使ってfirestoreに書き込みを行います

functions/index.jsの一部
        exports.registerCheckTokyo = functions
    .region("asia-northeast1")
    .https
    .onCall((data, context) => {
        
    const jst = new Date().toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo' });
    const currentTime = new Date(jst);

    const db = fireStore;
      // 'checkPoint'というcollectionがある前提で任意のドキュメントIDのdocumentを生成する
      const userRef = db.collection('users');
      userRef.doc(data.user_id).set({
          checkNo: data.checkNo,
          name: data.name,
          Timestamp: currentTime,
      });
        return {
            message: `${data.name}さんをチェックポイント${data.checkNo}担当として登録完了しました`
      }

    });

スカウト通過情報の登録方法

ポイント担当の登録同様、クエリパラメータでの登録としています。
Hostingに配置したJavascriptでLineアカウントで識別するアカウントIDとクエリパラメータを読み込み、function APIのhttpCallableを使って引数をFucnctions側に渡します。

regScouts.htmlの一部
    <script type="text/javascript">

        // 渡されたパラメータのスキーマをチェックする
        const validateParamsSchema = (params) => {
            const hasCheckNo = 'scoutId' in params;
            return hasCheckNo;
        };

        function getUrlQueries() {
                var queryStr = window.location.search.slice(1);  // 文頭?を除外
                queries = {};

                // クエリがない場合は空のオブジェクトを返す
                if (!queryStr) {
                    return queries;
                }

                // クエリ文字列を & で分割して処理
                queryStr.split('&').forEach(function (queryStr) {
                    // = で分割してkey,valueをオブジェクトに格納
                    var queryArr = queryStr.split('=');
                    queries[queryArr[0]] = queryArr[1];
                });

                return queries;
            }

        const parsed = getUrlQueries();
            console.log(parsed);


        // パラメータのスキーマのチェック
        if (!validateParamsSchema(parsed)) {
            console.log(  'パラメータが不正です' );
            document.getElementById('mess').innerHTML = "パラメータが不正です。QRコードを読んでください";
        } else {
            const functions = firebase.app().functions('asia-northeast1');
           
            firebase.auth().onAuthStateChanged(user => {
                if (user) {


                    const regScouts = functions.httpsCallable('registerScoutsTokyo');
                    document.getElementById('mess').innerHTML = "Loading...";
                    regScouts({ name: user.displayName, user_id: user.uid, scoutId: parsed.scoutId })
                        .then((result) => {
                            // Read result of the Cloud Function.
                            /** @type {any} */
                            const data = result.data;
                            const sanitizedMessage = data.message;
                            console.log('sanitizedMessage: ' + sanitizedMessage);

                            document.getElementById('mess').innerHTML = sanitizedMessage;
                        })
                        .catch((error) => {
                            // Getting the Error details.
                             document.getElementById('mess').innerHTML = "エラーが発生しました。もう一度試してください。";
                            const code = error.code;
                            const message = error.message;
                            const details = error.details;
                            // ...
                        });


                }
                else {
                    document.getElementById('mess').innerHTML = "ログインしていないので登録できません";
                }
            });
            

        }
    </script>

Functionは受信したパラメータを使ってfirestoreに書き込みを行います
(ここはチェックポイントとほぼ同様の処理なので省略)

情報の参照方法

認証不要のHTML内でFirestoreの特定のコレクションをsnapshot参照することで、更新時にページが反映されるようにしています。 見せ方はまだ検討中で 現状は更新した内容をテキスト出力するだけです。

statusCheck.htmlの一部
    <script type="text/javascript">

    document.querySelector('#status').innerHTML = '';

    firebase
    .firestore()
    .collection('status').orderBy('time')
    .onSnapshot(function(scoutSnap) {
      if (!scoutSnap.empty) {
                scoutSnap.docChanges().forEach((change) => {         
                         if (change.type === "added") {
                            const p = document.createElement('p');
                             const result = document.createTextNode(
                                 `${JSON.stringify(change.doc.data())}`,
                             );
                             p.appendChild(result);
                             document.querySelector('#status').appendChild(p);
                        }
                });
            }
        });
            
    </script>

おわりに

こんなシステム作れるのかなーと始めた心配しましたがトラブル部分は先輩たちが経験済みのノウハウを公開してくれたので助かりました。この記事もどなたかの構想具現化の一助になれば幸いです。

Discussion