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
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "public"."NotificationType" ADD VALUE 'ESTIMATE_CANCELED_BY_ADMIN';
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- SYSTEM 메시지는 특정 사용자가 보낸 메시지가 아니므로 sender_id를 nullable로 변경합니다.
ALTER TABLE "public"."chat_messages" ALTER COLUMN "sender_id" DROP NOT NULL;
9 changes: 5 additions & 4 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ enum NotificationType {
CONTENT_RESTORED // (작성자) 숨김 콘텐츠가 관리자에 의해 복구됨
ESTIMATE_REQUEST_CANCELED_BY_ACCOUNT_SUSPENSION // (기사) 고객 계정이 정지되어 견적 요청 취소됨
ESTIMATE_CANCELED_BY_ACCOUNT_SUSPENSION // (고객) 기사 계정이 정지되어 견적 취소됨
ESTIMATE_CANCELED_BY_ADMIN // (고객/기사) 관리자가 확정 견적을 취소함
}

/// 채팅 메시지 유형
Expand Down Expand Up @@ -1071,8 +1072,8 @@ model ChatMessage {
id Int @id @default(autoincrement())
/// 소속 채팅방.
roomId Int @map("room_id")
/// 보낸 사람.
senderId String @map("sender_id") @db.Uuid
/// 보낸 사람. SYSTEM 메시지는 null입니다.
senderId String? @map("sender_id") @db.Uuid
/// 메시지 유형. 기본 TEXT.
type ChatMessageType @default(TEXT)
/// 텍스트 내용. IMAGE 등에서는 null 일 수 있음.
Expand All @@ -1085,8 +1086,8 @@ model ChatMessage {
readAt DateTime? @map("read_at")
/// 채팅방 관계. 방 삭제 시 함께 삭제(Cascade).
room ChatRoom @relation(fields: [roomId], references: [id], onDelete: Cascade)
/// 보낸 사람 관계. 계정 삭제 시 함께 삭제(Cascade).
sender User @relation(fields: [senderId], references: [id], onDelete: Cascade)
/// 보낸 사람 관계. SYSTEM 메시지는 없으며, 사용자 메시지는 계정 삭제 시 함께 삭제됩니다.
sender User? @relation(fields: [senderId], references: [id], onDelete: Cascade)
/// 이 메시지에 연결된 견적 수정 요청(1:1, ESTIMATE_REVISION 타입일 때).
revision EstimateRevision?
/// 생성 시각.
Expand Down
2 changes: 2 additions & 0 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import errorHandler from "./middlewares/error-handler";
import notFoundHandler from "./middlewares/not-found-handler";

import { adminReviewRouter } from "./modules/admin/contents/contents.route";
import adminEstimateRouter from "./modules/admin/estimates/estimates.route";
import adminCustomerRouter from "./modules/admin/member-management/customers/customers.route";
import noticeRouter from "./modules/admin/notice/notice.route";
import { authRouter } from "./modules/auth/auth.route";
Expand Down Expand Up @@ -178,6 +179,7 @@ app.use("/api/terms", publicTermsRouter);
app.use("/api/admin/auth", adminAuthRouter);
app.use("/api/admin/notices", noticeRouter);
app.use("/api/admin/reviews", adminReviewRouter);
app.use("/api/admin/estimates", adminEstimateRouter);
app.use("/api/admin/users", adminCustomerRouter); // 관리자 고객(회원) 라우터
app.use("/api/admin/faqs", adminFaqRouter); // 관리자 FAQ 라우터
app.use("/api/faqs", publicFaqRouter); // 일반 사용자 FAQ 라우터
Expand Down
4 changes: 3 additions & 1 deletion src/config/openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ async function registerModuleDocs(): Promise<void> {
await import("../modules/admin/notice/notice.docs");
// 2026.08.03 신영미 - [추가] 관리자 콘텐츠(리뷰) API 문서 등록
await import("../modules/admin/contents/contents.docs");
// 2026.08.06 유서현 [추가] 관리자 고객 목록 API 문서 등록
// 2026.08.06 유서현 - [추가] 관리자 고객 목록 API 문서 등록
await import("../modules/admin/member-management/customers/customers.docs");
// 2026.08.08 유서현 - [추가] 관리자 확정 견적/견적 요청 취소 API 문서 등록
await import("../modules/admin/estimates/estimates.docs");
// 2026.07.30 장민주 - [추가] 인증 API 문서 등록
await import("../modules/auth/auth.docs");
// 2026.08.05 장민주 - [추가] 관리자 인증 API 문서 등록
Expand Down
5 changes: 5 additions & 0 deletions src/constants/error-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,11 @@ export const ERROR_CODES = {
code: "CUSTOMER_STATUS_ALREADY_PROCESSED",
message: "이미 요청한 상태로 처리된 회원입니다.",
},
ADMIN_ESTIMATE_CANCEL_NOT_ALLOWED: {
status: 409,
code: "ADMIN_ESTIMATE_CANCEL_NOT_ALLOWED",
message: "확정된 진행 거래만 관리자가 취소할 수 있습니다.",
},
} as const;

// ErrorCode의 key 타입
Expand Down
25 changes: 25 additions & 0 deletions src/modules/admin/estimates/estimates.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { Request, Response } from "express";

import { AppError } from "../../../lib/app-error";
import { sendResponse } from "../../../utils/response.util";
import { adminEstimatesService } from "./estimates.service";
import type { AdminEstimateIdParam, CancelAdminEstimateBody } from "./estimates.type";

export const adminEstimatesController = {
// PATCH /api/admin/estimates/:estimateId/cancel
cancelConfirmedEstimate: async (req: Request, res: Response) => {
const { estimateId } = res.locals.params as AdminEstimateIdParam;

if (!req.admin) {
throw new AppError("UNAUTHORIZED");
}

const result = await adminEstimatesService.cancelConfirmedEstimate({
estimateId,
adminId: req.admin.id,
input: req.body as CancelAdminEstimateBody,
});

return sendResponse(res, 200, result);
},
};
31 changes: 31 additions & 0 deletions src/modules/admin/estimates/estimates.docs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { z } from "zod";

import { registerRouterDocs } from "../../../config/openapi-router";
import adminEstimateRouter from "./estimates.route";

registerRouterDocs(adminEstimateRouter, {
basePath: "/api/admin/estimates",
tag: "Estimate (Admin)",
headers: z.object({ authorization: z.string().meta({ example: "Bearer <access-token>" }) }),
commonResponses: {
401: "인증이 필요합니다.",
403: "활성 관리자 권한이 필요합니다.",
422: "입력값이 올바르지 않습니다.",
},
endpoints: {
"PATCH /:estimateId/cancel": {
summary: "확정 견적 관리자 수동 취소",
description: [
"관리자가 확정된 거래를 수동으로 취소합니다.",
"",
"- 대상 견적과 견적 요청이 모두 `CONFIRMED`이고, 해당 견적이 요청의 `confirmedEstimateId`인 경우에만 취소합니다.",
"- 견적 요청·확정 견적·대기 중 견적 수정 요청을 취소하고, 이력·ActivityLog·채팅 SYSTEM 메시지·고객/기사 알림을 생성합니다.",
].join("\n"),
responses: {
200: "취소 성공",
404: "견적을 찾을 수 없습니다.",
409: "ADMIN_ESTIMATE_CANCEL_NOT_ALLOWED - 확정 거래가 아니거나 이미 종료된 거래입니다.",
},
},
},
});
114 changes: 114 additions & 0 deletions src/modules/admin/estimates/estimates.repository.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import type { Prisma } from "@prisma/client";

import { prisma } from "../../../lib/prisma";
import type { DbClient } from "../../../utils/transaction";

/** 관리자 취소 정책 검증 및 후속 처리에 필요한 확정 견적 조회 필드 */
const confirmedEstimateSelect = {
id: true,
estimateRequestId: true,
moverId: true,
status: true,
estimateRequest: {
select: {
id: true,
customerId: true,
status: true,
isActive: true,
confirmedEstimateId: true,
},
},
// 견적별 채팅방은 1개이므로, 취소 대상 견적의 채팅방만 조회합니다.
chatRoom: { select: { id: true } },
} satisfies Prisma.EstimateSelect;

export type ConfirmedEstimateRow = Prisma.EstimateGetPayload<{
select: typeof confirmedEstimateSelect;
}>;

export const adminEstimatesRepository = {
/** 확정 거래 취소 대상의 현재 상태와 연결된 채팅방 조회 */
findForCancellation(estimateId: number, db: DbClient = prisma) {
return db.estimate.findUnique({ where: { id: estimateId }, select: confirmedEstimateSelect });
},

/**
* 아직 취소되지 않은 확정 거래인 경우에만 견적 요청을 취소합니다.
* 이미 다른 처리로 상태가 변경됐다면 수정하지 않습니다.
*/
claimConfirmedEstimateRequestCancellation(
estimateRequestId: number,
estimateId: number,
canceledAt: Date,
db: DbClient = prisma,
) {
return db.estimateRequest.updateMany({
where: {
id: estimateRequestId,
status: "CONFIRMED",
isActive: true,
confirmedEstimateId: estimateId,
},
data: { status: "CANCELED", isActive: false, canceledAt },
});
},

/** 확정 상태인 견적만 취소합니다. */
cancelConfirmedEstimate(estimateId: number, canceledAt: Date, db: DbClient = prisma) {
return db.estimate.updateMany({
where: { id: estimateId, status: "CONFIRMED" },
data: { status: "CANCELED", canceledAt },
});
},

/** 취소된 거래에 더 이상 적용될 수 없는 PENDING 상태의 수정 요청을 종료합니다. */
cancelPendingRevisions(estimateId: number, db: DbClient = prisma) {
return db.estimateRevision.updateMany({
where: { estimateId, status: "PENDING" },
data: { status: "CANCELED" },
});
},

/** 견적 요청 상태 변경 이력을 저장합니다. */
createRequestCancellationHistory(
data: Prisma.EstimateRequestHistoryUncheckedCreateInput,
db: DbClient = prisma,
) {
return db.estimateRequestHistory.create({ data });
},

/** 이미 존재하는 채팅방에만 관리자 SYSTEM 메시지를 추가합니다. */
createSystemMessages(data: Prisma.ChatMessageCreateManyInput[], db: DbClient = prisma) {
return db.chatMessage.createMany({ data });
},

/** SYSTEM 메시지 생성 뒤 채팅 목록이 최신 메시지 순으로 정렬되도록 갱신합니다. */
updateChatRoomsLastMessageAt(roomIds: number[], lastMessageAt: Date, db: DbClient = prisma) {
return db.chatRoom.updateMany({
where: { id: { in: roomIds } },
data: { lastMessageAt },
});
},

/** 재시도 시 알림 중복 생성을 막습니다. */
createNotifications(data: Prisma.NotificationCreateManyInput[], db: DbClient = prisma) {
return db.notification.createMany({ data, skipDuplicates: true });
},

/** 관리자 취소 조치와 사유를 운영 감사 로그에 저장합니다. */
createActivityLog(
data: { adminId: string; estimateId: number; memo: string },
db: DbClient = prisma,
) {
return db.activityLog.create({
data: {
actorId: data.adminId,
actorRole: "ADMIN",
action: "UPDATE",
targetType: "ESTIMATE",
targetId: String(data.estimateId),
memo: data.memo,
},
});
},
};
21 changes: 21 additions & 0 deletions src/modules/admin/estimates/estimates.route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { UserRole } from "@prisma/client";
import { Router } from "express";

import { requireActiveAdmin } from "../../../middlewares/admin";
import { authenticate, authorize } from "../../../middlewares/auth";
import { validate } from "../../../middlewares/validate";
import { asyncHandler } from "../../../utils/async-handler.util";
import { adminEstimatesController } from "./estimates.controller";
import { adminEstimateIdParamSchema, cancelAdminEstimateBodySchema } from "./estimates.validator";

const adminEstimateRouter = Router();

adminEstimateRouter.use(authenticate, authorize(UserRole.ADMIN), requireActiveAdmin);

adminEstimateRouter.patch(
"/:estimateId/cancel",
validate({ params: adminEstimateIdParamSchema, body: cancelAdminEstimateBodySchema }),
asyncHandler(adminEstimatesController.cancelConfirmedEstimate),
);

export default adminEstimateRouter;
Loading