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
46 changes: 46 additions & 0 deletions docs/balance-game-tag-filter-request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
# 밸런스게임 태그 검색/필터 요청서

## 목적

- 태그 필터를 드롭다운이 아닌 **검색 입력 + Enter 적용** 방식으로 변경.
- **다중 태그 필터** 지원 (입력 후 Enter로 태그 추가).
- 필터 적용 시 **입력된 모든 태그를 포함**하는 밸런스게임 목록만 반환.

## 변경 요약

- 프론트는 태그 입력 후 Enter 또는 "적용" 버튼 클릭 시 `tag` 쿼리로 요청
- 입력 길이 제한: **최대 40자**
- 태그 필터 해제 시 `tag` 파라미터 제거

## API 요청

### GET /api/v1/balance-game

- 기존 목록 API 유지
- Query Params:
- `page` (int, 1-based)
- `size` (int)
- `sort` (`latest` | `popular`)
- `status` (`active` | `closed`, optional)
- `tags` (string, optional) — `tag1,tag2,tag3` 형태의 comma-separated

### 태그 필터 동작

- `tags`가 전달되면 **모든 태그를 포함**한 투표만 반환
- 대소문자/공백 처리 정책은 백엔드에서 일관되게 적용
- `tags`가 비어있거나 누락되면 전체 반환

## 입력 제약 (프론트 기준)

- 태그 길이: 1~40자
- 다중 태그 필터 지원

## 응답

- 기존 밸런스게임 목록 응답 스키마 그대로

## 예시

```
GET /api/v1/balance-game?page=1&size=10&sort=latest&status=active&tags=frontend,react
```
Comment on lines +44 to +46
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

코드 블록에 언어 지정이 필요합니다. markdownlint(MD040) 경고가 발생합니다.

