background
aurora5분

프로젝트 초대 기능

2025년 9월 6일

요즘 현생 이슈로 인해 프로젝트 진행을 많이 하지 못했었는데 주말이기도 해서 오랜만에 들어왔다.. 우선 오늘 한 작업은 간단하게 사용자 초대 관련 로직을 작성하였다.

초대 코드 생성

aurora 의 경우 다른 사용자를 초대하는 방법이 2가지가 존재하는데 먼저 초대 코드를 통해서 하는 방법과 사용자가 직접 서버의 도메인과 이름을 입력하고 들어오는 방법이 있다. 그 중 초대 코드를 통한 초대를 먼저 작성해보았다.

useCreateInviteCode

typescript
// 초대 코드 생성
export const useCreateInviteCodeApi = (serverUrl: string) => {
  return useApi<InviteCode, void>({
    endpoint: `/ex/servers/${serverUrl}/invite`,
    method: "POST",
    axiosInstance: expressClient,
  });
};

다음과 같이 초대 코드를 생성하는 API 통신을 작성하고

useCreateInviteCodeMutation

typescript
// ✅ Mutation: 초대 코드 생성
export const useCreateInviteCodeMutation = (serverUrl: string) => {
  const { execute: createInviteCode } = useCreateInviteCodeApi(serverUrl);

  return useMutation({
    mutationFn: async () => {
      const result = await createInviteCode();
      return result;
    },
    onSuccess: (data) => {
      console.log("🎉 초대 코드 생성 성공:", data);
    },
    onError: (error) => {
      console.error("❌ 초대 코드 생성 실패:", error);
    },
  });
};

post 요청인 코드 생성을 mutation 으로 정의하였다.

InviteCodePage

typescript
"use client";

import React, { useState, useEffect } from "react";
import { useCreateInviteCodeMutation } from "@/app/(server-setup)/hooks/useServerMutation";
import { useCurrentServerInfo } from "@/app/(server-setup)/hooks/useServer";

const InvitationsPage = () => {
  const [inviteLink, setInviteLink] = useState<string>("");
  const [isLoading, setIsLoading] = useState(true);
  const [hasGenerated, setHasGenerated] = useState(false);

  const serverUrl = useCurrentServerInfo()?.serverUrl;
  const createInviteCodeMutation = useCreateInviteCodeMutation(serverUrl || "");

  useEffect(() => {
    const generateInviteLink = async () => {
      try {
        const result = await createInviteCodeMutation.mutateAsync();
        console.log("생성된 초대 링크:", result);
        if (result) {
          const link = String(result.inviteLink);
          localStorage.setItem("inviteLink", link);
          setInviteLink(link);
        }
      } catch (error) {
        console.error("초대 링크 생성 실패:", error);
      } finally {
        setIsLoading(false);
        setHasGenerated(true);
      }
    };

    // serverUrl이 있고 아직 생성하지 않았을 때만 API 호출
    if (serverUrl && !hasGenerated) {
      generateInviteLink();
    } else if (!serverUrl) {
      setIsLoading(false);
    }
  }, [serverUrl]); // serverUrl이 준비되면 한 번만 실행

  const handleCopyInvite = async () => {
    try {
      await navigator.clipboard.writeText(inviteLink);
      alert("초대 링크가 클립보드에 복사되었습니다!");
    } catch (error) {
      alert("초대 링크 복사에 실패했습니다.");
    }
  };

  if (isLoading) {
    return (
      <div className="flex-1 bg-gray-900 p-6">
        <div className="flex items-center justify-center h-64">
          <div className="text-gray-400">초대 링크를 생성하는 중...</div>
        </div>
      </div>
    );
  }

  return (
    <div className="flex-1 bg-gray-900 p-6">
      {/* 헤더 */}
      <div className="mb-6">
        <h1 className="text-2xl font-bold text-white mb-2">초대 링크</h1>
        <p className="text-gray-400">
          아래 링크를 공유하여 새로운 멤버를 서버에 초대하세요.
        </p>
      </div>

      {/* 초대 링크 카드 */}
      {inviteLink && (
        <div className="bg-gray-800 rounded-lg border border-gray-700 p-6">
          <div className="mb-4">
            <h3 className="text-lg font-medium text-white mb-2">
              서버 초대 링크
            </h3>
            <p className="text-gray-400 text-sm">
              이 링크를 사용하여 다른 사람들을 서버에 초대할 수 있습니다.
            </p>
          </div>

          <div className="flex items-center space-x-3">
            <code className="bg-gray-700 px-4 py-3 rounded text-sm text-gray-300 flex-1 truncate">
              {inviteLink}
            </code>
            <button
              onClick={handleCopyInvite}
              className="px-4 py-3 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
            >
              복사
            </button>
          </div>
        </div>
      )}

      {!inviteLink && !isLoading && (
        <div className="text-center py-12">
          <div className="text-red-400">초대 링크 생성에 실패했습니다.</div>
        </div>
      )}
    </div>
  );
};

export default InvitationsPage;

그 후 초대 코드를 생성하는 페이지에서 해당 Mutation 을 통해 초대 코드를 생성하도록 하였다.

image.png

image.png

다음과 같이 해당 페이지에 접근할 경우 초대코드를 바로 생성할 수 있게 되었다.

직접 가입

