🐎

アンケートにコメントができるサービスを作った

2023/12/01に公開

はじめに

世の中には決着をつけなければいけない課題がたくさんあります。

  • きのこかたけのこか
  • WindowかMACか
  • AndroidかiPhoneか

とか
そんな論争の主戦場となる場所を作りました。
https://www.poll-gate.com/

できること

  • アンケートの作成
  • アンケート投票
  • 投票したアンケートにコメント

※どれもログインが必要です

使った技術

構成

  • ホスティング:Vercel
  • DB:PlanetScale
  • ドメイン:Cloudflare

フレームワークとか

  • NextJS(AppRouter)
  • NextAuth
  • prisma
  • react-hook-form
  • mui

今回初めて使ったものたち

PlanetScale

めっちゃ良い。
5Gも使っていいんでしょうか?使いきれません。
readを節約するための調整とかはしていないので、今後様子見て調整します。

prisma

RDBを使うのがほぼ初めてだったので、SQLの学習とどっちがいいのかはよくわかっていません。prisma.schemaを更新した際に、prisma generateとかを実行する必要がありますが、デバッグ実行中だとうまく動かないので、停止してからprisma generateしないといけないことを知らず、ここでだいぶ時間とられてしまいました。
取得したデータに型がついてくれるのはめっちゃ良い!

react-hook-form

めっちゃ良い。
最初はぱっと見わかりにくそうでしたが、使ってみると簡単。
とりあえずControllerのrenderの中にぶち込んで、fieldを{...field}みたいな形で渡せばいい感じに動く。

<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で読み込めば勝手にやってくれる。

const validationRules = {
  commentText: {
    required: "コメントを入力してください",
    minLength: { value: 1, message: "1文字以上入力してください" },
  },
};

フォームのsubmit時の実行ステータスもformState.isSubmittingで簡単に取れる!

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不要でできるので、すごく楽でした。

クライアント側
const onSubmit: SubmitHandler<Inputs> = async (data: Inputs) => {
    const result = await addComment(data); //これがサーバー側で実行される
    if ("error" in result) {
      //エラー処理
    } else {
      //正常に取得できた時の処理
    }
  };

"use server"と記載することで、サーバー側で実行してくれる

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は以下のようにして出し分けています。

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を宣言します。

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;
  }
}

初回ログイン時に、情報を格納する処理を追記します。

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から情報が取れるようになります。

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直下に記載します。
認証が必要なページの指定だけでよい場合は、以下のようにするだけです。

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で指定したパスにリダイレクトされます。

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

Discussion