From c42abd52cfa4906bfff6b961ab7f7e69f5aa99f3 Mon Sep 17 00:00:00 2001 From: Goder-0 Date: Fri, 5 Jun 2026 22:01:05 +0900 Subject: [PATCH] Refactor: migrate IDs to hashids (#517) --- src/apis/chatApi.ts | 9 +- src/apis/chatSocket.ts | 28 +++--- src/apis/linkApi.ts | 23 ++--- src/apis/summary.ts | 3 +- src/apis/summarySocket.ts | 19 ++-- src/app/(dev)/chat-api-demo/ChatApiDemo.tsx | 5 +- src/app/(dev)/link-api-demo/LinkApiDemo.tsx | 19 ++-- src/app/(dev)/mock-chat/MockChatPage.tsx | 11 ++- src/app/(route)/all-link/AllLink.tsx | 39 ++++---- src/app/(route)/chat/[id]/ChatPage.tsx | 18 ++-- src/app/api/chats/[id]/route.ts | 39 ++------ src/app/api/links/[id]/retry-summary/route.ts | 7 +- .../api/links/[id]/summary-status/route.ts | 11 +-- src/app/api/links/[id]/summary/route.ts | 12 +-- .../messages/[messageId]/feedback/route.ts | 20 ---- .../LinkCard/components/DeleteLinkModal.tsx | 5 +- .../components/ChatRoomSection/ChatItem.tsx | 3 +- .../ChatRoomSection/DeleteChatModal.tsx | 3 +- .../AddLink/hooks/useDuplicateCheck.ts | 5 +- .../components/MenuSection/AddLink/index.tsx | 3 +- .../LinkCardDetailPanel.tsx | 3 +- .../Sections/MemoSection.tsx | 3 +- .../Sections/SummarySection.tsx | 3 +- .../Sections/TitleSection.tsx | 3 +- .../ReSummaryModal/ReSummaryModal.tsx | 3 +- src/hooks/server/Chats/useChatStream.ts | 3 +- src/hooks/server/Chats/useDeleteChat.ts | 3 +- src/hooks/server/Chats/useStompChat.ts | 17 ++-- src/hooks/useDeleteChat.ts | 3 +- src/hooks/useDeleteLink.ts | 3 +- src/hooks/useGetInfiniteLinks.ts | 3 +- src/hooks/useGetLink.ts | 3 +- src/hooks/useReSummary.ts | 3 +- src/hooks/useRetrySummary.ts | 3 +- src/hooks/useSelectSummary.ts | 3 +- src/hooks/useUpdateLink.ts | 3 +- src/hooks/useUpdateLinkMemo.ts | 3 +- src/hooks/useUpdateLinkTitle.ts | 3 +- src/mocks/fixtures/chats.ts | 19 ++-- src/mocks/fixtures/links.ts | 91 ++++++++++--------- src/stores/linkStore.ts | 7 +- src/stores/modalStore.ts | 27 ++++-- src/stories/LinkCardDetailPanel.stories.tsx | 10 +- src/stories/Modal.stories.tsx | 4 +- src/stories/ReSummaryModal.stories.tsx | 4 +- src/types/api/chatApi.ts | 14 +-- src/types/api/linkApi.ts | 13 +-- src/types/api/summaryApi.ts | 7 +- src/types/id.ts | 1 + src/types/link.ts | 4 +- 50 files changed, 272 insertions(+), 279 deletions(-) create mode 100644 src/types/id.ts diff --git a/src/apis/chatApi.ts b/src/apis/chatApi.ts index 9fde71e8..1c6fe90d 100644 --- a/src/apis/chatApi.ts +++ b/src/apis/chatApi.ts @@ -11,6 +11,7 @@ import type { CreateChatPayload, DeleteChatApiResponse, } from '@/types/api/chatApi'; +import type { EntityId } from '@/types/id'; export const fetchChats = async (): Promise => { const response = await clientApiClient('/api/chats'); @@ -41,7 +42,7 @@ export const createChat = async (payload: CreateChatPayload): Promise return response.data; }; -export const deleteChat = async (id: number): Promise => { +export const deleteChat = async (id: EntityId): Promise => { const response = await clientApiClient(`/api/chats/${id}`, { method: 'DELETE', }); @@ -54,9 +55,9 @@ export const deleteChat = async (id: number): Promise => }; type FetchChatMessagesParams = { - chatId: number; + chatId: EntityId; size?: number; - lastId?: number | null; + lastId?: EntityId | null; }; export const fetchChatMessages = async ({ @@ -84,7 +85,7 @@ export const fetchChatMessages = async ({ }; export const addMessageFeedback = async ( - messageId: number, + messageId: EntityId, payload: AddMessageFeedbackPayload ): Promise => { const response = await clientApiClient( diff --git a/src/apis/chatSocket.ts b/src/apis/chatSocket.ts index e9f82b20..1ffbef2c 100644 --- a/src/apis/chatSocket.ts +++ b/src/apis/chatSocket.ts @@ -1,4 +1,5 @@ import { fetchSocketAuthState, isSocketAuthFailure } from '@/lib/client/socketAuth'; +import type { EntityId } from '@/types/id'; import { Client, type IFrame, @@ -20,7 +21,7 @@ const withAuthorizationHeader = (authorization: string | null): StompHeaders => type Callback = () => void; export type ChatSocketLink = { - linkId: number; + linkId: EntityId; title: string; url: string; imageUrl: string | null; @@ -29,8 +30,8 @@ export type ChatSocketLink = { export type ChatSocketMessage = { success: boolean; - chatId: number; - messageId: number | null; + chatId: EntityId; + messageId: EntityId | null; content: string; isEnd: boolean; step: string | string[] | null; @@ -38,7 +39,7 @@ export type ChatSocketMessage = { }; export type ChatSocketOptions = { - chatId: string | number; + chatId: EntityId; useSockJS?: boolean; onMessage: (payload: ChatSocketMessage) => void; onError?: (err: unknown) => void; @@ -72,12 +73,9 @@ const toWebSocketUrl = (url: string) => { return url.replace(/^http(s?):\/\//i, (_, secure) => `ws${secure ?? ''}://`); }; -const toNumberOrNull = (value: unknown) => { - if (typeof value === 'number' && Number.isFinite(value)) return value; - if (typeof value === 'string') { - const parsed = Number(value); - return Number.isFinite(parsed) ? parsed : null; - } +const toEntityIdOrNull = (value: unknown): EntityId | null => { + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + if (typeof value === 'string' && value.trim().length > 0) return value; return null; }; @@ -96,7 +94,7 @@ const toLinksOrNull = (value: unknown): ChatSocketLink[] | null => { .map(item => { if (!item || typeof item !== 'object') return null; const link = item as Record; - const linkId = toNumberOrNull(link.linkId) ?? toNumberOrNull(link.id); + const linkId = toEntityIdOrNull(link.linkId) ?? toEntityIdOrNull(link.id); const title = typeof link.title === 'string' ? link.title : ''; const url = typeof link.url === 'string' ? link.url : ''; if (linkId === null || !title || !url) return null; @@ -122,13 +120,13 @@ const parseIncomingMessage = (rawBody: string): ChatSocketMessage => { throw new Error('Invalid socket payload: success is missing.'); } - const chatId = toNumberOrNull(data.chatId); + const chatId = toEntityIdOrNull(data.chatId); if (chatId === null) { throw new Error('Invalid socket payload: chatId is missing.'); } const content = typeof data.content === 'string' ? data.content : ''; - const messageId = toNumberOrNull(data.messageId); + const messageId = toEntityIdOrNull(data.messageId); const isEnd = typeof data.isEnd === 'boolean' ? data.isEnd : false; const step = toStepOrNull(data.step); const links = toLinksOrNull(data.links); @@ -323,7 +321,7 @@ export const createChatSocket = (options: ChatSocketOptions): ChatSocket => { const send = async (message: string) => { await ensureReadyToPublish(); - const body = JSON.stringify({ chatId: Number(chatId), message }); + const body = JSON.stringify({ chatId: String(chatId), message }); logWsDebug('send', { destination: SEND_DEST, body }); client.publish({ destination: SEND_DEST, @@ -334,7 +332,7 @@ export const createChatSocket = (options: ChatSocketOptions): ChatSocket => { const cancel = async () => { await ensureReadyToPublish(); - const body = JSON.stringify({ chatId: Number(chatId) }); + const body = JSON.stringify({ chatId: String(chatId) }); logWsDebug('cancel', { destination: CANCEL_DEST, body }); client.publish({ destination: CANCEL_DEST, diff --git a/src/apis/linkApi.ts b/src/apis/linkApi.ts index 40a9f030..37688417 100644 --- a/src/apis/linkApi.ts +++ b/src/apis/linkApi.ts @@ -12,12 +12,13 @@ import type { LinkSummaryStatusData, SummaryStatusResponse, } from '@/types/api/linkApi'; +import type { EntityId } from '@/types/id'; import type { CreateLinkPayload, Link, LinkSummaryStatus, UpdateLinkPayload } from '@/types/link'; const LINKS_BFF = '/api/links'; export type LinkListParams = { - lastId?: number | null; + lastId?: EntityId | null; size?: number; }; @@ -39,7 +40,7 @@ const hasText = (value: string | null | undefined): value is string => typeof value === 'string' && value.trim().length > 0; export const resolveSummaryContent = ( - rawSummary: { id?: number; content?: string } | string | null | undefined + rawSummary: { id?: EntityId; content?: string } | string | null | undefined ): string => { if (rawSummary !== null && typeof rawSummary === 'object') { return typeof rawSummary.content === 'string' ? rawSummary.content : ''; @@ -86,10 +87,10 @@ function buildQuery(params?: LinkListParams) { } type LinkSource = { - id?: number; + id?: EntityId; url?: string; title?: string; - summary?: { id: number; content: string } | string | null; + summary?: { id: EntityId; content: string } | string | null; summaryStatus?: unknown; summaryErrorMessage?: string | null; summaryProgress?: number | null; @@ -110,7 +111,7 @@ const normalizeLink = (data: LinkSource): Link => { ); return { - id: data.id ?? 0, + id: data.id ?? '', url: data.url ?? '', title: data.title ?? '', summary, @@ -169,7 +170,7 @@ export const createLink = async (payload: CreateLinkPayload): Promise => { return normalizeLink(body.data); }; -export const fetchLink = async (id: number): Promise => { +export const fetchLink = async (id: EntityId): Promise => { const body = await clientApiClient(`${LINKS_BFF}/${id}`); if (!body?.data || !body.success) { @@ -179,7 +180,7 @@ export const fetchLink = async (id: number): Promise => { return normalizeLink(body.data); }; -export const updateLink = async (id: number, payload: UpdateLinkPayload): Promise => { +export const updateLink = async (id: EntityId, payload: UpdateLinkPayload): Promise => { const body = await clientApiClient(`${LINKS_BFF}/${id}`, { method: 'PUT', body: JSON.stringify(payload), @@ -192,7 +193,7 @@ export const updateLink = async (id: number, payload: UpdateLinkPayload): Promis return normalizeLink(body.data); }; -export const updateLinkTitle = async (id: number, title: string): Promise => { +export const updateLinkTitle = async (id: EntityId, title: string): Promise => { const body = await clientApiClient(`${LINKS_BFF}/${id}/title`, { method: 'PATCH', body: JSON.stringify({ title }), @@ -205,7 +206,7 @@ export const updateLinkTitle = async (id: number, title: string): Promise return normalizeLink(body.data); }; -export const updateLinkMemo = async (id: number, memo: string): Promise => { +export const updateLinkMemo = async (id: EntityId, memo: string): Promise => { const body = await clientApiClient(`${LINKS_BFF}/${id}/memo`, { method: 'PATCH', body: JSON.stringify({ memo }), @@ -218,7 +219,7 @@ export const updateLinkMemo = async (id: number, memo: string): Promise => return normalizeLink(body.data); }; -export const deleteLink = async (id: number): Promise => { +export const deleteLink = async (id: EntityId): Promise => { const body = await clientApiClient(`${LINKS_BFF}/${id}`, { method: 'DELETE', }); @@ -257,7 +258,7 @@ export const scrapeLinkMeta = async (url: string) => { return response.data; }; -export const fetchLinkSummaryStatus = async (id: number): Promise => { +export const fetchLinkSummaryStatus = async (id: EntityId): Promise => { const body = await clientApiClient( `/api/links/${id}/summary-status`, { diff --git a/src/apis/summary.ts b/src/apis/summary.ts index 9a299d64..bc32299a 100644 --- a/src/apis/summary.ts +++ b/src/apis/summary.ts @@ -6,8 +6,9 @@ import type { SelectSummaryResponse, SummaryResponse, } from '@/types/api/summaryApi'; +import type { EntityId } from '@/types/id'; -export const retrySummary = async (id: number) => { +export const retrySummary = async (id: EntityId) => { const body = await clientApiClient(`/api/links/${id}/retry-summary`, { method: 'POST', }); diff --git a/src/apis/summarySocket.ts b/src/apis/summarySocket.ts index 6048fe91..594d54ca 100644 --- a/src/apis/summarySocket.ts +++ b/src/apis/summarySocket.ts @@ -1,6 +1,7 @@ 'use client'; import { fetchSocketAuthState, isSocketAuthFailure } from '@/lib/client/socketAuth'; +import type { EntityId } from '@/types/id'; import { Client, type IFrame, @@ -26,19 +27,19 @@ const withAuthorizationHeader = (authorization: string | null): StompHeaders => authorization ? { Authorization: authorization } : {}; export type SummaryStatusPayload = { - linkId: number; + linkId: EntityId; status: 'PENDING' | 'PROCESSING' | 'COMPLETED' | 'FAILED'; progress?: number; - summary?: { id?: number; content?: string } | string | null; + summary?: { id?: EntityId; content?: string } | string | null; errorMessage?: string; updatedAt?: string; - data?: { id?: number; content?: string } | string | null; + data?: { id?: EntityId; content?: string } | string | null; }; export type LinkSummaryStatus = 'idle' | 'generating' | 'ready' | 'failed'; export type SummaryStatusEvent = { - linkId: number; + linkId: EntityId; status: LinkSummaryStatus; progress?: number; summary?: string; @@ -91,14 +92,20 @@ const resolveSummaryText = ( return typeof rawSummary === 'string' ? rawSummary : undefined; }; +const toEntityIdOrNull = (value: unknown): EntityId | null => { + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + if (typeof value === 'string' && value.trim().length > 0) return value; + return null; +}; + const parsePayload = (rawBody: string): SummaryStatusEvent => { const payload = JSON.parse(rawBody) as SummaryStatusPayload; if (typeof payload !== 'object' || payload === null) { throw new Error('Invalid summary status event payload'); } - const linkId = Number(payload.linkId); - if (!Number.isFinite(linkId)) { + const linkId = toEntityIdOrNull(payload.linkId); + if (linkId === null) { throw new Error('Invalid summary status event: missing linkId.'); } diff --git a/src/app/(dev)/chat-api-demo/ChatApiDemo.tsx b/src/app/(dev)/chat-api-demo/ChatApiDemo.tsx index bd06df64..cb320a39 100644 --- a/src/app/(dev)/chat-api-demo/ChatApiDemo.tsx +++ b/src/app/(dev)/chat-api-demo/ChatApiDemo.tsx @@ -7,6 +7,7 @@ import Label from '@/components/basics/Label/Label'; import TextArea from '@/components/basics/TextArea/TextArea'; import { useChatStream } from '@/hooks/server/Chats/useChatStream'; import type { ChatRoom } from '@/types/api/chatApi'; +import type { EntityId } from '@/types/id'; import { useCallback, useEffect, useMemo, useState } from 'react'; const defaultForm = { firstChat: '' }; @@ -41,7 +42,7 @@ export default function ChatApiDemo() { const [creating, setCreating] = useState(false); const [createError, setCreateError] = useState(null); - const [selectedChatId, setSelectedChatId] = useState(null); + const [selectedChatId, setSelectedChatId] = useState(null); const [streamLog, setStreamLog] = useState([]); const [question, setQuestion] = useState(''); const [streamEnabled, setStreamEnabled] = useState(false); @@ -53,7 +54,7 @@ export default function ChatApiDemo() { chatId: chatIdForSocket, enabled: streamEnabled && Boolean(chatIdForSocket), onMessage: payload => { - if (payload.chatId !== selectedChatId) return; + if (selectedChatId !== null && payload.chatId !== selectedChatId) return; setStreamLog(prev => [ ...prev, diff --git a/src/app/(dev)/link-api-demo/LinkApiDemo.tsx b/src/app/(dev)/link-api-demo/LinkApiDemo.tsx index f9eb161c..68166b7d 100644 --- a/src/app/(dev)/link-api-demo/LinkApiDemo.tsx +++ b/src/app/(dev)/link-api-demo/LinkApiDemo.tsx @@ -10,6 +10,7 @@ import { useGetLinks } from '@/hooks/useGetLinks'; import { usePostLinks } from '@/hooks/usePostLinks'; import { useUpdateLinkMemo } from '@/hooks/useUpdateLinkMemo'; import { useUpdateLinkTitle } from '@/hooks/useUpdateLinkTitle'; +import type { EntityId } from '@/types/id'; import type { Link } from '@/types/link'; import { useEffect, useMemo, useState } from 'react'; @@ -53,19 +54,15 @@ export default function LinkApiDemo() { } }; - const handleDelete = async (id: number) => { - try { - await deleteMut.mutateAsync(id); - } catch { - // 에러는 LinkCardRow 혹은 deleteMut.isError를 통해 표시 - } + const handleDelete = async (id: EntityId) => { + await deleteMut.mutateAsync(id); }; - const handleUpdateTitle = async (id: number, title: string) => { + const handleUpdateTitle = async (id: EntityId, title: string) => { await updateTitleMut.mutateAsync({ id, title }); }; - const handleUpdateMemo = async (id: number, memo: string) => { + const handleUpdateMemo = async (id: EntityId, memo: string) => { await updateMemoMut.mutateAsync({ id, memo }); }; @@ -200,9 +197,9 @@ function LinkCardRow({ onUpdateMemo, }: { link: Link; - onDelete: (id: number) => Promise; - onUpdateTitle: (id: number, title: string) => Promise; - onUpdateMemo: (id: number, memo: string) => Promise; + onDelete: (id: EntityId) => Promise; + onUpdateTitle: (id: EntityId, title: string) => Promise; + onUpdateMemo: (id: EntityId, memo: string) => Promise; }) { const [nextTitle, setNextTitle] = useState(link.title); const [nextMemo, setNextMemo] = useState(link.memo ?? ''); diff --git a/src/app/(dev)/mock-chat/MockChatPage.tsx b/src/app/(dev)/mock-chat/MockChatPage.tsx index a97e435f..62ff6105 100644 --- a/src/app/(dev)/mock-chat/MockChatPage.tsx +++ b/src/app/(dev)/mock-chat/MockChatPage.tsx @@ -9,13 +9,14 @@ import LinkCardDetailPanel from '@/components/wrappers/LinkCardDetailPanel/LinkC import ReportModal from '@/components/wrappers/ReportModal/ReportModal'; import { useModalStore } from '@/stores/modalStore'; import { showToast } from '@/stores/toastStore'; +import type { EntityId } from '@/types/id'; import { useState } from 'react'; import AnswerActions, { type AnswerReaction } from '../../(route)/chat/_components/AnswerActions'; import ChatQueryBox from '../../(route)/chat/_components/ChatQueryBox'; type ChatLink = { - linkId: number; + linkId: EntityId; title: string; url: string; imageUrl: string | null; @@ -24,7 +25,7 @@ type ChatLink = { type ChatMessage = { id: string; - messageId?: number | null; + messageId?: EntityId | null; role: 'user' | 'ai'; text: string; links?: ChatLink[] | null; @@ -36,7 +37,7 @@ const MOCK_RESPONSE = { '네이버 쇼핑(플러스 스토어) 접속 오류가 반복될 때는 서비스 공지 확인, 앱/브라우저 재시도, 고객센터 문의 순서로 점검하는 것이 좋습니다. 계정 자체 문제보다는 일시적인 장애일 가능성이 높습니다.', links: [ { - linkId: 46, + linkId: '46', title: '네이버 쇼핑', url: 'https://shopping.naver.com/', imageUrl: @@ -49,7 +50,7 @@ const MOCK_RESPONSE = { const createAiMockMessage = (): ChatMessage => ({ id: `${Date.now()}-${crypto.randomUUID()}`, - messageId: Date.now(), + messageId: String(Date.now()), role: 'ai', text: MOCK_RESPONSE.content, links: MOCK_RESPONSE.links, @@ -62,7 +63,7 @@ export default function MockChatPage() { const [messages, setMessages] = useState([ { id: 'mock-initial', - messageId: 1, + messageId: '1', role: 'ai', text: '목업 채팅 페이지입니다. 질문을 보내면 API 없이 고정 응답이 바로 표시됩니다.', links: MOCK_RESPONSE.links, diff --git a/src/app/(route)/all-link/AllLink.tsx b/src/app/(route)/all-link/AllLink.tsx index b70744bb..127278d6 100644 --- a/src/app/(route)/all-link/AllLink.tsx +++ b/src/app/(route)/all-link/AllLink.tsx @@ -18,6 +18,7 @@ import { useSummaryStatusSocket } from '@/hooks/useSummaryStatusSocket'; import { ApiError } from '@/lib/errors/ApiError'; import { useLinkStore } from '@/stores/linkStore'; import { useModalStore } from '@/stores/modalStore'; +import type { EntityId } from '@/types/id'; import type { LinkSummaryStatus } from '@/types/link'; import { type Link } from '@/types/link'; import { useQueryClient } from '@tanstack/react-query'; @@ -111,8 +112,8 @@ const LinkCardItem = memo( summaryStatus: LinkSummaryStatus; summaryText: string; summaryErrorMessage?: string; - onSelect: (id: number) => void; - onOpen: (id: number) => void; + onSelect: (id: EntityId) => void; + onOpen: (id: EntityId) => void; }) { const handleClick = useCallback(() => onOpen(item.id), [item.id, onOpen]); const handleSelect = useCallback(() => onSelect(item.id), [item.id, onSelect]); @@ -146,9 +147,9 @@ export default function AllLink() { const { selectedLinkId, selectLink } = useLinkStore(); const [isPanelOpen, setIsPanelOpen] = useState(false); const [isSocketConnected, setIsSocketConnected] = useState(false); - const [selectedIds, setSelectedIds] = useState>(new Set()); + const [selectedIds, setSelectedIds] = useState>(new Set()); const [summaryStatusByLinkId, setSummaryStatusByLinkId] = useState< - Record + Record >({}); const { modal, open } = useModalStore(); @@ -156,15 +157,15 @@ export default function AllLink() { const deleteButtonRef = useRef(null); const { count } = useLinkCount(); - const processingLinkIdsRef = useRef>(new Set()); - const polledUnknownLinkIdsRef = useRef>(new Set()); - const nonRetryablePollLinkIdsRef = useRef>(new Set()); - const generatingPollAttemptsRef = useRef>(new Map()); - const generatingPollSnapshotsRef = useRef>(new Map()); - const exhaustedGeneratingLinkIdsRef = useRef>(new Set()); + const processingLinkIdsRef = useRef>(new Set()); + const polledUnknownLinkIdsRef = useRef>(new Set()); + const nonRetryablePollLinkIdsRef = useRef>(new Set()); + const generatingPollAttemptsRef = useRef>(new Map()); + const generatingPollSnapshotsRef = useRef>(new Map()); + const exhaustedGeneratingLinkIdsRef = useRef>(new Set()); const linksRef = useRef([]); - const summaryStatusByLinkIdRef = useRef>({}); - const selectedLinkIdRef = useRef(selectedLinkId); + const summaryStatusByLinkIdRef = useRef>({}); + const selectedLinkIdRef = useRef(selectedLinkId); const queryClient = useQueryClient(); const { data, isLoading, isError, fetchNextPage, hasNextPage, isFetchingNextPage, refetch } = @@ -323,7 +324,7 @@ export default function AllLink() { const linksSnapshot = linksRef.current; const statusSnapshot = summaryStatusByLinkIdRef.current; - const targetIds: number[] = []; + const targetIds: EntityId[] = []; for (const link of linksSnapshot) { if (polledUnknownLinkIdsRef.current.has(link.id)) continue; @@ -360,7 +361,7 @@ export default function AllLink() { let shouldRefetchLinks = false; let shouldRefetchSelectedLink = false; const summaryUpdates: Array<{ - linkId: number; + linkId: EntityId; data: NonNullable>>; status: LinkSummaryStatus; summaryText: string; @@ -468,8 +469,8 @@ export default function AllLink() { const linksSnapshot = linksRef.current; const statusSnapshot = summaryStatusByLinkIdRef.current; - const targetModes = new Map(); - const targetIds: number[] = []; + const targetModes = new Map(); + const targetIds: EntityId[] = []; for (const link of linksSnapshot) { if (nonRetryablePollLinkIdsRef.current.has(link.id)) continue; @@ -529,7 +530,7 @@ export default function AllLink() { let shouldRefetchLinks = false; let shouldRefetchSelectedLink = false; const summaryUpdates: Array<{ - linkId: number; + linkId: EntityId; data: NonNullable>>; status: LinkSummaryStatus; summaryText: string; @@ -683,7 +684,7 @@ export default function AllLink() { ); const handleSelectLink = useCallback( - (id: number) => { + (id: EntityId) => { selectLink(id); setIsPanelOpen(true); }, @@ -696,7 +697,7 @@ export default function AllLink() { } }, [selectedLinkId]); - const handleToggleSelect = useCallback((id: number) => { + const handleToggleSelect = useCallback((id: EntityId) => { setSelectedIds(prev => { const next = new Set(prev); diff --git a/src/app/(route)/chat/[id]/ChatPage.tsx b/src/app/(route)/chat/[id]/ChatPage.tsx index a6d681ce..97e36cfc 100644 --- a/src/app/(route)/chat/[id]/ChatPage.tsx +++ b/src/app/(route)/chat/[id]/ChatPage.tsx @@ -13,6 +13,7 @@ import { useChatStream } from '@/hooks/server/Chats/useChatStream'; import { useModalStore } from '@/stores/modalStore'; import { showToast } from '@/stores/toastStore'; import type { ChatHistoryMessage } from '@/types/api/chatApi'; +import type { EntityId } from '@/types/id'; import { useParams, useSearchParams } from 'next/navigation'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -21,7 +22,7 @@ import ChatQueryBox from '../_components/ChatQueryBox'; type ChatMessage = { id: string; - messageId?: number | null; + messageId?: EntityId | null; role: 'user' | 'ai' | 'system'; text: string; links?: ChatSocketLink[] | null; @@ -71,7 +72,6 @@ export default function Chat() { const params = useParams(); const searchParams = useSearchParams(); const chatId = useMemo(() => (typeof params?.id === 'string' ? params.id : ''), [params]); - const chatIdNum = useMemo(() => Number(chatId), [chatId]); const initialQuestion = useMemo(() => searchParams.get('q')?.trim() ?? '', [searchParams]); const initialSentRef = useRef(false); const modal = useModalStore(state => state.modal); @@ -81,7 +81,7 @@ export default function Chat() { const [isAwaitingResponse, setIsAwaitingResponse] = useState(false); const [streamError, setStreamError] = useState(null); const [selectedLink, setSelectedLink] = useState(null); - const [historyCursor, setHistoryCursor] = useState(null); + const [historyCursor, setHistoryCursor] = useState(null); const [historyHasNext, setHistoryHasNext] = useState(false); const [historyLoading, setHistoryLoading] = useState(false); const [historyBootstrapped, setHistoryBootstrapped] = useState(false); @@ -269,14 +269,14 @@ export default function Chat() { }); const loadInitialHistory = useCallback(async () => { - if (!chatId || Number.isNaN(chatIdNum)) return; + if (!chatId) return; const requestSeq = historyRequestSeqRef.current + 1; historyRequestSeqRef.current = requestSeq; setHistoryLoading(true); setHistoryBootstrapped(false); try { - const data = await fetchChatMessages({ chatId: chatIdNum, size: PAGE_SIZE, lastId: null }); + const data = await fetchChatMessages({ chatId, size: PAGE_SIZE, lastId: null }); if (requestSeq !== historyRequestSeqRef.current) return; const normalized = data.messages.map(mapHistoryMessage).reverse(); setMessages(prev => { @@ -311,10 +311,10 @@ export default function Chat() { setHistoryLoading(false); } } - }, [chatId, chatIdNum]); + }, [chatId]); const loadOlderHistory = useCallback(async () => { - if (!chatId || Number.isNaN(chatIdNum)) return; + if (!chatId) return; if (!historyHasNext || olderHistoryInFlightRef.current) return; const root = scrollRootRef.current; @@ -326,7 +326,7 @@ export default function Chat() { setHistoryLoading(true); try { const data = await fetchChatMessages({ - chatId: chatIdNum, + chatId, size: PAGE_SIZE, lastId: historyCursor, }); @@ -351,7 +351,7 @@ export default function Chat() { setHistoryLoading(false); } } - }, [chatId, chatIdNum, historyCursor, historyHasNext]); + }, [chatId, historyCursor, historyHasNext]); useEffect(() => { historyRequestSeqRef.current += 1; diff --git a/src/app/api/chats/[id]/route.ts b/src/app/api/chats/[id]/route.ts index 42d7dd60..22352802 100644 --- a/src/app/api/chats/[id]/route.ts +++ b/src/app/api/chats/[id]/route.ts @@ -2,7 +2,7 @@ import { handleApiError } from '@/hooks/util/api'; import { serverApiClient } from '@/lib/server/apiClient'; import { NextResponse } from 'next/server'; -const parseOptionalInt = (value: string | null) => { +const parseOptionalSize = (value: string | null) => { if (value === null) return { valid: true, parsed: null as number | null }; if (!/^\d+$/.test(value)) return { valid: false, parsed: null as number | null }; @@ -11,40 +11,32 @@ const parseOptionalInt = (value: string | null) => { return { valid: true, parsed }; }; -const parseRequiredInt = (value: string) => { - if (!/^\d+$/.test(value)) return { valid: false, parsed: null as number | null }; - - const parsed = Number(value); - if (!Number.isSafeInteger(parsed)) return { valid: false, parsed: null as number | null }; - return { valid: true, parsed }; -}; - export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) { try { const { id } = await params; - const parsedId = parseRequiredInt(id); + const safeId = encodeURIComponent(id); const { searchParams } = new URL(req.url); const lastId = searchParams.get('lastId'); const size = searchParams.get('size'); - const parsedLastId = parseOptionalInt(lastId); - const parsedSize = parseOptionalInt(size); + const parsedSize = parseOptionalSize(size); + const trimmedLastId = lastId?.trim() ?? null; - if (!parsedId.valid || !parsedLastId.valid || !parsedSize.valid) { + if (!parsedSize.valid) { return NextResponse.json( { success: false, status: 'BAD_REQUEST', - message: 'Invalid path/query parameter: id, lastId and size must be numeric.', + message: 'Invalid query parameter: size must be numeric.', }, { status: 400 } ); } const qs = new URLSearchParams(); - if (parsedLastId.parsed !== null) qs.set('lastId', String(parsedLastId.parsed)); + if (trimmedLastId) qs.set('lastId', trimmedLastId); if (parsedSize.parsed !== null) qs.set('size', String(parsedSize.parsed)); - const endpoint = qs.toString() ? `/v1/chats/${id}?${qs}` : `/v1/chats/${id}`; + const endpoint = qs.toString() ? `/v1/chats/${safeId}?${qs}` : `/v1/chats/${safeId}`; const data = await serverApiClient(endpoint); return NextResponse.json(data); } catch (err) { @@ -55,19 +47,8 @@ export async function GET(req: Request, { params }: { params: Promise<{ id: stri export async function DELETE(_req: Request, { params }: { params: Promise<{ id: string }> }) { try { const { id } = await params; - const parsedId = parseRequiredInt(id); - if (!parsedId.valid) { - return NextResponse.json( - { - success: false, - status: 'BAD_REQUEST', - message: 'Invalid path parameter: id must be numeric.', - }, - { status: 400 } - ); - } - - const data = await serverApiClient(`/v1/chats/${id}`, { + const safeId = encodeURIComponent(id); + const data = await serverApiClient(`/v1/chats/${safeId}`, { method: 'DELETE', }); return NextResponse.json(data); diff --git a/src/app/api/links/[id]/retry-summary/route.ts b/src/app/api/links/[id]/retry-summary/route.ts index 9c1e2d9c..bb3828f4 100644 --- a/src/app/api/links/[id]/retry-summary/route.ts +++ b/src/app/api/links/[id]/retry-summary/route.ts @@ -4,12 +4,7 @@ import { NextResponse } from 'next/server'; export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { try { - const { id: rawId } = await params; - const id = Number(rawId); - - if (!Number.isInteger(id) || id <= 0) { - return NextResponse.json({ success: false, message: 'Invalid id.' }, { status: 400 }); - } + const { id } = await params; const data = await serverApiClient(`/v1/links/${id}/retry-summary`, { method: 'POST', diff --git a/src/app/api/links/[id]/summary-status/route.ts b/src/app/api/links/[id]/summary-status/route.ts index 171dc8ac..881d2f2c 100644 --- a/src/app/api/links/[id]/summary-status/route.ts +++ b/src/app/api/links/[id]/summary-status/route.ts @@ -23,17 +23,8 @@ const isEndpointMissing404 = (error: unknown): boolean => getErrorStatus(error) export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) { try { const { id } = await params; - const parsedId = Number(id); + const safeId = encodeURIComponent(id); const triedEndpoints = new Set(); - - if (!Number.isSafeInteger(parsedId) || parsedId <= 0) { - return NextResponse.json({ success: false, message: 'Invalid id.' }, { status: 400 }); - } - - // Use the normalized numeric value for upstream requests so inputs like - // "01", " 123 ", "1e3", or "+5" yield consistent routing, logging, and cache keys. - const safeId = String(parsedId); - if (resolvedSummaryStatusEndpoint) { triedEndpoints.add(resolvedSummaryStatusEndpoint); diff --git a/src/app/api/links/[id]/summary/route.ts b/src/app/api/links/[id]/summary/route.ts index 2a26c5bb..4d4870a6 100644 --- a/src/app/api/links/[id]/summary/route.ts +++ b/src/app/api/links/[id]/summary/route.ts @@ -4,11 +4,7 @@ import { NextResponse } from 'next/server'; export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) { try { - const { id: rawId } = await params; - const id = Number(rawId); - if (!id || isNaN(id)) { - return NextResponse.json({ success: false, message: 'Invalid id.' }, { status: 400 }); - } + const { id } = await params; const { searchParams } = new URL(req.url); const format = searchParams.get('format') ?? 'CONCISE'; @@ -29,11 +25,7 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str export async function PATCH(req: Request, { params }: { params: Promise<{ id: string }> }) { try { - const { id: rawId } = await params; - const id = Number(rawId); - if (!id || isNaN(id)) { - return NextResponse.json({ success: false, message: 'Invalid id.' }, { status: 400 }); - } + const { id } = await params; let body; try { diff --git a/src/app/api/messages/[messageId]/feedback/route.ts b/src/app/api/messages/[messageId]/feedback/route.ts index ded11a34..c3afd3f6 100644 --- a/src/app/api/messages/[messageId]/feedback/route.ts +++ b/src/app/api/messages/[messageId]/feedback/route.ts @@ -3,14 +3,6 @@ import { serverApiClient } from '@/lib/server/apiClient'; import { NextResponse } from 'next/server'; import { z } from 'zod'; -const parseRequiredInt = (value: string) => { - if (!/^\d+$/.test(value)) return { valid: false, parsed: null as number | null }; - - const parsed = Number(value); - if (!Number.isSafeInteger(parsed)) return { valid: false, parsed: null as number | null }; - return { valid: true, parsed }; -}; - const feedbackBodySchema = z.object({ sentiment: z.enum(['LIKE', 'DISLIKE', 'NONE']), text: z.string().max(20).optional(), @@ -19,18 +11,6 @@ const feedbackBodySchema = z.object({ export async function PUT(req: Request, { params }: { params: Promise<{ messageId: string }> }) { try { const { messageId } = await params; - const parsedId = parseRequiredInt(messageId); - if (!parsedId.valid) { - return NextResponse.json( - { - success: false, - status: 'BAD_REQUEST', - message: 'Invalid path parameter: messageId must be numeric.', - }, - { status: 400 } - ); - } - const rawBody = await req.json(); const body = feedbackBodySchema.parse(rawBody); const data = await serverApiClient(`/v1/messages/${messageId}/feedback`, { diff --git a/src/components/basics/LinkCard/components/DeleteLinkModal.tsx b/src/components/basics/LinkCard/components/DeleteLinkModal.tsx index 6670da13..9af9dd95 100644 --- a/src/components/basics/LinkCard/components/DeleteLinkModal.tsx +++ b/src/components/basics/LinkCard/components/DeleteLinkModal.tsx @@ -6,19 +6,20 @@ import { useDeleteLink } from '@/hooks/useDeleteLink'; import { getSafeUrl } from '@/hooks/util/getSafeUrl'; import { useModalStore } from '@/stores/modalStore'; import { showToast } from '@/stores/toastStore'; +import type { EntityId } from '@/types/id'; import { useState } from 'react'; import Anchor from '../../Anchor/Anchor'; interface DeleteLinkItem { - id: number; + id: EntityId; title: string; url: string; } interface DeleteLinkModalProps { links: DeleteLinkItem[]; - onSuccess?: (succeededIds: number[]) => void; + onSuccess?: (succeededIds: EntityId[]) => void; } const DeleteLinkModal = ({ links, onSuccess }: DeleteLinkModalProps) => { diff --git a/src/components/layout/SideNavigation/components/ChatRoomSection/ChatItem.tsx b/src/components/layout/SideNavigation/components/ChatRoomSection/ChatItem.tsx index 1f12e720..db232f9b 100644 --- a/src/components/layout/SideNavigation/components/ChatRoomSection/ChatItem.tsx +++ b/src/components/layout/SideNavigation/components/ChatRoomSection/ChatItem.tsx @@ -4,10 +4,11 @@ import Button from '@/components/basics/Button/Button'; import IconButton from '@/components/basics/IconButton/IconButton'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/basics/Popover'; import { useModalStore } from '@/stores/modalStore'; +import type { EntityId } from '@/types/id'; import { useRouter } from 'next/navigation'; interface Props { - id: number; + id: EntityId; label: string; } diff --git a/src/components/layout/SideNavigation/components/ChatRoomSection/DeleteChatModal.tsx b/src/components/layout/SideNavigation/components/ChatRoomSection/DeleteChatModal.tsx index 54b98057..da0fbe90 100644 --- a/src/components/layout/SideNavigation/components/ChatRoomSection/DeleteChatModal.tsx +++ b/src/components/layout/SideNavigation/components/ChatRoomSection/DeleteChatModal.tsx @@ -3,9 +3,10 @@ import Modal from '@/components/basics/Modal/Modal'; import { useDeleteChat } from '@/hooks/server/Chats/useDeleteChat'; import { useModalStore } from '@/stores/modalStore'; import { showToast } from '@/stores/toastStore'; +import type { EntityId } from '@/types/id'; interface Props { - chatId: number; + chatId: EntityId; title: string; } diff --git a/src/components/layout/SideNavigation/components/MenuSection/AddLink/hooks/useDuplicateCheck.ts b/src/components/layout/SideNavigation/components/MenuSection/AddLink/hooks/useDuplicateCheck.ts index 0c7c6e85..31de7b71 100644 --- a/src/components/layout/SideNavigation/components/MenuSection/AddLink/hooks/useDuplicateCheck.ts +++ b/src/components/layout/SideNavigation/components/MenuSection/AddLink/hooks/useDuplicateCheck.ts @@ -1,11 +1,12 @@ import { fetchLink } from '@/apis/linkApi'; import { useDuplicateLinkMutation } from '@/hooks/useCheckDuplicateLink'; +import type { EntityId } from '@/types/id'; import type { Link } from '@/types/link'; import { useEffect, useState } from 'react'; interface UseDuplicateCheckResult { isDuplicate: boolean; - duplicateLinkId: number | null; + duplicateLinkId: EntityId | null; duplicateLinkData: Link | null; } @@ -15,7 +16,7 @@ export function useDuplicateCheck( ): UseDuplicateCheckResult { const duplicateCheck = useDuplicateLinkMutation(); const [isDuplicate, setIsDuplicate] = useState(false); - const [duplicateLinkId, setDuplicateLinkId] = useState(null); + const [duplicateLinkId, setDuplicateLinkId] = useState(null); const [duplicateLinkData, setDuplicateLinkData] = useState(null); useEffect(() => { diff --git a/src/components/layout/SideNavigation/components/MenuSection/AddLink/index.tsx b/src/components/layout/SideNavigation/components/MenuSection/AddLink/index.tsx index 0896bf77..808f9dd4 100644 --- a/src/components/layout/SideNavigation/components/MenuSection/AddLink/index.tsx +++ b/src/components/layout/SideNavigation/components/MenuSection/AddLink/index.tsx @@ -10,6 +10,7 @@ import { MAX_MEMO_LENGTH } from '@/lib/constants/link'; import { useLinkStore } from '@/stores/linkStore'; import { useModalStore } from '@/stores/modalStore'; import { hideToast, showToast } from '@/stores/toastStore'; +import type { EntityId } from '@/types/id'; import { useQueryClient } from '@tanstack/react-query'; import clsx from 'clsx'; import { useRouter } from 'next/navigation'; @@ -75,7 +76,7 @@ const AddLinkModal = () => { }, [isDuplicate, duplicateLinkData, metaLoading, form]); const handleCreateSuccess = useCallback( - (createdLink: { id: number }) => { + (createdLink: { id: EntityId }) => { const toastId = showToast({ message: '링크가 저장되었습니다. 요약 생성을 시작합니다.', variant: 'success', diff --git a/src/components/wrappers/LinkCardDetailPanel/LinkCardDetailPanel.tsx b/src/components/wrappers/LinkCardDetailPanel/LinkCardDetailPanel.tsx index 839039f6..b840f78c 100644 --- a/src/components/wrappers/LinkCardDetailPanel/LinkCardDetailPanel.tsx +++ b/src/components/wrappers/LinkCardDetailPanel/LinkCardDetailPanel.tsx @@ -3,6 +3,7 @@ import { styles } from '@/components/wrappers/LinkCardDetailPanel/LinkCardDetailPanel.style'; import { getSafeUrl } from '@/hooks/util/getSafeUrl'; import { useModalStore } from '@/stores/modalStore'; +import type { EntityId } from '@/types/id'; import ReSummaryModal from '../ReSummaryModal/ReSummaryModal'; import HeaderSection from './Sections/HeaderSection'; @@ -14,7 +15,7 @@ import TitleSection from './Sections/TitleSection'; export type SummaryState = 'idle' | 'loading' | 'writing' | 'error' | 'ready'; interface LinkCardDetailPanelProps { - id: number; + id: EntityId; url: string; title: string; summary: string; diff --git a/src/components/wrappers/LinkCardDetailPanel/Sections/MemoSection.tsx b/src/components/wrappers/LinkCardDetailPanel/Sections/MemoSection.tsx index 6798b3a7..178daa84 100644 --- a/src/components/wrappers/LinkCardDetailPanel/Sections/MemoSection.tsx +++ b/src/components/wrappers/LinkCardDetailPanel/Sections/MemoSection.tsx @@ -5,13 +5,14 @@ import TextArea from '@/components/basics/TextArea/TextArea'; import Tooltip from '@/components/basics/Tooltip/Tooltip'; import { useUpdateLinkMemo } from '@/hooks/useUpdateLinkMemo'; import { MAX_MEMO_LENGTH } from '@/lib/constants/link'; +import type { EntityId } from '@/types/id'; import { useEffect, useRef, useState } from 'react'; import CopyButton from '../../CopyButton'; import { styles } from '../LinkCardDetailPanel.style'; interface MemoSectionProps { - linkId: number; + linkId: EntityId; memo: string; } diff --git a/src/components/wrappers/LinkCardDetailPanel/Sections/SummarySection.tsx b/src/components/wrappers/LinkCardDetailPanel/Sections/SummarySection.tsx index b93433f7..17cde7ca 100644 --- a/src/components/wrappers/LinkCardDetailPanel/Sections/SummarySection.tsx +++ b/src/components/wrappers/LinkCardDetailPanel/Sections/SummarySection.tsx @@ -6,6 +6,7 @@ import ProgressNotification from '@/components/basics/ProgressNotification/Progr import useRetrySummary from '@/hooks/useRetrySummary'; import MarkdownRenderer from '@/hooks/util/parseMarkdown'; import { useModalStore } from '@/stores/modalStore'; +import type { EntityId } from '@/types/id'; import { useEffect, useRef, useState } from 'react'; import CopyButton from '../../CopyButton'; @@ -13,7 +14,7 @@ import { SummaryState } from '../LinkCardDetailPanel'; import { styles } from '../LinkCardDetailPanel.style'; interface SummarySectionProps { - linkId: number; + linkId: EntityId; summary: string; summaryState: SummaryState; summaryErrorMessage?: string; diff --git a/src/components/wrappers/LinkCardDetailPanel/Sections/TitleSection.tsx b/src/components/wrappers/LinkCardDetailPanel/Sections/TitleSection.tsx index 3e83370f..5baec055 100644 --- a/src/components/wrappers/LinkCardDetailPanel/Sections/TitleSection.tsx +++ b/src/components/wrappers/LinkCardDetailPanel/Sections/TitleSection.tsx @@ -3,13 +3,14 @@ import TextArea from '@/components/basics/TextArea/TextArea'; import Tooltip from '@/components/basics/Tooltip/Tooltip'; import { useUpdateLinkTitle } from '@/hooks/useUpdateLinkTitle'; import { MAX_TITLE_LENGTH } from '@/lib/constants/link'; +import type { EntityId } from '@/types/id'; import { useEffect, useRef, useState } from 'react'; import CopyButton from '../../CopyButton'; import { styles } from '../LinkCardDetailPanel.style'; interface TitleSectionProps { - linkId: number; + linkId: EntityId; title: string; onTitleChange?: (value: string) => void; } diff --git a/src/components/wrappers/ReSummaryModal/ReSummaryModal.tsx b/src/components/wrappers/ReSummaryModal/ReSummaryModal.tsx index b246a952..898b2cd4 100644 --- a/src/components/wrappers/ReSummaryModal/ReSummaryModal.tsx +++ b/src/components/wrappers/ReSummaryModal/ReSummaryModal.tsx @@ -7,13 +7,14 @@ import ProgressNotification from '@/components/basics/ProgressNotification/Progr import useReSummary from '@/hooks/useReSummary'; import useSelectSummary from '@/hooks/useSelectSummary'; import MarkdownRenderer from '@/hooks/util/parseMarkdown'; +import type { EntityId } from '@/types/id'; import clsx from 'clsx'; import { useEffect } from 'react'; import PostReSummaryButton from './PostReSummaryButton'; interface ReSummaryProps { - linkId: number; + linkId: EntityId; } export default function ReSummaryModal({ linkId }: ReSummaryProps) { diff --git a/src/hooks/server/Chats/useChatStream.ts b/src/hooks/server/Chats/useChatStream.ts index 2e8b8cc3..1e04aaa9 100644 --- a/src/hooks/server/Chats/useChatStream.ts +++ b/src/hooks/server/Chats/useChatStream.ts @@ -1,8 +1,9 @@ import { type ChatSocket, type ChatSocketMessage, createChatSocket } from '@/apis/chatSocket'; +import type { EntityId } from '@/types/id'; import { useCallback, useEffect, useRef, useState } from 'react'; export type UseChatStreamOptions = { - chatId: string | number; + chatId: EntityId; enabled?: boolean; onMessage?: (payload: ChatSocketMessage) => void; onError?: (err: unknown) => void; diff --git a/src/hooks/server/Chats/useDeleteChat.ts b/src/hooks/server/Chats/useDeleteChat.ts index 6fc8bcfd..2cf75993 100644 --- a/src/hooks/server/Chats/useDeleteChat.ts +++ b/src/hooks/server/Chats/useDeleteChat.ts @@ -1,11 +1,12 @@ import { deleteChat } from '@/apis/chatApi'; import type { DeleteChatApiResponse } from '@/types/api/chatApi'; +import type { EntityId } from '@/types/id'; import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useDeleteChat() { const qc = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn: id => deleteChat(id), onSuccess: (_data, id) => { qc.invalidateQueries({ queryKey: ['chats'] }); diff --git a/src/hooks/server/Chats/useStompChat.ts b/src/hooks/server/Chats/useStompChat.ts index 3d77e9a6..810ff8bd 100644 --- a/src/hooks/server/Chats/useStompChat.ts +++ b/src/hooks/server/Chats/useStompChat.ts @@ -1,6 +1,7 @@ import { isExpiredJwt } from '@/lib/auth/jwt'; import { COOKIES_KEYS } from '@/lib/constants/cookies'; import { useChatStore } from '@/stores/chatStore'; +import type { EntityId } from '@/types/id'; import { Client, type StompHeaders, type StompSubscription } from '@stomp/stompjs'; import { useEffect, useRef } from 'react'; import SockJS from 'sockjs-client'; @@ -47,26 +48,26 @@ const toWebSocketUrl = (url: string) => { type IncomingMessage = { success: boolean; content: string; - chatId: number | null; + chatId: EntityId | null; }; const parseIncoming = (rawBody: string): IncomingMessage => { const parsed = JSON.parse(rawBody) as { success?: boolean; content?: string; - chatId?: number; + chatId?: string | number; data?: { content?: string; - chatId?: number; + chatId?: string | number; }; }; const data = parsed.data ?? parsed; const rawChatId = data.chatId; const normalizedChatId = typeof rawChatId === 'number' && Number.isFinite(rawChatId) - ? rawChatId - : typeof rawChatId === 'string' && /^\d+$/.test(rawChatId) - ? Number(rawChatId) + ? String(rawChatId) + : typeof rawChatId === 'string' && rawChatId.trim().length > 0 + ? rawChatId.trim() : null; return { success: parsed.success === true, @@ -179,7 +180,7 @@ export const useStompChat = () => { const authorization = resolveAuthorization(tokenRef.current); clientRef.current.publish({ destination: SEND_DEST, - body: JSON.stringify({ chatId: Number(chatId), message: content }), + body: JSON.stringify({ chatId: String(chatId), message: content }), headers: toStompHeaders(authorization), }); }; @@ -189,7 +190,7 @@ export const useStompChat = () => { const authorization = resolveAuthorization(tokenRef.current); clientRef.current.publish({ destination: CANCEL_DEST, - body: JSON.stringify({ chatId: Number(chatId) }), + body: JSON.stringify({ chatId: String(chatId) }), headers: toStompHeaders(authorization), }); }; diff --git a/src/hooks/useDeleteChat.ts b/src/hooks/useDeleteChat.ts index 580bfd0d..44e52cb0 100644 --- a/src/hooks/useDeleteChat.ts +++ b/src/hooks/useDeleteChat.ts @@ -1,11 +1,12 @@ import { deleteChat } from '@/apis/chatApi'; +import type { EntityId } from '@/types/id'; import { useMutation, useQueryClient } from '@tanstack/react-query'; export const useDeleteChat = () => { const qc = useQueryClient(); return useMutation({ - mutationFn: (id: number) => deleteChat(id), + mutationFn: (id: EntityId) => deleteChat(id), onSuccess: () => { qc.invalidateQueries({ queryKey: ['chats'] }); }, diff --git a/src/hooks/useDeleteLink.ts b/src/hooks/useDeleteLink.ts index aad8fbee..1a990361 100644 --- a/src/hooks/useDeleteLink.ts +++ b/src/hooks/useDeleteLink.ts @@ -1,11 +1,12 @@ import { deleteLink } from '@/apis/linkApi'; import type { DeleteLinkApiResponse } from '@/types/api/linkApi'; +import type { EntityId } from '@/types/id'; import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useDeleteLink() { const qc = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn: deleteLink, onSuccess: () => { qc.invalidateQueries({ queryKey: ['links'] }); diff --git a/src/hooks/useGetInfiniteLinks.ts b/src/hooks/useGetInfiniteLinks.ts index ff754e33..a899522c 100644 --- a/src/hooks/useGetInfiniteLinks.ts +++ b/src/hooks/useGetInfiniteLinks.ts @@ -1,5 +1,6 @@ import { type LinkListParams, fetchLinks } from '@/apis/linkApi'; import type { LinkListViewData } from '@/types/api/linkApi'; +import type { EntityId } from '@/types/id'; import { useInfiniteQuery } from '@tanstack/react-query'; import type { InfiniteData } from '@tanstack/react-query'; @@ -21,7 +22,7 @@ export function useGetInfiniteLinks( Error, InfiniteData, ['links', 'infinite', Omit | undefined, number], // useGetLinks와 캐시 섞이는 걸 방지하기 위해 'infinite'키를 가짐 - number | null + EntityId | null >({ queryKey: ['links', 'infinite', params, size], queryFn: ({ pageParam, signal }) => diff --git a/src/hooks/useGetLink.ts b/src/hooks/useGetLink.ts index 8a3b487b..dfe6be35 100644 --- a/src/hooks/useGetLink.ts +++ b/src/hooks/useGetLink.ts @@ -1,8 +1,9 @@ import { fetchLink } from '@/apis/linkApi'; +import type { EntityId } from '@/types/id'; import type { Link } from '@/types/link'; import { useQuery } from '@tanstack/react-query'; -export function useGetLink(id: number | null) { +export function useGetLink(id: EntityId | null) { return useQuery({ queryKey: ['link', id], queryFn: () => fetchLink(id!), diff --git a/src/hooks/useReSummary.ts b/src/hooks/useReSummary.ts index 61ebfad4..74ee402c 100644 --- a/src/hooks/useReSummary.ts +++ b/src/hooks/useReSummary.ts @@ -4,9 +4,10 @@ import { fetchNewSummary } from '@/apis/summary'; import { FetchError } from '@/hooks/util/api/error/errors'; import { showToast } from '@/stores/toastStore'; import type { LinkSummaryFormat } from '@/types/api/linkApi'; +import type { EntityId } from '@/types/id'; import { useMutation } from '@tanstack/react-query'; -export default function useReSummary(id: number, format: LinkSummaryFormat = 'CONCISE') { +export default function useReSummary(id: EntityId, format: LinkSummaryFormat = 'CONCISE') { const mutation = useMutation({ mutationFn: () => fetchNewSummary({ diff --git a/src/hooks/useRetrySummary.ts b/src/hooks/useRetrySummary.ts index 82c53ca8..0d0c56f1 100644 --- a/src/hooks/useRetrySummary.ts +++ b/src/hooks/useRetrySummary.ts @@ -3,9 +3,10 @@ import { retrySummary } from '@/apis/summary'; import { FetchError } from '@/hooks/util/api/error/errors'; import { showToast } from '@/stores/toastStore'; +import type { EntityId } from '@/types/id'; import { useMutation } from '@tanstack/react-query'; -export default function useRetrySummary(id: number) { +export default function useRetrySummary(id: EntityId) { const mutation = useMutation({ mutationFn: () => retrySummary(id), diff --git a/src/hooks/useSelectSummary.ts b/src/hooks/useSelectSummary.ts index 78b3f4db..002dd08e 100644 --- a/src/hooks/useSelectSummary.ts +++ b/src/hooks/useSelectSummary.ts @@ -3,11 +3,12 @@ import { FetchError } from '@/hooks/util/api/error/errors'; import { useModalStore } from '@/stores/modalStore'; import { showToast } from '@/stores/toastStore'; import type { LinkListViewData, LinkSummaryFormat } from '@/types/api/linkApi'; +import type { EntityId } from '@/types/id'; import type { Link } from '@/types/link'; import { InfiniteData, useMutation, useQueryClient } from '@tanstack/react-query'; type Params = { - id: number; + id: EntityId; summary: string; format: LinkSummaryFormat; }; diff --git a/src/hooks/useUpdateLink.ts b/src/hooks/useUpdateLink.ts index 7bc6d295..7316bbbd 100644 --- a/src/hooks/useUpdateLink.ts +++ b/src/hooks/useUpdateLink.ts @@ -1,11 +1,12 @@ import { updateLink } from '@/apis/linkApi'; +import type { EntityId } from '@/types/id'; import type { Link, UpdateLinkPayload } from '@/types/link'; import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useUpdateLink() { const qc = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn: ({ id, payload }) => updateLink(id, payload), onSuccess: (_data, variables) => { qc.invalidateQueries({ queryKey: ['links'] }); diff --git a/src/hooks/useUpdateLinkMemo.ts b/src/hooks/useUpdateLinkMemo.ts index 7220587b..2c58b796 100644 --- a/src/hooks/useUpdateLinkMemo.ts +++ b/src/hooks/useUpdateLinkMemo.ts @@ -1,11 +1,12 @@ import { updateLinkMemo } from '@/apis/linkApi'; +import type { EntityId } from '@/types/id'; import type { Link } from '@/types/link'; import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useUpdateLinkMemo() { const qc = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn: ({ id, memo }) => updateLinkMemo(id, memo), onSuccess: (_data, variables) => { qc.invalidateQueries({ queryKey: ['links'] }); diff --git a/src/hooks/useUpdateLinkTitle.ts b/src/hooks/useUpdateLinkTitle.ts index b7463ffb..562e53a1 100644 --- a/src/hooks/useUpdateLinkTitle.ts +++ b/src/hooks/useUpdateLinkTitle.ts @@ -1,11 +1,12 @@ import { updateLinkTitle } from '@/apis/linkApi'; +import type { EntityId } from '@/types/id'; import type { Link } from '@/types/link'; import { useMutation, useQueryClient } from '@tanstack/react-query'; export function useUpdateLinkTitle() { const qc = useQueryClient(); - return useMutation({ + return useMutation({ mutationFn: ({ id, title }) => updateLinkTitle(id, title), onSuccess: (_, variables) => { qc.invalidateQueries({ queryKey: ['links'] }); diff --git a/src/mocks/fixtures/chats.ts b/src/mocks/fixtures/chats.ts index 0936bfef..5b515353 100644 --- a/src/mocks/fixtures/chats.ts +++ b/src/mocks/fixtures/chats.ts @@ -8,13 +8,16 @@ import type { import { buildResponse } from '../response'; export const mockChats: ChatRoom[] = [ - { id: 201, title: '웹 접근성 요약 요청' }, - { id: 202, title: 'AI 관련 링크 찾아줘' }, - { id: 203, title: '요즘 IT 트렌드 정리' }, - { id: 204, title: '건강한 식단 링크' }, - { id: 205, title: '개발 자료 북마크' }, - { id: 206, title: '회의록 템플릿 질문' }, -]; + { id: '', title: '웹 접근성 요약 요청' }, + { id: '', title: 'AI 관련 링크 찾아줘' }, + { id: '', title: '요즘 IT 트렌드 정리' }, + { id: '', title: '건강한 식단 링크' }, + { id: '', title: '개발 자료 북마크' }, + { id: '', title: '회의록 템플릿 질문' }, +].map((chat, index) => ({ + ...chat, + id: `mock-chat-${index + 1}`, +})); export const mockChatsData: ChatListData = { chats: mockChats, @@ -23,7 +26,7 @@ export const mockChatsData: ChatListData = { export const mockChatsResponse: ChatListApiResponse = buildResponse(mockChatsData); export const buildCreateChatResponse = (input: { - id: number; + id: string; title: string; firstChat: string; }): CreateChatApiResponse => buildResponse(input, { status: '201 CREATED', message: 'Created' }); diff --git a/src/mocks/fixtures/links.ts b/src/mocks/fixtures/links.ts index 4fa45020..b3de4802 100644 --- a/src/mocks/fixtures/links.ts +++ b/src/mocks/fixtures/links.ts @@ -20,7 +20,7 @@ const pageable: Pageable = { export const mockLinks: LinkApiData[] = [ { - id: 101, + id: '', url: 'https://examples.design/clean-layouts', title: '복잡한 대시보드를 위한 깔끔한 레이아웃', summary: @@ -29,7 +29,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1487014679447-9f8336841d58', }, { - id: 102, + id: '', url: 'https://developer.mozilla.org/en-US/docs/Web/Accessibility', title: '웹 접근성 기초', summary: @@ -38,7 +38,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1454165804606-c3d57bc86b40', }, { - id: 103, + id: '', url: 'https://growth.design/case-studies', title: '온보딩 케이스 스터디 모음', summary: '', @@ -46,7 +46,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1451187580459-43490279c0fa', }, { - id: 104, + id: '', url: 'https://brunchstory.com/post/healthy-meals', title: '바쁜 평일을 위한 건강 식단', summary: @@ -55,7 +55,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1466978913421-dad2ebd01d17', }, { - id: 105, + id: '', url: 'https://velog.io/@sample/productivity', title: '주간 생산성 회고', summary: '', @@ -63,7 +63,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1517248135467-4c7edcad34c4', }, { - id: 106, + id: '', url: 'https://uibowl.io/recipes', title: '모던 앱을 위한 레시피 카드', summary: '음식/레시피 탐색에 맞춘 카드 UI 패턴 모음입니다.', @@ -71,7 +71,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1489515217757-5fd1be406fef', }, { - id: 107, + id: '', url: 'https://examples.design/brand-voice', title: '일관된 브랜드 톤 구축', summary: @@ -80,7 +80,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1529333166437-7750a6dd5a70', }, { - id: 108, + id: '', url: 'https://tech.example.com/ai-roadmap', title: '팀을 위한 AI 로드맵', summary: '', @@ -88,7 +88,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1498050108023-c5249f4df085', }, { - id: 109, + id: '', url: 'https://examples.design/white-space', title: '정보 밀도 높은 레이아웃의 여백', summary: @@ -97,7 +97,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1498050108023-c5249f4df085', }, { - id: 110, + id: '', url: 'https://design.example.com/typography', title: '제품을 위한 타이포그래피 시스템', summary: '계층과 가독성을 중심으로 한 확장 가능한 타이포 시스템 가이드.', @@ -105,7 +105,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1487014679447-9f8336841d58', }, { - id: 111, + id: '', url: 'https://wellness.example.com/sleep', title: '수면 최적화 기본', summary: '수면의 질과 아침 에너지를 높이는 습관과 루틴 정리.', @@ -113,7 +113,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1504384308090-c894fdcc538d', }, { - id: 112, + id: '', url: 'https://work.example.com/meeting-notes', title: '회의록 템플릿', summary: '', @@ -121,7 +121,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1485217988980-11786ced9454', }, { - id: 113, + id: '', url: 'https://product.example.com/release-notes', title: '릴리즈 노트 작성 가이드', summary: '제품 변경 사항을 효과적으로 전달하는 릴리즈 노트 구성법.', @@ -129,7 +129,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1520607162513-77705c0f0d4a', }, { - id: 114, + id: '', url: 'https://design.example.com/grid-systems', title: '그리드 시스템 실무 적용', summary: '반응형 그리드 설정과 카드 레이아웃 배치 기준 정리.', @@ -137,7 +137,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1487014679447-9f8336841d58', }, { - id: 115, + id: '', url: 'https://ops.example.com/incident-playbook', title: '인시던트 대응 플레이북', summary: '긴급 장애 대응 절차와 커뮤니케이션 가이드.', @@ -145,7 +145,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1504384308090-c894fdcc538d', }, { - id: 116, + id: '', url: 'https://marketing.example.com/campaign-brief', title: '캠페인 브리프 작성법', summary: '목표, 메시지, 채널을 정리하는 브리프 템플릿.', @@ -153,7 +153,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1454165804606-c3d57bc86b40', }, { - id: 117, + id: '', url: 'https://product.example.com/user-feedback', title: '사용자 피드백 수집 체크리스트', summary: '정성/정량 피드백 수집 항목과 설문 설계 팁.', @@ -161,7 +161,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1451187580459-43490279c0fa', }, { - id: 118, + id: '', url: 'https://design.example.com/iconography', title: '아이콘 그래픽 가이드', summary: '아이콘 스타일, 그리드, 스트로크 규칙을 정리했습니다.', @@ -169,7 +169,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1529333166437-7750a6dd5a70', }, { - id: 119, + id: '', url: 'https://content.example.com/editorial-calendar', title: '에디토리얼 캘린더 운영', summary: '콘텐츠 일정 관리와 협업 프로세스를 설명합니다.', @@ -177,7 +177,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1485217988980-11786ced9454', }, { - id: 120, + id: '', url: 'https://team.example.com/remote-work', title: '리모트 협업 원칙', summary: '비동기 커뮤니케이션과 문서화 기본 원칙.', @@ -185,7 +185,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1489515217757-5fd1be406fef', }, { - id: 121, + id: '', url: 'https://analytics.example.com/metrics', title: '제품 지표 정의 방법', summary: '북극성 지표와 보조 지표를 세팅하는 방법.', @@ -193,7 +193,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1498050108023-c5249f4df085', }, { - id: 122, + id: '', url: 'https://research.example.com/interview-guide', title: '유저 인터뷰 가이드', summary: '인터뷰 질문 설계와 리서치 기록 방법.', @@ -201,7 +201,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1487014679447-9f8336841d58', }, { - id: 123, + id: '', url: 'https://dev.example.com/api-versioning', title: 'API 버저닝 전략', summary: '호환성을 유지하면서 버전을 관리하는 기준.', @@ -209,7 +209,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1498050108023-c5249f4df085', }, { - id: 124, + id: '', url: 'https://design.example.com/forms', title: '폼 UI 체크리스트', summary: '입력 폼의 오류 처리와 안내 메시지 패턴.', @@ -217,7 +217,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1454165804606-c3d57bc86b40', }, { - id: 125, + id: '', url: 'https://service.example.com/pricing', title: '가격 페이지 구성 레퍼런스', summary: '플랜 비교표와 CTA 구성 사례 모음.', @@ -225,7 +225,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1504384308090-c894fdcc538d', }, { - id: 126, + id: '', url: 'https://qa.example.com/test-cases', title: '테스트 케이스 설계', summary: '기능/회귀 테스트 케이스 작성 기준.', @@ -233,7 +233,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1485217988980-11786ced9454', }, { - id: 127, + id: '', url: 'https://growth.example.com/activation', title: '활성화 지표 개선', summary: '온보딩과 활성화 지표를 연결하는 방법.', @@ -241,7 +241,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1451187580459-43490279c0fa', }, { - id: 128, + id: '', url: 'https://design.example.com/visual-hierarchy', title: '시각적 계층 구조 가이드', summary: '타이포/컬러/간격으로 정보 우선순위를 만드는 법.', @@ -249,7 +249,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1487014679447-9f8336841d58', }, { - id: 129, + id: '', url: 'https://product.example.com/roadmap', title: '프로덕트 로드맵 구성', summary: '분기별 로드맵을 명확하게 정리하는 방법.', @@ -257,7 +257,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1529333166437-7750a6dd5a70', }, { - id: 130, + id: '', url: 'https://design.example.com/empty-states', title: '빈 상태 UI 패턴', summary: '데이터가 없을 때의 안내 메시지와 행동 유도.', @@ -265,7 +265,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1466978913421-dad2ebd01d17', }, { - id: 131, + id: '', url: 'https://team.example.com/onboarding', title: '팀 온보딩 체크리스트', summary: '신규 멤버를 위한 문서/권한/장비 체크리스트.', @@ -273,7 +273,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1489515217757-5fd1be406fef', }, { - id: 132, + id: '', url: 'https://docs.example.com/style-guide', title: '문서 스타일 가이드', summary: '문서 제목, 본문, 링크 규칙 정리.', @@ -281,7 +281,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1485217988980-11786ced9454', }, { - id: 133, + id: '', url: 'https://support.example.com/faq', title: 'FAQ 작성 가이드', summary: '고객 문의를 줄이기 위한 FAQ 구성 방법.', @@ -289,7 +289,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1454165804606-c3d57bc86b40', }, { - id: 134, + id: '', url: 'https://design.example.com/color-tokens', title: '컬러 토큰 설계', summary: '디자인 토큰으로 컬러를 관리하는 방법.', @@ -297,7 +297,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1498050108023-c5249f4df085', }, { - id: 135, + id: '', url: 'https://product.example.com/feature-brief', title: '기능 브리프 템플릿', summary: '요구사항 정의와 일정 범위를 정리하는 템플릿.', @@ -305,7 +305,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1487014679447-9f8336841d58', }, { - id: 136, + id: '', url: 'https://design.example.com/cards', title: '카드 UI 레이아웃', summary: '카드 기반 리스트의 밀도와 정보 배치 기준.', @@ -313,7 +313,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1487014679447-9f8336841d58', }, { - id: 137, + id: '', url: 'https://engineering.example.com/code-review', title: '코드 리뷰 가이드', summary: '리뷰 기준과 피드백 작성 요령.', @@ -321,7 +321,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1498050108023-c5249f4df085', }, { - id: 138, + id: '', url: 'https://product.example.com/prd', title: 'PRD 작성 체크리스트', summary: '제품 요구사항 문서 작성 시 필수 항목 정리.', @@ -329,7 +329,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1466978913421-dad2ebd01d17', }, { - id: 139, + id: '', url: 'https://design.example.com/spacing', title: '레이아웃 스페이싱 규칙', summary: '콘텐츠 간격과 그리드 기반 여백 설정 기준.', @@ -337,7 +337,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1487014679447-9f8336841d58', }, { - id: 140, + id: '', url: 'https://service.example.com/retention', title: '리텐션 개선 사례', summary: '재방문을 높이기 위한 기능 개선 사례 모음.', @@ -345,7 +345,7 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1451187580459-43490279c0fa', }, { - id: 141, + id: '', url: 'https://content.example.com/seo-basics', title: 'SEO 기본 체크리스트', summary: '메타 태그, 구조화 데이터, 콘텐츠 최적화 기준.', @@ -353,14 +353,17 @@ export const mockLinks: LinkApiData[] = [ imageUrl: 'https://images.unsplash.com/photo-1485217988980-11786ced9454', }, { - id: 142, + id: '', url: 'https://design.example.com/feedback', title: '디자인 피드백 정리법', summary: '피드백 수집과 결정 기록 방법.', memo: '리뷰 회의 참고.', imageUrl: 'https://images.unsplash.com/photo-1454165804606-c3d57bc86b40', }, -]; +].map((link, index) => ({ + ...link, + id: `mock-link-${index + 1}`, +})); export const mockLinkListData: LinkListApiData = { links: mockLinks, @@ -370,4 +373,4 @@ export const mockLinkListData: LinkListApiData = { export const mockLinkListResponse: LinkListApiResponse = buildResponse(mockLinkListData); -export const mockLinkById = (id: number) => mockLinks.find(link => link.id === id) ?? null; +export const mockLinkById = (id: string) => mockLinks.find(link => link.id === id) ?? null; diff --git a/src/stores/linkStore.ts b/src/stores/linkStore.ts index 86081fe2..74457589 100644 --- a/src/stores/linkStore.ts +++ b/src/stores/linkStore.ts @@ -1,12 +1,13 @@ import type { LinkApiData } from '@/types/api/linkApi'; +import type { EntityId } from '@/types/id'; import { create } from 'zustand'; type LinkStoreState = { links: LinkApiData[]; - selectedLinkId: number | null; + selectedLinkId: EntityId | null; setLinks: (links: LinkApiData[]) => void; - selectLink: (id: number | null) => void; - updateLink: (id: number, updates: Partial) => void; + selectLink: (id: EntityId | null) => void; + updateLink: (id: EntityId, updates: Partial) => void; }; export const useLinkStore = create(set => ({ diff --git a/src/stores/modalStore.ts b/src/stores/modalStore.ts index 8d72fed5..e3bd66db 100644 --- a/src/stores/modalStore.ts +++ b/src/stores/modalStore.ts @@ -1,3 +1,4 @@ +import type { EntityId } from '@/types/id'; import { create } from 'zustand'; export const MODAL_TYPE = { @@ -12,24 +13,32 @@ export type ModalType = keyof typeof MODAL_TYPE | null; type ModalState = | { type: null; props?: undefined } | { type: 'ADD_LINK'; props?: Record } - | { type: 'RE_SUMMARY'; props: { linkId: number } } + | { type: 'RE_SUMMARY'; props: { linkId: EntityId } } | { type: 'REPORT'; props?: Record } - | { type: 'DELETE_CHAT'; props: { chatId: number; title: string } } - | { type: 'DELETE_LINK'; props: { linkIds: number[] } }; + | { type: 'DELETE_CHAT'; props: { chatId: EntityId; title: string } } + | { type: 'DELETE_LINK'; props: { linkIds: EntityId[] } }; + +type NonNullModalState = Exclude; +type ModalProps = Extract< + NonNullModalState, + { type: T } +>['props']; +type OpenModalArgs = + undefined extends ModalProps + ? [props?: Exclude, undefined>] + : [props: ModalProps]; +type OpenModal = (type: T, ...args: OpenModalArgs) => void; interface ModalStore { modal: ModalState; - open(type: 'ADD_LINK', props?: Record): void; - open(type: 'RE_SUMMARY', props: { linkId: number }): void; - open(type: 'REPORT', props?: Record): void; - open(type: 'DELETE_CHAT', props: { chatId: number; title: string }): void; - open(type: 'DELETE_LINK', props: { linkIds: number[] }): void; + open: OpenModal; close: () => void; } export const useModalStore = create(set => ({ modal: { type: null }, - open: ((type: ModalType, props?: unknown) => { + open: ((type: NonNullModalState['type'], ...args: [unknown?]) => { + const [props] = args; set({ modal: { type, props } as ModalState }); }) as ModalStore['open'], close: () => set({ modal: { type: null } }), diff --git a/src/stories/LinkCardDetailPanel.stories.tsx b/src/stories/LinkCardDetailPanel.stories.tsx index ac92caa6..b8966474 100644 --- a/src/stories/LinkCardDetailPanel.stories.tsx +++ b/src/stories/LinkCardDetailPanel.stories.tsx @@ -9,7 +9,7 @@ const meta = { layout: 'centered', }, argTypes: { - id: { control: 'number' }, + id: { control: 'text' }, url: { control: 'text' }, title: { control: 'text' }, summary: { control: 'text' }, @@ -23,7 +23,7 @@ export default meta; type Story = StoryObj; const ControlledPanel = (props: Story['args']) => { - const id = props?.id ?? 1; + const id = props?.id ?? '1'; const title = props?.title ?? ''; const memo = props?.memo ?? ''; @@ -52,7 +52,7 @@ export const SummaryError: Story = { render: args => , args: { ...baseArgs, - id: 1, + id: '1', summary: '', summaryState: 'error', summaryErrorMessage: '일시적 오류로 요약을 생성하지 못했습니다.', @@ -64,7 +64,7 @@ export const SummaryLoading: Story = { render: args => , args: { ...baseArgs, - id: 1, + id: '1', summary: '', summaryState: 'loading', }, @@ -75,7 +75,7 @@ export const SummaryReady: Story = { render: args => , args: { ...baseArgs, - id: 1, + id: '1', summaryState: 'ready', }, }; diff --git a/src/stories/Modal.stories.tsx b/src/stories/Modal.stories.tsx index 2fd51bae..d132ae31 100644 --- a/src/stories/Modal.stories.tsx +++ b/src/stories/Modal.stories.tsx @@ -42,11 +42,11 @@ export const Default: Story = { const handleOpen = () => { // 타입에 따라 적절한 props 전달 if (args.type === 'DELETE_CHAT') { - open('DELETE_CHAT', { chatId: 1, title: '어쩌구' }); // DELETE_CHAT은 props 필수 + open('DELETE_CHAT', { chatId: '1', title: '어쩌구' }); } else if (args.type === 'ADD_LINK') { open('ADD_LINK'); } else if (args.type === 'RE_SUMMARY') { - open('RE_SUMMARY', { linkId: 123 }); + open('RE_SUMMARY', { linkId: '123' }); } else if (args.type === 'REPORT') { open('REPORT'); } diff --git a/src/stories/ReSummaryModal.stories.tsx b/src/stories/ReSummaryModal.stories.tsx index 57079e20..6c1275a4 100644 --- a/src/stories/ReSummaryModal.stories.tsx +++ b/src/stories/ReSummaryModal.stories.tsx @@ -25,9 +25,9 @@ const StoryWrapper = () => {