이제 사용자가 직접 가입 신청을 하는 경우인데 먼저 구상한 계획은 따로 접근할 경로를 만드는 것이 아닌 서버 도메인과 이름을 입력하는 상황에서 서버에 가입되어 있지 않다면 서버 승인 대기 페이지로 라우팅 하도록 계획을 하였다. 우선 유틸 함수를 정의해 주었다.

ServerAccessUtil

typescript
import { ServerListItem } from "../types/Server";

/**
 * 서버 접근 권한 확인 유틸리티
 */
export const checkServerAccess = (
  serverList: ServerListItem[],
  serverUrl: string,
  serverName: string
): boolean => {
  return serverList.some(
    (server: ServerListItem) =>
      server.serverUrl === serverUrl || server.serverName === serverName
  );
};

/**
 * 승인 대기 페이지 URL 생성
 */
export const createPendingPageUrl = (
  serverUrl: string,
  serverName: string
): string => {
  return `/pending?serverUrl=${encodeURIComponent(
    serverUrl
  )}&serverName=${encodeURIComponent(serverName)}`;
};

/**
 * 채널 타입에 따른 경로 결정
 */
export const getChannelPath = (channelKind: string): string => {
  switch (channelKind) {
    case "voice":
      return "voice_channels";
    case "text":
    case "notice":
    default:
      return "channels";
  }
};

/**
 * 채널 URL 생성
 */
export const createChannelUrl = (
  serverUrl: string,
  projectPk: number,
  channelName: string,
  channelKind: string
): string => {
  const encodedChannelName = encodeURIComponent(channelName);
  const channelPath = getChannelPath(channelKind);
  return `/${serverUrl}/projects/${projectPk}/${channelPath}/${encodedChannelName}`;
};

해당 유틸 파일은 대기 페이지의 URL 을 생성하는 기능 뿐 만아니라 채널 서버 접근 권한에 따른 라우팅에 관여하는 파일이다.

useServerJoinStatusApi

typescript
export const useServerJoinStatusApi = (serverUrl: string) => {
  return useApi<ServerAccess[], void>({
    endpoint: `/ex/servers/${serverUrl}/join`,
    method: "POST",
    axiosInstance: expressClient,
  });
};

useServerAccessApi

typescript
export const useServerAccessApi = (serverUrl: string) => {
  return useApi<ServerAccess[], void>({
    endpoint: `/ex/servers/${serverUrl}/pending`,
    method: "GET",
    axiosInstance: expressClient,
  });
};

usePatchServerAccessApi

typescript
export const usePatchServerAccessApi = (serverUrl: string) => {
  return useApi<ServerAccess, { status: ServerStatus; userEmail: string }>({
    endpoint: `/ex/servers/${serverUrl}/members`,
    method: "PATCH",
    axiosInstance: expressClient,
  });
};

사용자의 입장에서의 가입 요청과 관리자의 입장에서의 승인 여부를 결정하는 API 이다.

useServerAccessQuery

typescript
export const useServerAccessQuery = (
  serverUrl: string,
  options?: { enabled?: boolean }
) => {
  const { execute: getServerAccess } = useServerAccessApi(serverUrl);

  return useQuery({
    queryKey: ["serverAccess", serverUrl],
    queryFn: () => getServerAccess(),
    enabled: !!serverUrl && options?.enabled !== false, // serverUrl이 있고 enabled가 false가 아닐 때만 실행
    staleTime: 5 * 60 * 1000, // 5분간 fresh
    gcTime: 10 * 60 * 1000, // 10분간 캐시 유지
  });
};

useServerJoinStatusQuery

typescript
// 🔄 Query: 사용자 본인의 서버 가입 상태 조회 (POST) - 승인 대기 페이지용
export const useServerJoinStatusQuery = (
  serverUrl: string,
  approvalStatus?: "pending" | "approved" | "rejected" | "checking"
) => {
  const { execute: getServerJoinStatus } = useServerJoinStatusApi(serverUrl);

  return useQuery({
    queryKey: ["serverJoinStatus", serverUrl],
    queryFn: () => getServerJoinStatus(),
    enabled: !!serverUrl && approvalStatus !== "approved", // serverUrl이 있고 승인되지 않았을 때만 실행
    staleTime: 0, // 항상 최신 데이터 확인
    gcTime: 1 * 60 * 1000, // 1분간 캐시 유지
    refetchInterval: 5000, // 5초마다 자동 refetch
    refetchIntervalInBackground: false, // 백그라운드에서는 refetch 안함
  });
};

usePatchServerAccessMutation

typescript
// ✅ Mutation: 서버 접근 권한 수정 (PATCH)
export const usePatchServerAccessMutation = (serverUrl: string) => {
  const { execute: patchServerAccess } = usePatchServerAccessApi(serverUrl);

  return useMutation({
    mutationFn: async ({
      status,
      userEmail,
    }: {
      status: ServerStatus;
      userEmail: string;
    }) => {
      const result = await patchServerAccess({ status, userEmail });
      return result;
    },
    onSuccess: (data) => {
      console.log("🎉 서버 접근 권한 수정 성공:", data);
    },
    onError: (error) => {
      console.error("❌ 서버 접근 권한 수정 실패:", error);
    },
  });
};

그 후 서버 가입 신청 및 승인에 관련된 쿼리문을 작성해주었다.

