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
22 changes: 22 additions & 0 deletions src/api/fetch/admin/api/useDeleteNotice.ts
Original file line number Diff line number Diff line change
@@ -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<void>("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");
},
});
};
10 changes: 10 additions & 0 deletions src/api/fetch/admin/api/useGetNoticeDraft.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import useAppQuery from "@/api/_base/query/useAppQuery";
import { NoticeDraftResponse } from "../types/NoticeDraftType";

export const useGetNoticeDraft = () => {
return useAppQuery<NoticeDraftResponse>("auth", ["notice-draft"], "/admin/notices/draft", {
// 임시저장본이 없을 때 서버가 오류로 응답할 수 있으므로 재시도하지 않는다.
retry: false,
staleTime: 0,
});
};
10 changes: 10 additions & 0 deletions src/api/fetch/admin/api/useGetUserDetail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import useAppQuery from "@/api/_base/query/useAppQuery";
import { GetUserDetailResponse } from "../types/UserDetailType";

export const useGetUserDetail = ({ userId }: { userId: number }) => {
return useAppQuery<GetUserDetailResponse>(
"auth",
["admin-user-detail", userId],
`/admin/users/${userId}`
);
};
29 changes: 29 additions & 0 deletions src/api/fetch/admin/api/usePostGuestInquiryReply.ts
Original file line number Diff line number Diff line change
@@ -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<GuestInquiryReplyRequest, ApiBaseResponseType<string>>(
"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");
},
}
);
};
30 changes: 30 additions & 0 deletions src/api/fetch/admin/api/usePostInquiryBlockIp.ts
Original file line number Diff line number Diff line change
@@ -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<string>,
AxiosError<ApiBaseResponseType<null>>
>("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");
},
});
};
26 changes: 26 additions & 0 deletions src/api/fetch/admin/api/usePostNoticeDraft.ts
Original file line number Diff line number Diff line change
@@ -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<NoticeSaveRequest & { draft: true }, ApiBaseResponseType<number>>(
"auth",
"/admin/notices",
"post",
{
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["notice-draft"] });
addToast("임시저장했어요", "success");
},
onError: () => {
addToast("임시저장에 실패했어요", "error");
},
}
);
};
39 changes: 39 additions & 0 deletions src/api/fetch/admin/api/usePutNoticeDraft.ts
Original file line number Diff line number Diff line change
@@ -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<NoticeSaveRequest, ApiBaseResponseType<string>>(
"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");
},
}
);
};
10 changes: 10 additions & 0 deletions src/api/fetch/admin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
23 changes: 23 additions & 0 deletions src/api/fetch/admin/types/NoticeDraftType.ts
Original file line number Diff line number Diff line change
@@ -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<NoticeDraft | null> {}

export interface NoticeSaveRequest {
title: string;
content: string;
category?: NoticeCategory;
imageUrls: string[];
draft: boolean;
}
24 changes: 24 additions & 0 deletions src/api/fetch/admin/types/UserDetailType.ts
Original file line number Diff line number Diff line change
@@ -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<AdminUserDetail> {}
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Button variant="outlined" className={className} onClick={() => setIsOpen(true)}>
IP 차단하기
</Button>

<ConfirmModal
isOpen={isOpen}
title="이 문의의 IP를 차단할까요?"
content="차단한 IP에서는 더 이상 문의를 보낼 수 없어요."
confirmLabel="차단"
isPending={isPending}
onClose={close}
onCancel={close}
onConfirm={confirmBlock}
size="small"
/>
</>
);
};

export default InquiryBlockIpButton;
1 change: 1 addition & 0 deletions src/app/(admin)/admin/_components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ jest.mock("@/app/(admin)/admin/_components", () => ({
AdminDetailSection: ({ data }: any) => (
<div data-testid="detail-section" data-username={data?.email} />
),
InquiryBlockIpButton: ({ inquiryId }: any) => (
<div data-testid="block-ip" data-inquiry-id={inquiryId} />
),
}));

jest.mock("../GuestInquiryReplyForm/GuestInquiryReplyForm", () => ({
__esModule: true,
default: ({ email }: any) => <div data-testid="reply-form" data-email={email} />,
}));

const addToastMock = jest.fn();
Expand Down Expand Up @@ -129,4 +137,24 @@ describe("GuestInquiriesDetailView", () => {
expect(writeText).toHaveBeenCalledTimes(1);
expect(addToastMock).toHaveBeenCalledWith("이메일 복사에 실패했어요", "error");
});

it("답변하지 않은 문의는 답변 폼과 IP 차단 버튼을 보여줌", () => {
render(<GuestInquiriesDetailView id={1} />, { 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(<GuestInquiriesDetailView id={1} />, { wrapper: createWrapper() });

expect(screen.queryByTestId("reply-form")).not.toBeInTheDocument();
expect(screen.getByText("답변을 보낸 문의예요.")).toBeInTheDocument();
});
});
Loading
Loading