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 360b62d..3469a11 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -54,6 +54,11 @@ - Remotion dependency policy: keep `remotion`, `@remotion/cli`, and `@remotion/player` on the same exact version to avoid render/runtime mismatches. - Remotion styling bridge: use `@remotion/tailwind` so Vite preview and Remotion CLI renders share the same Tailwind v3 visual system. +## 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. + ## 2026-06-13 - Remotion sound design: use `@remotion/sfx` at the same exact Remotion version for predefined motion-graphic SFX cues instead of hand-generating local audio assets. - PR video BGM: use a locally generated light musical WAV under `public/audio/pr-video/` with Remotion fade controls, avoiding external music licensing and network-dependent playback. 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/openapi/openapi.yaml b/openapi/openapi.yaml index cd63797..9e7e332 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] @@ -1566,6 +1636,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/package.json b/package.json index c8a15d2..27d9701 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@radix-ui/react-slot": "^1.2.4", "@remotion/player": "4.0.475", "@remotion/sfx": "4.0.475", + "@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 4594c9a..9827bf7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,9 @@ importers: '@remotion/sfx': specifier: 4.0.475 version: 4.0.475 + '@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) @@ -1321,6 +1324,9 @@ packages: webpack-hot-middleware: optional: true + '@stomp/stompjs@7.3.0': + resolution: {integrity: sha512-nKMLoFfJhrQAqkvvKd1vLq/cVBGCMwPRCD0LqW7UT1fecRx9C3GoKEIR2CYwVuErGeZu8w0kFkl2rlhPlqHVgQ==} + '@tanstack/query-core@5.90.20': resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} @@ -3942,6 +3948,8 @@ snapshots: html-entities: 2.6.0 react-refresh: 0.18.0 + '@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..2e4407e --- /dev/null +++ b/src/features/team/hooks/use-chat-queries.ts @@ -0,0 +1,105 @@ +import { useMutation, useQuery, useQueryClient } 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, +) { + const queryClient = useQueryClient(); + const queryKey = + typeof projectGroupId === "number" + ? chatQueryKeys.messages(projectGroupId) + : ["team-space", "chat", "disabled"]; + + return useQuery({ + enabled: enabled && typeof projectGroupId === "number", + queryFn: async () => { + const requiredProjectGroupId = requireProjectGroupId(projectGroupId); + const historyPage = await getChatMessages({ + projectGroupId: requiredProjectGroupId, + size: 30, + }); + const cachedPage = queryClient.getQueryData( + chatQueryKeys.messages(requiredProjectGroupId), + ); + + return mergeChatMessagePages(historyPage, cachedPage); + }, + queryKey, + 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], + }; +} + +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."); + } + + 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..08abd6c --- /dev/null +++ b/src/features/team/hooks/use-project-group-chat.ts @@ -0,0 +1,182 @@ +import { Client, type IMessage } from "@stomp/stompjs"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { ensureFreshAuthSession } from "@/lib/api/client"; +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(() => { + let isActiveEffect = true; + + if (!enabled || typeof projectGroupId !== "number") { + setConnectionState("idle"); + setConnectionMessage(null); + return; + } + + if (apiConfig.useMocks) { + setConnectionState("connected"); + setConnectionMessage(null); + return; + } + + const client = new Client({ + 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, + 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 () => { + isActiveEffect = false; + 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/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 = "요청 처리 중 오류가 발생했습니다.", diff --git a/src/lib/api/mocks/handlers.ts b/src/lib/api/mocks/handlers.ts index 250efcd..3b10c2f 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, @@ -67,6 +68,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 = @@ -273,6 +275,7 @@ function resetTeamSpaceApiState() { activeProjectChecklists = activeProjectGroup ? createMockProjectChecklists() : []; + activeChatMessages = activeProjectGroup ? createMockChatMessages() : []; activeDevGuide = createMockDevGuide(activeProjectGroup); activeDevGuideGenerationStatus = activeDevGuide ? "COMPLETED" : null; remainingDevGuideRegenerationCount = activeDevGuide ? 3 : null; @@ -759,6 +762,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, @@ -2073,6 +2122,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; +}