useAdmin

typescript
import { useParams } from "next/navigation";
import { useCurrentServerInfo } from "@/app/(server-setup)/hooks/useServer";
import {
  useServerAccessQuery,
  usePatchServerAccessMutation,
  useServerListQuery,
} from "@/app/(server-setup)/hooks/useServerMutation";
import { useMemo, useState, useCallback } from "react";
import { ServerAccess } from "@/app/(servers)/types/ServerAccess";

// JoinRequest 타입 정의
export interface JoinRequest {
  id: string;
  userName: string;
  userAvatar?: string;
  message: string;
  requestDate: string;
  userEmail: string;
  status: "pending" | "approved" | "rejected";
}

// ServerAccess를 JoinRequest로 변환하는 함수
const mapServerAccessToJoinRequest = (
  serverAccess: ServerAccess
): JoinRequest => {
  const statusMap: Record<string, "pending" | "approved" | "rejected"> = {
    Pending: "pending",
    Approved: "approved",
    Banned: "rejected",
  };

  return {
    id: serverAccess.userInfo.user_email,
    userName: serverAccess.userInfo.user_name,
    userAvatar: serverAccess.userInfo.profile_image_path || undefined,
    message: `${serverAccess.userInfo.user_name}님이 서버 가입을 요청했습니다.`,
    requestDate: new Date().toISOString(),
    status: statusMap[serverAccess.status] || "pending",
    userEmail: serverAccess.userInfo.user_email || "",
  };
};

// 관리자 사이드바 훅
export const useAdminSidebar = () => {
  const params = useParams();
  const serverUrl = params.server_id as string;

  const serverInfo = useCurrentServerInfo();
  const serverName = serverInfo?.serverName;

  // 서버 목록을 조회하여 현재 사용자의 role 확인
  const serverListQuery = useServerListQuery(true);

  // 현재 서버에서의 사용자 role 찾기
  const currentServerRole = serverListQuery.data?.find(
    (server) => server.serverUrl === serverInfo?.serverUrl
  )?.serverRole;

  // 관리자 권한 확인 (owner 또는 admin)
  const isAdmin =
    currentServerRole === "owner" || currentServerRole === "admin";

  const {
    data: serverAccessList = [],
    isLoading,
    error,
    refetch,
  } = useServerAccessQuery(serverUrl, {
    enabled: isAdmin, // 관리자 권한이 있을 때만 API 호출
  });

  const pendingRequestsCount = useMemo(() => {
    return (
      serverAccessList?.filter((access) => access.status === "Pending")
        .length || 0
    );
  }, [serverAccessList]);

  return {
    serverUrl,
    serverName,
    isLoading: isAdmin ? isLoading : false,
    error: isAdmin ? error : null,
    refetch,
    pendingRequestsCount,
    isAdmin, // 권한 정보도 반환
  };
};

// 가입 요청 아이템 훅
export const useJoinRequestItem = (
  request: JoinRequest,
  onApprove: (requestId: string) => Promise<void>,
  onReject: (requestId: string) => Promise<void>,
  onSelect?: (requestId: string, selected: boolean) => void
) => {
  const [isProcessing, setIsProcessing] = useState(false);

  const handleApprove = useCallback(async () => {
    setIsProcessing(true);
    try {
      await onApprove(request.id);
    } finally {
      setIsProcessing(false);
    }
  }, [request.id, onApprove]);

  const handleReject = useCallback(async () => {
    setIsProcessing(true);
    try {
      await onReject(request.id);
    } finally {
      setIsProcessing(false);
    }
  }, [request.id, onReject]);

  const handleCheckboxChange = useCallback(
    (e: React.ChangeEvent<HTMLInputElement>) => {
      onSelect?.(request.id, e.target.checked);
    },
    [request.id, onSelect]
  );

  const getStatusBadge = useCallback(() => {
    switch (request.status) {
      case "approved":
        return { text: "✅ 승인됨", className: "text-green-400 text-sm" };
      case "rejected":
        return { text: "❌ 거절됨", className: "text-red-400 text-sm" };
      default:
        return null;
    }
  }, [request.status]);

  return {
    isProcessing,
    handleApprove,
    handleReject,
    handleCheckboxChange,
    getStatusBadge,
  };
};

