2024.04.17 TIL - 최종프로젝트 23일차 (supabase realtime - 1:1 채팅 로직 수정, 단체 채팅방 구현 / 트러블슈팅(vercel build에러))

Innes·2024년 4월 17일
1

TIL(Today I Learned)

목록 보기
118/147

📝 오늘 한 일

  • 1:1 채팅방 로직 전면 수정
    (테이블이 3개로 바뀌어서 전면 수정함)
  • 메시지 리스트 가져오기 hook으로 분리
  • 개인액션 등록시 단체방 생성하도록 로직 추가
  • 단체방 컴포넌트 추가, 로직 구현
  • 단체 채팅방 테스트 성공!
  • 단체방 입장로직 전면 수정 (모집상태 자동 변경하도록, 미참여 상태일때 insert할지 말지 등 경우의수 추가)

단체 채팅방 구현 성공~~~ ㅠㅠ 모집인원까지 함께 처리하려니 로직이 점점 복잡해져서 오늘 하루 종일 걸렸다.
내 계정 2개(시크릿모드 포함)랑, 다른 팀원 1명이랑 같이 채팅방 참여해서 대화 나눴다! ㅋㅋ 신기했음...


presence도 오늘 시도하려했는데 못했다.ㅠㅠ 그런데 presence를 계속 조사하다보니 내가 원했던 기능은 어쩌면 presence가 아닌 걸수도 있겠다는 생각이 들었다.
(ㅁㅁ님이 들어왔습니다, 나갔습니다 표시 하고싶었는데 presence를 계속 조사하다보니 이건 presence랑은 좀 다른 것 같다.
postgres_changes에서 "chat_participants"테이블에 로그인유저 uid가 insert되는걸 추적하는 방식이 더 비슷한 것 같음)


비슷하게, push알림 할 때도 postgres_changes에서 "community_comments" 테이블에 filter(로그인유저 uid)해서 로그인유저 uid에게 쓰여진 comment가 insert될 때만 구독하는 방식으로 하면 될 듯 하다.



로직 수정에 대한 고민

1:1 채팅방

1. 이미 있는 방인지 먼저 확인하기

  • 참가자 테이블 접근 -> 로그인 유저 uid로 내가 참여중인 방의 room_id 리스트 뽑기
  • 채팅방 테이블 접근 -> room_id리스트 중 room_id 일치 + room_type이 '개인'인 것 + action_id 일치하는것 뽑기
    (room_id 일치하는 것만 가져오면 혹시 단체방일수도 있음 - 내가 액션에 참여신청 해놨어서 단체방에도 들어가있지만, 1:1 문의 하고싶을수도 있기 때문)

1) 이미 방이 있으면 -> room_id 반환
2) 방 없으면 -> chat_rooms_info테이블, chat_participants 테이블에 insert하기 -> room_id 반환

2. 반환받은 room_id를 1:1채팅 모달에 넘겨주기

-> channel명을 room_id로 설정하기


단체 채팅방

1. 채팅방 입장시

개인 액션 등록할 때 단체방도 같이 insert하도록 로직 수정완료
(type '단체'도 넣어놓기)

2. 채팅방 나가기 로직

1) 채팅방 인원 파악(참여자 테이블에서 length)
-> 채팅방 인원 === 모집인원인 경우 (현재 모집마감 상태임)

-> 내가 나가면 '모집중'으로 바꿔야됨
(채팅방 인원 !== 모집인원 인 경우 이미 is_recruiting이 false일 것이기 때문에 굳이 접근할 필요가 없음)

-> action_id들고 individaul_green_action 테이블 접근

-> is_recruiting을 true로 바꿔주기

2) 참가자 테이블에서 로그인유저 uid의 행 삭제


1:1 채팅방

