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:
kjs
2026-03-04 13:49:08 +09:00
parent f04d224b09
commit b4d5367e2b
26 changed files with 2620 additions and 140 deletions

View File

@@ -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;
}
}