// 가입 요청 페이지 훅
export const useJoinRequestsPage = () => {
  const params = useParams();
  const serverUrl = params.server_id as string;

  const serverInfo = useCurrentServerInfo();

  // 서버 목록을 조회하여 현재 사용자의 role 확인
  const serverListQuery = useServerListQuery(true);

  // 현재 서버에서의 사용자 role 찾기
  const currentServerRole = serverListQuery.data?.find(
    (server) => server.serverUrl === serverInfo?.serverUrl
  )?.serverRole;

  // 관리자 권한 확인 (owner 또는 admin)
  const isAdmin =
    currentServerRole === "owner" || currentServerRole === "admin";

  // 상태 관리
  const [selectedAll, setSelectedAll] = useState(false);
  const [selectedRequests, setSelectedRequests] = useState<Set<string>>(
    new Set()
  );
  const [filterStatus, setFilterStatus] = useState<
    "all" | "pending" | "approved" | "rejected"
  >("all");

  // API 호출 - 관리자 권한이 있을 때만
  const {
    data: serverAccessList = [],
    isLoading,
    error,
    refetch,
  } = useServerAccessQuery(serverUrl, {
    enabled: isAdmin, // 관리자 권한이 있을 때만 API 호출
  });

  const patchServerAccessMutation = usePatchServerAccessMutation(serverUrl);

  // 데이터 변환 및 계산
  const requests = useMemo(() => {
    return serverAccessList?.map(mapServerAccessToJoinRequest);
  }, [serverAccessList]);

  const filteredRequests = useMemo(() => {
    if (filterStatus === "all") return requests;
    return requests?.filter((request) => request.status === filterStatus);
  }, [requests, filterStatus]);

  const pendingCount = useMemo(() => {
    return (
      requests?.filter((request) => request.status === "pending").length || 0
    );
  }, [requests]);

  const isProcessing = useMemo(() => {
    return isLoading || patchServerAccessMutation.isPending;
  }, [isLoading, patchServerAccessMutation.isPending]);

  // 핸들러 함수들
  const handleApprove = useCallback(
    async (userEmail: string) => {
      try {
        console.log("가입 요청 승인:", userEmail);

        await patchServerAccessMutation.mutateAsync({
          status: "Approved",
          userEmail: userEmail,
        });

        refetch();
        console.log("✅ 가입 요청 승인 완료");
      } catch (error) {
        console.error("❌ 가입 요청 승인 실패:", error);
      }
    },
    [serverUrl, patchServerAccessMutation, refetch]
  );

  const handleReject = useCallback(
    async (userEmail: string) => {
      try {
        console.log("가입 요청 거절:", userEmail);

        await patchServerAccessMutation.mutateAsync({
          status: "Banned",
          userEmail: userEmail,
        });

        refetch();
        console.log("✅ 가입 요청 거절 완료");
      } catch (error) {
        console.error("❌ 가입 요청 거절 실패:", error);
      }
    },
    [serverUrl, patchServerAccessMutation, refetch]
  );

  const handleBulkApprove = useCallback(async () => {
    const selectedIds = Array.from(selectedRequests);

    try {
      await Promise.all(selectedIds.map((id) => handleApprove(id)));
      setSelectedRequests(new Set());
      setSelectedAll(false);
      console.log("✅ 일괄 승인 완료");
    } catch (error) {
      console.error("❌ 일괄 승인 실패:", error);
    }
  }, [selectedRequests, handleApprove]);

  const handleBulkReject = useCallback(async () => {
    const selectedIds = Array.from(selectedRequests);

    try {
      await Promise.all(selectedIds.map((id) => handleReject(id)));
      setSelectedRequests(new Set());
      setSelectedAll(false);
      console.log("✅ 일괄 거절 완료");
    } catch (error) {
      console.error("❌ 일괄 거절 실패:", error);
    }
  }, [selectedRequests, handleReject]);

  const handleSelectRequest = useCallback(
    (requestId: string, selected: boolean) => {
      setSelectedRequests((prev) => {
        const newSet = new Set(prev);
        if (selected) {
          newSet.add(requestId);
        } else {
          newSet.delete(requestId);
          setSelectedAll(false);
        }
        return newSet;
      });
    },
    []
  );

  const handleSelectAll = useCallback(
    (checked: boolean) => {
      setSelectedAll(checked);
      if (checked) {
        setSelectedRequests(
          new Set(
            filteredRequests
              ?.filter((r) => r.status === "pending")
              .map((r) => r.id)
          )
        );
      } else {
        setSelectedRequests(new Set());
      }
    },
    [filteredRequests]
  );

  const handleFilterChange = useCallback(
    (status: "all" | "pending" | "approved" | "rejected") => {
      setFilterStatus(status);
    },
    []
  );

  // 에러 로깅
  if (error) {
    console.error("❌ 서버 가입 요청 목록 조회 실패:", error);
  }

  return {
    // 상태
    selectedAll,
    selectedRequests,
    filterStatus,

    // 데이터
    requests: isAdmin ? requests : [],
    filteredRequests: isAdmin ? filteredRequests : [],
    pendingCount: isAdmin ? pendingCount : 0,
    isLoading: isAdmin ? isLoading : false,
    error: isAdmin ? error : null,
    isProcessing: isAdmin ? isProcessing : false,

    // 권한 정보
    isAdmin,

    // 핸들러
    handleApprove,
    handleReject,
    handleBulkApprove,
    handleBulkReject,
    handleSelectRequest,
    handleSelectAll,
    handleFilterChange,
    refetch,
  };
};

그 후 실제로 요청 승인과 거절을 위한 훅을 작성해주었는데, 먼저 관리자(owner, admin)의 경우에만 API를 호출할 수 있도록 제한을 하고 가입 요청을 보낸 사용자의 UI 를 결정하는 함수와 가입 승인, 거절에 관련된 함수를 작성해주었다.

JoinRequestItem

typescript
import React from "react";
import Image from "next/image";
import { useJoinRequestItem, JoinRequest } from "@/app/(servers)/hooks/useAdmin";

interface JoinRequestItemProps {
  request: JoinRequest;
  isSelected?: boolean;
  onSelect?: (userEmail: string, selected: boolean) => void;
  onApprove: (userEmail: string) => Promise<void>;
  onReject: (userEmail: string) => Promise<void>;
}

