feat: Integrate audit logging for various operations
- Added audit logging functionality across multiple controllers, including menu, user, department, flow, screen, and table management. - Implemented logging for create, update, and delete actions, capturing relevant details such as company code, user information, and changes made. - Enhanced the category tree service with a new endpoint to check if category values are in use, improving data integrity checks. - Updated routes to include new functionalities and ensure proper logging for batch operations and individual record changes. - This integration improves traceability and accountability for data modifications within the application.
This commit is contained in:
@@ -405,69 +405,169 @@ class CategoryTreeService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 하위 카테고리 값 ID 재귀 수집
|
||||
* 카테고리 값이 실제 데이터 테이블에서 사용 중인지 확인
|
||||
*/
|
||||
private async collectAllChildValueIds(
|
||||
private async checkCategoryValueInUse(
|
||||
companyCode: string,
|
||||
valueId: number
|
||||
): Promise<number[]> {
|
||||
value: CategoryValue
|
||||
): Promise<{ inUse: boolean; count: number }> {
|
||||
const pool = getPool();
|
||||
|
||||
// 재귀 CTE를 사용하여 모든 하위 카테고리 수집
|
||||
const query = `
|
||||
WITH RECURSIVE category_tree AS (
|
||||
SELECT value_id FROM category_values
|
||||
WHERE parent_value_id = $1 AND (company_code = $2 OR company_code = '*')
|
||||
UNION ALL
|
||||
SELECT cv.value_id
|
||||
FROM category_values cv
|
||||
INNER JOIN category_tree ct ON cv.parent_value_id = ct.value_id
|
||||
WHERE cv.company_code = $2 OR cv.company_code = '*'
|
||||
)
|
||||
SELECT value_id FROM category_tree
|
||||
`;
|
||||
|
||||
const result = await pool.query(query, [valueId, companyCode]);
|
||||
return result.rows.map(row => row.value_id);
|
||||
|
||||
try {
|
||||
const tableExists = await pool.query(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = $1
|
||||
) AS exists`,
|
||||
[value.tableName]
|
||||
);
|
||||
|
||||
if (!tableExists.rows[0].exists) {
|
||||
return { inUse: false, count: 0 };
|
||||
}
|
||||
|
||||
const columnExists = await pool.query(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = $1 AND column_name = $2
|
||||
) AS exists`,
|
||||
[value.tableName, value.columnName]
|
||||
);
|
||||
|
||||
if (!columnExists.rows[0].exists) {
|
||||
return { inUse: false, count: 0 };
|
||||
}
|
||||
|
||||
const hasCompanyCode = await pool.query(
|
||||
`SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'public' AND table_name = $1 AND column_name = 'company_code'
|
||||
) AS exists`,
|
||||
[value.tableName]
|
||||
);
|
||||
|
||||
let countQuery: string;
|
||||
let params: any[];
|
||||
|
||||
if (hasCompanyCode.rows[0].exists && companyCode !== "*") {
|
||||
countQuery = `
|
||||
SELECT COUNT(*) as count FROM "${value.tableName}"
|
||||
WHERE company_code = $1
|
||||
AND ($2 = ANY(string_to_array("${value.columnName}"::text, ','))
|
||||
OR "${value.columnName}"::text = $2)
|
||||
`;
|
||||
params = [companyCode, value.valueCode];
|
||||
} else {
|
||||
countQuery = `
|
||||
SELECT COUNT(*) as count FROM "${value.tableName}"
|
||||
WHERE $1 = ANY(string_to_array("${value.columnName}"::text, ','))
|
||||
OR "${value.columnName}"::text = $1
|
||||
`;
|
||||
params = [value.valueCode];
|
||||
}
|
||||
|
||||
const result = await pool.query(countQuery, params);
|
||||
const count = parseInt(result.rows[0].count);
|
||||
|
||||
return { inUse: count > 0, count };
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.warn("카테고리 사용 여부 확인 중 오류 (무시하고 삭제 허용)", {
|
||||
error: err.message,
|
||||
tableName: value.tableName,
|
||||
columnName: value.columnName,
|
||||
});
|
||||
return { inUse: false, count: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 삭제 (하위 항목도 함께 삭제)
|
||||
* 카테고리 값 삭제 가능 여부 사전 확인
|
||||
*/
|
||||
async checkCanDelete(
|
||||
companyCode: string,
|
||||
valueId: number
|
||||
): Promise<{ canDelete: boolean; reason?: string }> {
|
||||
const pool = getPool();
|
||||
|
||||
const value = await this.getCategoryValue(companyCode, valueId);
|
||||
if (!value) {
|
||||
return { canDelete: false, reason: "카테고리 값을 찾을 수 없습니다" };
|
||||
}
|
||||
|
||||
const childCheck = await pool.query(
|
||||
`SELECT COUNT(*) as count FROM category_values
|
||||
WHERE parent_value_id = $1 AND (company_code = $2 OR company_code = '*')`,
|
||||
[valueId, companyCode]
|
||||
);
|
||||
const childCount = parseInt(childCheck.rows[0].count);
|
||||
|
||||
if (childCount > 0) {
|
||||
return {
|
||||
canDelete: false,
|
||||
reason: `하위 카테고리가 ${childCount}개 존재합니다. 하위 카테고리를 먼저 삭제해주세요.`,
|
||||
};
|
||||
}
|
||||
|
||||
const usageCheck = await this.checkCategoryValueInUse(companyCode, value);
|
||||
if (usageCheck.inUse) {
|
||||
return {
|
||||
canDelete: false,
|
||||
reason: `이 카테고리 값(${value.valueLabel})은 ${usageCheck.count}건의 데이터에서 사용 중이므로 삭제할 수 없습니다.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { canDelete: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 카테고리 값 삭제 (자식 존재 및 사용 중 검증 후 삭제)
|
||||
*/
|
||||
async deleteCategoryValue(companyCode: string, valueId: number): Promise<boolean> {
|
||||
const pool = getPool();
|
||||
|
||||
try {
|
||||
// 1. 모든 하위 카테고리 ID 수집
|
||||
const childValueIds = await this.collectAllChildValueIds(companyCode, valueId);
|
||||
const allValueIds = [valueId, ...childValueIds];
|
||||
|
||||
logger.info("삭제 대상 카테고리 값 수집 완료", {
|
||||
valueId,
|
||||
childCount: childValueIds.length,
|
||||
totalCount: allValueIds.length,
|
||||
});
|
||||
const value = await this.getCategoryValue(companyCode, valueId);
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 2. 하위 카테고리부터 역순으로 삭제 (외래키 제약 회피)
|
||||
const reversedIds = [...allValueIds].reverse();
|
||||
|
||||
for (const id of reversedIds) {
|
||||
await pool.query(
|
||||
`DELETE FROM category_values WHERE (company_code = $1 OR company_code = '*') AND value_id = $2`,
|
||||
[companyCode, id]
|
||||
// 1. 자식 카테고리 존재 여부 확인
|
||||
const childCheck = await pool.query(
|
||||
`SELECT COUNT(*) as count FROM category_values
|
||||
WHERE parent_value_id = $1 AND (company_code = $2 OR company_code = '*')`,
|
||||
[valueId, companyCode]
|
||||
);
|
||||
const childCount = parseInt(childCheck.rows[0].count);
|
||||
|
||||
if (childCount > 0) {
|
||||
throw new Error(
|
||||
`VALIDATION:하위 카테고리가 ${childCount}개 존재합니다. 하위 카테고리를 먼저 삭제해주세요.`
|
||||
);
|
||||
}
|
||||
|
||||
logger.info("카테고리 값 삭제 완료", {
|
||||
valueId,
|
||||
deletedCount: allValueIds.length,
|
||||
deletedChildCount: childValueIds.length,
|
||||
});
|
||||
|
||||
// 2. 실제 데이터에서 사용 중인지 확인
|
||||
const usageCheck = await this.checkCategoryValueInUse(companyCode, value);
|
||||
if (usageCheck.inUse) {
|
||||
throw new Error(
|
||||
`VALIDATION:이 카테고리 값(${value.valueLabel})은 ${value.tableName} 테이블에서 ${usageCheck.count}건의 데이터가 사용 중이므로 삭제할 수 없습니다.`
|
||||
);
|
||||
}
|
||||
|
||||
// 3. 삭제
|
||||
await pool.query(
|
||||
`DELETE FROM category_values WHERE (company_code = $1 OR company_code = '*') AND value_id = $2`,
|
||||
[companyCode, valueId]
|
||||
);
|
||||
|
||||
logger.info("카테고리 값 삭제 완료", { valueId, valueLabel: value.valueLabel });
|
||||
|
||||
return true;
|
||||
} catch (error: unknown) {
|
||||
const err = error as Error;
|
||||
logger.error("카테고리 값 삭제 실패", { error: err.message, valueId });
|
||||
if (!err.message.startsWith("VALIDATION:")) {
|
||||
logger.error("카테고리 값 삭제 실패", { error: err.message, valueId });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user