From 59b7e4172a24206ba9df4ce21dbf953330460e9e Mon Sep 17 00:00:00 2001 From: hwangjo Date: Fri, 12 Jun 2026 00:50:57 +0900 Subject: [PATCH 1/2] feat(team): add real-time team chat --- ARCHITECTURE.md | 2 + DECISIONS.md | 5 + README.md | 12 +- package.json | 1 + pnpm-lock.yaml | 29 ++ pnpm-workspace.yaml | 3 + .../team/components/real-team-chat-panel.tsx | 272 ++++++++++++++++++ .../team/components/team-space-view.tsx | 55 +--- src/features/team/hooks/use-chat-queries.ts | 68 +++++ .../team/hooks/use-project-group-chat.ts | 173 +++++++++++ src/lib/api/chat.ts | 58 ++++ src/lib/api/mocks/handlers.ts | 116 ++++++++ src/lib/types/chat.ts | 32 +++ 13 files changed, 775 insertions(+), 51 deletions(-) create mode 100644 pnpm-workspace.yaml create mode 100644 src/features/team/components/real-team-chat-panel.tsx create mode 100644 src/features/team/hooks/use-chat-queries.ts create mode 100644 src/features/team/hooks/use-project-group-chat.ts create mode 100644 src/lib/api/chat.ts create mode 100644 src/lib/types/chat.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4f757ed..0501e33 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -12,6 +12,7 @@ - API request functions stay in `src/lib/api/*`. - MSW request handlers stay in `src/lib/api/mocks/handlers.ts`, with reusable team-space fixture builders in `src/lib/api/mocks/team-space-fixtures.ts`. - `/team` uses project group, checklist, admin permission, and GitHub App installation request functions when a user is signed in. +- The signed-in `/team` chat tab uses `src/features/team/components/real-team-chat-panel.tsx` for UI, `src/features/team/hooks/use-chat-queries.ts` for REST history/read state, and `src/features/team/hooks/use-project-group-chat.ts` for STOMP subscribe/publish. - In mock API mode, signed-in `/team` calls the same request functions through MSW; signed-out `/team` keeps the richer local demo workspace as a preview. ## Current User Flow @@ -23,6 +24,7 @@ ## Temporary UI State - Real API mode and signed-in mock mode use `src/lib/api/*` request functions for implemented team-space server capabilities. +- In mock API mode, chat messages are backed by MSW and appended directly into the TanStack Query cache; in real API mode, the same cache is hydrated from REST and updated from `/topic/project-groups/{projectGroupId}/chat/messages`. - Signed-out mock mode still keeps team name, lifecycle status, rules Markdown, GitHub repository preview, random reviewer, and team messages in local React state. - Move remaining mock-only team surfaces to `src/lib/api/*` when matching backend endpoints are introduced. diff --git a/DECISIONS.md b/DECISIONS.md index c12810d..2296206 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -48,3 +48,8 @@ - Route performance approach: lazy-load route pages from `src/App.tsx`, especially `/team` and `/deck/*`, to keep the initial app bundle smaller without changing route ownership. - Team workspace structure: keep `/team` orchestration in `team-space-view.tsx`, split signed-in server-backed panels into focused `real-team-*` components, and keep the signed-out mock preview separate while sharing tabs, status helpers, and GitHub permission notices. - MSW maintainability: keep route handlers in `src/lib/api/mocks/handlers.ts`, but move reusable team-space fixture builders into `src/lib/api/mocks/team-space-fixtures.ts` so handler code focuses on request behavior. + +## 2026-06-12 +- Team chat transport: use `@stomp/stompjs` for the signed-in team-space chat panel because the backend exposes Spring STOMP topics for real-time project-group messages. +- Team chat state: keep message history in TanStack Query through `src/features/team/hooks/use-chat-queries.ts`, and append live STOMP messages into the same cache so REST history and real-time delivery share one UI source of truth. +- Mock parity: keep chat handlers in MSW for `/project-groups/:projectGroupId/chat/messages` and `/project-groups/:projectGroupId/chat/read` so the same chat panel can be browser-tested without a running backend. diff --git a/README.md b/README.md index b60d270..f40d7ad 100644 --- a/README.md +++ b/README.md @@ -32,7 +32,7 @@ pnpm dev ## API Mode -API mode is controlled from [`src/lib/api/config.ts`](/Users/hwangjo/Client/src/lib/api/config.ts). +API mode is controlled from [`src/lib/api/config.ts`](/Users/hwangjo/team-po/Client/src/lib/api/config.ts). - `VITE_API_MODE=mock`: use MSW-based mocked API - `VITE_API_MODE=real`: call a real backend @@ -47,11 +47,19 @@ VITE_API_BASE_URL=https://api.example.com VITE_OAUTH_BASE_URL=https://api.example.com ``` +Local backend example: + +```bash +VITE_API_MODE=real VITE_API_BASE_URL=http://localhost:8080/api pnpm dev --host 127.0.0.1 +``` + +The team-space chat panel uses the same API mode. In real mode it connects to the backend STOMP endpoint derived from `VITE_API_BASE_URL`; for `http://localhost:8080/api`, chat connects to `ws://localhost:8080/ws`. + ## Vercel Deployment This project uses React Router with `BrowserRouter`, so Vercel needs an SPA rewrite for deep links such as `/login`, `/signup`, and `/me`. -The required rewrite is already configured in [`vercel.json`](/Users/hwangjo/Client/vercel.json). +The required rewrite is already configured in [`vercel.json`](/Users/hwangjo/team-po/Client/vercel.json). ### Recommended Setup diff --git a/package.json b/package.json index dc022ed..c5f257e 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@stomp/stompjs": "^7.2.1", "@tanstack/react-query": "^5.90.21", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 75c46df..39e00b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.2.4 version: 1.2.4(@types/react@19.2.14)(react@19.2.4) + '@stomp/stompjs': + specifier: ^7.2.1 + version: 7.3.0 '@tanstack/react-query': specifier: ^5.90.21 version: 5.90.21(react@19.2.4) @@ -193,24 +196,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@2.4.5': resolution: {integrity: sha512-U1GAG6FTjhAO04MyH4xn23wRNBkT6H7NentHh+8UxD6ShXKBm5SY4RedKJzkUThANxb9rUKIPc7B8ew9Xo/cWg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.5': resolution: {integrity: sha512-NlKa7GpbQmNhZf9kakQeddqZyT7itN7jjWdakELeXyTU3pg/83fTysRRDPJD0akTfKDl6vZYNT9Zqn4MYZVBOA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@2.4.5': resolution: {integrity: sha512-NdODlSugMzTlENPTa4z0xB82dTUlCpsrOxc43///aNkTLblIYH4XpYflBbf5ySlQuP8AA4AZd1qXhV07IdrHdQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@2.4.5': resolution: {integrity: sha512-EBfrTqRIWOFSd7CQb/0ttjHMR88zm3hGravnDwUA9wHAaCAYsULKDebWcN5RmrEo1KBtl/gDVJMrFjNR0pdGUw==} @@ -599,66 +606,79 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -690,6 +710,9 @@ packages: cpu: [x64] os: [win32] + '@stomp/stompjs@7.3.0': + resolution: {integrity: sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==} + '@tanstack/query-core@5.90.20': resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} @@ -999,24 +1022,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.31.1: resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.31.1: resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.31.1: resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.31.1: resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==} @@ -1881,6 +1908,8 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true + '@stomp/stompjs@7.3.0': {} + '@tanstack/query-core@5.90.20': {} '@tanstack/react-query@5.90.21(react@19.2.4)': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..220bd0b --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + esbuild: true + msw: true diff --git a/src/features/team/components/real-team-chat-panel.tsx b/src/features/team/components/real-team-chat-panel.tsx new file mode 100644 index 0000000..21ccfc2 --- /dev/null +++ b/src/features/team/components/real-team-chat-panel.tsx @@ -0,0 +1,272 @@ +import { SendHorizontal, Wifi, WifiOff } from "lucide-react"; +import { type FormEvent, useEffect, useMemo, useRef, useState } from "react"; + +import { AppPanel, AppPanelHeader } from "@/components/app-shell"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useProjectGroupChat } from "@/features/team/hooks/use-project-group-chat"; +import { + useChatMessagesQuery, + useMarkChatReadMutation, +} from "@/features/team/hooks/use-chat-queries"; +import { RealInlineStatus } from "@/features/team/components/real-team-shared"; +import { getApiErrorMessage } from "@/lib/api/client"; +import type { ChatMessage } from "@/lib/types/chat"; +import type { MyProjectGroup } from "@/lib/types/project-group"; +import { cn } from "@/lib/utils"; + +interface RealTeamChatPanelProps { + projectGroup: MyProjectGroup; +} + +export function RealTeamChatPanel({ projectGroup }: RealTeamChatPanelProps) { + const [draftMessage, setDraftMessage] = useState(""); + const messageListRef = useRef(null); + const lastMarkedMessageIdRef = useRef(null); + const chatMessagesQuery = useChatMessagesQuery(projectGroup.projectGroupId); + const { mutate: markChatRead } = useMarkChatReadMutation(); + const { connectionMessage, connectionState, isConnected, sendMessage } = + useProjectGroupChat({ + enabled: true, + projectGroupId: projectGroup.projectGroupId, + }); + const messages = useMemo( + () => chatMessagesQuery.data?.messages ?? [], + [chatMessagesQuery.data], + ); + const latestMessage = messages.at(-1); + const latestMessageId = latestMessage?.messageId; + + useEffect(() => { + const messageList = messageListRef.current; + if (!messageList || !latestMessageId) { + return; + } + + messageList.scrollTop = messageList.scrollHeight; + }, [latestMessageId]); + + useEffect(() => { + if ( + !latestMessageId || + lastMarkedMessageIdRef.current === latestMessageId + ) { + return; + } + + lastMarkedMessageIdRef.current = latestMessageId; + markChatRead({ + lastReadMessageId: latestMessageId, + projectGroupId: projectGroup.projectGroupId, + }); + }, [latestMessageId, markChatRead, projectGroup.projectGroupId]); + + function handleSubmit(event: FormEvent) { + event.preventDefault(); + const trimmedMessage = draftMessage.trim(); + + if (!trimmedMessage) { + return; + } + + if (sendMessage(trimmedMessage)) { + setDraftMessage(""); + } + } + + return ( + + + } + description="팀원이 빠르게 공유할 내용을 남기는 공간이에요." + eyebrow="Messages" + title="팀 채팅" + /> +
+
+ {chatMessagesQuery.isLoading ? : null} + {chatMessagesQuery.error ? ( + + ) : null} + {!chatMessagesQuery.isLoading && + !chatMessagesQuery.error && + messages.length === 0 ? ( + + ) : null} + {messages.length > 0 ? ( +
+ {messages.map((message) => ( + + ))} +
+ ) : null} +
+ {connectionMessage ? ( + + ) : null} +
+ +
+ +
+
+
+
+ ); +} + +function ConnectionBadge({ + connectionState, + isConnected, +}: { + connectionState: string; + isConnected: boolean; +}) { + return ( + + {isConnected ? ( + + ) : ( + + )} + {getConnectionLabel(connectionState)} + + ); +} + +function getConnectionLabel(connectionState: string) { + if (connectionState === "connected") { + return "실시간"; + } + if (connectionState === "connecting") { + return "연결 중"; + } + if (connectionState === "error") { + return "연결 오류"; + } + return "대기"; +} + +function ChatBubble({ message }: { message: ChatMessage }) { + return ( +
+
+
+

{message.senderNickname}

+

+ {formatChatTime(message.createdAt)} +

+
+

+ {message.content} +

+
+
+ ); +} + +function ChatLoadingState() { + return ( +
+ + + +
+ ); +} + +function EmptyChatState() { + return ( +
+
+

+ 아직 채팅 메시지가 없어요. +

+

+ 첫 메시지를 보내 팀원들과 진행 상황을 공유해요. +

+
+
+ ); +} + +function formatChatTime(value: string) { + const date = new Date(value); + if (Number.isNaN(date.getTime())) { + return ""; + } + + return new Intl.DateTimeFormat("ko-KR", { + hour: "2-digit", + minute: "2-digit", + }).format(date); +} diff --git a/src/features/team/components/team-space-view.tsx b/src/features/team/components/team-space-view.tsx index 2fc064b..b8a08ba 100644 --- a/src/features/team/components/team-space-view.tsx +++ b/src/features/team/components/team-space-view.tsx @@ -4,7 +4,6 @@ import { GitPullRequest, LoaderCircle, Save, - SendHorizontal, Settings2, Sparkles, } from "lucide-react"; @@ -30,6 +29,7 @@ import { storeProjectGroupFinishAgreement, } from "@/features/project-groups/lib/finish-agreement-storage"; import { RealGithubInstallationPanel } from "@/features/team/components/real-github-installation-panel"; +import { RealTeamChatPanel } from "@/features/team/components/real-team-chat-panel"; import { RealGuidePanel } from "@/features/team/components/real-team-guide-panel"; import { RealManagePanel } from "@/features/team/components/real-team-manage-panel"; import { RealOverviewPanel } from "@/features/team/components/real-team-overview-panel"; @@ -435,7 +435,9 @@ function RealTeamSpaceView({ isSignedIn }: { isSignedIn: boolean }) { projectGroup={projectGroup} /> ) : null} - {selectedTab === "chat" ? : null} + {selectedTab === "chat" ? ( + + ) : null} {selectedTab === "manage" ? ( - 준비 중} - description="팀 채팅 API가 연결되면 메시지를 보낼 수 있어요." - eyebrow="Messages" - title="팀 채팅" - /> -
-
-
-
-

Team-po

-

- 채팅 기능은 API가 연결되면 사용할 수 있어요. -

-
-
-
-
- -
- -
-
-
- - ); -} - function getRealTeamTabBadge( tabId: TeamTab, checklists: ProjectChecklist[], @@ -856,7 +813,7 @@ function getRealTeamTabBadge( return summary.openCount > 0 ? String(summary.openCount) : "완료"; } - if (tabId === "rules" || tabId === "chat") { + if (tabId === "rules") { return "준비"; } @@ -875,5 +832,5 @@ function getRealTeamTabBadge( } function isRealTeamTabDisabled(tabId: TeamTab) { - return tabId === "rules" || tabId === "chat"; + return tabId === "rules"; } diff --git a/src/features/team/hooks/use-chat-queries.ts b/src/features/team/hooks/use-chat-queries.ts new file mode 100644 index 0000000..bb62fa6 --- /dev/null +++ b/src/features/team/hooks/use-chat-queries.ts @@ -0,0 +1,68 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; + +import { getChatMessages, markChatRead } from "@/lib/api/chat"; +import type { ChatMessagePage } from "@/lib/types/chat"; + +export const chatQueryKeys = { + messages: (projectGroupId: number) => + ["team-space", projectGroupId, "chat", "messages"] as const, +}; + +const chatMessagesStaleTimeMs = 5_000; + +export function useChatMessagesQuery( + projectGroupId: number | undefined, + enabled = true, +) { + return useQuery({ + enabled: enabled && typeof projectGroupId === "number", + queryFn: () => + getChatMessages({ + projectGroupId: requireProjectGroupId(projectGroupId), + size: 30, + }), + queryKey: + typeof projectGroupId === "number" + ? chatQueryKeys.messages(projectGroupId) + : ["team-space", "chat", "disabled"], + refetchOnWindowFocus: false, + retry: false, + staleTime: chatMessagesStaleTimeMs, + }); +} + +export function useMarkChatReadMutation() { + return useMutation({ + mutationFn: markChatRead, + }); +} + +export function appendChatMessagePage( + current: ChatMessagePage | undefined, + message: ChatMessagePage["messages"][number], +): ChatMessagePage { + if (!current) { + return { + hasNext: false, + messages: [message], + nextBeforeMessageId: null, + }; + } + + if (current.messages.some((item) => item.messageId === message.messageId)) { + return current; + } + + return { + ...current, + messages: [...current.messages, message], + }; +} + +function requireProjectGroupId(projectGroupId: number | undefined) { + if (typeof projectGroupId !== "number") { + throw new Error("Project group id is required."); + } + + return projectGroupId; +} diff --git a/src/features/team/hooks/use-project-group-chat.ts b/src/features/team/hooks/use-project-group-chat.ts new file mode 100644 index 0000000..4fa3e84 --- /dev/null +++ b/src/features/team/hooks/use-project-group-chat.ts @@ -0,0 +1,173 @@ +import { Client, type IMessage } from "@stomp/stompjs"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { getAuthSession } from "@/lib/api/auth-session"; +import { getChatWebSocketUrl } from "@/lib/api/chat"; +import { apiConfig } from "@/lib/api/config"; +import type { + ChatMessage, + ChatMessagePage, + SendChatMessageRequest, +} from "@/lib/types/chat"; +import { + appendChatMessagePage, + chatQueryKeys, +} from "@/features/team/hooks/use-chat-queries"; + +type ChatConnectionState = + | "idle" + | "connecting" + | "connected" + | "disconnected" + | "error"; + +export function useProjectGroupChat({ + enabled, + projectGroupId, +}: { + enabled: boolean; + projectGroupId: number | undefined; +}) { + const queryClient = useQueryClient(); + const clientRef = useRef(null); + const [connectionState, setConnectionState] = + useState("idle"); + const [connectionMessage, setConnectionMessage] = useState( + null, + ); + + useEffect(() => { + if (!enabled || typeof projectGroupId !== "number") { + setConnectionState("idle"); + setConnectionMessage(null); + return; + } + + if (apiConfig.useMocks) { + setConnectionState("connected"); + setConnectionMessage(null); + return; + } + + const session = getAuthSession(); + if (!session) { + setConnectionState("error"); + setConnectionMessage("채팅 연결을 위해 로그인이 필요해요."); + return; + } + + const client = new Client({ + brokerURL: getChatWebSocketUrl(), + connectHeaders: { + Authorization: `Bearer ${session.accessToken}`, + }, + debug: () => undefined, + heartbeatIncoming: 10_000, + heartbeatOutgoing: 10_000, + onConnect: () => { + setConnectionState("connected"); + setConnectionMessage(null); + client.subscribe( + `/topic/project-groups/${projectGroupId}/chat/messages`, + (message) => { + const chatMessage = parseChatMessage(message); + if (!chatMessage) { + return; + } + + queryClient.setQueryData( + chatQueryKeys.messages(projectGroupId), + (current) => appendChatMessagePage(current, chatMessage), + ); + }, + ); + }, + onDisconnect: () => { + setConnectionState("disconnected"); + }, + onStompError: () => { + setConnectionState("error"); + setConnectionMessage("채팅 서버가 연결을 거절했어요."); + }, + onWebSocketClose: () => { + setConnectionState((current) => + current === "idle" ? "idle" : "disconnected", + ); + }, + onWebSocketError: () => { + setConnectionState("error"); + setConnectionMessage("채팅 서버에 연결하지 못했어요."); + }, + reconnectDelay: 3_000, + }); + + clientRef.current = client; + setConnectionState("connecting"); + setConnectionMessage(null); + client.activate(); + + return () => { + clientRef.current = null; + void client.deactivate(); + }; + }, [enabled, projectGroupId, queryClient]); + + const sendMessage = useCallback( + (content: string) => { + if (typeof projectGroupId !== "number") { + setConnectionMessage("팀 정보를 불러온 뒤 메시지를 보낼 수 있어요."); + return false; + } + + if (apiConfig.useMocks) { + queryClient.setQueryData( + chatQueryKeys.messages(projectGroupId), + (current) => + appendChatMessagePage(current, { + content, + createdAt: new Date().toISOString(), + messageId: Date.now(), + mine: true, + projectGroupId, + senderNickname: "나", + senderProfileImage: null, + senderUserId: 0, + type: "TEXT", + }), + ); + return true; + } + + const client = clientRef.current; + if (!client?.connected) { + setConnectionState("error"); + setConnectionMessage("채팅 서버에 연결된 뒤 메시지를 보낼 수 있어요."); + return false; + } + + const payload: SendChatMessageRequest = { content }; + client.publish({ + body: JSON.stringify(payload), + destination: `/app/project-groups/${projectGroupId}/chat/messages`, + }); + return true; + }, + [projectGroupId, queryClient], + ); + + return { + connectionMessage, + connectionState, + isConnected: connectionState === "connected", + sendMessage, + }; +} + +function parseChatMessage(message: IMessage) { + try { + return JSON.parse(message.body) as ChatMessage; + } catch { + return null; + } +} diff --git a/src/lib/api/chat.ts b/src/lib/api/chat.ts new file mode 100644 index 0000000..7d65168 --- /dev/null +++ b/src/lib/api/chat.ts @@ -0,0 +1,58 @@ +import { apiRequest } from "@/lib/api/client"; +import { apiConfig } from "@/lib/api/config"; +import type { + ChatMessagePage, + ChatReadState, + MarkChatReadRequest, +} from "@/lib/types/chat"; + +export function getChatMessages({ + beforeMessageId, + projectGroupId, + size = 30, +}: { + beforeMessageId?: number | null; + projectGroupId: number; + size?: number; +}) { + const params = new URLSearchParams({ size: String(size) }); + if (typeof beforeMessageId === "number") { + params.set("beforeMessageId", String(beforeMessageId)); + } + + return apiRequest( + `/project-groups/${projectGroupId}/chat/messages?${params.toString()}`, + ); +} + +export function markChatRead({ + lastReadMessageId, + projectGroupId, +}: MarkChatReadRequest & { projectGroupId: number }) { + return apiRequest( + `/project-groups/${projectGroupId}/chat/read`, + { + json: { lastReadMessageId }, + method: "PATCH", + }, + ); +} + +export function getChatWebSocketUrl() { + if (typeof window === "undefined") { + return "/ws"; + } + + if (/^https?:\/\//.test(apiConfig.baseUrl)) { + const apiUrl = new URL(apiConfig.baseUrl); + const protocol = apiUrl.protocol === "https:" ? "wss:" : "ws:"; + const pathname = apiUrl.pathname.endsWith("/api") + ? apiUrl.pathname.slice(0, -4) + : apiUrl.pathname; + + return `${protocol}//${apiUrl.host}${pathname}/ws`; + } + + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + return `${protocol}//${window.location.host}/ws`; +} diff --git a/src/lib/api/mocks/handlers.ts b/src/lib/api/mocks/handlers.ts index db4f2a5..56d84c0 100644 --- a/src/lib/api/mocks/handlers.ts +++ b/src/lib/api/mocks/handlers.ts @@ -36,6 +36,7 @@ import type { UpdateProjectChecklistRequest, } from "@/lib/types/project-checklist"; import type { MyProjectGroup } from "@/lib/types/project-group"; +import type { ChatMessage } from "@/lib/types/chat"; import type { DevGuideContent, DevGuideGenerationStatus, @@ -65,6 +66,7 @@ let activeMatchProject: MatchProjectResponse | null = null; let activeProjectGroup: MyProjectGroup | null = createMockProjectGroup(); let projectGroupFinishAgreementUserIds = new Set(); let activeProjectChecklists: ProjectChecklist[] = createMockProjectChecklists(); +let activeChatMessages: ChatMessage[] = createMockChatMessages(); let activeDevGuide: DevGuideContent | null = createMockDevGuide(activeProjectGroup); let activeDevGuideGenerationStatus: DevGuideGenerationStatus | null = @@ -269,6 +271,7 @@ function resetTeamSpaceApiState() { activeProjectChecklists = activeProjectGroup ? createMockProjectChecklists() : []; + activeChatMessages = activeProjectGroup ? createMockChatMessages() : []; activeDevGuide = createMockDevGuide(activeProjectGroup); activeDevGuideGenerationStatus = activeDevGuide ? "COMPLETED" : null; remainingDevGuideRegenerationCount = activeDevGuide ? 3 : null; @@ -653,6 +656,52 @@ function createMockProjectGroup(): MyProjectGroup { }; } +function createMockChatMessages(): ChatMessage[] { + const currentMember = activeProjectGroup?.members.find( + (member) => member.userId === currentUserId, + ); + const backendMember = activeProjectGroup?.members.find( + (member) => member.memberRole === "BACKEND", + ); + + return [ + { + content: "오늘 체크리스트 우선순위만 먼저 맞춰볼까요?", + createdAt: "2026-06-11T09:15:00Z", + messageId: 1000, + mine: false, + projectGroupId: activeProjectGroup?.projectGroupId ?? 10, + senderNickname: backendMember?.nickname ?? "api_builder", + senderProfileImage: backendMember?.profileImage ?? null, + senderUserId: backendMember?.userId ?? 2, + type: "TEXT", + }, + { + content: "좋아요. 저는 팀스페이스 화면 연결부터 볼게요.", + createdAt: "2026-06-11T09:17:00Z", + messageId: 1001, + mine: true, + projectGroupId: activeProjectGroup?.projectGroupId ?? 10, + senderNickname: currentMember?.nickname ?? "preview", + senderProfileImage: currentMember?.profileImage ?? null, + senderUserId: currentMember?.userId ?? currentUserId, + type: "TEXT", + }, + { + content: + "GitHub 연결 전까지는 체크리스트 기준으로 진행 상황을 공유하면 될 것 같아요.", + createdAt: "2026-06-11T09:20:00Z", + messageId: 1002, + mine: false, + projectGroupId: activeProjectGroup?.projectGroupId ?? 10, + senderNickname: "pixel_runner", + senderProfileImage: null, + senderUserId: 3, + type: "TEXT", + }, + ]; +} + function createMockProjectChecklists(): ProjectChecklist[] { const currentMember = activeProjectGroup?.members.find( (member) => member.userId === currentUserId, @@ -1967,6 +2016,73 @@ export const handlers = [ }, ), + http.get( + getPath("/project-groups/:projectGroupId/chat/messages"), + async ({ params, request }) => { + await delay(250); + syncSessionFromRequest(request); + + const projectGroupId = Number(params.projectGroupId); + const accessError = assertProjectGroupAccess(projectGroupId); + + if (accessError) { + return accessError; + } + + const url = new URL(request.url); + const beforeMessageId = Number(url.searchParams.get("beforeMessageId")); + const size = Number(url.searchParams.get("size") ?? "30"); + const normalizedSize = + Number.isFinite(size) && size > 0 ? Math.min(size, 50) : 30; + const cursor = + Number.isFinite(beforeMessageId) && beforeMessageId > 0 + ? beforeMessageId + : null; + const availableMessages = activeChatMessages + .filter((message) => message.projectGroupId === projectGroupId) + .filter((message) => cursor === null || message.messageId < cursor); + const pageMessages = availableMessages.slice(-normalizedSize); + const hasNext = availableMessages.length > pageMessages.length; + + return HttpResponse.json({ + hasNext, + messages: pageMessages, + nextBeforeMessageId: + hasNext && pageMessages.length > 0 ? pageMessages[0].messageId : null, + }); + }, + ), + + http.patch( + getPath("/project-groups/:projectGroupId/chat/read"), + async ({ params, request }) => { + const body = (await request.json()) as { lastReadMessageId?: number }; + + await delay(150); + syncSessionFromRequest(request); + + const projectGroupId = Number(params.projectGroupId); + const accessError = assertProjectGroupAccess(projectGroupId); + + if (accessError) { + return accessError; + } + + if (typeof body.lastReadMessageId !== "number") { + return buildErrorResponse( + 400, + "읽은 메시지 식별자는 필수입니다.", + "INVALID_INPUT_FIELD", + ); + } + + return HttpResponse.json({ + lastReadMessageId: body.lastReadMessageId, + updatedAt: new Date().toISOString(), + }); + }, + ), + http.get( getPath("/team-space/:projectGroupId/dev-guide"), async ({ params, request }) => { diff --git a/src/lib/types/chat.ts b/src/lib/types/chat.ts new file mode 100644 index 0000000..3fbd8cc --- /dev/null +++ b/src/lib/types/chat.ts @@ -0,0 +1,32 @@ +export type ChatMessageType = "TEXT" | "SYSTEM"; + +export interface ChatMessage { + messageId: number; + projectGroupId: number; + senderUserId: number; + senderNickname: string; + senderProfileImage: string | null; + type: ChatMessageType; + content: string; + createdAt: string; + mine: boolean; +} + +export interface ChatMessagePage { + messages: ChatMessage[]; + nextBeforeMessageId: number | null; + hasNext: boolean; +} + +export interface SendChatMessageRequest { + content: string; +} + +export interface MarkChatReadRequest { + lastReadMessageId: number; +} + +export interface ChatReadState { + lastReadMessageId: number | null; + updatedAt: string; +} From 3670861f954e8b7f87704da4df9512dab86ce794 Mon Sep 17 00:00:00 2001 From: hwangjo Date: Fri, 12 Jun 2026 01:00:45 +0900 Subject: [PATCH 2/2] fix(team): address chat review comments --- openapi/openapi.yaml | 148 ++++++++++++++++++ src/features/team/hooks/use-chat-queries.ts | 55 +++++-- .../team/hooks/use-project-group-chat.ts | 31 ++-- src/lib/api/client.ts | 27 ++++ 4 files changed, 241 insertions(+), 20 deletions(-) diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 18a953e..629301f 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -12,6 +12,7 @@ tags: - name: Match - name: ProjectGroups - name: ProjectChecklists + - name: Chat - name: TeamSpace paths: /signup/email: @@ -666,6 +667,75 @@ paths: $ref: '#/components/responses/NotFound' '500': $ref: '#/components/responses/InternalServerError' + /project-groups/{projectGroupId}/chat/messages: + get: + tags: [Chat] + security: + - bearerAuth: [] + summary: Get project group chat messages + parameters: + - $ref: '#/components/parameters/ProjectGroupId' + - in: query + name: beforeMessageId + required: false + schema: + type: integer + format: int64 + description: Return messages older than this message id. + - in: query + name: size + required: false + schema: + type: integer + minimum: 1 + maximum: 50 + default: 30 + responses: + '200': + description: Project group chat message page + content: + application/json: + schema: + $ref: '#/components/schemas/ChatMessagePage' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + '500': + $ref: '#/components/responses/InternalServerError' + /project-groups/{projectGroupId}/chat/read: + patch: + tags: [Chat] + security: + - bearerAuth: [] + summary: Mark project group chat messages as read + parameters: + - $ref: '#/components/parameters/ProjectGroupId' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/MarkChatReadRequest' + responses: + '200': + description: Chat read state updated + content: + application/json: + schema: + $ref: '#/components/schemas/ChatReadState' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' /team-space/{projectGroupId}/dev-guide: get: tags: [TeamSpace] @@ -1466,6 +1536,84 @@ components: format: int64 aiAdvice: $ref: '#/components/schemas/ChecklistAiAdvice' + ChatMessageType: + type: string + enum: [TEXT, SYSTEM] + ChatMessage: + type: object + additionalProperties: false + required: + - messageId + - projectGroupId + - senderUserId + - senderNickname + - senderProfileImage + - type + - content + - createdAt + - mine + properties: + messageId: + type: integer + format: int64 + projectGroupId: + type: integer + format: int64 + senderUserId: + type: integer + format: int64 + senderNickname: + type: string + senderProfileImage: + type: + - string + - 'null' + type: + $ref: '#/components/schemas/ChatMessageType' + content: + type: string + createdAt: + type: string + format: date-time + mine: + type: boolean + ChatMessagePage: + type: object + additionalProperties: false + required: [messages, nextBeforeMessageId, hasNext] + properties: + messages: + type: array + items: + $ref: '#/components/schemas/ChatMessage' + nextBeforeMessageId: + type: + - integer + - 'null' + format: int64 + hasNext: + type: boolean + MarkChatReadRequest: + type: object + additionalProperties: false + required: [lastReadMessageId] + properties: + lastReadMessageId: + type: integer + format: int64 + ChatReadState: + type: object + additionalProperties: false + required: [lastReadMessageId, updatedAt] + properties: + lastReadMessageId: + type: + - integer + - 'null' + format: int64 + updatedAt: + type: string + format: date-time GithubInstallationStatus: type: object additionalProperties: false diff --git a/src/features/team/hooks/use-chat-queries.ts b/src/features/team/hooks/use-chat-queries.ts index bb62fa6..2e4407e 100644 --- a/src/features/team/hooks/use-chat-queries.ts +++ b/src/features/team/hooks/use-chat-queries.ts @@ -1,4 +1,4 @@ -import { useMutation, useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { getChatMessages, markChatRead } from "@/lib/api/chat"; import type { ChatMessagePage } from "@/lib/types/chat"; @@ -14,17 +14,27 @@ export function useChatMessagesQuery( projectGroupId: number | undefined, enabled = true, ) { + const queryClient = useQueryClient(); + const queryKey = + typeof projectGroupId === "number" + ? chatQueryKeys.messages(projectGroupId) + : ["team-space", "chat", "disabled"]; + return useQuery({ enabled: enabled && typeof projectGroupId === "number", - queryFn: () => - getChatMessages({ - projectGroupId: requireProjectGroupId(projectGroupId), + queryFn: async () => { + const requiredProjectGroupId = requireProjectGroupId(projectGroupId); + const historyPage = await getChatMessages({ + projectGroupId: requiredProjectGroupId, size: 30, - }), - queryKey: - typeof projectGroupId === "number" - ? chatQueryKeys.messages(projectGroupId) - : ["team-space", "chat", "disabled"], + }); + const cachedPage = queryClient.getQueryData( + chatQueryKeys.messages(requiredProjectGroupId), + ); + + return mergeChatMessagePages(historyPage, cachedPage); + }, + queryKey, refetchOnWindowFocus: false, retry: false, staleTime: chatMessagesStaleTimeMs, @@ -59,6 +69,33 @@ export function appendChatMessagePage( }; } +export function mergeChatMessagePages( + historyPage: ChatMessagePage, + cachedPage: ChatMessagePage | undefined, +): ChatMessagePage { + if (!cachedPage?.messages.length) { + return historyPage; + } + + const messagesById = new Map(); + + for (const message of cachedPage.messages) { + messagesById.set(message.messageId, message); + } + + for (const message of historyPage.messages) { + messagesById.set(message.messageId, message); + } + + return { + ...historyPage, + messages: Array.from(messagesById.values()).sort( + (firstMessage, secondMessage) => + firstMessage.messageId - secondMessage.messageId, + ), + }; +} + function requireProjectGroupId(projectGroupId: number | undefined) { if (typeof projectGroupId !== "number") { throw new Error("Project group id is required."); diff --git a/src/features/team/hooks/use-project-group-chat.ts b/src/features/team/hooks/use-project-group-chat.ts index 4fa3e84..08abd6c 100644 --- a/src/features/team/hooks/use-project-group-chat.ts +++ b/src/features/team/hooks/use-project-group-chat.ts @@ -2,7 +2,7 @@ import { Client, type IMessage } from "@stomp/stompjs"; import { useCallback, useEffect, useRef, useState } from "react"; import { useQueryClient } from "@tanstack/react-query"; -import { getAuthSession } from "@/lib/api/auth-session"; +import { ensureFreshAuthSession } from "@/lib/api/client"; import { getChatWebSocketUrl } from "@/lib/api/chat"; import { apiConfig } from "@/lib/api/config"; import type { @@ -38,6 +38,8 @@ export function useProjectGroupChat({ ); useEffect(() => { + let isActiveEffect = true; + if (!enabled || typeof projectGroupId !== "number") { setConnectionState("idle"); setConnectionMessage(null); @@ -50,18 +52,24 @@ export function useProjectGroupChat({ return; } - const session = getAuthSession(); - if (!session) { - setConnectionState("error"); - setConnectionMessage("채팅 연결을 위해 로그인이 필요해요."); - return; - } - const client = new Client({ - brokerURL: getChatWebSocketUrl(), - connectHeaders: { - Authorization: `Bearer ${session.accessToken}`, + beforeConnect: async (stompClient) => { + const session = await ensureFreshAuthSession(); + + if (!session) { + if (isActiveEffect) { + setConnectionState("error"); + setConnectionMessage("채팅 연결을 위해 로그인이 필요해요."); + } + void stompClient.deactivate({ force: true }); + return; + } + + stompClient.connectHeaders = { + Authorization: `Bearer ${session.accessToken}`, + }; }, + brokerURL: getChatWebSocketUrl(), debug: () => undefined, heartbeatIncoming: 10_000, heartbeatOutgoing: 10_000, @@ -108,6 +116,7 @@ export function useProjectGroupChat({ client.activate(); return () => { + isActiveEffect = false; clientRef.current = null; void client.deactivate(); }; diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts index 9dcea39..90278ed 100644 --- a/src/lib/api/client.ts +++ b/src/lib/api/client.ts @@ -13,6 +13,8 @@ interface ApiRequestOptions extends Omit { skipRefresh?: boolean; } +const authRefreshSkewMs = 30_000; + export class ApiError extends Error { code?: string; fieldErrors?: Record; @@ -66,6 +68,21 @@ export async function apiRequest( return data as T; } +export async function ensureFreshAuthSession() { + const session = getAuthSession(); + + if (!session) { + return null; + } + + if (!isAuthSessionExpiring(session.expiresAt)) { + return session; + } + + const refreshed = await refreshAccessToken(); + return refreshed ? getAuthSession() : null; +} + function buildRequestInit({ body, headers, @@ -128,6 +145,16 @@ async function refreshAccessToken() { return true; } +function isAuthSessionExpiring(expiresAt: string) { + const expiresAtMs = new Date(expiresAt).getTime(); + + if (!Number.isFinite(expiresAtMs)) { + return true; + } + + return expiresAtMs <= Date.now() + authRefreshSkewMs; +} + export function getApiErrorMessage( error: unknown, fallbackMessage = "요청 처리 중 오류가 발생했습니다.",