見出し画像

Next.js とSupabaseで求人マッチングアプリを作る⑮~通知バッジと既読機能実装~

今回はメッセージのボタン類に置くメッセージ数や未読メッセージの通知機能と、それを可能にするための未読、既読の更新機能を作成します。

通知機能はほんの小さな表示ではありますが、あるとないとでは使い勝手や、ユーザーの利便性が大きく異なります。
また、実装は簡単そうに見えるかもしれませんが、未読数のカウント・表示、そして既読時の処理等、そこそこ工数がかかる箇所です。

では早速作り方をみてみましょう。


Supabase側の設定

trn_notification(通知テーブル)の作成

ユーザーごとに通知数を管理するためのテーブルを作成します。
添付画像のような設定で作成してください。

すべてnot nullで作成し、
user_uidは`auth`のユーザIDを外部参照するようにしてください。

Database Function作成

続いて、メッセージがテーブルに追加されるたびに通知テーブルの通知数を更新する仕組みをDatabase Functionとして作成します。

`SQL Editor`を開き新しいSQLを作成し、下記を入力してください。

CREATE OR REPLACE FUNCTION update_notification_count() 
RETURNS TRIGGER AS $$
BEGIN
    -- 未読メッセージの数をカウント
    UPDATE trn_notification
    SET badge_count = (
        SELECT COUNT(*) 
        FROM trn_apply_message 
        WHERE receiver_id = NEW.receiver_id AND read_at IS NULL AND delete_flg = FALSE
    ),
    updated_at = NOW()
    WHERE user_uid = NEW.receiver_id;

    -- 通知数が存在しない場合は新しく挿入
    INSERT INTO trn_notification (user_uid, badge_count, created_at, updated_at)
    SELECT NEW.receiver_id, COUNT(*), NOW(), NOW()
    FROM trn_apply_message 
    WHERE receiver_id = NEW.receiver_id AND read_at IS NULL AND delete_flg = FALSE
    ON CONFLICT (user_uid) 
    DO UPDATE SET 
        badge_count = EXCLUDED.badge_count,
        updated_at = NOW();
        
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;

トリガー作成

続いて、上記の関数をメッセージ追加のたびに実行するためのトリガーを作成します。
同じようにSQLでトリガーを作成します。

CREATE TRIGGER after_message_insert
AFTER INSERT ON trn_apply_message
FOR EACH ROW
EXECUTE FUNCTION update_notification_count();

メッセージ更新時にも同様に作成します。

CREATE TRIGGER after_message_insert
AFTER INSERT ON trn_apply_message
FOR EACH ROW
EXECUTE FUNCTION update_notification_count();

TypeScriptの型生成

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

npx supabase login

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

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

こちらでSupabase側の設定は完了です。

Next.jsの実装

まずはヘッダー周辺から対応します。

components/Header.tsx

メッセージのリンクに通知数を表示するため、丸々コンポーネント化しています。

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

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

    const usertype = getUserTypeFromEnv();

    let userDataFlg = false;

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

    const isUserDataExist = async () => {
        if (usertype == userType.company) {
            const {data, error } = await supabase.from("mst_company").select().eq("user_uid", user?.id)
            if (data?.length ? data?.length : 0 > 0) {
                userDataFlg = true
            }
        } else if (usertype == userType.job_seeker) {
            const {data, error } = await supabase.from("mst_job_seeker").select().eq("user_uid", user?.id)
            if (data?.length ? data?.length : 0 > 0) {
                userDataFlg = true
            }
        }
    }

    await isUserDataExist()

    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">
                    {userDataFlg ? (<>
                        <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>
                        <MessageLink user={user} />
                    </>) : null}
                    <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">
                        {userDataFlg ? (<>
                            <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>
                            <MessageLink user={user} />
                        </>) : null}

                        <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/header/MessageLink.tsx

メッセージのリンク部分です。

"use client"

import Link from "next/link";
import {User} from "@supabase/gotrue-js";
import {useEffect, useState} from "react";
import {createClient} from "@/utils/supabase/client";