const JoinRequestItem: React.FC<JoinRequestItemProps> = ({
  request,
  isSelected = false,
  onSelect,
  onApprove,
  onReject,
}) => {
  const {
    isProcessing,
    handleApprove,
    handleReject,
    handleCheckboxChange,
    getStatusBadge,
  } = useJoinRequestItem(request, onApprove, onReject, onSelect);

  const statusBadge = getStatusBadge();

  return (
    <div className="bg-gray-700 rounded-lg p-4 border border-gray-600">
      <div className="flex items-start justify-between">
        {/* 사용자 정보 */}
        <div className="flex items-start space-x-3 flex-1">
          {/* 체크박스 */}
          <input
            type="checkbox"
            checked={isSelected}
            onChange={handleCheckboxChange}
            className="mt-1 w-4 h-4 text-blue-600 bg-gray-600 border-gray-500 rounded focus:ring-blue-500"
          />

          {/* 아바타 */}
          <div className="w-10 h-10 bg-gray-600 rounded-full flex items-center justify-center flex-shrink-0">
            {request.userAvatar ? (
              <Image
                src={request.userAvatar}
                alt={request.userName}
                className="w-10 h-10 rounded-full"
              />
            ) : (
              <span className="text-white font-medium text-sm">
                {request.userName.charAt(0).toUpperCase()}
              </span>
            )}
          </div>

          {/* 사용자 정보 및 메시지 */}
          <div className="flex-1 min-w-0">
            <div className="flex items-center space-x-2">
              <h3 className="text-white font-medium">{request.userName}</h3>
              {statusBadge && (
                <span className={statusBadge.className}>
                  {statusBadge.text}
                </span>
              )}
            </div>
            <p className="text-gray-300 text-sm mt-1 break-words">
              {request.userEmail}
            </p>
            <p className="text-gray-300 text-sm mt-1 break-words">
              {request.message}
            </p>
            <p className="text-gray-500 text-xs mt-1">
              {new Date(request.requestDate).toLocaleDateString("ko-KR", {
                year: "numeric",
                month: "long",
                day: "numeric",
                hour: "2-digit",
                minute: "2-digit",
              })}
            </p>
          </div>
        </div>

        {/* 액션 버튼들 */}
        {request.status === "pending" && (
          <div className="flex space-x-2 ml-4 flex-shrink-0">
            <button
              onClick={handleApprove}
              disabled={isProcessing}
              className="w-10 h-10 bg-green-600 hover:bg-green-700 disabled:bg-green-400 rounded-lg flex items-center justify-center transition-colors"
              title="승인"
            >
              <svg
                className="w-5 h-5 text-white"
                fill="none"
                stroke="currentColor"
                viewBox="0 0 24 24"
              >
                <path
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  strokeWidth={2}
                  d="M5 13l4 4L19 7"
                />
              </svg>
            </button>
            <button
              onClick={handleReject}
              disabled={isProcessing}
              className="w-10 h-10 bg-red-600 hover:bg-red-700 disabled:bg-red-400 rounded-lg flex items-center justify-center transition-colors"
              title="거절"
            >
              <svg
                className="w-5 h-5 text-white"
                fill="none"
                stroke="currentColor"
                viewBox="0 0 24 24"
              >
                <path
                  strokeLinecap="round"
                  strokeLinejoin="round"
                  strokeWidth={2}
                  d="M6 18L18 6M6 6l12 12"
                />
              </svg>
            </button>
          </div>
        )}
      </div>
    </div>
  );
};

export default JoinRequestItem;

BulkActions

typescript
"use client";

import React from "react";

interface BulkActionsProps {
  selectedCount: number;
  onBulkKick: () => void;
  onBulkBan: () => void;
}

const BulkActions: React.FC<BulkActionsProps> = ({
  selectedCount,
  onBulkKick,
  onBulkBan,
}) => {
  return (
    <div className="mb-4 bg-blue-900/50 border border-blue-500/50 rounded-lg p-4">
      <div className="flex items-center justify-between">
        <div className="flex items-center space-x-4">
          <span className="text-white font-medium">
            {selectedCount}명의 멤버가 선택됨
          </span>
          <div className="flex space-x-2">
            <button
              onClick={onBulkKick}
              className="px-4 py-2 bg-orange-600 text-white rounded-md hover:bg-orange-700 transition-colors flex items-center space-x-2"
            >
              <span>👢</span>
              <span>일괄 킥</span>
            </button>
            <button
              onClick={onBulkBan}
              className="px-4 py-2 bg-red-600 text-white rounded-md hover:bg-red-700 transition-colors flex items-center space-x-2"
            >
              <span>🚫</span>
              <span>일괄 차단</span>
            </button>
          </div>
        </div>
        <div className="text-blue-200 text-sm">
          선택된 멤버들에게 일괄 작업을 수행할 수 있습니다.
        </div>
      </div>
    </div>
  );
};

export default BulkActions;

image.png

image.png

이렇게 서버 가입 요청을 확인할 수 있는 기능을 만들었다. 하지만 여기서 문제가 발생했는데 서버에 초대되더라도 프로젝트에 아직 초대가 되어 있지 않기 때문에 프로젝트에 초대하는 로직 또한 작성을 해야했다.

useUserMemberListApi

