Next.js とSupabaseで求人マッチングアプリを作る⑬~コミュニケーション機能の実装~

前回に続いて、今回は応募・スカウト後のコミュニケーション機能を作成します。
コミュニケーション機能は、あらゆるプロダクトで使うものになりますので、ぜひ作り方をマスターしておきましょう!
※既読機能や通知周りは、次回以降ご紹介します。

目次

Supabaseの設定

今回応募、スカウトをするにあたり必要なテーブルがいくつかあるため作成します。

trn_apply_message

送信されたメッセージを定義する`trn_apply_message`テーブルを作成します。
添付画像のような設定で列を作成しましょう。

`read_at`以外の列はすべてnullを許容せず、
job_seeker_job_id`m2m_job_seeker_job`の`id`を参照してください。
また、sender_idは`auth.user`の`id`を参照してください。

ポリシー設定

ポリシーは`ALL`かつtrueで作成します。

TypeScriptの型生成

Next.jsのプロジェクト直下でSupabaseにログインします。

npx supabase login

その後、下記を実行して今回のテーブルの追加を反映しましょう。

npx supabase gen types typescript --project-id "$PROJECT_REF" --schema public > types/supabase.ts

これでSupabase側の対応は完了です。

Next.jsの実装

まずはチャット画面全体を覆うページを作成します。

app/chats/page.tsx

import Header from "@/components/Header";
import Footer from "@/components/Footer";
import { createClient } from "@/utils/supabase/server";
import { redirect } from "next/navigation";
import ChatView from "@/components/chats/ChatView";

export default async function ChatsPage({
                                                    searchParams,
                                                }: {
    searchParams: { companyid: string };
}) {
    const supabase = createClient();

    const {
        data: { user },
    } = await supabase.auth.getUser();

    if (!user) {
        return redirect("/login");
    }

    return (
        <div className="flex-1 w-full flex flex-col items-center">
            <Header />
            <ChatView></ChatView>
        </div>
    );
}

components/Header.tsx

次にヘッダー部分に新規ページへのリンクを追加します。

import {createClient} from "@/utils/supabase/server";
import Link from "next/link";
import {redirect} from "next/navigation";
import {userType, getUserTypeFromEnv} from "@/utils/usertype";
import HeaderSearchForm from "@/components/jobseeker/HeaderSearchForm";
import Logo from "@/components/Logo";

export default async function Header() {
    const supabase = createClient();

    const usertype = getUserTypeFromEnv();

    const {
        data: {user},
    } = await supabase.auth.getUser();

    const signOut = async () => {
        "use server";

        const supabase = createClient();
        await supabase.auth.signOut();
        return redirect("/login");
    };

    return (
        <nav className="w-full p-3 text-sm border-b border-gray-300 bg-white">
            <Logo></Logo>
            {user && usertype == userType.company ? (
                <div className="flex justify-end items-center gap-4">
                    <Link className="text-center w-20" href="/myjoblist">
                        <div className="block">
                            <span className="material-symbols-outlined">
                                work
                            </span>
                        </div>
                        自社の求人
                    </Link>
                    <Link className="text-center w-20" href="/jobseekerlist">
                        <div className="block">
                                <span className="material-symbols-outlined">
                                    badge
                                </span>
                        </div>
                        求職者一覧
                    </Link>
                    <Link className="text-center w-20" href="/chats">
                        <div className="block">
                                <span className="material-symbols-outlined">
                                    chat
                                </span>
                        </div>
                        メッセージ
                    </Link>
                    <Link className="text-center w-20" href="/userpage">
                        <div className="block">
                                <span className="material-symbols-outlined">
                                    person
                                </span>
                        </div>
                        ユーザ
                    </Link>
                    <form className="content-center" action={signOut}>
                        <button
                            className="w-24 py-2 px-4 rounded-md no-underline bg-btn-background hover:bg-btn-background-hover">
                            ログアウト
                        </button>
                    </form>
                </div>
            ) : user && usertype == userType.job_seeker ? (
                <div className="w-full flex justify-between gap-4">
                    <HeaderSearchForm></HeaderSearchForm>
                    <div className="flex">
                        <Link className="text-center w-20" href="/joblist">
                        <div className="block">
                                <span className="material-symbols-outlined">
                                    work
                                </span>
                            </div>
                            求人一覧
                        </Link>
                        <Link className="text-center w-20" href="/companylist">
                            <div className="block">
                                <span className="material-symbols-outlined">
                                    apartment
                                </span>
                            </div>
                            企業一覧
                        </Link>
                        <Link className="text-center w-20" href="/chats">
                            <div className="block">
                                <span className="material-symbols-outlined">
                                    chat
                                </span>
                            </div>
                            メッセージ
                        </Link>
                        <Link className="text-center w-20" href="/userpage">
                            <div className="block">
                                <span className="material-symbols-outlined">
                                    person
                                </span>
                            </div>
                            ユーザ
                        </Link>
                        <form className="content-center" action={signOut}>
                            <button
                                className="w-24 py-2 px-4 rounded-md no-underline bg-btn-background hover:bg-btn-background-hover">
                                ログアウト
                            </button>
                        </form>
                    </div>

                </div>
            ) : (
                <div className="flex justify-end items-center gap-4">
                    <Link
                        href="/login"
                        className="w-24 py-2 px-4 rounded-md no-underline bg-btn-background hover:bg-btn-background-hover"
                    >
                        ログイン
                    </Link>
                    <Link
                        href="/signup"
                        className="w-24 py-2 px-4 rounded-md no-underline bg-btn-background hover:bg-btn-background-hover"
                    >
                        会員登録
                    </Link>
                </div>
            )}
        </nav>
    );
}

