2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2023-12-03

はじめに

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

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

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

できること

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

※どれもログインが必要です
screenshot.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}みたいな形で渡せばいい感じに動く。

<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

めっちゃよい。
大まかな使い方は公式ページとかほかの記事でもわかり安く書いてあるので、記載しません。

細かいところでわかりにくいところがあったので、いくつか紹介します。

情報を追加する場合

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系はこの方の動画がわかり安く、すごく参考になりました!

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?