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
4 changes: 3 additions & 1 deletion service/app/src/app/admin/reports/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@ export default function AdminReportsPage() {
<div className="flex h-full flex-col items-center bg-background-interactive-primary-disabled p-40">
<div className="flex size-full flex-col justify-between gap-52 rounded-20 bg-background-interactive-primary-sub px-[62px] pb-[34px] pt-60">
<div className="flex w-full flex-col items-start gap-52 overflow-y-auto">
<h1 className="typography-heading-2xl-extrabold">신고접수</h1>
<h1 className="typography-heading-2xl-extrabold shrink-0 text-text-secondary">
신고접수
</h1>
<ReportedGamesTable items={games} onRowClick={handleRowClick} />
</div>
<div className="flex w-full justify-center">
Expand Down
105 changes: 104 additions & 1 deletion service/app/src/app/admin/users/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,106 @@
"use client"

import { useCallback, useMemo, useState } from "react"

import { useAdminUsers } from "@/entities/report/hooks/useAdminUsers"
import { AdminPageLayout } from "@/shared/ui/admin-layout"
import {
useEmailSelection,
UsersPageHeader,
UsersTable,
useUserBlockMutations,
} from "@/widgets/admin/users-table"

import { openBlockConfirmModal } from "./_components/dialog/blockConfirmModal"
import { openUnblockConfirmModal } from "./_components/dialog/unblockConfirmModal"

export default function AdminUsersPage() {
return <div>AdminPage-Users</div>
const [currentPage, setCurrentPage] = useState(1)
const [searchQuery, setSearchQuery] = useState("")
const pageIndex = currentPage - 1

const { users = [], totalPages } = useAdminUsers({
page: pageIndex,
})

const {
selectedEmails,
handleSelectChange,
handleSelectAll,
clearSelection,
} = useEmailSelection()

const { blockMutation, unblockMutation } = useUserBlockMutations()

const filteredUsers = useMemo(() => {
if (!searchQuery) return users
const query = searchQuery.toLowerCase()
return users.filter(
(user) =>
user.nickname.toLowerCase().includes(query) ||
user.email.toLowerCase().includes(query),
)
}, [users, searchQuery])

const userEmails = useMemo(
() => filteredUsers.map((u) => u.email),
[filteredUsers],
)
Comment on lines +35 to +48

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

프론트엔드 필터링과 백엔드 페이지네이션 간의 불일치 문제가 있습니다.

filteredUsers는 클라이언트에서 필터링되지만, totalPages는 백엔드 전체 데이터 기준입니다. 검색 시 필터링된 결과 수와 페이지네이션이 맞지 않아 사용자 혼란을 야기할 수 있습니다.

예: 100명의 유저가 10페이지에 걸쳐 있을 때 검색으로 2명만 필터링되어도 여전히 10페이지가 표시됩니다.

권장 해결 방안:

  1. 검색을 백엔드 API로 이동 (?search=query&page=n)
  2. 또는 검색 중에는 페이지네이션을 숨기거나 비활성화

Also applies to: 94-96

🤖 Prompt for AI Agents
In `@service/app/src/app/admin/users/page.tsx` around lines 35 - 48, The
client-side filtering in filteredUsers (useMemo) using searchQuery/users causes
pagination mismatch with the backend-derived totalPages; either move search to
the backend by updating the API calls to accept a search query and page
parameter (so the data returned and totalPages reflect the filtered results), or
change the UI logic in page.tsx to detect active searchQuery and hide/disable
pagination controls (totalPages) while using filteredUsers; locate and update
the useMemo for filteredUsers and userEmails as well as any component or prop
that renders totalPages to implement the chosen approach.


const handlePageChange = useCallback(
(page: number) => {
setCurrentPage(page)
clearSelection()
},
[clearSelection],
)

const handleSelectAllUsers = useCallback(
(checked: boolean) => {
handleSelectAll(userEmails, checked)
},
[handleSelectAll, userEmails],
)

const handleBlock = useCallback(() => {
openBlockConfirmModal({
onConfirm: () => {
blockMutation.mutate(Array.from(selectedEmails), {
onSuccess: clearSelection,
})
},
})
}, [blockMutation, selectedEmails, clearSelection])

const handleUnblock = useCallback(() => {
openUnblockConfirmModal({
onConfirm: () => {
unblockMutation.mutate(Array.from(selectedEmails), {
onSuccess: clearSelection,
})
},
})
}, [unblockMutation, selectedEmails, clearSelection])

return (
<AdminPageLayout
header={
<UsersPageHeader
onSearch={setSearchQuery}
onBlock={handleBlock}
onUnblock={handleUnblock}
/>
}
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChange}
>
<UsersTable
items={filteredUsers}
selectedEmails={selectedEmails}
onSelectChange={handleSelectChange}
onSelectAll={handleSelectAllUsers}
/>
</AdminPageLayout>
)
}
18 changes: 18 additions & 0 deletions service/app/src/entities/report/api/getAdminUsers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { fetchClient } from "@/shared/api/fetchClient"

import type { AdminUsersResponse } from "../model/types"

export interface GetAdminUsersParams {
page?: number
}

export const getAdminUsers = async (
params: GetAdminUsersParams = {},
): Promise<AdminUsersResponse> => {
const { page = 0 } = params
const queryString = page > 0 ? `?page=${page}` : ""
const response = await fetchClient.get<AdminUsersResponse>(
`admin/users${queryString}`,
)
return response.json().then((res) => res.data)
Comment on lines +12 to +17

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 | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, let's examine the actual file
cat -n service/app/src/entities/report/api/getAdminUsers.ts

Repository: JECT-Study/JECT-5-FE

Length of output: 704


🏁 Script executed:

# Check for similar query string patterns in other API files
rg -n "queryString.*page|page.*queryString" --type=ts service/app/src/entities/ -A 2 -B 2

Repository: JECT-Study/JECT-5-FE

Length of output: 1070


🏁 Script executed:

# Look for pagination patterns in API files
rg -n "page.*\?" --type=ts service/app/src/entities/ | head -20

Repository: JECT-Study/JECT-5-FE

Length of output: 909


🏁 Script executed:

# Check async patterns - .json().then() vs await in API files
rg -n "\.json\(\)\.then\(" --type=ts service/app/src/entities/ -A 1 -B 1

Repository: JECT-Study/JECT-5-FE

Length of output: 518


🏁 Script executed:

# Check for other response.json() patterns to understand codebase conventions
rg -n "response\.json\(\)" --type=ts service/app/src/entities/ -A 2 | head -40

Repository: JECT-Study/JECT-5-FE

Length of output: 2086


response.json().then() 패턴을 await로 통일하세요.

같은 report 엔티티의 getReportDetail.ts에서는 const res = await response.json() 패턴을 사용하고 있으므로, 일관성을 위해 이 파일도 동일하게 변경해야 합니다. getAdminReports.ts도 같은 패턴을 사용하므로 함께 수정하세요.

♻️ 제안된 수정
-  return response.json().then((res) => res.data)
+  const res = await response.json()
+  return res.data

참고: page > 0 조건의 의도(0-인덱싱 여부)는 백엔드 API 사양과 검증 필요합니다.

📝 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
const { page = 0 } = params
const queryString = page > 0 ? `?page=${page}` : ""
const response = await fetchClient.get<AdminUsersResponse>(
`admin/users${queryString}`,
)
return response.json().then((res) => res.data)
const { page = 0 } = params
const queryString = page > 0 ? `?page=${page}` : ""
const response = await fetchClient.get<AdminUsersResponse>(
`admin/users${queryString}`,
)
const res = await response.json()
return res.data
🤖 Prompt for AI Agents
In `@service/app/src/entities/report/api/getAdminUsers.ts` around lines 12 - 17,
The current getAdminUsers implementation uses response.json().then(...) which is
inconsistent with getReportDetail and getAdminReports; update getAdminUsers (and
similarly getAdminReports) to use const res = await response.json() and then
return res.data, ensuring you await response.json() instead of chaining .then;
locate the fetch call in getAdminUsers (the
fetchClient.get<AdminUsersResponse>(`admin/users${queryString}`) and the
variable response) and replace the .then pattern with the awaited assignment to
res and return res.data, keeping the existing page/queryString logic intact.

}
1 change: 1 addition & 0 deletions service/app/src/entities/report/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from "./getAdminReports"
export * from "./getAdminUsers"
export * from "./getReportDetail"
export * from "./updateAdminReportStatus"
1 change: 1 addition & 0 deletions service/app/src/entities/report/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./useAdminReports"
export * from "./useAdminUsers"
43 changes: 43 additions & 0 deletions service/app/src/entities/report/hooks/useAdminUsers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { useQuery } from "@tanstack/react-query"

import { getAdminUsers } from "../api/getAdminUsers"
import type { AdminUser } from "../model/types"

interface UseAdminUsersParams {
page?: number
enabled?: boolean
}

interface UseAdminUsersReturn {
users: AdminUser[]
currentPage: number
totalPages: number
totalElements: number
isLoading: boolean
error: Error | null
}

export const useAdminUsers = ({
page = 0,
enabled = true,
}: UseAdminUsersParams = {}): UseAdminUsersReturn => {
const { data, isLoading, error } = useQuery({
queryKey: ["adminUsers", { page }] as const,
queryFn: async () => {
const response = await getAdminUsers({ page })
return response
},
enabled,
staleTime: 5 * 60 * 1000,
retry: 2,
})

return {
users: data?.content ?? [],
currentPage: data?.page ?? page,
totalPages: data?.totalPages ?? 0,
totalElements: data?.totalElements ?? 0,
isLoading,
error: error as Error | null,
}
}
26 changes: 26 additions & 0 deletions service/app/src/entities/report/model/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,32 @@
import z from "zod"

import type { ReportReasonCode } from "@/entities/game/model/report"
import { reportReasonCodeSchema } from "@/entities/game/model/report"

export interface AdminUser {
nickname: string
email: string
blockReason: ReportReasonCode | null
blockedAt: string | null
blocked: boolean
}

export const adminUserSchema = z.object({
nickname: z.string(),
email: z.string(),
blockReason: reportReasonCodeSchema.nullable(),
blockedAt: z.string().nullable(),
blocked: z.boolean(),
})

export interface AdminUsersResponse {
content: AdminUser[]
page: number
size: number
totalElements: number
totalPages: number
hasNext: boolean
}

export interface ReportedGame {
id: number
Expand Down
1 change: 1 addition & 0 deletions service/app/src/entities/suspension/api/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./blockUsers"
export * from "./unblockUsers"
11 changes: 11 additions & 0 deletions service/app/src/entities/suspension/api/unblockUsers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { fetchClient } from "@/shared/api/fetchClient"

import type { UnblockUsersRequest } from "../model/types"

export const unblockUsers = async (payload: UnblockUsersRequest) => {
const response = await fetchClient.post<null>("admin/users/unblock", {
json: payload,
})

return response.json()
}
5 changes: 5 additions & 0 deletions service/app/src/entities/suspension/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,10 @@ export const blockUsersRequestSchema = z.object({
banList: z.array(banListItemSchema).min(1),
})

export const unblockUsersRequestSchema = z.object({
emails: z.array(z.string().min(1)).min(1),
})

export type BanListItem = z.infer<typeof banListItemSchema>
export type BlockUsersRequest = z.infer<typeof blockUsersRequestSchema>
export type UnblockUsersRequest = z.infer<typeof unblockUsersRequestSchema>
115 changes: 113 additions & 2 deletions service/app/src/mocks/handlers/admin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,17 @@ import { http, HttpResponse } from "msw"
import type {
AdminReport,
AdminReportsResponse,
AdminUser,
AdminUsersResponse,
} from "@/entities/report/model/types"
import {
adminReportUpdateRequestSchema,
reportIdParamsSchema,
} from "@/entities/report/model/types"
import { blockUsersRequestSchema } from "@/entities/suspension/model/types"
import {
blockUsersRequestSchema,
unblockUsersRequestSchema,
} from "@/entities/suspension/model/types"

import { internalServerError, loginRequiredError } from "../data/common"
import {
Expand All @@ -24,8 +29,35 @@ const MSW_BASE_URL = process.env.MSW_BASE_URL || "http://localhost:3000"

const PAGE_SIZE = 7

const BLOCK_REASONS = [
"VIOLENT_OR_DISTURBING_CONTENT",
"SEXUAL_CONTENT",
"CYBERBULLYING_OR_HARASSMENT",
"SUICIDE_OR_SELF_HARM",
"FRAUD_OR_MISINFORMATION",
"SPAM_OR_PROMOTION",
"PRIVACY_VIOLATION",
"INTELLECTUAL_PROPERTY_INFRINGEMENT",
] as const

const generateMockUsers = (count: number): AdminUser[] => {
return Array.from({ length: count }, (_, i) => {
const blocked = i % 3 === 0
return {
nickname: `user_${i + 1}`,
email: `user${i + 1}@example.com`,
blockReason: blocked ? BLOCK_REASONS[i % BLOCK_REASONS.length] : null,
blockedAt: blocked
? `2026-01-${String((i % 28) + 1).padStart(2, "0")}`
: null,
blocked,
}
})
}

export const createAdminHandlers = (initialReports: AdminReport[]) => {
const store: AdminReport[] = structuredClone(initialReports)
const usersStore: AdminUser[] = generateMockUsers(32)
const blockedEmails = new Set<string>()
Comment on lines 58 to 61

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 | 🟡 Minor

초기 blocked 상태와 blockedEmails 동기화가 필요합니다.

usersStore에 이미 차단된 유저가 존재하는데 blockedEmails는 빈 상태라, 일부 응답에서 차단 상태가 불일치할 수 있습니다. 초기화 시 동기화해 두는 편이 안전합니다.

🔧 제안 수정안
-  const usersStore: AdminUser[] = generateMockUsers(32)
-  const blockedEmails = new Set<string>()
+  const usersStore: AdminUser[] = generateMockUsers(32)
+  const blockedEmails = new Set(
+    usersStore.filter((user) => user.blocked).map((user) => user.email),
+  )
🤖 Prompt for AI Agents
In `@service/app/src/mocks/handlers/admin.ts` around lines 58 - 61, The
blockedEmails Set is initialized empty while generateMockUsers may produce users
with a blocked flag, causing inconsistent responses; inside createAdminHandlers
after usersStore is created, iterate usersStore (AdminUser objects) and add each
user's email to blockedEmails when user.blocked (or equivalent property) is true
so blockedEmails and usersStore start synchronized.


return [
Expand Down Expand Up @@ -170,14 +202,93 @@ export const createAdminHandlers = (initialReports: AdminReport[]) => {
return HttpResponse.json(gameMissingFieldsError(), { status: 400 })
}

parsed.data.banList.forEach(({ email }) => {
parsed.data.banList.forEach(({ email, reason }) => {
blockedEmails.add(email)
const userIndex = usersStore.findIndex((u) => u.email === email)
if (userIndex !== -1) {
usersStore[userIndex] = {
...usersStore[userIndex],
blocked: true,
blockReason:
(reason as (typeof BLOCK_REASONS)[number]) ||
"VIOLENT_OR_DISTURBING_CONTENT",
blockedAt: new Date().toISOString().split("T")[0],
}
}
})

return HttpResponse.json(gameSuccessResponse())
} catch {
return HttpResponse.json(internalServerError, { status: 500 })
}
}),

http.post(`${MSW_BASE_URL}/admin/users/unblock`, async ({ request }) => {
try {
const cookieHeader = request.headers.get("Cookie")
if (!validateSessionCookie(cookieHeader)) {
return HttpResponse.json(loginRequiredError, { status: 401 })
}

const json = await request.json()
const parsed = unblockUsersRequestSchema.safeParse(json)

if (!parsed.success) {
return HttpResponse.json(gameMissingFieldsError(), { status: 400 })
}

parsed.data.emails.forEach((email) => {
blockedEmails.delete(email)
const userIndex = usersStore.findIndex((u) => u.email === email)
if (userIndex !== -1) {
usersStore[userIndex] = {
...usersStore[userIndex],
blocked: false,
blockReason: null,
blockedAt: null,
}
}
})

return HttpResponse.json(gameSuccessResponse())
} catch {
return HttpResponse.json(internalServerError, { status: 500 })
}
}),

http.get(`${MSW_BASE_URL}/admin/users`, ({ request }) => {
try {
const cookieHeader = request.headers.get("Cookie")
if (!validateSessionCookie(cookieHeader)) {
return HttpResponse.json(loginRequiredError, { status: 401 })
}

const url = new URL(request.url)
const pageParam = url.searchParams.get("page")
const page = pageParam ? Math.max(0, parseInt(pageParam, 10)) : 0

const totalElements = usersStore.length
const totalPages = Math.ceil(totalElements / PAGE_SIZE)

const startIndex = page * PAGE_SIZE
const endIndex = startIndex + PAGE_SIZE

const paginatedUsers = usersStore.slice(startIndex, endIndex)
const hasNext = page < totalPages - 1

const response: AdminUsersResponse = {
content: paginatedUsers,
page,
size: PAGE_SIZE,
totalElements,
totalPages,
hasNext,
}

return HttpResponse.json(generateSuccessResponse(response))
} catch {
return HttpResponse.json(internalServerError, { status: 500 })
}
}),
]
}
Loading
Loading