Next.jsとSupabaseで作成したチャットアプリにお知らせ機能を作成する

※この記事はNOTE等で連載しているNext.js・Supabase開発記事の続きとなります。
以下の内容は下記記事を参照の上、お楽しみください。
https://note.com/libproc/n/n37959203cc65

今回は以前作成した[チャットアプリ](https://note.com/libproc/n/n37959203cc65)にお知らせ機能を追加したいと思います。
最終系としては添付画像のように
- 未読お知らせ数がわかる
- お知らせ一覧が表示される

ようにしたいと思います。
<img width="638.25" alt="スクリーンショット 2024-03-17 173928.png (48.8 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/17/161342/12d22572-f99c-46c1-8832-5e3b2114387a.png">

# 事前準備
こちらの記事の内容をすべて完了させておいてください。
https://note.com/libproc/n/n37959203cc65

この実装をベースに作成を進めます。

# Supabase側の設定
## 既存テーブル修正
`Chats`テーブルの`isAlreadyRead`列を`read_at`というtimestamptz型の列に変更します。
before
<img width="942" alt="スクリーンショット 2024-03-17 180759.png (17.2 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/17/161342/0a607387-726b-4a65-bae5-7a1d8998adfe.png">

after
<img width="942" alt="スクリーンショット 2024-03-17 180805.png (17.3 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/17/161342/7359d301-3cc0-407b-bc9e-e81af002168a.png">

## お知らせテーブル（notifications）作成
Supabaseダッシュボード→`Table Editor`→`New Table`をクリックし、`notifications`テーブルを作成します。
各列は添付画像のような設定で作成します。
<img width="481.5" alt="スクリーンショット 2024-03-17 181000.png (20.4 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/17/161342/7f642706-6596-49ee-bad9-3195bb5a86d4.png">
<img width="438.75" alt="スクリーンショット 2024-03-17 180933.png (19.9 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/17/161342/fcd2b1b7-ad55-4355-a58e-bc803ec7c200.png">

### ポリシー作成
`notifications`テーブルのポリシーを添付画像のように追加します。
権限は`SELECT`のみで大丈夫です。
<img width="867" alt="スクリーンショット 2024-03-17 181146.png (38.6 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/17/161342/de721821-2e15-46ae-9aed-a4e92a79a437.png">

## お知らせ管理テーブル（notificationmanager）作成
お知らせとユーザを結び付け、どのユーザがどのお知らせを見たかどうかを定義するテーブルです。
各列は添付画像のような設定で作成します。
<img width="476.25" alt="スクリーンショット 2024-03-17 182242.png (29.5 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/17/161342/857e03fc-c5d4-431b-a04f-3f6ccc6237b9.png">

uidとnotificationidはそれぞれ`profiles`テーブルと`notifications`テーブルのIDを参照したいため、
外部キーの設定を行います。
<img width="469.5" alt="スクリーンショット 2024-03-17 182256.png (21.1 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/17/161342/673e825b-10d6-49d2-91e7-bbf9deb5d249.png">

設定後のテーブルが添付画像のような見た目です
<img width="727.5" alt="スクリーンショット 2024-03-17 182535.png (42.8 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/17/161342/4796b17b-dcd3-4c70-b417-abc64acf6061.png">

### ポリシー作成
`notificationmanager`テーブルのポリシーを添付画像のように追加します。
権限は念のため`ALL`に設定しています。
<img width="865.5" alt="スクリーンショット 2024-03-17 182712.png (47.0 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/17/161342/4f3b6387-1a14-4d7c-99cc-48029f573b7a.png">

## トリガー作成
お知らせテーブルに新しいお知らせが追加されたら、お知らせ管理テーブルにも全ユーザ分の管理情報を追加するためのトリガーを作成します。

まずは下記のクエリを`SQL Editor`で実行しましょう。
```sql
CREATE OR REPLACE FUNCTION public.insert_notification_manager()
RETURNS TRIGGER AS $$
DECLARE
    user_record RECORD;
BEGIN
    -- 全ユーザーに対してループを実行
    FOR user_record IN SELECT id FROM auth.users LOOP
        -- notificationmanager テーブルに行を挿入
        INSERT INTO notificationmanager (created_at, uid, notificationid, read_at)
        VALUES (CURRENT_TIMESTAMP, user_record.id, NEW.id, NULL);
    END LOOP;
    RETURN NEW;
END;
$$ LANGUAGE plpgsql;
```
これにより`insert_notification_manager`というDatabase Functionが作成されます。
関数の内容としては全ユーザ分ループしてユーザとメッセージを結び付けた行を作成している形です。

次にこの関数を実行するためのトリガーを同じくsqlで作成します。
```sql
CREATE TRIGGER trigger_insert_notification_manager
AFTER INSERT ON notifications
FOR EACH ROW
EXECUTE FUNCTION insert_notification_manager();
```
これによりお知らせが追加されるたびに実行されるようになります。

お知らせを追加すると、
<img width="505.5" alt="スクリーンショット 2024-03-17 200836.png (9.8 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/17/161342/fc961956-6341-4583-b715-862d54b50cc1.png">

お知らせに対応した管理テーブルの行が全ユーザ分作成される
<img width="750" alt="スクリーンショット 2024-03-17 200844.png (41.6 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/17/161342/37bc342e-ac0a-4777-bdeb-ae78abce53c5.png">


ここまででSupabase側の対応は終了です。

# Next.js側の実装
## Supabaseとの連携
新しく作成したテーブルの型を生成するために下記のコマンドを実行しましょう。
まずSupabase CLIにログインします。
```
npx supabase login
```

その後型を生成します。
プロジェクトのIDは`https://supabase.com/dashboard/project/`以下の文字列です。
```
npx supabase gen types typescript --project-id "${プロジェクトのID}" --schema public > types/supabasetype.ts
```

## 既存コードの修正
### app/chats/page.tsx
チャットアプリの実装にお知らせを追加する、その他`read_at`の変更を行うなどしています。

```app/chats/page.tsx
"use client";
import SideBar from "@/components/chats/sideBar";
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
import { Database } from "@/types/supabasetype";
import { useEffect, useRef, useState } from "react";
import ChatUI from "@/components/chats/chatUI";
import NotificationList from "@/components/notify/NotificationList";

export default function Chats() {
  const supabase = createClientComponentClient();
  const [userID, setUserID] = useState("");
  const [currentToID, setCurrentToID] = useState("");
  const [profiles, setProfiles] = useState<
    Database["public"]["Tables"]["profiles"]["Row"][]
  >([]);
  const [inputText, setInputText] = useState("");
  const [messageText, setMessageText] = useState<any[]>([]);
  const [isScrolled, setIsScrolled] = useState(false);
  const scrollElement = useRef(null);
  const [intersectionObserver, setIntersectionObserver] =
    useState<IntersectionObserver>();
  const [mutationObserver, setMutationObserver] = useState<MutationObserver>();

  // 一個目の未読メッセージまでスクロールする
  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 updateChat = async (id: string) => {
    try {
      const timeStamp = new Date().toISOString();
      const index = parseInt(id.split("id")[1]);
      const { error } = await supabase
        .from("Chats")
        .update({ read_at: timeStamp })
        .eq("id", index);
      if (error) {
        console.error(error);
        return;
      }
    } catch (error) {
      console.error(error);
      return;
    }
  };

  useEffect(() => {
    getUserID();

    const tmpIntersectionObserver = new IntersectionObserver(
      intersectionObserverCallback,
      {
        root: null,
        rootMargin: "0px",
        threshold: 0.1,
      }
    );
    setIntersectionObserver(tmpIntersectionObserver);

    const tmpMutationObserver = new MutationObserver((mutations) => {
      tmpMutationObserver.disconnect();
      mutations.forEach((mutation) => {
        const element: HTMLElement = scrollElement.current!;
        element.scrollTop = element.scrollHeight;
      });
    });
    setMutationObserver(tmpMutationObserver);
  }, []);

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

  const getUserID = async () => {
    const {
      data: { user },
    } = await supabase.auth.getUser();
    if (user != null) {
      setUserID(user.id);
    }
  };

  const fetchAndMergeChats = async (toID: string) => {
    // 1つ目の配列を取得
    const { data: chats1, error: error1 } = await supabase
      .from("Chats")
      .select("*")
      .eq("uid", userID)
      .eq("toID", toID)
      .order("created_at");

    // 2つ目の配列を取得
    const { data: chats2, error: error2 } = await supabase
      .from("Chats")
      .select("*")
      .eq("uid", toID)
      .eq("toID", userID)
      .order("created_at");

    if (error1 || error2) {
      console.error("Error fetching chats", error1 || error2);
      return;
    }
    const fixed_chats1 = [];
    for (let index = 0; index < chats1.length; index++) {
      fixed_chats1.push({
        created_at: chats1[index].created_at,
        id: chats1[index].id,
        message: chats1[index].message,
        toID: chats1[index].toID,
        uid: chats1[index].uid,
        isAlreadyRead: chats1[index].isAlreadyRead,
        isMyMessage: true,
      });
    }

    const fixed_chats2 = [];
    for (let index = 0; index < chats2.length; index++) {
      fixed_chats2.push({
        created_at: chats2[index].created_at,
        id: chats2[index].id,
        message: chats2[index].message,
        toID: chats2[index].toID,
        uid: chats2[index].uid,
        isAlreadyRead: chats2[index].isAlreadyRead,
        isMyMessage: false,
      });
    }

    // 配列をマージ
    const mergedChats = [...fixed_chats1, ...fixed_chats2];

    // `created_at`でソート
    mergedChats.sort(
      (a, b) =>
        new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
    );

    return mergedChats;
  };

  const getMessages = async (toID: string) => {
    let allMessages = null;
    try {
      const data = await fetchAndMergeChats(toID);

      allMessages = data;
    } catch (error) {
      console.error(error);
    }
    if (allMessages != null) {
      setMessageText(allMessages);
    }
  };

  const fetchRealtimeData = (currentToID: string) => {
    try {
      supabase
        .channel("chats")
        .on(
          "postgres_changes",
          {
            event: "*",
            schema: "public",
            table: "Chats",
          },
          (payload) => {
            // insert時の処理
            if (payload.eventType === "INSERT") {
              const { created_at, id, message, toID, uid, read_at } =
                payload.new;
              const isUser = uid === userID && toID === currentToID;
              const isToUser = uid === currentToID && toID === userID;

              const isAlreadyRead = false;

              if (isUser || isToUser) {
                let isMyMessage = true;
                if (uid === currentToID) {
                  isMyMessage = false;
                }
                setMessageText((messageText) => [
                  ...messageText,
                  {
                    created_at,
                    id,
                    message,
                    toID,
                    uid,
                    isAlreadyRead,
                    isMyMessage,
                  },
                ]);
              }
            }
            // update時に既読マークをつける。
            if (payload.eventType === "UPDATE") {
              const { id, toID, uid } = payload.new;
              const element = document.querySelector(`#id${id} .isAlreadyRead`);
              if (element && uid === userID && toID === currentToID) {
                element.textContent = "既読";
              }
            }
          }
        )
        .subscribe();
    } catch (error) {
      console.error(error);
    }
  };

  const handleSelectUser = async (event: any) => {
    event.preventDefault();
    setIsScrolled(false);
    const toUserID = event.target.id;

    setCurrentToID(toUserID);

    await getMessages(toUserID);

    fetchRealtimeData(toUserID);
  };

  const onSubmitNewMessage = async (
    event: React.FormEvent<HTMLFormElement>
  ) => {
    event.preventDefault();
    if (inputText === "") return;
    try {
      await supabase
        .from("Chats")
        .insert({ message: inputText, uid: userID, toID: currentToID });
    } catch (error) {
      console.error(error);
      return;
    }
    setInputText("");

    mutationObserver!.observe(scrollElement.current!, { childList: true });
  };

  return (
    <div className="mt-10 container mx-auto shadow-lg rounded-lg">
      <NotificationList></NotificationList>
      <div className="flex flex-row justify-between bg-white">
        <SideBar
          profiles={profiles}
          setProfiles={setProfiles}
          handleClick={handleSelectUser}
        ></SideBar>
        <div className="w-full px-5 flex flex-col justify-between">
          <div className="flex flex-col mt-5">
            <div
              ref={scrollElement}
              id="scrollElement"
              className="overflow-y-scroll h-96"
            >
              {messageText.map((item, index) => (
                <div
                  key={index}
                  ref={addToRefs}
                  className={
                    item.isMyMessage
                      ? "flex mb-4 justify-start flex-row-reverse isMyMessage"
                      : "flex justify-start mb-4"
                  }
                  id={"id" + item.id}
                  data-isalreadyread={
                    !item.isMyMessage ? item.isAlreadyRead : ""
                  }
                >
                  <ChatUI item={item}></ChatUI>
                </div>
              ))}
            </div>
            <div className="py-5">
              <form className="w-full flex" onSubmit={onSubmitNewMessage}>
                <input
                  className="w-full bg-gray-300 py-5 px-3 rounded-xl"
                  type="text"
                  id="message"
                  name="message"
                  placeholder="type your message here..."
                  value={inputText}
                  onChange={(event) => setInputText(() => event.target.value)}
                />
                <button
                  type="submit"
                  disabled={inputText === ""}
                  className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 font-medium rounded-lg text-sm w-20 ml-2 px-5 py-2.5 text-center disabled:opacity-25"
                >
                  送信
                </button>
              </form>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}
```

実質的に下記のお知らせリストのコンポーネントを追加しているだけです。
```tsx
<NotificationList></NotificationList>
```


## お知らせ機能の実装
メインのお知らせ機能の実装を行います。
### app/notify/page.tsx
単純にお知らせのテキスト情報を表示し、表示と同時にお知らせ管理テーブルの既読フラグを更新しています。
今後改善するとしたらHTML化、markdown化などが考えられますが、一旦単純な文字列で作成しています。
```app/notify/page.tsx
"use client";
import { useSearchParams } from "next/navigation";
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
import { useEffect, useState } from "react";
import { Database } from "@/types/supabasetype";
import DateFormatter from "@/components/date";

export default function Notify() {
  const supabase = createClientComponentClient();
  const searchParams = useSearchParams();
  const notificationID = searchParams.get("id");
  const [notifications, setNotifications] = useState<any>({});

  useEffect(() => {
    updateUnreadNotification();
    getNotification();
  }, []);

  const getNotification = async () => {
    const { data: notifications, error } = await supabase
      .from("notifications")
      .select()
      .eq("id", notificationID);

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

    if (notifications.length > 0) {
      setNotifications(notifications[0]);
    }
  };

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

    if (user === null) return;

    const timeStamp = new Date().toISOString();
    await supabase
      .from("notificationmanager")
      .update({ read_at: timeStamp })
      .eq("uid", user.id)
      .eq("notificationid", notificationID);
  };
  return (
    <div className="pt-10 w-2/4 m-auto">
      <p>
        <DateFormatter timestamp={notifications.created_at}></DateFormatter>
      </p>
      <p>{notifications.message}</p>
    </div>
  );
}
```

URLパラメータに渡したIDから、今回のお知らせを取得しています。
```tsx
const searchParams = useSearchParams();
const notificationID = searchParams.get("id");

~~~~~~~~~

const { data: notifications, error } = await supabase
      .from("notifications")
      .select()
      .eq("id", notificationID);
```

既読タイミングをタイムスタンプにしてテーブルを更新しています。
```tsx
const timeStamp = new Date().toISOString();
await supabase
  .from("notificationmanager")
  .update({ read_at: timeStamp })
  .eq("uid", user.id)
  .eq("notificationid", notificationID);
```

見た目はシンプルなので特に必要ないですが下記のような形です
<img width="525.75" alt="スクリーンショット 2024-03-17 202411.png (8.5 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/17/161342/8fc966eb-e50d-4c07-b8be-c7eaeceaa457.png">

### components/notify/NotificationList.tsx
<img width="138.75" alt="スクリーンショット 2024-03-17 202708.png (7.6 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/17/161342/4967dde7-db3d-423a-a697-d6adea062b57.png">
<br>
チャットアプリ上に表示するお知らせリストのコンポーネントです。
未読のお知らせをカウントし、未読のお知らせは濃い色、既読は薄い色で表示します。

```components/notify/NotificationList.tsx
"use client";
import { createClientComponentClient } from "@supabase/auth-helpers-nextjs";
import { useEffect, useState } from "react";
import { Database } from "@/types/supabasetype";
import Link from "next/link";

