firestoreを利用する

## 環境
- React v18.2.0
- TypeScript v4.9.5
- firebase v9.17.1
- [react-query-firebase][react-query-firebase]

[react-query-firebase]: https://github.com/invertase/react-query-firebase

## 目次
- [セットアップ](#セットアップ)
- [認証](#認証)
- [データ取得](#データ取得)
- [データ操作](#データ操作)
- [Security Rules](#security-rules)

## セットアップ
`firebase init`でローカル環境を作る。方法は各自検索されたし。

設定ファイル
今回はfirestoreとauthのみ使用する。
```ts:firebase.ts
import { initializeApp } from 'firebase/app';
import {
  connectAuthEmulator,
  getAuth,
  GithubAuthProvider,
  GoogleAuthProvider,
} from 'firebase/auth';
import { getFirestore, connectFirestoreEmulator } from 'firebase/firestore';

import { Firebaseの環境変数 } from '@/config';

const firebaseConfig = {
    // いつもの
};

const firebase = initializeApp(firebaseConfig);

const auth = getAuth();
FIREBASE_EMULATE === 'true' && connectAuthEmulator(auth, 'http://localhost:9009');

const db = getFirestore();
FIREBASE_EMULATE === 'true' && connectFirestoreEmulator(db, 'localhost', 8080);

const firebaseAuthProviders = {
  github: new GithubAuthProvider(),
  google: new GoogleAuthProvider(),
};

export { firebase, auth, db, firebaseAuthProviders };
```
エミュレーターを使用する時のポートはfirebase.jsonで後から変更できる。
```
yarn firebase emulators:start
```
でデータを永続化せず起動。
```
yarn firebase emulators:start --import=<保存先ディレクトリ> --export-on-exit
```
でデータを永続化して起動。
通信の中身を詳しく見ることが出来るので開発環境では基本的にエミュを使用する。
エミュはvscodeとは別でシェルを開いて起動すると良い。
終了する時はポートの一覧が出ているウインドウで`ctrl+C`

__firebase関連のファイルは1つに纏めて管理すると散らばらなくて良い。
rulesやindexesの参照位置もfirebase.jsonで変更できる。__

## 認証
#### 目次
- [認証プロバイダー](#認証プロバイダー)
- [認証ユーザーの取得](#認証ユーザーの取得)
- [認証ユーザーのFirestoreドキュメントの取得](#認証ユーザーのfirestoreドキュメントの取得)

#### 認証プロバイダー
SNSログインとログアウト機能
```ts:fireAuth.ts
import type { FC, ReactNode } from 'react';
import React, { createContext, useContext } from 'react';

import { signInWithPopup, signOut as firebaseSignOut } from 'firebase/auth';

import { SuspenseFallback } from '@/components/Elements/SuspenseFallback';
import { auth, firebaseAuthProviders } from '@/config/firebase';
import { useFireAuthUser } from '@/features/auth';
import type { FireUser } from '@/features/users';
import { useObserveUserDoc } from '@/hooks/useObserveUserDoc';

import type { User, UserCredential } from 'firebase/auth';

export const useFireAuth = () => {
  const { user, isLoading: isAuthLoading } = useFireAuthUser();
  const { userDocData, isLoading: isDocLoading } = useObserveUserDoc(user);

  const signIn = async (provider: keyof typeof firebaseAuthProviders) => {
    const result = await signInWithPopup(auth, firebaseAuthProviders[provider]);
    console.log(result);

    return result;
  };

  const signOut = async () => {
    await firebaseSignOut(auth);
    window.location.assign(window.location.origin as unknown as string);
  };

  return { user, isAuthLoading, userDocData, isDocLoading, signIn, signOut };
};

const AuthContext = createContext<{
  user: User | null;
  isAuthLoading: boolean;
  userDocData: FireUser | undefined;
  isDocLoading: boolean;
  signIn: (provider: keyof typeof firebaseAuthProviders) => Promise<UserCredential>;
  signOut: () => Promise<void>;
} | null>(null);

export const FireAuthProvider: FC<{ children: ReactNode }> = ({ children }) => {
  const auth = useFireAuth();

  if (auth.isAuthLoading) {
    return <SuspenseFallback />;
  }

  return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
};

export const useAuthContext = () => useContext(AuthContext);
```
#### 認証ユーザーの取得
```ts:getFireAuthUser.ts
import { useState, useEffect } from 'react';

import { onAuthStateChanged } from 'firebase/auth';

import { auth } from '@/config/firebase';

import type { User } from 'firebase/auth';

export const useFireAuthUser = () => {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState<boolean>(true);
  const [error, setError] = useState<Error | null>(null);

  const handleUser = (user: User | null) => {
    setUser(user);
    setIsLoading(false);
  };

  const handleError = (error: Error) => {
    setError(error);
    setIsLoading(false);
  };

  useEffect(() => {
    const unsubscribe = onAuthStateChanged(auth, handleUser, handleError);
    return unsubscribe;
  }, []);

  return { user, isLoading, error };
};
```
#### 認証ユーザーのFirestoreドキュメントの取得
認証されていてドキュメントが無ければ作成する。
[ドキュメント取得部分](#データ取得)
[ドキュメント作成部分](#データ追加)
```ts:useObserveUserDoc.ts
import { useEffect, useState } from 'react';

import type { FireUser } from '@/features/users';
import { useFireUser, useCreateFireUser } from '@/features/users';

import type { User } from 'firebase/auth';
import type { FirestoreError } from 'firebase/firestore';

export const useObserveUserDoc = (
  user: User | null
): { userDocData: FireUser | undefined; isLoading: boolean; error: FirestoreError | null } => {
  const { data: userDocData, isLoading, error } = useFireUser(user?.uid);

  const createFireUser = useCreateFireUser(user);

  const [check, setCheck] = useState(false);

  useEffect(() => {
    if (user && !isLoading && !userDocData && !check) {
      setCheck(true);
      createFireUser.mutateDTO();
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [userDocData, isLoading, user]);

  return {
    userDocData,
    isLoading,
    error,
  };
};
```

## データ取得
#### 目次
- [カスタムhook](#カスタムhook)
- [データの整形](#データの整形)

#### カスタムhook
collectionとdocumentどちらもこれで取得できる。
collectionGroupはindexとRulesの設定が必要なためそれらを済ませるのを忘れずに。
```ts:useFirestore.ts
import { useEffect, useState } from 'react';

import { onSnapshot } from 'firebase/firestore';

import { formatDoc } from '../utils/format';

import type { Query, DocumentReference, DocumentData, FirestoreError } from 'firebase/firestore';

export const useFirestore = <T>(
  docOrQuery: Query<DocumentData> | DocumentReference | undefined
) => {
  const [firestore, setFirestore] = useState<{
    data: T | undefined;
    isLoading: boolean;
    error: FirestoreError | null;
  }>({
    data: undefined,
    isLoading: true,
    error: null,
  });

  useEffect(() => {
    if (!docOrQuery) {
      return;
    }
    console.log('request');
    setFirestore((prev) => ({ ...prev, isLoading: true }));
    let unsubscribe: () => void;
    if (docOrQuery.type === 'document') {
      unsubscribe = onSnapshot(docOrQuery as DocumentReference, {
        next(doc) {
          if (doc.exists()) {
            const updatedData = formatDoc(doc) as unknown as T;
            setFirestore({ data: updatedData, isLoading: false, error: null });
          } else {
            setFirestore((prev) => ({
              ...prev,
              isLoading: false,
              error: {
                code: 'not-found',
                message: 'Document does not exist',
                name: 'FirestoreError',
              },
            }));
            unsubscribe;
          }
        },
        error(error) {
          setFirestore((prev) => ({ ...prev, isLoading: false, error: error }));
          unsubscribe;
        },
      });
    } else {
      unsubscribe = onSnapshot(docOrQuery as Query<DocumentData>, {
        next(snapshot) {
          const updatedData = snapshot.docs.map((doc) => formatDoc(doc)) as unknown as T;
          setFirestore({ data: updatedData, isLoading: false, error: null });
        },
        error(error) {
          setFirestore((prev) => ({ ...prev, isLoading: false, error: error }));
          unsubscribe;
        },
      });
    }
    return unsubscribe;
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [JSON.stringify(docOrQuery)]);

  return firestore;
};
```
このように使用する。
```ts:getPosts.ts
export const usePosts = () => {
  const posts = useFirestore<Post[]>(
    query(collectionGroup(db, 'posts'), orderBy('createdAt', 'desc'))
  );

  return posts;
};
```
```ts:getFireUser.ts
export const useFireUser = (uid: string | undefined) => {
  const fireUser = useFirestore<FireUser>(uid ? doc(collection(db, 'users'), uid) : undefined);

  return fireUser;
};
```
データが存在しない場合にエラーを投げてもらう方法が分らなかったので自分で作って投げている。
useEffectの第二引数の配列にオブジェクトを渡すと無限ループになるので文字列にしないといけない。
#### データの整形
```ts:format.ts
import { default as dayjs } from 'dayjs';

import type { Timestamp, DocumentData, QueryDocumentSnapshot } from 'firebase/firestore';

// 実際に画面に表示させる時に使う
export const formatDate = (date: string | number | Date | dayjs.Dayjs) =>
  dayjs(date).format('MMMM D, YYYY h:mm A');

export const timestampToDate = (timestamp: Timestamp) => {
  return timestamp ? timestamp.toDate() : new Date(0);
};

export const formatDoc = (doc: QueryDocumentSnapshot<DocumentData>) => {
  const data = doc.data();
  const formatedData = { id: doc.id, ...data } as any;

  'createdAt' in data && (formatedData.createdAt = timestampToDate(data.createdAt));
  'updatedAt' in data && (formatedData.updatedAt = timestampToDate(data.updatedAt));
  return formatedData;
};
```
idやタイムスタンプをフロントで扱いやすいようにしている。
timestampは一瞬だけデータがnullの瞬間があるためエラー回避のためにDate(0)を入れている。
withConverterは一度に2つ以上使用できず、不正なデータが入っていても通過してしまう等取り回しに難があったため、使わないという結論になった。
型安全はSecurity Rulesで保証すれば良い。

## データ操作
#### 目次
- [データ追加](#データ追加)
- [データ削除](#データ削除)
- [複合処理](#複合処理)

[react-query-firebase]を使用する。読み取りに関しては上手く動作しなかったがこれは普通に使えたので。
詳しいメソッドの説明は各自リンク先を参照されたし。

#### データ追加
引数が読み込まれるまでダミーのパスを入れているが気持ち悪い、もっといい感じにできるかも。
```ts:createFireUser.ts
import { useFirestoreDocumentMutation } from '@react-query-firebase/firestore';
import { collection, doc } from 'firebase/firestore';

import { db } from '@/config/firebase';

import type { User } from 'firebase/auth';

export const useCreateFireUser = (user: User | null) => {
  const createFireUserMutaion = useFirestoreDocumentMutation(
    doc(collection(db, 'users'), user ? user?.uid : '_')
  );

  const mutateDTO = () => {
    if (user) {
      console.log(user);
      const newUser = {
        displayName: user.displayName,
        email: user.email,
        role: 'USER',
      };

      createFireUserMutaion.mutate(newUser);
    }
  };

  return {
    ...createFireUserMutaion,
    mutateDTO,
  };
};
```
フォームで入力して個別の関数でデータを受け取る場合
```ts:
type CreatePostDTO = {
  data: {
    body: string;
  };
};
// ...
  const mutateDTO = (config: CreatePostDTO) => {
```

#### データ削除
```ts:deletePost.ts
import { useFirestoreDocumentDeletion } from '@react-query-firebase/firestore';
import { doc } from 'firebase/firestore';

import { db } from '@/config/firebase';

import type { Post } from '../types';

export const useDeletePost = (post: Post) => {
  const deletePostMutaion = useFirestoreDocumentDeletion(
    doc(db, 'users', post.author.path.split('/')[1], 'posts', post.id)
  );

  return deletePostMutaion;
};
```
docの参照は上記のようにも出来る。
collectionをimportしなくていいしこれで良いかもしれない。

#### 複合処理
```ts:createPostBatch.ts
export const useCreatePostTBatch = () => {
  const { user } = useFireAuth();
  const userRef = doc(collection(db, 'users'), user ? user?.uid : '_');
  const batch = writeBatch(db);
  const createPostBatch = useFirestoreWriteBatch(batch);

  const mutateDTO = (config: CreatePostDTO) => {
    const newPost = {
      body: config.data.body,
      author: userRef,
      createdAt: serverTimestamp(),
    };

    batch.set(doc(collection(userRef, 'posts')), newPost);
    batch.set(doc(collection(db, 'posts')), newPost);

    createPostBatch.mutate();
  };

  return {
    ...createPostBatch,
    mutateDTO,
  };
};
```
この例ではuserドキュメント配下とルートのpostsに対して同じデータが入る。
どちらかだけが成功するということは無い。

## Security Rules
型安全の保障とアクセス権限を制御する。
以下に汎用的なバリデーションの一部と使用例を示す。
実際に運用をすると１つのモデルに対してCRUDそれぞれに制御が必要になると思われる。
これらのチェックにはエミュが役立つので活用する。ファイルの更新も即座に反映される。
```js:firestore.rules
rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    // authenticated
    function isAuthenticated() {
      return request.auth != null;
    }
    // request user is same as resource user
    function isUserAuthenticated(userId) {
      return request.auth.uid == userId;
    }
    // request user is same as resource author
		// function isMatchUserReference(ref, userId) {
		// return ref != null && ref == get(/databases/$(database)/documents/users/$(userId)).__name__ && ref.size() > 0;
		// }

    // type check /////////////////////////////////////////////////////////////////////////
    // email
    function isValidEmail(email) {
      return email.matches('^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\\.[a-zA-Z0-9-.]+$');
    }
    // string
    function isValidString(string) {
      return string is string && string.size() > 0;
    }
    // number
    function isValidNumber(number) {
      return number is number && number.size() > 0;
    }
    // boolean
    function isValidBoolean(boolean) {
      return boolean is bool && boolean.size() > 0;
    }
    // timestamp
    function isValidTimestamp(timestampVal) {
      return timestampVal is timestamp && timestampVal.size() > 0;
    }
    // array
    function isValidArray(array) {
      return array is list;
    }
    // value in array
    function isValidInArray(array, value) {
      return array is list && array.size() > 0 && value in array;
    }
    // object
    function isValidObject(object) {
      return object is map && object.size() > 0;
    }
    // path
    function isValidPath(path) {
      return path is path && path.size() > 0;
    }
    /////////////////////////////////////////////////////////////////////////

    // validate user
    function isValidUser(user) {
      return
      isValidString(user.displayName) &&
      isValidEmail(user.email) &&
      isValidInArray(['ADMIN', 'USER'], user.role);
    }

    // validate post
    function isValidPost(post) {
      return
      isValidString(post.body) &&
      isValidTimestamp(post.createdAt);
    }

    // user
    match /users/{userId} {
    	allow read;
      allow create: if isValidUser(request.resource.data) && isAuthenticated();
      allow update: if isValidUser(request.resource.data) && isUserAuthenticated(userId);
    }

    // user's post
    match /users/{userId}/posts/{postId} {
      allow read;
      allow create: if isAuthenticated();
      allow update: if isValidPost(request.resource.data) && isUserAuthenticated(userId);
      allow delete: if isUserAuthenticated(userId);
    }

    // post group
    match /{path=**}/posts/{postId} {
      allow read;
    }
  }
}
```
#
以上。
functionsやhostingはまだ利用していないが、データ構造を考えるのに悩まされたくないのでもうRDBでいいのではないかと思ってしまっている。