【個人開発】コメントができるアンケートサービス

# はじめに
世の中には決着をつけなければいけない課題がたくさんあります。
- きのこかたけのこか
- WindowかMACか
- AndroidかiPhoneか

とか
そんな論争の主戦場となる場所を作りました。

https://www.poll-gate.com/

# できること
- アンケートの作成
- アンケート投票
- 投票したアンケートにコメント

※どれもログインが必要です
![screenshot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/517133/19d1a5e9-4843-74ca-1a88-b1e23c9fc607.png)

# 使った技術
#### 構成
- ホスティング：Vercel
- DB：PlanetScale
- ドメイン：Cloudflare
#### フレームワークとか
- NextJS(AppRouter)
- NextAuth
- prisma
- react-hook-form
- mui
# 今回初めて使ったものたち

話題になっている技術をなるべく採用しつつ開発をしたので、いくつか紹介します。

## PlanetScale
サーバレスDBサービス。
めっちゃ良い。
5Gも使っていいんでしょうか？使いきれません。容量で選ぶなら一択です。
readを節約するための調整とかはしていないので、今後様子見て調整します。
## prisma
みんな使ってるORM。
RDBを使うのがほぼ初めてだったので、SQLの学習とどっちがいいのかはよくわかっていません。prisma.schemaを更新した際に、prisma generateとかを実行する必要がありますが、デバッグ実行中だとうまく動かないので、停止してからprisma generateしないといけないことを知らず、ここでだいぶ時間とられてしまいました。
取得したデータに型がついてくれるのはめっちゃ良い！

## react-hook-form
フォームの入力とかバリデーションとかが簡単にできるやつ。
めっちゃ良い。
最初はぱっと見わかりにくそうでしたが、使ってみると簡単。
とりあえずControllerのrenderの中にぶち込んで、fieldを{...field}みたいな形で渡せばいい感じに動く。
```tsx
<Controller
  name="commentText"
  control={control}
  rules={validationRules.commentText}
  render={({ field, fieldState }) => (
    <TextField
      {...field}
      placeholder="コメント"
      fullWidth
      multiline
      error={fieldState.invalid}
      helperText={fieldState.error?.message}
    />
  )}
/>
```
validationも以下のように定義しておいて、Controller側のrulesで読み込めば勝手にやってくれる。
```tsx
const validationRules = {
  commentText: {
    required: "コメントを入力してください",
    minLength: { value: 1, message: "1文字以上入力してください" },
  },
};
```
フォームのsubmit時の実行ステータスもformState.isSubmittingで簡単に取れる！
```tsx
const { control, handleSubmit, formState } = useForm<Inputs>();
...
  <LoadingButton
    type="submit"
    variant="contained"
    color="secondary"
    loading={formState.isSubmitting}
    disableElevation
    sx={{ fontWeight: "bold" }}
  >
    作成
  </LoadingButton>
 ...
```
## NextJSの機能
### Server Actions
AppRouterで原則SSRで実装し、データ取得処理もServer Actionsを利用しました。
投稿フォーム系のコンポーネントだけをクライアント側で動かしています。
api不要でできるので、すごく楽でした。
```tsx:クライアント側
const onSubmit: SubmitHandler<Inputs> = async (data: Inputs) => {
    const result = await addComment(data); //これがサーバー側で実行される
    if ("error" in result) {
      //エラー処理
    } else {
      //正常に取得できた時の処理
    }
  };
```
"use server"と記載することで、サーバー側で実行してくれる
```tsx:addComment.tsx
"use server";
import { prisma } from "@/db/db";

export async function addComment(props: {
  pollId: number;
  commentText: string;
}) {
  const { pollId, commentText } = props;
  try {
    const comment = await prisma.comment.create({
     ...
    });
    return comment;
  } catch (error) {
    return { error: error };
  }
}
```
### Parallel Routes
投票してないなら投票画面、投票済みなら結果画面というように、そのユーザーの状態によって画面を出し分けています。
```
detail/[id]
├── @form
├── @guest
├── @result
├── @voted
└── layout.tsx
```
layout.tsxは以下のようにして出し分けています。
```tsx:layout.tsx
export default async function Layout({
  form,
  voted,
  guest,
  result,
  params,
}: {
  form: React.ReactNode;
  voted: React.ReactNode;
  guest: React.ReactNode;
  result: React.ReactNode;
  params: { id: string };
}) {
  const id = Number(params.id);
  if (isNaN(id)) {
    notFound();
  }
  
  const session = await getServerSession(options);
  const poll = await getPoll(id);

  const isClosed = poll.closed || (poll.deadline && poll.deadline < new Date());

　//クローズ済みのアンケートだったらリザルト画面
  if (isClosed) {
    return <section>{result}</section>;
  }

  //ログインしてなければゲスト画面
  if (!session) {
    return <section>{guest}</section>;
  }

  //投票してれば投票済み画面、そうでなければ投票画面
  const IsVoted = poll.votes.some((vote) => vote.authorId === session.user.uid);
  return <section>{IsVoted ? voted : form}</section>;
}
```
### NextAuth
めっちゃよい。
大まかな使い方は公式ページとかほかの記事でもわかり安く書いてあるので、記載しません。

