/** * 테이블 이력 조회 컨트롤러 * 테이블 타입 관리의 {테이블명}_log 테이블과 연동 */ import { Request, Response } from "express"; import { query } from "../database/db"; import { logger } from "../utils/logger"; export class TableHistoryController { /** * 특정 레코드의 변경 이력 조회 */ static async getRecordHistory(req: Request, res: Response): Promise { try { const { tableName, recordId } = req.params; const { limit = 50, offset = 0, operationType, changedBy, startDate, endDate } = req.query; logger.info(`📜 테이블 이력 조회 요청:`, { tableName, recordId, limit, offset, }); // 로그 테이블명 생성 const logTableName = `${tableName}_log`; // 동적 WHERE 조건 생성 const whereConditions: string[] = [`original_id = $1`]; const queryParams: any[] = [recordId]; let paramIndex = 2; // 작업 유형 필터 if (operationType) { whereConditions.push(`operation_type = $${paramIndex}`); queryParams.push(operationType); paramIndex++; } // 변경자 필터 if (changedBy) { whereConditions.push(`changed_by ILIKE $${paramIndex}`); queryParams.push(`%${changedBy}%`); paramIndex++; } // 날짜 범위 필터 if (startDate) { whereConditions.push(`changed_at >= $${paramIndex}`); queryParams.push(startDate); paramIndex++; } if (endDate) { whereConditions.push(`changed_at <= $${paramIndex}`); queryParams.push(endDate); paramIndex++; } // LIMIT과 OFFSET 파라미터 추가 queryParams.push(limit); const limitParam = `$${paramIndex}`; paramIndex++; queryParams.push(offset); const offsetParam = `$${paramIndex}`; const whereClause = whereConditions.join(" AND "); // 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결) const historyQuery = ` SELECT log_id, operation_type, original_id, changed_column, old_value, new_value, changed_by, changed_at, ip_address, user_agent, full_row_before, full_row_after FROM ${logTableName} WHERE ${whereClause} ORDER BY log_id DESC LIMIT ${limitParam} OFFSET ${offsetParam} `; // 전체 카운트 쿼리 const countQuery = ` SELECT COUNT(*) as total FROM ${logTableName} WHERE ${whereClause} `; const [historyRecords, countResult] = await Promise.all([ query(historyQuery, queryParams), query(countQuery, queryParams.slice(0, -2)), // LIMIT, OFFSET 제외 ]); const total = parseInt(countResult[0]?.total || "0", 10); logger.info(`✅ 이력 조회 완료: ${historyRecords.length}건 / 전체 ${total}건`); res.json({ success: true, data: { records: historyRecords, pagination: { total, limit: parseInt(limit as string, 10), offset: parseInt(offset as string, 10), hasMore: parseInt(offset as string, 10) + historyRecords.length < total, }, }, message: "이력 조회 성공", }); } catch (error: any) { logger.error(`❌ 테이블 이력 조회 실패:`, error); // 테이블이 존재하지 않는 경우 if (error.code === "42P01") { res.status(404).json({ success: false, message: "이력 테이블이 존재하지 않습니다. 테이블 타입 관리에서 이력 관리를 활성화해주세요.", errorCode: "TABLE_NOT_FOUND", }); return; } res.status(500).json({ success: false, message: "이력 조회 중 오류가 발생했습니다.", error: error.message, }); } } /** * 전체 테이블 이력 조회 (레코드 ID 없이) */ static async getAllTableHistory(req: Request, res: Response): Promise { try { const { tableName } = req.params; const { limit = 50, offset = 0, operationType, changedBy, startDate, endDate } = req.query; logger.info(`📜 전체 테이블 이력 조회 요청:`, { tableName, limit, offset, }); // 로그 테이블명 생성 const logTableName = `${tableName}_log`; // 동적 WHERE 조건 생성 const whereConditions: string[] = []; const queryParams: any[] = []; let paramIndex = 1; // 작업 유형 필터 if (operationType) { whereConditions.push(`operation_type = $${paramIndex}`); queryParams.push(operationType); paramIndex++; } // 변경자 필터 if (changedBy) { whereConditions.push(`changed_by ILIKE $${paramIndex}`); queryParams.push(`%${changedBy}%`); paramIndex++; } // 날짜 범위 필터 if (startDate) { whereConditions.push(`changed_at >= $${paramIndex}`); queryParams.push(startDate); paramIndex++; } if (endDate) { whereConditions.push(`changed_at <= $${paramIndex}`); queryParams.push(endDate); paramIndex++; } // LIMIT과 OFFSET 파라미터 추가 queryParams.push(limit); const limitParam = `$${paramIndex}`; paramIndex++; queryParams.push(offset); const offsetParam = `$${paramIndex}`; const whereClause = whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : ""; // 이력 조회 쿼리 (log_id로 정렬 - 시간 데이터 불일치 문제 해결) const historyQuery = ` SELECT log_id, operation_type, original_id, changed_column, old_value, new_value, changed_by, changed_at, ip_address, user_agent, full_row_before, full_row_after FROM ${logTableName} ${whereClause} ORDER BY log_id DESC LIMIT ${limitParam} OFFSET ${offsetParam} `; // 전체 카운트 쿼리 const countQuery = ` SELECT COUNT(*) as total FROM ${logTableName} ${whereClause} `; const [historyRecords, countResult] = await Promise.all([ query(historyQuery, queryParams), query(countQuery, queryParams.slice(0, -2)), // LIMIT, OFFSET 제외 ]); const total = parseInt(countResult[0]?.total || "0", 10); res.json({ success: true, data: { records: historyRecords, pagination: { total, limit: Number(limit), offset: Number(offset), hasMore: Number(offset) + Number(limit) < total, }, }, message: "전체 테이블 이력 조회 성공", }); } catch (error: any) { logger.error(`❌ 전체 테이블 이력 조회 실패:`, error); if (error.code === "42P01") { res.status(404).json({ success: false, message: "이력 테이블이 존재하지 않습니다.", errorCode: "TABLE_NOT_FOUND", }); return; } res.status(500).json({ success: false, message: "이력 조회 중 오류가 발생했습니다.", error: error.message, }); } } /** * 테이블 전체 이력 요약 조회 */ static async getTableHistorySummary(req: Request, res: Response): Promise { try { const { tableName } = req.params; const logTableName = `${tableName}_log`; const summaryQuery = ` SELECT operation_type, COUNT(*) as count, COUNT(DISTINCT original_id) as affected_records, COUNT(DISTINCT changed_by) as unique_users, MIN(changed_at) as first_change, MAX(changed_at) as last_change FROM ${logTableName} GROUP BY operation_type `; const summary = await query(summaryQuery); res.json({ success: true, data: summary, message: "이력 요약 조회 성공", }); } catch (error: any) { logger.error(`❌ 테이블 이력 요약 조회 실패:`, error); if (error.code === "42P01") { res.status(404).json({ success: false, message: "이력 테이블이 존재하지 않습니다.", errorCode: "TABLE_NOT_FOUND", }); return; } res.status(500).json({ success: false, message: "이력 요약 조회 중 오류가 발생했습니다.", error: error.message, }); } } /** * 특정 레코드의 변경 타임라인 조회 (그룹화) */ static async getRecordTimeline(req: Request, res: Response): Promise { try { const { tableName, recordId } = req.params; const logTableName = `${tableName}_log`; // 변경 이벤트별로 그룹화 (동일 시간대 변경을 하나의 이벤트로) const timelineQuery = ` WITH grouped_changes AS ( SELECT changed_at, changed_by, operation_type, ip_address, json_agg( json_build_object( 'column', changed_column, 'oldValue', old_value, 'newValue', new_value ) ORDER BY changed_column ) as changes, full_row_before, full_row_after FROM ${logTableName} WHERE original_id = $1 GROUP BY changed_at, changed_by, operation_type, ip_address, full_row_before, full_row_after ORDER BY changed_at DESC LIMIT 100 ) SELECT * FROM grouped_changes `; const timeline = await query(timelineQuery, [recordId]); res.json({ success: true, data: timeline, message: "타임라인 조회 성공", }); } catch (error: any) { logger.error(`❌ 레코드 타임라인 조회 실패:`, error); res.status(500).json({ success: false, message: "타임라인 조회 중 오류가 발생했습니다.", error: error.message, }); } } /** * 이력 테이블 존재 여부 확인 */ static async checkHistoryTableExists(req: Request, res: Response): Promise { try { const { tableName } = req.params; const logTableName = `${tableName}_log`; const checkQuery = ` SELECT EXISTS ( SELECT FROM information_schema.tables WHERE table_schema = 'public' AND table_name = $1 ) as exists `; const result = await query(checkQuery, [logTableName]); const exists = result[0]?.exists || false; res.json({ success: true, data: { tableName, logTableName, exists, historyEnabled: exists, }, message: exists ? "이력 테이블이 존재합니다." : "이력 테이블이 존재하지 않습니다.", }); } catch (error: any) { logger.error(`❌ 이력 테이블 존재 여부 확인 실패:`, error); res.status(500).json({ success: false, message: "이력 테이블 확인 중 오류가 발생했습니다.", error: error.message, }); } } }