components/chats/ChatView.tsx

チャット画面の構成を作るコンポーネントです。

"use client"

import {useEffect, useState} from "react";
import {createClient} from "@/utils/supabase/client";
import {getUserTypeFromEnv, userType} from "@/utils/usertype";
import {Database} from "@/types/supabase";
import ChatList from "@/components/chats/ChatList";
import {User} from "@supabase/gotrue-js";
import SendButton from "@/components/chats/SendButton";

export default function ChatView() {
    const supabase = createClient();
    const [jobSeekerJobs, setJobSeekerJobs] = useState<any[]>([]);
    const [currentJobSeekerJobID, setCurrentJobSeekerJobID] = useState<number | null>(null);
    const [currentUser, setCurrentUser] = useState<User | null>(null);
    useEffect(() => {
        getJobSeekerJob()
    }, []);

    // ユーザと結びつくjob_seeker_job IDを検索
    const getJobSeekerJob = async () => {
        const {data: {user}} = await supabase.auth.getUser()
        setCurrentUser(user)

        const usertype = getUserTypeFromEnv()
        if (usertype == userType.job_seeker && user != null) {
            const {data, error} = await supabase.from("m2m_job_seeker_job").select().eq("job_seeker_id", user.id)
            if (error) return;

            const tmp_data = data as Database["public"]["Tables"]["m2m_job_seeker_job"]["Row"][]

            let result: any[] = []
            for (let i = 0; i < tmp_data.length; i++) {
                const jobList = await getJobListFromJobID(tmp_data[i]["job_id"])
                if (error || jobList.length == 0) continue

                result.push({id: tmp_data[i]["id"], username: jobList[0]["name"]})
            }

            setJobSeekerJobs(result)
        } else if (usertype == userType.company && user != null) {
            const jobList = await getJobListFromCompanyID(user.id)
            let fixedJobList: Database["public"]["Tables"]["m2m_job_seeker_job"]["Row"][] = []
            for (let i = 0; i < jobList.length; i++) {
                const {data, error} = await supabase.from("m2m_job_seeker_job").select().eq("job_id", jobList[i]["id"])
                if (error) continue;

                fixedJobList = fixedJobList.concat(data as Database["public"]["Tables"]["m2m_job_seeker_job"]["Row"][]);
            }

            let result: any[] = []
            for (let i = 0; i < fixedJobList.length; i++) {
                const {
                    data,
                    error
                } = await supabase.from("mst_job_seeker").select().eq("user_uid", fixedJobList[i]["job_seeker_id"])
                if (error || data == null || data.length == 0) continue

                console.log(data)
                const tmp_data = data as Database["public"]["Tables"]["mst_job_seeker"]["Row"][]
                result.push({
                    id: fixedJobList[i]["id"],
                    username: tmp_data[0]["first_name"] + " " + tmp_data[0]["last_name"]
                })
            }

            setJobSeekerJobs(result);
        }

    }

    const getJobListFromJobID = async (id: string) => {
        const {data, error} = await supabase.from("trn_job").select().eq("id", id)
        if (error) return []

        return data as Database["public"]["Tables"]["trn_job"]["Row"][]
    }

    const getJobListFromCompanyID = async (companyId: string) => {
        const {data, error} = await supabase.from("trn_job").select().eq("company_uid", companyId)

        if (error) return []
        return data as Database["public"]["Tables"]["trn_job"]["Row"][]
    }

    return (<div className="relative flex w-full max-w-4xl h-max min-h-screen pb-16">
        <ul className="w-64 max-w-64 bg-white">
            {jobSeekerJobs.map((item) => (
                <li className="border-b-gray-200 border-b" key={item.id}>
                    <button onClick={() => {
                        setCurrentJobSeekerJobID(item.id)
                        console.log(item.id)
                    }} className="text-left p-4 bg-white w-full hover:bg-gray-200">{item.username}</button>
                </li>
            ))}
        </ul>
        <div className="w-full bg-gray-200 border-l p-4">
            <ChatList user={currentUser} job_seeker_jobid={currentJobSeekerJobID}></ChatList>
        </div>
    </div>)
}

