Files
vexplor/backend-node/src/controllers/adminController.ts
kjs 3982aabc24 refactor: Enhance unique constraint validation across data operations
- Integrated `TableManagementService` to validate unique constraints before insert, update, and upsert actions in various controllers, including `dataflowExecutionController`, `dynamicFormController`, and `tableManagementController`.
- Improved error handling in `errorHandler` to provide detailed messages indicating which field has a unique constraint violation.
- Updated the `formatPgError` utility to extract and display specific column labels for unique constraint violations, enhancing user feedback.
- Adjusted the table schema retrieval to include company-specific nullable and unique constraints, ensuring accurate representation of database rules.

These changes improve data integrity by preventing duplicate entries and enhance user experience through clearer error messages related to unique constraints.
2026-03-10 16:15:20 +09:00

4173 lines
122 KiB
TypeScript

import { Request, Response } from "express";
import { logger } from "../utils/logger";
import { AuthenticatedRequest } from "../types/auth";
import { ApiResponse } from "../types/common";
import { Client } from "pg";
import { query, queryOne, getPool } from "../database/db";
import config from "../config/environment";
import { AdminService } from "../services/adminService";
import { EncryptUtil } from "../utils/encryptUtil";
import { FileSystemManager } from "../utils/fileSystemManager";
import { validateBusinessNumber } from "../utils/businessNumberValidator";
import { MenuCopyService } from "../services/menuCopyService";
import { auditLogService, getClientIp } from "../services/auditLogService";
/**
* 관리자 메뉴 목록 조회
*/
export async function getAdminMenus(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
// 현재 로그인한 사용자의 정보 가져오기
const userId = req.user?.userId;
const userCompanyCode = req.user?.companyCode || "ILSHIN";
const userType = req.user?.userType;
const userLang = (req.query.userLang as string) || "ko";
const menuType = req.query.menuType as string | undefined; // menuType 파라미터 추가
const includeInactive = req.query.includeInactive === "true"; // includeInactive 파라미터 추가
const paramMap = {
userId,
userCompanyCode,
userType,
userLang,
menuType, // includeInactive와 관계없이 menuType 유지 (관리자/사용자 구분)
includeInactive, // includeInactive 추가
};
const menuList = await AdminService.getAdminMenuList(paramMap);
const response: ApiResponse<any[]> = {
success: true,
message: "관리자 메뉴 목록 조회 성공",
data: menuList,
};
res.status(200).json(response);
} catch (error) {
logger.error("관리자 메뉴 목록 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "관리자 메뉴 목록 조회 중 오류가 발생했습니다.",
error: {
code: "ADMIN_MENU_LIST_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 사용자 메뉴 목록 조회
*/
export async function getUserMenus(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
// 현재 로그인한 사용자의 정보 가져오기
const userId = req.user?.userId;
const userCompanyCode = req.user?.companyCode || "ILSHIN";
const userType = req.user?.userType;
const userLang = (req.query.userLang as string) || "ko";
const paramMap = {
userId,
userCompanyCode,
userType,
userLang,
};
const menuList = await AdminService.getUserMenuList(paramMap);
const response: ApiResponse<any[]> = {
success: true,
message: "사용자 메뉴 목록 조회 성공",
data: menuList,
};
res.status(200).json(response);
} catch (error) {
logger.error("사용자 메뉴 목록 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "사용자 메뉴 목록 조회 중 오류가 발생했습니다.",
error: {
code: "USER_MENU_LIST_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* 메뉴 정보 조회
*/
export async function getMenuInfo(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { menuId } = req.params;
logger.info(`=== 메뉴 정보 조회 시작 - menuId: ${menuId} ===`);
const menuInfo = await AdminService.getMenuInfo(menuId);
if (!menuInfo) {
const response: ApiResponse<null> = {
success: false,
message: "메뉴를 찾을 수 없습니다.",
error: {
code: "MENU_NOT_FOUND",
details: `Menu ID: ${menuId}`,
},
};
res.status(404).json(response);
return;
}
const response: ApiResponse<any> = {
success: true,
message: "메뉴 정보 조회 성공",
data: menuInfo,
};
res.status(200).json(response);
} catch (error) {
logger.error("메뉴 정보 조회 중 오류 발생:", error);
const response: ApiResponse<null> = {
success: false,
message: "메뉴 정보 조회 중 오류가 발생했습니다.",
error: {
code: "MENU_INFO_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
}
/**
* GET /api/admin/users
* 사용자 목록 조회 API
* 기존 Java AdminController.getUserList() 포팅
*/
export const getUserList = async (req: AuthenticatedRequest, res: Response) => {
try {
logger.info("사용자 목록 조회 요청", {
query: req.query,
user: req.user,
});
const {
page = 1,
countPerPage = 20,
search,
searchField,
searchValue,
search_sabun,
search_companyName,
search_deptName,
search_positionName,
search_userId,
search_userName,
search_tel,
search_email,
deptCode,
status,
companyCode, // 회사 코드 필터 추가
size, // countPerPage 대신 사용 가능
} = req.query;
// Raw Query를 사용한 사용자 목록 조회
let searchType = "none";
let whereConditions: string[] = [];
let queryParams: any[] = [];
let paramIndex = 1;
// 회사 코드 필터 (권한 그룹 멤버 관리 시 사용)
if (companyCode && typeof companyCode === "string" && companyCode.trim()) {
whereConditions.push(`company_code = $${paramIndex}`);
queryParams.push(companyCode.trim());
paramIndex++;
logger.info("회사 코드 필터 적용", { companyCode });
}
// 최고 관리자 필터링 (회사 관리자와 일반 사용자는 최고 관리자를 볼 수 없음)
if (req.user && req.user.companyCode !== "*") {
// 최고 관리자가 아닌 경우, company_code가 "*"인 사용자는 제외
whereConditions.push(`company_code != '*'`);
logger.info("최고 관리자 필터링 적용", {
userCompanyCode: req.user.companyCode,
});
}
// 검색 조건 처리
if (search && typeof search === "string" && search.trim()) {
// 통합 검색
searchType = "v2";
const searchTerm = search.trim();
whereConditions.push(`(
sabun ILIKE $${paramIndex} OR
user_type_name ILIKE $${paramIndex} OR
dept_name ILIKE $${paramIndex} OR
position_name ILIKE $${paramIndex} OR
user_id ILIKE $${paramIndex} OR
user_name ILIKE $${paramIndex} OR
tel ILIKE $${paramIndex} OR
cell_phone ILIKE $${paramIndex} OR
email ILIKE $${paramIndex}
)`);
queryParams.push(`%${searchTerm}%`);
paramIndex++;
logger.info("통합 검색 실행", { searchTerm });
} else if (searchField && searchValue) {
// 단일 필드 검색
searchType = "single";
const fieldMap: { [key: string]: string } = {
sabun: "sabun",
companyName: "user_type_name",
deptName: "dept_name",
positionName: "position_name",
userId: "user_id",
userName: "user_name",
tel: "tel",
cellPhone: "cell_phone",
email: "email",
};
if (fieldMap[searchField as string]) {
if (searchField === "tel") {
whereConditions.push(
`(tel ILIKE $${paramIndex} OR cell_phone ILIKE $${paramIndex})`
);
queryParams.push(`%${searchValue}%`);
paramIndex++;
} else {
whereConditions.push(
`${fieldMap[searchField as string]} ILIKE $${paramIndex}`
);
queryParams.push(`%${searchValue}%`);
paramIndex++;
}
logger.info("단일 필드 검색 실행", { searchField, searchValue });
}
} else {
// 고급 검색 (개별 필드별 AND 조건)
const advancedSearchFields = [
{ param: search_sabun, field: "sabun" },
{ param: search_companyName, field: "user_type_name" },
{ param: search_deptName, field: "dept_name" },
{ param: search_positionName, field: "position_name" },
{ param: search_userId, field: "user_id" },
{ param: search_userName, field: "user_name" },
{ param: search_email, field: "email" },
];
let hasAdvancedSearch = false;
for (const { param, field } of advancedSearchFields) {
if (param && typeof param === "string" && param.trim()) {
whereConditions.push(`${field} ILIKE $${paramIndex}`);
queryParams.push(`%${param.trim()}%`);
paramIndex++;
hasAdvancedSearch = true;
}
}
// 전화번호 검색
if (search_tel && typeof search_tel === "string" && search_tel.trim()) {
whereConditions.push(
`(tel ILIKE $${paramIndex} OR cell_phone ILIKE $${paramIndex})`
);
queryParams.push(`%${search_tel.trim()}%`);
paramIndex++;
hasAdvancedSearch = true;
}
if (hasAdvancedSearch) {
searchType = "advanced";
logger.info("고급 검색 실행", {
search_sabun,
search_companyName,
search_deptName,
search_positionName,
search_userId,
search_userName,
search_tel,
search_email,
});
}
}
// 현재 로그인한 사용자의 회사 코드 필터 (슈퍼관리자가 아닌 경우)
if (req.user && req.user.companyCode !== "*" && !companyCode) {
whereConditions.push(`company_code = $${paramIndex}`);
queryParams.push(req.user.companyCode);
paramIndex++;
logger.info("사용자 회사 코드 필터 적용", {
companyCode: req.user.companyCode,
});
}
// 기존 필터들
if (deptCode) {
whereConditions.push(`dept_code = $${paramIndex}`);
queryParams.push(deptCode);
paramIndex++;
}
if (status) {
whereConditions.push(`status = $${paramIndex}`);
queryParams.push(status);
paramIndex++;
}
const whereClause =
whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
// 총 개수 조회
const countQuery = `
SELECT COUNT(*) as total
FROM user_info
${whereClause}
`;
const countResult = await query<{ total: string }>(countQuery, queryParams);
const totalCount = parseInt(countResult[0]?.total || "0", 10);
// 사용자 목록 조회
const limit = size ? Number(size) : Number(countPerPage);
const offset = (Number(page) - 1) * limit;
const usersQuery = `
SELECT
sabun,
user_id,
user_name,
user_name_eng,
dept_code,
dept_name,
position_code,
position_name,
email,
tel,
cell_phone,
user_type,
user_type_name,
regdate,
status,
company_code,
locale
FROM user_info
${whereClause}
ORDER BY regdate DESC, user_name ASC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}
`;
const users = await query<any>(usersQuery, [...queryParams, limit, offset]);
// 응답 데이터 가공
const processedUsers = users.map((user) => ({
userId: user.user_id,
userName: user.user_name,
userNameEng: user.user_name_eng || null,
sabun: user.sabun || null,
deptCode: user.dept_code || null,
deptName: user.dept_name || null,
positionCode: user.position_code || null,
positionName: user.position_name || null,
email: user.email || null,
tel: user.tel || null,
cellPhone: user.cell_phone || null,
userType: user.user_type || null,
userTypeName: user.user_type_name || null,
status: user.status || "active",
companyCode: user.company_code || null,
locale: user.locale || null,
regDate: user.regdate
? new Date(user.regdate).toISOString().split("T")[0]
: null,
}));
const response = {
success: true,
data: processedUsers,
total: totalCount,
searchType,
pagination: {
page: Number(page),
limit: limit,
totalPages: Math.ceil(totalCount / limit),
},
message: "사용자 목록 조회 성공",
};
logger.info("사용자 목록 조회 성공", {
totalCount,
returnedCount: processedUsers.length,
searchType,
currentPage: Number(page),
limit: limit,
companyCode: companyCode || "all",
});
res.status(200).json(response);
} catch (error) {
logger.error("사용자 목록 조회 실패", { error });
res.status(500).json({
success: false,
message: "사용자 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/**
* GET /api/admin/user-locale
* 사용자 로케일 조회 API
*/
export const getUserLocale = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
logger.debug("사용자 로케일 조회 요청", {
query: req.query,
user: req.user,
});
if (!req.user?.userId) {
res.status(400).json({
success: false,
message: "사용자 정보가 없습니다.",
});
return;
}
// Raw Query로 사용자 로케일 조회
const userInfo = await queryOne<{ locale: string }>(
"SELECT locale FROM user_info WHERE user_id = $1",
[req.user.userId]
);
let userLocale = "en"; // 기본값
if (userInfo?.locale) {
userLocale = userInfo.locale;
logger.debug("데이터베이스에서 사용자 로케일 조회 성공", {
userId: req.user.userId,
locale: userLocale,
});
} else {
logger.info("사용자 로케일이 설정되지 않음, 기본값 사용", {
userId: req.user.userId,
defaultLocale: userLocale,
});
}
const response = {
success: true,
data: userLocale,
message: "사용자 로케일 조회 성공",
};
logger.debug("사용자 로케일 조회 성공", {
userLocale,
userId: req.user.userId,
fromDatabase: !!userInfo?.locale,
});
res.status(200).json(response);
} catch (error) {
logger.error("사용자 로케일 조회 실패", { error });
res.status(500).json({
success: false,
message: "사용자 로케일 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/**
* 사용자 로케일 설정
*/
export const setUserLocale = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
logger.info("사용자 로케일 설정 요청", {
body: req.body,
user: req.user,
});
if (!req.user?.userId) {
res.status(400).json({
success: false,
message: "사용자 정보가 없습니다.",
});
return;
}
const { locale } = req.body;
if (!locale) {
res.status(400).json({
success: false,
message: "로케일이 필요합니다.",
});
return;
}
// language_master 테이블에서 유효한 언어 코드인지 확인
const validLang = await queryOne<{ lang_code: string }>(
"SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'",
[locale]
);
if (!validLang) {
res.status(400).json({
success: false,
message: `유효하지 않은 로케일입니다: ${locale}`,
});
return;
}
// Raw Query로 사용자 로케일 저장
await query("UPDATE user_info SET locale = $1 WHERE user_id = $2", [
locale,
req.user.userId,
]);
logger.info("사용자 로케일을 데이터베이스에 저장 완료", {
locale,
userId: req.user.userId,
});
const response = {
success: true,
data: locale,
message: "사용자 로케일 설정 성공",
};
logger.info("사용자 로케일 설정 성공", {
locale,
userId: req.user.userId,
});
res.status(200).json(response);
} catch (error) {
logger.error("사용자 로케일 설정 실패", { error });
res.status(500).json({
success: false,
message: "사용자 로케일 설정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/**
* GET /api/admin/companies
* 회사 목록 조회 API
* 기존 Java AdminController의 회사 목록 조회 기능 포팅
*/
export const getCompanyList = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
logger.debug("회사 목록 조회 요청", {
query: req.query,
user: req.user,
});
// Raw Query로 회사 목록 조회
const companies = await query<any>(
` SELECT
company_code,
company_name,
business_registration_number,
representative_name,
representative_phone,
email,
website,
address,
status,
writer,
regdate
FROM company_mng
WHERE status = 'active' OR status IS NULL
ORDER BY company_name ASC`
);
// 프론트엔드에서 기대하는 응답 형식으로 변환
const response = {
success: true,
data: companies.map((company) => ({
company_code: company.company_code,
company_name: company.company_name,
status: company.status || "active",
writer: company.writer,
regdate: company.regdate
? new Date(company.regdate).toISOString()
: new Date().toISOString(),
data_type: "company",
})),
message: "회사 목록 조회 성공",
};
logger.debug("회사 목록 조회 성공", {
totalCount: companies.length,
});
res.status(200).json(response);
} catch (error) {
logger.error("회사 목록 조회 실패", { error });
res.status(500).json({
success: false,
message: "회사 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/**
* 다국어 언어 목록 조회 (더미 데이터)
*/
export async function getLanguageList(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
logger.info("다국어 언어 목록 조회 요청");
// 더미 데이터 반환
const languages = [
{
langCode: "KR",
langName: "한국어",
langNative: "한국어",
isActive: "Y",
},
{
langCode: "EN",
langName: "English",
langNative: "English",
isActive: "Y",
},
{
langCode: "JP",
langName: "日本語",
langNative: "日本語",
isActive: "Y",
},
{ langCode: "CN", langName: "中文", langNative: "中文", isActive: "Y" },
];
const response: ApiResponse<any[]> = {
success: true,
message: "언어 목록 조회 성공",
data: languages,
};
res.status(200).json(response);
} catch (error) {
logger.error("언어 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: "언어 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 다국어 키 목록 조회
*/
export async function getLangKeyList(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
logger.info("다국어 키 목록 조회 요청", {
query: req.query,
user: req.user,
});
// Raw Query로 다국어 키 목록 조회
const result = await query<any>(
`SELECT
key_id,
company_code,
menu_name,
lang_key,
description,
is_active,
created_date,
created_by,
updated_date,
updated_by
FROM multi_lang_key_master
ORDER BY company_code ASC, menu_name ASC, lang_key ASC`
);
const langKeys = result.map((row) => ({
keyId: row.key_id,
companyCode: row.company_code,
menuName: row.menu_name,
langKey: row.lang_key,
description: row.description,
isActive: row.is_active,
createdDate: row.created_date
? new Date(row.created_date).toISOString()
: null,
createdBy: row.created_by,
updatedDate: row.updated_date
? new Date(row.updated_date).toISOString()
: null,
updatedBy: row.updated_by,
}));
// 프론트엔드에서 기대하는 응답 형식으로 변환
const response: ApiResponse<any[]> = {
success: true,
message: "다국어 키 목록 조회 성공",
data: langKeys,
};
logger.info("다국어 키 목록 조회 성공", {
totalCount: langKeys.length,
response: response,
});
res.status(200).json(response);
} catch (error) {
logger.error("다국어 키 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: "다국어 키 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 다국어 텍스트 목록 조회 (더미 데이터)
*/
export async function getLangTextList(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { keyId } = req.params;
logger.info(`다국어 텍스트 목록 조회 요청: keyId = ${keyId}`);
// 더미 데이터 반환
const langTexts = [
{
textId: 1,
keyId: parseInt(keyId),
langCode: "KR",
langText: "사용자 관리",
isActive: "Y",
},
{
textId: 2,
keyId: parseInt(keyId),
langCode: "EN",
langText: "User Management",
isActive: "Y",
},
{
textId: 3,
keyId: parseInt(keyId),
langCode: "JP",
langText: "ユーザー管理",
isActive: "Y",
},
];
const response: ApiResponse<any[]> = {
success: true,
message: "다국어 텍스트 목록 조회 성공",
data: langTexts,
};
res.status(200).json(response);
} catch (error) {
logger.error("다국어 텍스트 목록 조회 실패:", error);
res.status(500).json({
success: false,
message: "다국어 텍스트 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 다국어 텍스트 저장 (더미 데이터)
*/
export async function saveLangTexts(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { keyId } = req.params;
const textData = req.body;
logger.info(`다국어 텍스트 저장 요청: keyId = ${keyId}`, { textData });
// 더미 응답
const response: ApiResponse<any> = {
success: true,
message: "다국어 텍스트 저장 성공",
data: { savedCount: textData.length },
};
res.status(200).json(response);
} catch (error) {
logger.error("다국어 텍스트 저장 실패:", error);
res.status(500).json({
success: false,
message: "다국어 텍스트 저장 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 다국어 키 저장 (더미 데이터)
*/
export async function saveLangKey(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const keyData = req.body;
logger.info("다국어 키 저장 요청", { keyData });
// 더미 응답
const response: ApiResponse<any> = {
success: true,
message: "다국어 키 저장 성공",
data: { keyId: Math.floor(Math.random() * 1000) + 1 },
};
res.status(200).json(response);
} catch (error) {
logger.error("다국어 키 저장 실패:", error);
res.status(500).json({
success: false,
message: "다국어 키 저장 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 다국어 키 수정 (더미 데이터)
*/
export async function updateLangKey(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { keyId } = req.params;
const keyData = req.body;
logger.info(`다국어 키 수정 요청: keyId = ${keyId}`, { keyData });
// 더미 응답
const response: ApiResponse<any> = {
success: true,
message: "다국어 키 수정 성공",
data: { keyId: parseInt(keyId) },
};
res.status(200).json(response);
} catch (error) {
logger.error("다국어 키 수정 실패:", error);
res.status(500).json({
success: false,
message: "다국어 키 수정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 다국어 키 삭제 (더미 데이터)
*/
export async function deleteLangKey(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { keyId } = req.params;
logger.info(`다국어 키 삭제 요청: keyId = ${keyId}`);
// 더미 응답
const response: ApiResponse<any> = {
success: true,
message: "다국어 키 삭제 성공",
data: { deletedKeyId: parseInt(keyId) },
};
res.status(200).json(response);
} catch (error) {
logger.error("다국어 키 삭제 실패:", error);
res.status(500).json({
success: false,
message: "다국어 키 삭제 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 다국어 키 상태 토글 (더미 데이터)
*/
export async function toggleLangKeyStatus(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { keyId } = req.params;
logger.info(`다국어 키 상태 토글 요청: keyId = ${keyId}`);
// 더미 응답
const response: ApiResponse<any> = {
success: true,
message: "다국어 키 상태 토글 성공",
data: "활성화",
};
res.status(200).json(response);
} catch (error) {
logger.error("다국어 키 상태 토글 실패:", error);
res.status(500).json({
success: false,
message: "다국어 키 상태 토글 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 언어 저장 (더미 데이터)
*/
export async function saveLanguage(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const langData = req.body;
logger.info("언어 저장 요청", { langData });
// 더미 응답
const response: ApiResponse<any> = {
success: true,
message: "언어 저장 성공",
data: { langCode: langData.langCode },
};
res.status(200).json(response);
} catch (error) {
logger.error("언어 저장 실패:", error);
res.status(500).json({
success: false,
message: "언어 저장 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 언어 수정 (더미 데이터)
*/
export async function updateLanguage(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { langCode } = req.params;
const langData = req.body;
logger.info(`언어 수정 요청: langCode = ${langCode}`, { langData });
// 더미 응답
const response: ApiResponse<any> = {
success: true,
message: "언어 수정 성공",
data: { langCode },
};
res.status(200).json(response);
} catch (error) {
logger.error("언어 수정 실패:", error);
res.status(500).json({
success: false,
message: "언어 수정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 언어 상태 토글 (더미 데이터)
*/
export async function toggleLanguageStatus(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { langCode } = req.params;
logger.info(`언어 상태 토글 요청: langCode = ${langCode}`);
// 더미 응답
const response: ApiResponse<any> = {
success: true,
message: "언어 상태 토글 성공",
data: "활성화",
};
res.status(200).json(response);
} catch (error) {
logger.error("언어 상태 토글 실패:", error);
res.status(500).json({
success: false,
message: "언어 상태 토글 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 메뉴 저장 (추가/수정)
*/
export async function saveMenu(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const menuData = req.body;
logger.info("메뉴 저장 요청", { menuData, user: req.user });
// 사용자의 company_code 확인
if (!req.user?.companyCode) {
res.status(400).json({
success: false,
message: "사용자의 회사 코드를 찾을 수 없습니다.",
error: "Missing company_code",
});
return;
}
const userCompanyCode = req.user.companyCode;
const userType = req.user.userType;
let requestCompanyCode = menuData.companyCode || menuData.company_code;
// "none"이나 빈 값은 undefined로 처리하여 사용자 회사 코드 사용
if (
requestCompanyCode === "none" ||
requestCompanyCode === "" ||
!requestCompanyCode
) {
requestCompanyCode = undefined;
}
// 공통 메뉴(company_code = '*')는 최고 관리자만 생성 가능
if (requestCompanyCode === "*") {
if (userCompanyCode !== "*" || userType !== "SUPER_ADMIN") {
res.status(403).json({
success: false,
message: "공통 메뉴는 최고 관리자만 생성할 수 있습니다.",
error: "Unauthorized to create common menu",
});
return;
}
} else if (userCompanyCode !== "*") {
// 회사 관리자는 자기 회사 메뉴만 생성 가능
// requestCompanyCode가 undefined면 사용자 회사 코드 사용 (권한 체크 통과)
if (requestCompanyCode && requestCompanyCode !== userCompanyCode) {
res.status(403).json({
success: false,
message: "해당 회사의 메뉴를 생성할 권한이 없습니다.",
error: "Unauthorized to create menu for this company",
});
return;
}
}
// Raw Query를 사용한 메뉴 저장
const objid = Date.now(); // 고유 ID 생성
const companyCode = requestCompanyCode || userCompanyCode;
// menu_url이 비어있으면 screen_code도 null로 설정
const menuUrl = menuData.menuUrl || null;
const screenCode = menuUrl ? menuData.screenCode || null : null;
const [savedMenu] = await query<any>(
`INSERT INTO menu_info (
objid, menu_type, parent_obj_id, menu_name_kor, menu_name_eng,
seq, menu_url, menu_desc, writer, regdate, status,
system_name, company_code, lang_key, lang_key_desc, screen_code, menu_icon
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17)
RETURNING *`,
[
objid,
menuData.menuType ? Number(menuData.menuType) : null,
menuData.parentObjId ? Number(menuData.parentObjId) : null,
menuData.menuNameKor,
menuData.menuNameEng || null,
menuData.seq ? Number(menuData.seq) : null,
menuUrl,
menuData.menuDesc || null,
req.user?.userId || "admin",
new Date(),
menuData.status || "active",
menuData.systemName || null,
companyCode,
menuData.langKey || null,
menuData.langKeyDesc || null,
screenCode,
menuData.menuIcon || null,
]
);
logger.info("메뉴 저장 성공", { savedMenu });
// 다국어 메뉴 카테고리 자동 생성
try {
const { MultiLangService } = await import("../services/multilangService");
const multilangService = new MultiLangService();
// 회사명 조회
const companyInfo = await queryOne<{ company_name: string }>(
`SELECT company_name FROM company_mng WHERE company_code = $1`,
[companyCode]
);
const companyName = companyCode === "*" ? "공통" : (companyInfo?.company_name || companyCode);
// 메뉴 경로 조회 및 카테고리 생성
const menuPath = await multilangService.getMenuPath(savedMenu.objid.toString());
await multilangService.ensureMenuCategory(companyCode, companyName, menuPath);
logger.info("메뉴 다국어 카테고리 생성 완료", {
menuObjId: savedMenu.objid.toString(),
menuPath,
});
} catch (categoryError) {
logger.warn("메뉴 다국어 카테고리 생성 실패 (메뉴 저장은 성공)", {
menuObjId: savedMenu.objid.toString(),
error: categoryError,
});
}
const response: ApiResponse<any> = {
success: true,
message: "메뉴가 성공적으로 저장되었습니다.",
data: {
objid: savedMenu.objid.toString(),
menuNameKor: savedMenu.menu_name_kor,
menuNameEng: savedMenu.menu_name_eng,
menuUrl: savedMenu.menu_url,
menuDesc: savedMenu.menu_desc,
status: savedMenu.status,
writer: savedMenu.writer,
regdate: new Date(savedMenu.regdate).toISOString(),
},
};
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: getClientIp(req),
requestPath: req.originalUrl,
});
res.status(200).json(response);
} catch (error) {
logger.error("메뉴 저장 실패:", error);
res.status(500).json({
success: false,
message: "메뉴 저장 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 메뉴 수정
*/
export async function updateMenu(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { menuId } = req.params;
const menuData = req.body;
logger.info(`메뉴 수정 요청: menuId = ${menuId}`, {
menuData,
user: req.user,
});
// 사용자의 company_code 확인
if (!req.user?.companyCode) {
res.status(400).json({
success: false,
message: "사용자의 회사 코드를 찾을 수 없습니다.",
error: "Missing company_code",
});
return;
}
const userCompanyCode = req.user.companyCode;
const userType = req.user.userType;
// 수정하려는 메뉴 조회
const currentMenu = await queryOne<any>(
`SELECT objid, company_code FROM menu_info WHERE objid = $1`,
[Number(menuId)]
);
if (!currentMenu) {
res.status(404).json({
success: false,
message: `메뉴를 찾을 수 없습니다: ${menuId}`,
error: "Menu not found",
});
return;
}
// 공통 메뉴(company_code = '*')는 최고 관리자만 수정 가능
if (currentMenu.company_code === "*") {
if (userCompanyCode !== "*" || userType !== "SUPER_ADMIN") {
res.status(403).json({
success: false,
message: "공통 메뉴는 최고 관리자만 수정할 수 있습니다.",
error: "Unauthorized to update common menu",
});
return;
}
} else if (userCompanyCode !== "*") {
// 회사 관리자는 자기 회사 메뉴만 수정 가능
if (currentMenu.company_code !== userCompanyCode) {
res.status(403).json({
success: false,
message: "해당 회사의 메뉴를 수정할 권한이 없습니다.",
error: "Unauthorized to update menu for this company",
});
return;
}
}
let requestCompanyCode =
menuData.companyCode || menuData.company_code;
// "none"이나 빈 값은 기존 메뉴의 회사 코드 유지
if (
requestCompanyCode === "none" ||
requestCompanyCode === "" ||
!requestCompanyCode
) {
requestCompanyCode = currentMenu.company_code;
}
// company_code 변경 시도하는 경우 권한 체크
if (requestCompanyCode !== currentMenu.company_code) {
// 공통 메뉴로 변경하려는 경우 최고 관리자만 가능
if (requestCompanyCode === "*") {
if (userCompanyCode !== "*" || userType !== "SUPER_ADMIN") {
res.status(403).json({
success: false,
message: "공통 메뉴로 변경할 권한이 없습니다.",
error: "Unauthorized to change to common menu",
});
return;
}
}
// 회사 관리자는 자기 회사로만 변경 가능
else if (
userCompanyCode !== "*" &&
requestCompanyCode !== userCompanyCode
) {
res.status(403).json({
success: false,
message: "해당 회사로 변경할 권한이 없습니다.",
error: "Unauthorized to change company",
});
return;
}
}
const companyCode = requestCompanyCode;
// menu_url이 비어있으면 screen_code도 null로 설정
const menuUrl = menuData.menuUrl || null;
const screenCode = menuUrl ? menuData.screenCode || null : null;
// Raw Query를 사용한 메뉴 수정
const [updatedMenu] = await query<any>(
`UPDATE menu_info SET
menu_type = $1,
parent_obj_id = $2,
menu_name_kor = $3,
menu_name_eng = $4,
seq = $5,
menu_url = $6,
menu_desc = $7,
status = $8,
system_name = $9,
company_code = $10,
lang_key = $11,
lang_key_desc = $12,
screen_code = $13,
menu_icon = $14
WHERE objid = $15
RETURNING *`,
[
menuData.menuType ? Number(menuData.menuType) : null,
menuData.parentObjId ? Number(menuData.parentObjId) : null,
menuData.menuNameKor,
menuData.menuNameEng || null,
menuData.seq ? Number(menuData.seq) : null,
menuUrl,
menuData.menuDesc || null,
menuData.status || "active",
menuData.systemName || null,
companyCode,
menuData.langKey || null,
menuData.langKeyDesc || null,
screenCode,
menuData.menuIcon || null,
Number(menuId),
]
);
// menu_url이 비어있으면 화면 할당도 해제 (screen_menu_assignments의 is_active를 'N'으로)
if (!menuUrl) {
await query(
`UPDATE screen_menu_assignments
SET is_active = 'N'
WHERE menu_objid = $1 AND company_code = $2`,
[Number(menuId), companyCode]
);
logger.info("화면 할당 비활성화", { menuId, companyCode });
}
logger.info("메뉴 수정 성공", { updatedMenu });
const response: ApiResponse<any> = {
success: true,
message: "메뉴가 성공적으로 수정되었습니다.",
data: {
objid: updatedMenu.objid.toString(),
menuNameKor: updatedMenu.menu_name_kor,
menuNameEng: updatedMenu.menu_name_eng,
menuUrl: updatedMenu.menu_url,
menuDesc: updatedMenu.menu_desc,
status: updatedMenu.status,
writer: updatedMenu.writer,
regdate: new Date(updatedMenu.regdate).toISOString(),
},
};
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: getClientIp(req),
requestPath: req.originalUrl,
});
res.status(200).json(response);
} catch (error) {
logger.error("메뉴 수정 실패:", error);
res.status(500).json({
success: false,
message: "메뉴 수정 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 재귀적으로 모든 하위 메뉴 ID를 수집하는 헬퍼 함수
*/
async function collectAllChildMenuIds(parentObjid: number): Promise<number[]> {
const allIds: number[] = [];
// 직접 자식 메뉴들 조회
const children = await query<any>(
`SELECT objid FROM menu_info WHERE parent_obj_id = $1`,
[parentObjid]
);
for (const child of children) {
allIds.push(child.objid);
// 자식의 자식들도 재귀적으로 수집
const grandChildren = await collectAllChildMenuIds(child.objid);
allIds.push(...grandChildren);
}
return allIds;
}
/**
* 메뉴 및 관련 데이터 정리 헬퍼 함수
*/
async function cleanupMenuRelatedData(menuObjid: number): Promise<void> {
// 1. code_category에서 menu_objid를 NULL로 설정
await query(
`UPDATE code_category SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
// 3. code_info에서 menu_objid를 NULL로 설정
await query(
`UPDATE code_info SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
// 4. numbering_rules: 새 스키마에서는 메뉴와 연결되지 않음 (스킵)
// 새 스키마: table_name + column_name + company_code 기반
// 5. rel_menu_auth에서 관련 권한 삭제
await query(
`DELETE FROM rel_menu_auth WHERE menu_objid = $1`,
[menuObjid]
);
// 6. screen_menu_assignments에서 관련 할당 삭제
await query(
`DELETE FROM screen_menu_assignments WHERE menu_objid = $1`,
[menuObjid]
);
// 7. screen_groups에서 menu_objid를 NULL로 설정
await query(
`UPDATE screen_groups SET menu_objid = NULL WHERE menu_objid = $1`,
[menuObjid]
);
}
/**
* 메뉴 삭제
*/
export async function deleteMenu(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { menuId } = req.params;
logger.info(`메뉴 삭제 요청: menuId = ${menuId}`, { user: req.user });
// 사용자의 company_code 확인
if (!req.user?.companyCode) {
res.status(400).json({
success: false,
message: "사용자의 회사 코드를 찾을 수 없습니다.",
error: "Missing company_code",
});
return;
}
const userCompanyCode = req.user.companyCode;
const userType = req.user.userType;
// 삭제하려는 메뉴 조회
const currentMenu = await queryOne<any>(
`SELECT objid, company_code, menu_name_kor FROM menu_info WHERE objid = $1`,
[Number(menuId)]
);
if (!currentMenu) {
res.status(404).json({
success: false,
message: `메뉴를 찾을 수 없습니다: ${menuId}`,
error: "Menu not found",
});
return;
}
// 공통 메뉴(company_code = '*')는 최고 관리자만 삭제 가능
if (currentMenu.company_code === "*") {
if (userCompanyCode !== "*" || userType !== "SUPER_ADMIN") {
res.status(403).json({
success: false,
message: "공통 메뉴는 최고 관리자만 삭제할 수 있습니다.",
error: "Unauthorized to delete common menu",
});
return;
}
} else if (userCompanyCode !== "*") {
// 회사 관리자는 자기 회사 메뉴만 삭제 가능
if (currentMenu.company_code !== userCompanyCode) {
res.status(403).json({
success: false,
message: "해당 회사의 메뉴를 삭제할 권한이 없습니다.",
error: "Unauthorized to delete menu for this company",
});
return;
}
}
const menuObjid = Number(menuId);
// 하위 메뉴들 재귀적으로 수집
const childMenuIds = await collectAllChildMenuIds(menuObjid);
const allMenuIdsToDelete = [menuObjid, ...childMenuIds];
logger.info(`메뉴 삭제 대상: 본인(${menuObjid}) + 하위 메뉴 ${childMenuIds.length}`, {
menuName: currentMenu.menu_name_kor,
totalCount: allMenuIdsToDelete.length,
childMenuIds,
});
// 모든 삭제 대상 메뉴에 대해 관련 데이터 정리
for (const objid of allMenuIdsToDelete) {
await cleanupMenuRelatedData(objid);
}
logger.info("메뉴 관련 데이터 정리 완료", {
menuObjid,
totalCleaned: allMenuIdsToDelete.length
});
// 하위 메뉴부터 역순으로 삭제 (외래키 제약 회피)
// 가장 깊은 하위부터 삭제해야 하므로 역순으로
const reversedIds = [...allMenuIdsToDelete].reverse();
for (const objid of reversedIds) {
await query(`DELETE FROM menu_info WHERE objid = $1`, [objid]);
}
logger.info("메뉴 삭제 성공", {
deletedMenuObjid: menuObjid,
deletedMenuName: currentMenu.menu_name_kor,
totalDeleted: allMenuIdsToDelete.length,
});
const response: ApiResponse<any> = {
success: true,
message: `메뉴가 성공적으로 삭제되었습니다. (하위 메뉴 ${childMenuIds.length}개 포함)`,
data: {
objid: menuObjid.toString(),
menuNameKor: currentMenu.menu_name_kor,
deletedCount: allMenuIdsToDelete.length,
deletedChildCount: childMenuIds.length,
},
};
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: getClientIp(req),
requestPath: req.originalUrl,
});
res.status(200).json(response);
} catch (error) {
logger.error("메뉴 삭제 실패:", error);
res.status(500).json({
success: false,
message: "메뉴 삭제 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 메뉴 일괄 삭제
*/
export async function deleteMenusBatch(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const menuIds = req.body as string[];
logger.info("메뉴 일괄 삭제 요청", { menuIds, user: req.user });
if (!Array.isArray(menuIds) || menuIds.length === 0) {
res.status(400).json({
success: false,
message: "삭제할 메뉴 ID 목록이 필요합니다.",
});
return;
}
// 사용자의 company_code 확인
if (!req.user?.companyCode) {
res.status(400).json({
success: false,
message: "사용자의 회사 코드를 찾을 수 없습니다.",
error: "Missing company_code",
});
return;
}
const userCompanyCode = req.user.companyCode;
const userType = req.user.userType;
// 삭제하려는 메뉴들의 company_code 확인
const menusToDelete = await query<any>(
`SELECT objid, company_code FROM menu_info WHERE objid = ANY($1::bigint[])`,
[menuIds.map((id) => Number(id))]
);
// 권한 체크: 공통 메뉴 포함 여부 확인
const hasCommonMenu = menusToDelete.some(
(menu: any) => menu.company_code === "*"
);
if (
hasCommonMenu &&
(userCompanyCode !== "*" || userType !== "SUPER_ADMIN")
) {
res.status(403).json({
success: false,
message: "공통 메뉴는 최고 관리자만 삭제할 수 있습니다.",
error: "Unauthorized to delete common menu",
});
return;
}
// 회사 관리자는 자기 회사 메뉴만 삭제 가능
if (userCompanyCode !== "*") {
const unauthorizedMenus = menusToDelete.filter(
(menu: any) =>
menu.company_code !== userCompanyCode && menu.company_code !== "*"
);
if (unauthorizedMenus.length > 0) {
res.status(403).json({
success: false,
message: "다른 회사의 메뉴를 삭제할 권한이 없습니다.",
error: "Unauthorized to delete menus for other companies",
});
return;
}
}
// 모든 삭제 대상 메뉴 ID 수집 (하위 메뉴 포함)
const allMenuIdsToDelete = new Set<number>();
for (const menuId of menuIds) {
const objid = Number(menuId);
allMenuIdsToDelete.add(objid);
// 하위 메뉴들 재귀적으로 수집
const childMenuIds = await collectAllChildMenuIds(objid);
childMenuIds.forEach(id => allMenuIdsToDelete.add(Number(id)));
}
const allIdsArray = Array.from(allMenuIdsToDelete);
logger.info(`메뉴 일괄 삭제 대상: 선택 ${menuIds.length}개 + 하위 메뉴 포함 총 ${allIdsArray.length}`, {
selectedMenuIds: menuIds,
totalWithChildren: allIdsArray.length,
});
// 모든 삭제 대상 메뉴에 대해 관련 데이터 정리
for (const objid of allIdsArray) {
await cleanupMenuRelatedData(objid);
}
logger.info("메뉴 관련 데이터 정리 완료", {
totalCleaned: allIdsArray.length
});
// Raw Query를 사용한 메뉴 일괄 삭제
let deletedCount = 0;
let failedCount = 0;
const deletedMenus: any[] = [];
const failedMenuIds: string[] = [];
// 하위 메뉴부터 삭제하기 위해 역순으로 정렬
const reversedIds = [...allIdsArray].reverse();
// 각 메뉴 ID에 대해 삭제 시도
for (const menuObjid of reversedIds) {
try {
const result = await query<any>(
`DELETE FROM menu_info WHERE objid = $1 RETURNING *`,
[menuObjid]
);
if (result.length > 0) {
deletedCount++;
deletedMenus.push({
...result[0],
objid: result[0].objid.toString(),
});
} else {
failedCount++;
failedMenuIds.push(String(menuObjid));
}
} catch (error) {
logger.error(`메뉴 삭제 실패 (ID: ${menuObjid}):`, error);
failedCount++;
failedMenuIds.push(String(menuObjid));
}
}
logger.info("메뉴 일괄 삭제 완료", {
requested: menuIds.length,
totalWithChildren: allIdsArray.length,
deletedCount,
failedCount,
failedMenuIds,
});
const response: ApiResponse<any> = {
success: true,
message: `메뉴 일괄 삭제 완료: ${deletedCount}개 삭제, ${failedCount}개 실패`,
data: {
deletedCount,
failedCount,
total: menuIds.length,
deletedMenus,
failedMenuIds,
},
};
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: getClientIp(req),
requestPath: req.originalUrl,
});
}
res.status(200).json(response);
} catch (error) {
logger.error("메뉴 일괄 삭제 실패:", error);
res.status(500).json({
success: false,
message: "메뉴 일괄 삭제 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* 메뉴 활성/비활성 토글
*/
export async function toggleMenuStatus(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { menuId } = req.params;
logger.info(`메뉴 상태 토글 요청: menuId = ${menuId}`, { user: req.user });
// 사용자의 company_code 확인
if (!req.user?.companyCode) {
res.status(400).json({
success: false,
message: "사용자의 회사 코드를 찾을 수 없습니다.",
error: "Missing company_code",
});
return;
}
const userCompanyCode = req.user.companyCode;
const userType = req.user.userType;
// 현재 상태 및 회사 코드 조회
const currentMenu = await queryOne<any>(
`SELECT objid, status, company_code FROM menu_info WHERE objid = $1`,
[Number(menuId)]
);
if (!currentMenu) {
res.status(404).json({
success: false,
message: `메뉴를 찾을 수 없습니다: ${menuId}`,
error: "Menu not found",
});
return;
}
// 공통 메뉴(company_code = '*')는 최고 관리자만 상태 변경 가능
if (currentMenu.company_code === "*") {
if (userCompanyCode !== "*" || userType !== "SUPER_ADMIN") {
res.status(403).json({
success: false,
message: "공통 메뉴는 최고 관리자만 상태를 변경할 수 있습니다.",
error: "Unauthorized to toggle common menu status",
});
return;
}
} else if (userCompanyCode !== "*") {
// 회사 관리자는 자기 회사 메뉴만 상태 변경 가능
if (currentMenu.company_code !== userCompanyCode) {
res.status(403).json({
success: false,
message: "해당 회사의 메뉴 상태를 변경할 권한이 없습니다.",
error: "Unauthorized to toggle menu status for this company",
});
return;
}
}
// 상태 토글 (active <-> inactive)
const currentStatus = currentMenu.status;
const newStatus = currentStatus === "active" ? "inactive" : "active";
// 상태 업데이트
const [updatedMenu] = await query<any>(
`UPDATE menu_info SET status = $1 WHERE objid = $2 RETURNING *`,
[newStatus, Number(menuId)]
);
logger.info("메뉴 상태 토글 성공", {
menuId,
oldStatus: currentStatus,
newStatus,
});
const result = newStatus === "active" ? "활성화" : "비활성화";
const response: ApiResponse<string> = {
success: true,
message: `메뉴가 ${result}되었습니다.`,
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: getClientIp(req),
requestPath: req.originalUrl,
});
res.status(200).json(response);
} catch (error) {
logger.error("메뉴 상태 토글 실패:", error);
res.status(500).json({
success: false,
message: "메뉴 상태 변경에 실패하였습니다.",
error: error instanceof Error ? error.message : "Unknown error",
errorCode: "MENU_TOGGLE_ERROR",
});
}
}
/**
* 회사 목록 조회 (실제 데이터베이스에서)
*/
export async function getCompanyListFromDB(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
logger.debug("회사 목록 조회 요청 (Raw Query)", { user: req.user });
// Raw Query로 회사 목록 조회
const companies = await query<any>(
` SELECT
company_code,
company_name,
business_registration_number,
representative_name,
representative_phone,
email,
website,
address,
writer,
regdate,
status
FROM company_mng
ORDER BY regdate DESC`
);
logger.debug("회사 목록 조회 성공 (Raw Query)", { count: companies.length });
const response: ApiResponse<any> = {
success: true,
message: "회사 목록 조회 성공",
data: companies,
total: companies.length,
};
res.status(200).json(response);
} catch (error) {
logger.error("회사 목록 조회 실패 (Raw Query):", error);
res.status(500).json({
success: false,
message: "회사 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
}
/**
* GET /api/admin/departments
* 부서 목록 조회 API
* 기존 Java AdminController의 부서 목록 조회 기능 포팅
*/
export const getDepartmentList = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
logger.info("부서 목록 조회 요청", {
query: req.query,
user: req.user,
});
const { companyCode, status, search } = req.query;
// Raw Query를 사용한 부서 목록 조회
let whereConditions: string[] = [];
let queryParams: any[] = [];
let paramIndex = 1;
// 회사 코드 필터
if (companyCode) {
whereConditions.push(`company_code = $${paramIndex}`);
queryParams.push(companyCode);
paramIndex++;
}
// 상태 필터
if (status) {
whereConditions.push(`status = $${paramIndex}`);
queryParams.push(status);
paramIndex++;
}
// 검색 조건
if (search && typeof search === "string" && search.trim()) {
whereConditions.push(`(
dept_name ILIKE $${paramIndex} OR
dept_code ILIKE $${paramIndex} OR
location_name ILIKE $${paramIndex}
)`);
queryParams.push(`%${search.trim()}%`);
paramIndex++;
}
const whereClause =
whereConditions.length > 0
? `WHERE ${whereConditions.join(" AND ")}`
: "";
const departments = await query<any>(
`SELECT
dept_code,
parent_dept_code,
dept_name,
master_sabun,
master_user_id,
location,
location_name,
regdate,
data_type,
status,
sales_yn,
company_code,
company_name
FROM dept_info
${whereClause}
ORDER BY parent_dept_code ASC NULLS FIRST, dept_name ASC`,
queryParams
);
// 부서 트리 구조 생성
const deptMap = new Map();
const rootDepartments: any[] = [];
// 모든 부서를 맵에 저장
departments.forEach((dept) => {
deptMap.set(dept.dept_code, {
deptCode: dept.dept_code,
deptName: dept.dept_name,
parentDeptCode: dept.parent_dept_code,
masterSabun: dept.master_sabun,
masterUserId: dept.master_user_id,
location: dept.location,
locationName: dept.location_name,
regdate: dept.regdate ? new Date(dept.regdate).toISOString() : null,
dataType: dept.data_type,
status: dept.status || "active",
salesYn: dept.sales_yn,
companyCode: dept.company_code,
companyName: dept.company_name,
children: [],
});
});
// 부서 트리 구조 생성
departments.forEach((dept) => {
const deptNode = deptMap.get(dept.dept_code);
if (dept.parent_dept_code && deptMap.has(dept.parent_dept_code)) {
// 상위 부서가 있으면 children에 추가
const parentDept = deptMap.get(dept.parent_dept_code);
parentDept.children.push(deptNode);
} else {
// 상위 부서가 없으면 루트 부서로 추가
rootDepartments.push(deptNode);
}
});
const response = {
success: true,
data: {
departments: rootDepartments,
flatList: departments.map((dept) => ({
deptCode: dept.dept_code,
deptName: dept.dept_name,
parentDeptCode: dept.parent_dept_code,
masterSabun: dept.master_sabun,
masterUserId: dept.master_user_id,
location: dept.location,
locationName: dept.location_name,
regdate: dept.regdate ? new Date(dept.regdate).toISOString() : null,
dataType: dept.data_type,
status: dept.status || "active",
salesYn: dept.sales_yn,
companyCode: dept.company_code,
companyName: dept.company_name,
})),
},
message: "부서 목록 조회 성공",
total: departments.length,
};
logger.info("부서 목록 조회 성공", {
totalCount: departments.length,
rootCount: rootDepartments.length,
});
res.status(200).json(response);
} catch (error) {
logger.error("부서 목록 조회 실패", { error });
res.status(500).json({
success: false,
message: "부서 목록 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/**
* GET /api/admin/users/:userId
* 사용자 상세 조회 API
* 기존 Java AdminController의 사용자 상세 조회 기능 포팅
*/
export const getUserInfo = async (req: AuthenticatedRequest, res: Response) => {
try {
const { userId } = req.params;
logger.info(`사용자 상세 조회 요청 - userId: ${userId}`, {
user: req.user,
});
if (!userId) {
res.status(400).json({
success: false,
message: "사용자 ID가 필요합니다.",
error: {
code: "USER_ID_REQUIRED",
details: "userId parameter is required",
},
});
return;
}
// Raw Query를 사용한 사용자 상세 정보 조회
const user = await queryOne<any>(
`SELECT * FROM user_info WHERE user_id = $1`,
[userId]
);
if (!user) {
res.status(404).json({
success: false,
message: "사용자를 찾을 수 없습니다.",
error: {
code: "USER_NOT_FOUND",
details: `User ID: ${userId}`,
},
});
return;
}
// 부서 정보 별도 조회
const deptInfo = user.dept_code
? await queryOne<any>(
`SELECT
dept_name,
parent_dept_code,
location,
location_name,
sales_yn,
company_name
FROM dept_info
WHERE dept_code = $1`,
[user.dept_code]
)
: null;
// 응답 데이터 가공
const userInfo = {
sabun: user.sabun,
userId: user.user_id,
userName: user.user_name,
userNameEng: user.user_name_eng,
userNameCn: user.user_name_cn,
deptCode: user.dept_code,
deptName: user.dept_name,
positionCode: user.position_code,
positionName: user.position_name,
email: user.email,
tel: user.tel,
cellPhone: user.cell_phone,
userType: user.user_type,
userTypeName: user.user_type_name,
regdate: user.regdate ? new Date(user.regdate).toISOString() : null,
status: user.status || "active",
endDate: user.end_date ? new Date(user.end_date).toISOString() : null,
faxNo: user.fax_no,
partnerObjid: user.partner_objid,
rank: user.rank,
photo: user.photo
? `data:image/jpeg;base64,${Buffer.from(user.photo).toString("base64")}`
: null,
locale: user.locale,
companyCode: user.company_code,
dataType: user.data_type,
// 부서 정보
deptInfo: {
deptCode: user.dept_code,
deptName: deptInfo?.dept_name,
parentDeptCode: deptInfo?.parent_dept_code,
location: deptInfo?.location,
locationName: deptInfo?.location_name,
salesYn: deptInfo?.sales_yn,
companyName: deptInfo?.company_name,
},
};
const response = {
success: true,
data: userInfo,
message: "사용자 상세 정보 조회 성공",
};
logger.info("사용자 상세 정보 조회 성공", {
userId,
userName: user.user_name,
});
res.status(200).json(response);
} catch (error) {
logger.error("사용자 상세 정보 조회 실패", {
error,
userId: req.params.userId,
});
res.status(500).json({
success: false,
message: "사용자 상세 정보 조회 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/**
* POST /api/admin/users/check-duplicate
* 사용자 ID 중복 체크 API
* 기존 Java AdminController의 checkDuplicateUserId 기능 포팅
*/
export const checkDuplicateUserId = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { userId } = req.body;
logger.info(`사용자 ID 중복 체크 요청 - userId: ${userId}`, {
user: req.user,
});
if (!userId) {
res.status(400).json({
success: false,
message: "사용자 ID가 필요합니다.",
error: {
code: "USER_ID_REQUIRED",
details: "userId is required",
},
});
return;
}
// Raw Query로 사용자 ID 중복 체크
const existingUser = await queryOne<any>(
`SELECT user_id FROM user_info WHERE user_id = $1`,
[userId]
);
const isDuplicate = !!existingUser;
const count = isDuplicate ? 1 : 0;
const response = {
success: true,
data: {
isDuplicate,
count,
message: isDuplicate
? "이미 사용 중인 사용자 ID입니다."
: "사용 가능한 사용자 ID입니다.",
},
message: "사용자 ID 중복 체크 완료",
};
logger.info("사용자 ID 중복 체크 완료", {
userId,
isDuplicate,
count,
});
res.status(200).json(response);
} catch (error) {
logger.error("사용자 ID 중복 체크 실패", {
error,
userId: req.body?.userId,
});
res.status(500).json({
success: false,
message: "사용자 ID 중복 체크 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/**
* POST /api/admin/users
* 사용자 등록/수정 API
* 기존 Java AdminController의 saveUserInfo 기능 포팅
*/
/**
* GET /api/admin/users/:userId/history
* 사용자 변경이력 조회 API
* 기존 Java AdminController.getUserHistory() 포팅
*/
export const getUserHistory = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { userId } = req.params;
const { page = 1, countPerPage = 10 } = req.query;
logger.info(`사용자 변경이력 조회 요청 - userId: ${userId}`, {
page,
countPerPage,
user: req.user,
});
if (!userId) {
res.status(400).json({
success: false,
message: "사용자 ID가 필요합니다.",
error: {
code: "USER_ID_REQUIRED",
details: "userId parameter is required",
},
});
return;
}
// PostgreSQL 클라이언트 생성
const client = new Client({
connectionString:
process.env.DATABASE_URL ||
"postgresql://postgres:postgres@localhost:5432/ilshin",
});
await client.connect();
try {
// 페이징 계산
const currentPage = Number(page);
const pageSize = Number(countPerPage);
const pageStart = (currentPage - 1) * pageSize + 1;
const pageEnd = currentPage * pageSize;
// 전체 건수 조회 쿼리 (기존 backend와 동일한 로직)
const countQuery = `
SELECT
CEIL(TOTAL_CNT::float / $1)::integer AS MAX_PAGE_SIZE,
TOTAL_CNT
FROM (
SELECT
COUNT(1) AS TOTAL_CNT
FROM user_info_history
WHERE user_id = $2
) A
`;
const countResult = await client.query(countQuery, [pageSize, userId]);
const countData = countResult.rows[0] || {
total_cnt: 0,
max_page_size: 1,
};
// 변경이력 목록 조회 쿼리 (기존 backend와 동일한 로직)
const historyQuery = `
SELECT
A.*
FROM (
SELECT
A.*,
ROW_NUMBER() OVER (ORDER BY RM DESC) AS RNUM
FROM (
SELECT
T.*,
ROW_NUMBER() OVER (ORDER BY regdate) AS RM,
(SELECT user_name FROM user_info UI WHERE T.writer = UI.user_id) AS writer_name,
TO_CHAR(T.regdate, 'YYYY-MM-DD HH24:MI:SS') AS reg_date_title
FROM
user_info_history T
WHERE user_id = $1
) A
WHERE 1=1
) A
WHERE 1=1
AND RNUM::integer <= $2
AND RNUM::integer >= $3
ORDER BY RM DESC
`;
const historyResult = await client.query(historyQuery, [
userId,
pageEnd,
pageStart,
]);
// 응답 데이터 가공
const historyList = historyResult.rows.map((row) => ({
sabun: row.sabun || "",
userId: row.user_id || "",
userName: row.user_name || "",
deptCode: row.dept_code || "",
deptName: row.dept_name || "",
userTypeName: row.user_type_name || "",
historyType: row.history_type || "",
writer: row.writer || "",
writerName: row.writer_name || "",
regDate: row.regdate,
regDateTitle: row.reg_date_title || "",
status: row.status || "",
rowNum: row.rnum,
}));
logger.info(
`사용자 변경이력 조회 완료 - userId: ${userId}, 조회건수: ${historyList.length}, 전체: ${countData.total_cnt}`
);
const response: ApiResponse<any[]> = {
success: true,
data: historyList,
total: Number(countData.total_cnt),
pagination: {
page: currentPage,
limit: pageSize,
total: Number(countData.total_cnt),
totalPages: Number(countData.max_page_size),
},
};
res.status(200).json(response);
} finally {
await client.end();
}
} catch (error) {
logger.error("사용자 변경이력 조회 중 오류 발생", error);
const response: ApiResponse<null> = {
success: false,
message: "사용자 변경이력 조회 중 오류가 발생했습니다.",
error: {
code: "USER_HISTORY_FETCH_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
};
res.status(500).json(response);
}
};
/**
* PATCH /api/admin/users/:userId/status
* 사용자 상태 변경 API (부분 수정)
* 기존 Java AdminController.changeUserStatus() 포팅
*/
export const changeUserStatus = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const { userId } = req.params;
const { status } = req.body;
logger.info("사용자 상태 변경 요청", { userId, status, user: req.user });
// 필수 파라미터 검증
if (!userId || !status) {
res.status(400).json({
result: false,
msg: "사용자 ID와 상태는 필수입니다.",
});
return;
}
// 상태 값 검증
if (!["active", "inactive"].includes(status)) {
res.status(400).json({
result: false,
msg: "유효하지 않은 상태값입니다. (active, inactive만 허용)",
});
return;
}
// Raw Query를 사용한 사용자 상태 변경
// 1. 사용자 존재 여부 확인
const currentUser = await queryOne<any>(
`SELECT user_id, user_name, status FROM user_info WHERE user_id = $1`,
[userId]
);
if (!currentUser) {
res.status(404).json({
result: false,
msg: "사용자를 찾을 수 없습니다.",
});
return;
}
// 2. 상태 변경 실행
// active/inactive에 따른 END_DATE 처리
const endDate = status === "inactive" ? new Date() : null;
const updateResult = await query<any>(
`UPDATE user_info
SET status = $1, end_date = $2
WHERE user_id = $3
RETURNING *`,
[status, endDate, userId]
);
if (updateResult.length > 0) {
// 사용자 이력 저장은 user_info_history 테이블이 @@ignore 상태이므로 생략
logger.info("사용자 상태 변경 성공", {
userId,
oldStatus: currentUser.status,
newStatus: status,
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: getClientIp(req),
requestPath: req.originalUrl,
});
res.json({
result: true,
msg: `사용자 상태가 ${status === "active" ? "활성" : "비활성"}으로 변경되었습니다.`,
});
} else {
res.status(400).json({
result: false,
msg: "사용자 상태 변경에 실패했습니다.",
});
}
} catch (error) {
logger.error("사용자 상태 변경 중 오류 발생", {
error,
userId: req.params.userId,
status: req.body.status,
});
res.status(500).json({
result: false,
msg: "시스템 오류가 발생했습니다.",
});
}
};
export const saveUser = async (req: AuthenticatedRequest, res: Response) => {
try {
const userData = req.body;
const isUpdate = req.method === "PUT"; // PUT 요청이면 수정
logger.info("사용자 저장 요청", {
userData,
user: req.user,
isUpdate,
method: req.method,
});
// 필수 필드 검증
let requiredFields = ["userId", "userName"];
// 신규 등록 시에만 비밀번호 필수
if (!isUpdate) {
requiredFields.push("userPassword");
}
for (const field of requiredFields) {
if (!userData[field] || userData[field].trim() === "") {
res.status(400).json({
success: false,
message: `${field}는 필수 입력 항목입니다.`,
error: {
code: "REQUIRED_FIELD_MISSING",
details: `Required field: ${field}`,
},
});
return;
}
}
// 비밀번호 암호화 (비밀번호가 제공된 경우에만)
let encryptedPassword = null;
if (userData.userPassword) {
encryptedPassword = await EncryptUtil.encrypt(userData.userPassword);
}
// Raw Query를 사용한 사용자 저장 (upsert with ON CONFLICT)
const updatePasswordClause = encryptedPassword ? "user_password = $4," : "";
const [savedUser] = await query<any>(
`INSERT INTO user_info (
user_id, user_name, user_name_eng, user_password,
dept_code, dept_name, position_code, position_name,
email, tel, cell_phone, user_type, user_type_name,
sabun, company_code, status, locale, regdate
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18)
ON CONFLICT (user_id) DO UPDATE SET
user_name = $2,
user_name_eng = $3,
${updatePasswordClause}
dept_code = $5,
dept_name = $6,
position_code = $7,
position_name = $8,
email = $9,
tel = $10,
cell_phone = $11,
user_type = $12,
user_type_name = $13,
sabun = $14,
company_code = $15,
status = $16,
locale = $17
RETURNING *`,
[
userData.userId,
userData.userName,
userData.userNameEng || null,
encryptedPassword || "", // 빈 문자열로 넣되, UPDATE에서는 조건부로 제외
userData.deptCode || null,
userData.deptName || null,
userData.positionCode || null,
userData.positionName || null,
userData.email || null,
userData.tel || null,
userData.cellPhone || null,
userData.userType || null,
userData.userTypeName || null,
userData.sabun || null,
userData.companyCode || null,
userData.status || "active",
userData.locale || null,
new Date(),
]
);
// 기존 사용자인지 새 사용자인지 확인 (regdate로 판단)
const isExistingUser =
savedUser.regdate &&
new Date(savedUser.regdate).getTime() < Date.now() - 1000;
logger.info(
isExistingUser ? "사용자 정보 수정 완료" : "새 사용자 등록 완료",
{
userId: userData.userId,
}
);
const response = {
success: true,
result: true,
message: isExistingUser
? "사용자 정보가 수정되었습니다."
: "사용자가 등록되었습니다.",
data: {
userId: userData.userId,
isUpdate: isExistingUser,
},
};
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: getClientIp(req),
requestPath: req.originalUrl,
});
res.status(200).json(response);
} catch (error) {
logger.error("사용자 저장 실패", { error, userData: req.body });
res.status(500).json({
success: false,
result: false,
message: "사용자 저장 중 오류가 발생했습니다.",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/**
* POST /api/admin/companies
* 회사 등록 API
* 기존 Java AdminController의 회사 등록 기능 포팅
*/
export const createCompany = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
logger.info("회사 등록 요청", {
body: req.body,
user: req.user,
});
const { company_name } = req.body;
// 필수 입력값 검증
if (!company_name || !company_name.trim()) {
res.status(400).json({
success: false,
message: "회사명을 입력해주세요.",
errorCode: "COMPANY_NAME_REQUIRED",
});
return;
}
// Raw Query로 회사명 중복 체크
const existingCompany = await queryOne<any>(
`SELECT company_code FROM company_mng WHERE company_name = $1`,
[company_name.trim()]
);
// 사업자등록번호 유효성 검증
const businessNumberValidation = validateBusinessNumber(
req.body.business_registration_number?.trim() || ""
);
if (!businessNumberValidation.isValid) {
res.status(400).json({
success: false,
message: businessNumberValidation.message,
errorCode: "INVALID_BUSINESS_NUMBER",
});
return;
}
// Raw Query로 사업자등록번호 중복 체크
const existingBusinessNumber = await queryOne<any>(
`SELECT company_code FROM company_mng WHERE business_registration_number = $1`,
[req.body.business_registration_number?.trim()]
);
if (existingCompany) {
res.status(400).json({
success: false,
message: "이미 등록된 회사명입니다.",
errorCode: "COMPANY_NAME_DUPLICATE",
});
return;
}
if (existingBusinessNumber) {
res.status(400).json({
success: false,
message: "이미 등록된 사업자등록번호입니다.",
errorCode: "DUPLICATE_BUSINESS_NUMBER",
});
return;
}
// PostgreSQL 클라이언트 생성 (복잡한 코드 생성 쿼리용)
const client = new Client({
connectionString:
process.env.DATABASE_URL ||
"postgresql://postgres:postgres@localhost:5432/ilshin",
});
await client.connect();
try {
// 회사 코드 생성 (COMPANY_1, COMPANY_2, ...)
const codeQuery = `
SELECT COALESCE(MAX(CAST(SUBSTRING(company_code FROM 9) AS INTEGER)), 0) + 1 as next_number
FROM company_mng
WHERE company_code LIKE 'COMPANY_%'
`;
const codeResult = await client.query(codeQuery);
const nextNumber = codeResult.rows[0].next_number;
const companyCode = `COMPANY_${nextNumber}`;
// 회사 정보 저장
const insertQuery = `
INSERT INTO company_mng (
company_code,
company_name,
business_registration_number,
representative_name,
representative_phone,
email,
website,
address,
writer,
regdate,
status
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
RETURNING *
`;
const writer = req.user
? `${req.user.userName}(${req.user.userId})`
: "시스템";
const insertValues = [
companyCode,
company_name.trim(),
req.body.business_registration_number?.trim() || null,
req.body.representative_name?.trim() || null,
req.body.representative_phone?.trim() || null,
req.body.email?.trim() || null,
req.body.website?.trim() || null,
req.body.address?.trim() || null,
writer,
new Date(),
"active",
];
const insertResult = await client.query(insertQuery, insertValues);
const createdCompany = insertResult.rows[0];
// 회사 폴더 초기화 (파일 시스템)
try {
FileSystemManager.initializeCompanyFolder(createdCompany.company_code);
logger.info("회사 폴더 초기화 완료", {
companyCode: createdCompany.company_code,
});
} catch (folderError) {
logger.warn("회사 폴더 초기화 실패 (회사 등록은 성공)", {
companyCode: createdCompany.company_code,
error: folderError,
});
}
// 다국어 카테고리 자동 생성
try {
const { MultiLangService } = await import("../services/multilangService");
const multilangService = new MultiLangService();
await multilangService.ensureCompanyCategory(
createdCompany.company_code,
createdCompany.company_name
);
logger.info("회사 다국어 카테고리 생성 완료", {
companyCode: createdCompany.company_code,
});
} catch (categoryError) {
logger.warn("회사 다국어 카테고리 생성 실패 (회사 등록은 성공)", {
companyCode: createdCompany.company_code,
error: categoryError,
});
}
logger.info("회사 등록 성공", {
companyCode: createdCompany.company_code,
companyName: createdCompany.company_name,
writer: createdCompany.writer,
});
const response = {
success: true,
message: "회사가 성공적으로 등록되었습니다.",
data: {
company_code: createdCompany.company_code,
company_name: createdCompany.company_name,
writer: createdCompany.writer,
regdate: createdCompany.regdate,
status: createdCompany.status,
},
};
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: getClientIp(req),
requestPath: req.originalUrl,
});
res.status(201).json(response);
} finally {
await client.end();
}
} catch (error) {
logger.error("회사 등록 실패", { error, body: req.body });
res.status(500).json({
success: false,
message: "회사 등록 중 오류가 발생했습니다.",
errorCode: "COMPANY_CREATE_ERROR",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/**
* GET /api/admin/companies/:companyCode
* 회사 정보 조회 API
*/
export const getCompanyByCode = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { companyCode } = req.params;
logger.info("회사 정보 조회 요청", {
companyCode,
user: req.user,
});
// Raw Query로 회사 정보 조회
const company = await queryOne<any>(
`SELECT * FROM company_mng WHERE company_code = $1`,
[companyCode]
);
if (!company) {
res.status(404).json({
success: false,
message: "해당 회사를 찾을 수 없습니다.",
errorCode: "COMPANY_NOT_FOUND",
});
return;
}
logger.info("회사 정보 조회 성공", {
companyCode: company.company_code,
companyName: company.company_name,
});
const response = {
success: true,
message: "회사 정보 조회 성공",
data: {
companyCode: company.company_code,
companyName: company.company_name,
businessRegistrationNumber: company.business_registration_number,
representativeName: company.representative_name,
representativePhone: company.representative_phone,
email: company.email,
website: company.website,
address: company.address,
status: company.status,
writer: company.writer,
regdate: company.regdate,
},
};
res.status(200).json(response);
} catch (error) {
logger.error("회사 정보 조회 실패", {
error,
companyCode: req.params.companyCode,
});
res.status(500).json({
success: false,
message: "회사 정보 조회 중 오류가 발생했습니다.",
errorCode: "COMPANY_GET_ERROR",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/**
* PUT /api/admin/companies/:companyCode
* 회사 정보 수정 API
*/
export const updateCompany = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { companyCode } = req.params;
const {
company_name,
business_registration_number,
representative_name,
representative_phone,
email,
website,
address,
status,
} = req.body;
logger.info("회사 정보 수정 요청", {
companyCode,
body: req.body,
user: req.user,
});
// 필수 입력값 검증
if (!company_name || !company_name.trim()) {
res.status(400).json({
success: false,
message: "회사명을 입력해주세요.",
errorCode: "COMPANY_NAME_REQUIRED",
});
return;
}
// Raw Query로 회사명 중복 체크 (자기 자신 제외)
const duplicateCompany = await queryOne<any>(
`SELECT company_code FROM company_mng
WHERE company_name = $1 AND company_code != $2`,
[company_name.trim(), companyCode]
);
if (duplicateCompany) {
res.status(400).json({
success: false,
message: "이미 등록된 회사명입니다.",
errorCode: "COMPANY_NAME_DUPLICATE",
});
return;
}
// 사업자등록번호 중복 체크 및 유효성 검증 (자기 자신 제외)
if (business_registration_number && business_registration_number.trim()) {
// 유효성 검증
const businessNumberValidation = validateBusinessNumber(
business_registration_number.trim()
);
if (!businessNumberValidation.isValid) {
res.status(400).json({
success: false,
message: businessNumberValidation.message,
errorCode: "INVALID_BUSINESS_NUMBER",
});
return;
}
// 중복 체크
const duplicateBusinessNumber = await queryOne<any>(
`SELECT company_code FROM company_mng
WHERE business_registration_number = $1 AND company_code != $2`,
[business_registration_number.trim(), companyCode]
);
if (duplicateBusinessNumber) {
res.status(400).json({
success: false,
message: "이미 등록된 사업자등록번호입니다.",
errorCode: "DUPLICATE_BUSINESS_NUMBER",
});
return;
}
}
const beforeCompany = await queryOne<any>(
`SELECT company_name, status FROM company_mng WHERE company_code = $1`,
[companyCode]
);
const result = await query<any>(
`UPDATE company_mng
SET
company_name = $1,
business_registration_number = $2,
representative_name = $3,
representative_phone = $4,
email = $5,
website = $6,
address = $7,
status = $8
WHERE company_code = $9
RETURNING *`,
[
company_name.trim(),
business_registration_number?.trim() || null,
representative_name?.trim() || null,
representative_phone?.trim() || null,
email?.trim() || null,
website?.trim() || null,
address?.trim() || null,
status || "active",
companyCode,
]
);
if (result.length === 0) {
res.status(404).json({
success: false,
message: "해당 회사를 찾을 수 없습니다.",
errorCode: "COMPANY_NOT_FOUND",
});
return;
}
const updatedCompany = result[0];
logger.info("회사 정보 수정 성공", {
companyCode: updatedCompany.company_code,
companyName: updatedCompany.company_name,
status: updatedCompany.status,
});
const response = {
success: true,
message: "회사 정보가 수정되었습니다.",
data: {
company_code: updatedCompany.company_code,
company_name: updatedCompany.company_name,
writer: updatedCompany.writer,
regdate: updatedCompany.regdate,
status: updatedCompany.status,
},
};
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: getClientIp(req),
requestPath: req.originalUrl,
});
res.status(200).json(response);
} catch (error) {
logger.error("회사 정보 수정 실패", { error, body: req.body });
res.status(500).json({
success: false,
message: "회사 정보 수정 중 오류가 발생했습니다.",
errorCode: "COMPANY_UPDATE_ERROR",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/**
* DELETE /api/admin/companies/:companyCode
* 회사 삭제 API
*/
export const deleteCompany = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { companyCode } = req.params;
logger.info("회사 삭제 요청", {
companyCode,
user: req.user,
});
// Raw Query로 회사 삭제
const result = await query<any>(
`DELETE FROM company_mng
WHERE company_code = $1
RETURNING company_code, company_name`,
[companyCode]
);
if (result.length === 0) {
res.status(404).json({
success: false,
message: "해당 회사를 찾을 수 없습니다.",
errorCode: "COMPANY_NOT_FOUND",
});
return;
}
const deletedCompany = result[0];
logger.info("회사 삭제 성공", {
companyCode: deletedCompany.company_code,
companyName: deletedCompany.company_name,
});
const response = {
success: true,
message: "회사가 삭제되었습니다.",
data: {
company_code: deletedCompany.company_code,
company_name: deletedCompany.company_name,
},
};
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: getClientIp(req),
requestPath: req.originalUrl,
});
res.status(200).json(response);
} catch (error) {
logger.error("회사 삭제 실패", { error });
res.status(500).json({
success: false,
message: "회사 삭제 중 오류가 발생했습니다.",
errorCode: "COMPANY_DELETE_ERROR",
error: error instanceof Error ? error.message : "Unknown error",
});
}
};
/**
* POST /api/admin/users/reset-password
* 사용자 비밀번호 초기화 API
* 기존 Java AdminController.resetUserPassword() 포팅
*/
export const updateProfile = async (
req: AuthenticatedRequest,
res: Response
) => {
try {
const userId = req.user?.userId;
if (!userId) {
res.status(401).json({
result: false,
error: {
code: "TOKEN_MISSING",
details: "인증 토큰이 필요합니다.",
},
});
return;
}
const {
userName,
userNameEng,
userNameCn,
email,
tel,
cellPhone,
photo,
locale,
} = req.body;
// 업데이트할 필드와 값 준비
const updateFields: string[] = [];
const updateValues: any[] = [];
let paramIndex = 1;
if (userName !== undefined) {
updateFields.push(`user_name = $${paramIndex}`);
updateValues.push(userName);
paramIndex++;
}
if (userNameEng !== undefined) {
updateFields.push(`user_name_eng = $${paramIndex}`);
updateValues.push(userNameEng);
paramIndex++;
}
if (userNameCn !== undefined) {
updateFields.push(`user_name_cn = $${paramIndex}`);
updateValues.push(userNameCn);
paramIndex++;
}
if (email !== undefined) {
updateFields.push(`email = $${paramIndex}`);
updateValues.push(email);
paramIndex++;
}
if (tel !== undefined) {
updateFields.push(`tel = $${paramIndex}`);
updateValues.push(tel);
paramIndex++;
}
if (cellPhone !== undefined) {
updateFields.push(`cell_phone = $${paramIndex}`);
updateValues.push(cellPhone);
paramIndex++;
}
// photo 데이터 처리 (Base64를 Buffer로 변환하여 저장)
if (photo !== undefined) {
if (photo && typeof photo === "string") {
try {
// Base64 헤더 제거 (data:image/jpeg;base64, 등)
const base64Data = photo.replace(/^data:image\/[a-z]+;base64,/, "");
// Base64를 Buffer로 변환
updateFields.push(`photo = $${paramIndex}`);
updateValues.push(Buffer.from(base64Data, "base64"));
paramIndex++;
} catch (error) {
console.error("Base64 이미지 처리 오류:", error);
updateFields.push(`photo = $${paramIndex}`);
updateValues.push(null);
paramIndex++;
}
} else {
updateFields.push(`photo = $${paramIndex}`);
updateValues.push(null);
paramIndex++;
}
}
if (locale !== undefined) {
// language_master 테이블에서 유효한 언어 코드인지 확인
const validLang = await queryOne<{ lang_code: string }>(
"SELECT lang_code FROM language_master WHERE lang_code = $1 AND is_active = 'Y'",
[locale]
);
if (!validLang) {
res.status(400).json({
result: false,
error: {
code: "INVALID_LOCALE",
details: `유효하지 않은 로케일입니다: ${locale}`,
},
});
return;
}
updateFields.push(`locale = $${paramIndex}`);
updateValues.push(locale);
paramIndex++;
}
// 업데이트할 데이터가 없으면 에러
if (updateFields.length === 0) {
res.status(400).json({
result: false,
error: {
code: "NO_DATA",
details: "업데이트할 데이터가 없습니다.",
},
});
return;
}
// Raw Query로 데이터베이스 업데이트
updateValues.push(userId);
await query(
`UPDATE user_info SET ${updateFields.join(", ")} WHERE user_id = $${paramIndex}`,
updateValues
);
// 업데이트된 사용자 정보 조회
const updatedUser = await queryOne<any>(
`SELECT
user_id, user_name, user_name_eng, user_name_cn,
dept_code, dept_name, position_code, position_name,
email, tel, cell_phone, user_type, user_type_name,
photo, locale
FROM user_info
WHERE user_id = $1`,
[userId]
);
// photo가 Buffer 타입인 경우 Base64로 변환
const responseData = {
...updatedUser,
photo: updatedUser?.photo
? `data:image/jpeg;base64,${Buffer.from(updatedUser.photo).toString("base64")}`
: 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: getClientIp(req),
requestPath: req.originalUrl,
});
res.json({
result: true,
message: "프로필이 성공적으로 업데이트되었습니다.",
data: responseData,
});
} catch (error) {
console.error("프로필 업데이트 오류:", error);
res.status(500).json({
result: false,
error: {
code: "UPDATE_FAILED",
details: "프로필 업데이트 중 오류가 발생했습니다.",
},
});
}
};
export const resetUserPassword = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
const { userId, newPassword } = req.body;
logger.info("비밀번호 초기화 요청", { userId, user: req.user });
// 입력값 검증
if (!userId || !userId.trim()) {
res.status(400).json({
result: false,
msg: "사용자 ID가 필요합니다.",
});
return;
}
if (!newPassword || !newPassword.trim()) {
res.status(400).json({
success: false,
result: false,
message: "새 비밀번호가 필요합니다.",
msg: "새 비밀번호가 필요합니다.",
});
return;
}
// 비밀번호 길이 검증 (최소 4자)
if (newPassword.length < 4) {
res.status(400).json({
success: false,
result: false,
message: "비밀번호는 최소 4자 이상이어야 합니다.",
msg: "비밀번호는 최소 4자 이상이어야 합니다.",
});
return;
}
try {
// 1. Raw Query로 사용자 존재 여부 확인
const currentUser = await queryOne<any>(
`SELECT user_id, user_name FROM user_info WHERE user_id = $1`,
[userId]
);
if (!currentUser) {
res.status(404).json({
success: false,
result: false,
message: "사용자를 찾을 수 없습니다.",
msg: "사용자를 찾을 수 없습니다.",
});
return;
}
// 2. 비밀번호 암호화 (기존 Java 로직과 동일)
let encryptedPassword: string;
try {
// EncryptUtil과 동일한 암호화 사용
const crypto = require("crypto");
const keyName = "ILJIAESSECRETKEY";
const algorithm = "aes-128-ecb";
// AES-128-ECB 암호화
const cipher = crypto.createCipher(algorithm, keyName);
let encrypted = cipher.update(newPassword, "utf8", "hex");
encrypted += cipher.final("hex");
encryptedPassword = encrypted.toUpperCase();
} catch (encryptError) {
logger.error("비밀번호 암호화 중 오류 발생", {
error: encryptError,
userId,
});
res.status(500).json({
success: false,
result: false,
message: "비밀번호 암호화 중 오류가 발생했습니다.",
msg: "비밀번호 암호화 중 오류가 발생했습니다.",
});
return;
}
// 3. Raw Query로 비밀번호 업데이트 실행
const updateResult = await query<any>(
`UPDATE user_info SET user_password = $1 WHERE user_id = $2 RETURNING *`,
[encryptedPassword, userId]
);
if (updateResult.length > 0) {
// 이력 저장은 user_info_history 테이블이 @@ignore 상태이므로 생략
logger.info("비밀번호 초기화 성공", {
userId,
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: getClientIp(req),
requestPath: req.originalUrl,
});
res.json({
success: true,
result: true,
message: "비밀번호가 성공적으로 초기화되었습니다.",
msg: "비밀번호가 성공적으로 초기화되었습니다.",
});
} else {
res.status(400).json({
success: false,
result: false,
message: "사용자 정보를 찾을 수 없거나 비밀번호 변경에 실패했습니다.",
msg: "사용자 정보를 찾을 수 없거나 비밀번호 변경에 실패했습니다.",
});
}
} catch (error) {
logger.error("비밀번호 초기화 중 오류 발생", {
error,
userId,
});
res.status(500).json({
success: false,
result: false,
message: "비밀번호 초기화 중 시스템 오류가 발생했습니다.",
msg: "비밀번호 초기화 중 시스템 오류가 발생했습니다.",
});
}
};
/**
* 테이블 스키마 조회 (엑셀 업로드 컬럼 매핑용)
* table_type_columns 테이블에서 라벨 정보도 함께 가져옴
*/
export async function getTableSchema(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { tableName } = req.params;
const companyCode = req.user?.companyCode;
if (!tableName) {
res.status(400).json({
success: false,
message: "테이블명이 필요합니다.",
});
return;
}
logger.info("테이블 스키마 조회", { tableName, companyCode });
// information_schema와 table_type_columns를 JOIN하여 컬럼 정보 + 회사별 제약조건 함께 가져오기
// 회사별 설정 우선, 없으면 공통(*) 설정 사용
const schemaQuery = `
SELECT
ic.column_name,
ic.data_type,
ic.is_nullable AS db_is_nullable,
ic.column_default,
ic.character_maximum_length,
ic.numeric_precision,
ic.numeric_scale,
COALESCE(ttc_company.column_label, ttc_common.column_label) AS column_label,
COALESCE(ttc_company.display_order, ttc_common.display_order) AS display_order,
COALESCE(ttc_company.is_nullable, ttc_common.is_nullable) AS ttc_is_nullable,
COALESCE(ttc_company.is_unique, ttc_common.is_unique) AS ttc_is_unique
FROM information_schema.columns ic
LEFT JOIN table_type_columns ttc_common
ON ttc_common.table_name = ic.table_name
AND ttc_common.column_name = ic.column_name
AND ttc_common.company_code = '*'
LEFT JOIN table_type_columns ttc_company
ON ttc_company.table_name = ic.table_name
AND ttc_company.column_name = ic.column_name
AND ttc_company.company_code = $2
WHERE ic.table_schema = 'public'
AND ic.table_name = $1
ORDER BY COALESCE(ttc_company.display_order, ttc_common.display_order, ic.ordinal_position), ic.ordinal_position
`;
const columns = await query<any>(schemaQuery, [tableName, companyCode]);
if (columns.length === 0) {
res.status(404).json({
success: false,
message: `테이블 '${tableName}'을 찾을 수 없습니다.`,
});
return;
}
// 컬럼 정보를 간단한 형태로 변환 (회사별 제약조건 반영)
const columnList = columns.map((col: any) => {
// DB level nullable + 회사별 table_type_columns 제약조건 통합
// table_type_columns에서 is_nullable = 'N'이면 필수 (DB가 nullable이어도)
const dbNullable = col.db_is_nullable === "YES";
const ttcNotNull = col.ttc_is_nullable === "N";
const effectiveNullable = ttcNotNull ? false : dbNullable;
const ttcUnique = col.ttc_is_unique === "Y";
return {
name: col.column_name,
label: col.column_label || col.column_name,
type: col.data_type,
nullable: effectiveNullable,
unique: ttcUnique,
default: col.column_default,
maxLength: col.character_maximum_length,
precision: col.numeric_precision,
scale: col.numeric_scale,
};
});
logger.info(`테이블 스키마 조회 성공: ${columnList.length}개 컬럼`);
res.json({
success: true,
message: "테이블 스키마 조회 성공",
data: {
tableName,
columns: columnList,
},
});
} catch (error) {
logger.error("테이블 스키마 조회 중 오류 발생:", error);
res.status(500).json({
success: false,
message: "테이블 스키마 조회 중 오류가 발생했습니다.",
error: {
code: "TABLE_SCHEMA_ERROR",
details: error instanceof Error ? error.message : "Unknown error",
},
});
}
}
/**
* 메뉴 복사
* POST /api/admin/menus/:menuObjid/copy
*/
export async function copyMenu(
req: AuthenticatedRequest,
res: Response
): Promise<void> {
try {
const { menuObjid } = req.params;
const { targetCompanyCode } = req.body;
const userId = req.user!.userId;
const userCompanyCode = req.user!.companyCode;
const userType = req.user!.userType;
const isSuperAdmin = req.user!.isSuperAdmin;
logger.info(`
=== 메뉴 복사 API 호출 ===
menuObjid: ${menuObjid}
targetCompanyCode: ${targetCompanyCode}
userId: ${userId}
userCompanyCode: ${userCompanyCode}
userType: ${userType}
isSuperAdmin: ${isSuperAdmin}
`);
// 권한 체크: 최고 관리자만 가능
if (!isSuperAdmin && userType !== "SUPER_ADMIN") {
logger.warn(
`권한 없음: ${userId} (userType=${userType}, company_code=${userCompanyCode})`
);
res.status(403).json({
success: false,
message: "메뉴 복사는 최고 관리자(SUPER_ADMIN)만 가능합니다",
error: {
code: "FORBIDDEN",
details: "Only super admin can copy menus",
},
});
return;
}
// 필수 파라미터 검증
if (!menuObjid || !targetCompanyCode) {
res.status(400).json({
success: false,
message: "필수 파라미터가 누락되었습니다",
error: {
code: "MISSING_PARAMETERS",
details: "menuObjid and targetCompanyCode are required",
},
});
return;
}
// 화면명 변환 설정 (선택사항)
const screenNameConfig = req.body.screenNameConfig
? {
removeText: req.body.screenNameConfig.removeText,
addPrefix: req.body.screenNameConfig.addPrefix,
replaceFrom: req.body.screenNameConfig.replaceFrom,
replaceTo: req.body.screenNameConfig.replaceTo,
}
: undefined;
// 추가 복사 옵션 (카테고리, 코드, 채번규칙 등)
const additionalCopyOptions = req.body.additionalCopyOptions
? {
copyCodeCategory: req.body.additionalCopyOptions.copyCodeCategory === true,
copyNumberingRules: req.body.additionalCopyOptions.copyNumberingRules === true,
copyCategoryMapping: req.body.additionalCopyOptions.copyCategoryMapping === true,
}
: undefined;
// 메뉴 복사 실행
const menuCopyService = new MenuCopyService();
const result = await menuCopyService.copyMenu(
parseInt(menuObjid, 10),
targetCompanyCode,
userId,
screenNameConfig,
additionalCopyOptions
);
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: getClientIp(req),
requestPath: req.originalUrl,
});
res.json({
success: true,
message: "메뉴 복사 완료",
data: result,
});
} catch (error: any) {
logger.error("❌ 메뉴 복사 API 실패:", error);
res.status(500).json({
success: false,
message: "메뉴 복사 중 오류가 발생했습니다",
error: {
code: "MENU_COPY_ERROR",
details: error.message || "Unknown error",
},
});
}
}
/**
* ============================================================
* 사원 + 부서 통합 관리 API
* ============================================================
*
* 사원 정보(user_info)와 부서 관계(user_dept)를 트랜잭션으로 동시 저장합니다.
*
* ## 핵심 기능
* 1. user_info 테이블에 사원 개인정보 저장
* 2. user_dept 테이블에 메인 부서 + 겸직 부서 저장
* 3. 메인 부서 변경 시 기존 메인 → 겸직으로 자동 전환
* 4. 트랜잭션으로 데이터 정합성 보장
*
* ## 요청 데이터 구조
* ```json
* {
* "userInfo": {
* "user_id": "string (필수)",
* "user_name": "string (필수)",
* "email": "string",
* "cell_phone": "string",
* "sabun": "string",
* ...
* },
* "mainDept": {
* "dept_code": "string (필수)",
* "dept_name": "string",
* "position_name": "string"
* },
* "subDepts": [
* {
* "dept_code": "string (필수)",
* "dept_name": "string",
* "position_name": "string"
* }
* ]
* }
* ```
*/
// 사원 + 부서 저장 요청 타입
interface UserWithDeptRequest {
userInfo: {
user_id: string;
user_name: string;
user_name_eng?: string;
user_password?: string;
email?: string;
tel?: string;
cell_phone?: string;
sabun?: string;
user_type?: string;
user_type_name?: string;
status?: string;
locale?: string;
// 메인 부서 정보 (user_info에도 저장)
dept_code?: string;
dept_name?: string;
position_code?: string;
position_name?: string;
};
mainDept?: {
dept_code: string;
dept_name?: string;
position_name?: string;
};
subDepts?: Array<{
dept_code: string;
dept_name?: string;
position_name?: string;
}>;
isUpdate?: boolean; // 수정 모드 여부
}
/**
* POST /api/admin/users/with-dept
* 사원 + 부서 통합 저장 API
*/
export const saveUserWithDept = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
const client = await getPool().connect();
try {
const { userInfo, mainDept, subDepts = [], isUpdate = false } = req.body as UserWithDeptRequest;
const companyCode = req.user?.companyCode || "*";
const currentUserId = req.user?.userId;
logger.info("사원+부서 통합 저장 요청", {
userId: userInfo?.user_id,
mainDept: mainDept?.dept_code,
subDeptsCount: subDepts.length,
isUpdate,
companyCode,
});
// 필수값 검증
if (!userInfo?.user_id || !userInfo?.user_name) {
res.status(400).json({
success: false,
message: "사용자 ID와 이름은 필수입니다.",
error: { code: "REQUIRED_FIELD_MISSING" },
});
return;
}
// 트랜잭션 시작
await client.query("BEGIN");
// 1. 기존 사용자 확인
const existingUser = await client.query(
"SELECT user_id FROM user_info WHERE user_id = $1",
[userInfo.user_id]
);
const isExistingUser = existingUser.rows.length > 0;
// 2. 비밀번호 암호화 (새 사용자이거나 비밀번호가 제공된 경우)
let encryptedPassword = null;
if (userInfo.user_password) {
encryptedPassword = await EncryptUtil.encrypt(userInfo.user_password);
}
// 3. user_info 저장 (UPSERT)
// mainDept가 있으면 user_info에도 메인 부서 정보 저장
const deptCode = mainDept?.dept_code || userInfo.dept_code || null;
const deptName = mainDept?.dept_name || userInfo.dept_name || null;
const positionName = mainDept?.position_name || userInfo.position_name || null;
if (isExistingUser) {
// 기존 사용자 수정
const updateFields: string[] = [];
const updateValues: any[] = [];
let paramIndex = 1;
// 동적으로 업데이트할 필드 구성
const fieldsToUpdate: Record<string, any> = {
user_name: userInfo.user_name,
user_name_eng: userInfo.user_name_eng,
email: userInfo.email,
tel: userInfo.tel,
cell_phone: userInfo.cell_phone,
sabun: userInfo.sabun,
user_type: userInfo.user_type,
user_type_name: userInfo.user_type_name,
status: userInfo.status || "active",
locale: userInfo.locale,
dept_code: deptCode,
dept_name: deptName,
position_code: userInfo.position_code,
position_name: positionName,
company_code: companyCode !== "*" ? companyCode : undefined,
};
// 비밀번호가 제공된 경우에만 업데이트
if (encryptedPassword) {
fieldsToUpdate.user_password = encryptedPassword;
}
for (const [key, value] of Object.entries(fieldsToUpdate)) {
if (value !== undefined) {
updateFields.push(`${key} = $${paramIndex}`);
updateValues.push(value);
paramIndex++;
}
}
if (updateFields.length > 0) {
updateValues.push(userInfo.user_id);
await client.query(
`UPDATE user_info SET ${updateFields.join(", ")} WHERE user_id = $${paramIndex}`,
updateValues
);
}
} else {
// 새 사용자 등록
await client.query(
`INSERT INTO user_info (
user_id, user_name, user_name_eng, user_password,
email, tel, cell_phone, sabun,
user_type, user_type_name, status, locale,
dept_code, dept_name, position_code, position_name,
company_code, regdate
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, NOW())`,
[
userInfo.user_id,
userInfo.user_name,
userInfo.user_name_eng || null,
encryptedPassword || null,
userInfo.email || null,
userInfo.tel || null,
userInfo.cell_phone || null,
userInfo.sabun || null,
userInfo.user_type || null,
userInfo.user_type_name || null,
userInfo.status || "active",
userInfo.locale || null,
deptCode,
deptName,
userInfo.position_code || null,
positionName,
companyCode !== "*" ? companyCode : null,
]
);
}
// 4. user_dept 처리
if (mainDept?.dept_code || subDepts.length > 0) {
// 4-1. 기존 부서 관계 조회 (메인 부서 변경 감지용)
const existingDepts = await client.query(
"SELECT dept_code, is_primary FROM user_dept WHERE user_id = $1",
[userInfo.user_id]
);
const existingMainDept = existingDepts.rows.find((d: any) => d.is_primary === true);
// 4-2. 메인 부서가 변경된 경우, 기존 메인 부서를 겸직으로 전환
if (mainDept?.dept_code && existingMainDept && existingMainDept.dept_code !== mainDept.dept_code) {
logger.info("메인 부서 변경 감지 - 기존 메인을 겸직으로 전환", {
userId: userInfo.user_id,
oldMain: existingMainDept.dept_code,
newMain: mainDept.dept_code,
});
await client.query(
"UPDATE user_dept SET is_primary = false, updated_at = NOW() WHERE user_id = $1 AND dept_code = $2",
[userInfo.user_id, existingMainDept.dept_code]
);
}
// 4-3. 기존 겸직 부서 삭제 (메인 제외)
// 새로 입력받은 subDepts로 교체하기 위해 기존 겸직 삭제
await client.query(
"DELETE FROM user_dept WHERE user_id = $1 AND is_primary = false",
[userInfo.user_id]
);
// 4-4. 메인 부서 저장 (UPSERT)
if (mainDept?.dept_code) {
await client.query(
`INSERT INTO user_dept (user_id, dept_code, is_primary, dept_name, user_name, position_name, company_code, created_at, updated_at)
VALUES ($1, $2, true, $3, $4, $5, $6, NOW(), NOW())
ON CONFLICT (user_id, dept_code) DO UPDATE SET
is_primary = true,
dept_name = $3,
user_name = $4,
position_name = $5,
company_code = $6,
updated_at = NOW()`,
[
userInfo.user_id,
mainDept.dept_code,
mainDept.dept_name || null,
userInfo.user_name,
mainDept.position_name || null,
companyCode !== "*" ? companyCode : null,
]
);
}
// 4-5. 겸직 부서 저장
for (const subDept of subDepts) {
if (!subDept.dept_code) continue;
// 메인 부서와 같은 부서는 겸직으로 추가하지 않음
if (mainDept?.dept_code === subDept.dept_code) continue;
await client.query(
`INSERT INTO user_dept (user_id, dept_code, is_primary, dept_name, user_name, position_name, company_code, created_at, updated_at)
VALUES ($1, $2, false, $3, $4, $5, $6, NOW(), NOW())
ON CONFLICT (user_id, dept_code) DO UPDATE SET
is_primary = false,
dept_name = $3,
user_name = $4,
position_name = $5,
company_code = $6,
updated_at = NOW()`,
[
userInfo.user_id,
subDept.dept_code,
subDept.dept_name || null,
userInfo.user_name,
subDept.position_name || null,
companyCode !== "*" ? companyCode : null,
]
);
}
}
// 트랜잭션 커밋
await client.query("COMMIT");
logger.info("사원+부서 통합 저장 완료", {
userId: userInfo.user_id,
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: getClientIp(req),
requestPath: req.originalUrl,
});
res.json({
success: true,
message: isExistingUser ? "사원 정보가 수정되었습니다." : "사원이 등록되었습니다.",
data: {
userId: userInfo.user_id,
isUpdate: isExistingUser,
},
});
} catch (error: any) {
// 트랜잭션 롤백
await client.query("ROLLBACK");
logger.error("사원+부서 통합 저장 실패", { error: error.message, body: req.body });
// 중복 키 에러 처리
if (error.code === "23505") {
res.status(400).json({
success: false,
message: "이미 존재하는 사용자 ID입니다.",
error: { code: "DUPLICATE_USER_ID" },
});
return;
}
res.status(500).json({
success: false,
message: "사원 저장 중 오류가 발생했습니다.",
error: { code: "SAVE_ERROR", details: error.message },
});
} finally {
client.release();
}
}
/**
* GET /api/admin/users/:userId/with-dept
* 사원 + 부서 정보 조회 API (수정 모달용)
*/
export const getUserWithDept = async (
req: AuthenticatedRequest,
res: Response
): Promise<void> => {
try {
const { userId } = req.params;
const companyCode = req.user?.companyCode || "*";
logger.info("사원+부서 조회 요청", { userId, companyCode });
// 1. user_info 조회
let userQuery = "SELECT * FROM user_info WHERE user_id = $1";
const userParams: any[] = [userId];
// 최고 관리자가 아니면 회사 필터링
if (companyCode !== "*") {
userQuery += " AND company_code = $2";
userParams.push(companyCode);
}
const userResult = await query<any>(userQuery, userParams);
if (userResult.length === 0) {
res.status(404).json({
success: false,
message: "사용자를 찾을 수 없습니다.",
error: { code: "USER_NOT_FOUND" },
});
return;
}
const userInfo = userResult[0];
// 2. user_dept 조회 (메인 + 겸직)
let deptQuery = "SELECT * FROM user_dept WHERE user_id = $1 ORDER BY is_primary DESC, created_at ASC";
const deptResult = await query<any>(deptQuery, [userId]);
const mainDept = deptResult.find((d: any) => d.is_primary === true);
const subDepts = deptResult.filter((d: any) => d.is_primary === false);
res.json({
success: true,
data: {
userInfo,
mainDept: mainDept || null,
subDepts,
},
});
} catch (error: any) {
logger.error("사원+부서 조회 실패", { error: error.message, userId: req.params.userId });
res.status(500).json({
success: false,
message: "사원 조회 중 오류가 발생했습니다.",
error: { code: "QUERY_ERROR", details: error.message },
});
}
}