import { Response } from "express"; import https from "https"; import axios, { AxiosRequestConfig } from "axios"; import { logger } from "../utils/logger"; import { AuthenticatedRequest } from "../middleware/authMiddleware"; import { DashboardService } from "../services/DashboardService"; import { CreateDashboardRequest, UpdateDashboardRequest, DashboardListQuery, } from "../types/dashboard"; import { PostgreSQLService } from "../database/PostgreSQLService"; import { ExternalRestApiConnectionService } from "../services/externalRestApiConnectionService"; /** * 대시보드 컨트롤러 * - REST API 엔드포인트 처리 * - 요청 검증 및 응답 포맷팅 */ export class DashboardController { /** * 대시보드 생성 * POST /api/dashboards */ async createDashboard( req: AuthenticatedRequest, res: Response ): Promise { try { const userId = req.user?.userId; const companyCode = req.user?.companyCode; if (!userId) { res.status(401).json({ success: false, message: "인증이 필요합니다.", }); return; } const { title, description, elements, isPublic = false, tags, category, settings, }: CreateDashboardRequest = req.body; // 유효성 검증 if (!title || title.trim().length === 0) { res.status(400).json({ success: false, message: "대시보드 제목이 필요합니다.", }); return; } if (!elements || !Array.isArray(elements)) { res.status(400).json({ success: false, message: "대시보드 요소 데이터가 필요합니다.", }); return; } // 제목 길이 체크 if (title.length > 200) { res.status(400).json({ success: false, message: "제목은 200자를 초과할 수 없습니다.", }); return; } // 설명 길이 체크 if (description && description.length > 1000) { res.status(400).json({ success: false, message: "설명은 1000자를 초과할 수 없습니다.", }); return; } const dashboardData: CreateDashboardRequest = { title: title.trim(), description: description?.trim(), isPublic, elements, tags, category, settings, }; // console.log('대시보드 생성 시작:', { title: dashboardData.title, userId, elementsCount: elements.length }); const savedDashboard = await DashboardService.createDashboard( dashboardData, userId, companyCode ); // console.log('대시보드 생성 성공:', { id: savedDashboard.id, title: savedDashboard.title }); res.status(201).json({ success: true, data: savedDashboard, message: "대시보드가 성공적으로 생성되었습니다.", }); } catch (error: any) { // console.error('Dashboard creation error:', { // message: error?.message, // stack: error?.stack, // error // }); res.status(500).json({ success: false, message: error?.message || "대시보드 생성 중 오류가 발생했습니다.", error: process.env.NODE_ENV === "development" ? error?.message : undefined, }); } } /** * 대시보드 목록 조회 * GET /api/dashboards */ async getDashboards(req: AuthenticatedRequest, res: Response): Promise { try { const userId = req.user?.userId; const companyCode = req.user?.companyCode; const query: DashboardListQuery = { page: parseInt(req.query.page as string) || 1, limit: Math.min(parseInt(req.query.limit as string) || 20, 100), // 최대 100개 search: req.query.search as string, category: req.query.category as string, isPublic: req.query.isPublic === "true" ? true : req.query.isPublic === "false" ? false : undefined, createdBy: req.query.createdBy as string, }; // 페이지 번호 유효성 검증 if (query.page! < 1) { res.status(400).json({ success: false, message: "페이지 번호는 1 이상이어야 합니다.", }); return; } const result = await DashboardService.getDashboards( query, userId, companyCode ); res.json({ success: true, data: result.dashboards, pagination: result.pagination, }); } catch (error) { // console.error('Dashboard list error:', error); res.status(500).json({ success: false, message: "대시보드 목록 조회 중 오류가 발생했습니다.", error: process.env.NODE_ENV === "development" ? (error as Error).message : undefined, }); } } /** * 대시보드 상세 조회 * GET /api/dashboards/:id */ async getDashboard(req: AuthenticatedRequest, res: Response): Promise { try { const { id } = req.params; const userId = req.user?.userId; const companyCode = req.user?.companyCode; if (!id) { res.status(400).json({ success: false, message: "대시보드 ID가 필요합니다.", }); return; } const dashboard = await DashboardService.getDashboardById( id, userId, companyCode ); if (!dashboard) { res.status(404).json({ success: false, message: "대시보드를 찾을 수 없거나 접근 권한이 없습니다.", }); return; } // 조회수 증가 (본인이 만든 대시보드가 아닌 경우에만) if (userId && dashboard.createdBy !== userId) { await DashboardService.incrementViewCount(id); } res.json({ success: true, data: dashboard, }); } catch (error) { // console.error('Dashboard get error:', error); res.status(500).json({ success: false, message: "대시보드 조회 중 오류가 발생했습니다.", error: process.env.NODE_ENV === "development" ? (error as Error).message : undefined, }); } } /** * 대시보드 수정 * PUT /api/dashboards/:id */ async updateDashboard( req: AuthenticatedRequest, res: Response ): Promise { try { const { id } = req.params; const userId = req.user?.userId; if (!userId) { res.status(401).json({ success: false, message: "인증이 필요합니다.", }); return; } if (!id) { res.status(400).json({ success: false, message: "대시보드 ID가 필요합니다.", }); return; } const updateData: UpdateDashboardRequest = req.body; // 유효성 검증 if (updateData.title !== undefined) { if ( typeof updateData.title !== "string" || updateData.title.trim().length === 0 ) { res.status(400).json({ success: false, message: "올바른 제목을 입력해주세요.", }); return; } if (updateData.title.length > 200) { res.status(400).json({ success: false, message: "제목은 200자를 초과할 수 없습니다.", }); return; } updateData.title = updateData.title.trim(); } if ( updateData.description !== undefined && updateData.description && updateData.description.length > 1000 ) { res.status(400).json({ success: false, message: "설명은 1000자를 초과할 수 없습니다.", }); return; } const updatedDashboard = await DashboardService.updateDashboard( id, updateData, userId ); if (!updatedDashboard) { res.status(404).json({ success: false, message: "대시보드를 찾을 수 없거나 수정 권한이 없습니다.", }); return; } res.json({ success: true, data: updatedDashboard, message: "대시보드가 성공적으로 수정되었습니다.", }); } catch (error) { // console.error('Dashboard update error:', error); if ((error as Error).message.includes("권한이 없습니다")) { res.status(403).json({ success: false, message: (error as Error).message, }); return; } res.status(500).json({ success: false, message: "대시보드 수정 중 오류가 발생했습니다.", error: process.env.NODE_ENV === "development" ? (error as Error).message : undefined, }); } } /** * 대시보드 삭제 * DELETE /api/dashboards/:id */ async deleteDashboard( req: AuthenticatedRequest, res: Response ): Promise { try { const { id } = req.params; const userId = req.user?.userId; if (!userId) { res.status(401).json({ success: false, message: "인증이 필요합니다.", }); return; } if (!id) { res.status(400).json({ success: false, message: "대시보드 ID가 필요합니다.", }); return; } const deleted = await DashboardService.deleteDashboard(id, userId); if (!deleted) { res.status(404).json({ success: false, message: "대시보드를 찾을 수 없거나 삭제 권한이 없습니다.", }); return; } res.json({ success: true, message: "대시보드가 성공적으로 삭제되었습니다.", }); } catch (error) { // console.error('Dashboard delete error:', error); res.status(500).json({ success: false, message: "대시보드 삭제 중 오류가 발생했습니다.", error: process.env.NODE_ENV === "development" ? (error as Error).message : undefined, }); } } /** * 내 대시보드 목록 조회 * GET /api/dashboards/my */ async getMyDashboards( req: AuthenticatedRequest, res: Response ): Promise { try { const userId = req.user?.userId; if (!userId) { res.status(401).json({ success: false, message: "인증이 필요합니다.", }); return; } const companyCode = req.user?.companyCode; const query: DashboardListQuery = { page: parseInt(req.query.page as string) || 1, limit: Math.min(parseInt(req.query.limit as string) || 20, 100), search: req.query.search as string, category: req.query.category as string, // createdBy 제거 - 회사 대시보드 전체 표시 }; const result = await DashboardService.getDashboards( query, userId, companyCode ); res.json({ success: true, data: result.dashboards, pagination: result.pagination, }); } catch (error) { // console.error('My dashboards error:', error); res.status(500).json({ success: false, message: "내 대시보드 목록 조회 중 오류가 발생했습니다.", error: process.env.NODE_ENV === "development" ? (error as Error).message : undefined, }); } } /** * 쿼리 실행 (SELECT만) * POST /api/dashboards/execute-query */ async executeQuery(req: AuthenticatedRequest, res: Response): Promise { try { // 개발용으로 인증 체크 제거 // const userId = req.user?.userId; // if (!userId) { // res.status(401).json({ // success: false, // message: '인증이 필요합니다.' // }); // return; // } const { query } = req.body; // 유효성 검증 if (!query || typeof query !== "string" || query.trim().length === 0) { res.status(400).json({ success: false, message: "쿼리가 필요합니다.", }); return; } // SQL 인젝션 방지를 위한 기본적인 검증 const trimmedQuery = query.trim().toLowerCase(); if (!trimmedQuery.startsWith("select")) { res.status(400).json({ success: false, message: "SELECT 쿼리만 허용됩니다.", }); return; } // 쿼리 실행 const result = await PostgreSQLService.query(query.trim()); // 결과 변환 const columns = result.fields?.map((field) => field.name) || []; const rows = result.rows || []; res.status(200).json({ success: true, data: { columns, rows, rowCount: rows.length, }, message: "쿼리가 성공적으로 실행되었습니다.", }); } catch (error) { // console.error('Query execution error:', error); res.status(500).json({ success: false, message: "쿼리 실행 중 오류가 발생했습니다.", error: process.env.NODE_ENV === "development" ? (error as Error).message : "쿼리 실행 오류", }); } } /** * DML 쿼리 실행 (INSERT, UPDATE, DELETE) * POST /api/dashboards/execute-dml */ async executeDML(req: AuthenticatedRequest, res: Response): Promise { try { const { query } = req.body; // 유효성 검증 if (!query || typeof query !== "string" || query.trim().length === 0) { res.status(400).json({ success: false, message: "쿼리가 필요합니다.", }); return; } // SQL 인젝션 방지를 위한 기본적인 검증 const trimmedQuery = query.trim().toLowerCase(); const allowedCommands = ["insert", "update", "delete"]; const isAllowed = allowedCommands.some((cmd) => trimmedQuery.startsWith(cmd) ); if (!isAllowed) { res.status(400).json({ success: false, message: "INSERT, UPDATE, DELETE 쿼리만 허용됩니다.", }); return; } // 위험한 명령어 차단 const dangerousPatterns = [ /drop\s+table/i, /drop\s+database/i, /truncate/i, /alter\s+table/i, /create\s+table/i, ]; if (dangerousPatterns.some((pattern) => pattern.test(query))) { res.status(403).json({ success: false, message: "허용되지 않는 쿼리입니다.", }); return; } // 쿼리 실행 const result = await PostgreSQLService.query(query.trim()); res.status(200).json({ success: true, data: { rowCount: result.rowCount || 0, command: result.command, }, message: "쿼리가 성공적으로 실행되었습니다.", }); } catch (error) { console.error("DML execution error:", error); res.status(500).json({ success: false, message: "쿼리 실행 중 오류가 발생했습니다.", error: process.env.NODE_ENV === "development" ? (error as Error).message : "쿼리 실행 오류", }); } } /** * 외부 API 프록시 (CORS 우회용) * POST /api/dashboards/fetch-external-api */ async fetchExternalApi( req: AuthenticatedRequest, res: Response ): Promise { try { const { url, method = "GET", headers = {}, queryParams = {}, body, externalConnectionId, // 프론트엔드에서 선택된 커넥션 ID를 전달받아야 함 } = req.body; if (!url || typeof url !== "string") { res.status(400).json({ success: false, message: "URL이 필요합니다.", }); return; } // 쿼리 파라미터 추가 const urlObj = new URL(url); Object.entries(queryParams).forEach(([key, value]) => { if (key && value) { urlObj.searchParams.append(key, String(value)); } }); // Axios 요청 설정 const requestConfig: AxiosRequestConfig = { url: urlObj.toString(), method: method.toUpperCase(), headers: { "Content-Type": "application/json", Accept: "application/json", ...headers, }, timeout: 60000, // 60초 타임아웃 validateStatus: () => true, // 모든 상태 코드 허용 (에러도 응답으로 처리) }; // 연결 정보 (응답에 포함용) let connectionInfo: { saveToHistory?: boolean } | null = null; // 외부 커넥션 ID가 있는 경우, 해당 커넥션의 인증 정보(DB 토큰 등)를 적용 if (externalConnectionId) { try { // 사용자 회사 코드가 있으면 사용하고, 없으면 '*' (최고 관리자)로 시도 let companyCode = req.user?.companyCode; if (!companyCode) { companyCode = "*"; } // 커넥션 로드 const connectionResult = await ExternalRestApiConnectionService.getConnectionById( Number(externalConnectionId), companyCode ); if (connectionResult.success && connectionResult.data) { const connection = connectionResult.data; // 연결 정보 저장 (응답에 포함) connectionInfo = { saveToHistory: connection.save_to_history === "Y", }; // 인증 헤더 생성 (DB 토큰 등) const authHeaders = await ExternalRestApiConnectionService.getAuthHeaders( connection.auth_type, connection.auth_config, connection.company_code ); // 기존 헤더에 인증 헤더 병합 requestConfig.headers = { ...requestConfig.headers, ...authHeaders, }; // API Key가 Query Param인 경우 처리 if ( connection.auth_type === "api-key" && connection.auth_config?.keyLocation === "query" && connection.auth_config?.keyName && connection.auth_config?.keyValue ) { const currentUrl = new URL(requestConfig.url!); currentUrl.searchParams.append( connection.auth_config.keyName, connection.auth_config.keyValue ); requestConfig.url = currentUrl.toString(); } } } catch (connError) { logger.error( `외부 커넥션(${externalConnectionId}) 정보 로드 및 인증 적용 실패:`, connError ); } } // Body 처리 if (body) { requestConfig.data = body; } // TLS 인증서 검증 예외 처리 (thiratis.com 등 내부망/레거시 API 대응) // ExternalRestApiConnectionService와 동일한 로직 적용 const bypassDomains = ["thiratis.com"]; const hostname = urlObj.hostname; const shouldBypassTls = bypassDomains.some((domain) => hostname.includes(domain) ); if (shouldBypassTls) { requestConfig.httpsAgent = new https.Agent({ rejectUnauthorized: false, }); } // 기상청 API 등 EUC-KR 인코딩을 사용하는 경우 arraybuffer로 받아서 디코딩 const isKmaApi = urlObj.hostname.includes("kma.go.kr"); if (isKmaApi) { requestConfig.responseType = "arraybuffer"; } const response = await axios(requestConfig); if (response.status >= 400) { throw new Error( `외부 API 오류: ${response.status} ${response.statusText}` ); } let data = response.data; const contentType = response.headers["content-type"]; // 기상청 API 인코딩 처리 (UTF-8 우선, 실패 시 EUC-KR) if (isKmaApi && Buffer.isBuffer(data)) { const iconv = require("iconv-lite"); const buffer = Buffer.from(data); const utf8Text = buffer.toString("utf-8"); // UTF-8로 정상 디코딩되었는지 확인 if ( utf8Text.includes("특보") || utf8Text.includes("경보") || utf8Text.includes("주의보") || (utf8Text.includes("#START7777") && !utf8Text.includes("�")) ) { data = { text: utf8Text, contentType, encoding: "utf-8" }; } else { // EUC-KR로 디코딩 const eucKrText = iconv.decode(buffer, "EUC-KR"); data = { text: eucKrText, contentType, encoding: "euc-kr" }; } } // 텍스트 응답인 경우 포맷팅 else if (typeof data === "string") { data = { text: data, contentType }; } res.status(200).json({ success: true, data, connectionInfo, // 외부 연결 정보 (saveToHistory 등) }); } catch (error: any) { const status = error.response?.status || 500; const message = error.response?.statusText || error.message; logger.error("외부 API 호출 오류:", { message, status, data: error.response?.data, }); res.status(500).json({ success: false, message: "외부 API 호출 중 오류가 발생했습니다.", error: process.env.NODE_ENV === "development" ? message : "외부 API 호출 오류", }); } } /** * 테이블 스키마 조회 (날짜 컬럼 감지용) * POST /api/dashboards/table-schema */ async getTableSchema( req: AuthenticatedRequest, res: Response ): Promise { try { const { tableName } = req.body; if (!tableName || typeof tableName !== "string") { res.status(400).json({ success: false, message: "테이블명이 필요합니다.", }); return; } // 테이블명 검증 (SQL 인젝션 방지) if (!/^[a-z_][a-z0-9_]*$/i.test(tableName)) { res.status(400).json({ success: false, message: "유효하지 않은 테이블명입니다.", }); return; } // PostgreSQL information_schema에서 컬럼 정보 조회 const query = ` SELECT column_name, data_type, udt_name FROM information_schema.columns WHERE table_name = $1 ORDER BY ordinal_position `; const result = await PostgreSQLService.query(query, [ tableName.toLowerCase(), ]); // 날짜/시간 타입 컬럼 필터링 const dateColumns = result.rows .filter((row: any) => { const dataType = row.data_type?.toLowerCase(); const udtName = row.udt_name?.toLowerCase(); return ( dataType === "timestamp" || dataType === "timestamp without time zone" || dataType === "timestamp with time zone" || dataType === "date" || dataType === "time" || dataType === "time without time zone" || dataType === "time with time zone" || udtName === "timestamp" || udtName === "timestamptz" || udtName === "date" || udtName === "time" || udtName === "timetz" ); }) .map((row: any) => row.column_name); res.status(200).json({ success: true, data: { tableName, columns: result.rows.map((row: any) => ({ name: row.column_name, type: row.data_type, udtName: row.udt_name, })), dateColumns, }, }); } catch (error) { res.status(500).json({ success: false, message: "테이블 스키마 조회 중 오류가 발생했습니다.", error: process.env.NODE_ENV === "development" ? (error as Error).message : "스키마 조회 오류", }); } } }