components/chats/ChatList.tsx

チャットのやり取りの、実体を表示するコンポーネントです。
送信部分もwrapして送信後のリロードをシンプルに実現しています。

"use client"
import {createClient} from "@/utils/supabase/client";
import {useEffect, useState} from "react";
import {User} from "@supabase/gotrue-js";
import {Database} from "@/types/supabase";
import SendButton from "@/components/chats/SendButton";

type Props = {
    user: User | null,
    job_seeker_jobid: number | null
}
export default function ChatList({user, job_seeker_jobid}: Props) {
    const supabase = createClient()
    const [chatData, setChatData] = useState<Database["public"]["Tables"]["trn_apply_message"]["Row"][]>([]);
    // リロードのためにトグルするやつ。
    const [reloadFlg, setReloadFlg] = useState<boolean>(false);

    useEffect(() => {
        if (job_seeker_jobid == null || user == null) return
        getChatData()
        setReloadFlg(false)
    }, [job_seeker_jobid, reloadFlg]);

    const getChatData = async () => {
        const {data, error} = await supabase.from("trn_apply_message").select().eq("job_seeker_job_id", job_seeker_jobid)

        if (error) {
            console.log(error);
            return []
        }

        setChatData(data)
    }

    return (<div>
        <ul>
            {chatData.map((item) => (
                <li className={user?.id == item.sender_id ? ("flex justify-end mb-2") : ("flex mb-2")} key={item.id}>
                    <div className={user?.id == item.sender_id ? ("inline-block rounded-md p-2 bg-green-500 text-white") : ("inline-block rounded-md p-2 bg-white")}>
                        {item.message}
                    </div>
                </li>
            ))}
        </ul>
        <SendButton user={user} job_seeker_jobid={job_seeker_jobid} setReloadFlg={setReloadFlg}></SendButton>
    </div>)
}

components/chats/SendButton.tsx

最後に送信ボタンを作成します。

import {Dispatch, SetStateAction, useState} from "react";
import {createClient} from "@/utils/supabase/client";
import {User} from "@supabase/gotrue-js";

type Props = {
    user: User | null,
    job_seeker_jobid: number | null,
    setReloadFlg: Dispatch<SetStateAction<boolean>>;
}
export default function SendButton({user, job_seeker_jobid, setReloadFlg}: Props) {
    const supabase = createClient()
    const [message, setMessage] = useState("")
    const onSubmit = async () => {
        if (user == null || job_seeker_jobid == null) return

        const {error} = await supabase
            .from('trn_apply_message')
            .insert({"sender_id": user.id, "job_seeker_job_id": job_seeker_jobid, "message": message})

        if (error) {
            console.log(error)
            return;
        }

        setReloadFlg(true)
        setMessage("")
    }
    return (
        <div className="fixed flex justify-center h-16 bottom-0 left-0 w-full">
            <div className="w-full max-w-4xl bg-gray-300">
                <div className="relative flex justify-end items-center w-full h-full p-2">
                    <input name="message" value={message} onChange={(e) => {
                        setMessage(e.target.value)
                    }} className="h-full w-4/5 rounded-md"/>
                    <button onClick={onSubmit}
                            className="ml-2 bg-green-500 hover:bg-green-600 rounded-md px-4 py-2 text-white mb-2"
                    >送信
                    </button>
                </div>
            </div>
        </div>
    )
}

実装の確認

求職者側でログインします。
前回の章で確認したように応募を行うと、
/chats以下のページに応募した求人が追加されていることがわかります。


こちらの求人にメッセージを送信してみると、添付画像のように自分のメッセージとして追加されます。

次に企業側でログインし、同じく/chats以下のページにアクセスします。
すると相手の求職者がこちらでは表示されていることがわかります。


名前部分をクリックすると送られてきたメッセージも確認できます。

同じように企業からもメッセージを送信してみましょう。


企業側からのメッセージが自分のメッセージ側に表示されたことがわかります。

ここで求職者側に戻ってリロードしてみるとこちらでもメッセージが反映されます。

基本的なメッセージのやり取り機能が実装できたことが確認できました!

その他参考資料など

弊社では『マッチングワン』という『低コスト・短期にマッチングサービスを構築できる』サービスを展開しており、今回ご紹介するコードは、その『マッチングワン』でも使われているコードとなります。
本記事で紹介したようなプロダクトを開発されたい場合は、是非お問い合わせください。

またTodoONada株式会社では、この記事で紹介した以外にも、Supabase・Next.jsを使ったアプリの作成方法についてご紹介しています。
下記記事にて一覧を見ることができますので、ぜひこちらもご覧ください!

https://note.com/embed/notes/n522396165049

お問合せ&各種リンク

presented by

  • システム開発、アプリ開発
  • マッチングアプリ開発
  • インフラ構築支援等、なんでもご相談ください。
よかったらシェアしてね!
  • URLをコピーしました!
目次