Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/api/callvan/APIDetail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
CallvanListResponse,
CallvanNotificationsResponse,
CallvanPostDetail,
CallvanRestrictionResponse,
CallvanReportRequest,
CreateCallvanRequest,
CreateCallvanResponse,
Expand Down Expand Up @@ -42,6 +43,18 @@ export class GetCallvanNotifications<R extends CallvanNotificationsResponse> imp
constructor(public authorization: string) {}
}

export class GetCallvanRestriction<R extends CallvanRestrictionResponse> implements APIRequest<R> {
method = HTTP_METHOD.GET;

path = '/callvan/restriction';

response!: R;

auth = true;

constructor(public authorization: string) {}
}

export class PostMarkAllNotificationsRead<R extends object> implements APIRequest<R> {
method = HTTP_METHOD.POST;

Expand Down
16 changes: 16 additions & 0 deletions src/api/callvan/entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,22 @@ export interface CallvanListResponse extends APIResponse {
total_page: number;
}

export type CallvanRestrictionType = 'TEMPORARY_RESTRICTION_14_DAYS' | 'PERMANENT_RESTRICTION';

export interface UnrestrictedCallvanResponse {
is_restricted: false;
restriction_type: null;
restricted_until: null;
}

export interface RestrictedCallvanResponse {
is_restricted: true;
restriction_type: CallvanRestrictionType;
restricted_until: string;
}

export type CallvanRestrictionResponse = UnrestrictedCallvanResponse | RestrictedCallvanResponse;

export type CallvanNotificationType =
| 'RECRUITMENT_COMPLETE'
| 'NEW_MESSAGE'
Expand Down
2 changes: 2 additions & 0 deletions src/api/callvan/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
GetCallvanList,
GetCallvanNotifications,
GetCallvanPostDetail,
GetCallvanRestriction,
PostCallvan,
PostCallvanReport,
PostCallvanChat,
Expand All @@ -20,6 +21,7 @@ import {
export const getCallvanList = APIClient.of(GetCallvanList);
export const getCallvanPostDetail = APIClient.of(GetCallvanPostDetail);
export const getCallvanNotifications = APIClient.of(GetCallvanNotifications);
export const getCallvanRestriction = APIClient.of(GetCallvanRestriction);
export const markAllNotificationsRead = APIClient.of(PostMarkAllNotificationsRead);
export const markNotificationRead = APIClient.of(PostMarkNotificationRead);
export const deleteAllNotifications = APIClient.of(DeleteAllNotifications);
Expand Down
2 changes: 1 addition & 1 deletion src/api/callvan/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ const invalidateCallvanInfiniteList = (queryClient: QueryClient) =>
queryClient.invalidateQueries({ queryKey: callvanQueryKeys.infiniteListRoot });

const invalidateCallvanNotifications = (queryClient: QueryClient) =>
queryClient.invalidateQueries({ queryKey: callvanQueryKeys.notifications });
queryClient.invalidateQueries({ queryKey: ['callvan', 'notifications'] });

export const callvanMutations = {
create: (queryClient: QueryClient, token: string) =>
Expand Down
15 changes: 12 additions & 3 deletions src/api/callvan/queries.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { infiniteQueryOptions, queryOptions } from '@tanstack/react-query';
import { CallvanListRequest } from './entity';
import { getCallvanChat, getCallvanList, getCallvanNotifications, getCallvanPostDetail } from './index';
import { getCallvanChat, getCallvanList, getCallvanNotifications, getCallvanPostDetail, getCallvanRestriction } from './index';

const CALLVAN_LIST_LIMIT = 10;

Expand All @@ -12,7 +12,8 @@ export const callvanQueryKeys = {
list: (params: CallvanListRequest) => [...callvanQueryKeys.listRoot, params] as const,
infiniteListRoot: ['callvan', 'infinite-list'] as const,
infiniteList: (params: CallvanInfiniteListParams) => [...callvanQueryKeys.infiniteListRoot, params] as const,
notifications: ['callvan', 'notifications'] as const,
notifications: (token: string) => ['callvan', 'notifications', token] as const,
restriction: (token: string) => ['callvan', 'restriction', token] as const,
Comment on lines +15 to +16
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n src/api/callvan/queries.ts | head -70

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 3050


🏁 Script executed:

rg -n "callvanQueryKeys\.(notifications|restriction)" src/

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 459


🏁 Script executed:

cat -n src/api/callvan/entity.ts | head -40

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 1291


🏁 Script executed:

rg -n "token" src/api/callvan/index.ts | head -20

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 49


🏁 Script executed:

cat -n src/api/callvan/index.ts

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 1865


🏁 Script executed:

rg -n "useQuery\|useSuspenseQuery" src/pages/callvan -A 5 | grep -A 5 "notifications\|restriction"

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 49


🏁 Script executed:

cat -n src/pages/callvan/notifications/index.tsx | head -60

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 2987


🏁 Script executed:

rg -n "useTokenState\|useUserState\|useAuthStore" src/ | head -20

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 49


🏁 Script executed:

cat -n src/utils/hooks/state/useTokenState.ts

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 293


🏁 Script executed:

rg -n "dehydrate" src/pages/callvan -B 2 -A 2

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 2621


🏁 Script executed:

cat -n src/pages/callvan/index.tsx | sed -n '40,70p'

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 1206


🏁 Script executed:

rg -n "useTokenStore\|token" src/utils/zustand/auth.ts -A 5

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 49


🏁 Script executed:

fd -i "auth" src/utils/zustand --type f

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 129


🏁 Script executed:

cat -n src/utils/zustand/auth.ts 2>/dev/null || rg -n "useTokenStore" src/ -A 10

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 1400


Raw token을 queryKey에 포함하지 마세요.

notifications/restriction의 queryKey에 인증 토큰을 포함하면 React Query 캐시와 SSR dehydrate() 페이로드에 토큰이 평문으로 노출됩니다. 같은 파일의 postDetail, chat처럼 queryKey에는 비민감 값만 포함하고, 토큰은 queryFn 실행 시점에 별도로 주입하는 방식을 권장합니다.

사용자당 캐시 격리가 필요하면 토큰 대신 사용자 ID나 별도 식별값을 사용하세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/api/callvan/queries.ts` around lines 15 - 16, The queryKey factories
notifications and restriction currently include the raw auth token which risks
exposing secrets in React Query cache/SSR; change notifications and restriction
to return only non-sensitive identifiers (e.g., ['callvan','notifications'] and
['callvan','restriction'] or include a userId instead of token) and stop passing
token in the key, then ensure the token is provided to the queryFn at execution
time (similar to postDetail and chat) by reading it from context/params or
injecting it into the fetcher call; update any callers to pass token only to the
queryFn, not into the queryKey.

postDetail: (postId: number) => ['callvan', 'post-detail', postId] as const,
chat: (postId: number) => ['callvan', 'chat', postId] as const,
};
Expand Down Expand Up @@ -45,8 +46,16 @@ export const callvanQueries = {

notifications: (token: string) =>
queryOptions({
queryKey: callvanQueryKeys.notifications,
queryKey: callvanQueryKeys.notifications(token),
queryFn: () => getCallvanNotifications(token),
staleTime: 60000,
}),

restriction: (token: string) =>
queryOptions({
queryKey: callvanQueryKeys.restriction(token),
queryFn: () => getCallvanRestriction(token),
staleTime: 0,
}),

postDetail: (token: string, postId: number) =>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
$callvan-restriction-accent-color: #b611f5;
$callvan-restriction-font-family: var(--font-pretendard), sans-serif;

.modal {
&__overlay {
position: fixed;
inset: 0;
z-index: 300;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
box-sizing: border-box;
}

&__dim {
position: absolute;
inset: 0;
border: none;
background: rgb(0 0 0 / 70%);
padding: 0;
cursor: pointer;
}

&__sheet {
position: relative;
z-index: 301;
width: min(301px, 100%);
border-radius: 8px;
background: #fff;
box-shadow:
0 1px 2px rgb(0 0 0 / 4%),
0 4px 5px rgb(0 0 0 / 8%);
}

&__content {
display: flex;
flex-direction: column;
gap: 24px;
align-items: center;
padding: 24px 32px;
}

&__text {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
width: 100%;
text-align: center;
}

&__title {
margin: 0;
color: #4b4b4b;
font-family: $callvan-restriction-font-family;
font-size: 18px;
font-weight: 500;
line-height: 1.6;
}

&__accent {
color: $callvan-restriction-accent-color;
}

&__description {
display: flex;
flex-direction: column;
width: 100%;
color: #727272;
font-family: $callvan-restriction-font-family;
font-size: 14px;
font-weight: 400;
line-height: 1.6;

p {
margin: 0;
}
}

&__button {
display: flex;
width: 100%;
align-items: center;
justify-content: center;
border: none;
border-radius: 8px;
background: $callvan-restriction-accent-color;
padding: 12px;
color: #fff;
font-family: $callvan-restriction-font-family;
font-size: 15px;
font-weight: 500;
line-height: 1.6;
cursor: pointer;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { useEffect } from 'react';
import { RestrictedCallvanResponse } from 'api/callvan/entity';
import { getCallvanRestrictionModalCopy } from 'components/Callvan/utils/callvanRestriction';
import { useBodyScrollLock } from 'utils/hooks/ui/useBodyScrollLock';
import styles from './CallvanRestrictionModal.module.scss';

interface CallvanRestrictionModalProps {
restriction: RestrictedCallvanResponse | null;
onClose: () => void;
}

export default function CallvanRestrictionModal({ restriction, onClose }: CallvanRestrictionModalProps) {
useBodyScrollLock();

const { titleAccent, titleRest, descriptionLines } = getCallvanRestrictionModalCopy(restriction);

useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onClose();
}
};

document.addEventListener('keydown', handleKeyDown);

return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [onClose]);

return (
<div className={styles.modal__overlay} role="dialog" aria-modal="true" aria-labelledby="callvan-restriction-title">
<button type="button" className={styles.modal__dim} onClick={onClose} aria-label="닫기" />
<div className={styles.modal__sheet}>
<div className={styles.modal__content}>
<div className={styles.modal__text}>
<h2 id="callvan-restriction-title" className={styles.modal__title}>
<span className={styles.modal__accent}>{titleAccent}</span>
{titleRest}
</h2>
<div className={styles.modal__description}>
{descriptionLines.map((line, index) => (
<p key={`${line}-${index}`}>{line}</p>
))}
</div>
</div>
<button type="button" className={styles.modal__button} onClick={onClose}>
닫기
</button>
</div>
</div>
</div>
);
Comment on lines +31 to +53
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

모달 포커스 관리(접근성) 보완 필요

role="dialog" aria-modal="true"만 선언되어 있고, 모달이 열릴 때 모달 내부로 포커스 이동, Tab 포커스 트랩, 닫힐 때 트리거 요소로 포커스 복원이 누락되어 있습니다. 키보드/스크린리더 사용자가 모달 뒤의 요소로 포커스가 빠져나가 흐름이 깨질 수 있습니다. 또한 첫 번째 포커스 가능 요소가 비가시 modal__dim 버튼이라 Tab 키로 진입 시 의도와 다른 포커스 위치에서 시작됩니다.

권장: react-focus-lock 같은 검증된 라이브러리를 도입하거나, 최소한 마운트 시 닫기 버튼에 초기 포커스를 부여하고 unmount 시 이전 active element로 포커스를 복원하세요.

♻️ 최소한의 초기 포커스 및 복원 예시
-import { useEffect } from 'react';
+import { useEffect, useRef } from 'react';
@@
 export default function CallvanRestrictionModal({ restriction, onClose }: CallvanRestrictionModalProps) {
   const { titleAccent, titleRest, descriptionLines } = getCallvanRestrictionModalCopy(restriction);
+  const closeButtonRef = useRef<HTMLButtonElement>(null);

   useEffect(() => {
+    const previouslyFocused = document.activeElement as HTMLElement | null;
+    closeButtonRef.current?.focus();
     const handleKeyDown = (event: KeyboardEvent) => {
       if (event.key === 'Escape') {
         onClose();
       }
     };

     document.addEventListener('keydown', handleKeyDown);

     return () => {
       document.removeEventListener('keydown', handleKeyDown);
+      previouslyFocused?.focus?.();
     };
   }, [onClose]);
@@
-          <button type="button" className={styles.modal__button} onClick={onClose}>
+          <button type="button" ref={closeButtonRef} className={styles.modal__button} onClick={onClose}>
             닫기
           </button>

코딩 가이드라인에 따라(src/components/**: 접근성(a11y) 관련 속성이 적절히 사용되고 있는지 확인) 모달 접근성 패턴을 보강해 주세요.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Callvan/components/CallvanRestrictionModal/index.tsx` around
lines 28 - 50, CallvanRestrictionModal currently sets role="dialog" but doesn't
manage focus: ensure when the component mounts focus moves into the modal, trap
Tab focus inside the modal while open, and restore focus to the previously
active element on unmount; specifically set initial focus to the visible close
button (the button with class modal__button) instead of the background dim
button (modal__dim), and implement a focus trap (use a library like
react-focus-lock around the modal__sheet or implement keyboard handlers to keep
focus inside). On unmount or when onClose runs, return focus to the element
saved before mounting. Update the CallvanRestrictionModal component to perform
these steps and wire them to the existing onClose prop.

}
47 changes: 47 additions & 0 deletions src/components/Callvan/hooks/useCallvanRestrictionModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useCallback } from 'react';
import { sendClientError } from '@bcsdlab/koin';
import { useQueryClient } from '@tanstack/react-query';
import { RestrictedCallvanResponse } from 'api/callvan/entity';
import { callvanQueries } from 'api/callvan/queries';
import CallvanRestrictionModal from 'components/Callvan/components/CallvanRestrictionModal';
import { isCallvanRestrictedError } from 'components/Callvan/utils/callvanRestriction';
import { Portal } from 'components/modal/Modal/PortalProvider';
import useModalPortal from 'utils/hooks/layout/useModalPortal';

export default function useCallvanRestrictionModal(token: string) {
const portalManager = useModalPortal();
const queryClient = useQueryClient();

const open = useCallback(
(restriction: RestrictedCallvanResponse) => {
portalManager.open((portalOption: Portal) => (
<CallvanRestrictionModal restriction={restriction} onClose={portalOption.close} />
));
},
[portalManager],
);
Comment on lines +11 to +22
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# callvanQueries.restriction 정의 및 queryKey/staleTime 확인
fd -t f 'queries' src/api/callvan
rg -nP -C8 "restriction\s*[:=]\s*\(?token" --type=ts
rg -nP -C5 "queryKey|staleTime" src/api/callvan

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 6139


🏁 Script executed:

# callvanQueryKeys 정의 확인
rg -nP "callvanQueryKeys\s*=" src/api/callvan --type=ts -A 15

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 1321


🏁 Script executed:

# useCallvanRestrictionModal 전체 코드 확인
cat -n src/components/Callvan/hooks/useCallvanRestrictionModal.tsx

Repository: BCSDLab/KOIN_WEB_RECODE

Length of output: 1958


queryKey 및 staleTime 설정 수정 필요

callvanQueries.restriction의 queryKey에 token이 포함되어 있지 않습니다. 토큰이 변경될 때 동일한 queryKey ['callvan', 'restriction']을 사용하므로, 이전 토큰의 캐시 데이터가 의도치 않게 재사용될 수 있습니다. 또한 staleTime이 설정되지 않아 반복 호출 시 매번 새로운 네트워크 요청이 발생합니다.

수정 제안:

// src/api/callvan/queries.ts
export const callvanQueryKeys = {
  // ...
  restriction: (token: string) => ['callvan', 'restriction', token] as const,
};

export const callvanQueries = {
  // ...
  restriction: (token: string) =>
    queryOptions({
      queryKey: callvanQueryKeys.restriction(token),
      queryFn: () => getCallvanRestriction(token),
      staleTime: 60000, // 또는 적절한 값
    }),
};

동일한 문제가 notifications 쿼리에도 적용됩니다 (queryKey 정적, staleTime 미설정).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/components/Callvan/hooks/useCallvanRestrictionModal.tsx` around lines 11
- 22, The current hook useCallvanRestrictionModal opens the modal but the
related queries use a static key and no staleTime; update callvanQueryKeys to
make restriction a function (callvanQueryKeys.restriction(token)) so the
queryKey includes the token, and change callvanQueries.restriction(token) to use
that key, pass a queryFn that calls getCallvanRestriction(token), and set a
sensible staleTime (e.g., 60000 ms) to avoid unnecessary refetches; apply the
same pattern for the notifications query so its key includes token and it sets
staleTime as well.


const openFromError = useCallback(
async (error: unknown) => {
if (!isCallvanRestrictedError(error)) return false;

if (!token) return false;

try {
const restriction = await queryClient.fetchQuery(callvanQueries.restriction(token));
if (restriction.is_restricted) {
open(restriction);
return true;
}

return false;
} catch (restrictionError) {
sendClientError(restrictionError);
return false;
}
},
[open, queryClient, token],
);

return { openFromError };
}
10 changes: 9 additions & 1 deletion src/components/Callvan/hooks/useCreateCallvan.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
import { isKoinError, sendClientError } from '@bcsdlab/koin';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { callvanMutations } from 'api/callvan/mutations';
import useCallvanRestrictionModal from 'components/Callvan/hooks/useCallvanRestrictionModal';
import useTokenState from 'utils/hooks/state/useTokenState';
import showToast from 'utils/ts/showToast';

const useCreateCallvan = () => {
const token = useTokenState();
const queryClient = useQueryClient();
const mutation = callvanMutations.create(queryClient, token);
const { openFromError } = useCallvanRestrictionModal(token);

const { mutate, isPending } = useMutation({
...mutation,
onError: (e) => {
onError: async (e) => {
try {
if (await openFromError(e)) return;
} catch (restrictionError) {
sendClientError(restrictionError);
}

if (isKoinError(e)) {
showToast('error', e.message || '게시글 작성에 실패했습니다.');
} else {
Expand Down
10 changes: 9 additions & 1 deletion src/components/Callvan/hooks/useJoinCallvan.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,29 @@
import { isKoinError, sendClientError } from '@bcsdlab/koin';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { callvanMutations } from 'api/callvan/mutations';
import useCallvanRestrictionModal from 'components/Callvan/hooks/useCallvanRestrictionModal';
import useTokenState from 'utils/hooks/state/useTokenState';
import showToast from 'utils/ts/showToast';

const useJoinCallvan = () => {
const token = useTokenState();
const queryClient = useQueryClient();
const mutation = callvanMutations.join(queryClient, token);
const { openFromError } = useCallvanRestrictionModal(token);

const { mutate, isPending } = useMutation({
...mutation,
onSuccess: async (...args) => {
await mutation.onSuccess?.(...args);
showToast('success', '참여가 완료되었습니다.');
},
onError: (e) => {
onError: async (e) => {
try {
if (await openFromError(e)) return;
} catch (restrictionError) {
sendClientError(restrictionError);
}

if (isKoinError(e)) {
showToast('error', e.message || '참여에 실패했습니다.');
} else {
Expand Down
Loading
Loading