Cloud Functionsでリマインダーを実装してみた

## はじめに
Qiita初投稿です！
現在[就活の予定や企業の管理を行う就活管理アプリ](https://jobhun.jp/)をしている中で、予定に対して通知機能を実装することになったので、その知見を共有できたらと考えています。

## 通知用のデータ構造
コレクションのデータ構造は以下のようになっています。

- users
    - calendars(usersのサブコレクション)
        - notifications(calendarsのサブコレクション)
            - id
            - isDone(通知完了フラグ)
            - time(通知時間)

このようにnotificationsをcalendarsのサブコレクションにしたのはGoogle Calendarのように通知を複数登録したかったためです。

## 実装について
実装の流れは以下のようになります
1. Cloud Functionsのスケジュール関数を利用して毎分関数を実行する
2. その際に通知時間(time)が一致しているかつ、 通知が完了していないもの(isDoneがfalse)であるデータを取得
3. 通知用データに紐づけられているプッシュ通知に必要なデータ(予定の時間や、FCM用のトークン)を取得
4. プッシュ通知を実行

今回は[こちら](https://qiita.com/hirothings/items/37430b2408a5a7a85972)の記事を参考にさせていただきました。


## 1. Cloud Functionsの毎分実行
詳細は[公式ドキュメント](https://firebase.google.com/docs/functions/schedule-functions)に記載してますので、説明は省かせていただきます。

コードに関しては以下のようになります。

```ts:push-notification.ts
exports.sendReminder = functions.region('asia-northeast1')
  .runWith({ memory: '512MB' })
  .pubsub.schedule('every 1 minutes')
  .timeZone('Asia/Tokyo')
  .onRun(async (context) => {})
```

ここで注意する必要があるのはリージョンとタイムゾーンを設定する必要があるということです。
もし設定していない場合には、プッシュ通知を行う時間がズレたりします。


:::note warn
補足
ローカルからfunctionsをデプロイした際にタイムゾーンの設定が外れることがあるそうです。以下のサイトを参考にしてみてください。
:::

https://zenn.dev/captain_blue/articles/how-to-get-timestamp-properly-cloudfunctions


## 2. 通知用データの取得
以下のようなコードになります

```ts:push-notification.ts
exports.sendReminder = functions.region('asia-northeast1')
  .runWith({ memory: '512MB' })
  .pubsub.schedule('every 1 minutes')
  .timeZone('Asia/Tokyo')
  .onRun(async (context) => {
    // 秒を切り捨てた現在時刻
    const now = (() => {
      let s = admin.firestore.Timestamp.now().seconds
      s = s - s % 60
      return new admin.firestore.Timestamp(s, 0)
    })()
    // notificationsコレクションから現在時刻に該当するデータを取得
    const remindersRef = await admin.firestore().collectionGroup('notifications')
      .where('time', '==', now)
      .where('isDone', '==', false)
      .get()
})
```
時間が一致しているかを検索する際に、秒数が邪魔になるので切り捨てています。

## 3. 通知に必要なデータの取得
この辺りは一般的なfirestoreの処理になるので紹介のみにさせていただきます。

```ts:push-notification.ts
exports.sendReminder = functions
  .region('asia-northeast1')
  .runWith({ memory: '512MB' })
  .pubsub.schedule('every 1 minutes')
  .timeZone('Asia/Tokyo')
  .onRun(async (context) => {
    // 秒を切り捨てた現在時刻
    const now = (() => {
      let s = admin.firestore.Timestamp.now().seconds
      s = s - s % 60
      return new admin.firestore.Timestamp(s, 0)
    })()
    // notificationsコレクションから現在時刻に該当するデータを取得
    const remindersRef = await admin.firestore().collectionGroup('notifications')
      .where('time', '==', now)
      .where('isDone', '==', false)
      .get()

    // カレンダーのデータとユーザーデータを取得する
    const notCompletedRemindTask: CalendarReminder[] = [];
    await Promise.all(remindersRef.docs.map(async (doc) => {
            /* 省略 */
            notCompletedRemindTask.push(new CalendarReminder(fcmToken, title, content, calendarData.id, userId))
        }).catch((err) => {
          functions.logger.error(`calendarDataError: ${err}`);
        });
      }
    }))
    if (notCompletedRemindTask.length !== 0) {
      // プッシュ通知の送信
      await sendPushNotificationsForReminder(notCompletedRemindTask)
    }
  })
```

## 4. プッシュ通知の送信

```ts:push-notification.ts
async function sendPushNotificationsForReminder(reminders: CalendarReminder[]) {
  const messages: admin.messaging.Message[] = []
  for (const reminder of reminders) {
    // プッシュ通知の設定
    const notification: admin.messaging.Notification = {
      title: reminder.title,
      body: reminder.content
    }
    const message: admin.messaging.Message = {
      token: reminder.fcmToken,
      // 通知を押したときの遷移先用にdataを入れています
      data: {
        userId: reminder.userId,
        calendarId: reminder.calendarId
      },
      notification: notification
    }
    messages.push(message)
  }
  if (messages.length === 0) { return }
  await sendAll(messages)
}

async function sendAll(messages: admin.messaging.Message[]) {
  // 500件ずつに分割する(500件以上は、sendAllメソッド側でエラーになるため)
  const msgs = messages.reduce((acc: admin.messaging.Message[][], val) => {
    const MAX_BATCH_SIZE = 500
    const last = acc[acc.length - 1]
    if (last.length === MAX_BATCH_SIZE) {
      acc.push([val])
      return acc
    }
    last.push(val)
    return acc
  }, [[]])

  // Promise.allで分割して配信
  const promises = msgs
    .map((ms) => admin.messaging()
      .sendAll(ms)
      .then((batchResponse) => {
        batchResponse.responses.forEach((res) => {
          if (res.error) {
            functions.logger.error(res.error)
          }
        })
        functions.logger.log(`send messages: ${ms.length} count`)
      })
      .catch((e) => {
        functions.logger.error(e)
      })
    )
  await Promise.all(promises)
}
```

参考にさせていただいた記事にもありますが、`Promise.all`を利用して並列処理を実装しないで、単純に直列処理にしてもいいと思いました。
特に自分のサービスはそこまでデータ量も多いわけではないので今後のためにという形で実装しておきました。

## 最後に
ユーザー単体に送る通知やトピックを設定する通知は実装したことがありましたが、今回のような少し複雑なのものは初めて実装したので勉強になりました。

最後にはなりますが、現在開発しているアプリに関してご紹介させていただきます。

就活の管理を行うアプリ**Jobhun**です！
Jobhunは「就活状況を一括管理したい...」　「自分が就活の時、予定を組むのが大変だった...」という就活生の声から生まれたアプリです。そのため、実際に就活を経験してきた方の意見を参考に、「本当に必要な機能」をまとめました！

以下のような機能があります。

* 企業管理機能
* カレンダー機能
* ES管理機能 etc...

気軽に就活管理ができるようになっているので、是非ダウンロードしてみてください！！
[LP](https://jobhun.jp/)、[IOS](https://apps.apple.com/jp/app/jobhun-%E5%B0%B1%E6%B4%BB%E7%94%9F%E3%81%AE%E3%81%9F%E3%82%81%E3%81%AE%E3%82%B9%E3%82%B1%E3%82%B8%E3%83%A5%E3%83%BC%E3%83%AB%E7%AE%A1%E7%90%86%E3%82%A2%E3%83%97%E3%83%AA/id1546735909#?platform=iphone)、[Android](https://play.google.com/store/apps/details?id=jobhun.jobhun)
![1639120308483.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/612338/e1710c6d-244f-3761-0c0d-3a10125a4778.jpeg)