アンケートにコメントができるサービスを作った
はじめに
世の中には決着をつけなければいけない課題がたくさんあります。
- きのこかたけのこか
- WindowかMACか
- AndroidかiPhoneか
とか
そんな論争の主戦場となる場所を作りました。
できること
- アンケートの作成
- アンケート投票
- 投票したアンケートにコメント
※どれもログインが必要です
使った技術
構成
- ホスティング: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"と記載することで、サーバー側で実行してくれる
"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は以下のようにして出し分けています。
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を宣言します。
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;
}
}
初回ログイン時に、情報を格納する処理を追記します。
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から情報が取れるようになります。
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直下に記載します。
認証が必要なページの指定だけでよい場合は、以下のようにするだけです。
export { default } from "next-auth/middleware"
export const config = {
matcher: [
"/user/:path*", //user配下のページすべて認証必須にしたい場合
"/dashboard" //dashboardのみ認証必須になります。※dashboard配下は認証されません
]
}
認証時に、そのページにアクセスする権限があるのか等の確認処理をしたい場合は、以下のようにします。
config内のmatcherで指定したパスにアクセスがあった際にcallbacks.authorizedが実行されます。trueを返せばアクセス許可、falseの場合はsignInで指定したパスにリダイレクトされます。
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系はこの方の動画がわかり安く、すごく参考になりました!
Discussion