typescript
export const useUserMemberListApi = (serverUrl: string) => {
  return useApi<MemberInfo[], void>({
    endpoint: `/ex/servers/${serverUrl}/members`,
    method: "GET",
    axiosInstance: expressClient,
  });
};

useInviteProjectApi

typescript
export const useInviteProjectApi = (serverUrl: string, projectPk: number) => {
  return useApi<{ message: string }, MemberEmail[] | string[]>({
    endpoint: `/ex/servers/${serverUrl}/projects/${projectPk}/invite`,
    method: "POST",
    axiosInstance: expressClient,
  });
};

빠르게 해당 기능을 담당하는 API 통신을 작성한뒤

useUserMemberListQuery

typescript
export const useUserMemberListQuery = (serverUrl: string) => {
  const { execute: getMemberList } = useUserMemberListApi(serverUrl);

  return useQuery({
    queryKey: ["memberList", serverUrl],
    queryFn: () => getMemberList(),
    enabled: !!serverUrl,
    staleTime: 5 * 60 * 1000,
    gcTime: 10 * 60 * 1000,
  });
};

useInviteProjectMutation

typescript
export const useInviteProjectMutation = (
  serverUrl: string,
  projectPk: number
) => {
  const { execute: inviteProject } = useInviteProjectApi(serverUrl, projectPk);

  return useMutation({
    mutationFn: async (userEmails: string[]) => {
      const memberEmails = userEmails.map((userEmail) => ({ userEmail }));
      const result = await inviteProject(memberEmails);
      return result;
    },
    onSuccess: (data) => {
      console.log("🎉 프로젝트 초대 성공:", data);
    },
    onError: (error) => {
      console.error("❌ 프로젝트 초대 실패:", error);
    },
  });
};

쿼리문 또한 빠르게 작성해주었다.

useModal

typescript
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { useDispatch, useSelector } from "react-redux";
import type { RootState, AppDispatch } from "../../lib/store";

// 모달 타입 정의
export type ModalType =
  | "SERVER_ADD"
  | "SERVER_EDIT"
  | "SERVER_DELETE"
  | "CHANNEL_ADD"
  | "PROJECT_ADD"
  | "PROJECT_INVITE"
  | null;

// 서버 데이터 타입 정의
export interface ServerData {
  id?: string;
  name: string;
  url: string;
  description?: string;
}

// 채널 데이터 타입 정의
export interface ChannelData {
  serverUrl: string;
  projectPk: number;
  channelName: string;
  channelKind: "text" | "voice" | "notice";
  isPrivate: boolean;
}

// 프로젝트 데이터 타입 정의
export interface ProjectData {
  serverUrl: string;
  projectName: string;
  projectDescription?: string;
}

// 프로젝트 초대 데이터 타입 정의
export interface ProjectInviteData {
  serverUrl: string;
  projectPk: number;
  projectName: string;
}

// 모달 상태 인터페이스
export interface ModalState {
  isOpen: boolean;
  type: ModalType;
  data: ServerData | ChannelData | ProjectData | ProjectInviteData | null;
  loading: boolean;
  error: string | null;
}

// 초기 상태
const initialState: ModalState = {
  isOpen: false,
  type: null,
  data: null,
  loading: false,
  error: null,
};

// 모달 슬라이스 생성
const modalSlice = createSlice({
  name: "modal",
  initialState,
  reducers: {
    // 모달 열기
    openModal: (
      state,
      action: PayloadAction<{
        type: ModalType;
        data?: ServerData | ChannelData | ProjectData | ProjectInviteData;
      }>
    ) => {
      state.isOpen = true;
      state.type = action.payload.type;
      state.data = action.payload.data || null;
      state.error = null;
    },

    // 모달 닫기
    closeModal: (state) => {
      state.isOpen = false;
      state.type = null;
      state.data = null;
      state.error = null;
      state.loading = false;
    },

    // 모달 데이터 업데이트
    updateModalData: (
      state,
      action: PayloadAction<
        ServerData | ChannelData | ProjectData | ProjectInviteData
      >
    ) => {
      state.data = action.payload;
    },

    // 로딩 상태 설정
    setLoading: (state, action: PayloadAction<boolean>) => {
      state.loading = action.payload;
    },

    // 에러 설정
    setError: (state, action: PayloadAction<string | null>) => {
      state.error = action.payload;
      state.loading = false;
    },

    // 에러 초기화
    clearError: (state) => {
      state.error = null;
    },
  },
});

// 액션 내보내기
export const {
  openModal,
  closeModal,
  updateModalData,
  setLoading,
  setError,
  clearError,
} = modalSlice.actions;

// 리듀서 내보내기
export default modalSlice.reducer;

