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 ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) `, [ dashboardId, data.title, data.description || null, data.isPublic || false, userId, now, now, JSON.stringify(data.tags || []), data.category || null, 0 ]); // 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, content, data_source_config, chart_config, display_order, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) `, [ elementId, dashboardId, element.type, element.subtype, element.position.x, element.position.y, element.size.width, element.size.height, element.title, element.content || null, JSON.stringify(element.dataSource || {}), JSON.stringify(element.chartConfig || {}), 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, content: row.content, dataSource: JSON.parse(row.data_source_config || '{}'), chartConfig: JSON.parse(row.chart_config || '{}') })); 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'), 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++; } 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, content, data_source_config, chart_config, display_order, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) `, [ elementId, dashboardId, element.type, element.subtype, element.position.x, element.position.y, element.size.width, element.size.height, element.title, element.content || null, JSON.stringify(element.dataSource || {}), JSON.stringify(element.chartConfig || {}), 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; } } }