type Props = {
    user: User | null;
}
export default function MessageLink({user}: Props) {
    const [count, setCount] = useState(0);
    const supabase = createClient();

    useEffect(() => {
        getCount()
    }, []);

    const getCount = async () => {
        if (user == null) return;
        const {data, error} = await supabase.from("trn_notification").select().eq("user_uid", user.id)
        if (error) return

        if (data != null && data.length > 0) {
            setCount(data[0]["badge_count"]);
        }
    }

    return (
        <Link className="text-center w-20" href="/chats">
            <div className="block">
                <span className="material-symbols-outlined">
                    chat
                </span>
                {count > 0 && (
                    <span className="inline-block absolute bg-red-600 text-xs w-4 h-4 text-white rounded-full">
                    {count > 99 ? ("99+") : (count)}
                </span>
                )}
            </div>
            メッセージ
        </Link>)
}

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 {getYearsOld} from "@/utils/jobseekerUtils";

type Props = {
    usertype: userType
}
export default function ChatView({usertype}: Props) {
    const supabase = createClient();
    const [jobSeekerJobs, setJobSeekerJobs] = useState<any[]>([]);
    const [currentJobSeekerJobID, setCurrentJobSeekerJobID] = useState<number | null>(null);
    const [currentSender, setCurrentSender] = useState<User | null>(null);
    useEffect(() => {
        getJobSeekerJob()
    }, []);

    // ユーザと結びつくjob_seeker_job IDを検索
    const getJobSeekerJob = async () => {
        let tmpUser: User | null
        if (currentSender) {
            tmpUser = currentSender
        } else {
            const {data: {user}} = await supabase.auth.getUser()
            setCurrentSender(user)
            tmpUser = user
        }

        const usertype = getUserTypeFromEnv()
        let fixedJobList: Database["public"]["Tables"]["m2m_job_seeker_job"]["Row"][] = []
        let result: any[] = []
        if (usertype == userType.job_seeker && tmpUser != null) {
            const {data, error} = await supabase.from("m2m_job_seeker_job").select().eq("job_seeker_id", tmpUser.id)
            if (error) return;

            fixedJobList = data as Database["public"]["Tables"]["m2m_job_seeker_job"]["Row"][]

        } else if (usertype == userType.company && tmpUser != null) {
            const jobList = await getJobListFromCompanyID(tmpUser.id)
            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"][]);
            }
        }

        // 求人名、求職者名取得
        for (let i = 0; i < fixedJobList.length; i++) {
            const jobList = await getJobListFromJobID(fixedJobList[i]["job_id"])
            if (jobList.length == 0) continue

            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

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

            const yearsOld = getYearsOld(tmp_data[0]["birthday"])

            let statusStr = ""

            switch (fixedJobList[i]["status"]) {
                case "selection_resume":
                    statusStr = "書類選考中"
                    break;
                case "scheduling_inteview":
                    statusStr = "面接日程調整中"
                    break;
                case "waiting_inteview_result":
                    statusStr = "面接結果待ち"
                    break;
                case "reject":
                    statusStr = "不採用"
                    break;
                case "offer":
                    statusStr = "オファー"
                    break
                case "decline_before_offer":
                    statusStr = "内定前辞退"
                    break
                case "decline_after_offer":
                    statusStr = "内定後辞退"
                    break
                case "accept_offer":
                    statusStr = "内定承諾"
                    break
            }

            const count = await getJobNotifyCount(tmpUser, fixedJobList[i]["id"])

            result.push({
                id: fixedJobList[i]["id"],
                jobName: jobList[0]["name"],
                username: tmp_data[0]["last_name"] + " " + tmp_data[0]["first_name"],
                yearsOld: yearsOld,
                status: statusStr,
                count: count
            })
        }

        setJobSeekerJobs(result)
    }

    const getJobNotifyCount = async (currentUser: User | null, job_seeker_job_id: number) => {
        if (currentUser == null) return 0

        let count = 0
        const {
            data,
            error
        } = await supabase.from("trn_apply_message").select().eq("job_seeker_job_id", job_seeker_job_id)
        if (error) return 0

        if (data != null && data.length > 0) {
            for (let i = 0; i < data.length; i++) {
                if (data[i].sender_id != currentUser?.id && data[i].read_at == null) {
                    count++
                }
            }
        }

        return count
    }

    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"][]
    }

    const [showStatusModal, setShowStatusModal] = useState<boolean>(false)
    const [currentStatus, setCurrentStatus] = useState<string>("")
    const handleStatus = async (id: string) => {
        const result = window.confirm("この求職者のステータスを変更しますか?");
        if (!result) return

        setShowStatusModal(false)

        const {error} = await supabase.from("m2m_job_seeker_job").update({status: currentStatus}).eq("id", id)
        if (error) return

        await getJobSeekerJob()
    }

    return (<div className="relative flex w-full max-w-4xl h-max min-h-screen pb-16">
        <ul className="w-3/5 bg-white">
            {jobSeekerJobs.map((item) => (
                <li className="relative border-b-gray-200 border-b" key={item.id}>
                    <button onClick={() => {
                        setCurrentJobSeekerJobID(item.id)
                    }}
                            className="relative text-left p-4 bg-white w-full hover:bg-gray-200 flex items-center justify-between">
                        <div className="w-48 font-bold text-lg">{item.jobName}<br/><span
                            className="font-normal text-base">{item.username} {item.yearsOld}歳</span></div>
                        {usertype == userType.job_seeker ? (
                            <div
                                className="relative text-sm px-1 bg-gray-300 rounded-md text-center">
                                {item.status}
                            </div>) : usertype == userType.company ? (
                            <button onClick={() => setShowStatusModal(true)}
                                    className="relative text-sm px-1 bg-gray-300 rounded-md text-center hover:bg-gray-50">
                                {item.status}
                            </button>) : null}
                        {item.count > 0 && (
                            <span
                                className="bg-red-600 w-3 h-3 rounded-full text-white text-xs absolute top-3 right-3 text-center"></span>
                        )}
                    </button>
                    {showStatusModal ? (
                        <div className="absolute top-0 right-0 p-4 z-50 border border-gray-300 bg-gray-100">
                            <div className="text-right">
                                <button onClick={() => setShowStatusModal(false)}>
                                <span className="material-symbols-outlined">
                                    close
                                </span>
                                </button>
                            </div>
                            <p>ステータスを選択してください。</p>
                            <select className="border block mb-4"
                                    onChange={(e) => setCurrentStatus(e.target.value)}
                            >
                                <option value="selection_resume" selected={item.status === "書類選考中"}>書類選考中
                                </option>
                                <option value="scheduling_inteview"
                                        selected={item.status === "面接日程調整中"}>面接日程調整中
                                </option>
                                <option value="waiting_inteview_result"
                                        selected={item.status === "面接結果待ち"}>面接結果待ち
                                </option>
                                <option value="reject" selected={item.status === "不採用"}>不採用</option>
                                <option value="offer" selected={item.status === "オファー"}>オファー</option>
                                <option value="decline_before_offer"
                                        selected={item.status === "内定前辞退"}>内定前辞退
                                </option>
                                <option value="decline_after_offer"
                                        selected={item.status === "内定後辞退"}>内定後辞退
                                </option>
                                <option value="accept_offer"
                                        selected={item.status === "内定承諾"}>内定承諾
                                </option>
                            </select>
                            <div className="w-full text-right">
                                <button className="bg-green-500 hover:bg-green-600 rounded-md px-4 py-2 text-white"
                                        onClick={() => handleStatus(item.id)}>保存
                                </button>
                            </div>
                        </div>) : null}
                </li>
            ))}
        </ul>
        <div className="w-full bg-gray-200 border-l p-4">
            <ChatList user={currentSender} job_seeker_jobid={currentJobSeekerJobID}></ChatList>
        </div>
    </div>)
}