// 커스텀 훅
export const useModal = () => {
  const dispatch = useDispatch<AppDispatch>();
  const modalState = useSelector((state: RootState) => state.modal);

  const actions = {
    // 서버 추가 모달 열기
    openServerAddModal: () => {
      dispatch(openModal({ type: "SERVER_ADD" }));
    },

    // 서버 편집 모달 열기
    openServerEditModal: (serverData: ServerData) => {
      dispatch(openModal({ type: "SERVER_EDIT", data: serverData }));
    },

    // 서버 삭제 모달 열기
    openServerDeleteModal: (serverData: ServerData) => {
      dispatch(openModal({ type: "SERVER_DELETE", data: serverData }));
    },

    // 채널 추가 모달 열기
    openChannelAddModal: (channelData: ChannelData) => {
      dispatch(openModal({ type: "CHANNEL_ADD", data: channelData }));
    },

    // 프로젝트 추가 모달 열기
    openProjectAddModal: (projectData: ProjectData) => {
      dispatch(openModal({ type: "PROJECT_ADD", data: projectData }));
    },

    // 프로젝트 초대 모달 열기
    openProjectInviteModal: (projectInviteData: ProjectInviteData) => {
      dispatch(openModal({ type: "PROJECT_INVITE", data: projectInviteData }));
    },

    // 모달 닫기
    close: () => {
      dispatch(closeModal());
    },

    // 모달 데이터 업데이트
    updateData: (
      data: ServerData | ChannelData | ProjectData | ProjectInviteData
    ) => {
      dispatch(updateModalData(data));
    },

    // 로딩 시작
    startLoading: () => {
      dispatch(setLoading(true));
    },

    // 로딩 종료
    stopLoading: () => {
      dispatch(setLoading(false));
    },

    // 에러 설정
    setError: (error: string) => {
      dispatch(setError(error));
    },

    // 에러 초기화
    clearError: () => {
      dispatch(clearError());
    },
  };

  return {
    // 상태
    isOpen: modalState.isOpen,
    type: modalState.type,
    data: modalState.data,
    loading: modalState.loading,
    error: modalState.error,

    // 액션들
    ...actions,

    // 유틸리티 메서드
    isServerAddModal: modalState.type === "SERVER_ADD",
    isServerEditModal: modalState.type === "SERVER_EDIT",
    isServerDeleteModal: modalState.type === "SERVER_DELETE",
    isChannelAddModal: modalState.type === "CHANNEL_ADD",
    isProjectAddModal: modalState.type === "PROJECT_ADD",
    isProjectInviteModal: modalState.type === "PROJECT_INVITE",
  };
};

모달을 관리하는 파일에 프로젝트 초대 모달을 추가 해주고

AddProjectInviteModal

typescript
"use client";