✏️ 제안 수정안
-```
-GET /api/v1/balance-game?page=1&size=10&sort=latest&status=active&tags=frontend,react
-```
+```http
+GET /api/v1/balance-game?page=1&size=10&sort=latest&status=active&tags=frontend,react
+```
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
GET /api/v1/balance-game?page=1&size=10&sort=latest&status=active&tags=frontend,react
```
🧰 Tools
🪛 markdownlint-cli2 (0.20.0)

[warning] 44-44: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
In `@docs/balance-game-tag-filter-request.md` around lines 44 - 46, The fenced
code block containing the HTTP example "GET
/api/v1/balance-game?page=1&size=10&sort=latest&status=active&tags=frontend,react"
needs a language specifier to satisfy markdownlint MD040; update that fenced
block (the triple-backtick block wrapping the GET request) to use ```http as the
opening fence and keep the closing ``` unchanged so the code block is properly
annotated.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.27.1",
"googleapis": "^164.1.0",
"hashids": "^2.3.0",
"lucide-react": "^0.475.0",
"next": "15.2.8",
"postcss": "^8.5.2",
Expand Down
86 changes: 86 additions & 0 deletions src/api/client/api-logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import type {
AxiosError,
AxiosInstance,
InternalAxiosRequestConfig,
} from 'axios';

const shouldLog = process.env.NODE_ENV !== 'production';

const normalizeUrl = (config: InternalAxiosRequestConfig) => {
const base = config.baseURL ?? '';
const url = config.url ?? '';

if (!base) return url;
if (url.startsWith('http://') || url.startsWith('https://')) return url;

return `${base.replace(/\/$/, '')}/${url.replace(/^\//, '')}`;
};

const stringifyParams = (params: InternalAxiosRequestConfig['params']) => {
if (!params) return '';

try {
return JSON.stringify(params);
} catch {
return '';
}
};

const stringifyData = (data: unknown) => {
if (data === undefined) return '';

if (typeof data === 'string') return data;

try {
return JSON.stringify(data);
} catch {
return String(data);
}
};

export const attachApiLogger = (instance: AxiosInstance, label: string) => {
if (!shouldLog) return;

instance.interceptors.request.use((config) => {
const method = (config.method || 'get').toUpperCase();
const url = normalizeUrl(config);
const params = stringifyParams(config.params);

console.log(
`[API ${label}] ${method} ${url}${params ? ` params=${params}` : ''}`,
);

return config;
});

instance.interceptors.response.use(
(response) => {
const method = (response.config.method || 'get').toUpperCase();
const url = normalizeUrl(response.config);
const data = stringifyData(response.data);

console.log(`[API ${label}] ${method} ${url} -> ${response.status}`);
if (data) {
console.log(`[API ${label}] response=${data}`);
}

return response;
},
(error: AxiosError) => {
const config = error.config;
const method = config?.method?.toUpperCase() || 'UNKNOWN';
const url = config ? normalizeUrl(config) : 'unknown';
const status = error.response?.status;
const data = stringifyData(error.response?.data);

console.log(
`[API ${label}] ${method} ${url} -> ERROR${status ? ` ${status}` : ''}`,
);
if (data) {
console.log(`[API ${label}] response=${data}`);
}
Comment on lines +29 to +81
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

응답/파라미터 전체 로깅은 PII 노출 위험이 큽니다.
비프로덕션에서도 실제 사용자 데이터가 섞일 수 있으니 민감 키 마스킹 + 길이 제한 + 명시적 플래그로 제한하는 편이 안전합니다.

🛡️ 제안 수정
+const MAX_LOG_LENGTH = 2000;
+const REDACT_KEYS = new Set([
+  'password',
+  'token',
+  'accessToken',
+  'refreshToken',
+  'authorization',
+]);
+
+const truncate = (value: string) =>
+  value.length > MAX_LOG_LENGTH ? `${value.slice(0, MAX_LOG_LENGTH)}…` : value;
+
 const stringifyData = (data: unknown) => {
   if (data === undefined) return '';
 
-  if (typeof data === 'string') return data;
+  if (typeof data === 'string') return truncate(data);
 
   try {
-    return JSON.stringify(data);
+    return truncate(
+      JSON.stringify(data, (key, value) =>
+        REDACT_KEYS.has(key) ? '[REDACTED]' : value,
+      ),
+    );
   } catch {
-    return String(data);
+    return truncate(String(data));
   }
 };
🤖 Prompt for AI Agents
In `@src/api/client/api-logger.ts` around lines 29 - 81, The request logger
currently prints full params and response bodies (see attachApiLogger,
stringifyData, stringifyParams, and the request/response interceptors); update
it to avoid PII by adding an explicit opt-in flag to attachApiLogger (e.g.,
enableSensitiveLogging) and only emit full payloads when that flag is true,
implement masking in stringifyData/stringifyParams to redact known sensitive
keys (password, token, ssn, email, authorization, etc.), and enforce a maximum
length/truncation for strings and arrays to avoid huge dumps; ensure the
interceptors use the new flag and masked/truncated output when logging params
and response bodies.


return Promise.reject(error);
},
);
};
3 changes: 3 additions & 0 deletions src/api/client/axios.server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import axios, { InternalAxiosRequestConfig } from 'axios';
import { attachApiLogger } from './api-logger';
import { getServerCookie } from '../../utils/server-cookie';

// * server-side axios 인스턴스
Expand All @@ -13,6 +14,8 @@ export const axiosServerInstance = axios.create({
withCredentials: true,
});

attachApiLogger(axiosServerInstance, 'server-json');

const onRequestServer = async (config: InternalAxiosRequestConfig) => {
const accessToken = await getServerCookie('accessToken');

Expand Down
4 changes: 4 additions & 0 deletions src/api/client/axios.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import axios, { InternalAxiosRequestConfig, isAxiosError } from 'axios';
import { ApiError, isApiError } from './api-error';
import { attachApiLogger } from './api-logger';
import { getCookie, setCookie } from './cookie';

// * client-side axios 인스턴스
Expand All @@ -23,6 +24,9 @@
},
});

attachApiLogger(axiosInstance, 'client-json');
attachApiLogger(axiosInstanceForMultipart, 'client-multipart');

