diff --git a/@noctaCrdt/Interfaces.ts b/@noctaCrdt/Interfaces.ts index ae8608b6..63fba8a8 100644 --- a/@noctaCrdt/Interfaces.ts +++ b/@noctaCrdt/Interfaces.ts @@ -195,5 +195,6 @@ export interface WorkspaceListItem { id: string; name: string; role: string; - memberCount?: number; + memberCount: number; + activeUsers: number; } diff --git a/client/src/App.tsx b/client/src/App.tsx index b968ceea..d88a2a2d 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -8,26 +8,14 @@ import { useSocketStore } from "./stores/useSocketStore"; const App = () => { // TODO 라우터, react query 설정 const { isErrorModalOpen, errorMessage } = useErrorStore(); - const { userId } = useUserInfo(); useEffect(() => { const socketStore = useSocketStore.getState(); - socketStore.init(userId, null); - - // // 소켓이 연결된 후에 이벤트 리스너 등록 - // const { socket } = socketStore; - // socket?.on("connect", () => { - // const unsubscribe = socketStore.subscribeToWorkspaceOperations({ - // onWorkspaceListUpdate: (workspaces) => { - // console.log("Workspace list updated:", workspaces); - // }, - // }); - - // return () => { - // if (unsubscribe) unsubscribe(); - // }; - // }); + const savedWorkspace = sessionStorage.getItem("currentWorkspace"); + const workspaceId = savedWorkspace ? JSON.parse(savedWorkspace).id : null; + console.log(workspaceId); + socketStore.init(userId, workspaceId); return () => { setTimeout(() => { diff --git a/client/src/components/modal/InviteModal.style.ts b/client/src/components/modal/InviteModal.style.ts new file mode 100644 index 00000000..cc6cb8c8 --- /dev/null +++ b/client/src/components/modal/InviteModal.style.ts @@ -0,0 +1,39 @@ +// InviteModal.style.ts +import { css } from "@styled-system/css"; + +export const modalContentContainer = css({ + display: "flex", + gap: "16px", + flexDirection: "column", + width: "400px", + padding: "16px", +}); + +export const titleText = css({ + color: "gray.800", + fontSize: "xl", + fontWeight: "bold", +}); + +export const descriptionText = css({ + color: "gray.600", + fontSize: "sm", +}); + +export const emailInput = css({ + outline: "none", + border: "1px solid", + borderColor: "gray.200", + borderRadius: "md", + // 기본 input 스타일 추가 + width: "100%", + padding: "8px 12px", + fontSize: "sm", + _placeholder: { + color: "gray.400", + }, + _focus: { + borderColor: "blue.500", + boxShadow: "0 0 0 1px blue.500", + }, +}); diff --git a/client/src/components/modal/InviteModal.tsx b/client/src/components/modal/InviteModal.tsx new file mode 100644 index 00000000..e6779dce --- /dev/null +++ b/client/src/components/modal/InviteModal.tsx @@ -0,0 +1,42 @@ +// InviteModal.tsx +import { useState } from "react"; +import { modalContentContainer, titleText, descriptionText, emailInput } from "./InviteModal.style"; +import { Modal } from "./modal"; + +interface InviteModalProps { + isOpen: boolean; + onClose: () => void; + onInvite: (email: string) => void; +} + +export const InviteModal = ({ isOpen, onClose, onInvite }: InviteModalProps) => { + const [email, setEmail] = useState(""); + + const handleInvite = () => { + onInvite(email); + setEmail(""); + onClose(); + }; + + return ( + +
+

워크스페이스 초대

+

초대할 사용자의 이메일을 입력해주세요

+ setEmail(e.target.value)} + placeholder="이메일 주소 입력" + type="email" + value={email} + /> +
+
+ ); +}; diff --git a/client/src/components/sidebar/components/menuButton/MenuButton.tsx b/client/src/components/sidebar/components/menuButton/MenuButton.tsx index 66870c0c..57d4c2ed 100644 --- a/client/src/components/sidebar/components/menuButton/MenuButton.tsx +++ b/client/src/components/sidebar/components/menuButton/MenuButton.tsx @@ -1,4 +1,8 @@ import { useState, useEffect } from "react"; +import { InviteModal } from "@src/components/modal/InviteModal"; +import { useModal } from "@src/components/modal/useModal"; +import { useSocketStore } from "@src/stores/useSocketStore"; +import { useToastStore } from "@src/stores/useToastStore"; import { useUserInfo } from "@stores/useUserStore"; import { menuItemWrapper, textBox, menuButtonContainer } from "./MenuButton.style"; import { MenuIcon } from "./components/MenuIcon"; @@ -7,12 +11,18 @@ import { WorkspaceSelectModal } from "./components/WorkspaceSelectModal"; export const MenuButton = () => { const { name } = useUserInfo(); const [isOpen, setIsOpen] = useState(false); + const { socket, workspace } = useSocketStore(); + const { addToast } = useToastStore(); + const { + isOpen: isInviteModalOpen, + openModal: openInviteModal, + closeModal: closeInviteModal, + } = useModal(); const handleMenuClick = () => { - setIsOpen((prev) => !prev); // 토글 형태로 변경 + setIsOpen((prev) => !prev); }; - // 모달 외부 클릭시 닫기 처리를 위한 함수 const handleClickOutside = (e: MouseEvent) => { const target = e.target as HTMLElement; if (!target.closest(`.menu_button_container`)) { @@ -20,7 +30,6 @@ export const MenuButton = () => { } }; - // 외부 클릭 이벤트 리스너 등록 useEffect(() => { document.addEventListener("mousedown", handleClickOutside); return () => { @@ -28,7 +37,52 @@ export const MenuButton = () => { }; }, []); + useEffect(() => { + if (!socket) return; + + // 초대 성공 응답 수신 + socket.on( + "invite/workspace/success", + (data: { email: string; workspaceId: string; message: string }) => { + addToast(data.message); + closeInviteModal(); + }, + ); + + // 초대 실패 응답 수신 + socket.on( + "invite/workspace/fail", + (data: { email: string; workspaceId: string; message: string }) => { + addToast(data.message); + closeInviteModal(); + }, + ); + + // 초대 받은 경우 수신 + socket.on( + "workspace/invited", + (data: { workspaceId: string; invitedBy: string; message: string }) => { + addToast(data.message); + }, + ); + + return () => { + socket.off("invite/workspace/success"); + socket.off("invite/workspace/fail"); + socket.off("workspace/invited"); + }; + }, [socket]); + + const handleInvite = (email: string) => { + if (!socket || !workspace?.id) return; + + socket.emit("invite/workspace", { + email, + workspaceId: workspace.id, + }); + }; return ( + <> - - + + ); }; diff --git a/client/src/components/sidebar/components/menuButton/components/WorkspaceSelectModal.style.ts b/client/src/components/sidebar/components/menuButton/components/WorkspaceSelectModal.style.ts index a1b43b59..a5c1225d 100644 --- a/client/src/components/sidebar/components/menuButton/components/WorkspaceSelectModal.style.ts +++ b/client/src/components/sidebar/components/menuButton/components/WorkspaceSelectModal.style.ts @@ -3,7 +3,7 @@ import { glassContainer } from "@styled-system/recipes"; export const workspaceListContainer = css({ display: "flex", - gap: "sm", + gap: "8px", flexDirection: "column", padding: "md", }); diff --git a/client/src/components/sidebar/components/menuButton/components/WorkspaceSelectModal.tsx b/client/src/components/sidebar/components/menuButton/components/WorkspaceSelectModal.tsx index 7f3980a8..a5a80cfd 100644 --- a/client/src/components/sidebar/components/menuButton/components/WorkspaceSelectModal.tsx +++ b/client/src/components/sidebar/components/menuButton/components/WorkspaceSelectModal.tsx @@ -1,5 +1,5 @@ import { motion } from "framer-motion"; -import { useRef } from "react"; +import { useRef, useEffect, useState, useMemo } from "react"; import { SIDE_BAR } from "@constants/size"; import { useSocketStore } from "@src/stores/useSocketStore"; import { @@ -7,22 +7,43 @@ import { workspaceModalContainer, textBox, } from "./WorkspaceSelectModal.style"; +import { InviteButton } from "./components/InviteButton"; import { WorkspaceSelectItem } from "./components/WorkspaceSelectItem"; interface WorkspaceSelectModalProps { isOpen: boolean; userName: string | null; + onInviteClick: () => void; } -export const WorkspaceSelectModal = ({ isOpen, userName }: WorkspaceSelectModalProps) => { +export const WorkspaceSelectModal = ({ + isOpen, + userName, + onInviteClick, +}: WorkspaceSelectModalProps) => { const modalRef = useRef(null); - const { availableWorkspaces } = useSocketStore(); // 소켓 스토어에서 직접 워크스페이스 목록 가져오기 + const availableWorkspaces = useSocketStore((state) => state.availableWorkspaces); + const workspaceConnections = useSocketStore((state) => state.workspaceConnections); + const workspacesWithActiveUsers = useMemo( + () => + availableWorkspaces.map((workspace) => ({ + ...workspace, + activeUsers: workspaceConnections[workspace.id] || 0, + })), + [availableWorkspaces, workspaceConnections], + ); + const [workspaces, setWorkspaces] = useState(workspacesWithActiveUsers); const informText = userName ? availableWorkspaces.length > 0 ? "" : "접속할 수 있는 워크스페이스가 없습니다." : `다른 워크스페이스 기능은\n 회원전용 입니다`; + + useEffect(() => { + setWorkspaces(workspacesWithActiveUsers); + }, [availableWorkspaces, workspacesWithActiveUsers]); // availableWorkspaces가 변경될 때마다 실행 + return (
- {userName && availableWorkspaces.length > 0 ? ( - availableWorkspaces.map((workspace) => ( - - )) + {userName && workspaces.length > 0 ? ( + <> + {workspaces.map((workspace) => ( + + ))} + + ) : (

{informText}

)} diff --git a/client/src/components/sidebar/components/menuButton/components/components/InviteButton.style.ts b/client/src/components/sidebar/components/menuButton/components/components/InviteButton.style.ts new file mode 100644 index 00000000..bac3731c --- /dev/null +++ b/client/src/components/sidebar/components/menuButton/components/components/InviteButton.style.ts @@ -0,0 +1,19 @@ +import { css } from "@styled-system/css"; + +export const inviteButtonStyle = css({ + display: "flex", + gap: "32px", + alignItems: "center", + borderTop: "1px solid", + borderColor: "gray.200", + + width: "100%", + padding: "12px 16px", + color: "gray.600", + backgroundColor: "transparent", + transition: "all 0.2s", + cursor: "pointer", + _hover: { + backgroundColor: "gray.200", + }, +}); diff --git a/client/src/components/sidebar/components/menuButton/components/components/InviteButton.tsx b/client/src/components/sidebar/components/menuButton/components/components/InviteButton.tsx new file mode 100644 index 00000000..62090c80 --- /dev/null +++ b/client/src/components/sidebar/components/menuButton/components/components/InviteButton.tsx @@ -0,0 +1,15 @@ +import Plus from "@assets/icons/plusIcon.svg?react"; +import { inviteButtonStyle } from "./InviteButton.style"; + +interface InviteButtonProps { + onClick: () => void; +} + +export const InviteButton = ({ onClick }: InviteButtonProps) => { + return ( + + ); +}; diff --git a/client/src/components/sidebar/components/menuButton/components/components/WorkspaceSelectItem.style.tsx b/client/src/components/sidebar/components/menuButton/components/components/WorkspaceSelectItem.style.tsx index 568d9316..4a342a07 100644 --- a/client/src/components/sidebar/components/menuButton/components/components/WorkspaceSelectItem.style.tsx +++ b/client/src/components/sidebar/components/menuButton/components/components/WorkspaceSelectItem.style.tsx @@ -5,17 +5,37 @@ export const itemContainer = css({ display: "flex", justifyContent: "space-between", alignItems: "center", - padding: "md", + borderLeft: "3px solid transparent", // 활성화되지 않았을 때 border 공간 확보 + width: "100%", + padding: "8px 16px", + transition: "all 0.2s", cursor: "pointer", - _hover: { backgroundColor: "gray.100" }, + _hover: { backgroundColor: "gray.200" }, }); +export const informBox = css({ + display: "flex", + gap: "16px", + justifyContent: "center", + alignItems: "center", + marginLeft: "14px", +}); export const itemContent = css({ display: "flex", - gap: "2", + flex: 1, + gap: "10", alignItems: "center", }); +export const activeItem = css({ + borderLeft: "3px solid", // 왼쪽 하이라이트 바 + borderLeftColor: "blue", // 포인트 컬러 + backgroundColor: "rgba(0, 0, 0, 0.05)", // 약간 어두운 배경 + _hover: { + backgroundColor: "rgba(0, 0, 0, 0.08)", // 호버 시 약간 더 어둡게 + }, +}); + export const itemIcon = css({ display: "flex", justifyContent: "center", @@ -24,12 +44,13 @@ export const itemIcon = css({ width: "8", height: "8", fontSize: "sm", - backgroundColor: "gray.200", + backgroundColor: "gray.100", }); export const itemInfo = css({ display: "flex", flexDirection: "column", + alignItems: "center", }); export const itemName = css({ diff --git a/client/src/components/sidebar/components/menuButton/components/components/WorkspaceSelectItem.tsx b/client/src/components/sidebar/components/menuButton/components/components/WorkspaceSelectItem.tsx index cdd64344..5480e8cb 100644 --- a/client/src/components/sidebar/components/menuButton/components/components/WorkspaceSelectItem.tsx +++ b/client/src/components/sidebar/components/menuButton/components/components/WorkspaceSelectItem.tsx @@ -1,5 +1,6 @@ import { WorkspaceListItem } from "@noctaCrdt/Interfaces"; // 이전에 만든 인터페이스 import import { useSocketStore } from "@src/stores/useSocketStore"; +import { useToastStore } from "@src/stores/useToastStore"; import { useUserInfo } from "@src/stores/useUserStore"; import { itemContainer, @@ -9,30 +10,48 @@ import { itemMemberCount, itemName, itemRole, + informBox, + activeItem, // 추가: 활성화된 아이템 스타일 } from "./WorkspaceSelectItem.style"; interface WorkspaceSelectItemProps extends WorkspaceListItem { userName: string; } -export const WorkspaceSelectItem = ({ id, name, role, memberCount }: WorkspaceSelectItemProps) => { +export const WorkspaceSelectItem = ({ + id, + name, + role, + memberCount, + activeUsers, +}: WorkspaceSelectItemProps) => { const { userId } = useUserInfo(); - const switchWorkspace = useSocketStore((state) => state.switchWorkspace); + const { workspace, switchWorkspace } = useSocketStore(); + const { addToast } = useToastStore(); + const isActive = workspace?.id === id; // 현재 워크스페이스 확인 const handleClick = () => { - switchWorkspace(userId, id); + if (!isActive) { + switchWorkspace(userId, id); + addToast(`워크스페이스(${name})에 접속하였습니다.`); + } }; return ( - ); }; diff --git a/client/src/stores/useSocketStore.ts b/client/src/stores/useSocketStore.ts index 4fb8fd5a..0d191cef 100644 --- a/client/src/stores/useSocketStore.ts +++ b/client/src/stores/useSocketStore.ts @@ -52,11 +52,11 @@ class BatchProcessor { interface SocketStore { socket: Socket | null; - clientId: number | null; + clientId: number | null; // 숫자로 된 클라이언트Id workspace: WorkSpaceSerializedProps | null; - availableWorkspaces: WorkspaceListItem[]; batchProcessor: BatchProcessor; + workspaceConnections: Record; // 워크스페이스별 접속자 수 init: (userId: string | null, workspaceId: string | null) => void; cleanup: () => void; switchWorkspace: (userId: string | null, workspaceId: string | null) => void; // 새로운 함수 추가 @@ -100,9 +100,8 @@ export const useSocketStore = create((set, get) => ({ socket: null, clientId: null, workspace: null, - availableWorkspaces: [], - + workspaceConnections: {}, batchProcessor: new BatchProcessor((batch) => { const { socket } = get(); socket?.emit("batch/operations", batch); @@ -136,7 +135,12 @@ export const useSocketStore = create((set, get) => ({ }); socket.on("workspace", (workspace: WorkSpaceSerializedProps) => { - set({ workspace }); + const { setWorkspace } = get(); + setWorkspace(workspace); // 수정된 부분 + }); + + socket.on("workspace/connections", (connections: Record) => { + set({ workspaceConnections: connections }); }); socket.on("connect", () => { @@ -148,7 +152,6 @@ export const useSocketStore = create((set, get) => ({ }); socket.on("workspace/list", (workspaces: WorkspaceListItem[]) => { - console.log("Received workspace list:", workspaces); set({ availableWorkspaces: workspaces }); }); @@ -164,25 +167,31 @@ export const useSocketStore = create((set, get) => ({ if (socket) { socket.removeAllListeners(); socket.disconnect(); + sessionStorage.removeItem("currentWorkspace"); // sessionStorage 삭제 set({ socket: null, workspace: null, clientId: null }); } }, switchWorkspace: (userId: string | null, workspaceId: string | null) => { - const { socket, init } = get(); - console.log(userId, workspaceId); + const { socket, workspace, init } = get(); // 기존 연결 정리 if (socket) { + if (workspace?.id) { + socket.emit("leave/workspace", { workspaceId: workspace.id }); + } socket.disconnect(); } - - // 새로운 연결 시작 (userId는 유지) + sessionStorage.removeItem("currentWorkspace"); + set({ workspace: null }); // 상태도 초기화 init(userId, workspaceId); }, fetchWorkspaceData: () => get().workspace, - setWorkspace: (workspace: WorkSpaceSerializedProps) => set({ workspace }), + setWorkspace: (workspace: WorkSpaceSerializedProps) => { + sessionStorage.setItem("currentWorkspace", JSON.stringify(workspace)); + set({ workspace }); + }, sendPageUpdateOperation: (operation: RemotePageUpdateOperation) => { const { socket } = get(); diff --git a/server/src/auth/auth.service.ts b/server/src/auth/auth.service.ts index 844487af..88faa464 100644 --- a/server/src/auth/auth.service.ts +++ b/server/src/auth/auth.service.ts @@ -28,6 +28,9 @@ export class AuthService { name, }); } + async addWorkspace(userId: string, workspaceId: string): Promise { + await this.userModel.updateOne({ id: userId }, { $addToSet: { workspaces: workspaceId } }); + } async findById(id: string): Promise { return this.userModel.findOne({ id }); diff --git a/server/src/workspace/workspace.gateway.ts b/server/src/workspace/workspace.gateway.ts index 273c092b..39540d2c 100644 --- a/server/src/workspace/workspace.gateway.ts +++ b/server/src/workspace/workspace.gateway.ts @@ -27,11 +27,13 @@ import { Logger } from "@nestjs/common"; import { nanoid } from "nanoid"; import { Page } from "@noctaCrdt/Page"; import { EditorCRDT } from "@noctaCrdt/Crdt"; - +import { AuthService } from "../auth/auth.service"; +import { WorkspaceInviteData } from "./workspace.interface"; // 클라이언트 맵 타입 정의 interface ClientInfo { - clientId: number; + clientId: number; // 서버가 부여한 아이디로, 0,1,2,3, 같은 숫자임. connectionTime: Date; + email: string; } interface BatchOperation { @@ -68,7 +70,10 @@ export class WorkspaceGateway implements OnGatewayInit, OnGatewayConnection, OnG private clientIdCounter: number = 1; private clientMap: Map = new Map(); private batchMap: Map = new Map(); - constructor(private readonly workSpaceService: WorkSpaceService) {} + constructor( + private readonly workSpaceService: WorkSpaceService, + private readonly authService: AuthService, + ) {} afterInit(server: Server) { this.workSpaceService.setServer(server); @@ -100,64 +105,71 @@ export class WorkspaceGateway implements OnGatewayInit, OnGatewayConnection, OnG async handleConnection(client: Socket) { try { let { userId } = client.handshake.auth; + const { workspaceId } = client.handshake.auth; if (!userId) { userId = "guest"; } client.data.userId = userId; + client.join(`user:${userId}`); // 유저 전용 룸에 조인 추후 초대 수신용 const workspaces = await this.workSpaceService.getUserWorkspaces(userId); - let workspaceId = ""; + const userInfo = await this.authService.getProfile(userId); + let NewWorkspaceId = ""; + if (userId === "guest") { client.join("guest"); - workspaceId = "guest"; + NewWorkspaceId = "guest"; } else if (workspaces.length === 0) { - const workspace = await this.workSpaceService.createWorkspace(userId, "My Workspace"); + const workspace = await this.workSpaceService.createWorkspace( + userId, + `${userInfo.name}의 Workspace`, + ); client.join(workspace.id); - workspaceId = workspace.id; + NewWorkspaceId = workspace.id; } else { - const requestedWorkspaceId = client.handshake.query.workspaceId; - - // workspaces가 WorkspaceListItem[]이므로 id 비교로 수정 - if ( - requestedWorkspaceId && - workspaces.some((workspace) => workspace.id === requestedWorkspaceId) - ) { - // 요청한 워크스페이스가 있고 접근 권한이 있으면 해당 워크스페이스 사용 - workspaceId = requestedWorkspaceId as string; + if (workspaceId && workspaces.some((workspace) => workspace.id === workspaceId)) { + NewWorkspaceId = workspaceId; } else { // 없으면 첫 번째 워크스페이스 사용 - workspaceId = workspaces[0].id; // WorkspaceListItem의 id 속성 접근 + NewWorkspaceId = workspaces[0].id; // WorkspaceListItem의 id 속성 접근 } - client.join(workspaceId); + client.join(NewWorkspaceId); } - - client.data.workspaceId = workspaceId; - const currentWorkSpace = (await this.workSpaceService.getWorkspace(workspaceId)).serialize(); + const user = await this.authService.findById(userId); + client.data.workspaceId = NewWorkspaceId; + const currentWorkSpace = ( + await this.workSpaceService.getWorkspace(NewWorkspaceId) + ).serialize(); client.emit("workspace", currentWorkSpace); const assignedId = (this.clientIdCounter += 1); const clientInfo: ClientInfo = { clientId: assignedId, connectionTime: new Date(), + email: user?.email || "guest", // email 정보 저장 }; this.clientMap.set(client.id, clientInfo); client.emit("assign/clientId", assignedId); setTimeout(async () => { const workspaces = await this.workSpaceService.getUserWorkspaces(userId); - const workspaceList = workspaces.map((workspace) => ({ - id: workspace.id, - name: workspace.name, - role: workspace.role, - memberCount: workspace.memberCount, - })); + const workspaceList = workspaces.map((workspace) => { + return { + id: workspace.id, + name: workspace.name, + role: workspace.role, + memberCount: workspace.memberCount, + activeUsers: workspace.activeUsers, + }; + }); this.logger.log(`Sending workspace list to client ${client.id}`); client.emit("workspace/list", workspaceList); + this.SocketStoreBroadcastWorkspaceConnections(); }, 100); client.broadcast.emit("userJoined", { clientId: assignedId }); - this.logger.log(`클라이언트 연결 성공 - Socket ID: ${client.id}, Client ID: ${assignedId}`); + this.logger.log(`클라이언트 연결 성공 - User ID: ${userId}, Client ID: ${assignedId}`); this.logger.debug(`현재 연결된 클라이언트 수: ${this.clientMap.size}`); } catch (error) { this.logger.error(`클라이언트 연결 중 오류 발생: ${error.message}`, error.stack); @@ -185,12 +197,104 @@ export class WorkspaceGateway implements OnGatewayInit, OnGatewayConnection, OnG } this.clientMap.delete(client.id); + this.SocketStoreBroadcastWorkspaceConnections(); this.logger.debug(`남은 연결된 클라이언트 수: ${this.clientMap.size}`); } catch (error) { this.logger.error(`클라이언트 연결 해제 중 오류 발생: ${error.message}`, error.stack); } } + Copy; // workspace.gateway.ts + @SubscribeMessage("leave/workspace") + async handleWorkspaceLeave( + @MessageBody() data: { workspaceId: string }, + @ConnectedSocket() client: Socket, + ): Promise { + try { + client.leave(data.workspaceId); + this.SocketStoreBroadcastWorkspaceConnections(); + } catch (error) { + this.logger.error(`워크스페이스 퇴장 중 오류 발생`, error.stack); + } + } + + @SubscribeMessage("invite/workspace") + async handleWorkspaceInvite( + @MessageBody() data: WorkspaceInviteData, + @ConnectedSocket() client: Socket, + ): Promise { + const clientInfo = this.clientMap.get(client.id); + if (!clientInfo) { + throw new WsException("Client information not found"); + } + + try { + // 1. 초대받을 사용자 확인 + const targetUser = await this.authService.findByEmail(data.email); + const sentUser = await this.authService.findByEmail(clientInfo.email); + if (!targetUser) { + client.emit("invite/workspace/fail", { + email: data.email, + workspaceId: data.workspaceId, + message: "유저가 존재하지 않습니다.", + }); + throw new WsException("User not found"); + } + + // 2. 이미 워크스페이스 멤버인지 확인 + if (targetUser.workspaces.includes(data.workspaceId)) { + client.emit("invite/workspace/fail", { + email: data.email, + workspaceId: data.workspaceId, + message: "유저가 이미 워크스페이스에 존재합니다.", + }); + throw new WsException("User is already a member of this workspace"); + } + + const { userId } = client.handshake.auth; + await this.authService.addWorkspace(targetUser.id, data.workspaceId); + await this.workSpaceService.inviteUserToWorkspace(userId, data.workspaceId, targetUser.id); + const targetSocket = Array.from(this.clientMap.entries()).find( + ([_, info]) => info.email === targetUser.email, + ); + + if (targetSocket) { + const [socketId] = targetSocket; + const server = this.workSpaceService.getServer(); + + server.to(`user:${targetUser.id}`).emit("workspace/invited", { + workspaceId: data.workspaceId, + invitedUserId: client.data.userId, + message: `${sentUser.name}님이 초대하셨습니다.`, + }); + + const workspaces = await this.workSpaceService.getUserWorkspaces(targetUser.id); + const workspaceList = workspaces.map((workspace) => ({ + id: workspace.id, + name: workspace.name, + role: workspace.role, + memberCount: workspace.memberCount, + })); + + this.logger.log(`Sending workspace list to client ${client.id}`); + server.to(socketId).emit("workspace/list", workspaceList); + } + + // 5. 초대한 사용자에게 성공 메시지 + client.emit("invite/workspace/success", { + email: data.email, + workspaceId: data.workspaceId, + message: `${targetUser.name}유저를 초대하였습니다.`, + }); + } catch (error) { + this.logger.error( + `Workspace Invite 처리 중 오류 발생 - Client ID: ${clientInfo.clientId}`, + error.stack, + ); + throw new WsException(`Invite 실패: ${error.message}`); + } + } + /** * 페이지 참여 처리 * 클라이언트가 특정 페이지에 참여할 때 호출됨 @@ -767,6 +871,21 @@ export class WorkspaceGateway implements OnGatewayInit, OnGatewayConnection, OnG } } + private SocketStoreBroadcastWorkspaceConnections() { + const server = this.workSpaceService.getServer(); + const connectionCounts = {} as Record; + + // 각 워크스페이스의 room size를 가져옴 + for (const [roomId, room] of server.sockets.adapter.rooms.entries()) { + // user: 로 시작하는 룸은 제외 (초대용 룸) + if (!roomId.startsWith("user:")) { + connectionCounts[roomId] = room.size; + } + } + // 모든 클라이언트에게 전송 + server.emit("workspace/connections", connectionCounts); + } + executeBatch() { try { const server = this.workSpaceService.getServer(); diff --git a/server/src/workspace/workspace.interface.ts b/server/src/workspace/workspace.interface.ts new file mode 100644 index 00000000..3e8e525f --- /dev/null +++ b/server/src/workspace/workspace.interface.ts @@ -0,0 +1,4 @@ +export interface WorkspaceInviteData { + email: string; + workspaceId: string; +} diff --git a/server/src/workspace/workspace.service.ts b/server/src/workspace/workspace.service.ts index 0642ce62..507d24a9 100644 --- a/server/src/workspace/workspace.service.ts +++ b/server/src/workspace/workspace.service.ts @@ -172,6 +172,7 @@ export class WorkSpaceService implements OnModuleInit { name: "Guest Workspace", role: "editor", memberCount: 0, + activeUsers: 0, }, ]; } @@ -185,12 +186,18 @@ export class WorkSpaceService implements OnModuleInit { id: { $in: user.workspaces }, }); - return workspaces.map((workspace) => ({ - id: workspace.id, - name: workspace.name, - role: workspace.authUser.get(userId) || "editor", - memberCount: workspace.authUser.size, - })); + const workspaceList = workspaces.map((workspace) => { + const room = this.getServer().sockets.adapter.rooms.get(workspace.id); + return { + id: workspace.id, + name: workspace.name, + role: workspace.authUser.get(userId) || "editor", + memberCount: workspace.authUser.size, + activeUsers: room ? room.size : 0, + }; + }); + + return workspaceList; } // 워크스페이스에 유저 초대