import { v4 as uuidv4 } from "uuid"; import { PostgreSQLService } from "../database/PostgreSQLService"; import { Dashboard, DashboardElement, CreateDashboardRequest, UpdateDashboardRequest, DashboardListQuery, } from "../types/dashboard"; /** * 대시보드 서비스 - Raw Query 방식 * PostgreSQL 직접 연결을 통한 CRUD 작업 */ export class DashboardService { /** * 대시보드 생성 */ static async createDashboard( data: CreateDashboardRequest, userId: string ): Promise { const dashboardId = uuidv4(); const now = new Date(); try { // 트랜잭션으로 대시보드와 요소들을 함께 생성 const result = await PostgreSQLService.transaction(async (client) => { // 1. 대시보드 메인 정보 저장 await client.query( ` INSERT INTO dashboards ( id, title, description, is_public, created_by, created_at, updated_at, tags, category, view_count, settings ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) `, [ dashboardId, data.title, data.description || null, data.isPublic || false, userId, now, now, JSON.stringify(data.tags || []), data.category || null, 0, JSON.stringify(data.settings || {}), ] ); // 2. 대시보드 요소들 저장 if (data.elements && data.elements.length > 0) { for (let i = 0; i < data.elements.length; i++) { const element = data.elements[i]; const elementId = uuidv4(); // 항상 새로운 UUID 생성 await client.query( ` INSERT INTO dashboard_elements ( id, dashboard_id, element_type, element_subtype, position_x, position_y, width, height, title, custom_title, show_header, content, data_source_config, chart_config, list_config, yard_config, display_order, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) `, [ elementId, dashboardId, element.type, element.subtype, element.position.x, element.position.y, element.size.width, element.size.height, element.title, element.customTitle || null, element.showHeader !== false, // 기본값 true element.content || null, JSON.stringify(element.dataSource || {}), JSON.stringify(element.chartConfig || {}), JSON.stringify(element.listConfig || null), JSON.stringify(element.yardConfig || null), i, now, now, ] ); } } return dashboardId; }); // 생성된 대시보드 반환 try { const dashboard = await this.getDashboardById(dashboardId, userId); if (!dashboard) { console.error("대시보드 생성은 성공했으나 조회에 실패:", dashboardId); // 생성은 성공했으므로 기본 정보만이라도 반환 return { id: dashboardId, title: data.title, description: data.description, thumbnailUrl: undefined, isPublic: data.isPublic || false, createdBy: userId, createdAt: now.toISOString(), updatedAt: now.toISOString(), tags: data.tags || [], category: data.category, viewCount: 0, elements: data.elements || [], }; } return dashboard; } catch (fetchError) { console.error("생성된 대시보드 조회 중 오류:", fetchError); // 생성은 성공했으므로 기본 정보 반환 return { id: dashboardId, title: data.title, description: data.description, thumbnailUrl: undefined, isPublic: data.isPublic || false, createdBy: userId, createdAt: now.toISOString(), updatedAt: now.toISOString(), tags: data.tags || [], category: data.category, viewCount: 0, elements: data.elements || [], }; } } catch (error) { console.error("Dashboard creation error:", error); throw error; } } /** * 대시보드 목록 조회 */ static async getDashboards(query: DashboardListQuery, userId?: string) { const { page = 1, limit = 20, search, category, isPublic, createdBy, } = query; const offset = (page - 1) * limit; try { // 기본 WHERE 조건 let whereConditions = ["d.deleted_at IS NULL"]; let params: any[] = []; let paramIndex = 1; // 권한 필터링 if (userId) { whereConditions.push( `(d.created_by = $${paramIndex} OR d.is_public = true)` ); params.push(userId); paramIndex++; } else { whereConditions.push("d.is_public = true"); } // 검색 조건 if (search) { whereConditions.push( `(d.title ILIKE $${paramIndex} OR d.description ILIKE $${paramIndex + 1})` ); params.push(`%${search}%`, `%${search}%`); paramIndex += 2; } // 카테고리 필터 if (category) { whereConditions.push(`d.category = $${paramIndex}`); params.push(category); paramIndex++; } // 공개/비공개 필터 if (typeof isPublic === "boolean") { whereConditions.push(`d.is_public = $${paramIndex}`); params.push(isPublic); paramIndex++; } // 작성자 필터 if (createdBy) { whereConditions.push(`d.created_by = $${paramIndex}`); params.push(createdBy); paramIndex++; } const whereClause = whereConditions.join(" AND "); // 대시보드 목록 조회 (users 테이블 조인 제거) const dashboardQuery = ` SELECT d.id, d.title, d.description, d.thumbnail_url, d.is_public, d.created_by, d.created_at, d.updated_at, d.tags, d.category, d.view_count, COUNT(de.id) as elements_count FROM dashboards d LEFT JOIN dashboard_elements de ON d.id = de.dashboard_id WHERE ${whereClause} GROUP BY d.id, d.title, d.description, d.thumbnail_url, d.is_public, d.created_by, d.created_at, d.updated_at, d.tags, d.category, d.view_count ORDER BY d.updated_at DESC LIMIT $${paramIndex} OFFSET $${paramIndex + 1} `; const dashboardResult = await PostgreSQLService.query(dashboardQuery, [ ...params, limit, offset, ]); // 전체 개수 조회 const countQuery = ` SELECT COUNT(DISTINCT d.id) as total FROM dashboards d WHERE ${whereClause} `; const countResult = await PostgreSQLService.query(countQuery, params); const total = parseInt(countResult.rows[0]?.total || "0"); return { dashboards: dashboardResult.rows.map((row: any) => ({ id: row.id, title: row.title, description: row.description, thumbnailUrl: row.thumbnail_url, isPublic: row.is_public, createdBy: row.created_by, createdAt: row.created_at, updatedAt: row.updated_at, tags: JSON.parse(row.tags || "[]"), category: row.category, viewCount: parseInt(row.view_count || "0"), elementsCount: parseInt(row.elements_count || "0"), })), pagination: { page, limit, total, totalPages: Math.ceil(total / limit), }, }; } catch (error) { console.error("Dashboard list error:", error); throw error; } } /** * 대시보드 상세 조회 */ static async getDashboardById( dashboardId: string, userId?: string ): Promise { try { // 1. 대시보드 기본 정보 조회 (권한 체크 포함) let dashboardQuery: string; let dashboardParams: any[]; if (userId) { dashboardQuery = ` SELECT d.* FROM dashboards d WHERE d.id = $1 AND d.deleted_at IS NULL AND (d.created_by = $2 OR d.is_public = true) `; dashboardParams = [dashboardId, userId]; } else { dashboardQuery = ` SELECT d.* FROM dashboards d WHERE d.id = $1 AND d.deleted_at IS NULL AND d.is_public = true `; dashboardParams = [dashboardId]; } const dashboardResult = await PostgreSQLService.query( dashboardQuery, dashboardParams ); if (dashboardResult.rows.length === 0) { return null; } const dashboard = dashboardResult.rows[0]; // 2. 대시보드 요소들 조회 const elementsQuery = ` SELECT * FROM dashboard_elements WHERE dashboard_id = $1 ORDER BY display_order ASC `; const elementsResult = await PostgreSQLService.query(elementsQuery, [ dashboardId, ]); // 3. 요소 데이터 변환 const elements: DashboardElement[] = elementsResult.rows.map( (row: any) => ({ id: row.id, type: row.element_type, subtype: row.element_subtype, position: { x: row.position_x, y: row.position_y, }, size: { width: row.width, height: row.height, }, title: row.title, customTitle: row.custom_title || undefined, showHeader: row.show_header !== false, // 기본값 true content: row.content, dataSource: JSON.parse(row.data_source_config || "{}"), chartConfig: JSON.parse(row.chart_config || "{}"), listConfig: row.list_config ? typeof row.list_config === "string" ? JSON.parse(row.list_config) : row.list_config : undefined, yardConfig: row.yard_config ? typeof row.yard_config === "string" ? JSON.parse(row.yard_config) : row.yard_config : undefined, }) ); return { id: dashboard.id, title: dashboard.title, description: dashboard.description, thumbnailUrl: dashboard.thumbnail_url, isPublic: dashboard.is_public, createdBy: dashboard.created_by, createdAt: dashboard.created_at, updatedAt: dashboard.updated_at, tags: JSON.parse(dashboard.tags || "[]"), category: dashboard.category, viewCount: parseInt(dashboard.view_count || "0"), settings: dashboard.settings || undefined, elements, }; } catch (error) { console.error("Dashboard get error:", error); throw error; } } /** * 대시보드 업데이트 */ static async updateDashboard( dashboardId: string, data: UpdateDashboardRequest, userId: string ): Promise { try { const result = await PostgreSQLService.transaction(async (client) => { // 권한 체크 const authCheckResult = await client.query( ` SELECT id FROM dashboards WHERE id = $1 AND created_by = $2 AND deleted_at IS NULL `, [dashboardId, userId] ); if (authCheckResult.rows.length === 0) { throw new Error("대시보드를 찾을 수 없거나 수정 권한이 없습니다."); } const now = new Date(); // 1. 대시보드 메인 정보 업데이트 const updateFields: string[] = []; const updateParams: any[] = []; let paramIndex = 1; if (data.title !== undefined) { updateFields.push(`title = $${paramIndex}`); updateParams.push(data.title); paramIndex++; } if (data.description !== undefined) { updateFields.push(`description = $${paramIndex}`); updateParams.push(data.description); paramIndex++; } if (data.isPublic !== undefined) { updateFields.push(`is_public = $${paramIndex}`); updateParams.push(data.isPublic); paramIndex++; } if (data.tags !== undefined) { updateFields.push(`tags = $${paramIndex}`); updateParams.push(JSON.stringify(data.tags)); paramIndex++; } if (data.category !== undefined) { updateFields.push(`category = $${paramIndex}`); updateParams.push(data.category); paramIndex++; } if (data.settings !== undefined) { updateFields.push(`settings = $${paramIndex}`); updateParams.push(JSON.stringify(data.settings)); paramIndex++; } updateFields.push(`updated_at = $${paramIndex}`); updateParams.push(now); paramIndex++; updateParams.push(dashboardId); if (updateFields.length > 1) { // updated_at 외에 다른 필드가 있는 경우 const updateQuery = ` UPDATE dashboards SET ${updateFields.join(", ")} WHERE id = $${paramIndex} `; await client.query(updateQuery, updateParams); } // 2. 요소 업데이트 (있는 경우) if (data.elements) { // 기존 요소들 삭제 await client.query( ` DELETE FROM dashboard_elements WHERE dashboard_id = $1 `, [dashboardId] ); // 새 요소들 추가 for (let i = 0; i < data.elements.length; i++) { const element = data.elements[i]; const elementId = uuidv4(); await client.query( ` INSERT INTO dashboard_elements ( id, dashboard_id, element_type, element_subtype, position_x, position_y, width, height, title, custom_title, show_header, content, data_source_config, chart_config, list_config, yard_config, display_order, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19) `, [ elementId, dashboardId, element.type, element.subtype, element.position.x, element.position.y, element.size.width, element.size.height, element.title, element.customTitle || null, element.showHeader !== false, // 기본값 true element.content || null, JSON.stringify(element.dataSource || {}), JSON.stringify(element.chartConfig || {}), JSON.stringify(element.listConfig || null), JSON.stringify(element.yardConfig || null), i, now, now, ] ); } } return dashboardId; }); // 업데이트된 대시보드 반환 return await this.getDashboardById(dashboardId, userId); } catch (error) { console.error("Dashboard update error:", error); throw error; } } /** * 대시보드 삭제 (소프트 삭제) */ static async deleteDashboard( dashboardId: string, userId: string ): Promise { try { const now = new Date(); const result = await PostgreSQLService.query( ` UPDATE dashboards SET deleted_at = $1, updated_at = $2 WHERE id = $3 AND created_by = $4 AND deleted_at IS NULL `, [now, now, dashboardId, userId] ); return (result.rowCount || 0) > 0; } catch (error) { console.error("Dashboard delete error:", error); throw error; } } /** * 조회수 증가 */ static async incrementViewCount(dashboardId: string): Promise { try { await PostgreSQLService.query( ` UPDATE dashboards SET view_count = view_count + 1 WHERE id = $1 AND deleted_at IS NULL `, [dashboardId] ); } catch (error) { console.error("View count increment error:", error); // 조회수 증가 실패는 치명적이지 않으므로 에러를 던지지 않음 } } /** * 사용자 권한 체크 */ static async checkUserPermission( dashboardId: string, userId: string, requiredPermission: "view" | "edit" | "admin" = "view" ): Promise { try { const result = await PostgreSQLService.query( ` SELECT CASE WHEN d.created_by = $2 THEN 'admin' WHEN d.is_public = true THEN 'view' ELSE 'none' END as permission FROM dashboards d WHERE d.id = $1 AND d.deleted_at IS NULL `, [dashboardId, userId] ); if (result.rows.length === 0) { return false; } const userPermission = result.rows[0].permission; // 권한 레벨 체크 const permissionLevels = { view: 1, edit: 2, admin: 3 }; const userLevel = permissionLevels[userPermission as keyof typeof permissionLevels] || 0; const requiredLevel = permissionLevels[requiredPermission]; return userLevel >= requiredLevel; } catch (error) { console.error("Permission check error:", error); return false; } } }