From b4d5367e2b44e2eb652cff9d0e8ce640a7a34047 Mon Sep 17 00:00:00 2001 From: kjs Date: Wed, 4 Mar 2026 13:49:08 +0900 Subject: [PATCH] feat: Integrate audit logging for various operations - Added audit logging functionality across multiple controllers, including menu, user, department, flow, screen, and table management. - Implemented logging for create, update, and delete actions, capturing relevant details such as company code, user information, and changes made. - Enhanced the category tree service with a new endpoint to check if category values are in use, improving data integrity checks. - Updated routes to include new functionalities and ensure proper logging for batch operations and individual record changes. - This integration improves traceability and accountability for data modifications within the application. --- backend-node/src/app.ts | 2 + .../src/controllers/adminController.ts | 210 +++- .../src/controllers/auditLogController.ts | 139 +++ .../src/controllers/categoryTreeController.ts | 35 + .../src/controllers/commonCodeController.ts | 48 + backend-node/src/controllers/ddlController.ts | 15 + .../src/controllers/departmentController.ts | 46 + .../src/controllers/flowController.ts | 111 +- .../controllers/numberingRuleController.ts | 43 + .../src/controllers/roleController.ts | 45 + .../controllers/screenManagementController.ts | 173 ++++ .../controllers/tableManagementController.ts | 76 +- backend-node/src/routes/auditLogRoutes.ts | 11 + backend-node/src/routes/dataRoutes.ts | 87 +- .../src/routes/screenManagementRoutes.ts | 2 + backend-node/src/services/auditLogService.ts | 296 ++++++ .../src/services/categoryTreeService.ts | 190 +++- .../src/services/entityJoinService.ts | 4 +- .../src/services/screenManagementService.ts | 32 + frontend/app/(main)/admin/audit-log/page.tsx | 948 ++++++++++++++++++ .../components/admin/CreateTableModal.tsx | 6 +- frontend/components/screen/ScreenDesigner.tsx | 34 +- .../CategoryValueManagerTree.tsx | 93 +- frontend/lib/api/auditLog.ts | 91 ++ frontend/lib/api/categoryTree.ts | 18 + frontend/lib/api/screen.ts | 5 + 26 files changed, 2620 insertions(+), 140 deletions(-) create mode 100644 backend-node/src/controllers/auditLogController.ts create mode 100644 backend-node/src/routes/auditLogRoutes.ts create mode 100644 backend-node/src/services/auditLogService.ts create mode 100644 frontend/app/(main)/admin/audit-log/page.tsx create mode 100644 frontend/lib/api/auditLog.ts diff --git a/backend-node/src/app.ts b/backend-node/src/app.ts index 4b3d212a..536b8f46 100644 --- a/backend-node/src/app.ts +++ b/backend-node/src/app.ts @@ -125,6 +125,7 @@ import cascadingHierarchyRoutes from "./routes/cascadingHierarchyRoutes"; // 다 import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"; // 카테고리 값 연쇄관계 import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트) import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준 +import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력 import { BatchSchedulerService } from "./services/batchSchedulerService"; // import collectionRoutes from "./routes/collectionRoutes"; // 임시 주석 // import batchRoutes from "./routes/batchRoutes"; // 임시 주석 @@ -308,6 +309,7 @@ app.use("/api/cascading-hierarchy", cascadingHierarchyRoutes); // 다단계 계 app.use("/api/category-value-cascading", categoryValueCascadingRoutes); // 카테고리 값 연쇄관계 app.use("/api/category-tree", categoryTreeRoutes); // 카테고리 트리 (테스트) app.use("/api/process-work-standard", processWorkStandardRoutes); // 공정 작업기준 +app.use("/api/audit-log", auditLogRoutes); // 통합 변경 이력 app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달 app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리 // app.use("/api/collections", collectionRoutes); // 임시 주석 diff --git a/backend-node/src/controllers/adminController.ts b/backend-node/src/controllers/adminController.ts index f2f2f3ee..e1190f6c 100644 --- a/backend-node/src/controllers/adminController.ts +++ b/backend-node/src/controllers/adminController.ts @@ -10,6 +10,7 @@ import { EncryptUtil } from "../utils/encryptUtil"; import { FileSystemManager } from "../utils/fileSystemManager"; import { validateBusinessNumber } from "../utils/businessNumberValidator"; import { MenuCopyService } from "../services/menuCopyService"; +import { auditLogService } from "../services/auditLogService"; /** * 관리자 메뉴 목록 조회 @@ -1177,7 +1178,7 @@ export async function saveMenu( success: true, message: "메뉴가 성공적으로 저장되었습니다.", data: { - objid: savedMenu.objid.toString(), // BigInt를 문자열로 변환 + objid: savedMenu.objid.toString(), menuNameKor: savedMenu.menu_name_kor, menuNameEng: savedMenu.menu_name_eng, menuUrl: savedMenu.menu_url, @@ -1188,6 +1189,20 @@ export async function saveMenu( }, }; + auditLogService.log({ + companyCode: req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "CREATE", + resourceType: "MENU", + resourceId: savedMenu.objid?.toString(), + resourceName: savedMenu.menu_name_kor, + summary: `메뉴 "${savedMenu.menu_name_kor}" 생성`, + changes: { after: { menuNameKor: savedMenu.menu_name_kor, menuUrl: savedMenu.menu_url, status: savedMenu.status } }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.status(200).json(response); } catch (error) { logger.error("메뉴 저장 실패:", error); @@ -1375,6 +1390,23 @@ export async function updateMenu( }, }; + auditLogService.log({ + companyCode: req.user?.companyCode || updatedMenu.company_code || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "UPDATE", + resourceType: "MENU", + resourceId: updatedMenu.objid?.toString(), + resourceName: updatedMenu.menu_name_kor, + summary: `메뉴 "${updatedMenu.menu_name_kor}" 수정`, + changes: { + before: { menuNameKor: currentMenu.menu_name_kor, menuUrl: currentMenu.menu_url, status: currentMenu.status }, + after: { menuNameKor: updatedMenu.menu_name_kor, menuUrl: updatedMenu.menu_url, status: updatedMenu.status }, + }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.status(200).json(response); } catch (error) { logger.error("메뉴 수정 실패:", error); @@ -1554,6 +1586,20 @@ export async function deleteMenu( }, }; + auditLogService.log({ + companyCode: currentMenu.company_code || req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "DELETE", + resourceType: "MENU", + resourceId: menuObjid.toString(), + resourceName: currentMenu.menu_name_kor, + summary: `메뉴 "${currentMenu.menu_name_kor}" 삭제 (하위 ${childMenuIds.length}개 포함, 총 ${allMenuIdsToDelete.length}건)`, + changes: { before: { menuNameKor: currentMenu.menu_name_kor } }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.status(200).json(response); } catch (error) { logger.error("메뉴 삭제 실패:", error); @@ -1717,6 +1763,20 @@ export async function deleteMenusBatch( }, }; + if (deletedCount > 0) { + auditLogService.log({ + companyCode: req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "DELETE", + resourceType: "MENU", + summary: `메뉴 일괄 삭제: ${deletedCount}개 삭제, ${failedCount}개 실패`, + changes: { before: { deletedMenus, failedMenuIds } }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + } + res.status(200).json(response); } catch (error) { logger.error("메뉴 일괄 삭제 실패:", error); @@ -1813,6 +1873,20 @@ export async function toggleMenuStatus( data: result, }; + auditLogService.log({ + companyCode: currentMenu.company_code || req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "STATUS_CHANGE", + resourceType: "MENU", + resourceId: String(menuId), + resourceName: currentMenu.menu_name_kor, + summary: `메뉴 "${currentMenu.menu_name_kor}" 상태 변경: ${currentStatus} → ${newStatus}`, + changes: { before: { status: currentStatus }, after: { status: newStatus }, fields: ["status"] }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.status(200).json(response); } catch (error) { logger.error("메뉴 상태 토글 실패:", error); @@ -2442,6 +2516,20 @@ export const changeUserStatus = async ( updatedBy: req.user?.userId, }); + auditLogService.log({ + companyCode: currentUser.company_code || req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "STATUS_CHANGE", + resourceType: "USER", + resourceId: userId, + resourceName: currentUser.user_name, + summary: `사용자 "${currentUser.user_name}" 상태 변경: ${currentUser.status} → ${status}`, + changes: { before: { status: currentUser.status }, after: { status }, fields: ["status"] }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.json({ result: true, msg: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`, @@ -2579,6 +2667,20 @@ export const saveUser = async (req: AuthenticatedRequest, res: Response) => { }, }; + auditLogService.log({ + companyCode: userData.companyCode || req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: isExistingUser ? "UPDATE" : "CREATE", + resourceType: "USER", + resourceId: userData.userId, + resourceName: userData.userName, + summary: isExistingUser ? `사용자 "${userData.userName}" 정보 수정` : `사용자 "${userData.userName}" 등록`, + changes: { after: { userId: userData.userId, userName: userData.userName, deptName: userData.deptName, status: userData.status } }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.status(200).json(response); } catch (error) { logger.error("사용자 저장 실패", { error, userData: req.body }); @@ -2769,6 +2871,20 @@ export const createCompany = async ( }, }; + auditLogService.log({ + companyCode: createdCompany.company_code, + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "CREATE", + resourceType: "COMPANY", + resourceId: createdCompany.company_code, + resourceName: createdCompany.company_name, + summary: `회사 "${createdCompany.company_name}" (${createdCompany.company_code}) 등록`, + changes: { after: { company_code: createdCompany.company_code, company_name: createdCompany.company_name } }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.status(201).json(response); } finally { await client.end(); @@ -2938,7 +3054,11 @@ export const updateCompany = async ( } } - // Raw Query로 회사 정보 수정 + const beforeCompany = await queryOne( + `SELECT company_name, status FROM company_mng WHERE company_code = $1`, + [companyCode] + ); + const result = await query( `UPDATE company_mng SET @@ -2994,6 +3114,23 @@ export const updateCompany = async ( }, }; + auditLogService.log({ + companyCode: updatedCompany.company_code, + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "UPDATE", + resourceType: "COMPANY", + resourceId: updatedCompany.company_code, + resourceName: updatedCompany.company_name, + summary: `회사 "${updatedCompany.company_name}" 정보 수정`, + changes: { + before: { company_name: beforeCompany?.company_name, status: beforeCompany?.status }, + after: { company_name: updatedCompany.company_name, status: updatedCompany.status }, + }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.status(200).json(response); } catch (error) { logger.error("회사 정보 수정 실패", { error, body: req.body }); @@ -3055,6 +3192,20 @@ export const deleteCompany = async ( }, }; + auditLogService.log({ + companyCode: deletedCompany.company_code, + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "DELETE", + resourceType: "COMPANY", + resourceId: deletedCompany.company_code, + resourceName: deletedCompany.company_name, + summary: `회사 "${deletedCompany.company_name}" (${deletedCompany.company_code}) 삭제`, + changes: { before: { company_code: deletedCompany.company_code, company_name: deletedCompany.company_name } }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.status(200).json(response); } catch (error) { logger.error("회사 삭제 실패", { error }); @@ -3221,6 +3372,20 @@ export const updateProfile = async ( : null, }; + auditLogService.log({ + companyCode: req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "UPDATE", + resourceType: "USER", + resourceId: userId, + resourceName: updatedUser?.user_name || "", + summary: `프로필 수정 (${updateFields.length}개 항목)`, + changes: { after: { userName, email, tel, cellPhone, locale } }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.json({ result: true, message: "프로필이 성공적으로 업데이트되었습니다.", @@ -3334,6 +3499,20 @@ export const resetUserPassword = async ( updatedBy: req.user?.userId, }); + auditLogService.log({ + companyCode: req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "UPDATE", + resourceType: "USER", + resourceId: userId, + resourceName: currentUser.user_name, + summary: `사용자 "${currentUser.user_name}" 비밀번호 초기화`, + changes: { fields: ["user_password"] }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.json({ success: true, result: true, @@ -3535,6 +3714,19 @@ export async function copyMenu( logger.info("✅ 메뉴 복사 API 성공"); + auditLogService.log({ + companyCode: req.user?.companyCode || "", + userId: req.user?.userId || userId, + userName: req.user?.userName || "", + action: "COPY", + resourceType: "MENU", + resourceId: menuObjid, + summary: `메뉴(${menuObjid}) → 회사 "${targetCompanyCode}"로 복사`, + changes: { after: { targetCompanyCode, menuObjid } }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.json({ success: true, message: "메뉴 복사 완료", @@ -3849,6 +4041,20 @@ export const saveUserWithDept = async ( isUpdate: isExistingUser, }); + auditLogService.log({ + companyCode: req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: isExistingUser ? "UPDATE" : "CREATE", + resourceType: "USER", + resourceId: userInfo.user_id, + resourceName: userInfo.user_name, + summary: `사용자 "${userInfo.user_name}" ${isExistingUser ? "수정" : "등록"} (부서: ${mainDept?.dept_name || "없음"})`, + changes: { after: { userName: userInfo.user_name, email: userInfo.email, deptName: mainDept?.dept_name, status: userInfo.status } }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.json({ success: true, message: isExistingUser ? "사원 정보가 수정되었습니다." : "사원이 등록되었습니다.", diff --git a/backend-node/src/controllers/auditLogController.ts b/backend-node/src/controllers/auditLogController.ts new file mode 100644 index 00000000..828529bd --- /dev/null +++ b/backend-node/src/controllers/auditLogController.ts @@ -0,0 +1,139 @@ +import { Response } from "express"; +import { AuthenticatedRequest } from "../middleware/authMiddleware"; +import { auditLogService } from "../services/auditLogService"; +import { query } from "../database/db"; +import logger from "../utils/logger"; + +export const getAuditLogs = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const userCompanyCode = req.user?.companyCode; + const isSuperAdmin = userCompanyCode === "*"; + + const { + companyCode, + userId, + resourceType, + action, + tableName, + dateFrom, + dateTo, + search, + page, + limit, + } = req.query; + + const result = await auditLogService.queryLogs( + { + companyCode: (companyCode as string) || (isSuperAdmin ? undefined : userCompanyCode), + userId: userId as string, + resourceType: resourceType as string, + action: action as string, + tableName: tableName as string, + dateFrom: dateFrom as string, + dateTo: dateTo as string, + search: search as string, + page: page ? parseInt(page as string, 10) : 1, + limit: limit ? parseInt(limit as string, 10) : 50, + }, + isSuperAdmin + ); + + res.json({ + success: true, + data: result.data, + total: result.total, + page: page ? parseInt(page as string, 10) : 1, + limit: limit ? parseInt(limit as string, 10) : 50, + }); + } catch (error: any) { + logger.error("감사 로그 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "감사 로그 조회 중 오류가 발생했습니다.", + }); + } +}; + +export const getAuditLogStats = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const userCompanyCode = req.user?.companyCode; + const isSuperAdmin = userCompanyCode === "*"; + const { companyCode, days } = req.query; + + const targetCompany = isSuperAdmin + ? (companyCode as string) || undefined + : userCompanyCode; + + const stats = await auditLogService.getStats( + targetCompany, + days ? parseInt(days as string, 10) : 30 + ); + + res.json({ success: true, data: stats }); + } catch (error: any) { + logger.error("감사 로그 통계 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "감사 로그 통계 조회 중 오류가 발생했습니다.", + }); + } +}; + +export const getAuditLogUsers = async ( + req: AuthenticatedRequest, + res: Response +): Promise => { + try { + const userCompanyCode = req.user?.companyCode; + const isSuperAdmin = userCompanyCode === "*"; + const { companyCode } = req.query; + + const conditions: string[] = ["LOWER(u.status) = 'active'"]; + const params: any[] = []; + let paramIndex = 1; + + if (!isSuperAdmin) { + conditions.push(`u.company_code = $${paramIndex++}`); + params.push(userCompanyCode); + } else if (companyCode) { + conditions.push(`u.company_code = $${paramIndex++}`); + params.push(companyCode); + } + + if (!isSuperAdmin) { + conditions.push(`u.company_code != '*'`); + } + + const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const users = await query<{ user_id: string; user_name: string; count: number }>( + `SELECT + u.user_id, + u.user_name, + COALESCE(sal.log_count, 0)::int as count + FROM user_info u + LEFT JOIN ( + SELECT user_id, COUNT(*) as log_count + FROM system_audit_log + GROUP BY user_id + ) sal ON u.user_id = sal.user_id + ${whereClause} + ORDER BY count DESC, u.user_name ASC`, + params + ); + + res.json({ success: true, data: users }); + } catch (error: any) { + logger.error("감사 로그 사용자 목록 조회 실패", { error: error.message }); + res.status(500).json({ + success: false, + message: "사용자 목록 조회 중 오류가 발생했습니다.", + }); + } +}; diff --git a/backend-node/src/controllers/categoryTreeController.ts b/backend-node/src/controllers/categoryTreeController.ts index ec7ef92b..54b93ee4 100644 --- a/backend-node/src/controllers/categoryTreeController.ts +++ b/backend-node/src/controllers/categoryTreeController.ts @@ -205,6 +205,31 @@ router.put("/test/value/:valueId", async (req: AuthenticatedRequest, res: Respon } }); +/** + * 카테고리 값 삭제 가능 여부 사전 확인 + * GET /api/category-tree/test/value/:valueId/can-delete + */ +router.get("/test/value/:valueId/can-delete", async (req: AuthenticatedRequest, res: Response) => { + try { + const { valueId } = req.params; + const companyCode = req.user?.companyCode || "*"; + + const result = await categoryTreeService.checkCanDelete(companyCode, Number(valueId)); + + res.json({ + success: true, + data: result, + }); + } catch (error: unknown) { + const err = error as Error; + logger.error("카테고리 삭제 가능 여부 확인 API 오류", { error: err.message }); + res.status(500).json({ + success: false, + error: err.message, + }); + } +}); + /** * 카테고리 값 삭제 * DELETE /api/category-tree/test/value/:valueId @@ -229,6 +254,16 @@ router.delete("/test/value/:valueId", async (req: AuthenticatedRequest, res: Res }); } catch (error: unknown) { const err = error as Error; + + if (err.message.startsWith("VALIDATION:")) { + const validationMessage = err.message.replace("VALIDATION:", ""); + logger.warn("카테고리 값 삭제 검증 실패", { valueId: req.params.valueId, reason: validationMessage }); + return res.status(400).json({ + success: false, + error: validationMessage, + }); + } + logger.error("카테고리 값 삭제 API 오류", { error: err.message }); res.status(500).json({ success: false, diff --git a/backend-node/src/controllers/commonCodeController.ts b/backend-node/src/controllers/commonCodeController.ts index 5b6b1453..4b361434 100644 --- a/backend-node/src/controllers/commonCodeController.ts +++ b/backend-node/src/controllers/commonCodeController.ts @@ -6,6 +6,7 @@ import { } from "../services/commonCodeService"; import { AuthenticatedRequest } from "../types/auth"; import { logger } from "../utils/logger"; +import { auditLogService } from "../services/auditLogService"; export class CommonCodeController { private commonCodeService: CommonCodeService; @@ -163,6 +164,18 @@ export class CommonCodeController { Number(menuObjid) ); + auditLogService.log({ + companyCode: companyCode || "", + userId: userId || "", + action: "CREATE", + resourceType: "CODE_CATEGORY", + resourceId: category?.categoryCode, + resourceName: category?.categoryName || categoryData.categoryName, + summary: `코드 카테고리 "${category?.categoryName || categoryData.categoryName}" 생성`, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + return res.status(201).json({ success: true, data: category, @@ -208,6 +221,18 @@ export class CommonCodeController { companyCode ); + auditLogService.log({ + companyCode: companyCode || "", + userId: userId || "", + action: "UPDATE", + resourceType: "CODE_CATEGORY", + resourceId: categoryCode, + resourceName: category?.categoryName, + summary: `코드 카테고리 "${categoryCode}" 수정`, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + return res.json({ success: true, data: category, @@ -245,6 +270,17 @@ export class CommonCodeController { await this.commonCodeService.deleteCategory(categoryCode, companyCode); + auditLogService.log({ + companyCode: companyCode || "", + userId: req.user?.userId || "", + action: "DELETE", + resourceType: "CODE_CATEGORY", + resourceId: categoryCode, + summary: `코드 카테고리 "${categoryCode}" 삭제`, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + return res.json({ success: true, message: "카테고리 삭제 성공", @@ -303,6 +339,18 @@ export class CommonCodeController { effectiveMenuObjid ); + auditLogService.log({ + companyCode: companyCode || "", + userId: userId || "", + action: "CREATE", + resourceType: "CODE", + resourceId: codeData.codeValue, + resourceName: codeData.codeName, + summary: `코드 "${codeData.codeName}" (${categoryCode}) 생성`, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + return res.status(201).json({ success: true, data: code, diff --git a/backend-node/src/controllers/ddlController.ts b/backend-node/src/controllers/ddlController.ts index 20c2dc16..07f0b391 100644 --- a/backend-node/src/controllers/ddlController.ts +++ b/backend-node/src/controllers/ddlController.ts @@ -9,6 +9,7 @@ import { DDLExecutionService } from "../services/ddlExecutionService"; import { DDLAuditLogger } from "../services/ddlAuditLogger"; import { CreateTableRequest, AddColumnRequest } from "../types/ddl"; import { logger } from "../utils/logger"; +import { auditLogService } from "../services/auditLogService"; export class DDLController { /** @@ -59,6 +60,20 @@ export class DDLController { ); if (result.success) { + auditLogService.log({ + companyCode: userCompanyCode || "", + userId, + action: "CREATE", + resourceType: "TABLE", + resourceId: tableName, + resourceName: tableName, + tableName, + summary: `테이블 "${tableName}" 생성 (${columns.length}개 컬럼)`, + changes: { after: { tableName, columnCount: columns.length, description } }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.status(200).json({ success: true, message: result.message, diff --git a/backend-node/src/controllers/departmentController.ts b/backend-node/src/controllers/departmentController.ts index 9e3f0b6a..43e56f7e 100644 --- a/backend-node/src/controllers/departmentController.ts +++ b/backend-node/src/controllers/departmentController.ts @@ -3,6 +3,7 @@ import { logger } from "../utils/logger"; import { AuthenticatedRequest } from "../types/auth"; import { ApiResponse } from "../types/common"; import { query, queryOne } from "../database/db"; +import { auditLogService } from "../services/auditLogService"; /** * 부서 목록 조회 (회사별) @@ -170,6 +171,21 @@ export async function createDepartment(req: AuthenticatedRequest, res: Response) logger.info("부서 생성 성공", { deptCode, dept_name }); + auditLogService.log({ + companyCode: companyCode || req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "CREATE", + resourceType: "DATA", + resourceId: deptCode, + resourceName: dept_name.trim(), + tableName: "dept_info", + summary: `부서 "${dept_name.trim()}" 생성`, + changes: { after: { deptCode, deptName: dept_name.trim(), parentDeptCode: parent_dept_code } }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.status(201).json({ success: true, message: "부서가 생성되었습니다.", @@ -219,6 +235,21 @@ export async function updateDepartment(req: AuthenticatedRequest, res: Response) logger.info("부서 수정 성공", { deptCode }); + auditLogService.log({ + companyCode: req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "UPDATE", + resourceType: "DATA", + resourceId: deptCode, + resourceName: dept_name.trim(), + tableName: "dept_info", + summary: `부서 "${dept_name.trim()}" 수정`, + changes: { after: { deptName: dept_name.trim(), parentDeptCode: parent_dept_code } }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.status(200).json({ success: true, message: "부서가 수정되었습니다.", @@ -285,6 +316,21 @@ export async function deleteDepartment(req: AuthenticatedRequest, res: Response) deletedMemberCount: memberCount }); + auditLogService.log({ + companyCode: req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "DELETE", + resourceType: "DATA", + resourceId: deptCode, + resourceName: result[0].dept_name, + tableName: "dept_info", + summary: `부서 "${result[0].dept_name}" 삭제${memberCount > 0 ? ` (부서원 ${memberCount}명 제외)` : ""}`, + changes: { before: { deptCode, deptName: result[0].dept_name } }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.status(200).json({ success: true, message: memberCount > 0 diff --git a/backend-node/src/controllers/flowController.ts b/backend-node/src/controllers/flowController.ts index c8041749..180f17fd 100644 --- a/backend-node/src/controllers/flowController.ts +++ b/backend-node/src/controllers/flowController.ts @@ -10,6 +10,7 @@ import { FlowConnectionService } from "../services/flowConnectionService"; import { FlowExecutionService } from "../services/flowExecutionService"; import { FlowDataMoveService } from "../services/flowDataMoveService"; import { FlowProcedureService } from "../services/flowProcedureService"; +import { auditLogService } from "../services/auditLogService"; export class FlowController { private flowDefinitionService: FlowDefinitionService; @@ -86,12 +87,25 @@ export class FlowController { restApiConnectionId, restApiEndpoint, restApiJsonPath, - restApiConnections: req.body.restApiConnections, // 다중 REST API 설정 + restApiConnections: req.body.restApiConnections, }, userId, userCompanyCode ); + auditLogService.log({ + companyCode: userCompanyCode || "", + userId: userId || "", + action: "CREATE", + resourceType: "FLOW", + resourceId: String(flowDef?.id || ""), + resourceName: flowDef?.name || name, + summary: `플로우 "${flowDef?.name || name}" 생성`, + changes: { after: { name, tableName } }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.json({ success: true, data: flowDef, @@ -188,6 +202,7 @@ export class FlowController { const { name, description, isActive } = req.body; const userCompanyCode = (req as any).user?.companyCode; + const beforeFlow = await this.flowDefinitionService.findById(flowId); const flowDef = await this.flowDefinitionService.update(flowId, { name, description, @@ -202,6 +217,22 @@ export class FlowController { return; } + auditLogService.log({ + companyCode: userCompanyCode || "", + userId: (req as any).user?.userId || "", + action: "UPDATE", + resourceType: "FLOW", + resourceId: String(flowId), + resourceName: flowDef?.name || name, + summary: `플로우 "${flowDef?.name || name}" 수정`, + changes: { + before: { name: beforeFlow?.name, description: beforeFlow?.description, isActive: beforeFlow?.isActive }, + after: { name, description, isActive }, + }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.json({ success: true, data: flowDef, @@ -234,6 +265,17 @@ export class FlowController { return; } + auditLogService.log({ + companyCode: userCompanyCode || "", + userId: (req as any).user?.userId || "", + action: "DELETE", + resourceType: "FLOW", + resourceId: String(flowId), + summary: `플로우(ID:${flowId}) 삭제`, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.json({ success: true, message: "Flow definition deleted successfully", @@ -321,6 +363,19 @@ export class FlowController { positionY, }); + auditLogService.log({ + companyCode: userCompanyCode || "", + userId: (req as any).user?.userId || "", + action: "CREATE", + resourceType: "FLOW_STEP", + resourceId: String(step?.id || ""), + resourceName: stepName, + summary: `플로우 스텝 "${stepName}" 생성 (플로우 ID:${flowDefinitionId})`, + changes: { after: { stepName, tableName, stepOrder } }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.json({ success: true, data: step, @@ -373,6 +428,7 @@ export class FlowController { } } + const beforeStep = existingStep; const step = await this.flowStepService.update(id, { stepName, stepOrder, @@ -399,6 +455,22 @@ export class FlowController { return; } + auditLogService.log({ + companyCode: userCompanyCode || "", + userId: (req as any).user?.userId || "", + action: "UPDATE", + resourceType: "FLOW_STEP", + resourceId: String(id), + resourceName: step?.stepName || stepName, + summary: `플로우 스텝 "${step?.stepName || stepName}" 수정`, + changes: { + before: { stepName: beforeStep?.stepName, tableName: beforeStep?.tableName, stepOrder: beforeStep?.stepOrder }, + after: { stepName, tableName, stepOrder }, + }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.json({ success: true, data: step, @@ -444,6 +516,18 @@ export class FlowController { return; } + auditLogService.log({ + companyCode: userCompanyCode || "", + userId: (req as any).user?.userId || "", + action: "DELETE", + resourceType: "FLOW_STEP", + resourceId: String(id), + resourceName: existingStep?.stepName, + summary: `플로우 스텝 "${existingStep?.stepName || id}" 삭제`, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.json({ success: true, message: "Flow step deleted successfully", @@ -530,6 +614,19 @@ export class FlowController { label, }); + auditLogService.log({ + companyCode: userCompanyCode || "", + userId: (req as any).user?.userId || "", + action: "CREATE", + resourceType: "FLOW", + resourceId: String(flowDefinitionId), + resourceName: flowDef?.name || "", + summary: `플로우 "${flowDef?.name}" 연결 생성 (${fromStep?.stepName} → ${toStep?.stepName})`, + changes: { after: { fromStepId, toStepId, label } }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.json({ success: true, data: connection, @@ -575,6 +672,18 @@ export class FlowController { return; } + auditLogService.log({ + companyCode: userCompanyCode || "", + userId: (req as any).user?.userId || "", + action: "DELETE", + resourceType: "FLOW", + resourceId: String(existingConn?.flowDefinitionId || id), + summary: `플로우 연결 삭제 (ID: ${id})`, + changes: { before: { connectionId: id } }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.json({ success: true, message: "Connection deleted successfully", diff --git a/backend-node/src/controllers/numberingRuleController.ts b/backend-node/src/controllers/numberingRuleController.ts index d307b41a..fe71a617 100644 --- a/backend-node/src/controllers/numberingRuleController.ts +++ b/backend-node/src/controllers/numberingRuleController.ts @@ -9,6 +9,7 @@ import { } from "../middleware/authMiddleware"; import { numberingRuleService } from "../services/numberingRuleService"; import { logger } from "../utils/logger"; +import { auditLogService } from "../services/auditLogService"; const router = Router(); @@ -189,6 +190,19 @@ router.post( menuObjid: newRule.menuObjid, }); + auditLogService.log({ + companyCode, + userId, + action: "CREATE", + resourceType: "NUMBERING_RULE", + resourceId: String(newRule.ruleId), + resourceName: ruleConfig.ruleName, + summary: `채번 규칙 "${ruleConfig.ruleName}" 생성`, + changes: { after: { ruleName: ruleConfig.ruleName, prefix: ruleConfig.prefix } }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + return res.status(201).json({ success: true, data: newRule }); } catch (error: any) { if (error.code === "23505") { @@ -218,12 +232,29 @@ router.put( logger.info("채번 규칙 수정 요청", { ruleId, companyCode, updates }); try { + const beforeRule = await numberingRuleService.getRuleById(ruleId, companyCode); const updatedRule = await numberingRuleService.updateRule( ruleId, updates, companyCode ); logger.info("채번 규칙 수정 성공", { ruleId, companyCode }); + + auditLogService.log({ + companyCode, + userId: req.user?.userId || "", + action: "UPDATE", + resourceType: "NUMBERING_RULE", + resourceId: ruleId, + summary: `채번 규칙(ID:${ruleId}) 수정`, + changes: { + before: { ruleName: beforeRule?.ruleName, prefix: beforeRule?.prefix }, + after: updates, + }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + return res.json({ success: true, data: updatedRule }); } catch (error: any) { logger.error("채번 규칙 수정 실패", { @@ -250,6 +281,18 @@ router.delete( try { await numberingRuleService.deleteRule(ruleId, companyCode); + + auditLogService.log({ + companyCode, + userId: req.user?.userId || "", + action: "DELETE", + resourceType: "NUMBERING_RULE", + resourceId: ruleId, + summary: `채번 규칙(ID:${ruleId}) 삭제`, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + return res.json({ success: true, message: "규칙이 삭제되었습니다" }); } catch (error: any) { if (error.message.includes("찾을 수 없거나")) { diff --git a/backend-node/src/controllers/roleController.ts b/backend-node/src/controllers/roleController.ts index 3c6ed1e5..c70149e2 100644 --- a/backend-node/src/controllers/roleController.ts +++ b/backend-node/src/controllers/roleController.ts @@ -8,6 +8,7 @@ import { isCompanyAdmin, canAccessCompanyData, } from "../utils/permissionUtils"; +import { auditLogService } from "../services/auditLogService"; /** * 권한 그룹 목록 조회 @@ -179,6 +180,20 @@ export const createRoleGroup = async ( data: roleGroup, }; + auditLogService.log({ + companyCode: companyCode || req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "CREATE", + resourceType: "ROLE", + resourceId: String(roleGroup?.objid || ""), + resourceName: authName, + summary: `권한 그룹 "${authName}" 생성`, + changes: { after: { authName, authCode, companyCode } }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.status(201).json(response); } catch (error) { logger.error("권한 그룹 생성 실패", { error }); @@ -243,6 +258,23 @@ export const updateRoleGroup = async ( data: roleGroup, }; + auditLogService.log({ + companyCode: req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "UPDATE", + resourceType: "ROLE", + resourceId: String(objid), + resourceName: authName, + summary: `권한 그룹 "${authName}" 수정`, + changes: { + before: { authName: existingRoleGroup.authName, authCode: existingRoleGroup.authCode, status: existingRoleGroup.status }, + after: { authName, authCode, status }, + }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.status(200).json(response); } catch (error) { logger.error("권한 그룹 수정 실패", { error }); @@ -302,6 +334,19 @@ export const deleteRoleGroup = async ( data: null, }; + auditLogService.log({ + companyCode: existingRoleGroup.companyCode || req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "DELETE", + resourceType: "ROLE", + resourceId: String(objid), + resourceName: existingRoleGroup.authName, + summary: `권한 그룹 "${existingRoleGroup.authName}" 삭제`, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.status(200).json(response); } catch (error) { logger.error("권한 그룹 삭제 실패", { error }); diff --git a/backend-node/src/controllers/screenManagementController.ts b/backend-node/src/controllers/screenManagementController.ts index 3e624c40..68f580f0 100644 --- a/backend-node/src/controllers/screenManagementController.ts +++ b/backend-node/src/controllers/screenManagementController.ts @@ -1,6 +1,7 @@ import { Response } from "express"; import { screenManagementService } from "../services/screenManagementService"; import { AuthenticatedRequest } from "../types/auth"; +import { auditLogService } from "../services/auditLogService"; // 화면 목록 조회 export const getScreens = async (req: AuthenticatedRequest, res: Response) => { @@ -108,6 +109,21 @@ export const createScreen = async ( screenData, companyCode ); + + auditLogService.log({ + companyCode, + userId: (req.user as any)?.userId || "", + userName: (req.user as any)?.userName || "", + action: "CREATE", + resourceType: "SCREEN", + resourceId: String(newScreen?.id || ""), + resourceName: newScreen?.screenName || screenData.screenName, + summary: `화면 "${newScreen?.screenName || screenData.screenName}" 생성`, + changes: { after: { screenName: newScreen?.screenName, tableName: newScreen?.tableName } }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.status(201).json({ success: true, data: newScreen }); } catch (error) { console.error("화면 생성 실패:", error); @@ -125,12 +141,31 @@ export const updateScreen = async ( try { const { id } = req.params; const { companyCode } = req.user as any; + const beforeScreen = await screenManagementService.getScreenById(parseInt(id)); const updateData = { ...req.body, companyCode }; const updatedScreen = await screenManagementService.updateScreen( parseInt(id), updateData, companyCode ); + + auditLogService.log({ + companyCode, + userId: (req.user as any)?.userId || "", + userName: (req.user as any)?.userName || "", + action: "UPDATE", + resourceType: "SCREEN", + resourceId: id, + resourceName: updatedScreen?.screenName || updateData.screenName, + summary: `화면 "${updatedScreen?.screenName || updateData.screenName}" 수정`, + changes: { + before: { screenName: beforeScreen?.screenName, tableName: beforeScreen?.tableName, isActive: beforeScreen?.isActive }, + after: { screenName: updateData.screenName, tableName: updateData.tableName, isActive: updateData.isActive }, + }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.json({ success: true, data: updatedScreen }); } catch (error) { console.error("화면 수정 실패:", error); @@ -140,6 +175,33 @@ export const updateScreen = async ( } }; +// 화면 테이블명 변경 +export const updateScreenTableName = async ( + req: AuthenticatedRequest, + res: Response +) => { + try { + const { screenId } = req.params; + const { companyCode } = req.user as any; + const { tableName } = req.body; + + if (!tableName) { + return res.status(400).json({ success: false, message: "테이블명이 필요합니다." }); + } + + await screenManagementService.updateScreenTableName( + parseInt(screenId), + tableName, + companyCode + ); + + res.json({ success: true, message: "테이블명이 변경되었습니다." }); + } catch (error) { + console.error("테이블명 변경 실패:", error); + res.status(500).json({ success: false, message: "테이블명 변경에 실패했습니다." }); + } +}; + // 화면 정보 수정 (메타데이터만) export const updateScreenInfo = async ( req: AuthenticatedRequest, @@ -170,6 +232,8 @@ export const updateScreenInfo = async ( restApiJsonPath, }); + const beforeScreen = await screenManagementService.getScreenById(parseInt(id)); + await screenManagementService.updateScreenInfo( parseInt(id), { @@ -186,6 +250,24 @@ export const updateScreenInfo = async ( }, companyCode ); + + auditLogService.log({ + companyCode, + userId: (req.user as any)?.userId || "", + userName: (req.user as any)?.userName || "", + action: "UPDATE", + resourceType: "SCREEN", + resourceId: id, + resourceName: screenName, + summary: `화면 "${screenName}" 정보 수정`, + changes: { + before: { screenName: beforeScreen?.screenName, tableName: beforeScreen?.tableName, description: beforeScreen?.description, isActive: beforeScreen?.isActive }, + after: { screenName, tableName, description, isActive }, + }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.json({ success: true, message: "화면 정보가 수정되었습니다." }); } catch (error) { console.error("화면 정보 수정 실패:", error); @@ -227,6 +309,9 @@ export const deleteScreen = async ( const { companyCode, userId } = req.user as any; const { deleteReason, force } = req.body; + const screenInfo = await screenManagementService.getScreenById(parseInt(id)); + const screenName = screenInfo?.screenName || ""; + await screenManagementService.deleteScreen( parseInt(id), companyCode, @@ -234,6 +319,21 @@ export const deleteScreen = async ( deleteReason, force || false ); + + auditLogService.log({ + companyCode, + userId: userId || "", + userName: (req.user as any)?.userName || "", + action: "DELETE", + resourceType: "SCREEN", + resourceId: id, + resourceName: screenName, + summary: `화면(ID:${id}, ${screenName}) 삭제 (사유: ${deleteReason || "없음"})`, + changes: { before: { deleteReason, force } }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.json({ success: true, message: "화면이 휴지통으로 이동되었습니다." }); } catch (error: any) { console.error("화면 삭제 실패:", error); @@ -513,6 +613,20 @@ export const copyScreenWithModals = async ( modalScreens: modalScreens || [], }); + auditLogService.log({ + companyCode: targetCompanyCode || companyCode, + userId: userId || "", + userName: (req.user as any)?.userName || "", + action: "COPY", + resourceType: "SCREEN", + resourceId: id, + resourceName: mainScreen?.screenName, + summary: `화면 일괄 복사 (메인 1개 + 모달 ${result.modalScreens.length}개, 원본 ID:${id})`, + changes: { after: { sourceScreenId: id, targetCompanyCode, mainScreenName: mainScreen?.screenName } }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.json({ success: true, data: result, @@ -548,6 +662,20 @@ export const copyScreen = async ( } ); + auditLogService.log({ + companyCode, + userId: userId || "", + userName: (req.user as any)?.userName || "", + action: "COPY", + resourceType: "SCREEN", + resourceId: String(copiedScreen?.id || ""), + resourceName: screenName, + summary: `화면 "${screenName}" 복사 (원본 ID:${id})`, + changes: { after: { sourceScreenId: id, screenName, screenCode } }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.json({ success: true, data: copiedScreen, @@ -647,6 +775,21 @@ export const saveLayout = async (req: AuthenticatedRequest, res: Response) => { layoutData, companyCode ); + + const screenInfo = await screenManagementService.getScreenById(parseInt(screenId)); + auditLogService.log({ + companyCode, + userId: (req.user as any)?.userId || "", + userName: (req.user as any)?.userName || "", + action: "UPDATE", + resourceType: "SCREEN_LAYOUT", + resourceId: screenId, + resourceName: screenInfo?.screenName || "", + summary: `화면(ID:${screenId}, ${screenInfo?.screenName || ""}) 레이아웃 저장`, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.json({ success: true, data: savedLayout }); } catch (error) { console.error("레이아웃 저장 실패:", error); @@ -723,6 +866,21 @@ export const saveLayoutV2 = async (req: AuthenticatedRequest, res: Response) => layoutData, companyCode ); + + const screenInfo = await screenManagementService.getScreenById(parseInt(screenId)); + auditLogService.log({ + companyCode, + userId: (req.user as any)?.userId || "", + userName: (req.user as any)?.userName || "", + action: "UPDATE", + resourceType: "SCREEN_LAYOUT", + resourceId: screenId, + resourceName: screenInfo?.screenName || "", + summary: `화면(ID:${screenId}, ${screenInfo?.screenName || ""}) V2 레이아웃 저장`, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.json({ success: true, message: "V2 레이아웃이 저장되었습니다." }); } catch (error) { console.error("V2 레이아웃 저장 실패:", error); @@ -895,6 +1053,21 @@ export const saveLayoutPop = async (req: AuthenticatedRequest, res: Response) => companyCode, userId ); + + const screenInfo = await screenManagementService.getScreenById(parseInt(screenId)); + auditLogService.log({ + companyCode, + userId: userId || "", + userName: (req.user as any)?.userName || "", + action: "UPDATE", + resourceType: "SCREEN_LAYOUT", + resourceId: screenId, + resourceName: screenInfo?.screenName || "", + summary: `화면(ID:${screenId}, ${screenInfo?.screenName || ""}) POP 레이아웃 저장`, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.json({ success: true, message: "POP 레이아웃이 저장되었습니다." }); } catch (error) { console.error("POP 레이아웃 저장 실패:", error); diff --git a/backend-node/src/controllers/tableManagementController.ts b/backend-node/src/controllers/tableManagementController.ts index 8cd9f770..68f5b33b 100644 --- a/backend-node/src/controllers/tableManagementController.ts +++ b/backend-node/src/controllers/tableManagementController.ts @@ -12,7 +12,8 @@ import { ColumnListResponse, ColumnSettingsResponse, } from "../types/tableManagement"; -import { query } from "../database/db"; // 🆕 query 함수 import +import { query } from "../database/db"; +import { auditLogService } from "../services/auditLogService"; /** * 테이블 목록 조회 @@ -962,6 +963,21 @@ export async function addTableData( logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`); + auditLogService.log({ + companyCode: req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "CREATE", + resourceType: "DATA", + resourceId: result.insertedId || "", + resourceName: tableName, + tableName, + summary: `${tableName} 데이터 추가`, + changes: { after: data }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + const response: ApiResponse<{ id: string | null }> = { success: true, message: "테이블 데이터를 성공적으로 추가했습니다.", @@ -1080,6 +1096,16 @@ export async function editTableData( return; } + // 변경된 필드만 추출 + const changedBefore: Record = {}; + const changedAfter: Record = {}; + for (const key of Object.keys(updatedData)) { + if (String(originalData[key] ?? "") !== String(updatedData[key] ?? "")) { + changedBefore[key] = originalData[key]; + changedAfter[key] = updatedData[key]; + } + } + // 데이터 수정 await tableManagementService.editTableData( tableName, @@ -1089,6 +1115,23 @@ export async function editTableData( logger.info(`테이블 데이터 수정 완료: ${tableName}`); + if (Object.keys(changedAfter).length > 0) { + auditLogService.log({ + companyCode: req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "UPDATE", + resourceType: "DATA", + resourceId: originalData.id?.toString() || "", + resourceName: tableName, + tableName, + summary: `${tableName} 데이터 수정`, + changes: { before: changedBefore, after: changedAfter }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + } + const response: ApiResponse = { success: true, message: "테이블 데이터를 성공적으로 수정했습니다.", @@ -1406,6 +1449,22 @@ export async function deleteTableData( `테이블 데이터 삭제 완료: ${tableName}, ${deletedCount}건 삭제` ); + const deleteItems = Array.isArray(data) ? data : [data]; + auditLogService.log({ + companyCode: req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "DELETE", + resourceType: "DATA", + resourceId: deleteItems[0]?.id?.toString() || "", + resourceName: tableName, + tableName, + summary: `${tableName} 데이터 삭제 (${deletedCount}건)`, + changes: { before: { deletedCount, items: deleteItems.length } }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + const response: ApiResponse<{ deletedCount: number }> = { success: true, message: `테이블 데이터를 성공적으로 삭제했습니다. (${deletedCount}건)`, @@ -2285,6 +2344,21 @@ export async function multiTableSave( subTableResultsCount: subTableResults.length, }); + auditLogService.log({ + companyCode: req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: isUpdate ? "UPDATE" : "CREATE", + resourceType: "DATA", + resourceId: savedPkValue?.toString() || "", + resourceName: mainTableName, + tableName: mainTableName, + summary: `${mainTableName} 데이터 ${isUpdate ? "수정" : "생성"}${subTableResults.length > 0 ? ` (서브 테이블 ${subTableResults.length}건)` : ""}`, + changes: { after: mainData }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + res.json({ success: true, message: "다중 테이블 저장이 완료되었습니다.", diff --git a/backend-node/src/routes/auditLogRoutes.ts b/backend-node/src/routes/auditLogRoutes.ts new file mode 100644 index 00000000..0d219018 --- /dev/null +++ b/backend-node/src/routes/auditLogRoutes.ts @@ -0,0 +1,11 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/authMiddleware"; +import { getAuditLogs, getAuditLogStats, getAuditLogUsers } from "../controllers/auditLogController"; + +const router = Router(); + +router.get("/", authenticateToken, getAuditLogs); +router.get("/stats", authenticateToken, getAuditLogStats); +router.get("/users", authenticateToken, getAuditLogUsers); + +export default router; diff --git a/backend-node/src/routes/dataRoutes.ts b/backend-node/src/routes/dataRoutes.ts index c4c80e19..0bae3617 100644 --- a/backend-node/src/routes/dataRoutes.ts +++ b/backend-node/src/routes/dataRoutes.ts @@ -3,6 +3,7 @@ import { dataService } from "../services/dataService"; import { masterDetailExcelService } from "../services/masterDetailExcelService"; import { authenticateToken } from "../middleware/authMiddleware"; import { AuthenticatedRequest } from "../types/auth"; +import { auditLogService } from "../services/auditLogService"; const router = express.Router(); @@ -736,17 +737,39 @@ router.post( return res.status(400).json(result); } + const inserted = result.data?.inserted || 0; + const updated = result.data?.updated || 0; + const deleted = result.data?.deleted || 0; + console.log(`✅ 그룹화된 데이터 UPSERT 성공: ${tableName}`, { - inserted: result.data?.inserted || 0, - updated: result.data?.updated || 0, - deleted: result.data?.deleted || 0, + inserted, updated, deleted, }); + const parts: string[] = []; + if (inserted > 0) parts.push(`${inserted}건 생성`); + if (updated > 0) parts.push(`${updated}건 수정`); + if (deleted > 0) parts.push(`${deleted}건 삭제`); + + if (parts.length > 0) { + auditLogService.log({ + companyCode: req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: inserted > 0 && updated === 0 && deleted === 0 ? "BATCH_CREATE" : "UPDATE", + resourceType: "DATA", + tableName, + summary: `${tableName} 테이블 배치 처리: ${parts.join(", ")}`, + changes: { after: { inserted, updated, deleted } }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + } + return res.json({ success: true, message: "데이터가 저장되었습니다.", - inserted: result.data?.inserted || 0, - updated: result.data?.updated || 0, + inserted, + updated, deleted: result.data?.deleted || 0, savedIds: result.data?.savedIds || [], }); @@ -824,6 +847,19 @@ router.post( console.log(`✅ 레코드 생성 성공: ${tableName}`); + auditLogService.log({ + companyCode: req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "CREATE", + resourceType: "DATA", + resourceId: result.data?.id ? String(result.data.id) : undefined, + tableName, + summary: `${tableName} 테이블에 데이터 1건 생성`, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + return res.status(201).json({ success: true, data: result.data, @@ -880,6 +916,20 @@ router.put( console.log(`✅ 레코드 수정 성공: ${tableName}/${id}`); + auditLogService.log({ + companyCode: req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "UPDATE", + resourceType: "DATA", + resourceId: String(id), + tableName, + summary: `${tableName} 테이블 데이터 수정 (ID:${id})`, + changes: { after: data, fields: Object.keys(data || {}) }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + return res.json({ success: true, data: result.data, @@ -940,6 +990,20 @@ router.post( } console.log(`✅ 레코드 삭제 성공: ${tableName}`); + + auditLogService.log({ + companyCode: req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "DELETE", + resourceType: "DATA", + tableName, + summary: `${tableName} 테이블 데이터 삭제 (복합키)`, + changes: { before: compositeKey }, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + return res.json(result); } catch (error: any) { console.error(`레코드 삭제 오류 (${req.params.tableName}):`, error); @@ -1032,6 +1096,19 @@ router.delete( console.log(`✅ 레코드 삭제 성공: ${tableName}/${id}`); + auditLogService.log({ + companyCode: req.user?.companyCode || "", + userId: req.user?.userId || "", + userName: req.user?.userName || "", + action: "DELETE", + resourceType: "DATA", + resourceId: String(id), + tableName, + summary: `${tableName} 테이블 데이터 삭제 (ID:${id})`, + ipAddress: (req as any).ip, + requestPath: req.originalUrl, + }); + return res.json({ success: true, message: "레코드가 삭제되었습니다.", diff --git a/backend-node/src/routes/screenManagementRoutes.ts b/backend-node/src/routes/screenManagementRoutes.ts index 824bee71..3bbded6f 100644 --- a/backend-node/src/routes/screenManagementRoutes.ts +++ b/backend-node/src/routes/screenManagementRoutes.ts @@ -7,6 +7,7 @@ import { createScreen, updateScreen, updateScreenInfo, + updateScreenTableName, deleteScreen, bulkDeleteScreens, checkScreenDependencies, @@ -65,6 +66,7 @@ router.get("/screens/:id/menu", getScreenMenu); // 화면에 할당된 메뉴 router.post("/screens", createScreen); router.put("/screens/:id", updateScreen); router.put("/screens/:id/info", updateScreenInfo); // 화면 정보만 수정 +router.patch("/screens/:screenId/table-name", updateScreenTableName); // 화면 테이블명 변경 router.get("/screens/:id/dependencies", checkScreenDependencies); // 의존성 체크 router.delete("/screens/:id", deleteScreen); // 휴지통으로 이동 router.delete("/screens/bulk/delete", bulkDeleteScreens); // 활성 화면 일괄 삭제 (휴지통으로 이동) diff --git a/backend-node/src/services/auditLogService.ts b/backend-node/src/services/auditLogService.ts new file mode 100644 index 00000000..67c3b72d --- /dev/null +++ b/backend-node/src/services/auditLogService.ts @@ -0,0 +1,296 @@ +import { query, pool } from "../database/db"; +import logger from "../utils/logger"; + +export type AuditAction = + | "CREATE" + | "UPDATE" + | "DELETE" + | "COPY" + | "LOGIN" + | "STATUS_CHANGE" + | "BATCH_CREATE" + | "BATCH_UPDATE" + | "BATCH_DELETE"; + +export type AuditResourceType = + | "MENU" + | "SCREEN" + | "SCREEN_LAYOUT" + | "FLOW" + | "FLOW_STEP" + | "USER" + | "ROLE" + | "PERMISSION" + | "COMPANY" + | "CODE_CATEGORY" + | "CODE" + | "DATA" + | "TABLE" + | "NUMBERING_RULE" + | "BATCH"; + +export interface AuditLogParams { + companyCode: string; + userId: string; + userName?: string; + action: AuditAction; + resourceType: AuditResourceType; + resourceId?: string; + resourceName?: string; + tableName?: string; + summary?: string; + changes?: { + before?: Record; + after?: Record; + fields?: string[]; + }; + ipAddress?: string; + requestPath?: string; +} + +export interface AuditLogEntry { + id: number; + company_code: string; + user_id: string; + user_name: string | null; + action: string; + resource_type: string; + resource_id: string | null; + resource_name: string | null; + table_name: string | null; + summary: string | null; + changes: any; + ip_address: string | null; + request_path: string | null; + created_at: string; +} + +export interface AuditLogFilters { + companyCode?: string; + userId?: string; + resourceType?: string; + action?: string; + tableName?: string; + dateFrom?: string; + dateTo?: string; + search?: string; + page?: number; + limit?: number; +} + +export interface AuditLogStats { + dailyCounts: Array<{ date: string; count: number }>; + resourceTypeCounts: Array<{ resource_type: string; count: number }>; + actionCounts: Array<{ action: string; count: number }>; + topUsers: Array<{ user_id: string; user_name: string; count: number }>; +} + +class AuditLogService { + /** + * 감사 로그 1건 기록 (fire-and-forget) + * 본 작업에 영향을 주지 않도록 에러를 내부에서 처리 + */ + async log(params: AuditLogParams): Promise { + try { + await query( + `INSERT INTO system_audit_log + (company_code, user_id, user_name, action, resource_type, + resource_id, resource_name, table_name, summary, changes, + ip_address, request_path) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)`, + [ + params.companyCode, + params.userId, + params.userName || null, + params.action, + params.resourceType, + params.resourceId || null, + params.resourceName || null, + params.tableName || null, + params.summary || null, + params.changes ? JSON.stringify(params.changes) : null, + params.ipAddress || null, + params.requestPath || null, + ] + ); + } catch (error) { + logger.error("감사 로그 기록 실패 (무시됨)", { error, params }); + } + } + + /** + * 감사 로그 다건 기록 (배치) + */ + async logBatch(entries: AuditLogParams[]): Promise { + if (entries.length === 0) return; + try { + const values = entries + .map( + (_, i) => + `($${i * 12 + 1}, $${i * 12 + 2}, $${i * 12 + 3}, $${i * 12 + 4}, $${i * 12 + 5}, $${i * 12 + 6}, $${i * 12 + 7}, $${i * 12 + 8}, $${i * 12 + 9}, $${i * 12 + 10}, $${i * 12 + 11}, $${i * 12 + 12})` + ) + .join(", "); + + const params = entries.flatMap((e) => [ + e.companyCode, + e.userId, + e.userName || null, + e.action, + e.resourceType, + e.resourceId || null, + e.resourceName || null, + e.tableName || null, + e.summary || null, + e.changes ? JSON.stringify(e.changes) : null, + e.ipAddress || null, + e.requestPath || null, + ]); + + await query( + `INSERT INTO system_audit_log + (company_code, user_id, user_name, action, resource_type, + resource_id, resource_name, table_name, summary, changes, + ip_address, request_path) + VALUES ${values}`, + params + ); + } catch (error) { + logger.error("감사 로그 배치 기록 실패 (무시됨)", { error }); + } + } + + /** + * 감사 로그 조회 (페이징, 필터) + */ + async queryLogs( + filters: AuditLogFilters, + isSuperAdmin: boolean = false + ): Promise<{ data: AuditLogEntry[]; total: number }> { + const conditions: string[] = []; + const params: any[] = []; + let paramIndex = 1; + + if (!isSuperAdmin && filters.companyCode) { + conditions.push(`company_code = $${paramIndex++}`); + params.push(filters.companyCode); + } else if (isSuperAdmin && filters.companyCode) { + conditions.push(`company_code = $${paramIndex++}`); + params.push(filters.companyCode); + } + + if (filters.userId) { + conditions.push(`user_id = $${paramIndex++}`); + params.push(filters.userId); + } + if (filters.resourceType) { + conditions.push(`resource_type = $${paramIndex++}`); + params.push(filters.resourceType); + } + if (filters.action) { + conditions.push(`action = $${paramIndex++}`); + params.push(filters.action); + } + if (filters.tableName) { + conditions.push(`table_name = $${paramIndex++}`); + params.push(filters.tableName); + } + if (filters.dateFrom) { + conditions.push(`created_at >= $${paramIndex++}::timestamptz`); + params.push(filters.dateFrom); + } + if (filters.dateTo) { + conditions.push(`created_at <= $${paramIndex++}::timestamptz`); + params.push(filters.dateTo); + } + if (filters.search) { + conditions.push( + `(summary ILIKE $${paramIndex} OR resource_name ILIKE $${paramIndex} OR user_name ILIKE $${paramIndex})` + ); + params.push(`%${filters.search}%`); + paramIndex++; + } + + const whereClause = + conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : ""; + + const page = filters.page || 1; + const limit = filters.limit || 50; + const offset = (page - 1) * limit; + + const countResult = await query<{ count: string }>( + `SELECT COUNT(*) as count FROM system_audit_log ${whereClause}`, + params + ); + const total = parseInt(countResult[0].count, 10); + + const data = await query( + `SELECT * FROM system_audit_log ${whereClause} + ORDER BY created_at DESC + LIMIT $${paramIndex++} OFFSET $${paramIndex++}`, + [...params, limit, offset] + ); + + return { data, total }; + } + + /** + * 통계 조회 + */ + async getStats( + companyCode?: string, + days: number = 30 + ): Promise { + const companyFilter = companyCode + ? "AND company_code = $1" + : ""; + const params = companyCode ? [companyCode] : []; + + const dailyCounts = await query<{ date: string; count: number }>( + `SELECT DATE(created_at) as date, COUNT(*)::int as count + FROM system_audit_log + WHERE created_at >= NOW() - INTERVAL '${days} days' ${companyFilter} + GROUP BY DATE(created_at) + ORDER BY date DESC`, + params + ); + + const resourceTypeCounts = await query<{ + resource_type: string; + count: number; + }>( + `SELECT resource_type, COUNT(*)::int as count + FROM system_audit_log + WHERE created_at >= NOW() - INTERVAL '${days} days' ${companyFilter} + GROUP BY resource_type + ORDER BY count DESC`, + params + ); + + const actionCounts = await query<{ action: string; count: number }>( + `SELECT action, COUNT(*)::int as count + FROM system_audit_log + WHERE created_at >= NOW() - INTERVAL '${days} days' ${companyFilter} + GROUP BY action + ORDER BY count DESC`, + params + ); + + const topUsers = await query<{ + user_id: string; + user_name: string; + count: number; + }>( + `SELECT user_id, COALESCE(MAX(user_name), user_id) as user_name, COUNT(*)::int as count + FROM system_audit_log + WHERE created_at >= NOW() - INTERVAL '${days} days' ${companyFilter} + GROUP BY user_id + ORDER BY count DESC + LIMIT 10`, + params + ); + + return { dailyCounts, resourceTypeCounts, actionCounts, topUsers }; + } +} + +export const auditLogService = new AuditLogService(); diff --git a/backend-node/src/services/categoryTreeService.ts b/backend-node/src/services/categoryTreeService.ts index 1550a780..462a5191 100644 --- a/backend-node/src/services/categoryTreeService.ts +++ b/backend-node/src/services/categoryTreeService.ts @@ -405,69 +405,169 @@ class CategoryTreeService { } /** - * 모든 하위 카테고리 값 ID 재귀 수집 + * 카테고리 값이 실제 데이터 테이블에서 사용 중인지 확인 */ - private async collectAllChildValueIds( + private async checkCategoryValueInUse( companyCode: string, - valueId: number - ): Promise { + value: CategoryValue + ): Promise<{ inUse: boolean; count: number }> { const pool = getPool(); - - // 재귀 CTE를 사용하여 모든 하위 카테고리 수집 - const query = ` - WITH RECURSIVE category_tree AS ( - SELECT value_id FROM category_values - WHERE parent_value_id = $1 AND (company_code = $2 OR company_code = '*') - UNION ALL - SELECT cv.value_id - FROM category_values cv - INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id - WHERE cv.company_code = $2 OR cv.company_code = '*' - ) - SELECT value_id FROM category_tree - `; - - const result = await pool.query(query, [valueId, companyCode]); - return result.rows.map(row => row.value_id); + + try { + const tableExists = await pool.query( + `SELECT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = $1 + ) AS exists`, + [value.tableName] + ); + + if (!tableExists.rows[0].exists) { + return { inUse: false, count: 0 }; + } + + const columnExists = await pool.query( + `SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2 + ) AS exists`, + [value.tableName, value.columnName] + ); + + if (!columnExists.rows[0].exists) { + return { inUse: false, count: 0 }; + } + + const hasCompanyCode = await pool.query( + `SELECT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1 AND column_name = 'company_code' + ) AS exists`, + [value.tableName] + ); + + let countQuery: string; + let params: any[]; + + if (hasCompanyCode.rows[0].exists && companyCode !== "*") { + countQuery = ` + SELECT COUNT(*) as count FROM "${value.tableName}" + WHERE company_code = $1 + AND ($2 = ANY(string_to_array("${value.columnName}"::text, ',')) + OR "${value.columnName}"::text = $2) + `; + params = [companyCode, value.valueCode]; + } else { + countQuery = ` + SELECT COUNT(*) as count FROM "${value.tableName}" + WHERE $1 = ANY(string_to_array("${value.columnName}"::text, ',')) + OR "${value.columnName}"::text = $1 + `; + params = [value.valueCode]; + } + + const result = await pool.query(countQuery, params); + const count = parseInt(result.rows[0].count); + + return { inUse: count > 0, count }; + } catch (error: unknown) { + const err = error as Error; + logger.warn("카테고리 사용 여부 확인 중 오류 (무시하고 삭제 허용)", { + error: err.message, + tableName: value.tableName, + columnName: value.columnName, + }); + return { inUse: false, count: 0 }; + } } /** - * 카테고리 값 삭제 (하위 항목도 함께 삭제) + * 카테고리 값 삭제 가능 여부 사전 확인 + */ + async checkCanDelete( + companyCode: string, + valueId: number + ): Promise<{ canDelete: boolean; reason?: string }> { + const pool = getPool(); + + const value = await this.getCategoryValue(companyCode, valueId); + if (!value) { + return { canDelete: false, reason: "카테고리 값을 찾을 수 없습니다" }; + } + + const childCheck = await pool.query( + `SELECT COUNT(*) as count FROM category_values + WHERE parent_value_id = $1 AND (company_code = $2 OR company_code = '*')`, + [valueId, companyCode] + ); + const childCount = parseInt(childCheck.rows[0].count); + + if (childCount > 0) { + return { + canDelete: false, + reason: `하위 카테고리가 ${childCount}개 존재합니다. 하위 카테고리를 먼저 삭제해주세요.`, + }; + } + + const usageCheck = await this.checkCategoryValueInUse(companyCode, value); + if (usageCheck.inUse) { + return { + canDelete: false, + reason: `이 카테고리 값(${value.valueLabel})은 ${usageCheck.count}건의 데이터에서 사용 중이므로 삭제할 수 없습니다.`, + }; + } + + return { canDelete: true }; + } + + /** + * 카테고리 값 삭제 (자식 존재 및 사용 중 검증 후 삭제) */ async deleteCategoryValue(companyCode: string, valueId: number): Promise { const pool = getPool(); try { - // 1. 모든 하위 카테고리 ID 수집 - const childValueIds = await this.collectAllChildValueIds(companyCode, valueId); - const allValueIds = [valueId, ...childValueIds]; - - logger.info("삭제 대상 카테고리 값 수집 완료", { - valueId, - childCount: childValueIds.length, - totalCount: allValueIds.length, - }); + const value = await this.getCategoryValue(companyCode, valueId); + if (!value) { + return false; + } - // 2. 하위 카테고리부터 역순으로 삭제 (외래키 제약 회피) - const reversedIds = [...allValueIds].reverse(); - - for (const id of reversedIds) { - await pool.query( - `DELETE FROM category_values WHERE (company_code = $1 OR company_code = '*') AND value_id = $2`, - [companyCode, id] + // 1. 자식 카테고리 존재 여부 확인 + const childCheck = await pool.query( + `SELECT COUNT(*) as count FROM category_values + WHERE parent_value_id = $1 AND (company_code = $2 OR company_code = '*')`, + [valueId, companyCode] + ); + const childCount = parseInt(childCheck.rows[0].count); + + if (childCount > 0) { + throw new Error( + `VALIDATION:하위 카테고리가 ${childCount}개 존재합니다. 하위 카테고리를 먼저 삭제해주세요.` ); } - logger.info("카테고리 값 삭제 완료", { - valueId, - deletedCount: allValueIds.length, - deletedChildCount: childValueIds.length, - }); - + // 2. 실제 데이터에서 사용 중인지 확인 + const usageCheck = await this.checkCategoryValueInUse(companyCode, value); + if (usageCheck.inUse) { + throw new Error( + `VALIDATION:이 카테고리 값(${value.valueLabel})은 ${value.tableName} 테이블에서 ${usageCheck.count}건의 데이터가 사용 중이므로 삭제할 수 없습니다.` + ); + } + + // 3. 삭제 + await pool.query( + `DELETE FROM category_values WHERE (company_code = $1 OR company_code = '*') AND value_id = $2`, + [companyCode, valueId] + ); + + logger.info("카테고리 값 삭제 완료", { valueId, valueLabel: value.valueLabel }); + return true; } catch (error: unknown) { const err = error as Error; - logger.error("카테고리 값 삭제 실패", { error: err.message, valueId }); + if (!err.message.startsWith("VALIDATION:")) { + logger.error("카테고리 값 삭제 실패", { error: err.message, valueId }); + } throw error; } } diff --git a/backend-node/src/services/entityJoinService.ts b/backend-node/src/services/entityJoinService.ts index 059dad4a..a37942e1 100644 --- a/backend-node/src/services/entityJoinService.ts +++ b/backend-node/src/services/entityJoinService.ts @@ -765,7 +765,7 @@ export class EntityJoinService { }> > { try { - // 1. 테이블의 기본 컬럼 정보 조회 + // 1. 테이블의 기본 컬럼 정보 조회 (모든 데이터 타입 포함) const columns = await query<{ column_name: string; data_type: string; @@ -775,7 +775,7 @@ export class EntityJoinService { data_type FROM information_schema.columns WHERE table_name = $1 - AND data_type IN ('character varying', 'varchar', 'text', 'char') + AND table_schema = 'public' ORDER BY ordinal_position`, [tableName] ); diff --git a/backend-node/src/services/screenManagementService.ts b/backend-node/src/services/screenManagementService.ts index 74506a39..3ced9ca8 100644 --- a/backend-node/src/services/screenManagementService.ts +++ b/backend-node/src/services/screenManagementService.ts @@ -403,6 +403,38 @@ export class ScreenManagementService { }); } + /** + * 화면의 메인 테이블명만 업데이트 + */ + async updateScreenTableName( + screenId: number, + tableName: string, + userCompanyCode: string, + ): Promise { + const existingResult = await query<{ company_code: string | null }>( + `SELECT company_code FROM screen_definitions WHERE screen_id = $1 LIMIT 1`, + [screenId], + ); + + if (existingResult.length === 0) { + throw new Error("화면을 찾을 수 없습니다."); + } + + if ( + userCompanyCode !== "*" && + existingResult[0].company_code !== userCompanyCode + ) { + throw new Error("이 화면을 수정할 권한이 없습니다."); + } + + await query( + `UPDATE screen_definitions SET table_name = $1, updated_date = NOW() WHERE screen_id = $2`, + [tableName, screenId], + ); + + console.log(`화면 테이블명 업데이트 완료: screenId=${screenId}, tableName=${tableName}`); + } + /** * 화면 의존성 체크 - 다른 화면에서 이 화면을 참조하는지 확인 */ diff --git a/frontend/app/(main)/admin/audit-log/page.tsx b/frontend/app/(main)/admin/audit-log/page.tsx new file mode 100644 index 00000000..a6f4493d --- /dev/null +++ b/frontend/app/(main)/admin/audit-log/page.tsx @@ -0,0 +1,948 @@ +"use client"; + +import React, { useState, useEffect, useCallback } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Badge } from "@/components/ui/badge"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "@/components/ui/dialog"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import { + Layout, + Monitor, + GitBranch, + User, + Database, + Shield, + Search, + ChevronLeft, + ChevronRight, + Clock, + Filter, + Building2, + Hash, + FileText, + RefreshCw, + Check, + ChevronsUpDown, +} from "lucide-react"; +import { + getAuditLogs, + getAuditLogStats, + getAuditLogUsers, + AuditLogEntry, + AuditLogFilters, + AuditLogStats, + AuditLogUser, +} from "@/lib/api/auditLog"; +import { getCompanyList } from "@/lib/api/company"; +import { useAuth } from "@/hooks/useAuth"; +import { Company } from "@/types/company"; + +const RESOURCE_TYPE_CONFIG: Record< + string, + { label: string; icon: React.ElementType; color: string } +> = { + MENU: { label: "메뉴", icon: Layout, color: "bg-blue-100 text-blue-700" }, + SCREEN: { label: "화면", icon: Monitor, color: "bg-purple-100 text-purple-700" }, + SCREEN_LAYOUT: { label: "레이아웃", icon: Monitor, color: "bg-purple-100 text-purple-700" }, + FLOW: { label: "플로우", icon: GitBranch, color: "bg-green-100 text-green-700" }, + FLOW_STEP: { label: "플로우 스텝", icon: GitBranch, color: "bg-green-100 text-green-700" }, + USER: { label: "사용자", icon: User, color: "bg-orange-100 text-orange-700" }, + ROLE: { label: "권한", icon: Shield, color: "bg-red-100 text-red-700" }, + PERMISSION: { label: "권한", icon: Shield, color: "bg-red-100 text-red-700" }, + COMPANY: { label: "회사", icon: Building2, color: "bg-indigo-100 text-indigo-700" }, + CODE_CATEGORY: { label: "코드 카테고리", icon: Hash, color: "bg-cyan-100 text-cyan-700" }, + CODE: { label: "코드", icon: Hash, color: "bg-cyan-100 text-cyan-700" }, + DATA: { label: "데이터", icon: Database, color: "bg-gray-100 text-gray-700" }, + TABLE: { label: "테이블", icon: Database, color: "bg-gray-100 text-gray-700" }, + NUMBERING_RULE: { label: "채번 규칙", icon: FileText, color: "bg-amber-100 text-amber-700" }, + BATCH: { label: "배치", icon: RefreshCw, color: "bg-teal-100 text-teal-700" }, +}; + +const ACTION_CONFIG: Record = { + CREATE: { label: "생성", color: "bg-emerald-100 text-emerald-700" }, + UPDATE: { label: "수정", color: "bg-blue-100 text-blue-700" }, + DELETE: { label: "삭제", color: "bg-red-100 text-red-700" }, + COPY: { label: "복사", color: "bg-violet-100 text-violet-700" }, + LOGIN: { label: "로그인", color: "bg-gray-100 text-gray-700" }, + STATUS_CHANGE: { label: "상태변경", color: "bg-amber-100 text-amber-700" }, + BATCH_CREATE: { label: "배치생성", color: "bg-emerald-100 text-emerald-700" }, + BATCH_UPDATE: { label: "배치수정", color: "bg-blue-100 text-blue-700" }, + BATCH_DELETE: { label: "배치삭제", color: "bg-red-100 text-red-700" }, +}; + +function formatDateTime(dateStr: string): string { + const d = new Date(dateStr); + return d.toLocaleString("ko-KR", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +function formatTime(dateStr: string): string { + const d = new Date(dateStr); + return d.toLocaleTimeString("ko-KR", { + hour: "2-digit", + minute: "2-digit", + }); +} + +const FIELD_NAME_MAP: Record = { + status: "상태", + menuUrl: "메뉴 URL", + menu_url: "메뉴 URL", + menuNameKor: "메뉴명", + menu_name_kor: "메뉴명", + menuNameEng: "메뉴명(영)", + menu_name_eng: "메뉴명(영)", + screenName: "화면명", + screen_name: "화면명", + tableName: "테이블명", + table_name: "테이블명", + description: "설명", + isActive: "활성 여부", + is_active: "활성 여부", + userName: "사용자명", + user_name: "사용자명", + userId: "사용자 ID", + user_id: "사용자 ID", + deptName: "부서명", + dept_name: "부서명", + authName: "권한명", + authCode: "권한코드", + companyCode: "회사코드", + company_code: "회사코드", + company_name: "회사명", + name: "이름", + user_password: "비밀번호", + prefix: "접두사", + ruleName: "규칙명", + stepName: "스텝명", + stepOrder: "스텝 순서", + sourceScreenId: "원본 화면 ID", + targetCompanyCode: "대상 회사코드", + mainScreenName: "메인 화면명", + screenCode: "화면코드", + menuObjid: "메뉴 ID", + deleteReason: "삭제 사유", + force: "강제 삭제", + deletedMenus: "삭제된 메뉴", + failedMenuIds: "실패한 메뉴", + deletedCount: "삭제 건수", + items: "항목 수", +}; + +function formatFieldValue(value: unknown): string { + if (value === null || value === undefined) return "(없음)"; + if (typeof value === "boolean") return value ? "예" : "아니오"; + if (Array.isArray(value)) return value.length > 0 ? `${value.length}건` : "(없음)"; + if (typeof value === "object") return JSON.stringify(value); + return String(value); +} + +function renderChanges(changes: Record) { + const before = (changes.before as Record) || {}; + const after = (changes.after as Record) || {}; + const fields = (changes.fields as string[]) || []; + + const allKeys = new Set([ + ...Object.keys(before), + ...Object.keys(after), + ...fields, + ]); + + if (allKeys.size === 0) return null; + + const rows = Array.from(allKeys) + .filter((key) => key !== "deletedMenus" && key !== "failedMenuIds") + .map((key) => ({ + field: FIELD_NAME_MAP[key] || key, + beforeVal: key in before ? formatFieldValue(before[key]) : null, + afterVal: key in after ? formatFieldValue(after[key]) : null, + isSensitive: fields.includes(key) && !(key in before) && !(key in after), + })); + + const hasBefore = Object.keys(before).length > 0; + const hasAfter = Object.keys(after).length > 0; + + return ( +
+ + + + + {hasBefore && ( + + )} + {hasAfter && ( + + )} + + + + {rows.map((row, i) => ( + + + {row.isSensitive ? ( + + ) : ( + <> + {hasBefore && ( + + )} + {hasAfter && ( + + )} + + )} + + ))} + +
항목 + 변경 전 + + 변경 후 +
+ {row.field} + + (보안 항목 - 값 비공개) + + {row.beforeVal !== null ? ( + + {row.beforeVal} + + ) : ( + - + )} + + {row.afterVal !== null ? ( + + {row.afterVal} + + ) : ( + - + )} +
+
+ ); +} + +function formatDateGroup(dateStr: string): string { + const d = new Date(dateStr); + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + if (d.toDateString() === today.toDateString()) return "오늘"; + if (d.toDateString() === yesterday.toDateString()) return "어제"; + + return d.toLocaleDateString("ko-KR", { + year: "numeric", + month: "long", + day: "numeric", + weekday: "short", + }); +} + +function groupByDate(entries: AuditLogEntry[]): Map { + const groups = new Map(); + for (const entry of entries) { + const dateKey = new Date(entry.created_at).toDateString(); + if (!groups.has(dateKey)) groups.set(dateKey, []); + groups.get(dateKey)!.push(entry); + } + return groups; +} + +export default function AuditLogPage() { + const { user } = useAuth(); + const isSuperAdmin = user?.companyCode === "*" || user?.company_code === "*"; + + const [entries, setEntries] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [filters, setFilters] = useState({ + page: 1, + limit: 50, + }); + const [stats, setStats] = useState(null); + const [selectedEntry, setSelectedEntry] = useState(null); + const [detailOpen, setDetailOpen] = useState(false); + const [userComboOpen, setUserComboOpen] = useState(false); + const [companyComboOpen, setCompanyComboOpen] = useState(false); + const [companies, setCompanies] = useState([]); + const [auditUsers, setAuditUsers] = useState([]); + + const fetchCompanies = useCallback(async () => { + if (!isSuperAdmin) return; + try { + const list = await getCompanyList({ status: "Y" }); + setCompanies(list); + } catch (error) { + console.error("회사 목록 조회 실패:", error); + } + }, [isSuperAdmin]); + + const fetchAuditUsers = useCallback(async () => { + try { + const result = await getAuditLogUsers(filters.companyCode); + if (result.success) { + setAuditUsers(result.data); + } + } catch (error) { + console.error("사용자 목록 조회 실패:", error); + } + }, [filters.companyCode]); + + const fetchLogs = useCallback(async () => { + setLoading(true); + try { + const result = await getAuditLogs(filters); + if (result.success) { + setEntries(result.data); + setTotal(result.total); + } + } catch (error) { + console.error("감사 로그 조회 실패:", error); + } finally { + setLoading(false); + } + }, [filters]); + + const fetchStats = useCallback(async () => { + try { + const result = await getAuditLogStats(filters.companyCode, 30); + if (result.success) { + setStats(result.data); + } + } catch (error) { + console.error("통계 조회 실패:", error); + } + }, [filters.companyCode]); + + useEffect(() => { + fetchCompanies(); + }, [fetchCompanies]); + + useEffect(() => { + fetchAuditUsers(); + }, [fetchAuditUsers]); + + useEffect(() => { + fetchLogs(); + }, [fetchLogs]); + + useEffect(() => { + fetchStats(); + }, [fetchStats]); + + const totalPages = Math.ceil(total / (filters.limit || 50)); + + const dateGroups = groupByDate(entries); + + const handleFilterChange = (key: keyof AuditLogFilters, value: string) => { + const updates: Partial = { [key]: value || undefined, page: 1 }; + if (key === "companyCode") { + updates.userId = undefined; + } + setFilters((prev) => ({ ...prev, ...updates })); + }; + + const handleSearch = (e: React.FormEvent) => { + e.preventDefault(); + fetchLogs(); + }; + + const openDetail = (entry: AuditLogEntry) => { + setSelectedEntry(entry); + setDetailOpen(true); + }; + + return ( +
+
+
+

통합 변경 이력

+

+ 시스템 전체 변경 사항을 추적합니다 +

+
+ +
+ + {stats && ( +
+ + +

최근 30일 총 변경

+

+ {stats.dailyCounts.reduce((s, d) => s + d.count, 0).toLocaleString()}건 +

+
+
+ + +

리소스 유형

+

{stats.resourceTypeCounts.length}종

+
+
+ + +

활동 사용자

+

{stats.topUsers.length}명

+
+
+ + +

오늘 변경

+

+ {( + stats.dailyCounts.find( + (d) => + new Date(d.date).toDateString() === + new Date().toDateString() + )?.count || 0 + ).toLocaleString()}건 +

+
+
+
+ )} + + + +
+
+ +
+ + handleFilterChange("search", e.target.value)} + className="h-9 pl-8 text-sm" + /> +
+
+ +
+ + +
+ +
+ + +
+ + {isSuperAdmin && ( +
+ + + + + + + + + + + 회사를 찾을 수 없습니다 + + + { + handleFilterChange("companyCode", ""); + setCompanyComboOpen(false); + }} + className="text-xs" + > + + 전체 회사 + + {companies.map((company) => ( + { + handleFilterChange( + "companyCode", + filters.companyCode === company.company_code + ? "" + : company.company_code + ); + setCompanyComboOpen(false); + }} + className="text-xs" + > + +
+ {company.company_name} + + {company.company_code} + +
+
+ ))} +
+
+
+
+
+
+ )} + +
+ + + + + + + + + + + 사용자를 찾을 수 없습니다 + + + { + handleFilterChange("userId", ""); + setUserComboOpen(false); + }} + className="text-xs" + > + + 전체 + + {auditUsers.map((u) => ( + { + handleFilterChange( + "userId", + filters.userId === u.user_id ? "" : u.user_id + ); + setUserComboOpen(false); + }} + className="text-xs" + > + +
+ {u.user_name} + + {u.user_id} ({u.count}건) + +
+
+ ))} +
+
+
+
+
+
+ +
+ + handleFilterChange("dateFrom", e.target.value)} + className="h-9 text-xs" + /> +
+ +
+ + handleFilterChange("dateTo", e.target.value)} + className="h-9 text-xs" + /> +
+ + +
+
+
+ + + +
+ + 변경 이력 ({total.toLocaleString()}건) + +
+ + + {filters.page || 1} / {totalPages || 1} + + +
+
+
+ + {loading ? ( +
+ +
+ ) : entries.length === 0 ? ( +
+ +

+ 변경 이력이 없습니다 +

+
+ ) : ( +
+ {Array.from(dateGroups.entries()).map(([dateKey, items]) => ( +
+
+ + {formatDateGroup(items[0].created_at)} + + + {items.length}건 + +
+ {items.map((entry) => { + const rtConfig = + RESOURCE_TYPE_CONFIG[entry.resource_type] || + RESOURCE_TYPE_CONFIG.DATA; + const actConfig = + ACTION_CONFIG[entry.action] || ACTION_CONFIG.UPDATE; + const IconComp = rtConfig.icon; + + return ( +
openDetail(entry)} + > +
+ +
+
+
+ + {entry.user_name || entry.user_id} + + + {rtConfig.label} + + + {actConfig.label} + + {entry.company_code && entry.company_code !== "*" && ( + + [{entry.company_code}] + + )} +
+

+ {entry.summary || entry.resource_name || "-"} +

+
+ + {formatTime(entry.created_at)} + +
+ ); + })} +
+ ))} +
+ )} +
+
+ + + + + + 변경 상세 정보 + + + {selectedEntry && + formatDateTime(selectedEntry.created_at)} + + + {selectedEntry && ( +
+
+
+ +

+ {selectedEntry.user_name || selectedEntry.user_id} +

+
+
+ +

{selectedEntry.company_code}

+
+
+ +

+ {RESOURCE_TYPE_CONFIG[selectedEntry.resource_type]?.label || + selectedEntry.resource_type} +

+
+
+ +

+ {ACTION_CONFIG[selectedEntry.action]?.label || + selectedEntry.action} +

+
+ {selectedEntry.resource_name && ( +
+ +

{selectedEntry.resource_name}

+
+ )} + {selectedEntry.table_name && ( +
+ +

{selectedEntry.table_name}

+
+ )} + {selectedEntry.ip_address && ( +
+ +

{selectedEntry.ip_address}

+
+ )} +
+ + {selectedEntry.summary && ( +
+ +

+ {selectedEntry.summary} +

+
+ )} + + {selectedEntry.changes && ( +
+ +
+ {renderChanges( + selectedEntry.changes as Record + )} +
+
+ )} + + {selectedEntry.request_path && ( +
+ +

+ {selectedEntry.request_path} +

+
+ )} +
+ )} +
+
+
+ ); +} diff --git a/frontend/components/admin/CreateTableModal.tsx b/frontend/components/admin/CreateTableModal.tsx index ecb6b03f..63d42c88 100644 --- a/frontend/components/admin/CreateTableModal.tsx +++ b/frontend/components/admin/CreateTableModal.tsx @@ -380,8 +380,8 @@ export function CreateTableModal({ - {/* 로그 테이블 생성 옵션 */} -
+ {/* 로그 테이블 생성 옵션 - 통합 변경 이력 시스템으로 대체됨 (숨김 처리) */} + {/*
-
+ */} {/* 자동 추가 컬럼 안내 */} diff --git a/frontend/components/screen/ScreenDesigner.tsx b/frontend/components/screen/ScreenDesigner.tsx index 7f94bca0..2cbacb03 100644 --- a/frontend/components/screen/ScreenDesigner.tsx +++ b/frontend/components/screen/ScreenDesigner.tsx @@ -2092,24 +2092,25 @@ export default function ScreenDesigner({ // V2/POP API 사용 여부에 따라 분기 const v2Layout = convertLegacyToV2(layoutWithResolution); if (USE_POP_API) { - // POP 모드: screen_layouts_pop 테이블에 저장 await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout); } else if (USE_V2_API) { - // 레이어 기반 저장: 현재 활성 레이어의 layout만 저장 const currentLayerId = activeLayerIdRef.current || 1; await screenApi.saveLayoutV2(selectedScreen.screenId, { ...v2Layout, layerId: currentLayerId, - mainTableName: currentMainTableName, // 화면의 기본 테이블 (DB 업데이트용) + mainTableName: currentMainTableName, }); } else { await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); } - // console.log("✅ 저장 성공!"); + // 테이블이 변경된 경우 전용 API로 명시적으로 업데이트 + if (currentMainTableName && currentMainTableName !== selectedScreen.tableName) { + await screenApi.updateScreenTableName(selectedScreen.screenId, currentMainTableName); + } + toast.success("화면이 저장되었습니다."); - // 저장 성공 후 부모에게 화면 정보 업데이트 알림 (테이블명 즉시 반영) if (onScreenUpdate && currentMainTableName) { onScreenUpdate({ tableName: currentMainTableName }); } @@ -5625,33 +5626,38 @@ export default function ScreenDesigner({ if (layout.components.length > 0 && selectedScreen?.screenId) { setIsSaving(true); try { - // 해상도 정보를 포함한 레이아웃 데이터 생성 + const currentMainTableName = tables.length > 0 ? tables[0].tableName : null; + const layoutWithResolution = { ...layout, screenResolution: screenResolution, + mainTableName: currentMainTableName, }; - console.log("⚡ 자동 저장할 레이아웃 데이터:", { - componentsCount: layoutWithResolution.components.length, - gridSettings: layoutWithResolution.gridSettings, - screenResolution: layoutWithResolution.screenResolution, - }); // V2/POP API 사용 여부에 따라 분기 const v2Layout = convertLegacyToV2(layoutWithResolution); if (USE_POP_API) { await screenApi.saveLayoutPop(selectedScreen.screenId, v2Layout); } else if (USE_V2_API) { - // 현재 활성 레이어 ID 포함 (레이어별 저장) const currentLayerId = activeLayerIdRef.current || 1; await screenApi.saveLayoutV2(selectedScreen.screenId, { ...v2Layout, layerId: currentLayerId, + mainTableName: currentMainTableName, }); } else { await screenApi.saveLayout(selectedScreen.screenId, layoutWithResolution); } + + if (currentMainTableName && currentMainTableName !== selectedScreen.tableName) { + await screenApi.updateScreenTableName(selectedScreen.screenId, currentMainTableName); + } + toast.success("레이아웃이 저장되었습니다."); + + if (onScreenUpdate && currentMainTableName) { + onScreenUpdate({ tableName: currentMainTableName }); + } } catch (error) { - // console.error("레이아웃 저장 실패:", error); toast.error("레이아웃 저장에 실패했습니다."); } finally { setIsSaving(false); @@ -5783,6 +5789,8 @@ export default function ScreenDesigner({ handleGroupDistribute, handleMatchSize, handleToggleAllLabels, + tables, + onScreenUpdate, ]); // 플로우 위젯 높이 자동 업데이트 이벤트 리스너 diff --git a/frontend/components/table-category/CategoryValueManagerTree.tsx b/frontend/components/table-category/CategoryValueManagerTree.tsx index d4da04fc..07965ce2 100644 --- a/frontend/components/table-category/CategoryValueManagerTree.tsx +++ b/frontend/components/table-category/CategoryValueManagerTree.tsx @@ -29,6 +29,7 @@ import { createCategoryValue, updateCategoryValue, deleteCategoryValue, + checkCanDeleteCategoryValue, CreateCategoryValueInput, } from "@/lib/api/categoryTree"; import { @@ -310,53 +311,6 @@ export const CategoryValueManagerTree: React.FC = return count; }, []); - // 하위 항목 개수만 계산 (자기 자신 제외) - const countAllDescendants = useCallback( - (node: CategoryValue): number => { - if (!node.children || node.children.length === 0) { - return 0; - } - return countAllValues(node.children); - }, - [countAllValues], - ); - - // 노드와 모든 하위 항목의 ID 수집 - const collectNodeAndDescendantIds = useCallback((node: CategoryValue): number[] => { - const ids: number[] = [node.valueId]; - if (node.children) { - for (const child of node.children) { - ids.push(...collectNodeAndDescendantIds(child)); - } - } - return ids; - }, []); - - // 트리에서 valueId로 노드 찾기 - const findNodeById = useCallback((nodes: CategoryValue[], valueId: number): CategoryValue | null => { - for (const node of nodes) { - if (node.valueId === valueId) { - return node; - } - if (node.children) { - const found = findNodeById(node.children, valueId); - if (found) return found; - } - } - return null; - }, []); - - // 체크된 항목들의 총 삭제 대상 수 계산 (하위 포함) - const totalDeleteCount = useMemo(() => { - const allIds = new Set(); - checkedIds.forEach((id) => { - const node = findNodeById(tree, id); - if (node) { - collectNodeAndDescendantIds(node).forEach((descendantId) => allIds.add(descendantId)); - } - }); - return allIds.size; - }, [checkedIds, tree, findNodeById, collectNodeAndDescendantIds]); // 활성 노드만 필터링 const filterActiveNodes = useCallback((nodes: CategoryValue[]): CategoryValue[] => { @@ -504,8 +458,20 @@ export const CategoryValueManagerTree: React.FC = setIsEditModalOpen(true); }; - // 삭제 다이얼로그 열기 - const handleOpenDeleteDialog = (value: CategoryValue) => { + // 삭제 다이얼로그 열기 (사전 확인 후) + const handleOpenDeleteDialog = async (value: CategoryValue) => { + try { + const response = await checkCanDeleteCategoryValue(value.valueId); + if (response.success && response.data) { + if (!response.data.canDelete) { + toast.error(response.data.reason || "이 카테고리는 삭제할 수 없습니다"); + return; + } + } + } catch { + // 사전 확인 실패 시에도 다이얼로그는 열어줌 (삭제 시 백엔드에서 재검증) + } + setDeletingValue(value); setIsDeleteDialogOpen(true); }; @@ -616,8 +582,8 @@ export const CategoryValueManagerTree: React.FC = try { let successCount = 0; let failCount = 0; + const failMessages: string[] = []; - // 체크된 항목들을 순차적으로 삭제 (하위는 백엔드에서 자동 삭제) for (const valueId of Array.from(checkedIds)) { try { const response = await deleteCategoryValue(valueId); @@ -625,6 +591,7 @@ export const CategoryValueManagerTree: React.FC = successCount++; } else { failCount++; + if (response.error) failMessages.push(response.error); } } catch { failCount++; @@ -634,12 +601,14 @@ export const CategoryValueManagerTree: React.FC = setIsBulkDeleteDialogOpen(false); setCheckedIds(new Set()); setSelectedValue(null); - loadTree(true); // 기존 펼침 상태 유지 + loadTree(true); if (failCount === 0) { - toast.success(`${successCount}개 카테고리가 삭제되었습니다 (하위 항목 포함)`); + toast.success(`${successCount}개 카테고리가 삭제되었습니다`); + } else if (successCount === 0) { + toast.error(`삭제할 수 없습니다: ${failMessages[0] || "삭제 실패"}`); } else { - toast.warning(`${successCount}개 삭제 성공, ${failCount}개 삭제 실패`); + toast.warning(`${successCount}개 삭제 성공, ${failCount}개 삭제 실패 (사용 중이거나 하위 항목 존재)`); } } catch (error) { console.error("카테고리 일괄 삭제 오류:", error); @@ -889,14 +858,8 @@ export const CategoryValueManagerTree: React.FC = 카테고리 삭제 {deletingValue?.valueLabel}을(를) 삭제하시겠습니까? - {deletingValue && countAllDescendants(deletingValue) > 0 && ( - <> -
- - 하위 카테고리 {countAllDescendants(deletingValue)}개도 모두 함께 삭제됩니다. - - - )} +
+ 삭제된 카테고리는 복구할 수 없습니다.
@@ -918,12 +881,6 @@ export const CategoryValueManagerTree: React.FC = 카테고리 일괄 삭제 선택한 {checkedIds.size}개 카테고리를 삭제하시겠습니까? - {totalDeleteCount > checkedIds.size && ( - <> -
- 하위 카테고리 포함 총 {totalDeleteCount}개가 삭제됩니다. - - )}
삭제된 카테고리는 복구할 수 없습니다.
@@ -934,7 +891,7 @@ export const CategoryValueManagerTree: React.FC = onClick={handleBulkDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90" > - {totalDeleteCount}개 삭제 + {checkedIds.size}개 삭제
diff --git a/frontend/lib/api/auditLog.ts b/frontend/lib/api/auditLog.ts new file mode 100644 index 00000000..96c5463e --- /dev/null +++ b/frontend/lib/api/auditLog.ts @@ -0,0 +1,91 @@ +import { apiClient } from "./client"; + +export interface AuditLogEntry { + id: number; + company_code: string; + user_id: string; + user_name: string | null; + action: string; + resource_type: string; + resource_id: string | null; + resource_name: string | null; + table_name: string | null; + summary: string | null; + changes: any; + ip_address: string | null; + request_path: string | null; + created_at: string; +} + +export interface AuditLogFilters { + companyCode?: string; + userId?: string; + resourceType?: string; + action?: string; + tableName?: string; + dateFrom?: string; + dateTo?: string; + search?: string; + page?: number; + limit?: number; +} + +export interface AuditLogStats { + dailyCounts: Array<{ date: string; count: number }>; + resourceTypeCounts: Array<{ resource_type: string; count: number }>; + actionCounts: Array<{ action: string; count: number }>; + topUsers: Array<{ user_id: string; user_name: string; count: number }>; +} + +export async function getAuditLogs( + filters: AuditLogFilters +): Promise<{ + success: boolean; + data: AuditLogEntry[]; + total: number; + page: number; + limit: number; +}> { + const params = new URLSearchParams(); + if (filters.companyCode) params.append("companyCode", filters.companyCode); + if (filters.userId) params.append("userId", filters.userId); + if (filters.resourceType) params.append("resourceType", filters.resourceType); + if (filters.action) params.append("action", filters.action); + if (filters.tableName) params.append("tableName", filters.tableName); + if (filters.dateFrom) params.append("dateFrom", filters.dateFrom); + if (filters.dateTo) params.append("dateTo", filters.dateTo); + if (filters.search) params.append("search", filters.search); + if (filters.page) params.append("page", String(filters.page)); + if (filters.limit) params.append("limit", String(filters.limit)); + + const response = await apiClient.get(`/audit-log?${params.toString()}`); + return response.data; +} + +export async function getAuditLogStats( + companyCode?: string, + days?: number +): Promise<{ success: boolean; data: AuditLogStats }> { + const params = new URLSearchParams(); + if (companyCode) params.append("companyCode", companyCode); + if (days) params.append("days", String(days)); + + const response = await apiClient.get(`/audit-log/stats?${params.toString()}`); + return response.data; +} + +export interface AuditLogUser { + user_id: string; + user_name: string; + count: number; +} + +export async function getAuditLogUsers( + companyCode?: string +): Promise<{ success: boolean; data: AuditLogUser[] }> { + const params = new URLSearchParams(); + if (companyCode) params.append("companyCode", companyCode); + + const response = await apiClient.get(`/audit-log/users?${params.toString()}`); + return response.data; +} diff --git a/frontend/lib/api/categoryTree.ts b/frontend/lib/api/categoryTree.ts index ac429607..07806d2f 100644 --- a/frontend/lib/api/categoryTree.ts +++ b/frontend/lib/api/categoryTree.ts @@ -156,6 +156,24 @@ export async function updateCategoryValue( } } +/** + * 카테고리 값 삭제 가능 여부 사전 확인 + */ +export async function checkCanDeleteCategoryValue( + valueId: number +): Promise> { + try { + const response = await apiClient.get(`/category-tree/test/value/${valueId}/can-delete`); + return response.data; + } catch (error: unknown) { + const err = error as { response?: { data?: { error?: string } }; message?: string }; + return { + success: false, + error: err.response?.data?.error || err.message || "삭제 가능 여부 확인 실패", + }; + } +} + /** * 카테고리 값 삭제 */ diff --git a/frontend/lib/api/screen.ts b/frontend/lib/api/screen.ts index 96766816..cc61f7ff 100644 --- a/frontend/lib/api/screen.ts +++ b/frontend/lib/api/screen.ts @@ -87,6 +87,11 @@ export const screenApi = { await apiClient.put(`/screen-management/screens/${screenId}/info`, data); }, + // 화면 테이블명 변경 + updateScreenTableName: async (screenId: number, tableName: string): Promise => { + await apiClient.patch(`/screen-management/screens/${screenId}/table-name`, { tableName }); + }, + // 화면 의존성 체크 checkScreenDependencies: async ( screenId: number,