const onRequestClient = (config: InternalAxiosRequestConfig) => {
const accessToken = getCookie('accessToken');

Expand All @@ -37,7 +41,7 @@
axiosInstanceForMultipart.interceptors.request.use(onRequestClient);

// refresh token을 사용해서 access token을 재갱신하는 함수
const refreshAccessToken = async (): Promise<string | null> => {

Check warning on line 44 in src/api/client/axios.ts

View workflow job for this annotation

GitHub Actions / lint

Usage of "null" is deprecated except when describing legacy APIs; use "undefined" instead
try {
const response = await axios.get<{ content: { accessToken: string } }>(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/auth/access-token/refresh`,
Expand Down Expand Up @@ -68,11 +72,11 @@
// 토큰 갱신을 기다리는 요청들 저장
let failedQueue: Array<{
resolve: (value: string) => void;
reject: (error: any) => void;

Check warning on line 75 in src/api/client/axios.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
}> = [];

// 대기 중인 요청들을 처리하는 함수
const processFailedQueue = (error: unknown, token: string | null = null) => {

Check warning on line 79 in src/api/client/axios.ts

View workflow job for this annotation

GitHub Actions / lint

Usage of "null" is deprecated except when describing legacy APIs; use "undefined" instead
failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error);
Expand Down
3 changes: 3 additions & 0 deletions src/api/client/axiosV2.server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import axios, { InternalAxiosRequestConfig } from 'axios';
import { attachApiLogger } from './api-logger';
import { getServerCookie } from '../../utils/server-cookie';

// * server-side axios 인스턴스
Expand All @@ -13,6 +14,8 @@ export const axiosServerInstanceV2 = axios.create({
withCredentials: true,
});

attachApiLogger(axiosServerInstanceV2, 'server-v2-json');

const onRequestServer = async (config: InternalAxiosRequestConfig) => {
const accessToken = await getServerCookie('accessToken');

Expand Down
4 changes: 4 additions & 0 deletions src/api/client/axiosV2.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import axios, { InternalAxiosRequestConfig, isAxiosError } from 'axios';
import { ApiError, isApiError } from './api-error';
import { attachApiLogger } from './api-logger';
import { getCookie, setCookie } from './cookie';

// * client-side axios 인스턴스 - openapi에 사용될 용도 (openapi로 전환 완료하면 axiosInstance로 변경)
Expand All @@ -23,6 +24,9 @@
},
});

attachApiLogger(axiosInstanceV2, 'client-v2-json');
attachApiLogger(axiosInstanceForMultipartV2, 'client-v2-multipart');

const onRequestClient = (config: InternalAxiosRequestConfig) => {
const accessToken = getCookie('accessToken');

Expand All @@ -37,7 +41,7 @@
axiosInstanceForMultipartV2.interceptors.request.use(onRequestClient);

// refresh token을 사용해서 access token을 재갱신하는 함수
const refreshAccessToken = async (): Promise<string | null> => {

Check warning on line 44 in src/api/client/axiosV2.ts

View workflow job for this annotation

GitHub Actions / lint

Usage of "null" is deprecated except when describing legacy APIs; use "undefined" instead
try {
const response = await axios.get<{ content: { accessToken: string } }>(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/v1/auth/access-token/refresh`,
Expand Down Expand Up @@ -68,11 +72,11 @@
// 토큰 갱신을 기다리는 요청들 저장
let failedQueue: Array<{
resolve: (value: string) => void;
reject: (error: any) => void;

Check warning on line 75 in src/api/client/axiosV2.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected any. Specify a different type
}> = [];

// 대기 중인 요청들을 처리하는 함수
const processFailedQueue = (error: unknown, token: string | null = null) => {

Check warning on line 79 in src/api/client/axiosV2.ts

View workflow job for this annotation

GitHub Actions / lint

Usage of "null" is deprecated except when describing legacy APIs; use "undefined" instead
failedQueue.forEach(({ resolve, reject }) => {
if (error) {
reject(error);
Expand Down
1 change: 1 addition & 0 deletions src/app/(service)/home/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export default async function Home({
<FeedbackLink />
<StartStudyButton />
<HomeContent activeTab={activeTab} />
<div className="h-[400px]" aria-hidden />
</div>
);
}
11 changes: 0 additions & 11 deletions src/app/(service)/insights/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,17 +92,6 @@ export default async function BlogPage({ searchParams }: BlogPageProps) {
{category.name}
</Link>
))}
<Link
href="/insights/weekly"
className="font-designer-15r relative px-300 pb-200 text-[#535862] transition-colors hover:text-[#6366f1]"
>
<span className="flex items-center gap-100">
위클리
<span className="animate-pulse rounded-full bg-gradient-to-r from-[#6366f1] to-[#8b5cf6] px-100 py-25 text-[10px] font-bold text-white">
NEW
</span>
</span>
</Link>
</div>

{/* 아티클 목록 */}
Expand Down
12 changes: 0 additions & 12 deletions src/app/(service)/insights/weekly/[id]/page.tsx

This file was deleted.

5 changes: 0 additions & 5 deletions src/app/(service)/insights/weekly/page.tsx

This file was deleted.

31 changes: 24 additions & 7 deletions src/components/card/voting-card.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { MessageCircle, Users } from 'lucide-react';
import Link from 'next/link';
import React from 'react';
import { cn } from '@/components/ui/(shadcn)/lib/utils';
import UserAvatar from '@/components/ui/avatar';
Expand All @@ -10,12 +9,23 @@ import VoteTimer from '../voting/vote-timer';
interface VotingCardProps {
voting: BalanceGame;
onClick?: () => void;
onTagClick?: (tag: string) => void;
}

export default function VotingCard({ voting, onClick }: VotingCardProps) {
export default function VotingCard({
voting,
onClick,
onTagClick,
}: VotingCardProps) {
const topOption = voting.options.reduce((prev, current) =>
prev.percentage > current.percentage ? prev : current,
);
const isActive = voting.isActive ?? true;

const authorImage =
typeof voting.author.profileImage === 'string'
? voting.author.profileImage
: voting.author.profileImage?.resizedImages?.[0]?.resizedImageUrl;

// myVote can be null or number (optionId)
const hasVoted = voting.myVote !== undefined && voting.myVote !== null;
Expand All @@ -27,6 +37,7 @@ export default function VotingCard({ voting, onClick }: VotingCardProps) {
hasVoted
? 'ring-border-brand shadow-2'
: 'ring-border-subtle hover:ring-border-brand hover:shadow-2',
!isActive && 'bg-background-default',
)}
onClick={
onClick
Expand All @@ -48,7 +59,7 @@ export default function VotingCard({ voting, onClick }: VotingCardProps) {
<div>
<UserAvatar
size={32}
image={voting.author.profileImage || undefined}
image={authorImage || undefined}
className="relative z-10"
/>
</div>
Expand All @@ -73,12 +84,18 @@ export default function VotingCard({ voting, onClick }: VotingCardProps) {
{voting.tags && Array.isArray(voting.tags) && voting.tags.length > 0 && (
<div className="mb-200 flex flex-wrap gap-100">
{voting.tags.map((tag, index) => (
<span
<button
key={tag || index}
className="rounded-100 bg-fill-neutral-subtle-default font-designer-12r text-text-subtle px-150 py-50"
type="button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onTagClick?.(tag);
}}
className="rounded-100 bg-fill-neutral-subtle-default font-designer-12r text-text-subtle hover:ring-fill-brand-default-default px-150 py-50 ring-1 ring-transparent transition-shadow ring-inset"
>
#{tag}
</span>
</button>
))}
</div>
)}
Expand Down Expand Up @@ -129,5 +146,5 @@ export default function VotingCard({ voting, onClick }: VotingCardProps) {
return cardContent;
}

return <Link href={`/insights/weekly/${voting.id}`}>{cardContent}</Link>;
return cardContent;
}
5 changes: 4 additions & 1 deletion src/components/discussion/comment-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,10 @@ export default function CommentList({
'avatar' in comment.author
? comment.author.avatar
: 'profileImage' in comment.author
? (comment.author as any).profileImage
? typeof (comment.author as any).profileImage === 'string'
? (comment.author as any).profileImage
: (comment.author as any).profileImage?.resizedImages?.[0]
?.resizedImageUrl
: undefined;

return (
Expand Down
29 changes: 25 additions & 4 deletions src/components/home/tab-navigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ import {
History,
} from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useState } from 'react';
import { getCookie } from '@/api/client/cookie';
import { cn } from '@/components/ui/(shadcn)/lib/utils';
import Button from '@/components/ui/button';
import { useAuth } from '@/hooks/common/use-auth';
import { useScrollToHomeContent } from '@/hooks/use-scroll-to-home-content';

interface TabNavigationProps {
activeTab: string;
Expand Down Expand Up @@ -53,22 +56,40 @@ export default function TabNavigation({ activeTab }: TabNavigationProps) {
const router = useRouter();
const searchParams = useSearchParams();
const { isAuthenticated } = useAuth();
const hasMemberId = !!getCookie('memberId');
const canViewHistory = isAuthenticated && hasMemberId;
const [canViewHistory, setCanViewHistory] = useState(false);
const visibleTabs = canViewHistory
? TABS
: TABS.filter((tab) => tab.id !== 'history');

useEffect(() => {
const hasMemberId = !!getCookie('memberId');
setCanViewHistory(isAuthenticated && hasMemberId);
}, [isAuthenticated]);

const scrollToHomeContent = useScrollToHomeContent();

const handleTabChange = (tabId: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set('tab', tabId);
router.push(`/home?${params.toString()}`);
router.push(`/home?${params.toString()}`, { scroll: false });
requestAnimationFrame(scrollToHomeContent);
};

const handleStudyTutorial = () => {
const params = new URLSearchParams(searchParams.toString());
params.set('tab', 'study');
params.set('tutorial', 'study');
router.push(`/home?${params.toString()}`, { scroll: false });
requestAnimationFrame(scrollToHomeContent);
};

return (
<div className="mb-500 flex flex-col gap-300">
<div id="home-content-anchor" className="mb-500 flex flex-col gap-300">
<div className="flex items-center justify-between">
<h1 className="font-bold-h3 text-text-strong">제로원 홈</h1>
<Button size="small" color="outlined" onClick={handleStudyTutorial}>
스터디 튜토리얼 보기
</Button>
</div>

<nav className="border-border-subtle flex gap-100 border-b">
Expand Down
Loading
Loading