Files
vexplor_dev/backend-node/src/controllers/tableManagementController.ts
kjs 7c96461f59 feat: enhance audit log functionality and file upload components
- Updated the audit log controller to determine super admin status based on user type instead of company code.
- Added detailed logging for column settings updates and batch updates in the table management controller, capturing user actions and changes made.
- Implemented security measures in the audit log service to mask sensitive data for non-super admin users.
- Introduced a new TableCellFile component to handle file attachments, supporting both objid and JSON array formats for file information.
- Enhanced the file upload component to manage file states more effectively during record changes and mode transitions.

These updates aim to improve the audit logging capabilities and file management features within the ERP system, ensuring better security and user experience.
2026-03-17 11:31:54 +09:00

3319 lines
105 KiB
TypeScript

import { Request, Response } from "express";
import { Client } from "pg";
import { logger } from "../utils/logger";
import { AuthenticatedRequest } from "../types/auth";
import { ApiResponse } from "../types/common";
import { TableManagementService } from "../services/tableManagementService";
import {
TableInfo,
ColumnTypeInfo,
ColumnSettings,
TableListResponse,
ColumnListResponse,
ColumnSettingsResponse,
} from "../types/tableManagement";
import { query } from "../database/db";
import { auditLogService, getClientIp } from "../services/auditLogService";
/**
* 테이블 목록 조회
*/
export async function getTableList(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
logger.info("=== 테이블 목록 조회 시작 ===");
const tableManagementService = new TableManagementService();
const tableList = await tableManagementService.getTableList();
logger.info(`테이블 목록 조회 결과: ${tableList.length}`);
const response: ApiResponse<TableInfo[]> = {
success: true,
message: "테이블 목록을 성공적으로 조회했습니다.",
data: tableList,
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 목록 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 목록 조회 중 오류가 발생했습니다.",
error: {
code: "TABLE_LIST_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 테이블 컬럼 정보 조회
*/
export async function getColumnList(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const { page = 1, size = 50 } = req.query;
// 🔥 회사 코드 추출 (JWT에서 또는 DB에서 조회)
let companyCode = req.user?.companyCode;
if (!companyCode && req.user?.userId) {
// JWT에 없으면 DB에서 조회
const { query } = require("../database/db");
const userResult = await query(
`SELECT company_code FROM user_info WHERE user_id = $1`,
[req.user.userId]
);
companyCode = userResult[0]?.company_code;
logger.info(
`DB에서 회사 코드 조회 (컬럼 목록): ${req.user.userId}${companyCode}`
);
}
logger.info(
`=== 컬럼 정보 조회 시작: ${tableName} (page: ${page}, size: ${size}), company: ${companyCode} ===`
);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
// 🔥 캐시 버스팅: _t 파라미터가 있으면 캐시 무시
const bustCache = !!req.query._t;
const result = await tableManagementService.getColumnList(
tableName,
parseInt(page as string),
parseInt(size as string),
companyCode, // 🔥 회사 코드 전달
bustCache // 🔥 캐시 버스팅 옵션
);
logger.info(
`컬럼 정보 조회 결과: ${tableName}, ${result.columns.length}/${result.total}개 (${result.page}/${result.totalPages} 페이지)`
);
const response: ApiResponse<typeof result> = {
success: true,
message: "컬럼 목록을 성공적으로 조회했습니다.",
data: result,
};
res.status(200).json(response);
} catch (error) {
logger.error("컬럼 정보 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "컬럼 목록 조회 중 오류가 발생했습니다.",
error: {
code: "COLUMN_LIST_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 개별 컬럼 설정 업데이트
*/
export async function updateColumnSettings(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, columnName } = req.params;
const settings: ColumnSettings = req.body;
// 🔥 회사 코드 추출 (JWT에서 또는 DB에서 조회)
let companyCode = req.user?.companyCode;
if (!companyCode && req.user?.userId) {
// JWT에 없으면 DB에서 조회
const { query } = require("../database/db");
const userResult = await query(
`SELECT company_code FROM user_info WHERE user_id = $1`,
[req.user.userId]
);
companyCode = userResult[0]?.company_code;
logger.info(`DB에서 회사 코드 조회: ${req.user.userId}${companyCode}`);
}
logger.info(
`=== 컬럼 설정 업데이트 시작: ${tableName}.${columnName}, company: ${companyCode} ===`
);
if (!tableName || !columnName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명과 컬럼명이 필요합니다.",
error: {
code: "MISSING_PARAMETERS",
details: "테이블명 또는 컬럼명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
if (!settings) {
const response: ApiResponse<null> = {
success: false,
message: "컬럼 설정 정보가 필요합니다.",
error: {
code: "MISSING_SETTINGS",
details: "요청 본문에 컬럼 설정 정보가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
if (!companyCode) {
logger.error(`회사 코드 누락: ${tableName}.${columnName}`, {
user: req.user,
hasUser: !!req.user,
userId: req.user?.userId,
companyCodeFromJWT: req.user?.companyCode,
});
const response: ApiResponse<null> = {
success: false,
message: "회사 코드를 찾을 수 없습니다.",
error: {
code: "MISSING_COMPANY_CODE",
details:
"사용자 정보에서 회사 코드를 찾을 수 없습니다. 관리자에게 문의하세요.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
await tableManagementService.updateColumnSettings(
tableName,
columnName,
settings,
companyCode // 🔥 회사 코드 전달
);
logger.info(
`컬럼 설정 업데이트 완료: ${tableName}.${columnName}, company: ${companyCode}`
);
auditLogService.log({
companyCode: companyCode || "",
userId: req.user?.userId || "",
userName: req.user?.userName || "",
action: "UPDATE",
resourceType: "TABLE",
resourceId: `${tableName}.${columnName}`,
resourceName: settings.columnLabel || columnName,
tableName: "table_type_columns",
summary: `테이블 타입관리: ${tableName}.${columnName} 컬럼 설정 변경`,
changes: {
after: {
columnLabel: settings.columnLabel,
inputType: settings.inputType,
referenceTable: settings.referenceTable,
referenceColumn: settings.referenceColumn,
displayColumn: settings.displayColumn,
codeCategory: settings.codeCategory,
},
fields: ["columnLabel", "inputType", "referenceTable", "referenceColumn", "displayColumn", "codeCategory"],
},
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
const response: ApiResponse<null> = {
success: true,
message: "컬럼 설정을 성공적으로 저장했습니다.",
};
res.status(200).json(response);
} catch (error) {
logger.error("컬럼 설정 업데이트 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "컬럼 설정 저장 중 오류가 발생했습니다.",
error: {
code: "COLUMN_SETTINGS_UPDATE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 전체 컬럼 설정 일괄 업데이트
*/
export async function updateAllColumnSettings(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const columnSettings: ColumnSettings[] = req.body;
// 🔥 회사 코드 추출 (JWT에서 또는 DB에서 조회)
let companyCode = req.user?.companyCode;
if (!companyCode && req.user?.userId) {
// JWT에 없으면 DB에서 조회
const { query } = require("../database/db");
const userResult = await query(
`SELECT company_code FROM user_info WHERE user_id = $1`,
[req.user.userId]
);
companyCode = userResult[0]?.company_code;
logger.info(`DB에서 회사 코드 조회: ${req.user.userId}${companyCode}`);
}
// 🔍 디버깅: 사용자 정보 출력
logger.info(`[DEBUG] req.user:`, JSON.stringify(req.user, null, 2));
logger.info(`[DEBUG] req.user?.companyCode: ${req.user?.companyCode}`);
logger.info(`[DEBUG] req.user?.userId: ${req.user?.userId}`);
logger.info(`[DEBUG] companyCode 최종값: ${companyCode}`);
logger.info(
`=== 전체 컬럼 설정 일괄 업데이트 시작: ${tableName}, company: ${companyCode} ===`
);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
if (!Array.isArray(columnSettings) || columnSettings.length === 0) {
const response: ApiResponse<null> = {
success: false,
message: "컬럼 설정 목록이 필요합니다.",
error: {
code: "MISSING_COLUMN_SETTINGS",
details: "요청 본문에 컬럼 설정 목록이 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
if (!companyCode) {
logger.error(`회사 코드 누락 (일괄 업데이트): ${tableName}`, {
user: req.user,
hasUser: !!req.user,
userId: req.user?.userId,
companyCodeFromJWT: req.user?.companyCode,
settingsCount: columnSettings.length,
});
const response: ApiResponse<null> = {
success: false,
message: "회사 코드를 찾을 수 없습니다.",
error: {
code: "MISSING_COMPANY_CODE",
details:
"사용자 정보에서 회사 코드를 찾을 수 없습니다. 관리자에게 문의하세요.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
await tableManagementService.updateAllColumnSettings(
tableName,
columnSettings,
companyCode // 🔥 회사 코드 전달
);
logger.info(
`전체 컬럼 설정 일괄 업데이트 완료: ${tableName}, ${columnSettings.length}개, company: ${companyCode}`
);
const changedColumns = columnSettings
.filter((c) => c.columnName)
.map((c) => c.columnName)
.join(", ");
auditLogService.log({
companyCode: companyCode || "",
userId: req.user?.userId || "",
userName: req.user?.userName || "",
action: "BATCH_UPDATE",
resourceType: "TABLE",
resourceId: tableName,
resourceName: tableName,
tableName: "table_type_columns",
summary: `테이블 타입관리: ${tableName} 전체 컬럼 설정 일괄 변경 (${columnSettings.length}개)`,
changes: {
after: { columns: changedColumns, count: columnSettings.length },
fields: columnSettings.filter((c) => c.columnName).map((c) => c.columnName!),
},
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
const response: ApiResponse<null> = {
success: true,
message: "모든 컬럼 설정을 성공적으로 저장했습니다.",
};
res.status(200).json(response);
} catch (error) {
logger.error("전체 컬럼 설정 일괄 업데이트 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "컬럼 설정 저장 중 오류가 발생했습니다.",
error: {
code: "ALL_COLUMN_SETTINGS_UPDATE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 테이블 라벨 정보 조회
*/
export async function getTableLabels(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
logger.info(`=== 테이블 라벨 정보 조회 시작: ${tableName} ===`);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
const tableLabels = await tableManagementService.getTableLabels(tableName);
if (!tableLabels) {
// 라벨이 없으면 빈 객체를 성공으로 반환 (404 에러 대신)
const response: ApiResponse<{}> = {
success: true,
message: "테이블 라벨 정보를 조회했습니다.",
data: {},
};
res.status(200).json(response);
return;
}
logger.info(`테이블 라벨 정보 조회 완료: ${tableName}`);
const response: ApiResponse<any> = {
success: true,
message: "테이블 라벨 정보를 성공적으로 조회했습니다.",
data: tableLabels,
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 라벨 정보 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 라벨 정보 조회 중 오류가 발생했습니다.",
error: {
code: "TABLE_LABELS_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 컬럼 라벨 정보 조회
*/
export async function getColumnLabels(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, columnName } = req.params;
logger.info(`=== 컬럼 라벨 정보 조회 시작: ${tableName}.${columnName} ===`);
if (!tableName || !columnName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명과 컬럼명이 필요합니다.",
error: {
code: "MISSING_PARAMETERS",
details: "테이블명 또는 컬럼명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
const columnLabels = await tableManagementService.getColumnLabels(
tableName,
columnName
);
if (!columnLabels) {
// 라벨이 없으면 빈 객체를 성공으로 반환 (404 에러 대신)
const response: ApiResponse<{}> = {
success: true,
message: "컬럼 라벨 정보를 조회했습니다.",
data: {},
};
res.status(200).json(response);
return;
}
logger.info(`컬럼 라벨 정보 조회 완료: ${tableName}.${columnName}`);
const response: ApiResponse<any> = {
success: true,
message: "컬럼 라벨 정보를 성공적으로 조회했습니다.",
data: columnLabels,
};
res.status(200).json(response);
} catch (error) {
logger.error("컬럼 라벨 정보 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "컬럼 라벨 정보 조회 중 오류가 발생했습니다.",
error: {
code: "COLUMN_LABELS_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 테이블 라벨 설정
*/
export async function updateTableLabel(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const { displayName, description } = req.body;
logger.info(`=== 테이블 라벨 설정 시작: ${tableName} ===`);
logger.info(`표시명: ${displayName}, 설명: ${description}`);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
await tableManagementService.updateTableLabel(
tableName,
displayName,
description
);
logger.info(`테이블 라벨 설정 완료: ${tableName}`);
const response: ApiResponse<null> = {
success: true,
message: "테이블 라벨이 성공적으로 설정되었습니다.",
data: null,
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 라벨 설정 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 라벨 설정 중 오류가 발생했습니다.",
error: {
code: "TABLE_LABEL_UPDATE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 컬럼 입력 타입 설정
*/
export async function updateColumnInputType(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, columnName } = req.params;
let { inputType, detailSettings } = req.body;
// 🔥 "direct" 또는 "auto"는 프론트엔드의 입력 방식 구분값이므로
// DB의 input_type(웹타입)으로 저장하면 안 됨 - "text"로 변환
if (inputType === "direct" || inputType === "auto") {
logger.warn(
`잘못된 inputType 값 감지: ${inputType} → 'text'로 변환 (${tableName}.${columnName})`
);
inputType = "text";
}
// 🔥 회사 코드 추출 (JWT에서 또는 DB에서 조회)
let companyCode = req.user?.companyCode;
if (!companyCode && req.user?.userId) {
// JWT에 없으면 DB에서 조회
const { query } = require("../database/db");
const userResult = await query(
`SELECT company_code FROM user_info WHERE user_id = $1`,
[req.user.userId]
);
companyCode = userResult[0]?.company_code;
logger.info(`DB에서 회사 코드 조회: ${req.user.userId}${companyCode}`);
}
logger.info(
`=== 컬럼 입력 타입 설정 시작: ${tableName}.${columnName} = ${inputType}, company: ${companyCode} ===`
);
if (!tableName || !columnName || !inputType) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명, 컬럼명, 입력 타입이 모두 필요합니다.",
error: {
code: "MISSING_PARAMETERS",
details: "필수 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
if (!companyCode) {
logger.error(`회사 코드 누락 (입력 타입): ${tableName}.${columnName}`, {
user: req.user,
hasUser: !!req.user,
userId: req.user?.userId,
companyCodeFromJWT: req.user?.companyCode,
inputType,
});
const response: ApiResponse<null> = {
success: false,
message: "회사 코드를 찾을 수 없습니다.",
error: {
code: "MISSING_COMPANY_CODE",
details:
"사용자 정보에서 회사 코드를 찾을 수 없습니다. 관리자에게 문의하세요.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
await tableManagementService.updateColumnInputType(
tableName,
columnName,
inputType,
companyCode,
detailSettings
);
logger.info(
`컬럼 입력 타입 설정 완료: ${tableName}.${columnName} = ${inputType}, company: ${companyCode}`
);
const response: ApiResponse<null> = {
success: true,
message: "컬럼 입력 타입이 성공적으로 설정되었습니다.",
data: null,
};
res.status(200).json(response);
} catch (error) {
logger.error("컬럼 입력 타입 설정 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "컬럼 입력 타입 설정 중 오류가 발생했습니다.",
error: {
code: "INPUT_TYPE_UPDATE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 단일 레코드 조회 (자동 입력용)
*/
export async function getTableRecord(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const { filterColumn, filterValue, displayColumn } = req.body;
logger.info(`=== 단일 레코드 조회 시작: ${tableName} ===`);
logger.info(`필터: ${filterColumn} = ${filterValue}`);
logger.info(`표시 컬럼: ${displayColumn}`);
if (!tableName || !filterColumn || !filterValue) {
const response: ApiResponse<null> = {
success: false,
message: "필수 파라미터가 누락되었습니다.",
error: {
code: "MISSING_PARAMETERS",
details:
"tableName, filterColumn, filterValue가 필요합니다. displayColumn은 선택적입니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
// 단일 레코드 조회 (WHERE filterColumn = filterValue)
const result = await tableManagementService.getTableData(tableName, {
page: 1,
size: 1,
search: {
[filterColumn]: filterValue,
},
});
if (!result.data || result.data.length === 0) {
const response: ApiResponse<null> = {
success: false,
message: "데이터를 찾을 수 없습니다.",
error: {
code: "NOT_FOUND",
details: `${filterColumn} = ${filterValue}에 해당하는 데이터가 없습니다.`,
},
};
res.status(404).json(response);
return;
}
const record = result.data[0];
// displayColumn이 "*"이거나 없으면 전체 레코드 반환
const displayValue = displayColumn && displayColumn !== "*"
? record[displayColumn]
: record;
logger.info(`레코드 조회 완료: ${displayColumn || "*"} = ${typeof displayValue === 'object' ? '[전체 레코드]' : displayValue}`);
const response: ApiResponse<{ value: any; record: any }> = {
success: true,
message: "레코드를 성공적으로 조회했습니다.",
data: {
value: displayValue,
record: record,
},
};
res.status(200).json(response);
} catch (error) {
logger.error("레코드 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "레코드 조회 중 오류가 발생했습니다.",
error: {
code: "RECORD_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 테이블 데이터 조회 (페이징 + 검색 + 필터링)
*/
export async function getTableData(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const {
page = 1,
size = 10,
search = {},
sortBy,
sortOrder = "asc",
autoFilter, // 🆕 자동 필터 설정 추가 (컴포넌트에서 직접 전달)
dataFilter, // 🆕 컬럼 값 기반 데이터 필터링
} = req.body;
logger.info(`=== 테이블 데이터 조회 시작: ${tableName} ===`);
logger.info(`페이징: page=${page}, size=${size}`);
logger.info(`검색 조건:`, search);
logger.info(`정렬: ${sortBy} ${sortOrder}`);
logger.info(`자동 필터:`, autoFilter); // 🆕
logger.info(`데이터 필터:`, dataFilter); // 🆕
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
// 🆕 현재 사용자 필터 적용 (autoFilter가 없거나 enabled가 명시적으로 false가 아니면 기본 적용)
let enhancedSearch = { ...search };
const shouldApplyAutoFilter = autoFilter?.enabled !== false; // 기본값: true
if (shouldApplyAutoFilter && req.user) {
const filterColumn = autoFilter?.filterColumn || "company_code";
const userField = autoFilter?.userField || "companyCode";
const userValue = (req.user as any)[userField];
// 🆕 프리뷰용 회사 코드 오버라이드 (최고 관리자만 허용)
let finalCompanyCode = userValue;
if (autoFilter?.companyCodeOverride && userValue === "*") {
// 최고 관리자만 다른 회사 코드로 오버라이드 가능
finalCompanyCode = autoFilter.companyCodeOverride;
logger.info("🔓 최고 관리자 회사 코드 오버라이드:", {
originalCompanyCode: userValue,
overrideCompanyCode: autoFilter.companyCodeOverride,
tableName,
});
}
if (finalCompanyCode) {
enhancedSearch[filterColumn] = finalCompanyCode;
logger.info("🔍 현재 사용자 필터 적용:", {
filterColumn,
userField,
userValue: finalCompanyCode,
tableName,
});
} else {
logger.warn("⚠️ 사용자 정보 필드 값 없음:", {
userField,
user: req.user,
});
}
}
// 데이터 조회
const result = await tableManagementService.getTableData(tableName, {
page: parseInt(page),
size: parseInt(size),
search: enhancedSearch, // 🆕 필터가 적용된 search 사용
sortBy,
sortOrder,
dataFilter, // 🆕 데이터 필터 전달
});
logger.info(
`테이블 데이터 조회 완료: ${tableName}, 총 ${result.total}건, 페이지 ${result.page}/${result.totalPages}`
);
const response: ApiResponse<any> = {
success: true,
message: "테이블 데이터를 성공적으로 조회했습니다.",
data: result,
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 데이터 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 데이터 조회 중 오류가 발생했습니다.",
error: {
code: "TABLE_DATA_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 테이블 데이터 추가
*/
export async function addTableData(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const data = req.body;
logger.info(`=== 테이블 데이터 추가 시작: ${tableName} ===`);
logger.info(`추가할 데이터:`, data);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
if (!data || Object.keys(data).length === 0) {
const response: ApiResponse<null> = {
success: false,
message: "추가할 데이터가 필요합니다.",
error: {
code: "MISSING_DATA",
details: "요청 본문에 데이터가 없습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
// 🆕 멀티테넌시: company_code 자동 추가 (테이블에 company_code 컬럼이 있는 경우)
const companyCode = req.user?.companyCode;
if (companyCode && !data.company_code) {
// 테이블에 company_code 컬럼이 있는지 확인
const hasCompanyCodeColumn = await tableManagementService.hasColumn(tableName, "company_code");
if (hasCompanyCodeColumn) {
data.company_code = companyCode;
logger.info(`멀티테넌시: company_code 자동 추가 - ${companyCode}`);
}
}
// 🆕 writer 컬럼 자동 추가 (테이블에 writer 컬럼이 있고 값이 없는 경우)
const userId = req.user?.userId;
if (userId && !data.writer) {
const hasWriterColumn = await tableManagementService.hasColumn(tableName, "writer");
if (hasWriterColumn) {
data.writer = userId;
logger.info(`writer 자동 추가 - ${userId}`);
}
}
// 회사별 NOT NULL 소프트 제약조건 검증
const notNullViolations = await tableManagementService.validateNotNullConstraints(
tableName,
data,
companyCode || "*"
);
if (notNullViolations.length > 0) {
res.status(400).json({
success: false,
message: `필수 항목이 비어있습니다: ${notNullViolations.join(", ")}`,
error: {
code: "NOT_NULL_VIOLATION",
details: notNullViolations,
},
});
return;
}
// 회사별 UNIQUE 소프트 제약조건 검증
const uniqueViolations = await tableManagementService.validateUniqueConstraints(
tableName,
data,
companyCode || "*"
);
if (uniqueViolations.length > 0) {
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
error: {
code: "UNIQUE_VIOLATION",
details: uniqueViolations,
},
});
return;
}
// 데이터 추가
const result = await tableManagementService.addTableData(tableName, data);
logger.info(`테이블 데이터 추가 완료: ${tableName}, id: ${result.insertedId}`);
const systemFields = new Set([
"id", "created_date", "updated_date", "writer", "company_code",
"createdDate", "updatedDate", "companyCode",
]);
const auditData: Record<string, any> = {};
for (const [k, v] of Object.entries(data)) {
if (!systemFields.has(k)) auditData[k] = v;
}
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: auditData },
ipAddress: getClientIp(req),
requestPath: req.originalUrl,
});
const response: ApiResponse<{ id: string | null }> = {
success: true,
message: "테이블 데이터를 성공적으로 추가했습니다.",
data: { id: result.insertedId },
};
res.status(201).json(response);
} catch (error) {
logger.error("테이블 데이터 추가 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 데이터 추가 중 오류가 발생했습니다.",
error: {
code: "TABLE_ADD_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 테이블 데이터 수정
*/
export async function editTableData(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const { originalData, updatedData } = req.body;
logger.info(`=== 테이블 데이터 수정 시작: ${tableName} ===`);
logger.info(`원본 데이터:`, originalData);
logger.info(`수정할 데이터:`, updatedData);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "INVALID_TABLE_NAME",
details: "테이블명이 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
if (!originalData || !updatedData) {
const response: ApiResponse<null> = {
success: false,
message: "원본 데이터와 수정할 데이터가 모두 필요합니다.",
error: {
code: "INVALID_DATA",
details: "originalData와 updatedData가 모두 제공되어야 합니다.",
},
};
res.status(400).json(response);
return;
}
if (Object.keys(updatedData).length === 0) {
const response: ApiResponse<null> = {
success: false,
message: "수정할 데이터가 없습니다.",
error: {
code: "INVALID_DATA",
details: "수정할 데이터가 비어있습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
const companyCode = req.user?.companyCode || "*";
// 회사별 NOT NULL 소프트 제약조건 검증 (수정 데이터 대상)
const notNullViolations = await tableManagementService.validateNotNullConstraints(
tableName,
updatedData,
companyCode
);
if (notNullViolations.length > 0) {
res.status(400).json({
success: false,
message: `필수 항목이 비어있습니다: ${notNullViolations.join(", ")}`,
error: {
code: "NOT_NULL_VIOLATION",
details: notNullViolations,
},
});
return;
}
// 회사별 UNIQUE 소프트 제약조건 검증 (수정 시 자기 자신 제외)
const excludeId = originalData?.id ? String(originalData.id) : undefined;
const uniqueViolations = await tableManagementService.validateUniqueConstraints(
tableName,
updatedData,
companyCode,
excludeId
);
if (uniqueViolations.length > 0) {
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
error: {
code: "UNIQUE_VIOLATION",
details: uniqueViolations,
},
});
return;
}
const systemFieldsForEdit = new Set([
"id", "created_date", "updated_date", "writer", "company_code",
"createdDate", "updatedDate", "companyCode",
]);
const changedBefore: Record<string, any> = {};
const changedAfter: Record<string, any> = {};
for (const key of Object.keys(updatedData)) {
if (systemFieldsForEdit.has(key)) continue;
if (String(originalData[key] ?? "") !== String(updatedData[key] ?? "")) {
changedBefore[key] = originalData[key];
changedAfter[key] = updatedData[key];
}
}
// 데이터 수정
await tableManagementService.editTableData(
tableName,
originalData,
updatedData
);
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: getClientIp(req),
requestPath: req.originalUrl,
});
}
const response: ApiResponse<null> = {
success: true,
message: "테이블 데이터를 성공적으로 수정했습니다.",
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 데이터 수정 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 데이터 수정 중 오류가 발생했습니다.",
error: {
code: "TABLE_EDIT_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 테이블 스키마 정보 조회 (컬럼 존재 여부 검증용)
*/
export async function getTableSchema(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
logger.info(`=== 테이블 스키마 정보 조회 시작: ${tableName} ===`);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
const schema = await tableManagementService.getTableSchema(tableName);
logger.info(
`테이블 스키마 정보 조회 완료: ${tableName}, ${schema.length}개 컬럼`
);
const response: ApiResponse<ColumnTypeInfo[]> = {
success: true,
message: "테이블 스키마 정보를 성공적으로 조회했습니다.",
data: schema,
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 스키마 정보 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 스키마 정보 조회 중 오류가 발생했습니다.",
error: {
code: "TABLE_SCHEMA_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 테이블 존재 여부 확인
*/
export async function checkTableExists(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
logger.info(`=== 테이블 존재 여부 확인 시작: ${tableName} ===`);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
const exists = await tableManagementService.checkTableExists(tableName);
logger.info(`테이블 존재 여부 확인 완료: ${tableName} = ${exists}`);
const response: ApiResponse<{ exists: boolean }> = {
success: true,
message: "테이블 존재 여부를 확인했습니다.",
data: { exists },
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 존재 여부 확인 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 존재 여부 확인 중 오류가 발생했습니다.",
error: {
code: "TABLE_EXISTS_CHECK_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 컬럼 웹타입 정보 조회 (화면관리 연동용)
*/
export async function getColumnWebTypes(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
// 🔥 회사 코드 추출 (JWT에서 또는 DB에서 조회)
let companyCode = req.user?.companyCode;
if (!companyCode && req.user?.userId) {
// JWT에 없으면 DB에서 조회
const { query } = require("../database/db");
const userResult = await query(
`SELECT company_code FROM user_info WHERE user_id = $1`,
[req.user.userId]
);
companyCode = userResult[0]?.company_code;
logger.info(
`DB에서 회사 코드 조회 (조회): ${req.user.userId}${companyCode}`
);
}
logger.info(
`=== 컬럼 웹타입 정보 조회 시작: ${tableName}, company: ${companyCode} ===`
);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
if (!companyCode) {
logger.error(`회사 코드 누락 (조회): ${tableName}`, {
user: req.user,
hasUser: !!req.user,
userId: req.user?.userId,
companyCodeFromJWT: req.user?.companyCode,
});
const response: ApiResponse<null> = {
success: false,
message: "회사 코드를 찾을 수 없습니다.",
error: {
code: "MISSING_COMPANY_CODE",
details:
"사용자 정보에서 회사 코드를 찾을 수 없습니다. 관리자에게 문의하세요.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
const inputTypes = await tableManagementService.getColumnInputTypes(
tableName,
companyCode
);
logger.info(
`컬럼 입력타입 정보 조회 완료: ${tableName}, company: ${companyCode}, ${inputTypes.length}개 컬럼`
);
const response: ApiResponse<ColumnTypeInfo[]> = {
success: true,
message: "컬럼 입력타입 정보를 성공적으로 조회했습니다.",
data: inputTypes,
};
res.status(200).json(response);
} catch (error) {
logger.error("컬럼 웹타입 정보 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "컬럼 웹타입 정보 조회 중 오류가 발생했습니다.",
error: {
code: "COLUMN_WEB_TYPES_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 데이터베이스 연결 상태 확인
*/
export async function checkDatabaseConnection(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
logger.info("=== 데이터베이스 연결 상태 확인 시작 ===");
const tableManagementService = new TableManagementService();
const connectionStatus =
await tableManagementService.checkDatabaseConnection();
logger.info(
`데이터베이스 연결 상태: ${connectionStatus.connected ? "연결됨" : "연결 안됨"}`
);
const response: ApiResponse<{ connected: boolean; message: string }> = {
success: true,
message: "데이터베이스 연결 상태를 확인했습니다.",
data: connectionStatus,
};
res.status(200).json(response);
} catch (error) {
logger.error("데이터베이스 연결 상태 확인 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "데이터베이스 연결 상태 확인 중 오류가 발생했습니다.",
error: {
code: "DATABASE_CONNECTION_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 테이블 데이터 삭제
*/
export async function deleteTableData(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const data = req.body;
logger.info(`=== 테이블 데이터 삭제 시작: ${tableName} ===`);
logger.info(`삭제할 데이터:`, data);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
if (!data || (Array.isArray(data) && data.length === 0)) {
const response: ApiResponse<null> = {
success: false,
message: "삭제할 데이터가 필요합니다.",
error: {
code: "MISSING_DATA",
details: "요청 본문에 삭제할 데이터가 없습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
// 데이터 삭제
const deletedCount = await tableManagementService.deleteTableData(
tableName,
data
);
logger.info(
`테이블 데이터 삭제 완료: ${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: getClientIp(req),
requestPath: req.originalUrl,
});
const response: ApiResponse<{ deletedCount: number }> = {
success: true,
message: `테이블 데이터를 성공적으로 삭제했습니다. (${deletedCount}건)`,
data: { deletedCount },
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 데이터 삭제 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 데이터 삭제 중 오류가 발생했습니다.",
error: {
code: "TABLE_DELETE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 컬럼 웹 타입 설정 (레거시 지원)
* @deprecated updateColumnInputType 사용 권장
*/
export async function updateColumnWebType(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, columnName } = req.params;
const { webType, detailSettings, inputType } = req.body;
logger.warn(
`레거시 API 사용: updateColumnWebType → updateColumnInputType 사용 권장`
);
// 🔥 inputType이 "direct" 또는 "auto"이면 무시하고 webType 사용
// "direct"/"auto"는 프론트엔드의 입력 방식(직접입력/자동입력) 구분값이지
// DB에 저장할 웹 타입(text, number, date 등)이 아님
let convertedInputType = webType || "text";
if (inputType && inputType !== "direct" && inputType !== "auto") {
convertedInputType = inputType;
}
logger.info(
`웹타입 변환: webType=${webType}, inputType=${inputType}${convertedInputType}`
);
// 새로운 메서드 호출
req.body = { inputType: convertedInputType, detailSettings };
await updateColumnInputType(req, res);
} catch (error) {
logger.error("레거시 컬럼 웹 타입 설정 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "컬럼 웹 타입 설정 중 오류가 발생했습니다.",
error: {
code: "WEB_TYPE_UPDATE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
// ========================================
// 🎯 테이블 로그 시스템 API
// ========================================
/**
* 로그 테이블 생성
*/
export async function createLogTable(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const { pkColumn } = req.body;
const userId = req.user?.userId;
logger.info(`=== 로그 테이블 생성 시작: ${tableName} ===`);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
if (!pkColumn || !pkColumn.columnName || !pkColumn.dataType) {
const response: ApiResponse<null> = {
success: false,
message: "PK 컬럼 정보가 필요합니다.",
error: {
code: "MISSING_PK_COLUMN",
details: "PK 컬럼명과 데이터 타입이 필요합니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
await tableManagementService.createLogTable(tableName, pkColumn, userId);
logger.info(`로그 테이블 생성 완료: ${tableName}_log`);
const response: ApiResponse<null> = {
success: true,
message: "로그 테이블이 성공적으로 생성되었습니다.",
};
res.status(200).json(response);
} catch (error) {
logger.error("로그 테이블 생성 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "로그 테이블 생성 중 오류가 발생했습니다.",
error: {
code: "LOG_TABLE_CREATE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 로그 설정 조회
*/
export async function getLogConfig(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
logger.info(`=== 로그 설정 조회: ${tableName} ===`);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
const logConfig = await tableManagementService.getLogConfig(tableName);
const response: ApiResponse<typeof logConfig> = {
success: true,
message: "로그 설정을 조회했습니다.",
data: logConfig,
};
res.status(200).json(response);
} catch (error) {
logger.error("로그 설정 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "로그 설정 조회 중 오류가 발생했습니다.",
error: {
code: "LOG_CONFIG_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 로그 데이터 조회
*/
export async function getLogData(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const {
page = 1,
size = 20,
operationType,
startDate,
endDate,
changedBy,
originalId,
} = req.query;
logger.info(`=== 로그 데이터 조회: ${tableName} ===`);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
const result = await tableManagementService.getLogData(tableName, {
page: parseInt(page as string),
size: parseInt(size as string),
operationType: operationType as string,
startDate: startDate as string,
endDate: endDate as string,
changedBy: changedBy as string,
originalId: originalId as string,
});
logger.info(`로그 데이터 조회 완료: ${tableName}_log, ${result.total}`);
const response: ApiResponse<typeof result> = {
success: true,
message: "로그 데이터를 조회했습니다.",
data: result,
};
res.status(200).json(response);
} catch (error) {
logger.error("로그 데이터 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "로그 데이터 조회 중 오류가 발생했습니다.",
error: {
code: "LOG_DATA_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 로그 테이블 활성화/비활성화
*/
export async function toggleLogTable(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const { isActive } = req.body;
logger.info(
`=== 로그 테이블 토글: ${tableName}, isActive: ${isActive} ===`
);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "테이블명이 필요합니다.",
error: {
code: "MISSING_TABLE_NAME",
details: "테이블명 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
if (isActive === undefined || isActive === null) {
const response: ApiResponse<null> = {
success: false,
message: "isActive 값이 필요합니다.",
error: {
code: "MISSING_IS_ACTIVE",
details: "isActive 파라미터가 누락되었습니다.",
},
};
res.status(400).json(response);
return;
}
const tableManagementService = new TableManagementService();
await tableManagementService.toggleLogTable(
tableName,
isActive === "Y" || isActive === true
);
logger.info(`로그 테이블 토글 완료: ${tableName}, isActive: ${isActive}`);
const response: ApiResponse<null> = {
success: true,
message: `로그 기능이 ${isActive ? "활성화" : "비활성화"}되었습니다.`,
};
res.status(200).json(response);
} catch (error) {
logger.error("로그 테이블 토글 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "로그 테이블 토글 중 오류가 발생했습니다.",
error: {
code: "LOG_TOGGLE_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 회사별 카테고리 컬럼 조회 (메뉴 종속 없음)
*
* @route GET /api/table-management/category-columns
* @description table_type_columns에서 회사 코드 기준으로 input_type = 'category'인 컬럼을 조회
*/
export async function getCategoryColumnsByCompany(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user?.companyCode;
logger.info("📥 회사별 카테고리 컬럼 조회 요청", { companyCode });
if (!companyCode) {
logger.error("❌ 회사 코드가 없습니다", { user: req.user });
res.status(400).json({
success: false,
message: "회사 코드를 확인할 수 없습니다. 다시 로그인해주세요.",
});
return;
}
const { getPool } = await import("../database/db");
const pool = getPool();
let columnsResult;
// 최고 관리자인 경우 company_code = '*'인 카테고리 컬럼 조회
// category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함)
if (companyCode === "*") {
const columnsQuery = `
SELECT DISTINCT
ttc.table_name AS "tableName",
COALESCE(
tl.table_label,
initcap(replace(ttc.table_name, '_', ' '))
) AS "tableLabel",
ttc.column_name AS "columnName",
COALESCE(
ttc.column_label,
initcap(replace(ttc.column_name, '_', ' '))
) AS "columnLabel",
ttc.input_type AS "inputType"
FROM table_type_columns ttc
LEFT JOIN table_labels tl
ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category'
AND ttc.company_code = '*'
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
ORDER BY ttc.table_name, ttc.column_name
`;
columnsResult = await pool.query(columnsQuery);
logger.info("최고 관리자: 전체 카테고리 컬럼 조회 완료 (참조 제외)", {
rowCount: columnsResult.rows.length
});
} else {
const columnsQuery = `
SELECT DISTINCT
ttc.table_name AS "tableName",
COALESCE(
tl.table_label,
initcap(replace(ttc.table_name, '_', ' '))
) AS "tableLabel",
ttc.column_name AS "columnName",
COALESCE(
ttc.column_label,
initcap(replace(ttc.column_name, '_', ' '))
) AS "columnLabel",
ttc.input_type AS "inputType"
FROM table_type_columns ttc
LEFT JOIN table_labels tl
ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category'
AND ttc.company_code = $1
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
ORDER BY ttc.table_name, ttc.column_name
`;
columnsResult = await pool.query(columnsQuery, [companyCode]);
logger.info("회사별 카테고리 컬럼 조회 완료 (참조 제외)", {
companyCode,
rowCount: columnsResult.rows.length
});
}
res.json({
success: true,
data: columnsResult.rows,
message: "카테고리 컬럼 조회 성공",
});
} catch (error: any) {
logger.error("❌ 회사별 카테고리 컬럼 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "카테고리 컬럼 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* 메뉴의 상위 메뉴들이 설정한 모든 카테고리 타입 컬럼 조회 (계층 구조 상속)
*
* @route GET /api/table-management/menu/:menuObjid/category-columns
* @description 현재 메뉴와 상위 메뉴들에서 설정한 category_column_mapping의 모든 카테고리 컬럼 조회
*
* 예시:
* - 2레벨 메뉴 "고객사관리"에서 discount_type, rounding_type 설정
* - 3레벨 메뉴 "고객등록", "고객조회" 등에서도 동일하게 보임 (상속)
*/
export async function getCategoryColumnsByMenu(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { menuObjid } = req.params;
const companyCode = req.user?.companyCode;
logger.info("📥 메뉴별 카테고리 컬럼 조회 요청", { menuObjid, companyCode });
if (!menuObjid) {
res.status(400).json({
success: false,
message: "메뉴 OBJID가 필요합니다.",
});
return;
}
if (!companyCode) {
logger.error("❌ 회사 코드가 없습니다", { menuObjid, user: req.user });
res.status(400).json({
success: false,
message: "회사 코드를 확인할 수 없습니다. 다시 로그인해주세요.",
});
return;
}
const { getPool } = await import("../database/db");
const pool = getPool();
// table_type_columns에서 input_type = 'category' 컬럼 조회
// category_ref가 설정된 컬럼은 제외 (참조 컬럼은 자체 값 관리 안 함)
let columnsResult;
if (companyCode === "*") {
const columnsQuery = `
SELECT DISTINCT
ttc.table_name AS "tableName",
COALESCE(
tl.table_label,
initcap(replace(ttc.table_name, '_', ' '))
) AS "tableLabel",
ttc.column_name AS "columnName",
COALESCE(
ttc.column_label,
initcap(replace(ttc.column_name, '_', ' '))
) AS "columnLabel",
ttc.input_type AS "inputType"
FROM table_type_columns ttc
LEFT JOIN table_labels tl
ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category'
AND ttc.company_code = '*'
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
ORDER BY ttc.table_name, ttc.column_name
`;
columnsResult = await pool.query(columnsQuery);
logger.info("최고 관리자: 메뉴별 카테고리 컬럼 조회 완료 (참조 제외)", {
rowCount: columnsResult.rows.length
});
} else {
const columnsQuery = `
SELECT DISTINCT
ttc.table_name AS "tableName",
COALESCE(
tl.table_label,
initcap(replace(ttc.table_name, '_', ' '))
) AS "tableLabel",
ttc.column_name AS "columnName",
COALESCE(
ttc.column_label,
initcap(replace(ttc.column_name, '_', ' '))
) AS "columnLabel",
ttc.input_type AS "inputType"
FROM table_type_columns ttc
LEFT JOIN table_labels tl
ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'category'
AND ttc.company_code = $1
AND (ttc.category_ref IS NULL OR ttc.category_ref = '')
ORDER BY ttc.table_name, ttc.column_name
`;
columnsResult = await pool.query(columnsQuery, [companyCode]);
logger.info("회사별 메뉴 카테고리 컬럼 조회 완료 (참조 제외)", {
companyCode,
rowCount: columnsResult.rows.length
});
}
logger.info("✅ 카테고리 컬럼 조회 완료", {
columnCount: columnsResult.rows.length
});
res.json({
success: true,
data: columnsResult.rows,
message: "카테고리 컬럼 조회 성공",
});
} catch (error: any) {
logger.error("❌ 메뉴별 카테고리 컬럼 조회 실패");
logger.error("에러 메시지:", error.message);
logger.error("에러 스택:", error.stack);
logger.error("에러 전체:", error);
res.status(500).json({
success: false,
message: "카테고리 컬럼 조회에 실패했습니다.",
error: error.message,
stack: error.stack, // 디버깅용
});
}
}
/**
* 범용 다중 테이블 저장 API
*
* 메인 테이블과 서브 테이블(들)에 트랜잭션으로 데이터를 저장합니다.
*
* 요청 본문:
* {
* mainTable: { tableName: string, primaryKeyColumn: string },
* mainData: Record<string, any>,
* subTables: Array<{
* tableName: string,
* linkColumn: { mainField: string, subColumn: string },
* items: Record<string, any>[],
* options?: {
* saveMainAsFirst?: boolean,
* mainFieldMappings?: Array<{ formField: string, targetColumn: string }>,
* mainMarkerColumn?: string,
* mainMarkerValue?: any,
* subMarkerValue?: any,
* deleteExistingBefore?: boolean,
* }
* }>,
* isUpdate?: boolean
* }
*/
export async function multiTableSave(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
const pool = require("../database/db").getPool();
const client = await pool.connect();
try {
const { mainTable, mainData, subTables, isUpdate } = req.body;
const companyCode = req.user?.companyCode || "*";
logger.info("=== 다중 테이블 저장 시작 ===", {
mainTable,
mainDataKeys: Object.keys(mainData || {}),
subTablesCount: subTables?.length || 0,
isUpdate,
companyCode,
});
// 유효성 검사
if (!mainTable?.tableName || !mainTable?.primaryKeyColumn) {
res.status(400).json({
success: false,
message: "메인 테이블 설정이 올바르지 않습니다.",
});
return;
}
if (!mainData || Object.keys(mainData).length === 0) {
res.status(400).json({
success: false,
message: "저장할 메인 데이터가 없습니다.",
});
return;
}
// UNIQUE 제약조건 검증 (트랜잭션 전에)
const tmsMulti = new TableManagementService();
const uniqueViolations = await tmsMulti.validateUniqueConstraints(
mainTable.tableName,
mainData,
companyCode,
isUpdate ? mainData[mainTable.primaryKeyColumn] : undefined
);
if (uniqueViolations.length > 0) {
client.release();
res.status(400).json({
success: false,
message: `중복된 값이 존재합니다: ${uniqueViolations.join(", ")}`,
});
return;
}
await client.query("BEGIN");
// 1. 메인 테이블 저장
const mainTableName = mainTable.tableName;
const pkColumn = mainTable.primaryKeyColumn;
const pkValue = mainData[pkColumn];
// company_code 자동 추가 (최고 관리자가 아닌 경우)
if (companyCode !== "*" && !mainData.company_code) {
mainData.company_code = companyCode;
}
let mainResult: any;
if (isUpdate && pkValue) {
// UPDATE
const updateColumns = Object.keys(mainData)
.filter(col => col !== pkColumn)
.map((col, idx) => `"${col}" = $${idx + 1}`)
.join(", ");
const updateValues = Object.keys(mainData)
.filter(col => col !== pkColumn)
.map(col => mainData[col]);
// updated_at 컬럼 존재 여부 확인
const hasUpdatedAt = await client.query(`
SELECT 1 FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'updated_at'
`, [mainTableName]);
const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : "";
const updateQuery = `
UPDATE "${mainTableName}"
SET ${updateColumns}${updatedAtClause}
WHERE "${pkColumn}" = $${updateValues.length + 1}
${companyCode !== "*" ? `AND company_code = $${updateValues.length + 2}` : ""}
RETURNING *
`;
const updateParams = companyCode !== "*"
? [...updateValues, pkValue, companyCode]
: [...updateValues, pkValue];
logger.info("메인 테이블 UPDATE:", { query: updateQuery, paramsCount: updateParams.length });
mainResult = await client.query(updateQuery, updateParams);
} else {
// INSERT
const columns = Object.keys(mainData).map(col => `"${col}"`).join(", ");
const placeholders = Object.keys(mainData).map((_, idx) => `$${idx + 1}`).join(", ");
const values = Object.values(mainData);
// updated_at 컬럼 존재 여부 확인
const hasUpdatedAt = await client.query(`
SELECT 1 FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'updated_at'
`, [mainTableName]);
const updatedAtClause = hasUpdatedAt.rowCount && hasUpdatedAt.rowCount > 0 ? ", updated_at = NOW()" : "";
const updateSetClause = Object.keys(mainData)
.filter(col => col !== pkColumn)
.map(col => `"${col}" = EXCLUDED."${col}"`)
.join(", ");
const insertQuery = `
INSERT INTO "${mainTableName}" (${columns})
VALUES (${placeholders})
ON CONFLICT ("${pkColumn}") DO UPDATE SET
${updateSetClause}${updatedAtClause}
RETURNING *
`;
logger.info("메인 테이블 INSERT/UPSERT:", { query: insertQuery, paramsCount: values.length });
mainResult = await client.query(insertQuery, values);
}
if (mainResult.rowCount === 0) {
throw new Error("메인 테이블 저장 실패");
}
const savedMainData = mainResult.rows[0];
const savedPkValue = savedMainData[pkColumn];
logger.info("메인 테이블 저장 완료:", { pkColumn, savedPkValue });
// 2. 서브 테이블 저장
const subTableResults: any[] = [];
for (const subTableConfig of subTables || []) {
const { tableName, linkColumn, items, options } = subTableConfig;
// saveMainAsFirst가 활성화된 경우, items가 비어있어도 메인 데이터를 서브 테이블에 저장해야 함
const hasSaveMainAsFirst = options?.saveMainAsFirst &&
options?.mainFieldMappings &&
options.mainFieldMappings.length > 0;
if (!tableName || (!items?.length && !hasSaveMainAsFirst)) {
logger.info(`서브 테이블 ${tableName} 스킵: 데이터 없음 (saveMainAsFirst: ${hasSaveMainAsFirst})`);
continue;
}
logger.info(`서브 테이블 ${tableName} 저장 시작:`, {
itemsCount: items?.length || 0,
linkColumn,
options,
hasSaveMainAsFirst,
});
// 기존 데이터 삭제 옵션
if (options?.deleteExistingBefore && linkColumn?.subColumn) {
const deleteQuery = options?.deleteOnlySubItems && options?.mainMarkerColumn
? `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1 AND "${options.mainMarkerColumn}" = $2`
: `DELETE FROM "${tableName}" WHERE "${linkColumn.subColumn}" = $1`;
const deleteParams = options?.deleteOnlySubItems && options?.mainMarkerColumn
? [savedPkValue, options.subMarkerValue ?? false]
: [savedPkValue];
logger.info(`서브 테이블 ${tableName} 기존 데이터 삭제:`, { deleteQuery, deleteParams });
await client.query(deleteQuery, deleteParams);
}
// 메인 데이터도 서브 테이블에 저장 (옵션)
// mainFieldMappings가 비어 있으면 건너뜀 (필수 컬럼 누락 방지)
logger.info(`saveMainAsFirst 옵션 확인:`, {
saveMainAsFirst: options?.saveMainAsFirst,
mainFieldMappings: options?.mainFieldMappings,
mainFieldMappingsLength: options?.mainFieldMappings?.length,
linkColumn,
mainDataKeys: Object.keys(mainData),
});
if (options?.saveMainAsFirst && options?.mainFieldMappings && options.mainFieldMappings.length > 0 && linkColumn?.subColumn) {
const mainSubItem: Record<string, any> = {
[linkColumn.subColumn]: savedPkValue,
};
// 메인 필드 매핑 적용
for (const mapping of options.mainFieldMappings) {
if (mapping.formField && mapping.targetColumn) {
mainSubItem[mapping.targetColumn] = mainData[mapping.formField];
}
}
// 메인 마커 설정
if (options.mainMarkerColumn) {
mainSubItem[options.mainMarkerColumn] = options.mainMarkerValue ?? true;
}
// company_code 추가
if (companyCode !== "*") {
mainSubItem.company_code = companyCode;
}
// 먼저 기존 데이터 존재 여부 확인 (user_id + is_primary 조합)
const checkQuery = `
SELECT * FROM "${tableName}"
WHERE "${linkColumn.subColumn}" = $1
${options.mainMarkerColumn ? `AND "${options.mainMarkerColumn}" = $2` : ""}
${companyCode !== "*" ? `AND company_code = $${options.mainMarkerColumn ? 3 : 2}` : ""}
LIMIT 1
`;
const checkParams: any[] = [savedPkValue];
if (options.mainMarkerColumn) {
checkParams.push(options.mainMarkerValue ?? true);
}
if (companyCode !== "*") {
checkParams.push(companyCode);
}
const existingResult = await client.query(checkQuery, checkParams);
if (existingResult.rows.length > 0) {
// UPDATE
const updateColumns = Object.keys(mainSubItem)
.filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code")
.map((col, idx) => `"${col}" = $${idx + 1}`)
.join(", ");
const updateValues = Object.keys(mainSubItem)
.filter(col => col !== linkColumn.subColumn && col !== options.mainMarkerColumn && col !== "company_code")
.map(col => mainSubItem[col]);
if (updateColumns) {
const updateQuery = `
UPDATE "${tableName}"
SET ${updateColumns}
WHERE "${linkColumn.subColumn}" = $${updateValues.length + 1}
${options.mainMarkerColumn ? `AND "${options.mainMarkerColumn}" = $${updateValues.length + 2}` : ""}
${companyCode !== "*" ? `AND company_code = $${updateValues.length + (options.mainMarkerColumn ? 3 : 2)}` : ""}
RETURNING *
`;
const updateParams = [...updateValues, savedPkValue];
if (options.mainMarkerColumn) {
updateParams.push(options.mainMarkerValue ?? true);
}
if (companyCode !== "*") {
updateParams.push(companyCode);
}
const updateResult = await client.query(updateQuery, updateParams);
subTableResults.push({ tableName, type: "main", data: updateResult.rows[0] });
} else {
subTableResults.push({ tableName, type: "main", data: existingResult.rows[0] });
}
} else {
// INSERT
const mainSubColumns = Object.keys(mainSubItem).map(col => `"${col}"`).join(", ");
const mainSubPlaceholders = Object.keys(mainSubItem).map((_, idx) => `$${idx + 1}`).join(", ");
const mainSubValues = Object.values(mainSubItem);
const insertQuery = `
INSERT INTO "${tableName}" (${mainSubColumns})
VALUES (${mainSubPlaceholders})
RETURNING *
`;
const insertResult = await client.query(insertQuery, mainSubValues);
subTableResults.push({ tableName, type: "main", data: insertResult.rows[0] });
}
}
// 서브 아이템들 저장
for (const item of items) {
// 연결 컬럼 값 설정
if (linkColumn?.subColumn) {
item[linkColumn.subColumn] = savedPkValue;
}
// company_code 추가
if (companyCode !== "*" && !item.company_code) {
item.company_code = companyCode;
}
const subColumns = Object.keys(item).map(col => `"${col}"`).join(", ");
const subPlaceholders = Object.keys(item).map((_, idx) => `$${idx + 1}`).join(", ");
const subValues = Object.values(item);
const subInsertQuery = `
INSERT INTO "${tableName}" (${subColumns})
VALUES (${subPlaceholders})
RETURNING *
`;
logger.info(`서브 테이블 ${tableName} 아이템 저장:`, { subInsertQuery, subValuesCount: subValues.length });
const subResult = await client.query(subInsertQuery, subValues);
subTableResults.push({ tableName, type: "sub", data: subResult.rows[0] });
}
logger.info(`서브 테이블 ${tableName} 저장 완료`);
}
await client.query("COMMIT");
logger.info("=== 다중 테이블 저장 완료 ===", {
mainTable: mainTableName,
mainPk: savedPkValue,
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: getClientIp(req),
requestPath: req.originalUrl,
});
res.json({
success: true,
message: "다중 테이블 저장이 완료되었습니다.",
data: {
main: savedMainData,
subTables: subTableResults,
},
});
} catch (error: any) {
await client.query("ROLLBACK");
logger.error("다중 테이블 저장 실패:", {
message: error.message,
stack: error.stack,
});
res.status(500).json({
success: false,
message: error.message || "다중 테이블 저장에 실패했습니다.",
error: error.message,
});
} finally {
client.release();
}
}
/**
* 두 테이블 간 엔티티 관계 조회
* table_type_columns의 entity/category 타입 설정을 기반으로 두 테이블 간의 관계를 조회
*/
export async function getTableEntityRelations(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { leftTable, rightTable } = req.query;
if (!leftTable || !rightTable) {
res.status(400).json({
success: false,
message: "leftTable과 rightTable 파라미터가 필요합니다.",
});
return;
}
logger.info("=== 테이블 엔티티 관계 조회 ===", { leftTable, rightTable });
// 두 테이블의 컬럼 라벨 정보 조회
const columnLabelsQuery = `
SELECT
table_name,
column_name,
column_label,
input_type as web_type,
detail_settings
FROM table_type_columns
WHERE table_name IN ($1, $2)
AND input_type IN ('entity', 'category')
AND company_code = '*'
`;
const result = await query(columnLabelsQuery, [leftTable, rightTable]);
// 관계 분석
const relations: Array<{
fromTable: string;
fromColumn: string;
toTable: string;
toColumn: string;
relationType: string;
}> = [];
for (const row of result) {
try {
const detailSettings = typeof row.detail_settings === "string"
? JSON.parse(row.detail_settings)
: row.detail_settings;
if (detailSettings && detailSettings.referenceTable) {
const refTable = detailSettings.referenceTable;
const refColumn = detailSettings.referenceColumn || "id";
// leftTable과 rightTable 간의 관계인지 확인
if (
(row.table_name === leftTable && refTable === rightTable) ||
(row.table_name === rightTable && refTable === leftTable)
) {
relations.push({
fromTable: row.table_name,
fromColumn: row.column_name,
toTable: refTable,
toColumn: refColumn,
relationType: row.web_type,
});
}
}
} catch (parseError) {
logger.warn("detail_settings 파싱 오류:", {
table: row.table_name,
column: row.column_name,
error: parseError
});
}
}
logger.info("테이블 엔티티 관계 조회 완료", {
leftTable,
rightTable,
relationsCount: relations.length
});
res.json({
success: true,
data: {
leftTable,
rightTable,
relations,
},
});
} catch (error: any) {
logger.error("테이블 엔티티 관계 조회 실패:", error);
res.status(500).json({
success: false,
message: "테이블 엔티티 관계 조회에 실패했습니다.",
error: error.message,
});
}
}
/**
* 현재 테이블을 참조(FK로 연결)하는 테이블 목록 조회
* GET /api/table-management/columns/:tableName/referenced-by
*
* table_type_columns에서 reference_table이 현재 테이블인 레코드를 찾아서
* 해당 테이블과 FK 컬럼 정보를 반환합니다.
*
* 우선순위: 현재 사용자의 company_code > 공통('*')
*/
export async function getReferencedByTables(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
// 현재 사용자의 회사 코드 (없으면 '*' 사용)
const userCompanyCode = req.user?.companyCode || "*";
logger.info(
`=== 테이블 참조 관계 조회 시작: ${tableName} 을 참조하는 테이블 (회사코드: ${userCompanyCode}) ===`
);
if (!tableName) {
const response: ApiResponse<null> = {
success: false,
message: "tableName 파라미터가 필요합니다.",
error: {
code: "MISSING_PARAMETERS",
details: "tableName 경로 파라미터가 필요합니다.",
},
};
res.status(400).json(response);
return;
}
// table_type_columns에서 reference_table이 현재 테이블인 레코드 조회
// input_type이 'entity'인 것만 조회 (실제 FK 관계)
// 우선순위: 현재 사용자의 company_code > 공통('*')
// ROW_NUMBER를 사용해서 같은 테이블/컬럼 조합에서 회사코드 우선순위로 하나만 선택
const sqlQuery = `
WITH ranked AS (
SELECT
ttc.table_name,
ttc.column_name,
ttc.column_label,
ttc.reference_table,
ttc.reference_column,
ttc.display_column,
ttc.company_code,
ROW_NUMBER() OVER (
PARTITION BY ttc.table_name, ttc.column_name
ORDER BY CASE WHEN ttc.company_code = $2 THEN 1 ELSE 2 END
) as rn
FROM table_type_columns ttc
WHERE ttc.reference_table = $1
AND ttc.input_type = 'entity'
AND ttc.company_code IN ($2, '*')
)
SELECT DISTINCT
table_name,
column_name,
column_label,
reference_table,
reference_column,
display_column,
table_name as table_label
FROM ranked
WHERE rn = 1
ORDER BY table_name, column_name
`;
const result = await query(sqlQuery, [tableName, userCompanyCode]);
const referencedByTables = result.map((row: any) => ({
tableName: row.table_name,
tableLabel: row.table_label,
columnName: row.column_name,
columnLabel: row.column_label,
referenceTable: row.reference_table,
referenceColumn: row.reference_column || "id",
displayColumn: row.display_column,
}));
logger.info(
`테이블 참조 관계 조회 완료: ${referencedByTables.length}개 발견 (회사코드: ${userCompanyCode})`
);
const response: ApiResponse<any> = {
success: true,
message: `${referencedByTables.length}개의 테이블이 ${tableName}을 참조합니다.`,
data: referencedByTables,
};
res.status(200).json(response);
} catch (error) {
logger.error("테이블 참조 관계 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "테이블 참조 관계 조회 중 오류가 발생했습니다.",
error: {
code: "REFERENCED_BY_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
// ========================================
// PK / 인덱스 관리 API
// ========================================
/**
* PK/인덱스 상태 조회
* GET /api/table-management/tables/:tableName/constraints
*/
export async function getTableConstraints(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
if (!tableName) {
res.status(400).json({ success: false, message: "테이블명이 필요합니다." });
return;
}
// PK 조회
const pkResult = await query<any>(
`SELECT tc.conname AS constraint_name,
array_agg(a.attname ORDER BY x.n) AS columns
FROM pg_constraint tc
JOIN pg_class c ON tc.conrelid = c.oid
JOIN pg_namespace ns ON c.relnamespace = ns.oid
CROSS JOIN LATERAL unnest(tc.conkey) WITH ORDINALITY AS x(attnum, n)
JOIN pg_attribute a ON a.attrelid = tc.conrelid AND a.attnum = x.attnum
WHERE ns.nspname = 'public' AND c.relname = $1 AND tc.contype = 'p'
GROUP BY tc.conname`,
[tableName]
);
// array_agg 결과가 문자열로 올 수 있으므로 안전하게 배열로 변환
const parseColumns = (cols: any): string[] => {
if (Array.isArray(cols)) return cols;
if (typeof cols === "string") {
// PostgreSQL 배열 형식: {col1,col2}
return cols.replace(/[{}]/g, "").split(",").filter(Boolean);
}
return [];
};
const primaryKey = pkResult.length > 0
? { name: pkResult[0].constraint_name, columns: parseColumns(pkResult[0].columns) }
: { name: "", columns: [] };
// 인덱스 조회 (PK 인덱스 제외)
const indexResult = await query<any>(
`SELECT i.relname AS index_name,
ix.indisunique AS is_unique,
array_agg(a.attname ORDER BY x.n) AS columns
FROM pg_index ix
JOIN pg_class t ON ix.indrelid = t.oid
JOIN pg_class i ON ix.indexrelid = i.oid
JOIN pg_namespace ns ON t.relnamespace = ns.oid
CROSS JOIN LATERAL unnest(ix.indkey) WITH ORDINALITY AS x(attnum, n)
JOIN pg_attribute a ON a.attrelid = t.oid AND a.attnum = x.attnum
WHERE ns.nspname = 'public' AND t.relname = $1
AND ix.indisprimary = false
GROUP BY i.relname, ix.indisunique
ORDER BY i.relname`,
[tableName]
);
const indexes = indexResult.map((row: any) => ({
name: row.index_name,
columns: parseColumns(row.columns),
isUnique: row.is_unique,
}));
logger.info(`제약조건 조회: ${tableName} - PK: ${primaryKey.columns.join(",")}, 인덱스: ${indexes.length}`);
res.status(200).json({
success: true,
data: { primaryKey, indexes },
});
} catch (error) {
logger.error("제약조건 조회 오류:", error);
res.status(500).json({
success: false,
message: "제약조건 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* PK 설정
* PUT /api/table-management/tables/:tableName/primary-key
*/
export async function setTablePrimaryKey(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const { columns } = req.body;
if (!tableName || !columns || !Array.isArray(columns) || columns.length === 0) {
res.status(400).json({ success: false, message: "테이블명과 PK 컬럼 배열이 필요합니다." });
return;
}
logger.info(`PK 설정: ${tableName} → [${columns.join(", ")}]`);
// 기존 PK 제약조건 이름 조회
const existingPk = await query<any>(
`SELECT conname FROM pg_constraint tc
JOIN pg_class c ON tc.conrelid = c.oid
JOIN pg_namespace ns ON c.relnamespace = ns.oid
WHERE ns.nspname = 'public' AND c.relname = $1 AND tc.contype = 'p'`,
[tableName]
);
// 기존 PK 삭제
if (existingPk.length > 0) {
const dropSql = `ALTER TABLE "public"."${tableName}" DROP CONSTRAINT "${existingPk[0].conname}"`;
logger.info(`기존 PK 삭제: ${dropSql}`);
await query(dropSql);
}
// 새 PK 추가
const colList = columns.map((c: string) => `"${c}"`).join(", ");
const addSql = `ALTER TABLE "public"."${tableName}" ADD PRIMARY KEY (${colList})`;
logger.info(`새 PK 추가: ${addSql}`);
await query(addSql);
res.status(200).json({
success: true,
message: `PK가 설정되었습니다: ${columns.join(", ")}`,
});
} catch (error) {
logger.error("PK 설정 오류:", error);
res.status(500).json({
success: false,
message: "PK 설정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 인덱스 토글 (생성/삭제)
* POST /api/table-management/tables/:tableName/indexes
*/
export async function toggleTableIndex(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const { columnName, indexType, action } = req.body;
if (!tableName || !columnName || !indexType || !action) {
res.status(400).json({
success: false,
message: "tableName, columnName, indexType(index|unique), action(create|drop)이 필요합니다.",
});
return;
}
const indexName = `idx_${tableName}_${columnName}${indexType === "unique" ? "_uq" : ""}`;
logger.info(`인덱스 ${action}: ${indexName} (${indexType})`);
if (action === "create") {
let indexColumns = `"${columnName}"`;
// 유니크 인덱스: company_code 컬럼이 있으면 복합 유니크 (회사별 유니크 보장)
if (indexType === "unique") {
const hasCompanyCode = await query(
`SELECT 1 FROM information_schema.columns WHERE table_schema = 'public' AND table_name = $1 AND column_name = 'company_code'`,
[tableName]
);
if (hasCompanyCode.length > 0) {
indexColumns = `"company_code", "${columnName}"`;
logger.info(`멀티테넌시: company_code + ${columnName} 복합 유니크 인덱스 생성`);
}
}
const uniqueClause = indexType === "unique" ? "UNIQUE " : "";
const sql = `CREATE ${uniqueClause}INDEX IF NOT EXISTS "${indexName}" ON "public"."${tableName}" (${indexColumns})`;
logger.info(`인덱스 생성: ${sql}`);
await query(sql);
} else if (action === "drop") {
const sql = `DROP INDEX IF EXISTS "public"."${indexName}"`;
logger.info(`인덱스 삭제: ${sql}`);
await query(sql);
} else {
res.status(400).json({ success: false, message: "action은 create 또는 drop이어야 합니다." });
return;
}
res.status(200).json({
success: true,
message: action === "create"
? `인덱스가 생성되었습니다: ${indexName}`
: `인덱스가 삭제되었습니다: ${indexName}`,
});
} catch (error: any) {
logger.error("인덱스 토글 오류:", error);
const errMsg = error.message || "";
let userMessage = "인덱스 설정 중 오류가 발생했습니다.";
let duplicates: any[] = [];
// 중복 데이터로 인한 UNIQUE 인덱스 생성 실패
if (
errMsg.includes("could not create unique index") ||
errMsg.includes("duplicate key")
) {
const { columnName, tableName } = { ...req.params, ...req.body };
try {
duplicates = await query(
`SELECT company_code, "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY company_code, "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10`
);
} catch {
try {
duplicates = await query(
`SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" GROUP BY "${columnName}" HAVING COUNT(*) > 1 ORDER BY cnt DESC LIMIT 10`
);
} catch { /* 중복 조회 실패 시 무시 */ }
}
const dupDetails = duplicates.length > 0
? duplicates.map((d: any) => {
const company = d.company_code ? `[${d.company_code}] ` : "";
return `${company}"${d[columnName] ?? 'NULL'}" (${d.cnt}건)`;
}).join(", ")
: "";
userMessage = dupDetails
? `[${columnName}] 컬럼에 같은 회사 내 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 값: ${dupDetails}`
: `[${columnName}] 컬럼에 중복 데이터가 있어 유니크 인덱스를 생성할 수 없습니다. 중복 데이터를 먼저 정리해주세요.`;
}
res.status(500).json({
success: false,
message: userMessage,
error: errMsg,
duplicates,
});
}
}
/**
* NOT NULL 토글 (회사별 소프트 제약조건)
* PUT /api/table-management/tables/:tableName/columns/:columnName/nullable
*
* DB 레벨 ALTER TABLE 대신 table_type_columns.is_nullable을 회사별로 관리한다.
* 멀티테넌시 환경에서 회사 A는 NOT NULL, 회사 B는 NULL 허용이 가능하다.
*/
export async function toggleColumnNullable(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, columnName } = req.params;
const { nullable } = req.body;
const companyCode = req.user?.companyCode || "*";
if (!tableName || !columnName || typeof nullable !== "boolean") {
res.status(400).json({
success: false,
message: "tableName, columnName, nullable(boolean)이 필요합니다.",
});
return;
}
// is_nullable 값: 'Y' = NULL 허용, 'N' = NOT NULL
const isNullableValue = nullable ? "Y" : "N";
if (!nullable) {
// NOT NULL 설정 전 - 해당 회사의 기존 데이터에 NULL이 있는지 확인
const hasCompanyCode = await query<{ column_name: string }>(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[tableName]
);
if (hasCompanyCode.length > 0) {
const nullCheckQuery = companyCode === "*"
? `SELECT COUNT(*) as null_count FROM "${tableName}" WHERE "${columnName}" IS NULL`
: `SELECT COUNT(*) as null_count FROM "${tableName}" WHERE "${columnName}" IS NULL AND company_code = $1`;
const nullCheckParams = companyCode === "*" ? [] : [companyCode];
const nullCheckResult = await query<{ null_count: string }>(nullCheckQuery, nullCheckParams);
const nullCount = parseInt(nullCheckResult[0]?.null_count || "0", 10);
if (nullCount > 0) {
logger.warn(`NOT NULL 설정 불가 - 해당 회사에 NULL 데이터 존재: ${tableName}.${columnName}`, {
companyCode,
nullCount,
});
res.status(400).json({
success: false,
message: `현재 회사 데이터에 NULL 값이 ${nullCount}건 존재합니다. NULL 데이터를 먼저 정리해주세요.`,
});
return;
}
}
}
// table_type_columns에 회사별 is_nullable 설정 UPSERT
await query(
`INSERT INTO table_type_columns (table_name, column_name, is_nullable, company_code, created_date, updated_date)
VALUES ($1, $2, $3, $4, NOW(), NOW())
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET is_nullable = $3, updated_date = NOW()`,
[tableName, columnName, isNullableValue, companyCode]
);
logger.info(`NOT NULL 소프트 제약조건 변경: ${tableName}.${columnName} → is_nullable=${isNullableValue}`, {
companyCode,
});
res.status(200).json({
success: true,
message: nullable
? `${columnName} 컬럼의 NOT NULL 제약이 해제되었습니다.`
: `${columnName} 컬럼이 NOT NULL로 설정되었습니다.`,
});
} catch (error: any) {
logger.error("NOT NULL 토글 오류:", error);
res.status(500).json({
success: false,
message: "NOT NULL 설정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* UNIQUE 토글 (회사별 소프트 제약조건)
* PUT /api/table-management/tables/:tableName/columns/:columnName/unique
*
* DB 레벨 인덱스 대신 table_type_columns.is_unique를 회사별로 관리한다.
* 저장 시 앱 레벨에서 중복 검증을 수행한다.
*/
export async function toggleColumnUnique(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, columnName } = req.params;
const { unique } = req.body;
const companyCode = req.user?.companyCode || "*";
if (!tableName || !columnName || typeof unique !== "boolean") {
res.status(400).json({
success: false,
message: "tableName, columnName, unique(boolean)이 필요합니다.",
});
return;
}
const isUniqueValue = unique ? "Y" : "N";
if (unique) {
// UNIQUE 설정 전 - 해당 회사의 기존 데이터에 중복이 있는지 확인
const hasCompanyCode = await query<{ column_name: string }>(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[tableName]
);
if (hasCompanyCode.length > 0) {
const dupQuery = companyCode === "*"
? `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10`
: `SELECT "${columnName}", COUNT(*) as cnt FROM "${tableName}" WHERE "${columnName}" IS NOT NULL AND company_code = $1 GROUP BY "${columnName}" HAVING COUNT(*) > 1 LIMIT 10`;
const dupParams = companyCode === "*" ? [] : [companyCode];
const dupResult = await query<any>(dupQuery, dupParams);
if (dupResult.length > 0) {
const dupDetails = dupResult
.map((d: any) => `"${d[columnName]}" (${d.cnt}건)`)
.join(", ");
res.status(400).json({
success: false,
message: `현재 회사 데이터에 중복 값이 존재합니다. 중복 데이터를 먼저 정리해주세요. 중복 값: ${dupDetails}`,
});
return;
}
}
}
// table_type_columns에 회사별 is_unique 설정 UPSERT
await query(
`INSERT INTO table_type_columns (table_name, column_name, is_unique, company_code, created_date, updated_date)
VALUES ($1, $2, $3, $4, NOW(), NOW())
ON CONFLICT (table_name, column_name, company_code)
DO UPDATE SET is_unique = $3, updated_date = NOW()`,
[tableName, columnName, isUniqueValue, companyCode]
);
logger.info(`UNIQUE 소프트 제약조건 변경: ${tableName}.${columnName} → is_unique=${isUniqueValue}`, {
companyCode,
});
res.status(200).json({
success: true,
message: unique
? `${columnName} 컬럼이 UNIQUE로 설정되었습니다.`
: `${columnName} 컬럼의 UNIQUE 제약이 해제되었습니다.`,
});
} catch (error: any) {
logger.error("UNIQUE 토글 오류:", error);
res.status(500).json({
success: false,
message: "UNIQUE 설정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 회사별 채번 타입 컬럼 조회 (카테고리 패턴과 동일)
*
* @route GET /api/table-management/numbering-columns
*/
export async function getNumberingColumnsByCompany(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const companyCode = req.user?.companyCode;
logger.info("회사별 채번 컬럼 조회 요청", { companyCode });
if (!companyCode) {
res.status(400).json({
success: false,
message: "회사 코드를 확인할 수 없습니다.",
});
return;
}
const { getPool } = await import("../database/db");
const pool = getPool();
const targetCompanyCode = companyCode === "*" ? "*" : companyCode;
const columnsQuery = `
SELECT DISTINCT
ttc.table_name AS "tableName",
COALESCE(
tl.table_label,
initcap(replace(ttc.table_name, '_', ' '))
) AS "tableLabel",
ttc.column_name AS "columnName",
COALESCE(
ttc.column_label,
initcap(replace(ttc.column_name, '_', ' '))
) AS "columnLabel",
ttc.input_type AS "inputType"
FROM table_type_columns ttc
LEFT JOIN table_labels tl
ON ttc.table_name = tl.table_name
WHERE ttc.input_type = 'numbering'
AND ttc.company_code = $1
ORDER BY ttc.table_name, ttc.column_name
`;
const columnsResult = await pool.query(columnsQuery, [targetCompanyCode]);
logger.info("채번 컬럼 조회 완료", {
companyCode,
rowCount: columnsResult.rows.length,
});
res.json({
success: true,
data: columnsResult.rows,
});
} catch (error: any) {
logger.error("채번 컬럼 조회 실패", { error: error.message });
res.status(500).json({
success: false,
message: "채번 컬럼 조회 중 오류가 발생했습니다.",
error: error.message,
});
}
}
/**
* 엑셀 업로드 전 데이터 검증
* POST /api/table-management/validate-excel
* Body: { tableName, data: Record<string,any>[] }
*/
export async function validateExcelData(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName, data } = req.body as {
tableName: string;
data: Record<string, any>[];
};
const companyCode = req.user?.companyCode || "*";
if (!tableName || !Array.isArray(data) || data.length === 0) {
res.status(400).json({ success: false, message: "tableName과 data 배열이 필요합니다." });
return;
}
const effectiveCompanyCode =
companyCode === "*" && data[0]?.company_code && data[0].company_code !== "*"
? data[0].company_code
: companyCode;
let constraintCols = await query<{
column_name: string;
column_label: string;
is_nullable: string;
is_unique: string;
}>(
`SELECT column_name,
COALESCE(column_label, column_name) as column_label,
COALESCE(is_nullable, 'Y') as is_nullable,
COALESCE(is_unique, 'N') as is_unique
FROM table_type_columns
WHERE table_name = $1 AND company_code = $2`,
[tableName, effectiveCompanyCode]
);
if (constraintCols.length === 0 && effectiveCompanyCode !== "*") {
constraintCols = await query(
`SELECT column_name,
COALESCE(column_label, column_name) as column_label,
COALESCE(is_nullable, 'Y') as is_nullable,
COALESCE(is_unique, 'N') as is_unique
FROM table_type_columns
WHERE table_name = $1 AND company_code = '*'`,
[tableName]
);
}
const autoGenCols = ["id", "created_date", "updated_date", "writer", "company_code"];
const notNullCols = constraintCols.filter((c) => c.is_nullable === "N" && !autoGenCols.includes(c.column_name));
const uniqueCols = constraintCols.filter((c) => c.is_unique === "Y" && !autoGenCols.includes(c.column_name));
const notNullErrors: { row: number; column: string; label: string }[] = [];
const uniqueInExcelErrors: { rows: number[]; column: string; label: string; value: string }[] = [];
const uniqueInDbErrors: { row: number; column: string; label: string; value: string }[] = [];
// NOT NULL 검증
for (const col of notNullCols) {
for (let i = 0; i < data.length; i++) {
const val = data[i][col.column_name];
if (val === null || val === undefined || String(val).trim() === "") {
notNullErrors.push({ row: i + 1, column: col.column_name, label: col.column_label });
}
}
}
// UNIQUE: 엑셀 내부 중복
for (const col of uniqueCols) {
const seen = new Map<string, number[]>();
for (let i = 0; i < data.length; i++) {
const val = data[i][col.column_name];
if (val === null || val === undefined || String(val).trim() === "") continue;
const key = String(val).trim();
if (!seen.has(key)) seen.set(key, []);
seen.get(key)!.push(i + 1);
}
for (const [value, rows] of seen) {
if (rows.length > 1) {
uniqueInExcelErrors.push({ rows, column: col.column_name, label: col.column_label, value });
}
}
}
// UNIQUE: DB 기존 데이터와 중복
const hasCompanyCode = await query(
`SELECT column_name FROM information_schema.columns
WHERE table_name = $1 AND column_name = 'company_code'`,
[tableName]
);
for (const col of uniqueCols) {
const values = [...new Set(
data
.map((row) => row[col.column_name])
.filter((v) => v !== null && v !== undefined && String(v).trim() !== "")
.map((v) => String(v).trim())
)];
if (values.length === 0) continue;
let dupQuery: string;
let dupParams: any[];
const targetCompany = data[0]?.company_code || (effectiveCompanyCode !== "*" ? effectiveCompanyCode : null);
if (hasCompanyCode.length > 0 && targetCompany) {
dupQuery = `SELECT "${col.column_name}" FROM "${tableName}" WHERE "${col.column_name}" = ANY($1) AND company_code = $2`;
dupParams = [values, targetCompany];
} else {
dupQuery = `SELECT "${col.column_name}" FROM "${tableName}" WHERE "${col.column_name}" = ANY($1)`;
dupParams = [values];
}
const existingRows = await query<Record<string, any>>(dupQuery, dupParams);
const existingSet = new Set(existingRows.map((r) => String(r[col.column_name]).trim()));
for (let i = 0; i < data.length; i++) {
const val = data[i][col.column_name];
if (val === null || val === undefined || String(val).trim() === "") continue;
if (existingSet.has(String(val).trim())) {
uniqueInDbErrors.push({ row: i + 1, column: col.column_name, label: col.column_label, value: String(val) });
}
}
}
const isValid = notNullErrors.length === 0 && uniqueInExcelErrors.length === 0 && uniqueInDbErrors.length === 0;
res.json({
success: true,
data: {
isValid,
notNullErrors,
uniqueInExcelErrors,
uniqueInDbErrors,
summary: {
notNull: notNullErrors.length,
uniqueInExcel: uniqueInExcelErrors.length,
uniqueInDb: uniqueInDbErrors.length,
},
},
});
} catch (error: any) {
logger.error("엑셀 데이터 검증 오류:", error);
res.status(500).json({ success: false, message: "데이터 검증 중 오류가 발생했습니다." });
}
}