Firebase で作るフレンドシステムの一例

先週リリースした**Notespod**というメモアプリでフレンドシステムを実装しました（後付けで）

https://play.google.com/store/apps/details?id=com.karas.notespod

備忘録もかねて記事にしておこうかと思った次第です。アプリはFlutterですが、この実装自体はFlutterに依存するものではないと思います。
あくまで一例ですが、参考になれば幸いです（もっといいやり方あったら教えてください）


### フレンドシステムとは
基本的にゲームに多いんですが、ソシャゲとそれ以外では仕組みが異なる場合が多いです。自分のアプリで実装したのは**フレンドコード**を登録するタイプのものです。プラットフォームの機能でよくあるやつです。コード以外にもIDとかタグとか言い方は複数ありますが、意味はどれも同じようなものです。

1. フレンドになりたいユーザーのフレンドコードを入力
1. 該当するコードのユーザーにフレンド申請が送られる
1. 申請を受け取った相手はフレンドになることもできるし拒否することもできる
1. フレンドになったら一緒にゲームを楽しむことができる

自分のアプリの場合はゲームではなくメモを交換できるようになるわけですが、今回はこの1～4までを簡単にまとめてみます。

---

### Firestore の階層構成
```js
users - {user} - sentRequests - {sentRequest}
               - gotRequests  - {gotRequest}
               - friends      - {friend}
```

`sentRequest` は、送信したフレンド申請です。
`gotRequest` は、受け取ったフレンド申請です。
`friend` は、フレンドです。

UserA がUserB にフレンド申請を送った場合、UserAの`sentRequests`とUserBの`gotRequests`にそれぞれドキュメントが書き込まれます。

特に`gotRequests`コレクションをsnapshotで常に更新を取得する形にすると、申請が来たタイミングで何らかのアクションが起こせていい感じです。


もちろん、UserA がUserB のコレクションに直接書き込むことはなく、処理はすべて`Cloud Functions`で行われます。

セキュリティルールですが、今回のように`Functions`で処理する場合、readだけ残してwriteは消してしまっていいと思います。

```js
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{userId} {
        match /sentRequests/{sentRequestId} {
               allow read : if request.auth.uid == userId;
            }
        match /gotRequests/{gotRequestId} {
               allow read : if request.auth.uid == userId;
            }  
        match /friends/{friendId} {
               allow read : if request.auth.uid == userId;
            } 
    } 
  }
}
```

### 1. フレンドコードを入力して送信
自分のアプリではユーザー検索のような機能はないので、フレンドコードはSNS等で教えてもらう想定です。それを入力してもらい`Functions`に送信します

```dart:send_request.dart
final functions = FirebaseFunctions.instanceFor(region: 'asia-northeast1');
final callable = functions.httpsCallable('requestFriend');
final result = await callable({'friendCode': 'hogehoge'});
  if (result.data == 'success'){
      print('申請完了');
    }
  if (result.data == 'none') {
      print('ユーザーが見つかりませんでした');
    }
```
### 2. 相手にフレンド申請を送る
`Functions` の処理は以下の感じです。アプリではここに「すでに申請中」とか「ブロック中」とかの処理もあるんですが、省略してます。

```ts
import admin = require("firebase-admin");

const db = admin.firestore();

exports.requestFriend = functions
  .region("asia-northeast1")
  .https.onCall(async (data, context) => {
    const friendCode: string = data.friendCode;
    const sentRequestUserId = context.auth!.uid;
    const gotRequestUserDocs = await db.collection('users')
      .where("friendCode", "==", friendCode)
      .get();
    let result=''
    if (gotRequestUserDocs.empty) {
      result = "none";
    } else {
      const gotRequestUser = gotRequestUserDocs.docs[0]; // UserB
      await gotRequestUser.ref
      .collection('gotRequests')
      .doc(sentRequestUserId) // 送り側のIdにすると扱いやすい
      .set({id:sentRequestUserId}) // ほか必要なフィールドをセット

      await db.collection('users').doc(sentRequestUserId) // UserA
      .collection('sentRequests')
      .doc(gotRequestUser.id) // 受け手側のIdにすると扱いやすい
      .set({id:gotRequestUser.id}) // ほか必要なフィールドをセット

      result = 'success';
    }
  return result;
})
```