components/chats/ChatList.tsx

下記の記事の既読管理部分を再利用して、既読の管理をしています。
https://note.com/libproc/n/nd50cc66cb314

"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);

    const [isScrolled, setIsScrolled] = useState(false)
    const [intersectionObserver, setIntersectionObserver] = useState<IntersectionObserver>()

    useEffect(() => {
        if (job_seeker_jobid == null || user == null) return
        getChatData()
        setReloadFlg(false)

        const tmpIntersectionObserver = new IntersectionObserver(intersectionObserverCallback, {
            root: null,
            rootMargin: '0px',
            threshold: 0.1
        });
        setIntersectionObserver(tmpIntersectionObserver)
    }, [job_seeker_jobid, reloadFlg]);

    // 一個目の未読メッセージまでスクロールする
    const scrollToFirstUnread = () => {
        setIsScrolled(true)
        const items = document.querySelectorAll('[data-isalreadyread]');

        const firstUnreadItem = Array.from(items).find(item => item.getAttribute("data-isalreadyread") === 'false');

        if (firstUnreadItem) {
            firstUnreadItem.scrollIntoView({behavior: "smooth", block: "start"});
        } else if (items.length > 0) {
            items[items.length - 1].scrollIntoView({
                behavior: "smooth",
                block: "start",
            });
        }
    };

    // 未読メッセージが画面に入った時のイベント
    const intersectionObserverCallback = (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
        entries.forEach(async entry => {
            if (entry.isIntersecting) {
                if (entry.target.getAttribute("data-isalreadyread") !== "true" && !entry.target.classList.contains("isMyMessage")) {
                    entry.target.setAttribute("data-isalreadyread", "true");
                    await updateChat(entry.target.id)
                }

                observer.unobserve(entry.target);
            }
        });
    };

    const addToRefs = (el: never) => {
        if (el) {
            // 要素監視のためにintersectionObserverに追加
            intersectionObserver!.observe(el);
            if (!isScrolled) {
                scrollToFirstUnread()
            }
        }
    };


    // チャットの更新処理
    const updateChat = async (id: string) => {
        try {
            const timeStamp = new Date().toISOString();
            const index = parseInt(id.split("id")[1]);
            const {error} = await supabase
                .from("trn_apply_message")
                .update({read_at: timeStamp})
                .eq("id", index);

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

    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 data as Database["public"]["Tables"]["trn_apply_message"]["Row"][]
    }

    return (<div>
        <ul>
            {chatData.map((item) => (
                <li ref={addToRefs}
                    className={user?.id == item.sender_id ? ("flex justify-end mb-2 isMyMessage") : ("flex mb-2")}
                    key={item.id}
                    data-isalreadyread={user?.id != item.sender_id && item.read_at == null ? "false" : "true"}
                    id={"id" + item.id}
                >
                    <div
                        className={user?.id == item.sender_id ? ("ml-4") : ("mr-4")}
                    >
                        <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>
                        {user?.id == item.sender_id && item.read_at != null && (
                            <div className="text-gray-500 text-xs text-right">
                                既読
                            </div>)
                        }
                    </div>

                </li>
            ))}
        </ul>
        <SendButton user={user} job_seeker_jobid={job_seeker_jobid} setReloadFlg={setReloadFlg}></SendButton>
    </div>)
}

実装の確認

企業側でメッセージを送信します。

この状態で求職者側で開くと、ヘッダーのメッセージに通知数が表示されます。


メッセージページを開くと、未読のメッセージがあるやり取りに赤丸が追加されます。

今回はリアルタイムの更新はしていないため、
メッセージを開いたうえでリロードすると、未読の通知は双方ともになくなります。

求職者側から逆にメッセージを送ってみます。

再度企業側で開くと、同じように通知が表現されていることが確認できます。

やり取りを開くと、前回のメッセージに既読というテキストが追加され、
リロード後は通知の表示がなくなることが確認できます。

その他参考資料など

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

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

お問合せ&各種リンク

presented by

サポートしていただくと、筆者のやる気がガンガンアップします!