// ✅ page.tsx (individualAction/detail/[id])

  // 1:1 채팅방 room_id 담는 Ref
  const privateRoomIdRef = useRef("");

  // 1:1 채팅방 모달 열기
  const handleOpenPrivateChatRoom = async () => {
    // TODO 로그인한 유저가 액션장이면 1:1채팅하기 버튼 안보이게 or 문구 수정
    // 본인이 방장인 경우, '1:1채팅 목록 확인' 이런식으로 버튼 이름 바꿔야겠어
    // 누르면 목록 보여주는 모달창 여는 로직 -> 채팅방 클릭시 채팅방 모달창 open

    // 1. 이미 1:1 채팅방이 존재하는지 먼저 확인 - 이미 있으면 string값, 없으면 null값 반환
    const exited_room_id = await checkPrivateChatRoomExist({
      user_uid,
      action_id: params.id,
    });

    // 1) exited_room_id가 있으면 (1:1채팅방 이미 열려있는 경우) -> 모달에 전달
    // privateRoomIdRef에 room_id 설정 -> 1:1채팅 모달 props로 넘겨주기
    if (exited_room_id) {
      // privateRoomIdRef에 room_id 설정
      privateRoomIdRef.current = exited_room_id;

      // 채팅방 모달창 open
      onPrivateChatOpen();
      return; // 함수 종료
    }

    // 2) exited_room_id가 없으면 (1:1채팅방 아직 안열린 경우)
    // -> chat_rooms_info 테이블, chat_participants 테이블에 insert하기 -> room_id 반환
    const new_room_id = await insertNewPrivateChatRoom({
      action_id: params.id,
      loggedInUserUid: user_uid,
    });

    // privateRoomIdRef에 room_id 설정
    if (new_room_id) {
      privateRoomIdRef.current = new_room_id;
    }

    // 채팅방 모달창 open
    onPrivateChatOpen();
  };
// ✅ PrivateChat.tsx

  useEffect(() => {
    const subscription = supabase
      .channel(`${roomId}`)
      .on(
        "postgres_changes",
        { event: "INSERT", schema: "public", table: "chat_messages" },

        // 채팅 리스트 무효화 성공 - 리스트 전체를 무효화 (수정 필요)
        (payload) => {
          queryClient.invalidateQueries({
            queryKey: [QUERY_KEY_MESSAGES_LIST],
          });
        },
      )
      .subscribe();

    return () => {
      subscription.unsubscribe();
    };
  }, []);

  const { messagesList, isLoading, isError } = useGetMessagesList({
    roomId,
    loggedInUserUid,
  });

  if (isLoading) {
    <div>Loading</div>;
  }
  if (isError || messagesList === undefined) {
    <div>Error</div>;
  }

  // 메시지 보내기 핸들러
  const handleSendMessage = async () => {
    if (message === "") return;
    setMessage(""); // 메시지를 전송한 후에 입력 필드의 값을 비움

    await sendMessage({
      sender_uid: loggedInUserUid,
      room_id: roomId,
      content: message,
    });
  };
// ✅ privateChat-api.ts

// 1. 이미 1:1방 존재하는지 먼저 확인하기
export const checkPrivateChatRoomExist = async ({
  user_uid,
  action_id,
}: {
  user_uid: string;
  action_id: string;
}) => {
  try {
    // 1) 참가자 테이블 접근 -> 로그인 유저 uid로 내가 참여중인 방의 room_id 리스트 뽑기
    const { data: roomsList, error: roomsListError } = await supabase
      .from("chat_participants")
      .select("room_id")
      .eq("participant_uid", user_uid);

    if (roomsListError) {
      console.log("error", roomsListError.message);
      throw roomsListError;
    }

    // 2) roomsList에서 room_id 리스트 추출
    const roomIds = roomsList?.map((room) => room.room_id) || [];

    // 3) 채팅방 테이블 접근 -> room_id리스트 중 room_id 일치 + room_type이 '개인'인 것 + action_id 일치하는것 뽑기
    const { data: room_id, error: roomIdError } = await supabase
      .from("chat_rooms_info")
      .select("id")
      .in("id", roomIds) // roomsList에서 가져온 room_id 리스트 중에 포함되는 것만 선택
      .eq("room_type", "개인")
      .eq("action_id", action_id);

    if (roomIdError) {
      console.log("error", roomIdError.message);
      throw roomIdError;
    }

    if (room_id && room_id.length > 0) {
      return room_id[0].id; // room_id 값이 있으면 해당 값 반환 - 이미 1:1 방이 있는 경우
    } else {
      return null; // 값이 없으면 null 반환 - 아직 1:1방이 열리지 않은 경우
    }
  } catch (error) {
    console.error("error >>", error);
    throw error;
  }
};

// 2. 열려있는 채팅방이 없는 경우 - 채팅방 테이블, 참가자 테이블에 insert
export const insertNewPrivateChatRoom = async ({
  action_id,
  loggedInUserUid,
}: {
  action_id: string;
  loggedInUserUid: string;
}) => {
  try {
    // 1) 채팅방 테이블에 insert
    // 2) action_id로 owner_id 파악하여 함께 insert
    // 3) room_id 반환

    // owner_id 가져오기
    const { data: ownerId, error: ownerIdError } = await supabase
      .from("individual_green_actions")
      .select("user_uid")
      .eq("id", action_id);

    if (ownerIdError) {
      console.log("error", ownerIdError.message);
      throw ownerIdError;
    }

    const actionOwnerId = ownerId[0].user_uid;

    // 채팅방 insert, room_id 반환
    if (actionOwnerId !== null) {
      const { data: roomId, error: insertRoomError } = await supabase
        .from("chat_rooms_info")
        .insert({
          owner_uid: actionOwnerId,
          action_id,
          room_type: "개인",
        })
        .select("id");

      if (insertRoomError) {
        console.log("error", insertRoomError.message);
        throw insertRoomError;
      }
      const privateChatRoom_id = roomId[0]?.id;

      // 4) 참가자 테이블에 insert - 참가자 본인과, 방장도 함께
      const { error: insertParticipantError } = await supabase
        .from("chat_participants")
        .insert([
          {
            room_id: privateChatRoom_id,
            participant_uid: loggedInUserUid,
            participant_type: "참가자",
          },
          {
            room_id: privateChatRoom_id,
            participant_uid: actionOwnerId,
            participant_type: "방장",
          },
        ]);

      if (insertParticipantError) {
        console.log("error", insertParticipantError.message);
        throw insertParticipantError;
      }

      return privateChatRoom_id;
    }
  } catch (error) {
    console.error("error >>", error);
    throw error;
  }
};

// 메시지 보내기
export const sendMessage = async ({
  sender_uid,
  room_id,
  content,
}: {
  sender_uid: string;
  room_id: string;
  content: string;
}) => {
  const { error } = await supabase.from("chat_messages").insert({
    sender_uid,
    room_id,
    content,
  });

  if (error) {
    console.log("error", error.message);
  }
};

단체 채팅방

// ✅ page.tsx (individualAction/detail/[id])

  // 단체 채팅방 room_id 담는 Ref
  const groupRoomIdRef = useRef("");

  // 🧡 단체 채팅방 클릭 핸들러 - 로직이 복잡해서 시간이 오래걸렸다.ㅠㅠ
  const handleOpenGroupChatRoom = async () => {
    const action_id = params.id;

    // 단체 채팅방 room_id 가져오기
    const room_id = await getChatRoomId(action_id);
    groupRoomIdRef.current = room_id;

    // 채팅에 참여중인지 여부 확인(참여중이면 id값 있음 / 미참여 상태이면 null)
    const participant_id = await checkUserExist({
      room_id,
      loggedInUserUid: user_uid,
    });

    // 이미 참여중인 경우 처리
    if (participant_id) {
      onGroupChatOpen();
      return;
    }

    // 현재 채팅방 인원 가져오기
    const participantsNumber = await countParticipants(room_id);

    // action 모집인원 가져오기
    const recruitingNumber = await getRecruitingNumber(room_id);

    // 채팅인원 === 모집인원 -> alert띄우기
    if (participantsNumber === recruitingNumber) {
      alert("모집마감 되었습니다.");
      return;
    }

    // 채팅인원 < 모집인원 -> 참가자 테이블에 insert
    if (participantsNumber < recruitingNumber) {
      await insertNewParticipant({
        room_id,
        loggedInUserUid: user_uid,
      });
    }

    // 채팅인원 +1(내가 참여했으니까) === 모집인원 -> '모집마감' 처리
    if (participantsNumber + 1 === recruitingNumber) {
      await changeRecruitingState({ action_id, mode: "in" });
    }

    // 채팅방 모달창 open
    onGroupChatOpen();
  };
// ✅ GroupChat.tsx

  useEffect(() => {
    const messageSubscription = supabase
      .channel(`${roomId}`)
      .on(
        "postgres_changes",
        { event: "INSERT", schema: "public", table: "chat_messages" },

        // 채팅 리스트 무효화 성공 - 리스트 전체를 무효화 (수정 필요)
        (payload) => {
          queryClient.invalidateQueries({
            queryKey: [QUERY_KEY_MESSAGES_LIST],
          });
        },
      )
      .subscribe();

    return () => {
      messageSubscription.unsubscribe();
    };
  }, []);

  const { messagesList, isLoading, isError } = useGetMessagesList({
    roomId,
    loggedInUserUid,
  });

  if (isLoading) {
    <div>Loading</div>;
  }
  if (isError || messagesList === undefined) {
    <div>Error</div>;
  }

  // 메시지 보내기 핸들러
  const handleSendMessage = async () => {
    if (message === "") return;
    setMessage(""); // 메시지를 전송한 후에 입력 필드의 값을 비움

    await sendMessage({
      sender_uid: loggedInUserUid,
      room_id: roomId,
      content: message,
    });
  };

  // action 참여 취소 핸들러
  const handleCancelParticipate = async (onClose: () => void) => {
    const isConfirm = window.confirm("참여를 취소하시겠습니까?");
    if (isConfirm) {
      // 1. 채팅방 인원 === 모집인원 인지 확인하기
      // (맞으면 내가 나갔을때 '모집중'으로 바꿔야 함)

      // 현재 채팅방 인원 가져오기
      const participantsNumber = await countParticipants(roomId);

      // action 모집인원 가져오기
      const recruitingNumber = await getRecruitingNumber(roomId);

      if (participantsNumber === recruitingNumber) {
        await changeRecruitingState({ action_id: actionId, mode: "out" });
      }

      // 2. 참가자 테이블에서 삭제
      await deleteParticipant(loggedInUserUid);
    }
    onClose();
  };
// ✅ groupChat-api.ts (중요한 것만)

// 채팅방 인원 파악
export const countParticipants = async (room_id: string) => {
  try {
    const { data: participants, error: participantsNumberError } =
      await supabase
        .from("chat_participants")
        .select("id")
        .eq("room_id", room_id);

    if (participantsNumberError) {
      console.log("participantsNumberError", participantsNumberError.message);
      throw participantsNumberError;
    }

    const participantsNumber = participants.length;

    return participantsNumber;
  } catch (error) {
    console.error("error >>", error);
    throw error;
  }
};

// 모집인원 파악
export const getRecruitingNumber = async (room_id: string) => {
  try {
    const { data: recruiting, error: recruitingNumberError } = await supabase
      .from("chat_rooms_info")
      .select("individual_green_actions(recruit_number)")
      .eq("id", room_id);

    if (recruitingNumberError) {
      console.log("recruitingNumberError", recruitingNumberError.message);
      throw recruitingNumberError;
    }

    const recruitingNumber =
      recruiting[0]?.individual_green_actions?.recruit_number || 0;

    return recruitingNumber;
  } catch (error) {
    console.error("error >>", error);
    throw error;
  }
};

// action의 모집상태 변경하기(채팅방 인원 === 모집인원 일때)
// 1) mode가 'in'(단체방에 참가하려는 경우) - '모집마감'처리
// 2) mode가 'out'(단체방에서 나가려는 경우) - '모집중'처리
export const changeRecruitingState = async ({
  action_id,
  mode,
}: {
  action_id: string;
  mode: string;
}) => {
  try {
    const is_recruiting = mode === "in" ? false : true;

    const { error } = await supabase
      .from("individual_green_actions")
      .update({ is_recruiting })
      .eq("id", action_id);

    if (error) {
      console.log("error", error.message);
      throw error;
    }
  } catch (error) {
    console.error("error >>", error);
    throw error;
  }
};

🏹 트러블슈팅

문제

  • dev에 merge했을 뿐인데 왜 build를 하는거지...? 아마 main에 머지하기 전에 미리 build해서 에러나는거 미리 확인할 수 있게 도와주는 vercel의 친절한 기능인 것 같다. 흠

원인

  • 자꾸 타입에러가 나서 해당 부분 작성한 팀원과 이야기 나눠본 결과, 코드에서 null관련된 타입에러가 났을때 본인 코드에서 처리한게 아니라supabase.d.ts 파일을 직접 수정했다고 한다.

  • 근데 나는 새로운 테이블을 추가한게 있어서 supabase.d.ts 파일을 supabase에서 다운받아 업데이트 해줬던 것이다.

  • 결국, supabase 테이블의 column 타입과 작성한 코드의 타입이 일치하지 않아서 생긴 문제였다.

해결

  • 팀원들이 함께 모여서 supabase테이블에서 is Nullable처리되어있는 column들 확인하고, 전부 is Nullable을 체크 해제했다.

  • 그리고 앞으로는 supabase.d.ts파일을 직접 수정하지 말고, 코드 상에서 처리를 하거나, 아니면 supabase 테이블의 타입 자체를 바꿔주는 식으로 진행하자고 이야기 나눴다.

profile
무서운 속도로 흡수하는 스폰지 개발자 🧽

0개의 댓글