### 3. 受け取った申請を処理
フレンド申請を受け取ったUserB は、申請を受けるか拒否するか選択できます。拒否する場合は、基本的にはUserAの`sentRequest`とUserBの`gotRequest` をそれぞれ削除するだけでいいかと思います。実際にはここで「この相手をブロックする」といった選択肢も必要になりますが、その場合は`gotRequest`を削除せずに`isBlocked`フィールドをtrueにした状態で残しておくと扱いやすく感じました（申請時に`gotRequests`コレクションにすでに同じIDのドキュメントがあると、「すでに申請中」か「ブロック中」のいずれかになる）

```dart:decline_request.dart
final functions = FirebaseFunctions.instanceFor(region: 'asia-northeast1');
final callable = functions.httpsCallable('declineFriendRequest');
final result = await callable({
    'sentRequestUserId': 'hugahuga',
    'blockRequest': blockRequest // bool
  });
  if (result.data == 'decline'){
      print('拒否しました');
    }
  if (result.data == 'block') {
      print('ブロックしました');
    }
```

```ts
import admin = require("firebase-admin");

const db = admin.firestore();

exports.declineFriendRequest = functions
  .region("asia-northeast1")
  .https.onCall(async (data, context) => {
    const gotRequestUserId = context.auth!.uid;
    const sentRequestUserId = data.sentRequestUserId;
    const blockRequest: boolean = data.blockRequest;
    let result =''
    await db.collection('users')
      .doc(sentRequestUserId) // UserA
      .collection('sentRequests')
      .doc(gotRequestUserId)
      .delete();
    if(blockRequest){
    // ブロックする場合
     await db.collection('users')
      .doc(gotRequestUserId) // UserB
      .collection('gotRequests')
      .doc(sentRequestUserId)
      .update({isBlocked: true})
     result = 'block'
    } else{
    // 拒否する場合
     await db.collection('users')
      .doc(gotRequestUserId) // UserB
      .collection('gotRequests')
      .doc(sentRequestUserId)
      .delete()
     result = 'decline'
    }
 return result
})
```

フレンドになる場合は、それぞれの`friends`コレクションにお互いのドキュメントをセットすることになります。不要になるのでUserAの`sentRequest`とUserBの`gotRequest` はそれぞれ削除されます。上の処理と同じなのでコードは省略します


```ts
import admin = require("firebase-admin");

const db = admin.firestore();

exports.registrateFriendRequest = functions
  .region("asia-northeast1")
  .https.onCall(async (data, context) => {
    const gotRequestUserId = context.auth!.uid;
    const sentRequestUserId = data.sentRequestUserId;
    await db.collection('users')
      .doc(sentRequestUserId) // UserA
      .collection('friends')
      .doc(gotRequestUserId)
      .set({id:gotRequestUserId}) // UserB
    await db.collection('users')
      .doc(gotRequestUserId) // UserB
      .collection('friends')
      .doc(sentRequestUserId)
      .set({id:sentRequestUserId}) // UserA
})
```

### 4. フレンドにメモを送る
複数のフレンドに送るコードです。`Functions`にはメモではなくメモのパスを送ってますが、こっちのほうが処理が調整しやすく感じました。実際のアプリ内ではこれに加えて添付データの画像やら動画やらに関連した`Cloud storage`の処理と通知周りの`Cloud Messaging`の処理が入ってるんですが長いので省略

```ts
import admin = require("firebase-admin");

const db = admin.firestore();

exports.sendNoteToFriends = functions
  .region("asia-northeast1")
  .https.onCall(async (data, context) => {
    const senderUserId = context.auth!.uid;
    const notePath:string = data.notePath;
    const friends = await db.collection('users')
        .doc(senderUserId)
        .collection('friends')
        .get()
    const note = await db.doc(notePath).get();
    const noteData = note.data() as NOTE
    const promises = friends.docs.map(async (friend)=>{
      await db.collection('users')
        .doc(friend.id)
        .collection('notes')
        .doc(noteData.id)
        .set({...noteData})
     })
    await Promise.allSettled(promises);

})
```
もちろん、データは上階層に置いて、フレンドにはパスだけ送るのもアリかと思います。

---


### 終わりに
完全に余談なんですが、アプリ内での日本語の表現は少し気を付けました。例えば、**「フレンドを削除する」**ってちょっと酷くないかと思ったので、**「フレンド登録を解除する」**にしてみたり、たまに見かける**「承諾」**や**「許可」**も何か違う気がして**「登録」**にしてみたりとかです。

記事内容はツッコミ歓迎です:rolling_eyes: