diff --git a/src/api/fetch/admin/api/useDeleteNotice.ts b/src/api/fetch/admin/api/useDeleteNotice.ts new file mode 100644 index 0000000..5303600 --- /dev/null +++ b/src/api/fetch/admin/api/useDeleteNotice.ts @@ -0,0 +1,22 @@ +import useAppMutation from "@/api/_base/query/useAppMutation"; +import { useToast } from "@/context/ToastContext"; +import { useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; + +export const useDeleteNotice = (noticeId: number) => { + const queryClient = useQueryClient(); + const { addToast } = useToast(); + const router = useRouter(); + + return useAppMutation("auth", `/admin/notices/${noticeId}`, "delete", { + onSuccess: () => { + queryClient.removeQueries({ queryKey: ["notice-detail", noticeId] }); + queryClient.invalidateQueries({ queryKey: ["notices"] }); + addToast("공지사항이 삭제되었어요", "success"); + router.replace("/admin/notice"); + }, + onError: () => { + addToast("공지사항 삭제에 실패했어요", "error"); + }, + }); +}; diff --git a/src/api/fetch/admin/api/useGetNoticeDraft.ts b/src/api/fetch/admin/api/useGetNoticeDraft.ts new file mode 100644 index 0000000..125e811 --- /dev/null +++ b/src/api/fetch/admin/api/useGetNoticeDraft.ts @@ -0,0 +1,10 @@ +import useAppQuery from "@/api/_base/query/useAppQuery"; +import { NoticeDraftResponse } from "../types/NoticeDraftType"; + +export const useGetNoticeDraft = () => { + return useAppQuery("auth", ["notice-draft"], "/admin/notices/draft", { + // 임시저장본이 없을 때 서버가 오류로 응답할 수 있으므로 재시도하지 않는다. + retry: false, + staleTime: 0, + }); +}; diff --git a/src/api/fetch/admin/api/useGetUserDetail.ts b/src/api/fetch/admin/api/useGetUserDetail.ts new file mode 100644 index 0000000..2392c08 --- /dev/null +++ b/src/api/fetch/admin/api/useGetUserDetail.ts @@ -0,0 +1,10 @@ +import useAppQuery from "@/api/_base/query/useAppQuery"; +import { GetUserDetailResponse } from "../types/UserDetailType"; + +export const useGetUserDetail = ({ userId }: { userId: number }) => { + return useAppQuery( + "auth", + ["admin-user-detail", userId], + `/admin/users/${userId}` + ); +}; diff --git a/src/api/fetch/admin/api/usePostGuestInquiryReply.ts b/src/api/fetch/admin/api/usePostGuestInquiryReply.ts new file mode 100644 index 0000000..af1b2fa --- /dev/null +++ b/src/api/fetch/admin/api/usePostGuestInquiryReply.ts @@ -0,0 +1,29 @@ +import { useQueryClient } from "@tanstack/react-query"; +import useAppMutation from "@/api/_base/query/useAppMutation"; +import { useToast } from "@/context/ToastContext"; +import { ApiBaseResponseType } from "@/api/_base/types/ApiBaseResponseType"; + +interface GuestInquiryReplyRequest { + content: string; +} + +export const usePostGuestInquiryReply = (inquiryId: number) => { + const { addToast } = useToast(); + const queryClient = useQueryClient(); + + return useAppMutation>( + "auth", + `/admin/guest-inquiries/${inquiryId}/reply`, + "post", + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["guest-inquiries-detail", inquiryId] }); + queryClient.invalidateQueries({ queryKey: ["guest-inquiries"] }); + addToast("답변을 이메일로 보냈어요", "success"); + }, + onError: () => { + addToast("답변 발송에 실패했어요", "error"); + }, + } + ); +}; diff --git a/src/api/fetch/admin/api/usePostInquiryBlockIp.ts b/src/api/fetch/admin/api/usePostInquiryBlockIp.ts new file mode 100644 index 0000000..6fd9bfe --- /dev/null +++ b/src/api/fetch/admin/api/usePostInquiryBlockIp.ts @@ -0,0 +1,30 @@ +import { AxiosError } from "axios"; +import useAppMutation from "@/api/_base/query/useAppMutation"; +import { useToast } from "@/context/ToastContext"; +import { ApiBaseResponseType } from "@/api/_base/types/ApiBaseResponseType"; + +interface InquiryBlockIpRequest { + reason?: string; +} + +export const usePostInquiryBlockIp = (inquiryId: number) => { + const { addToast } = useToast(); + + return useAppMutation< + InquiryBlockIpRequest, + ApiBaseResponseType, + AxiosError> + >("auth", `/admin/inquiries/${inquiryId}/block-ip`, "post", { + onSuccess: () => { + addToast("이 문의의 IP를 차단했어요", "success"); + }, + onError: (error) => { + // 409는 이미 차단 목록에 있는 IP라는 뜻이다. + if (error.response?.status === 409) { + addToast("이미 차단된 IP예요", "warning"); + return; + } + addToast("IP 차단에 실패했어요", "error"); + }, + }); +}; diff --git a/src/api/fetch/admin/api/usePostNoticeDraft.ts b/src/api/fetch/admin/api/usePostNoticeDraft.ts new file mode 100644 index 0000000..f8bb162 --- /dev/null +++ b/src/api/fetch/admin/api/usePostNoticeDraft.ts @@ -0,0 +1,26 @@ +import { useQueryClient } from "@tanstack/react-query"; +import useAppMutation from "@/api/_base/query/useAppMutation"; +import { useToast } from "@/context/ToastContext"; +import { ApiBaseResponseType } from "@/api/_base/types/ApiBaseResponseType"; +import { NoticeSaveRequest } from "../types/NoticeDraftType"; + +/** 공지를 처음 임시저장한다. 응답의 `result`가 새로 만들어진 공지 ID다. */ +export const usePostNoticeDraft = () => { + const { addToast } = useToast(); + const queryClient = useQueryClient(); + + return useAppMutation>( + "auth", + "/admin/notices", + "post", + { + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["notice-draft"] }); + addToast("임시저장했어요", "success"); + }, + onError: () => { + addToast("임시저장에 실패했어요", "error"); + }, + } + ); +}; diff --git a/src/api/fetch/admin/api/usePutNoticeDraft.ts b/src/api/fetch/admin/api/usePutNoticeDraft.ts new file mode 100644 index 0000000..3a24e95 --- /dev/null +++ b/src/api/fetch/admin/api/usePutNoticeDraft.ts @@ -0,0 +1,39 @@ +import { useQueryClient } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import useAppMutation from "@/api/_base/query/useAppMutation"; +import { useToast } from "@/context/ToastContext"; +import { ApiBaseResponseType } from "@/api/_base/types/ApiBaseResponseType"; +import { NoticeSaveRequest } from "../types/NoticeDraftType"; + +/** + * 임시저장한 공지를 갱신한다. `draft: true`면 임시저장을 덮어쓰고, + * `draft: false`면 그 공지를 발행하고 공지 목록으로 이동한다. + */ +export const usePutNoticeDraft = (draftId: number | null) => { + const { addToast } = useToast(); + const queryClient = useQueryClient(); + const router = useRouter(); + + return useAppMutation>( + "auth", + `/admin/notices/${draftId}`, + "put", + { + onSuccess: (_, { draft }) => { + queryClient.invalidateQueries({ queryKey: ["notice-draft"] }); + + if (draft) { + addToast("임시저장했어요", "success"); + return; + } + + queryClient.invalidateQueries({ queryKey: ["notices"] }); + addToast("공지사항이 등록되었습니다.", "success"); + router.replace("/admin/notice"); + }, + onError: (_, { draft }) => { + addToast(draft ? "임시저장에 실패했어요" : "공지사항 등록에 실패했어요", "error"); + }, + } + ); +}; diff --git a/src/api/fetch/admin/index.ts b/src/api/fetch/admin/index.ts index d243ab5..45df116 100644 --- a/src/api/fetch/admin/index.ts +++ b/src/api/fetch/admin/index.ts @@ -20,3 +20,13 @@ export { usePutInquiryStatus } from "./api/usePutInquiryStatus"; export { usePostReportComments } from "./api/usePostReportComments"; export { usePutNoticeDetail } from "./api/usePutNoticeDetail"; export { useGetMarketingPosts } from "./api/useGetMarketingPosts"; +export * from "./types/NoticeDraftType"; +export * from "./types/UserDetailType"; + +export { usePostGuestInquiryReply } from "./api/usePostGuestInquiryReply"; +export { usePostInquiryBlockIp } from "./api/usePostInquiryBlockIp"; +export { useDeleteNotice } from "./api/useDeleteNotice"; +export { useGetNoticeDraft } from "./api/useGetNoticeDraft"; +export { usePostNoticeDraft } from "./api/usePostNoticeDraft"; +export { usePutNoticeDraft } from "./api/usePutNoticeDraft"; +export { useGetUserDetail } from "./api/useGetUserDetail"; diff --git a/src/api/fetch/admin/types/NoticeDraftType.ts b/src/api/fetch/admin/types/NoticeDraftType.ts new file mode 100644 index 0000000..bb99dd6 --- /dev/null +++ b/src/api/fetch/admin/types/NoticeDraftType.ts @@ -0,0 +1,23 @@ +import { ApiBaseResponseType } from "@/api/_base/types/ApiBaseResponseType"; +import { NoticeCategory } from "@/types"; + +export interface NoticeDraft { + noticeId: number; + title: string; + content: string; + category: NoticeCategory | null; + pinned: boolean; + images: string[] | null; + createdAt: string; + updatedAt: string; +} + +export interface NoticeDraftResponse extends ApiBaseResponseType {} + +export interface NoticeSaveRequest { + title: string; + content: string; + category?: NoticeCategory; + imageUrls: string[]; + draft: boolean; +} diff --git a/src/api/fetch/admin/types/UserDetailType.ts b/src/api/fetch/admin/types/UserDetailType.ts new file mode 100644 index 0000000..f98d78d --- /dev/null +++ b/src/api/fetch/admin/types/UserDetailType.ts @@ -0,0 +1,24 @@ +import { ApiBaseResponseType } from "@/api/_base/types/ApiBaseResponseType"; +import { CategoryType } from "@/types"; + +export interface AdminUserDetail { + userId: number; + nickname: string; + email: string; + emailVerified: boolean; + role: "USER" | "ADMIN"; + profileImg: string | null; + privacyPolicyAgreed: boolean; + termsOfServiceAgreed: boolean; + contentPolicyAgreed: boolean; + marketingConsent: boolean; + createdAt: string; + updatedAt: string; + deletedAt: string | null; + postCount: number; + commentCount: number; + reportCount: number; + subscribedCategories: CategoryType[]; +} + +export interface GetUserDetailResponse extends ApiBaseResponseType {} diff --git a/src/app/(admin)/admin/_components/InquiryBlockIpButton/InquiryBlockIpButton.tsx b/src/app/(admin)/admin/_components/InquiryBlockIpButton/InquiryBlockIpButton.tsx new file mode 100644 index 0000000..4ce5a47 --- /dev/null +++ b/src/app/(admin)/admin/_components/InquiryBlockIpButton/InquiryBlockIpButton.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useState } from "react"; +import { Button, ConfirmModal } from "@/components"; +import { usePostInquiryBlockIp } from "@/api/fetch/admin"; + +interface InquiryBlockIpButtonProps { + inquiryId: number; + className?: string; +} + +/** 문의를 보낸 IP를 차단 목록에 올리는 버튼과 확인 모달입니다. 회원·비회원 문의 상세에서 함께 씁니다. */ +const InquiryBlockIpButton = ({ inquiryId, className }: InquiryBlockIpButtonProps) => { + const [isOpen, setIsOpen] = useState(false); + const { mutate: blockIp, isPending } = usePostInquiryBlockIp(inquiryId); + + const close = () => setIsOpen(false); + + const confirmBlock = () => { + blockIp({}, { onSettled: close }); + }; + + return ( + <> + + + + + ); +}; + +export default InquiryBlockIpButton; diff --git a/src/app/(admin)/admin/_components/index.ts b/src/app/(admin)/admin/_components/index.ts index 8c94030..9e23f8c 100644 --- a/src/app/(admin)/admin/_components/index.ts +++ b/src/app/(admin)/admin/_components/index.ts @@ -7,3 +7,4 @@ export { default as AdminReportsItem } from "./AdminReportsItem/AdminReportsItem export { default as AdminDetailSection } from "./AdminDetailSection/AdminDetailSection"; export { default as AdminDropdown } from "./AdminDropdown/AdminDropdown"; export { default as AdminSidebar } from "./AdminSidebar/AdminSidebar"; +export { default as InquiryBlockIpButton } from "./InquiryBlockIpButton/InquiryBlockIpButton"; diff --git a/src/app/(admin)/admin/_utils/toReportsItemVM/toReportsItemVM.test.ts b/src/app/(admin)/admin/_utils/toReportsItemVM/toReportsItemVM.test.ts index 16590af..ec58744 100644 --- a/src/app/(admin)/admin/_utils/toReportsItemVM/toReportsItemVM.test.ts +++ b/src/app/(admin)/admin/_utils/toReportsItemVM/toReportsItemVM.test.ts @@ -30,6 +30,14 @@ describe("toReportItemVM", () => { }); }); +describe("toReportItemVM 신고자 ID", () => { + it("reporterId가 있으면 상세 주소에 userId 쿼리를 붙임", () => { + const item = { reportId: 1, reporterId: 42, reportType: "DUPLICATE" } as any; + + expect(toReportItemVM(item).href).toBe("/admin/reports/report/1?userId=42"); + }); +}); + describe("toInquiryItemVM", () => { it("문의 매핑", () => { const item = { @@ -56,6 +64,14 @@ describe("toInquiryItemVM", () => { }); }); +describe("toInquiryItemVM 문의자 ID", () => { + it("userId가 있으면 상세 주소에 userId 쿼리를 붙임", () => { + const item = { inquiryId: 10, userId: 7 } as any; + + expect(toInquiryItemVM(item).href).toBe("/admin/reports/inquiry/10?userId=7"); + }); +}); + describe("toGuestInquiryItemVM", () => { it("비회원 문의 매핑", () => { const item = { diff --git a/src/app/(admin)/admin/_utils/toReportsItemVM/toReportsItemVM.ts b/src/app/(admin)/admin/_utils/toReportsItemVM/toReportsItemVM.ts index 6b2cf0d..3b50ba8 100644 --- a/src/app/(admin)/admin/_utils/toReportsItemVM/toReportsItemVM.ts +++ b/src/app/(admin)/admin/_utils/toReportsItemVM/toReportsItemVM.ts @@ -8,7 +8,8 @@ import { REPORT_TYPE_TITLE } from "../../_constants/REPORT_TYPE_TITLE"; export const toReportItemVM = (item: AdminReportItem): AdminReportsItemData => { return { - href: `/admin/reports/report/${item.reportId}`, + // 상세 응답에는 신고자 ID가 없어서, 유저 정보로 이어지도록 목록의 ID를 쿼리로 넘긴다. + href: `/admin/reports/report/${item.reportId}${item.reporterId ? `?userId=${item.reporterId}` : ""}`, title: REPORT_TYPE_TITLE[item.reportType], content: item.reason, nickname: item.reporterNickname, @@ -21,7 +22,8 @@ export const toReportItemVM = (item: AdminReportItem): AdminReportsItemData => { export const toInquiryItemVM = (item: AdminInquiriesItem): AdminReportsItemData => { return { - href: `/admin/reports/inquiry/${item.inquiryId}`, + // 상세 응답에는 문의자 ID가 없어서, 유저 정보로 이어지도록 목록의 ID를 쿼리로 넘긴다. + href: `/admin/reports/inquiry/${item.inquiryId}${item.userId ? `?userId=${item.userId}` : ""}`, title: item.title, content: item.content, nickname: item.nickname, diff --git a/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiriesDetailView/GuestInquiriesDetailView.test.tsx b/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiriesDetailView/GuestInquiriesDetailView.test.tsx index c75d824..7292e74 100644 --- a/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiriesDetailView/GuestInquiriesDetailView.test.tsx +++ b/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiriesDetailView/GuestInquiriesDetailView.test.tsx @@ -20,6 +20,14 @@ jest.mock("@/app/(admin)/admin/_components", () => ({ AdminDetailSection: ({ data }: any) => (
), + InquiryBlockIpButton: ({ inquiryId }: any) => ( +
+ ), +})); + +jest.mock("../GuestInquiryReplyForm/GuestInquiryReplyForm", () => ({ + __esModule: true, + default: ({ email }: any) =>
, })); const addToastMock = jest.fn(); @@ -129,4 +137,24 @@ describe("GuestInquiriesDetailView", () => { expect(writeText).toHaveBeenCalledTimes(1); expect(addToastMock).toHaveBeenCalledWith("이메일 복사에 실패했어요", "error"); }); + + it("답변하지 않은 문의는 답변 폼과 IP 차단 버튼을 보여줌", () => { + render(, { wrapper: createWrapper() }); + + expect(screen.getByTestId("reply-form")).toHaveAttribute("data-email", "admin@gmail.com"); + expect(screen.getByTestId("block-ip")).toHaveAttribute("data-inquiry-id", "1"); + }); + + it("이미 답변한 문의는 답변 폼 대신 안내 문구를 보여줌", () => { + mockedUseGetDetailGuestInquiries.mockReturnValue({ + data: { result: { email: "admin@gmail.com", answered: true } }, + isLoading: false, + isError: false, + }); + + render(, { wrapper: createWrapper() }); + + expect(screen.queryByTestId("reply-form")).not.toBeInTheDocument(); + expect(screen.getByText("답변을 보낸 문의예요.")).toBeInTheDocument(); + }); }); diff --git a/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiriesDetailView/GuestInquiriesDetailView.tsx b/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiriesDetailView/GuestInquiriesDetailView.tsx index 4299bfe..04a9af5 100644 --- a/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiriesDetailView/GuestInquiriesDetailView.tsx +++ b/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiriesDetailView/GuestInquiriesDetailView.tsx @@ -2,8 +2,9 @@ import { Button, LoadingState } from "@/components"; import { useToast } from "@/context/ToastContext"; -import { AdminDetailSection } from "@/app/(admin)/admin/_components"; +import { AdminDetailSection, InquiryBlockIpButton } from "@/app/(admin)/admin/_components"; import { useGetDetailGuestInquiries } from "@/api/fetch/admin"; +import GuestInquiryReplyForm from "../GuestInquiryReplyForm/GuestInquiryReplyForm"; interface GuestInquiriesDetailViewProps { id: number; @@ -34,12 +35,21 @@ const GuestInquiriesDetailView = ({ id }: GuestInquiriesDetailViewProps) => {
+ + {data.result.answered ? ( +

+ 답변을 보낸 문의예요. +

+ ) : ( + + )}
-
- +
); diff --git a/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiryReplyForm/GuestInquiryReplyForm.test.tsx b/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiryReplyForm/GuestInquiryReplyForm.test.tsx new file mode 100644 index 0000000..eec7925 --- /dev/null +++ b/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiryReplyForm/GuestInquiryReplyForm.test.tsx @@ -0,0 +1,58 @@ +import React from "react"; +import { render, screen, fireEvent } from "@testing-library/react"; +import GuestInquiryReplyForm from "./GuestInquiryReplyForm"; +import { usePostGuestInquiryReply } from "@/api/fetch/admin"; + +jest.mock("@/components", () => ({ + Button: ({ children, loading, ...rest }: any) => , +})); + +jest.mock("@/api/fetch/admin", () => ({ + usePostGuestInquiryReply: jest.fn(), +})); + +const mutateMock = jest.fn(); +const mockedUsePostGuestInquiryReply = usePostGuestInquiryReply as jest.Mock; + +describe("GuestInquiryReplyForm", () => { + beforeEach(() => { + mutateMock.mockReset(); + mockedUsePostGuestInquiryReply.mockReturnValue({ mutate: mutateMock, isPending: false }); + }); + + it("답변 내용이 비어 있으면 보내기 버튼이 비활성화됨", () => { + render(); + + expect(screen.getByRole("button", { name: "답변 보내기" })).toBeDisabled(); + expect(screen.getByText(/guest@test.com/)).toBeInTheDocument(); + }); + + it("공백만 입력하면 보내기 버튼이 비활성화됨", () => { + render(); + + fireEvent.change(screen.getByLabelText("답변 내용"), { target: { value: " " } }); + + expect(screen.getByRole("button", { name: "답변 보내기" })).toBeDisabled(); + }); + + it("답변을 입력해 보내면 앞뒤 공백을 지운 내용으로 요청하고, 성공 시 입력을 비움", () => { + mutateMock.mockImplementation((_body, options) => options?.onSuccess?.()); + render(); + + const textarea = screen.getByLabelText("답변 내용") as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: " 확인했습니다. " } }); + fireEvent.click(screen.getByRole("button", { name: "답변 보내기" })); + + expect(mockedUsePostGuestInquiryReply).toHaveBeenCalledWith(3); + expect(mutateMock).toHaveBeenCalledWith({ content: "확인했습니다." }, expect.any(Object)); + expect(textarea.value).toBe(""); + }); + + it("발송 중에는 입력창과 버튼이 비활성화됨", () => { + mockedUsePostGuestInquiryReply.mockReturnValue({ mutate: mutateMock, isPending: true }); + render(); + + expect(screen.getByLabelText("답변 내용")).toBeDisabled(); + expect(screen.getByRole("button", { name: "답변 보내기" })).toBeDisabled(); + }); +}); diff --git a/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiryReplyForm/GuestInquiryReplyForm.tsx b/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiryReplyForm/GuestInquiryReplyForm.tsx new file mode 100644 index 0000000..a95b14d --- /dev/null +++ b/src/app/(admin)/admin/guest-inquiries/[id]/_components/GuestInquiryReplyForm/GuestInquiryReplyForm.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useState, type FormEvent } from "react"; +import { Button } from "@/components"; +import { cn } from "@/utils"; +import { usePostGuestInquiryReply } from "@/api/fetch/admin"; + +const MAX_LENGTH = 2000; + +interface GuestInquiryReplyFormProps { + inquiryId: number; + email: string; +} + +/** 비회원 문의에 대한 답변을 작성해 문의자 이메일로 보내는 폼입니다. */ +const GuestInquiryReplyForm = ({ inquiryId, email }: GuestInquiryReplyFormProps) => { + const [content, setContent] = useState(""); + const { mutate: sendReply, isPending } = usePostGuestInquiryReply(inquiryId); + + const isSubmitDisabled = !content.trim() || isPending; + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + if (isSubmitDisabled) return; + + sendReply({ content: content.trim() }, { onSuccess: () => setContent("") }); + }; + + return ( +
+
+

+ 이메일로 답변하기 +

+

+ {email}으로 답변이 발송되고, 문의는 처리 완료로 바뀌어요. +

+
+ +
+ +