export default function NotificationList() {
  const supabase = createClientComponentClient();
  const [notifications, setNotifications] = useState<any[]>([]);
  const [unreadCount, setUnreadCount] = useState<number>(0);
  const [display, setDisplay] = useState<boolean>(false);

  useEffect(() => {
    getUserData();
  }, []);
  const getUserData = async () => {
    const {
      data: { user },
    } = await supabase.auth.getUser();

    if (user === null) return;
    await getAllNotificationManager(user.id);
  };

  const getAllNotificationManager = async (userID: String) => {
    const { data: notificationManager, error } = await supabase
      .from("notificationmanager")
      .select()
      .eq("uid", userID)
      .order("id", { ascending: false });

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

    const notificationManagerList: Database["public"]["Tables"]["notificationmanager"]["Row"][] =
      notificationManager;

    await getAllNotifications(notificationManagerList);
  };

  const getAllNotifications = async (
    notificationManagerList: Database["public"]["Tables"]["notificationmanager"]["Row"][]
  ) => {
    const { data: notifications, error } = await supabase
      .from("notifications")
      .select();

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

    console.log(notifications);

    const notificationList: Database["public"]["Tables"]["notifications"]["Row"][] =
      notifications;

    // 既読フラグも含めたリスト
    const tmpNotificationList = [];
    let tmpCount = 0;

    for (let i = 0; i < notificationManagerList.length; i++) {
      const manager = notificationManagerList[i];

      for (let j = 0; j < notificationList.length; j++) {
        const notification = notificationList[j];
        if (manager.notificationid === notification.id) {
          let isAlreadyRead = true;
          if (manager.read_at == null) {
            isAlreadyRead = false;
            tmpCount += 1;
          }
          tmpNotificationList.push({
            id: notification.id,
            message: notification.message,
            isAlreadyRead: isAlreadyRead,
          });
        }
      }
    }

    setNotifications(tmpNotificationList);
    setUnreadCount(tmpCount);
  };

  const openList = () => {
    setDisplay(!display);
  };

  return (
    <div className="fixed right-5 top-5">
      <button
        onClick={openList}
        type="button"
        className="relative inline-flex items-center p-3 text-sm font-medium text-center text-white bg-blue-700 rounded-lg hover:bg-blue-800 focus:ring-4 focus:outline-none focus:ring-blue-300 dark:bg-blue-600 dark:hover:bg-blue-700 dark:focus:ring-blue-800"
      >
        <svg
          className="w-5 h-5"
          aria-hidden="true"
          xmlns="http://www.w3.org/2000/svg"
          fill="currentColor"
          viewBox="0 0 20 16"
        >
          <path d="m10.036 8.278 9.258-7.79A1.979 1.979 0 0 0 18 0H2A1.987 1.987 0 0 0 .641.541l9.395 7.737Z" />
          <path d="M11.241 9.817c-.36.275-.801.425-1.255.427-.428 0-.845-.138-1.187-.395L0 2.6V14a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V2.5l-8.759 7.317Z" />
        </svg>
        {unreadCount != 0 ? (
          <>
            <span className="sr-only">Notifications</span>
            <div className="absolute inline-flex items-center justify-center w-6 h-6 text-xs font-bold text-white bg-red-500 border-2 border-white rounded-full -top-2 -end-2 dark:border-gray-900">
              {unreadCount}
            </div>
          </>
        ) : (
          <></>
        )}
      </button>
      <ul
        className={
          display
            ? "p-3 bg-gray-100 fixed mt-2 right-0 shadow-md overflow-y-auto max-h-60"
            : "p-3 bg-gray-100 fixed mt-2 right-0 shadow-md hidden"
        }
      >
        {notifications.length == 0
          ? "お知らせは存在しません"
          : notifications.map((item, index) => (
              <li className="mt-2 w-60 truncate" key={index}>
                <Link
                  className={
                    item.isAlreadyRead ? " text-blue-400" : " text-blue-700"
                  }
                  href={"/notify?id=" + item.id}
                >
                  {item.message}
                </Link>
              </li>
            ))}
      </ul>
    </div>
  );
}
```

細かい説明は割愛しますが、お知らせの一覧コンポーネントの作成処理は、
1. ユーザIDの取得
1. ユーザIDでフィルタしたお知らせ管理テーブルを取得
1. お知らせ管理テーブルをもとに未読のお知らせをカウント
1. 既読フラグを付与したお知らせを配列で取得

という流れで行っています。

# 実装確認
Supabase、Next.jsの対応ともに終わったのでちゃんと作成できているか確認しましょう。

```
npm run dev
```
してアプリにアクセスします。

適当なユーザでログインしましょう。
<img width="317.25" alt="スクリーンショット 2024-03-17 203908.png (15.9 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/17/161342/ae9f18aa-15a4-43d2-8b06-3739a9ae55e3.png">

その後チャット画面に遷移すると右上にお知らせのアイコンが追加されています。
<img width="1436.25" alt="スクリーンショット 2024-03-17 203958.png (49.1 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/17/161342/affde169-13ce-445f-8831-39d0479e362a.png">
現状はお知らせが一つもないため、
アイコンをクリックしても何も表示されません。
<img width="161.25" alt="スクリーンショット 2024-03-17 204509.png (5.2 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/17/161342/e2036a5e-b5c9-4e8c-b2b0-c97f41724eaa.png">


なので、お知らせテーブルにデータを追加してみましょう。
※内容は適当でOKです。
<img width="475.5" alt="スクリーンショット 2024-03-17 204637.png (19.1 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/17/161342/affe1c84-92b7-41e6-9afa-9ca0bb4c0295.png">

もちろんお知らせ追加と同時にお知らせ管理テーブルにも行が追加されます。
<img width="774.75" alt="スクリーンショット 2024-03-17 204807.png (111.3 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/17/161342/94746fc7-a0e2-4898-a85b-84fd839318bc.png">

チャット画面をリロードすると右上に未読バッジが3と追加されているのがわかります。
<img width="1439.25" alt="スクリーンショット 2024-03-17 204840.png (49.7 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/17/161342/6450a504-7eb0-43a0-b07e-dba18d46614f.png">

右上のアイコンをクリックすると追加されたお知らせが表示されます。
<img width="213" alt="スクリーンショット 2024-03-17 204948.png (11.6 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/17/161342/e20e44b8-136e-423c-961c-c4eb1465c085.png">

お知らせをクリックするとお知らせのページにアクセスできます。
<img width="776.25" alt="スクリーンショット 2024-03-17 205026.png (7.7 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/17/161342/b2f0b294-30e8-49f5-8f16-c7ef15ac3832.png">

アクセス後にチャット画面に戻ると未読が減り、既読のお知らせが薄い色になっていることがわかります。
<img width="222.75" alt="スクリーンショット 2024-03-17 210203.png (11.4 kB)" src="https://img.esa.io/uploads/production/attachments/20920/2024/03/17/161342/7b131d66-2631-496b-9f96-f00b77ba0576.png">


これでお知らせ機能の作成が確認できました！

# その他参考資料など

パブリックリポジトリ
https://github.com/TodoONada/nextjs-supabase-notificationbadge

またTodoONada株式会社では、この記事で紹介した以外にも、各種チャットシステムやストレージを利用した画像アプリの作成方法についてご紹介しています。下記記事にて一覧を見ることができますので、ぜひこちらもご覧ください。
https://note.com/libproc/n/n522396165049

お問合せ：[GoogleForm](https://forms.gle/CsUChSVVJUV5BxDF8)

ホームページ：https://libproc.com

運営会社：[TodoONada株式会社](https://todoonada.co.jp/)

Twitter：https://twitter.com/Todoonada_corp

Instagram：https://www.instagram.com/todoonada_corp/

Youtube：https://www.youtube.com/@todoonada_corp/

Tiktok：https://www.tiktok.com/@todoonada_corp