import React, { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { useModal, ProjectInviteData } from "../hooks/useModal";
import { useRouter } from "next/navigation";
import {
  useUserMemberListQuery,
  useInviteProjectMutation,
} from "../hooks/useServerMutation";
import { MemberInfo } from "../types/Server";

const AddProjectInviteModal = () => {
  const { isOpen, isProjectInviteModal, close, data } = useModal();
  const router = useRouter();

  const [selectedMembers, setSelectedMembers] = useState<Set<string>>(
    new Set()
  );
  const [searchQuery, setSearchQuery] = useState("");
  const [isLoading, setIsLoading] = useState(false);

  const projectInviteData = data as ProjectInviteData;

  // 서버 멤버 목록 조회
  const memberListQuery = useUserMemberListQuery(
    projectInviteData?.serverUrl || ""
  );

  // 프로젝트 초대 뮤테이션
  const inviteProjectMutation = useInviteProjectMutation(
    projectInviteData?.serverUrl || "",
    projectInviteData?.projectPk || 0
  );

  // 멤버 선택/해제
  const handleMemberSelect = (userEmail: string) => {
    const newSelectedMembers = new Set(selectedMembers);
    if (newSelectedMembers.has(userEmail)) {
      newSelectedMembers.delete(userEmail);
    } else {
      newSelectedMembers.add(userEmail);
    }
    setSelectedMembers(newSelectedMembers);
  };

  // 모든 멤버 선택/해제
  const handleSelectAll = () => {
    if (selectedMembers.size === filteredMembers.length) {
      setSelectedMembers(new Set());
    } else {
      setSelectedMembers(
        new Set(filteredMembers.map((member) => member.userInfo.userEmail))
      );
    }
  };

  // 선택된 멤버들 초대
  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();

    if (!projectInviteData || selectedMembers.size === 0) return;

    setIsLoading(true);
    try {
      // 선택된 멤버들을 한 번에 초대
      const userEmails = Array.from(selectedMembers);
      await inviteProjectMutation.mutateAsync(userEmails);

      console.log(
        `✅ ${selectedMembers.size}명의 멤버를 프로젝트에 초대했습니다.`
      );

      // 성공 시 모달 닫기
      handleClose();
    } catch (error) {
      console.error("❌ 프로젝트 초대 실패:", error);
    } finally {
      setIsLoading(false);
    }
  };

  const handleClose = () => {
    setSelectedMembers(new Set());
    setSearchQuery("");
    setIsLoading(false);
    close();
  };

  // 멤버 목록 필터링
  const filteredMembers = (memberListQuery.data || []).filter(
    (member: MemberInfo) =>
      member.userInfo.userName
        .toLowerCase()
        .includes(searchQuery.toLowerCase()) ||
      member.userInfo.userEmail
        .toLowerCase()
        .includes(searchQuery.toLowerCase())
  );

  return (
    <AnimatePresence>
      {isOpen && isProjectInviteModal && (
        <motion.div
          initial={{ opacity: 0 }}
          animate={{ opacity: 1 }}
          exit={{ opacity: 0 }}
          className="fixed inset-0 flex items-center justify-center z-50 bg-black/50 backdrop-blur-sm"
          onClick={handleClose}
        >
          <motion.div
            initial={{ opacity: 0, scale: 0.9, y: 20 }}
            animate={{ opacity: 1, scale: 1, y: 0 }}
            exit={{ opacity: 0, scale: 0.9, y: 20 }}
            className="w-full max-w-md mx-auto bg-gray-700 rounded-lg p-6"
            onClick={(e) => e.stopPropagation()}
          >
            <div className="flex justify-between items-center mb-6">
              <h2 className="text-xl font-semibold text-white">
                프로젝트에 멤버 초대하기
              </h2>
              <button
                onClick={handleClose}
                className="text-gray-400 hover:text-white transition-colors"
              >
                ✕
              </button>
            </div>

            <div className="space-y-4">
              {/* 프로젝트 정보 */}
              <div className="bg-blue-600/10 border border-blue-600/20 rounded-lg p-3">
                <div className="flex items-center">
                  <span className="mr-2 text-blue-400">📁</span>
                  <div className="text-blue-300 text-sm">
                    <div className="font-medium">
                      {projectInviteData?.projectName} 프로젝트
                    </div>
                    <div>선택한 멤버들을 이 프로젝트에 초대할 수 있습니다.</div>
                  </div>
                </div>
              </div>

              {/* 검색 */}
              <div>
                <label className="block text-white text-sm font-medium mb-2">
                  멤버 검색
                </label>
                <div className="relative">
                  <span className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400">
                    🔍
                  </span>
                  <input
                    type="text"
                    value={searchQuery}
                    onChange={(e) => setSearchQuery(e.target.value)}
                    placeholder="이름 또는 이메일로 검색..."
                    className="w-full pl-10 pr-4 py-2 bg-gray-800 border border-gray-600 rounded-lg text-white placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
                  />
                </div>
              </div>

              {/* 전체 선택 */}
              <div className="flex items-center justify-between">
                <label className="flex items-center cursor-pointer">
                  <input
                    type="checkbox"
                    checked={
                      selectedMembers.size === filteredMembers.length &&
                      filteredMembers.length > 0
                    }
                    onChange={handleSelectAll}
                    className="mr-2 rounded border-gray-600 bg-gray-700"
                  />
                  <span className="text-white text-sm">전체 선택</span>
                </label>
                <span className="text-gray-400 text-sm">
                  {selectedMembers.size}명 선택됨
                </span>
              </div>

              {/* 멤버 목록 */}
              <div className="max-h-60 overflow-y-auto space-y-2">
                {memberListQuery.isLoading ? (
                  <div className="text-center py-4">
                    <div className="text-gray-400">
                      멤버 목록을 불러오는 중...
                    </div>
                  </div>
                ) : filteredMembers.length === 0 ? (
                  <div className="text-center py-4">
                    <div className="text-gray-400">검색 결과가 없습니다.</div>
                  </div>
                ) : (
                  filteredMembers.map((member: MemberInfo) => (
                    <div
                      key={member.userInfo.userEmail}
                      onClick={() =>
                        handleMemberSelect(member.userInfo.userEmail)
                      }
                      className="flex items-center p-3 bg-gray-800 rounded-lg cursor-pointer hover:bg-gray-700 transition-colors"
                    >
                      <input
                        type="checkbox"
                        checked={selectedMembers.has(member.userInfo.userEmail)}
                        onChange={() => {}} // onClick에서 처리
                        className="mr-3 rounded border-gray-600 bg-gray-700"
                      />
                      <div className="flex items-center flex-1">
                        <div className="w-8 h-8 bg-blue-600 rounded-full flex items-center justify-center mr-3">
                          {member.userInfo.ProfileImageUrl ? (
                            <img
                              src={member.userInfo.ProfileImageUrl}
                              alt={member.userInfo.userName}
                              className="w-full h-full rounded-full object-cover"
                            />
                          ) : (
                            <span className="text-white text-sm font-semibold">
                              {member.userInfo.userName.charAt(0).toUpperCase()}
                            </span>
                          )}
                        </div>
                        <div className="flex-1">
                          <div className="text-white font-medium flex items-center">
                            {member.userInfo.userName}
                            {member.serverRole === "owner" && (
                              <span className="ml-2 px-1 py-0.5 bg-yellow-600 text-yellow-100 text-xs rounded">
                                소유자
                              </span>
                            )}
                            {member.serverRole === "admin" && (
                              <span className="ml-2 px-1 py-0.5 bg-purple-600 text-purple-100 text-xs rounded">
                                관리자
                              </span>
                            )}
                          </div>
                          <div className="text-gray-400 text-sm">
                            {member.userInfo.userEmail}
                          </div>
                        </div>
                      </div>
                    </div>
                  ))
                )}
              </div>
              ... 코드 길이 초과...

모달창을 구현하였다. 그 결과

image.png

image.png

image.png

image.png

다음과 같이 서버 및 프로젝트 초대가 가능해졌다.

이번에 초대 기능을 빠르게 구현하면서 신경쓸 점이 생각보다 많았는데, 특히 서버 초대 후 바로 기본 프로젝트로 초대되는 기능이 아직 백엔드에 구현되어 있지 않아서 수동으로 관리자가 초대를 같이 하는 기능을 추가하다보니 시간이 더 걸린 것 같다. 추후에 백엔드에 해당 기능이 추가되면 더 편해지겠지?

태그

#Next.js#aurora