https://next-auth.js.org/getting-started/example

細かいところでわかりにくいところがあったので、いくつか紹介します。
#### 情報を追加する場合
roleの情報を追加する場合の例を示します。
まず、どこかにtypeを宣言します。
```tsx:types/next-auth.d.tsx
import type { DefaultSession } from "next-auth";

declare module "next-auth" {
  interface Session {
    user: {
      role: string;
    } & DefaultSession["user"];
  }
}

declare module "next-auth/jwt" {
  interface JWT {
    role: string;
  }
}

```
初回ログイン時に、情報を格納する処理を追記します。
```tsx:auth.config.tsx
import type { NextAuthOptions } from "next-auth";
import GoogleProvider from "next-auth/providers/google";

export const options: NextAuthOptions = {
  secret: process.env.NEXTAUTH_SECRET,
  session: { strategy: "jwt" },
  providers: [
    GoogleProvider({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
  ],
  callbacks: {
    jwt: async ({ token, account }) => {
      if (account) {
	// 初回ログイン時の処理を記載する
	// roleをtokenに追加する
	if (token.email == 'example.com') {
	  token.role = 'admin';
	} else {
	  token.role = 'user';
	}
      }
      return token;
    },
    session: async ({ session, token }) => {
      // sessionを利用する場合は、sessionにもroleを追加する
      session.user.role = token.role;
      return session;
    },
  },
};
```
これで、middlewareの処理や、sessionから情報が取れるようになります。
```tsx:layout.tsxとか
export default async function Layout({
  children
}: {
  children: React.ReactNode;
}) {  
  const session = await getServerSession(options);
  const role = session.role;  
  
  return <section>{children}</section>
}
```
#### middlewareの処理
middlewareの処理を記載することで、認証が必要なページや認証時の処理を追加できます。
middlewareはsrc直下に記載します。
認証が必要なページの指定だけでよい場合は、以下のようにするだけです。
```tsx:middleware.tsx
export { default } from "next-auth/middleware"

export const config = { 
  matcher: [
    "/user/:path*", //user配下のページすべて認証必須にしたい場合
    "/dashboard" //dashboardのみ認証必須になります。※dashboard配下は認証されません
  ]
}
```
認証時に、そのページにアクセスする権限があるのか等の確認処理をしたい場合は、以下のようにします。
config内のmatcherで指定したパスにアクセスがあった際にcallbacks.authorizedが実行されます。trueを返せばアクセス許可、falseの場合はsignInで指定したパスにリダイレクトされます。
```tsx:middleware.tsx
import { withAuth } from "next-auth/middleware";

export default withAuth(
  // function middleware(req) {
  //   // callbacks.authorizedがtrueの場合、実行される
  // },
  {
    callbacks: {
      //config.matcherで指定したパスに合致する場合実行される
      //アクセスを許可する場合はtrue、許可しない場合はfalseを返す
      authorized: ({ req, token }) => {
        if (req.nextUrl.pathname.startsWith("/admin")) {
	  // admin配下のパスへのアクセスであれば、token.roleに格納されている権限がadminの場合のみtrueを返す（アクセスを許可する）
	  const roleIsAdmin = token.role === 'admin'
          return roleIsAdmin;
        }
	// 認証済みの場合のみ許可する場合は以下のようにする。
	// これがないと、configで指定したuser配下のページへのアクセスができなくなる
        return !!token;
      },
    },
    pages: {
      //callbacks.authorizedがfalseとなった場合はここで指定したパスにリダイレクトされる
      signIn: "/",
    },
  }
);

export const config = {
  matcher: ["/admin/:path*","/user/:path*"],
};

```
# 今後
- ページ切り替え実装していないので追加したい（50件までしか表示できない）
- UIが適当なところがあるのでどうにかしたい。見てわかると思いますが、あまり深く考えず、zennとかredditとかどうしているのか見ながら実装してます。
- 反映が遅いところとかあるので、修正したい。useOptimisticとかちゃんと使いたい。
- 宣伝（一番大事そうだけど一番よくわからない）
 # まとめ
ぱっと思いついて、ワンピース見ながら半月くらいで実装したので、ちょっと不格好なところもありますが、必要最低限は実装してリリースしてみました。（ワンピース見ながらめっちゃ作業進むのは自分だけではないはず）
個人開発ってスピード大事ですよね。
使ってくれる方がいるようであれば、続けていこうと思います！
 # 参考
 react-hook-formやServerActions系はこの方の動画がわかり安く、すごく参考になりました！
 
 https://www.youtube.com/channel/UCf6AGqO98eGk11nfazociVQ/videos